From d2ea13f2d96c985c6312a1c373e63cef6807b945 Mon Sep 17 00:00:00 2001 From: zackees Date: Sat, 22 Aug 2026 03:26:04 -0700 Subject: [PATCH] refactor(platform): migrate device mechanics to the neutral facade Phase 7 of the platform-boundary refactor (FastLED/fbuild#1313). All native serial/USB/PnP mechanics move out of fbuild-serial into fbuild-core's per-OS platform tree behind the neutral platform::device facade: - platform/windows/device.rs: the SetupAPI serial enumeration fork (#962), USB problem-device and Pico reset-interface enumeration, the WinUsb BOOTSEL reset, and the CfgMgr32 PnP recovery primitives (#1148/#1152) - platform/linux/device.rs: sysfs kernel-driver classification (#895) plus the portable serialport enumeration delegate - platform/macos/device.rs: device-node-naming classification plus the same portable delegate - fbuild-serial keeps the caller-facing policy layer: PortHealth flattening, DetectedPort, the sysfs health enrichment, and the recovery ladder over a PlatformPnpBackend that delegates to the facade (fails closed off Windows exactly as before) - fbuild-serial drops its windows-sys dependency; fbuild-core gains the serialport delegate dep and the Windows Devices/Registry features - ban_direct_serialport allowlist gains the three new facade files (#1313 justification); boundary ledgers regenerated (95 -> 57 rows) No behavior change: all public paths (ports::available_ports, UsbProblemDevice/UsbResetInterface re-exports, port_class shim, usb_recovery entry points) keep their names and signatures. Co-Authored-By: Claude --- Cargo.lock | 2 +- ci/platform_boundary_ledger.tsv | 38 - ci/platform_boundary_research.tsv | 70 +- ci/test_enforce_platform_boundary.py | 2 +- crates/fbuild-core/Cargo.toml | 18 +- crates/fbuild-core/src/platform/device.rs | 270 +++- .../fbuild-core/src/platform/linux/device.rs | 233 +++ crates/fbuild-core/src/platform/linux/mod.rs | 2 + .../fbuild-core/src/platform/linux/usb_pnp.rs | 49 + .../fbuild-core/src/platform/macos/device.rs | 128 ++ crates/fbuild-core/src/platform/macos/mod.rs | 2 + .../fbuild-core/src/platform/macos/usb_pnp.rs | 49 + .../src/platform/windows/device.rs | 683 +++++++++ .../fbuild-core/src/platform/windows/mod.rs | 2 + .../src/platform/windows/usb_pnp.rs | 722 +++++++++ crates/fbuild-serial/Cargo.toml | 15 - crates/fbuild-serial/src/port_class.rs | 499 +----- crates/fbuild-serial/src/ports.rs | 1333 ++--------------- crates/fbuild-serial/src/sysfs_usb.rs | 22 +- crates/fbuild-serial/src/usb_recovery.rs | 336 +---- .../ban_direct_serialport/src/allowlist.txt | 9 + .../src/baseline.txt | 38 - 22 files changed, 2351 insertions(+), 2171 deletions(-) create mode 100644 crates/fbuild-core/src/platform/linux/device.rs create mode 100644 crates/fbuild-core/src/platform/linux/usb_pnp.rs create mode 100644 crates/fbuild-core/src/platform/macos/device.rs create mode 100644 crates/fbuild-core/src/platform/macos/usb_pnp.rs create mode 100644 crates/fbuild-core/src/platform/windows/device.rs create mode 100644 crates/fbuild-core/src/platform/windows/usb_pnp.rs diff --git a/Cargo.lock b/Cargo.lock index be11cdc5..875f5e5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1197,6 +1197,7 @@ dependencies = [ "same-file", "serde", "serde_json", + "serialport", "sha2", "socket2", "tar", @@ -1452,7 +1453,6 @@ dependencies = [ "tracing", "tracing-test", "uuid", - "windows-sys 0.52.0", ] [[package]] diff --git a/ci/platform_boundary_ledger.tsv b/ci/platform_boundary_ledger.tsv index 50066b0c..16f16568 100644 --- a/ci/platform_boundary_ledger.tsv +++ b/ci/platform_boundary_ledger.tsv @@ -45,47 +45,9 @@ crates/fbuild-deploy/src/rp2040_topology.rs attr_cfg #[cfg(windows)] 1 device ho crates/fbuild-deploy/src/rp2040_topology.rs native_path std::os::windows::ffi::OsStrExt 0 device host_mechanic crates/fbuild-library/src/library/library_spec.rs attr_cfg #[cfg(windows)] 0 host host_mechanic crates/fbuild-paths/src/dev_daemon_namespace.rs native_path std::env::current_exe 0 host_executable host_mechanic -crates/fbuild-serial/Cargo.toml native_dependency windows-sys 0 device host_mechanic -crates/fbuild-serial/Cargo.toml target_dependency_table [target.'cfg(windows)'.dependencies] 0 device host_mechanic -crates/fbuild-serial/src/port_class.rs attr_cfg #[cfg(not(any(target_os=,target_os=,target_os=)))] 0 device host_mechanic -crates/fbuild-serial/src/port_class.rs attr_cfg #[cfg(target_os=)] 0 device host_mechanic -crates/fbuild-serial/src/port_class.rs attr_cfg #[cfg(target_os=)] 1 device host_mechanic -crates/fbuild-serial/src/port_class.rs attr_cfg #[cfg(target_os=)] 2 device host_mechanic -crates/fbuild-serial/src/port_class.rs attr_cfg #[cfg(target_os=)] 3 device host_mechanic -crates/fbuild-serial/src/port_class.rs attr_cfg #[cfg(target_os=)] 4 device host_mechanic -crates/fbuild-serial/src/port_class.rs attr_cfg #[cfg(target_os=)] 5 device host_mechanic -crates/fbuild-serial/src/port_class.rs attr_cfg #[cfg(target_os=)] 6 device host_mechanic -crates/fbuild-serial/src/port_class.rs attr_cfg #[cfg(target_os=)] 7 device host_mechanic -crates/fbuild-serial/src/ports.rs attr_cfg #[cfg(any(windows,test))] 0 device host_mechanic -crates/fbuild-serial/src/ports.rs attr_cfg #[cfg(not(windows))] 0 device host_mechanic -crates/fbuild-serial/src/ports.rs attr_cfg #[cfg(not(windows))] 1 device host_mechanic -crates/fbuild-serial/src/ports.rs attr_cfg #[cfg(not(windows))] 2 device host_mechanic -crates/fbuild-serial/src/ports.rs attr_cfg #[cfg(not(windows))] 3 device host_mechanic crates/fbuild-serial/src/ports.rs attr_cfg #[cfg(target_os=)] 0 device host_mechanic crates/fbuild-serial/src/ports.rs attr_cfg #[cfg(target_os=)] 1 device host_mechanic crates/fbuild-serial/src/ports.rs attr_cfg #[cfg(target_os=)] 2 device host_mechanic -crates/fbuild-serial/src/ports.rs attr_cfg #[cfg(windows)] 0 device host_mechanic -crates/fbuild-serial/src/ports.rs attr_cfg #[cfg(windows)] 1 device host_mechanic -crates/fbuild-serial/src/ports.rs attr_cfg #[cfg(windows)] 2 device host_mechanic -crates/fbuild-serial/src/ports.rs attr_cfg #[cfg(windows)] 3 device host_mechanic -crates/fbuild-serial/src/ports.rs attr_cfg #[cfg(windows)] 4 device host_mechanic -crates/fbuild-serial/src/ports.rs attr_cfg #[cfg_attr(not(windows),allow(dead_code))] 0 device host_mechanic -crates/fbuild-serial/src/ports.rs attr_cfg #[cfg_attr(not(windows),allow(dead_code))] 1 device host_mechanic -crates/fbuild-serial/src/ports.rs attr_cfg #[cfg_attr(not(windows),allow(dead_code))] 2 device host_mechanic -crates/fbuild-serial/src/ports.rs native_path windows_sys:: 0 device host_mechanic -crates/fbuild-serial/src/ports.rs native_path windows_sys:: 1 device host_mechanic -crates/fbuild-serial/src/ports.rs native_path windows_sys:: 2 device host_mechanic -crates/fbuild-serial/src/ports.rs native_path windows_sys:: 3 device host_mechanic -crates/fbuild-serial/src/ports.rs native_path windows_sys:: 4 device host_mechanic -crates/fbuild-serial/src/ports.rs native_path windows_sys:: 5 device host_mechanic -crates/fbuild-serial/src/ports.rs native_path windows_sys:: 6 device host_mechanic -crates/fbuild-serial/src/sysfs_usb.rs attr_cfg #[cfg(target_os=)] 0 device host_mechanic -crates/fbuild-serial/src/usb_recovery.rs attr_cfg #[cfg(not(windows))] 0 device host_mechanic -crates/fbuild-serial/src/usb_recovery.rs attr_cfg #[cfg(windows)] 0 device host_mechanic -crates/fbuild-serial/src/usb_recovery.rs attr_cfg #[cfg(windows)] 1 device host_mechanic -crates/fbuild-serial/src/usb_recovery.rs native_path windows:: 0 device host_mechanic -crates/fbuild-serial/src/usb_recovery.rs native_path windows_sys:: 0 device host_mechanic -crates/fbuild-serial/src/usb_recovery.rs native_path windows_sys:: 1 device host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(not(target_os=))] 0 host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(not(windows))] 0 host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(not(windows))] 1 host_executable host_mechanic diff --git a/ci/platform_boundary_research.tsv b/ci/platform_boundary_research.tsv index 00b88b0a..a0fed0ea 100644 --- a/ci/platform_boundary_research.tsv +++ b/ci/platform_boundary_research.tsv @@ -21,10 +21,10 @@ crates/fbuild-config/src/bin/enrich_boards.rs 74 attr_cfg #[cfg(windows)] host h crates/fbuild-config/src/bin/enrich_boards.rs 78 attr_cfg #[cfg(not(windows))] host host_mechanic crates/fbuild-core/Cargo.toml 47 native_dependency interprocess ipc host_mechanic crates/fbuild-core/Cargo.toml 48 native_dependency socket2 ipc host_mechanic -crates/fbuild-core/Cargo.toml 50 target_dependency_table [target.'cfg(unix)'.dependencies] fs host_mechanic -crates/fbuild-core/Cargo.toml 52 native_dependency libc fs host_mechanic -crates/fbuild-core/Cargo.toml 54 target_dependency_table [target.'cfg(windows)'.dependencies] fs host_mechanic -crates/fbuild-core/Cargo.toml 57 native_dependency windows-sys fs host_mechanic +crates/fbuild-core/Cargo.toml 54 target_dependency_table [target.'cfg(unix)'.dependencies] fs host_mechanic +crates/fbuild-core/Cargo.toml 56 native_dependency libc fs host_mechanic +crates/fbuild-core/Cargo.toml 58 target_dependency_table [target.'cfg(windows)'.dependencies] fs host_mechanic +crates/fbuild-core/Cargo.toml 64 native_dependency windows-sys fs host_mechanic crates/fbuild-core/src/platform/executable.rs 56 native_path std::env::current_exe host_executable host_mechanic crates/fbuild-core/src/platform/linux/fs.rs 2 native_path std::os::unix::fs::PermissionsExt fs host_mechanic crates/fbuild-core/src/platform/linux/fs.rs 40 native_path std::os::unix::fs::symlink fs host_mechanic @@ -37,7 +37,7 @@ crates/fbuild-core/src/platform/linux/ipc.rs 2 native_path interprocess::local_s crates/fbuild-core/src/platform/linux/ipc.rs 3 native_path interprocess::os::unix ipc host_mechanic crates/fbuild-core/src/platform/linux/ipc.rs 4 native_path socket2:: ipc host_mechanic crates/fbuild-core/src/platform/linux/ipc.rs 66 native_path std::os::unix::fs::PermissionsExt ipc host_mechanic -crates/fbuild-core/src/platform/linux/mod.rs 10 compile_host_fact std::env::consts::ARCH host host_mechanic +crates/fbuild-core/src/platform/linux/mod.rs 12 compile_host_fact std::env::consts::ARCH host host_mechanic crates/fbuild-core/src/platform/linux/process.rs 1 native_path std::os::unix::process::ExitStatusExt process host_mechanic crates/fbuild-core/src/platform/macos/fs.rs 2 native_path std::os::unix::fs::PermissionsExt fs host_mechanic crates/fbuild-core/src/platform/macos/fs.rs 40 native_path std::os::unix::fs::symlink fs host_mechanic @@ -50,8 +50,13 @@ crates/fbuild-core/src/platform/macos/ipc.rs 2 native_path interprocess::local_s crates/fbuild-core/src/platform/macos/ipc.rs 3 native_path socket2:: ipc host_mechanic crates/fbuild-core/src/platform/macos/ipc.rs 19 native_path std::os::unix::fs::PermissionsExt ipc host_mechanic crates/fbuild-core/src/platform/macos/ipc.rs 72 native_path std::os::unix::fs::PermissionsExt ipc host_mechanic -crates/fbuild-core/src/platform/macos/mod.rs 10 compile_host_fact std::env::consts::ARCH host host_mechanic +crates/fbuild-core/src/platform/macos/mod.rs 12 compile_host_fact std::env::consts::ARCH host host_mechanic crates/fbuild-core/src/platform/macos/process.rs 1 native_path std::os::unix::process::ExitStatusExt process host_mechanic +crates/fbuild-core/src/platform/windows/device.rs 18 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/device.rs 26 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/device.rs 29 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/device.rs 30 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/device.rs 34 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/fs.rs 2 native_path std::os::windows::ffi::OsStrExt process host_mechanic crates/fbuild-core/src/platform/windows/fs.rs 3 native_path std::os::windows::fs fs host_mechanic crates/fbuild-core/src/platform/windows/fs.rs 4 native_path std::os::windows::io::AsRawHandle process host_mechanic @@ -65,8 +70,15 @@ crates/fbuild-core/src/platform/windows/ipc.rs 3 native_path interprocess::os::w crates/fbuild-core/src/platform/windows/ipc.rs 4 native_path interprocess::os::windows ipc host_mechanic crates/fbuild-core/src/platform/windows/ipc.rs 5 native_path socket2:: ipc host_mechanic crates/fbuild-core/src/platform/windows/ipc.rs 6 native_path std::os::windows::io::AsRawSocket ipc host_mechanic -crates/fbuild-core/src/platform/windows/mod.rs 10 compile_host_fact std::env::consts::ARCH host host_mechanic +crates/fbuild-core/src/platform/windows/mod.rs 12 compile_host_fact std::env::consts::ARCH host host_mechanic crates/fbuild-core/src/platform/windows/process.rs 1 native_path std::os::windows::io::AsHandle process host_mechanic +crates/fbuild-core/src/platform/windows/usb_pnp.rs 13 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/usb_pnp.rs 24 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/usb_pnp.rs 28 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/usb_pnp.rs 32 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/usb_pnp.rs 35 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/usb_pnp.rs 39 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/usb_pnp.rs 40 native_path windows_sys:: process host_mechanic crates/fbuild-daemon/src/handlers/emulator/tests_npm_cache.rs 146 attr_cfg #[cfg(windows)] host_executable host_artifact_policy crates/fbuild-daemon/src/handlers/emulator/tests_process.rs 9 attr_cfg #[cfg(windows)] host_executable host_artifact_policy crates/fbuild-daemon/src/handlers/emulator/tests_process.rs 21 attr_cfg #[cfg(not(windows))] host_executable host_artifact_policy @@ -93,47 +105,9 @@ crates/fbuild-deploy/src/rp2040_topology.rs 123 native_path std::os::windows::ff crates/fbuild-deploy/src/rp2040_topology.rs 506 attr_cfg #[cfg(not(windows))] device host_mechanic crates/fbuild-library/src/library/library_spec.rs 266 attr_cfg #[cfg(windows)] host host_mechanic crates/fbuild-paths/src/dev_daemon_namespace.rs 83 native_path std::env::current_exe host_executable host_mechanic -crates/fbuild-serial/Cargo.toml 32 target_dependency_table [target.'cfg(windows)'.dependencies] device host_mechanic -crates/fbuild-serial/Cargo.toml 33 native_dependency windows-sys device host_mechanic -crates/fbuild-serial/src/port_class.rs 105 attr_cfg #[cfg(target_os=)] device host_mechanic -crates/fbuild-serial/src/port_class.rs 109 attr_cfg #[cfg(target_os=)] device host_mechanic -crates/fbuild-serial/src/port_class.rs 113 attr_cfg #[cfg(target_os=)] device host_mechanic -crates/fbuild-serial/src/port_class.rs 122 attr_cfg #[cfg(not(any(target_os=,target_os=,target_os=)))] device host_mechanic -crates/fbuild-serial/src/port_class.rs 129 attr_cfg #[cfg(target_os=)] device host_mechanic -crates/fbuild-serial/src/port_class.rs 224 attr_cfg #[cfg(target_os=)] device host_mechanic -crates/fbuild-serial/src/port_class.rs 281 attr_cfg #[cfg(target_os=)] device host_mechanic -crates/fbuild-serial/src/port_class.rs 419 attr_cfg #[cfg(target_os=)] device host_mechanic -crates/fbuild-serial/src/port_class.rs 495 attr_cfg #[cfg(target_os=)] device host_mechanic -crates/fbuild-serial/src/ports.rs 129 attr_cfg #[cfg_attr(not(windows),allow(dead_code))] device host_mechanic -crates/fbuild-serial/src/ports.rs 137 attr_cfg #[cfg_attr(not(windows),allow(dead_code))] device host_mechanic -crates/fbuild-serial/src/ports.rs 159 attr_cfg #[cfg_attr(not(windows),allow(dead_code))] device host_mechanic -crates/fbuild-serial/src/ports.rs 182 attr_cfg #[cfg(windows)] device host_mechanic -crates/fbuild-serial/src/ports.rs 186 attr_cfg #[cfg(not(windows))] device host_mechanic -crates/fbuild-serial/src/ports.rs 195 attr_cfg #[cfg(target_os=)] device host_mechanic -crates/fbuild-serial/src/ports.rs 208 attr_cfg #[cfg(target_os=)] device host_mechanic -crates/fbuild-serial/src/ports.rs 270 attr_cfg #[cfg(any(windows,test))] device host_mechanic -crates/fbuild-serial/src/ports.rs 281 attr_cfg #[cfg(windows)] device host_mechanic -crates/fbuild-serial/src/ports.rs 285 attr_cfg #[cfg(not(windows))] device host_mechanic -crates/fbuild-serial/src/ports.rs 299 attr_cfg #[cfg(windows)] device host_mechanic -crates/fbuild-serial/src/ports.rs 303 attr_cfg #[cfg(not(windows))] device host_mechanic -crates/fbuild-serial/src/ports.rs 326 attr_cfg #[cfg(windows)] device host_mechanic -crates/fbuild-serial/src/ports.rs 330 attr_cfg #[cfg(not(windows))] device host_mechanic -crates/fbuild-serial/src/ports.rs 341 attr_cfg #[cfg(target_os=)] device host_mechanic -crates/fbuild-serial/src/ports.rs 471 attr_cfg #[cfg(windows)] device host_mechanic -crates/fbuild-serial/src/ports.rs 482 native_path windows_sys:: device host_mechanic -crates/fbuild-serial/src/ports.rs 492 native_path windows_sys:: device host_mechanic -crates/fbuild-serial/src/ports.rs 495 native_path windows_sys:: device host_mechanic -crates/fbuild-serial/src/ports.rs 499 native_path windows_sys:: device host_mechanic -crates/fbuild-serial/src/ports.rs 502 native_path windows_sys:: device host_mechanic -crates/fbuild-serial/src/ports.rs 507 native_path windows_sys:: device host_mechanic -crates/fbuild-serial/src/ports.rs 511 native_path windows_sys:: device host_mechanic -crates/fbuild-serial/src/sysfs_usb.rs 461 attr_cfg #[cfg(target_os=)] device host_mechanic -crates/fbuild-serial/src/usb_recovery.rs 258 attr_cfg #[cfg(windows)] device host_mechanic -crates/fbuild-serial/src/usb_recovery.rs 260 native_path windows:: device host_mechanic -crates/fbuild-serial/src/usb_recovery.rs 263 attr_cfg #[cfg(not(windows))] device host_mechanic -crates/fbuild-serial/src/usb_recovery.rs 278 attr_cfg #[cfg(windows)] device host_mechanic -crates/fbuild-serial/src/usb_recovery.rs 282 native_path windows_sys:: device host_mechanic -crates/fbuild-serial/src/usb_recovery.rs 288 native_path windows_sys:: device host_mechanic +crates/fbuild-serial/src/ports.rs 222 attr_cfg #[cfg(target_os=)] device host_mechanic +crates/fbuild-serial/src/ports.rs 234 attr_cfg #[cfg(target_os=)] device host_mechanic +crates/fbuild-serial/src/ports.rs 291 attr_cfg #[cfg(target_os=)] device host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 220 attr_cfg #[cfg(not(target_os=))] host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 226 attr_cfg #[cfg(target_os=)] host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 576 attr_cfg #[cfg(windows)] host_executable host_mechanic diff --git a/ci/test_enforce_platform_boundary.py b/ci/test_enforce_platform_boundary.py index 95573d9d..8f5c3e0b 100644 --- a/ci/test_enforce_platform_boundary.py +++ b/ci/test_enforce_platform_boundary.py @@ -14,7 +14,7 @@ def setUpClass(cls) -> None: cls.observed = boundary.rows_from_findings(boundary.research.inventory()) def test_committed_exact_occurrence_ledger_matches_whole_tree(self) -> None: - self.assertEqual(len(self.expected), 95) + self.assertEqual(len(self.expected), 57) self.assertFalse(boundary.validate_ledger(self.expected)) self.assertFalse(boundary.compare(self.expected, self.observed)) diff --git a/crates/fbuild-core/Cargo.toml b/crates/fbuild-core/Cargo.toml index 2b440c32..4aad0dea 100644 --- a/crates/fbuild-core/Cargo.toml +++ b/crates/fbuild-core/Cargo.toml @@ -46,6 +46,10 @@ same-file = { workspace = true } # to the selected `platform::ipc` implementations. interprocess = { workspace = true } socket2 = { workspace = true } +# Portable serial-port enumeration delegate for `platform::device` on Unix +# hosts, plus the `SerialPortInfo` shape the neutral facts map from. The +# Windows SetupAPI fork does not use it. FastLED/fbuild#1313. +serialport = { workspace = true } [target.'cfg(unix)'.dependencies] # Selected Unix filesystem mechanics query the exact mount containing a path. @@ -54,7 +58,19 @@ libc = { workspace = true } [target.'cfg(windows)'.dependencies] # Selected Windows filesystem mechanics: removable-volume facts and retiring # a kernel-blocked output handle after its watchdog expires. -windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO"] } +# The Devices_* / Registry / Security features serve `platform::device`: +# the SetupAPI serial enumeration fork, CfgMgr32 PnP recovery primitives, +# and the WinUsb BOOTSEL reset (FastLED/fbuild#962, #1152, #1313). +windows-sys = { version = "0.52", features = [ + "Win32_Devices_DeviceAndDriverInstallation", + "Win32_Devices_Properties", + "Win32_Devices_Usb", + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Registry", +] } # Selected Windows IPC creates a protected owner-only pipe descriptor. widestring = { workspace = true } diff --git a/crates/fbuild-core/src/platform/device.rs b/crates/fbuild-core/src/platform/device.rs index 7b6b777a..70e99f0a 100644 --- a/crates/fbuild-core/src/platform/device.rs +++ b/crates/fbuild-core/src/platform/device.rs @@ -1 +1,269 @@ -//! Neutral serial, USB, removable-volume, and topology APIs. +//! Neutral serial-port, USB PnP, and USB-recovery mechanics. +//! +//! Every native enumeration surface lives behind this module: the Windows +//! SetupAPI/CfgMgr32/WinUsb fork (`selected::device` for enumeration and +//! `selected::usb_pnp` for PnP/recovery) and the portable `serialport` +//! delegate on Unix hosts. Callers receive host-neutral facts +//! ([`SerialPortFacts`]) plus the raw [`DevNodeObservation`] each host can +//! honestly provide, and keep all selection policy (which unhealthy endpoints +//! are deployable, which are diagnostics-only) on their side of the seam. + +use std::time::Duration; + +/// Raw host observation of a serial devnode, before any policy flattening. +/// +/// Mirrors what `CM_Get_DevNode_Status` can say about a devnode. Hosts +/// without an equivalent signal report [`DevNodeObservation::Unknown`] and +/// callers keep their cross-platform default behavior. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DevNodeObservation { + /// The devnode is in the live tree; the host reported its status word and + /// problem code (`0` means healthy). + Present { status: u32, problem_code: u32 }, + /// The devnode is retained by host history but is not in the live tree. + Phantom, + /// The host cannot provide equivalent data. + Unknown, +} + +/// Kernel-driver classification of a serial devnode (FastLED/fbuild#895). +/// +/// Returned only when the host can confidently classify; ambiguous cases +/// yield `None` so callers fall back to their existing defaults. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum KernelDriverClass { + /// The kernel created this port via its CDC-ACM stack (Linux + /// `cdc_acm.ko`, macOS IOUSBHostFamily CDC, Windows `usbser` once + /// implemented). + CdcAcm, + /// The kernel created this port via a chip-specific USB-serial bridge + /// driver (Linux `ftdi_sio`/`cp210x`/`ch341`/..., macOS vendor drivers, + /// Windows `FTDIBUS`/`silabser`/... once implemented). + UsbSerialBridge, +} + +/// USB identity facts for a serial endpoint. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UsbSerialIdentityFacts { + pub vid: u16, + pub pid: u16, + pub serial_number: Option, + pub manufacturer: Option, + pub product: Option, + /// Composite-function index (`MI_xx`) when the endpoint is one function + /// of a composite device; used to disambiguate e.g. Teensy Serial vs MIDI. + pub interface: Option, +} + +/// Host-neutral view of one serial endpoint's kind. Only shapes some host +/// actually reports today; PCI/Bluetooth endpoints carry no identity facts +/// in either backend and land in [`SerialPortTypeFacts::Unknown`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SerialPortTypeFacts { + Usb(UsbSerialIdentityFacts), + Unknown, +} + +/// A serial endpoint plus the raw host facts needed to decide whether it is +/// safe to select. This is the neutral counterpart of the caller-facing +/// record; health *policy* (flattening [`DevNodeObservation`] into a +/// deployable/not-deployable verdict) stays with the caller. +#[derive(Clone, Debug)] +pub struct SerialPortFacts { + pub port_name: String, + pub port_type: SerialPortTypeFacts, + pub observation: DevNodeObservation, + /// Canonical Plug and Play device instance ID when the host exposes one. + pub instance_id: Option, + /// Immediate parent device instance ID when the host exposes one. + pub parent_instance_id: Option, + /// Full USB ancestor chain, nearest first, when the host exposes one. + pub ancestor_instance_ids: Vec, + /// Physical USB location paths. Empty when unavailable. These are + /// identity history only and never make a phantom endpoint selectable. + pub location_paths: Vec, +} + +/// Map a portable `serialport` enumeration entry onto neutral facts. +/// +/// Used by the Unix delegates, which have no richer source than the +/// portable library; `observation` starts at [`DevNodeObservation::Unknown`] +/// and enrichment happens above the seam. Public so callers holding a +/// `SerialPortInfo` obtained elsewhere can use the same mapping. +pub fn facts_from_port_info(info: serialport::SerialPortInfo) -> SerialPortFacts { + let port_type = match info.port_type { + serialport::SerialPortType::UsbPort(usb) => { + SerialPortTypeFacts::Usb(UsbSerialIdentityFacts { + vid: usb.vid, + pid: usb.pid, + serial_number: usb.serial_number, + manufacturer: usb.manufacturer, + product: usb.product, + interface: usb.interface, + }) + } + // PCI/Bluetooth are unit variants upstream — no facts to carry. + serialport::SerialPortType::PciPort + | serialport::SerialPortType::BluetoothPort + | serialport::SerialPortType::Unknown => SerialPortTypeFacts::Unknown, + }; + SerialPortFacts { + port_name: info.port_name, + port_type, + observation: DevNodeObservation::Unknown, + instance_id: None, + parent_instance_id: None, + ancestor_instance_ids: Vec::new(), + location_paths: Vec::new(), + } +} + +/// Enumerate every serial port currently visible to the OS, including +/// endpoints whose devnode reports a non-OK problem status where the host +/// supports that (Windows; FastLED/fbuild#962). +pub fn available_serial_ports() -> std::io::Result> { + super::selected::device::available_serial_ports() +} + +/// Detect which kernel driver class instantiated a serial devnode. +/// +/// Returns `None` when the host cannot classify (unsupported platform, +/// disconnected port, container without sysfs, ambiguous name). Purely +/// additive: callers MUST keep their existing fallback on `None`. +pub fn detect_serial_kernel_driver(port_name: &str) -> Option { + super::selected::device::detect_serial_kernel_driver(port_name) +} + +/// Live sysfs USB topology root (`/sys/bus/usb/devices`-shaped) when the +/// host provides one, `None` elsewhere. +pub fn live_sysfs_usb_root() -> Option { + super::selected::device::live_sysfs_usb_root() +} + +/// A USB device that Windows has instantiated but could not start normally. +/// +/// These nodes may not have a usable VID/PID or serial number (for example, +/// Windows reports a descriptor failure as `VID_0000&PID_0002`). The result +/// is deliberately diagnostic only: callers must not treat one of these +/// nodes as a particular target board. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UsbProblemDevice { + pub instance_id: String, + pub problem_code: u32, + pub friendly_name: Option, + pub location: Option, + /// `Some(true)` means a USB device ancestor exists before the root hub; + /// `Some(false)` means the node reaches a root hub directly; `None` means + /// the host could not provide enough ancestry to classify it. + pub behind_external_hub: Option, + /// Immediate parent instance ID, when Config Manager can prove one. + /// Needed to compose an exact-device USB recovery request for a problem + /// interface devnode (FastLED/fbuild#1152). + pub parent_instance_id: Option, + /// Windows device class (e.g. `Ports`, `USB`); `None` for driverless + /// devnodes that never got a class assigned. + pub device_class: Option, + /// Windows physical USB location paths for exact device-local + /// correlation. Human-readable `location` is not stable enough for this. + pub location_paths: Vec, +} + +/// A healthy, present Pico SDK application-mode USB reset interface. +/// +/// Arduino-Pico exposes this WinUSB function when `ENABLE_PICOTOOL_USB` is +/// enabled. It remains independently addressable when the sibling CDC +/// interface is missing or unusable, which lets the RP deployer recover the +/// exact application device without opening a stale COM endpoint. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UsbResetInterface { + pub instance_id: String, + pub parent_instance_id: String, + pub vid: u16, + pub pid: u16, + pub serial_number: String, + /// WinUSB device-interface path for the fixed Pico SDK reset GUID. + pub device_path: String, + /// USB interface number carried by the composite `MI_xx` devnode. + pub interface_number: u8, + pub location_paths: Vec, +} + +/// Best-effort enumeration of present USB devnodes with a non-zero problem +/// code. Empty on hosts without an equivalent diagnostic, and never makes a +/// port scan fail merely because host diagnostics are unavailable. +pub fn present_usb_problem_devices() -> Vec { + super::selected::usb_pnp::present_usb_problem_devices() +} + +/// Best-effort enumeration of healthy Pico SDK application reset interfaces. +/// Empty on hosts without the WinUSB reset surface; their normal +/// libusb/picotool path remains unchanged. +pub fn present_usb_reset_interfaces() -> Vec { + super::selected::usb_pnp::present_usb_reset_interfaces() +} + +/// Ask one exact Pico SDK WinUSB reset interface to enter BOOTSEL mode. +/// +/// The interface must come from [`present_usb_reset_interfaces`], which binds +/// the live device path to its USB serial and VID/PID before this request is +/// issued. The board may disconnect before the OS reports completion; that +/// is the normal successful shape of the no-data control transfer, so the +/// deployer confirms success by waiting for the target BOOTSEL transport. +pub fn reset_usb_interface_to_bootsel(interface: &UsbResetInterface) -> std::io::Result<()> { + super::selected::usb_pnp::reset_usb_interface_to_bootsel(interface) +} + +/// A PnP devnode observed directly by the USB recovery backend. +/// +/// Facts only: the recovery ladder revalidates every field against the +/// caller's request before any operation is allowed. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UsbPnpDevice { + pub instance_id: String, + pub parent_instance_id: Option, + pub device_class: String, + pub vid: u16, + pub pid: u16, + pub serial: Option, + pub health: crate::usb::UsbRecoveryHealth, + pub location_paths: Vec, +} + +/// Return the current node. `allow_phantom` is required only to inspect the +/// original recovery target; verified parents are always looked up live. +pub fn inspect_usb_pnp_device( + instance_id: &str, + allow_phantom: bool, +) -> Result { + super::selected::usb_pnp::inspect_usb_pnp_device(instance_id, allow_phantom) +} + +/// Re-enumerate only the exact, verified live parent of a phantom target. +pub fn reenumerate_usb_parent(parent_instance_id: &str) -> Result<(), String> { + super::selected::usb_pnp::reenumerate_usb_parent(parent_instance_id) +} + +/// Restart only the exact, verified present target child (or the equally +/// verified healthy parent composite of a problematic interface devnode; +/// FastLED/fbuild#1152). Never call this with an unverified instance ID. +pub fn restart_usb_device(instance_id: &str) -> Result<(), String> { + super::selected::usb_pnp::restart_usb_device(instance_id) +} + +/// Whether a Windows compatible-ID string is the standard Raspberry Pi +/// Pico SDK application-mode reset interface (`USB\Class_ff&SubClass_00&Prot_01`). +pub fn is_picotool_reset_compatible_id(value: &str) -> bool { + value.eq_ignore_ascii_case("USB\\Class_ff&SubClass_00&Prot_01") +} + +/// Number of bounded post-operation observations the host backend wants. +/// Fakes stay instant; real backends wait between observations for +/// re-enumeration to settle. +pub fn usb_pnp_post_operation_poll_attempts() -> usize { + super::selected::usb_pnp::usb_pnp_post_operation_poll_attempts() +} + +/// How long the host backend waits between post-operation observations. +pub fn usb_pnp_post_operation_poll_interval() -> Duration { + super::selected::usb_pnp::usb_pnp_post_operation_poll_interval() +} diff --git a/crates/fbuild-core/src/platform/linux/device.rs b/crates/fbuild-core/src/platform/linux/device.rs new file mode 100644 index 00000000..00cd9b19 --- /dev/null +++ b/crates/fbuild-core/src/platform/linux/device.rs @@ -0,0 +1,233 @@ +//! Selected Linux device mechanics: sysfs-backed kernel-driver +//! classification (FastLED/fbuild#895) and the portable `serialport` +//! enumeration delegate behind [`crate::platform::device`]. +//! +//! No library bridges "serial port name → kernel driver class" without +//! an OS-specific linking step, so this module reads the authoritative +//! source directly: a pure `std::fs::read_link` on +//! `/sys/class/tty//device/driver` — no libudev dependency. + +use std::io; +use std::path::Path; + +use crate::path::NormalizedPath; +use crate::platform::device::{KernelDriverClass, SerialPortFacts, facts_from_port_info}; + +/// Canonical sysfs USB topology root. +pub(crate) const SYSFS_USB_ROOT: &str = "/sys/bus/usb/devices"; + +pub(crate) fn available_serial_ports() -> io::Result> { + let ports = serialport::available_ports()?; + Ok(ports.into_iter().map(facts_from_port_info).collect()) +} + +pub(crate) fn detect_serial_kernel_driver(port_name: &str) -> Option { + detect_with_sysfs_root(port_name, Path::new("/sys")) +} + +pub(crate) fn live_sysfs_usb_root() -> Option { + let root = Path::new(SYSFS_USB_ROOT); + if !root.is_dir() { + return None; + } + Some(NormalizedPath::from(SYSFS_USB_ROOT)) +} + +/// Linux implementation, factored on `sysfs_root` so unit tests can +/// point at a temp dir holding a fake sysfs. +pub(crate) fn detect_with_sysfs_root( + port_name: &str, + sysfs_root: &Path, +) -> Option { + let bare = port_name_stem(port_name)?; + + // 1. Authoritative path: read the driver symlink. + // /sys/class/tty//device/driver -> a driver dir + // e.g. -> .../bus/usb-serial/drivers/cdc_acm + // -> .../bus/usb-serial/drivers/ftdi_sio + // -> .../bus/usb-serial/drivers/cp210x + if let Some(driver_name) = read_driver_symlink_name(sysfs_root, bare) { + return Some(classify_driver(&driver_name)); + } + + // 2. Fallback: the kernel's device-node naming convention. + // `ttyACM*` is created by `cdc_acm.ko`; `ttyUSB*` by + // `usbserial.ko`. If sysfs isn't readable for some reason + // (container, permissions), the name is still a strong signal + // because the kernel picks the prefix based on which driver + // claimed the device. + classify_by_devnode_name(bare) +} + +fn read_driver_symlink_name(sysfs_root: &Path, port_stem: &str) -> Option { + let driver_link = sysfs_root + .join("class") + .join("tty") + .join(port_stem) + .join("device") + .join("driver"); + let target = std::fs::read_link(&driver_link).ok()?; + target + .file_name() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) +} + +/// Strip `/dev/` (or `/devices/` in some odd configurations) and return +/// the bare port name, e.g. `ttyACM0`. +pub(crate) fn port_name_stem(port_name: &str) -> Option<&str> { + if let Some(stem) = port_name.strip_prefix("/dev/") { + return Some(stem); + } + // Already a bare name (passed from a test or a sysfs walker). + if !port_name.contains('/') { + return Some(port_name); + } + // Pull the last path segment as a last resort. + port_name.rsplit('/').next() +} + +/// Classify a driver name pulled from the sysfs symlink. +/// +/// `cdc_acm` is the unambiguous CDC-ACM driver. Anything else defaults +/// to bridge: the kernel only invokes `cdc_acm` for actual CDC class — +/// every other usb-serial driver is a chip-specific bridge by +/// construction. +pub(crate) fn classify_driver(driver_name: &str) -> KernelDriverClass { + match driver_name { + "cdc_acm" => KernelDriverClass::CdcAcm, + _ => KernelDriverClass::UsbSerialBridge, + } +} + +/// Fall back to device-node naming when sysfs isn't readable. +pub(crate) fn classify_by_devnode_name(port_stem: &str) -> Option { + if port_stem.starts_with("ttyACM") { + return Some(KernelDriverClass::CdcAcm); + } + if port_stem.starts_with("ttyUSB") { + return Some(KernelDriverClass::UsbSerialBridge); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + // Build a fake `/sys/class/tty//device/driver` symlink + // pointing at a fake driver dir under a tmp root, then ask + // detect_with_sysfs_root to classify it. + fn build_fake_sysfs_tree( + sysfs_root: &std::path::Path, + port_stem: &str, + driver_name: &str, + ) { + let device_dir = sysfs_root + .join("class") + .join("tty") + .join(port_stem) + .join("device"); + std::fs::create_dir_all(&device_dir).unwrap(); + // The driver target dir must exist (the actual sysfs has it; + // read_link only follows the symlink, but having a real target + // dir matches the production shape). + let driver_dir = sysfs_root + .join("bus") + .join("usb-serial") + .join("drivers") + .join(driver_name); + std::fs::create_dir_all(&driver_dir).unwrap(); + crate::platform::fs::symlink_dir(&driver_dir, &device_dir.join("driver")).unwrap(); + } + + #[test] + fn linux_sysfs_cdc_acm_driver_is_cdc() { + let tmp = tempdir().unwrap(); + build_fake_sysfs_tree(tmp.path(), "ttyACM0", "cdc_acm"); + assert_eq!( + detect_with_sysfs_root("/dev/ttyACM0", tmp.path()), + Some(KernelDriverClass::CdcAcm) + ); + } + + #[test] + fn linux_sysfs_ftdi_driver_is_bridge() { + let tmp = tempdir().unwrap(); + build_fake_sysfs_tree(tmp.path(), "ttyUSB0", "ftdi_sio"); + assert_eq!( + detect_with_sysfs_root("/dev/ttyUSB0", tmp.path()), + Some(KernelDriverClass::UsbSerialBridge) + ); + } + + #[test] + fn linux_sysfs_cp210x_driver_is_bridge() { + let tmp = tempdir().unwrap(); + build_fake_sysfs_tree(tmp.path(), "ttyUSB1", "cp210x"); + assert_eq!( + detect_with_sysfs_root("/dev/ttyUSB1", tmp.path()), + Some(KernelDriverClass::UsbSerialBridge) + ); + } + + #[test] + fn linux_sysfs_ch341_driver_is_bridge() { + let tmp = tempdir().unwrap(); + build_fake_sysfs_tree(tmp.path(), "ttyUSB2", "ch341"); + assert_eq!( + detect_with_sysfs_root("/dev/ttyUSB2", tmp.path()), + Some(KernelDriverClass::UsbSerialBridge) + ); + } + + #[test] + fn linux_devnode_name_acm_is_cdc() { + // No sysfs entry exists at all → fall back to devnode name. + // ttyACM* is created only by cdc_acm so this is reliable. + let tmp = tempdir().unwrap(); + assert_eq!( + detect_with_sysfs_root("/dev/ttyACM7", tmp.path()), + Some(KernelDriverClass::CdcAcm) + ); + } + + #[test] + fn linux_devnode_name_usb_is_bridge() { + let tmp = tempdir().unwrap(); + assert_eq!( + detect_with_sysfs_root("/dev/ttyUSB3", tmp.path()), + Some(KernelDriverClass::UsbSerialBridge) + ); + } + + #[test] + fn linux_unrelated_devnode_returns_none() { + // ttyS0 (real UART, not USB) shouldn't classify as either — + // the kernel didn't bind it via cdc_acm or usbserial. + let tmp = tempdir().unwrap(); + assert_eq!(detect_with_sysfs_root("/dev/ttyS0", tmp.path()), None); + } + + #[test] + fn linux_classify_driver_unknown_falls_back_to_bridge() { + // A new bridge driver landing in mainline Linux (e.g. + // qcserial, mos7720) should classify as a bridge because + // anything not literally `cdc_acm` is by construction a + // chip-specific bridge. + assert_eq!(classify_driver("qcserial"), KernelDriverClass::UsbSerialBridge); + assert_eq!(classify_driver("pl2303"), KernelDriverClass::UsbSerialBridge); + assert_eq!( + classify_driver("totally-not-a-driver"), + KernelDriverClass::UsbSerialBridge + ); + } + + #[test] + fn linux_port_name_stem_strips_dev_prefix() { + assert_eq!(port_name_stem("/dev/ttyACM0"), Some("ttyACM0")); + assert_eq!(port_name_stem("ttyACM0"), Some("ttyACM0")); + assert_eq!(port_name_stem("/some/oddpath/ttyUSB2"), Some("ttyUSB2")); + } +} diff --git a/crates/fbuild-core/src/platform/linux/mod.rs b/crates/fbuild-core/src/platform/linux/mod.rs index e81bca90..75c40067 100644 --- a/crates/fbuild-core/src/platform/linux/mod.rs +++ b/crates/fbuild-core/src/platform/linux/mod.rs @@ -1,8 +1,10 @@ use super::host::{HostArch, HostOs}; +pub(super) mod device; pub(super) mod fs; pub(super) mod ipc; pub(super) mod process; +pub(super) mod usb_pnp; pub(super) const HOST_OS: HostOs = HostOs::Linux; diff --git a/crates/fbuild-core/src/platform/linux/usb_pnp.rs b/crates/fbuild-core/src/platform/linux/usb_pnp.rs new file mode 100644 index 00000000..4c840002 --- /dev/null +++ b/crates/fbuild-core/src/platform/linux/usb_pnp.rs @@ -0,0 +1,49 @@ +//! Selected Linux USB PnP mechanics behind [`crate::platform::device`]. +//! +//! USB PnP diagnostics/recovery (problem-device enumeration, the Pico +//! WinUSB BOOTSEL reset surface, CfgMgr32-style inspect/reenumerate/ +//! restart) is a Windows-only recovery surface; every entry point here +//! fails closed so callers keep their non-Windows behaviour unchanged. + +use std::io; + +use crate::platform::device::{UsbPnpDevice, UsbProblemDevice, UsbResetInterface}; + +pub(crate) fn present_usb_problem_devices() -> Vec { + Vec::new() +} + +pub(crate) fn present_usb_reset_interfaces() -> Vec { + Vec::new() +} + +pub(crate) fn reset_usb_interface_to_bootsel( + _interface: &UsbResetInterface, +) -> io::Result<()> { + Err(io::Error::other( + "Pico WinUSB reset interface is a Windows-only recovery surface", + )) +} + +pub(crate) fn inspect_usb_pnp_device( + _instance_id: &str, + _allow_phantom: bool, +) -> Result { + Err("USB PnP recovery is a Windows-only surface".to_string()) +} + +pub(crate) fn reenumerate_usb_parent(_parent_instance_id: &str) -> Result<(), String> { + Err("USB PnP recovery is a Windows-only surface".to_string()) +} + +pub(crate) fn restart_usb_device(_instance_id: &str) -> Result<(), String> { + Err("USB PnP recovery is a Windows-only surface".to_string()) +} + +pub(crate) fn usb_pnp_post_operation_poll_attempts() -> usize { + 0 +} + +pub(crate) fn usb_pnp_post_operation_poll_interval() -> std::time::Duration { + std::time::Duration::from_millis(250) +} diff --git a/crates/fbuild-core/src/platform/macos/device.rs b/crates/fbuild-core/src/platform/macos/device.rs new file mode 100644 index 00000000..9cabdcb0 --- /dev/null +++ b/crates/fbuild-core/src/platform/macos/device.rs @@ -0,0 +1,128 @@ +//! Selected macOS device mechanics: device-node-naming kernel-driver +//! classification (FastLED/fbuild#895) and the portable `serialport` +//! enumeration delegate behind [`crate::platform::device`]. +//! +//! macOS device-node naming is set per-driver and is the canonical +//! signal here — no IOKit query is needed to distinguish the cases this +//! module covers: +//! +//! - `IOUSBHostFamily`'s CDC-ACM stack publishes `/dev/cu.usbmodem*` +//! and `/dev/tty.usbmodem*` when the device exposes the CDC class. +//! - Vendor drivers publish their own prefixes (`cu.usbserial-*`, +//! `cu.SLAB_USBtoUART*`, `cu.wchusbserial*`, `cu.PL2303-*`). + +use std::io; + +use crate::platform::device::{KernelDriverClass, SerialPortFacts, facts_from_port_info}; + +pub(crate) fn available_serial_ports() -> io::Result> { + let ports = serialport::available_ports()?; + Ok(ports.into_iter().map(facts_from_port_info).collect()) +} + +pub(crate) fn detect_serial_kernel_driver(port_name: &str) -> Option { + classify_macos_devnode(port_name) +} + +pub(crate) fn live_sysfs_usb_root() -> Option { + None +} + +/// Classify a macOS serial devnode from its device-node name. +/// +/// Any name we don't recognize returns `None` so the caller falls back +/// to its existing default — same safety contract as the rest of the +/// device facade. +pub(crate) fn classify_macos_devnode(port_name: &str) -> Option { + // Strip `/dev/` prefix if present, then strip the cu./tty. + // disambiguation prefix. + let bare = port_name.strip_prefix("/dev/").unwrap_or(port_name); + let suffix = bare + .strip_prefix("cu.") + .or_else(|| bare.strip_prefix("tty.")) + .unwrap_or(bare); + + if suffix.starts_with("usbmodem") { + return Some(KernelDriverClass::CdcAcm); + } + if suffix.starts_with("usbserial-") + || suffix.starts_with("usbserial.") + || suffix.starts_with("SLAB_USBtoUART") + || suffix.starts_with("wchusbserial") + || suffix.starts_with("PL2303") + { + return Some(KernelDriverClass::UsbSerialBridge); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn macos_usbmodem_is_cdc() { + assert_eq!( + classify_macos_devnode("/dev/cu.usbmodem14101"), + Some(KernelDriverClass::CdcAcm) + ); + assert_eq!( + classify_macos_devnode("/dev/tty.usbmodem14101"), + Some(KernelDriverClass::CdcAcm) + ); + } + + #[test] + fn macos_ftdi_usbserial_is_bridge() { + assert_eq!( + classify_macos_devnode("/dev/cu.usbserial-A1234567"), + Some(KernelDriverClass::UsbSerialBridge) + ); + assert_eq!( + classify_macos_devnode("/dev/tty.usbserial-FTDI"), + Some(KernelDriverClass::UsbSerialBridge) + ); + } + + #[test] + fn macos_slab_cp210x_is_bridge() { + assert_eq!( + classify_macos_devnode("/dev/cu.SLAB_USBtoUART"), + Some(KernelDriverClass::UsbSerialBridge) + ); + } + + #[test] + fn macos_wch_ch340_is_bridge() { + assert_eq!( + classify_macos_devnode("/dev/cu.wchusbserial1410"), + Some(KernelDriverClass::UsbSerialBridge) + ); + } + + #[test] + fn macos_pl2303_is_bridge() { + assert_eq!( + classify_macos_devnode("/dev/cu.PL2303-XYZ"), + Some(KernelDriverClass::UsbSerialBridge) + ); + } + + #[test] + fn macos_bare_name_without_dev_prefix() { + // Caller passed in a bare name — should still work. + assert_eq!( + classify_macos_devnode("cu.usbmodem1101"), + Some(KernelDriverClass::CdcAcm) + ); + } + + #[test] + fn macos_unrelated_returns_none() { + // /dev/cu.Bluetooth-Incoming-Port shouldn't be classified as + // either CDC or bridge — it's not USB. + assert_eq!(classify_macos_devnode("/dev/cu.Bluetooth-Incoming-Port"), None); + // Stray random name returns None too. + assert_eq!(classify_macos_devnode("/dev/cu.random-thing"), None); + } +} diff --git a/crates/fbuild-core/src/platform/macos/mod.rs b/crates/fbuild-core/src/platform/macos/mod.rs index c8d721be..ba075ee8 100644 --- a/crates/fbuild-core/src/platform/macos/mod.rs +++ b/crates/fbuild-core/src/platform/macos/mod.rs @@ -1,8 +1,10 @@ use super::host::{HostArch, HostOs}; +pub(super) mod device; pub(super) mod fs; pub(super) mod ipc; pub(super) mod process; +pub(super) mod usb_pnp; pub(super) const HOST_OS: HostOs = HostOs::Macos; diff --git a/crates/fbuild-core/src/platform/macos/usb_pnp.rs b/crates/fbuild-core/src/platform/macos/usb_pnp.rs new file mode 100644 index 00000000..9eed1334 --- /dev/null +++ b/crates/fbuild-core/src/platform/macos/usb_pnp.rs @@ -0,0 +1,49 @@ +//! Selected macOS USB PnP mechanics behind [`crate::platform::device`]. +//! +//! USB PnP diagnostics/recovery (problem-device enumeration, the Pico +//! WinUSB BOOTSEL reset surface, CfgMgr32-style inspect/reenumerate/ +//! restart) is a Windows-only recovery surface; every entry point here +//! fails closed so callers keep their non-Windows behaviour unchanged. + +use std::io; + +use crate::platform::device::{UsbPnpDevice, UsbProblemDevice, UsbResetInterface}; + +pub(crate) fn present_usb_problem_devices() -> Vec { + Vec::new() +} + +pub(crate) fn present_usb_reset_interfaces() -> Vec { + Vec::new() +} + +pub(crate) fn reset_usb_interface_to_bootsel( + _interface: &UsbResetInterface, +) -> io::Result<()> { + Err(io::Error::other( + "Pico WinUSB reset interface is a Windows-only recovery surface", + )) +} + +pub(crate) fn inspect_usb_pnp_device( + _instance_id: &str, + _allow_phantom: bool, +) -> Result { + Err("USB PnP recovery is a Windows-only surface".to_string()) +} + +pub(crate) fn reenumerate_usb_parent(_parent_instance_id: &str) -> Result<(), String> { + Err("USB PnP recovery is a Windows-only surface".to_string()) +} + +pub(crate) fn restart_usb_device(_instance_id: &str) -> Result<(), String> { + Err("USB PnP recovery is a Windows-only surface".to_string()) +} + +pub(crate) fn usb_pnp_post_operation_poll_attempts() -> usize { + 0 +} + +pub(crate) fn usb_pnp_post_operation_poll_interval() -> std::time::Duration { + std::time::Duration::from_millis(250) +} diff --git a/crates/fbuild-core/src/platform/windows/device.rs b/crates/fbuild-core/src/platform/windows/device.rs new file mode 100644 index 00000000..22049cd1 --- /dev/null +++ b/crates/fbuild-core/src/platform/windows/device.rs @@ -0,0 +1,683 @@ +//! Selected Windows serial-port enumeration mechanics behind +//! [`crate::platform::device`] — a SetupAPI/CfgMgr32 fork of +//! `serialport` 4.9's `windows/enumerate.rs` (MIT/Apache-2.0) with one +//! behavioural change: devnodes whose `CM_Get_DevNode_Status` reports a +//! **non-OK problem code** are still listed. `serialport` skips any such +//! devnode, and PJRC/Teensy (VID `16C0`) serial functions enumerate as +//! composite `MI_00` interfaces that commonly report `Status = Unknown`, +//! so upstream drops **every** Teensy COM port — a physically-attached +//! Teensy is invisible to port discovery. FastLED/fbuild#962. +//! +//! USB PnP diagnostics/recovery live in [`super::usb_pnp`]; the two +//! modules share the SetupAPI helpers re-exported here. + +use std::collections::HashSet; +use std::io; +use std::ptr; + +use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{ + CM_Get_DevNode_Status, CM_Get_Device_IDW, CM_Get_Parent, CR_NO_SUCH_DEVINST, CR_SUCCESS, + DICS_FLAG_GLOBAL, DIREG_DEV, HDEVINFO, MAX_DEVICE_ID_LEN, SP_DEVINFO_DATA, + SPDRP_FRIENDLYNAME, SPDRP_HARDWAREID, SPDRP_MFG, SetupDiClassGuidsFromNameW, + SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInfo, SetupDiGetClassDevsW, + SetupDiGetDeviceInstanceIdW, SetupDiGetDevicePropertyW, SetupDiGetDeviceRegistryPropertyW, + SetupDiOpenDevRegKey, +}; +use windows_sys::Win32::Devices::Properties::{ + DEVPKEY_Device_LocationPaths, DEVPROP_TYPE_STRING_LIST, +}; +use windows_sys::Win32::Foundation::{FALSE, FILETIME, INVALID_HANDLE_VALUE, MAX_PATH}; +use windows_sys::Win32::System::Registry::{ + HKEY, HKEY_LOCAL_MACHINE, KEY_READ, REG_SZ, RegCloseKey, RegEnumValueW, RegOpenKeyExW, + RegQueryInfoKeyW, RegQueryValueExW, +}; +use windows_sys::core::GUID; + +use crate::path::NormalizedPath; +use crate::platform::device::{ + DevNodeObservation, KernelDriverClass, SerialPortFacts, SerialPortTypeFacts, + UsbSerialIdentityFacts, +}; + +const CONNECTOR_PUNCTUATION_SELECTION: &[char] = &[':', '_', '\u{ff3f}']; + +pub(crate) fn available_serial_ports() -> io::Result> { + let mut ports = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for guid in get_ports_guids()? { + let port_devices = PortDevices::new(&guid); + for mut port_device in port_devices { + let port_name = port_device.name(); + if port_name.is_empty() { + // No PortName in the devnode registry key → not an actual + // COM port (e.g. a modem enumerator entry). Skip. + continue; + } + if port_name.starts_with("LPT") { + continue; + } + let instance_id = port_device.instance_id(); + let parent_instance_id = port_device.parent_instance_id(); + let ancestor_instance_ids = ancestor_ids(port_device.devinfo_data.DevInst); + let observation = port_device.pnp_observation(); + let port_type = + port_device.port_type(instance_id.as_deref(), parent_instance_id.as_deref()); + let is_usb = matches!(port_type, SerialPortTypeFacts::Usb(_)); + // Include every present port (unchanged behaviour), PLUS + // non-present USB serial ports — the Status=Unknown Teensy + // case the whole fix exists for. A non-present *non-USB* + // devnode is a stale phantom with no VID:PID to act on, so we + // leave it out to avoid resurrecting ancient ACPI/BT junk. + // FastLED/fbuild#962. + if matches!(observation, DevNodeObservation::Phantom) && !is_usb { + continue; + } + // A phantom devnode can be enumerated once per matching class + // GUID; de-dup on the COM name. + if !seen.insert(port_name.clone()) { + continue; + } + ports.push(SerialPortFacts { + port_name, + port_type, + observation, + instance_id, + parent_instance_id, + ancestor_instance_ids, + location_paths: location_paths_from_info( + port_device.hdi, + &port_device.devinfo_data, + ), + }); + } + } + + // Fold in any DEVICEMAP\SERIALCOMM ports not already found. + for raw_port in get_registry_com_ports() { + if seen.insert(raw_port.clone()) { + ports.push(SerialPortFacts { + port_name: raw_port, + port_type: SerialPortTypeFacts::Unknown, + observation: DevNodeObservation::Unknown, + instance_id: None, + parent_instance_id: None, + ancestor_instance_ids: Vec::new(), + location_paths: Vec::new(), + }); + } + } + Ok(ports) +} + +pub(crate) fn detect_serial_kernel_driver(_port_name: &str) -> Option { + // Windows SetupDi detection (SPDRP_SERVICE) is a documented #895 + // follow-up. Returning None here preserves the caller fallback chain + // so Windows behaviour cannot regress. + None +} + +pub(crate) fn live_sysfs_usb_root() -> Option { + None +} + +pub(super) fn as_utf16(utf8: &str) -> Vec { + utf8.encode_utf16().chain(Some(0)).collect() +} + +fn from_utf16_lossy_trimmed(utf16: &[u16]) -> String { + String::from_utf16_lossy(utf16) + .trim_end_matches(0 as char) + .to_string() +} + +fn get_ports_guids() -> io::Result> { + let class_names = ["Ports", "Modem"]; + let mut guids: Vec = Vec::new(); + for class_name in class_names { + let class_name_w = as_utf16(class_name); + let mut num_guids: u32 = 1; + let class_start_idx = guids.len(); + + for _ in 0..2 { + guids.resize(class_start_idx + num_guids as usize, GUID::from_u128(0)); + let guid_buffer = &mut guids[class_start_idx..]; + let res = unsafe { + SetupDiClassGuidsFromNameW( + class_name_w.as_ptr(), + guid_buffer.as_mut_ptr(), + guid_buffer.len() as u32, + &mut num_guids, + ) + }; + if res == FALSE { + return Err(io::Error::other("Unable to determine number of Ports GUIDs")); + } + let len_cmp = guid_buffer.len().cmp(&(num_guids as usize)); + if len_cmp == std::cmp::Ordering::Less { + continue; + } else if len_cmp == std::cmp::Ordering::Greater { + guids.truncate(class_start_idx + num_guids as usize); + } + break; + } + } + Ok(guids) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct HwidMatches<'hwid> { + vid: &'hwid str, + pid: &'hwid str, + serial: Option<&'hwid str>, + interface: Option<&'hwid str>, +} + +impl<'hwid> HwidMatches<'hwid> { + fn new(hwid: &'hwid str) -> Option { + let mut hwid_tail = hwid; + let vid_start = hwid.find("VID_")?; + let vid = hwid_tail.get(vid_start + 4..vid_start + 8)?; + hwid_tail = hwid_tail.get(vid_start + 8..)?; + + let pid = if hwid_tail.starts_with("&PID_") || hwid_tail.starts_with("+PID_") { + hwid_tail.get(5..9)? + } else { + return None; + }; + hwid_tail = hwid_tail.get(9..)?; + + let iid = if hwid_tail.starts_with("&MI_") || hwid_tail.starts_with("+MI_") { + let iid = hwid_tail.get(4..6); + hwid_tail = hwid_tail.get(6..).unwrap_or(hwid_tail); + iid + } else { + None + }; + + let serial = if hwid_tail.starts_with('\\') || hwid_tail.starts_with('+') { + hwid_tail.get(1..).and_then(|tail| { + let index = tail + .char_indices() + .find(|&(_, char)| { + !(char.is_alphanumeric() + || CONNECTOR_PUNCTUATION_SELECTION.contains(&char)) + }) + .map(|(index, _)| index) + .unwrap_or(tail.len()); + tail.get(..index) + }) + } else { + None + }; + + Some(Self { + vid, + pid, + serial, + interface: iid, + }) + } +} + +/// Parse a Windows HWID string into [`UsbSerialIdentityFacts`] (with the +/// composite `MI_xx` interface index preserved). Pure — unit-tested below. +/// +/// VID/PID always come from the device's own hardware id (a composite +/// interface's `MI_xx` hwid carries the same VID/PID as its parent). Only +/// the serial number is taken from the parent for composite devices — and +/// if the parent isn't available (a **phantom** devnode whose live parent +/// no longer exists, i.e. the Status=Unknown Teensy case) we fall back to +/// the child's own serial tail rather than giving up. This is the key +/// difference from upstream serialport, which returns `None` (→ no VID/PID) +/// for a composite devnode with no reachable parent. FastLED/fbuild#962. +pub(super) fn parse_usb_port_info( + hardware_id: &str, + parent_hardware_id: Option<&str>, +) -> Option { + let child = HwidMatches::new(hardware_id)?; + let interface = child.interface.and_then(|m| u8::from_str_radix(m, 16).ok()); + let serial = if interface.is_some() { + parent_hardware_id + .and_then(HwidMatches::new) + .and_then(|p| p.serial) + .or(child.serial) + } else { + child.serial + }; + + Some(UsbSerialIdentityFacts { + vid: u16::from_str_radix(child.vid, 16).ok()?, + pid: u16::from_str_radix(child.pid, 16).ok()?, + serial_number: serial.map(str::to_string), + manufacturer: None, + product: None, + // The workspace enables serialport's `usbportinfo-interface` + // feature precisely so this field exists downstream; it carries + // the `MI_xx` index used to disambiguate Teensy Serial vs MIDI. + interface, + }) +} + +struct PortDevices { + hdi: HDEVINFO, + dev_idx: u32, +} + +impl PortDevices { + fn new(guid: &GUID) -> Self { + PortDevices { + // flags = 0 (NOT `DIGCF_PRESENT`) so non-present / phantom / + // Status=Unknown devnodes — every PJRC/Teensy composite serial + // port — are enumerated too. We re-derive real presence below + // via `CM_Get_DevNode_Status`. FastLED/fbuild#962. + hdi: unsafe { SetupDiGetClassDevsW(guid, ptr::null(), 0, 0) }, + dev_idx: 0, + } + } +} + +impl Iterator for PortDevices { + type Item = PortDevice; + + fn next(&mut self) -> Option { + let mut port_dev = PortDevice { + hdi: self.hdi, + devinfo_data: SP_DEVINFO_DATA { + cbSize: std::mem::size_of::() as u32, + ClassGuid: GUID::from_u128(0), + DevInst: 0, + Reserved: 0, + }, + }; + let res = unsafe { + SetupDiEnumDeviceInfo(self.hdi, self.dev_idx, &mut port_dev.devinfo_data) + }; + if res == FALSE { + None + } else { + self.dev_idx += 1; + Some(port_dev) + } + } +} + +impl Drop for PortDevices { + fn drop(&mut self) { + unsafe { + SetupDiDestroyDeviceInfoList(self.hdi); + } + } +} + +struct PortDevice { + hdi: HDEVINFO, + devinfo_data: SP_DEVINFO_DATA, +} + +impl PortDevice { + fn parent_instance_id(&mut self) -> Option { + let mut result_buf = [0u16; MAX_PATH as usize]; + let mut parent_device_instance_id = 0; + let res = unsafe { + CM_Get_Parent(&mut parent_device_instance_id, self.devinfo_data.DevInst, 0) + }; + if res == CR_SUCCESS { + let buffer_len = result_buf.len() - 1; + let res = unsafe { + CM_Get_Device_IDW( + parent_device_instance_id, + result_buf.as_mut_ptr(), + buffer_len as u32, + 0, + ) + }; + if res == CR_SUCCESS { + Some(from_utf16_lossy_trimmed(&result_buf)) + } else { + None + } + } else { + None + } + } + + fn instance_id(&mut self) -> Option { + let mut result_buf = [0u16; MAX_DEVICE_ID_LEN as usize]; + let working_buffer_len = result_buf.len() - 1; + let mut desired_result_len = 0; + let res = unsafe { + SetupDiGetDeviceInstanceIdW( + self.hdi, + &self.devinfo_data, + result_buf.as_mut_ptr(), + working_buffer_len as u32, + &mut desired_result_len, + ) + }; + if res == FALSE { + self.property(SPDRP_HARDWAREID) + } else { + let actual_result_len = working_buffer_len.min(desired_result_len as usize); + Some(from_utf16_lossy_trimmed(&result_buf[..actual_result_len])) + } + } + + // Retrieves the port name (i.e. COM6) associated with this device. + fn name(&mut self) -> String { + let hkey = unsafe { + SetupDiOpenDevRegKey( + self.hdi, + &self.devinfo_data, + DICS_FLAG_GLOBAL, + 0, + DIREG_DEV, + KEY_READ, + ) + }; + if hkey == INVALID_HANDLE_VALUE { + return String::new(); + } + + let mut port_name_buffer = [0u16; MAX_PATH as usize]; + let buffer_byte_len = 2 * port_name_buffer.len() as u32; + let mut byte_len = buffer_byte_len; + let mut value_type = 0; + let value_name = as_utf16("PortName"); + let err = unsafe { + RegQueryValueExW( + hkey, + value_name.as_ptr(), + ptr::null_mut(), + &mut value_type, + port_name_buffer.as_mut_ptr() as *mut u8, + &mut byte_len, + ) + }; + unsafe { RegCloseKey(hkey) }; + if err != 0 { + return String::new(); + } + if value_type != REG_SZ || !byte_len.is_multiple_of(2) || byte_len > buffer_byte_len { + return String::new(); + } + let len = buffer_byte_len as usize / 2; + let port_name = &port_name_buffer[0..len]; + from_utf16_lossy_trimmed(port_name) + } + + /// Read the Config Manager observation without flattening its three + /// important outcomes. A missing live devnode is a phantom; a query + /// failure that is not that explicit state remains unknown. + fn pnp_observation(&mut self) -> DevNodeObservation { + let mut status = 0u32; + let mut problem = 0u32; + // SAFETY: `DevInst` comes from the live SetupAPI record and both + // output pointers reference initialized writable local storage. + let res = unsafe { + CM_Get_DevNode_Status(&mut status, &mut problem, self.devinfo_data.DevInst, 0) + }; + if res == CR_SUCCESS { + DevNodeObservation::Present { + status, + problem_code: problem, + } + } else if res == CR_NO_SUCH_DEVINST { + DevNodeObservation::Phantom + } else { + DevNodeObservation::Unknown + } + } + + fn port_type( + &mut self, + instance_id: Option<&str>, + parent_instance_id: Option<&str>, + ) -> SerialPortTypeFacts { + instance_id + .and_then(|id| parse_usb_port_info(id, parent_instance_id)) + .map(|mut facts: UsbSerialIdentityFacts| { + facts.manufacturer = self.property(SPDRP_MFG); + facts.product = self.property(SPDRP_FRIENDLYNAME); + SerialPortTypeFacts::Usb(facts) + }) + .unwrap_or(SerialPortTypeFacts::Unknown) + } + + fn property(&mut self, property_id: u32) -> Option { + let mut value_type = 0; + let mut property_buf = [0u16; MAX_PATH as usize]; + let res = unsafe { + SetupDiGetDeviceRegistryPropertyW( + self.hdi, + &self.devinfo_data, + property_id, + &mut value_type, + property_buf.as_mut_ptr() as *mut u8, + property_buf.len() as u32, + ptr::null_mut(), + ) + }; + if res == FALSE || value_type != REG_SZ { + return None; + } + from_utf16_lossy_trimmed(&property_buf) + .split(';') + .next_back() + .map(str::to_string) + } +} + +pub(super) fn ancestor_ids(devinst: u32) -> Vec { + let mut ids = Vec::new(); + let mut current = devinst; + for _ in 0..16 { + let mut parent = 0; + let result = unsafe { CM_Get_Parent(&mut parent, current, 0) }; + if result != CR_SUCCESS { + break; + } + let mut buffer = [0u16; MAX_DEVICE_ID_LEN as usize]; + let result = + unsafe { CM_Get_Device_IDW(parent, buffer.as_mut_ptr(), buffer.len() as u32, 0) }; + if result != CR_SUCCESS { + break; + } + let length = buffer + .iter() + .position(|&unit| unit == 0) + .unwrap_or(buffer.len()); + ids.push(String::from_utf16_lossy(&buffer[..length])); + current = parent; + } + ids +} + +pub(super) fn location_paths_from_info(hdi: HDEVINFO, info: &SP_DEVINFO_DATA) -> Vec { + let mut property_type = 0u32; + let mut required_bytes = 0u32; + // First call obtains the required byte count. SetupAPI reports + // insufficient buffer here, so the return value itself is not the + // success signal; a non-zero required size is. + unsafe { + SetupDiGetDevicePropertyW( + hdi, + info, + &DEVPKEY_Device_LocationPaths, + &mut property_type, + std::ptr::null_mut(), + 0, + &mut required_bytes, + 0, + ) + }; + if required_bytes < 2 { + return Vec::new(); + } + let mut buffer = vec![0u16; (required_bytes as usize).div_ceil(2)]; + let ok = unsafe { + SetupDiGetDevicePropertyW( + hdi, + info, + &DEVPKEY_Device_LocationPaths, + &mut property_type, + buffer.as_mut_ptr().cast(), + required_bytes, + &mut required_bytes, + 0, + ) + }; + if ok == FALSE || property_type != DEVPROP_TYPE_STRING_LIST { + return Vec::new(); + } + buffer + .split(|unit| *unit == 0) + .take_while(|segment| !segment.is_empty()) + .map(String::from_utf16_lossy) + .filter(|path| !path.is_empty()) + .collect() +} + +/// COM ports listed under `HKLM\HARDWARE\DEVICEMAP\SERIALCOMM` that the +/// "Ports" class walk did not surface (parity with upstream serialport). +fn get_registry_com_ports() -> HashSet { + let mut ports_list = HashSet::new(); + let reg_key = as_utf16("HARDWARE\\DEVICEMAP\\SERIALCOMM"); + let mut ports_key: HKEY = 0; + let open_res = unsafe { + RegOpenKeyExW( + HKEY_LOCAL_MACHINE, + reg_key.as_ptr(), + 0, + KEY_READ, + &mut ports_key, + ) + }; + if open_res != 0 { + return ports_list; + } + let mut class_name_buff = [0u16; MAX_PATH as usize]; + let mut class_name_size = MAX_PATH; + let mut sub_key_count = 0; + let mut largest_sub_key = 0; + let mut largest_class_string = 0; + let mut num_key_values = 0; + let mut longest_value_name = 0; + let mut longest_value_data = 0; + let mut size_security_desc = 0; + let mut last_write_time = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let query_res = unsafe { + RegQueryInfoKeyW( + ports_key, + class_name_buff.as_mut_ptr(), + &mut class_name_size, + ptr::null(), + &mut sub_key_count, + &mut largest_sub_key, + &mut largest_class_string, + &mut num_key_values, + &mut longest_value_name, + &mut longest_value_data, + &mut size_security_desc, + &mut last_write_time, + ) + }; + if query_res == 0 { + for idx in 0..num_key_values { + let mut val_name_buff = [0u16; MAX_PATH as usize]; + let mut val_name_size = MAX_PATH; + let mut value_type = 0; + let mut val_data = [0u16; MAX_PATH as usize]; + let buffer_byte_len = 2 * val_data.len() as u32; + let mut byte_len = buffer_byte_len; + let res = unsafe { + RegEnumValueW( + ports_key, + idx, + val_name_buff.as_mut_ptr(), + &mut val_name_size, + ptr::null(), + &mut value_type, + val_data.as_mut_ptr() as *mut u8, + &mut byte_len, + ) + }; + if res != 0 + || value_type != REG_SZ + || !byte_len.is_multiple_of(2) + || byte_len > buffer_byte_len + { + break; + } + let val_data = from_utf16_lossy_trimmed(unsafe { + let utf16_len = byte_len / 2; + std::slice::from_raw_parts(val_data.as_ptr(), utf16_len as usize) + }); + ports_list.insert(val_data); + } + } + unsafe { RegCloseKey(ports_key) }; + ports_list +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_teensy_composite_serial_with_interface() { + // Teensy 4.x USB Serial enumerates as a composite MI_00 interface; + // the serial comes from the PARENT instance id, VID/PID from the child. + let child = r"USB\VID_16C0&PID_0483&MI_00\8&226AD2B7&0&0000"; + let parent = r"USB\VID_16C0&PID_0483\12345678"; + let info = parse_usb_port_info(child, Some(parent)).expect("parse"); + assert_eq!(info.vid, 0x16C0); + assert_eq!(info.pid, 0x0483); + assert_eq!(info.serial_number.as_deref(), Some("12345678")); + assert_eq!(info.interface, Some(0)); + } + + #[test] + fn parses_phantom_teensy_composite_without_parent() { + // The bug's core case: a Status=Unknown Teensy port is a phantom + // devnode whose live parent no longer exists, so no parent hwid is + // available. We must STILL recover VID/PID (16C0:0483) from the + // child's own MI_00 hardware id — upstream serialport returns None + // here, which is why the Teensy was invisible. FastLED/fbuild#962. + let child = r"USB\VID_16C0&PID_0483&MI_00\8&226AD2B7&0&0000"; + let info = parse_usb_port_info(child, None).expect("parse without parent"); + assert_eq!(info.vid, 0x16C0); + assert_eq!(info.pid, 0x0483); + assert_eq!(info.interface, Some(0)); + } + + #[test] + fn parses_teensy_serial_midi_audio_pid() { + let child = r"USB\VID_16C0&PID_0489&MI_00\9&32144BF9&0&0000"; + let parent = r"USB\VID_16C0&PID_0489\ABCDEF"; + let info = parse_usb_port_info(child, Some(parent)).expect("parse"); + assert_eq!(info.vid, 0x16C0); + assert_eq!(info.pid, 0x0489); + assert_eq!(info.interface, Some(0)); + } + + #[test] + fn non_composite_device_has_no_interface() { + let info = parse_usb_port_info(r"USB\VID_303A&PID_1001\B4:3A:45:B0:08:24", None) + .expect("parse"); + assert_eq!(info.vid, 0x303A); + assert_eq!(info.interface, None); + assert_eq!(info.serial_number.as_deref(), Some("B4:3A:45:B0:08:24")); + } + + #[test] + fn kernel_driver_detection_is_deferred_on_windows() { + // Documented #895 follow-up (SetupDi SPDRP_SERVICE). Until it + // lands, the path returns None so the existing fallback chain + // stays in charge — no regression risk. + assert_eq!(detect_serial_kernel_driver("COM3"), None); + assert_eq!(detect_serial_kernel_driver("COM42"), None); + } +} diff --git a/crates/fbuild-core/src/platform/windows/mod.rs b/crates/fbuild-core/src/platform/windows/mod.rs index dafb5bf8..1278ebaf 100644 --- a/crates/fbuild-core/src/platform/windows/mod.rs +++ b/crates/fbuild-core/src/platform/windows/mod.rs @@ -1,8 +1,10 @@ use super::host::{HostArch, HostOs}; +pub(super) mod device; pub(super) mod fs; pub(super) mod ipc; pub(super) mod process; +pub(super) mod usb_pnp; pub(super) const HOST_OS: HostOs = HostOs::Windows; diff --git a/crates/fbuild-core/src/platform/windows/usb_pnp.rs b/crates/fbuild-core/src/platform/windows/usb_pnp.rs new file mode 100644 index 00000000..984d7e9c --- /dev/null +++ b/crates/fbuild-core/src/platform/windows/usb_pnp.rs @@ -0,0 +1,722 @@ +//! Selected Windows USB PnP diagnostics and recovery mechanics behind +//! [`crate::platform::device`]: problem-device enumeration, the Pico SDK +//! BOOTSEL reset interface (WinUsb control transfer), and the CfgMgr32 +//! inspect/re-enumerate/restart primitives the recovery ladder drives. +//! +//! Split from serial-port enumeration (`super::device`) purely for size; +//! both halves share the SetupAPI/CfgMgr32 helpers re-exported there. + +use std::collections::HashMap; +use std::io; +use std::time::Duration; + +use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{ + CM_Disable_DevNode, CM_Enable_DevNode, CM_Get_DevNode_PropertyW, CM_Get_DevNode_Status, + CM_Get_Device_IDW, CM_Get_Parent, CM_LOCATE_DEVNODE_NORMAL, CM_LOCATE_DEVNODE_PHANTOM, + CM_Locate_DevNodeW, CM_Reenumerate_DevNode, CR_NO_SUCH_DEVINST, CR_NO_SUCH_VALUE, CR_SUCCESS, + DIGCF_ALLCLASSES, DIGCF_DEVICEINTERFACE, DIGCF_PRESENT, HDEVINFO, MAX_DEVICE_ID_LEN, + SP_DEVICE_INTERFACE_DATA, SP_DEVICE_INTERFACE_DETAIL_DATA_W, SP_DEVINFO_DATA, SPDRP_CLASS, + SPDRP_COMPATIBLEIDS, SPDRP_FRIENDLYNAME, SPDRP_LOCATION_INFORMATION, + SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInfo, SetupDiEnumDeviceInterfaces, + SetupDiGetClassDevsW, SetupDiGetDeviceInstanceIdW, SetupDiGetDeviceInterfaceDetailW, + SetupDiGetDeviceRegistryPropertyW, +}; +use windows_sys::Win32::Devices::Properties::{ + DEVPKEY_Device_Class, DEVPKEY_Device_LocationPaths, DEVPROP_TYPE_STRING, + DEVPROP_TYPE_STRING_LIST, +}; +use windows_sys::Win32::Devices::Usb::{ + WINUSB_INTERFACE_HANDLE, WINUSB_SETUP_PACKET, WinUsb_ControlTransfer, WinUsb_Free, + WinUsb_Initialize, +}; +use windows_sys::Win32::Foundation::{ + CloseHandle, FALSE, GENERIC_READ, GENERIC_WRITE, INVALID_HANDLE_VALUE, MAX_PATH, +}; +use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_FLAG_OVERLAPPED, FILE_SHARE_READ, FILE_SHARE_WRITE, + OPEN_EXISTING, +}; +use windows_sys::Win32::System::Registry::{REG_MULTI_SZ, REG_SZ}; +use windows_sys::core::GUID; + +use crate::platform::device::{ + UsbPnpDevice, UsbProblemDevice, UsbResetInterface, is_picotool_reset_compatible_id, +}; +use crate::usb::{UsbRecoveryHealth, UNCLASSED_DEVICE_CLASS}; + +use super::device::{ancestor_ids, as_utf16, location_paths_from_info, parse_usb_port_info}; + +const PICO_RESET_INTERFACE_GUID: GUID = GUID::from_u128(0xbc7398c1_73cd_4cb7_98b8_913a8fca7bf6); +const RESET_REQUEST_BOOTSEL: u8 = 0x01; +// USB_DIR_OUT | USB_TYPE_CLASS | USB_RECIP_INTERFACE. This exactly +// matches picotool's reset-interface request; the endpoint is vendor +// class, but the control request itself is class-scoped. +const RESET_REQUEST_TYPE: u8 = 0x21; + +pub(crate) fn present_usb_problem_devices() -> Vec { + // Enumerate by the `USB` *enumerator* with DIGCF_ALLCLASSES, not by + // the USB *setup class*: a driverless devnode (e.g. a BOOTSEL + // PICOBOOT interface stuck at CM_PROB_FAILED_INSTALL) has no setup + // class at all and is invisible to a class-scoped query, which hid + // exactly the problem interface the FastLED/fbuild#1152 recovery + // request needs to target. + let enumerator: Vec = "USB".encode_utf16().chain(Some(0)).collect(); + let hdi = unsafe { + SetupDiGetClassDevsW( + std::ptr::null(), + enumerator.as_ptr(), + 0, + DIGCF_PRESENT | DIGCF_ALLCLASSES, + ) + }; + if hdi == INVALID_HANDLE_VALUE { + return Vec::new(); + } + + let mut devices = Vec::new(); + let mut index = 0u32; + loop { + let mut info = SP_DEVINFO_DATA { + cbSize: std::mem::size_of::() as u32, + ClassGuid: GUID::from_u128(0), + DevInst: 0, + Reserved: 0, + }; + if unsafe { SetupDiEnumDeviceInfo(hdi, index, &mut info) } == FALSE { + break; + } + index += 1; + + let Some(instance_id) = device_instance_id_from_info(hdi, &info) else { + continue; + }; + if !instance_id.to_ascii_uppercase().starts_with("USB\\") { + continue; + } + let mut status = 0u32; + let mut problem_code = 0u32; + if unsafe { CM_Get_DevNode_Status(&mut status, &mut problem_code, info.DevInst, 0) } + != CR_SUCCESS + || problem_code == 0 + { + continue; + } + + devices.push(UsbProblemDevice { + instance_id, + problem_code, + friendly_name: property_from_info(hdi, &info, SPDRP_FRIENDLYNAME), + location: property_from_info(hdi, &info, SPDRP_LOCATION_INFORMATION), + behind_external_hub: classify_usb_ancestry(info.DevInst), + parent_instance_id: ancestor_ids(info.DevInst).into_iter().next(), + device_class: property_from_info(hdi, &info, SPDRP_CLASS), + location_paths: location_paths_from_info(hdi, &info), + }); + } + unsafe { + SetupDiDestroyDeviceInfoList(hdi); + } + devices +} + +pub(crate) fn present_usb_reset_interfaces() -> Vec { + let device_paths = pico_reset_interface_paths(); + let enumerator: Vec = "USB".encode_utf16().chain(Some(0)).collect(); + let hdi = unsafe { + SetupDiGetClassDevsW( + std::ptr::null(), + enumerator.as_ptr(), + 0, + DIGCF_PRESENT | DIGCF_ALLCLASSES, + ) + }; + if hdi == INVALID_HANDLE_VALUE { + return Vec::new(); + } + + let mut devices = Vec::new(); + let mut index = 0u32; + loop { + let mut info = SP_DEVINFO_DATA { + cbSize: std::mem::size_of::() as u32, + ClassGuid: GUID::from_u128(0), + DevInst: 0, + Reserved: 0, + }; + if unsafe { SetupDiEnumDeviceInfo(hdi, index, &mut info) } == FALSE { + break; + } + index += 1; + + let compatible_ids = string_list_property_from_info(hdi, &info, SPDRP_COMPATIBLEIDS); + if !compatible_ids + .iter() + .any(|value| is_picotool_reset_compatible_id(value)) + { + continue; + } + let mut status = 0u32; + let mut problem_code = 0u32; + if unsafe { CM_Get_DevNode_Status(&mut status, &mut problem_code, info.DevInst, 0) } + != CR_SUCCESS + || problem_code != 0 + { + continue; + } + + let Some(instance_id) = device_instance_id_from_info(hdi, &info) else { + continue; + }; + let Some(parent_instance_id) = ancestor_ids(info.DevInst).into_iter().next() else { + continue; + }; + let Some(identity) = parse_usb_port_info(&instance_id, Some(&parent_instance_id)) else { + continue; + }; + let Some(serial_number) = identity.serial_number else { + continue; + }; + let Some(interface_number) = identity.interface else { + continue; + }; + let Some(device_path) = device_paths.get(&instance_id.to_ascii_uppercase()) else { + continue; + }; + devices.push(UsbResetInterface { + instance_id, + parent_instance_id, + vid: identity.vid, + pid: identity.pid, + serial_number, + device_path: device_path.clone(), + interface_number, + location_paths: location_paths_from_info(hdi, &info), + }); + } + unsafe { + SetupDiDestroyDeviceInfoList(hdi); + } + devices.sort_by(|left, right| left.instance_id.cmp(&right.instance_id)); + devices +} + +pub(crate) fn reset_usb_interface_to_bootsel(interface: &UsbResetInterface) -> io::Result<()> { + let path = as_utf16(&interface.device_path); + let device = unsafe { + CreateFileW( + path.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + std::ptr::null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, + 0, + ) + }; + if device == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + + let mut winusb: WINUSB_INTERFACE_HANDLE = 0; + if unsafe { WinUsb_Initialize(device, &mut winusb) } == FALSE { + let error = io::Error::last_os_error(); + unsafe { + CloseHandle(device); + } + return Err(error); + } + + let setup = WINUSB_SETUP_PACKET { + RequestType: RESET_REQUEST_TYPE, + Request: RESET_REQUEST_BOOTSEL, + Value: 0, + Index: u16::from(interface.interface_number), + Length: 0, + }; + let mut transferred = 0u32; + let transfer_ok = unsafe { + WinUsb_ControlTransfer( + winusb, + setup, + std::ptr::null_mut(), + 0, + &mut transferred, + std::ptr::null(), + ) + }; + let transfer_error = (transfer_ok == FALSE).then(io::Error::last_os_error); + unsafe { + WinUsb_Free(winusb); + CloseHandle(device); + } + if let Some(error) = transfer_error { + // The reset handler does not return. Windows can therefore report + // the expected disconnect as a failed zero-length transfer even + // though the request was accepted. The deployer performs the + // authoritative BOOTSEL wait immediately after this call. + tracing::debug!( + instance_id = %interface.instance_id, + %error, + "Pico reset interface disconnected while handling the BOOTSEL request" + ); + } + Ok(()) +} + +pub(crate) fn inspect_usb_pnp_device( + instance_id: &str, + allow_phantom: bool, +) -> Result { + let devinst = locate(instance_id, allow_phantom)?; + let actual_instance_id = device_id(devinst)?; + let parent_instance_id = parent_id(devinst)?; + let device_class = device_class(devinst)?; + let health = device_health(devinst); + let location_paths = device_location_paths(devinst); + let (vid, pid, serial) = + parse_usb_identity(&actual_instance_id, parent_instance_id.as_deref()).ok_or_else( + || "device does not expose a canonical USB VID/PID identity".to_string(), + )?; + + Ok(UsbPnpDevice { + instance_id: actual_instance_id, + parent_instance_id, + device_class, + vid, + pid, + serial, + health, + location_paths, + }) +} + +pub(crate) fn reenumerate_usb_parent(parent_instance_id: &str) -> Result<(), String> { + let parent = locate(parent_instance_id, false)?; + // SAFETY: `parent` was obtained from Config Manager for the exact + // verified live parent. Flags are zero, requesting no broad scan. + let result = unsafe { CM_Reenumerate_DevNode(parent, 0) }; + (result == CR_SUCCESS) + .then_some(()) + .ok_or_else(|| format!("CM_Reenumerate_DevNode failed ({result})")) +} + +pub(crate) fn restart_usb_device(instance_id: &str) -> Result<(), String> { + let target = locate(instance_id, false)?; + // SAFETY: `target` was revalidated as the exact present problematic + // child by the recovery ladder. The helper never passes a + // parent/hub/controller to this call. + let disabled = unsafe { CM_Disable_DevNode(target, 0) }; + if disabled != CR_SUCCESS { + return Err(format!("CM_Disable_DevNode failed ({disabled})")); + } + // SAFETY: same exact child devinst as the immediately preceding + // disable. No other Config Manager action is performed here. + let enabled = unsafe { CM_Enable_DevNode(target, 0) }; + if enabled == CR_SUCCESS { + return Ok(()); + } + // Best-effort rollback for a transient Config Manager failure. This + // is still the same exact child and does not widen the allowlist; its + // result is retained in the diagnostic rather than silently leaving a + // potentially disabled endpoint behind. + // SAFETY: same exact validated child devinst; this is a bounded + // best-effort re-enable after the first enable reported failure. + let rollback = unsafe { CM_Enable_DevNode(target, 0) }; + Err(format!( + "CM_Enable_DevNode failed ({enabled}); rollback enable returned ({rollback})" + )) +} + +pub(crate) fn usb_pnp_post_operation_poll_attempts() -> usize { + 8 +} + +pub(crate) fn usb_pnp_post_operation_poll_interval() -> Duration { + Duration::from_millis(250) +} + +fn from_utf16(buffer: &[u16]) -> String { + let length = buffer + .iter() + .position(|unit| *unit == 0) + .unwrap_or(buffer.len()); + String::from_utf16_lossy(&buffer[..length]) +} + +fn classify_usb_ancestry(devinst: u32) -> Option { + let ancestors = ancestor_ids(devinst); + let root_index = ancestors + .iter() + .position(|id| id.to_ascii_uppercase().starts_with("USB\\ROOT_HUB"))?; + Some(ancestors[..root_index].iter().any(|id| { + let upper = id.to_ascii_uppercase(); + upper.starts_with("USB\\VID_") && upper.contains("&PID_") + })) +} + +fn pico_reset_interface_paths() -> HashMap { + let hdi = unsafe { + SetupDiGetClassDevsW( + &PICO_RESET_INTERFACE_GUID, + std::ptr::null(), + 0, + DIGCF_PRESENT | DIGCF_DEVICEINTERFACE, + ) + }; + if hdi == INVALID_HANDLE_VALUE { + return HashMap::new(); + } + + let mut paths = HashMap::new(); + let mut index = 0u32; + loop { + let mut interface = SP_DEVICE_INTERFACE_DATA { + cbSize: std::mem::size_of::() as u32, + InterfaceClassGuid: GUID::from_u128(0), + Flags: 0, + Reserved: 0, + }; + if unsafe { + SetupDiEnumDeviceInterfaces( + hdi, + std::ptr::null(), + &PICO_RESET_INTERFACE_GUID, + index, + &mut interface, + ) + } == FALSE + { + break; + } + index += 1; + + let mut required_bytes = 0u32; + unsafe { + SetupDiGetDeviceInterfaceDetailW( + hdi, + &interface, + std::ptr::null_mut(), + 0, + &mut required_bytes, + std::ptr::null_mut(), + ) + }; + if required_bytes < std::mem::size_of::() as u32 { + continue; + } + + // `Vec` provides pointer alignment suitable for the + // variable-sized SetupAPI detail record while still letting the + // API state its required byte count exactly. + let units = (required_bytes as usize).div_ceil(std::mem::size_of::()); + let mut storage = vec![0usize; units]; + let detail = storage + .as_mut_ptr() + .cast::(); + unsafe { + (*detail).cbSize = std::mem::size_of::() as u32; + } + let mut info = SP_DEVINFO_DATA { + cbSize: std::mem::size_of::() as u32, + ClassGuid: GUID::from_u128(0), + DevInst: 0, + Reserved: 0, + }; + if unsafe { + SetupDiGetDeviceInterfaceDetailW( + hdi, + &interface, + detail, + required_bytes, + &mut required_bytes, + &mut info, + ) + } == FALSE + { + continue; + } + let Some(instance_id) = device_instance_id_from_info(hdi, &info) else { + continue; + }; + let path_ptr = unsafe { std::ptr::addr_of!((*detail).DevicePath).cast::() }; + let path_offset = std::mem::offset_of!(SP_DEVICE_INTERFACE_DETAIL_DATA_W, DevicePath); + let max_units = (required_bytes as usize).saturating_sub(path_offset) / 2; + let path_units = unsafe { std::slice::from_raw_parts(path_ptr, max_units) }; + let length = path_units + .iter() + .position(|unit| *unit == 0) + .unwrap_or(path_units.len()); + if length != 0 { + paths.insert( + instance_id.to_ascii_uppercase(), + String::from_utf16_lossy(&path_units[..length]), + ); + } + } + unsafe { + SetupDiDestroyDeviceInfoList(hdi); + } + paths +} + +fn device_instance_id_from_info(hdi: HDEVINFO, info: &SP_DEVINFO_DATA) -> Option { + let mut buffer = [0u16; MAX_DEVICE_ID_LEN as usize]; + let mut required = 0u32; + let ok = unsafe { + SetupDiGetDeviceInstanceIdW( + hdi, + info, + buffer.as_mut_ptr(), + buffer.len() as u32, + &mut required, + ) + }; + if ok == FALSE { + return None; + } + let length = buffer + .iter() + .position(|&unit| unit == 0) + .unwrap_or(buffer.len()); + Some(String::from_utf16_lossy(&buffer[..length])) +} + +fn property_from_info( + hdi: HDEVINFO, + info: &SP_DEVINFO_DATA, + property_id: u32, +) -> Option { + let mut value_type = 0u32; + let mut buffer = [0u16; MAX_PATH as usize]; + let ok = unsafe { + SetupDiGetDeviceRegistryPropertyW( + hdi, + info, + property_id, + &mut value_type, + buffer.as_mut_ptr() as *mut u8, + (buffer.len() * 2) as u32, + std::ptr::null_mut(), + ) + }; + if ok == FALSE || value_type != REG_SZ { + return None; + } + let length = buffer + .iter() + .position(|&unit| unit == 0) + .unwrap_or(buffer.len()); + let value = String::from_utf16_lossy(&buffer[..length]); + (!value.is_empty()).then_some(value) +} + +fn string_list_property_from_info( + hdi: HDEVINFO, + info: &SP_DEVINFO_DATA, + property_id: u32, +) -> Vec { + let mut value_type = 0u32; + let mut required_bytes = 0u32; + unsafe { + SetupDiGetDeviceRegistryPropertyW( + hdi, + info, + property_id, + &mut value_type, + std::ptr::null_mut(), + 0, + &mut required_bytes, + ) + }; + if required_bytes < 2 { + return Vec::new(); + } + let mut buffer = vec![0u16; (required_bytes as usize).div_ceil(2)]; + let ok = unsafe { + SetupDiGetDeviceRegistryPropertyW( + hdi, + info, + property_id, + &mut value_type, + buffer.as_mut_ptr().cast(), + required_bytes, + &mut required_bytes, + ) + }; + if ok == FALSE || value_type != REG_MULTI_SZ { + return Vec::new(); + } + buffer + .split(|unit| *unit == 0) + .take_while(|segment| !segment.is_empty()) + .map(String::from_utf16_lossy) + .filter(|value| !value.is_empty()) + .collect() +} + +fn locate(instance_id: &str, allow_phantom: bool) -> Result { + let mut devinst = 0u32; + let utf16 = instance_id + .encode_utf16() + .chain(Some(0)) + .collect::>(); + let flags = if allow_phantom { + CM_LOCATE_DEVNODE_PHANTOM + } else { + CM_LOCATE_DEVNODE_NORMAL + }; + // SAFETY: `utf16` is NUL-terminated and remains alive for the call; + // `devinst` is writable local storage. + let result = unsafe { CM_Locate_DevNodeW(&mut devinst, utf16.as_ptr(), flags) }; + (result == CR_SUCCESS) + .then_some(devinst) + .ok_or_else(|| format!("CM_Locate_DevNodeW failed ({result})")) +} + +fn device_id(devinst: u32) -> Result { + let mut buffer = [0u16; MAX_DEVICE_ID_LEN as usize]; + // SAFETY: `buffer` is writable local UTF-16 storage sized according to + // the Config Manager API's documented maximum device ID length. + let result = unsafe { + CM_Get_Device_IDW(devinst, buffer.as_mut_ptr(), (buffer.len() - 1) as u32, 0) + }; + if result != CR_SUCCESS { + return Err(format!("CM_Get_Device_IDW failed ({result})")); + } + Ok(from_utf16(&buffer)) +} + +fn parent_id(devinst: u32) -> Result, String> { + let mut parent = 0u32; + // SAFETY: `parent` is writable local storage and `devinst` came from + // Config Manager in the same process. + let result = unsafe { CM_Get_Parent(&mut parent, devinst, 0) }; + if result == CR_NO_SUCH_DEVINST { + return Ok(None); + } + if result != CR_SUCCESS { + return Err(format!("CM_Get_Parent failed ({result})")); + } + device_id(parent).map(Some) +} + +fn device_class(devinst: u32) -> Result { + let mut property_type = 0u32; + let mut buffer = [0u16; 256]; + let mut byte_len = (buffer.len() * std::mem::size_of::()) as u32; + // SAFETY: the property key and all output pointers remain valid for + // the call; the buffer size is provided in bytes as required by CM. + let result = unsafe { + CM_Get_DevNode_PropertyW( + devinst, + &DEVPKEY_Device_Class, + &mut property_type, + buffer.as_mut_ptr().cast(), + &mut byte_len, + 0, + ) + }; + if result == CR_NO_SUCH_VALUE { + // Driverless devnodes (e.g. a BOOTSEL PICOBOOT interface stuck at + // CM_PROB_FAILED_INSTALL) have no Device_Class property at all. + // Report the shared sentinel so identity revalidation treats the + // absence as an exact-match fact (FastLED/fbuild#1152). + return Ok(UNCLASSED_DEVICE_CLASS.to_string()); + } + if result != CR_SUCCESS || property_type != DEVPROP_TYPE_STRING { + return Err(format!( + "CM_Get_DevNode_PropertyW(Device_Class) failed ({result})" + )); + } + Ok(from_utf16(&buffer)) +} + +fn device_health(devinst: u32) -> UsbRecoveryHealth { + let mut status = 0u32; + let mut problem_code = 0u32; + // SAFETY: both output pointers are writable local storage and the + // devinst was returned by Config Manager. + let result = unsafe { CM_Get_DevNode_Status(&mut status, &mut problem_code, devinst, 0) }; + if result == CR_SUCCESS { + if problem_code == 0 { + UsbRecoveryHealth::HealthyPresent + } else { + UsbRecoveryHealth::PresentProblem { problem_code } + } + } else if result == CR_NO_SUCH_DEVINST { + UsbRecoveryHealth::Phantom { problem_code: None } + } else { + UsbRecoveryHealth::Unknown + } +} + +fn device_location_paths(devinst: u32) -> Vec { + let mut property_type = 0u32; + let mut byte_len = 0u32; + unsafe { + CM_Get_DevNode_PropertyW( + devinst, + &DEVPKEY_Device_LocationPaths, + &mut property_type, + std::ptr::null_mut(), + &mut byte_len, + 0, + ) + }; + if byte_len < 2 { + return Vec::new(); + } + let mut buffer = vec![0u16; (byte_len as usize).div_ceil(2)]; + let result = unsafe { + CM_Get_DevNode_PropertyW( + devinst, + &DEVPKEY_Device_LocationPaths, + &mut property_type, + buffer.as_mut_ptr().cast(), + &mut byte_len, + 0, + ) + }; + if result != CR_SUCCESS || property_type != DEVPROP_TYPE_STRING_LIST { + return Vec::new(); + } + buffer + .split(|unit| *unit == 0) + .take_while(|segment| !segment.is_empty()) + .map(String::from_utf16_lossy) + .filter(|path| !path.is_empty()) + .collect() +} + +fn parse_usb_identity( + instance_id: &str, + parent_instance_id: Option<&str>, +) -> Option<(u16, u16, Option)> { + fn parse(id: &str) -> Option<(u16, u16, Option)> { + let mut parts = id.split('\\'); + if !parts.next()?.eq_ignore_ascii_case("USB") { + return None; + } + let hardware = parts.next()?.to_ascii_uppercase(); + let vid_start = hardware.find("VID_")? + 4; + let pid_start = hardware.find("PID_")? + 4; + let vid = u16::from_str_radix(hardware.get(vid_start..vid_start + 4)?, 16).ok()?; + let pid = u16::from_str_radix(hardware.get(pid_start..pid_start + 4)?, 16).ok()?; + let serial = parts + .next() + .filter(|value| !value.is_empty()) + .map(str::to_string); + Some((vid, pid, serial)) + } + + let (vid, pid, child_serial) = parse(instance_id)?; + let parent_serial = + parent_instance_id + .and_then(parse) + .and_then(|(parent_vid, parent_pid, serial)| { + (parent_vid == vid && parent_pid == pid) + .then_some(serial) + .flatten() + }); + Some((vid, pid, parent_serial.or(child_serial))) +} diff --git a/crates/fbuild-serial/Cargo.toml b/crates/fbuild-serial/Cargo.toml index cbba3de5..7140cfa3 100644 --- a/crates/fbuild-serial/Cargo.toml +++ b/crates/fbuild-serial/Cargo.toml @@ -26,21 +26,6 @@ futures = { workspace = true } async-trait = { workspace = true } regex = { workspace = true } -# Windows serial-port enumeration that (unlike upstream serialport) does not -# drop devnodes with a non-OK PnP problem status — see `src/ports.rs`. -# FastLED/fbuild#962. Version matches serialport 4.9's windows-sys pin. -[target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.52", features = [ - "Win32_Devices_DeviceAndDriverInstallation", - "Win32_Devices_Properties", - "Win32_Devices_Usb", - "Win32_Foundation", - "Win32_Security", - "Win32_Storage_FileSystem", - "Win32_System_IO", - "Win32_System_Registry", -] } - [dev-dependencies] tempfile = { workspace = true } tracing-test = { workspace = true } diff --git a/crates/fbuild-serial/src/port_class.rs b/crates/fbuild-serial/src/port_class.rs index 34d54bb9..390cde73 100644 --- a/crates/fbuild-serial/src/port_class.rs +++ b/crates/fbuild-serial/src/port_class.rs @@ -1,98 +1,17 @@ -//! Platform-native detection of how a serial port is bound to its OS driver. +//! Caller-facing kernel-driver classification of a serial port. //! -//! FastLED/fbuild#895. `serialport-rs`'s enumeration gives us only -//! `(vid, pid, serial_number, manufacturer, product)`. Our -//! [`crate::boards::family_for_vid_pid`] table covers ~7 well-known -//! vendor ranges (Espressif `0x303A:*`, FTDI/CP210x/CH340, Arduino, -//! RP2040, NXP debug probes, Teensy bootloader). Everything outside -//! that table returns `None` and the caller falls back to -//! `(true, true)` — the universal CDC-ACM-bridge-host-ready default. -//! -//! The fallback is unsafe for **any unknown CDC native USB device**. -//! Off-brand ESP32-S3 dev boards (custom OEM VID/PID), uncatalogued -//! native-USB devices, or any future hardware not yet in the VID/PID -//! table all get `(true, true)` — which pulses DTR/RTS in a way that -//! native-USB CDC firmware reads as a reset, clobbering the running -//! sketch on every attach. -//! -//! The host OS already knows whether the port is CDC-class or -//! chip-specific-bridge-class — it picks a different kernel driver -//! based on the USB descriptor's `bInterfaceClass`. This module pulls -//! that signal out per platform and returns a [`PortKernelClass`] that -//! the existing `BoardFamily` fallback chain can consult before -//! defaulting. -//! -//! # Library strategy -//! -//! Researched first: -//! -//! - `serialport-rs` — uses libudev on Linux internally but doesn't -//! surface class info; would need an upstream patch. -//! - `udev` crate — clean wrapper but requires libudev at runtime. -//! fbuild deliberately avoids that runtime dependency. -//! - `nusb` / `rusb` — enumerate USB devices but can't link a -//! `/dev/ttyACM0` path back to its USB device; the linking step is -//! itself OS-specific. -//! - `usb-enumeration` — returns the same fields as serialport-rs. -//! -//! No library bridges "serial port name → kernel driver class" -//! without the OS-specific linking step. So this module reads the OS -//! authoritative source directly: -//! -//! - **Linux**: pure `std::fs::read_link` on -//! `/sys/class/tty//device/driver` — no extra deps. -//! - **macOS**: device-node naming pattern (the IOUSBHostFamily / -//! vendor-driver naming convention is the canonical signal here). -//! No `IOKit` query needed for the cases this module needs to -//! distinguish. -//! - **Windows**: deferred. SetupDi via `windows-sys` is the right -//! path; documented as a follow-up so this PR stays focused on -//! Linux/macOS where the gain is concrete. Windows returns `None` -//! here, which preserves the existing fallback chain — no -//! regression. -//! -//! # Safety contract -//! -//! Any detection failure for any reason returns `None`. Callers MUST -//! fall through to their existing default behavior on `None` — this -//! module is purely additive. No change to existing call sites' -//! behavior when detection fails (sysfs unmounted, port already -//! disconnected, malformed path, container without /sys, etc.). +//! FastLED/fbuild#895. The detection mechanics (Linux sysfs driver-symlink +//! reads, macOS device-node naming, Windows deferred SetupDi) live behind +//! [`fbuild_core::platform::device::detect_serial_kernel_driver`]; this +//! module keeps only the caller-facing name and enum so existing consumers +//! (`boards.rs`, the daemon device manager, `port scan`) are unchanged. /// The kernel's view of which driver class instantiated this port. /// -/// Surfaced from the host OS's authoritative source per platform. -/// Returned only when we can confidently classify; ambiguous cases -/// yield `None` at the top level so callers can keep their existing -/// defaults. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum PortKernelClass { - /// The kernel created this port via its CDC-ACM stack: - /// - Linux: `cdc_acm.ko` driver (sysfs `/device/driver` - /// symlink-target = `cdc_acm`) - /// - macOS: IOUSBHostFamily's CDC-ACM stack - /// (`/dev/cu.usbmodem*` / `/dev/tty.usbmodem*`) - /// - Windows (deferred): driver service `usbser` - /// - /// Typical devices: ESP32-S3 / -C3 native USB, Arduino Leonardo / - /// Micro / Nano Every / native USB, Teensy 3.x/4.x USB-Serial, - /// RP2040 stdio_usb, SAMD21/SAMD51 native USB. - CdcAcm, - - /// The kernel created this port via a chip-specific USB-serial - /// bridge driver: - /// - Linux: `usbserial.ko` umbrella (`ftdi_sio`, `cp210x`, - /// `ch341`, `pl2303`, etc.) — `/dev/ttyUSB*` - /// - macOS: vendor driver names — `/dev/cu.usbserial-*`, - /// `/dev/cu.SLAB_USBtoUART*`, `/dev/cu.wchusbserial*`, - /// `/dev/cu.PL2303-*` - /// - Windows (deferred): driver services `FTDIBUS`, `silabser`, - /// `ch341ser`, etc. - /// - /// Typical devices: ESP32-WROOM behind FTDI/CP210x autoreset, - /// classic Arduino UNO/Mega behind FT232/CH340. - UsbSerialBridge, -} +/// Re-exported from the platform facade; see the facade docs for the +/// per-platform detection strategy and the safety contract (ambiguous +/// cases yield `None`, callers fall through to their existing defaults). +pub use fbuild_core::platform::device::KernelDriverClass as PortKernelClass; /// Detect the port's kernel-side driver class. /// @@ -102,403 +21,21 @@ pub enum PortKernelClass { /// default on `None` — this function is purely additive. #[must_use] pub fn detect_port_kernel_class(port_name: &str) -> Option { - #[cfg(target_os = "linux")] - { - linux::detect(port_name) - } - #[cfg(target_os = "macos")] - { - macos::detect(port_name) - } - #[cfg(target_os = "windows")] - { - // Windows path not yet implemented (#895 follow-up: SetupDi - // via windows-sys to read SPDRP_SERVICE). Returning None here - // preserves the existing fallback chain so we cannot regress - // Windows behavior with this PR. - let _ = port_name; - None - } - #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] - { - let _ = port_name; - None - } -} - -#[cfg(target_os = "linux")] -mod linux { - use super::PortKernelClass; - use std::path::Path; - - pub(super) fn detect(port_name: &str) -> Option { - detect_with_sysfs_root(port_name, Path::new("/sys")) - } - - /// Linux implementation, factored on `sysfs_root` so unit tests - /// can point at a temp dir holding a fake sysfs. - pub(crate) fn detect_with_sysfs_root( - port_name: &str, - sysfs_root: &Path, - ) -> Option { - let bare = port_name_stem(port_name)?; - - // 1. Authoritative path: read the driver symlink. - // /sys/class/tty//device/driver -> a driver dir - // e.g. -> .../bus/usb-serial/drivers/cdc_acm - // -> .../bus/usb-serial/drivers/ftdi_sio - // -> .../bus/usb-serial/drivers/cp210x - if let Some(driver_name) = read_driver_symlink_name(sysfs_root, bare) { - return Some(classify_driver(&driver_name)); - } - - // 2. Fallback: the kernel's device-node naming convention. - // `ttyACM*` is created by `cdc_acm.ko`; `ttyUSB*` by - // `usbserial.ko`. If sysfs isn't readable for some reason - // (container, permissions), the name is still a strong - // signal because the kernel picks the prefix based on - // which driver claimed the device. - classify_by_devnode_name(bare) - } - - fn read_driver_symlink_name(sysfs_root: &Path, port_stem: &str) -> Option { - let driver_link = sysfs_root - .join("class") - .join("tty") - .join(port_stem) - .join("device") - .join("driver"); - let target = std::fs::read_link(&driver_link).ok()?; - target - .file_name() - .and_then(|s| s.to_str()) - .map(|s| s.to_string()) - } - - /// Strip `/dev/` (or `/devices/` in some odd configurations) and - /// return the bare port name, e.g. `ttyACM0`. - pub(super) fn port_name_stem(port_name: &str) -> Option<&str> { - if let Some(stem) = port_name.strip_prefix("/dev/") { - return Some(stem); - } - // Already a bare name (passed from a test or a sysfs walker). - if !port_name.contains('/') { - return Some(port_name); - } - // Pull the last path segment as a last resort. - port_name.rsplit('/').next() - } - - /// Classify a driver name pulled from the sysfs symlink. - /// - /// `cdc_acm` is the unambiguous CDC-ACM driver. The rest of the - /// usb-serial bridge drivers are bridge chips; the canonical set - /// is what's enabled in mainline Linux's - /// `drivers/usb/serial/*.c`. Anything unknown defaults to bridge - /// because the kernel only invokes `cdc_acm` for actual CDC class - /// — every other driver is a chip-specific bridge by construction. - pub(crate) fn classify_driver(driver_name: &str) -> PortKernelClass { - match driver_name { - "cdc_acm" => PortKernelClass::CdcAcm, - _ => PortKernelClass::UsbSerialBridge, - } - } - - /// Fall back to device-node naming when sysfs isn't readable. - pub(crate) fn classify_by_devnode_name(port_stem: &str) -> Option { - if port_stem.starts_with("ttyACM") { - return Some(PortKernelClass::CdcAcm); - } - if port_stem.starts_with("ttyUSB") { - return Some(PortKernelClass::UsbSerialBridge); - } - None - } - - #[cfg(test)] - pub(super) fn port_name_stem_for_tests(port_name: &str) -> Option<&str> { - port_name_stem(port_name) - } + fbuild_core::platform::device::detect_serial_kernel_driver(port_name) } -#[cfg(target_os = "macos")] -mod macos { - use super::PortKernelClass; - - pub(super) fn detect(port_name: &str) -> Option { - classify_macos_devnode(port_name) - } - - /// macOS device-node naming is set per-driver and is the canonical - /// signal: - /// - /// - `IOUSBHostFamily`'s CDC-ACM stack publishes `/dev/cu.usbmodem*` - /// and `/dev/tty.usbmodem*`. The kernel picks this when the - /// device exposes the CDC class. - /// - Vendor drivers publish their own prefixes: - /// - `FTDIUSBSerialDriver` -> `cu.usbserial-*` - /// - `SiLabsUSBDriver` (CP210x) -> `cu.SLAB_USBtoUART*` (and on - /// newer macOS, `cu.usbserial-*` via the Apple-shipped - /// `AppleUSBCHCOM` driver) - /// - WCH (CH340/CH341) -> `cu.wchusbserial*` - /// - Prolific (PL2303) -> `cu.PL2303-*` or `cu.usbserial-*` - /// - /// Any name we don't recognize returns `None` so the caller falls - /// back to its existing default — same safety contract as the - /// rest of this module. - pub(crate) fn classify_macos_devnode(port_name: &str) -> Option { - // Strip `/dev/` prefix if present, then strip the cu./tty. - // disambiguation prefix. - let bare = port_name.strip_prefix("/dev/").unwrap_or(port_name); - let suffix = bare - .strip_prefix("cu.") - .or_else(|| bare.strip_prefix("tty.")) - .unwrap_or(bare); - - if suffix.starts_with("usbmodem") { - return Some(PortKernelClass::CdcAcm); - } - if suffix.starts_with("usbserial-") - || suffix.starts_with("usbserial.") - || suffix.starts_with("SLAB_USBtoUART") - || suffix.starts_with("wchusbserial") - || suffix.starts_with("PL2303") - { - return Some(PortKernelClass::UsbSerialBridge); - } - None - } -} - -// ---------- Cross-platform unit tests for pure-function logic ---------- - #[cfg(test)] mod tests { use super::*; - // --- Linux --- - - #[cfg(target_os = "linux")] - mod linux_tests { - use super::*; - use std::fs; - use tempfile::tempdir; - - // Build a fake `/sys/class/tty//device/driver` symlink - // pointing at a fake driver dir under a tmp root, then ask - // detect_with_sysfs_root to classify it. - fn build_fake_sysfs_tree(sysfs_root: &std::path::Path, port_stem: &str, driver_name: &str) { - let device_dir = sysfs_root - .join("class") - .join("tty") - .join(port_stem) - .join("device"); - fs::create_dir_all(&device_dir).unwrap(); - // The driver target dir must exist (the actual sysfs has - // it; read_link only follows the symlink, but having a - // real target dir matches the production shape). - let driver_dir = sysfs_root - .join("bus") - .join("usb-serial") - .join("drivers") - .join(driver_name); - fs::create_dir_all(&driver_dir).unwrap(); - fbuild_core::platform::fs::symlink_dir(&driver_dir, &device_dir.join("driver")) - .unwrap(); - } - - #[test] - fn linux_sysfs_cdc_acm_driver_is_cdc() { - let tmp = tempdir().unwrap(); - build_fake_sysfs_tree(tmp.path(), "ttyACM0", "cdc_acm"); - assert_eq!( - linux::detect_with_sysfs_root("/dev/ttyACM0", tmp.path()), - Some(PortKernelClass::CdcAcm) - ); - } - - #[test] - fn linux_sysfs_ftdi_driver_is_bridge() { - let tmp = tempdir().unwrap(); - build_fake_sysfs_tree(tmp.path(), "ttyUSB0", "ftdi_sio"); - assert_eq!( - linux::detect_with_sysfs_root("/dev/ttyUSB0", tmp.path()), - Some(PortKernelClass::UsbSerialBridge) - ); - } - - #[test] - fn linux_sysfs_cp210x_driver_is_bridge() { - let tmp = tempdir().unwrap(); - build_fake_sysfs_tree(tmp.path(), "ttyUSB1", "cp210x"); - assert_eq!( - linux::detect_with_sysfs_root("/dev/ttyUSB1", tmp.path()), - Some(PortKernelClass::UsbSerialBridge) - ); - } - - #[test] - fn linux_sysfs_ch341_driver_is_bridge() { - let tmp = tempdir().unwrap(); - build_fake_sysfs_tree(tmp.path(), "ttyUSB2", "ch341"); - assert_eq!( - linux::detect_with_sysfs_root("/dev/ttyUSB2", tmp.path()), - Some(PortKernelClass::UsbSerialBridge) - ); - } - - #[test] - fn linux_devnode_name_acm_is_cdc() { - // No sysfs entry exists at all → fall back to devnode - // name. ttyACM* is created only by cdc_acm so this is - // reliable. - let tmp = tempdir().unwrap(); - assert_eq!( - linux::detect_with_sysfs_root("/dev/ttyACM7", tmp.path()), - Some(PortKernelClass::CdcAcm) - ); - } - - #[test] - fn linux_devnode_name_usb_is_bridge() { - let tmp = tempdir().unwrap(); - assert_eq!( - linux::detect_with_sysfs_root("/dev/ttyUSB3", tmp.path()), - Some(PortKernelClass::UsbSerialBridge) - ); - } - - #[test] - fn linux_unrelated_devnode_returns_none() { - // ttyS0 (real UART, not USB) shouldn't classify as - // either — the kernel didn't bind it via cdc_acm or - // usbserial. - let tmp = tempdir().unwrap(); - assert_eq!( - linux::detect_with_sysfs_root("/dev/ttyS0", tmp.path()), - None - ); - } - - #[test] - fn linux_classify_driver_unknown_falls_back_to_bridge() { - // A new bridge driver landing in mainline Linux (e.g. - // qcserial, mos7720) should classify as a bridge because - // anything not literally `cdc_acm` is by construction a - // chip-specific bridge. - assert_eq!( - linux::classify_driver("qcserial"), - PortKernelClass::UsbSerialBridge - ); - assert_eq!( - linux::classify_driver("pl2303"), - PortKernelClass::UsbSerialBridge - ); - assert_eq!( - linux::classify_driver("totally-not-a-driver"), - PortKernelClass::UsbSerialBridge - ); - } - - #[test] - fn linux_port_name_stem_strips_dev_prefix() { - assert_eq!( - linux::port_name_stem_for_tests("/dev/ttyACM0"), - Some("ttyACM0") - ); - assert_eq!(linux::port_name_stem_for_tests("ttyACM0"), Some("ttyACM0")); - assert_eq!( - linux::port_name_stem_for_tests("/some/oddpath/ttyUSB2"), - Some("ttyUSB2") - ); - } - } - - // --- macOS --- - - #[cfg(target_os = "macos")] - mod macos_tests { - use super::*; - - #[test] - fn macos_usbmodem_is_cdc() { - assert_eq!( - macos::classify_macos_devnode("/dev/cu.usbmodem14101"), - Some(PortKernelClass::CdcAcm) - ); - assert_eq!( - macos::classify_macos_devnode("/dev/tty.usbmodem14101"), - Some(PortKernelClass::CdcAcm) - ); - } - - #[test] - fn macos_ftdi_usbserial_is_bridge() { - assert_eq!( - macos::classify_macos_devnode("/dev/cu.usbserial-A1234567"), - Some(PortKernelClass::UsbSerialBridge) - ); - assert_eq!( - macos::classify_macos_devnode("/dev/tty.usbserial-FTDI"), - Some(PortKernelClass::UsbSerialBridge) - ); - } - - #[test] - fn macos_slab_cp210x_is_bridge() { - assert_eq!( - macos::classify_macos_devnode("/dev/cu.SLAB_USBtoUART"), - Some(PortKernelClass::UsbSerialBridge) - ); - } - - #[test] - fn macos_wch_ch340_is_bridge() { - assert_eq!( - macos::classify_macos_devnode("/dev/cu.wchusbserial1410"), - Some(PortKernelClass::UsbSerialBridge) - ); - } - - #[test] - fn macos_pl2303_is_bridge() { - assert_eq!( - macos::classify_macos_devnode("/dev/cu.PL2303-XYZ"), - Some(PortKernelClass::UsbSerialBridge) - ); - } - - #[test] - fn macos_bare_name_without_dev_prefix() { - // Caller passed in a bare name — should still work. - assert_eq!( - macos::classify_macos_devnode("cu.usbmodem1101"), - Some(PortKernelClass::CdcAcm) - ); - } - - #[test] - fn macos_unrelated_returns_none() { - // /dev/cu.Bluetooth-Incoming-Port shouldn't be classified - // as either CDC or bridge — it's not USB. - assert_eq!( - macos::classify_macos_devnode("/dev/cu.Bluetooth-Incoming-Port"), - None - ); - // Stray random name returns None too. - assert_eq!(macos::classify_macos_devnode("/dev/cu.random-thing"), None); - } - } - - // --- Cross-platform: Windows path is a no-op for now --- - - #[cfg(target_os = "windows")] #[test] - fn windows_returns_none_for_now() { - // Documented: Windows SetupDi detection is a follow-up. Until - // it lands, the path returns None so the existing fallback - // chain stays in charge — no regression risk. + fn shim_delegates_to_the_platform_facade() { + // Pure delegation: whatever the facade says for a name that no + // host classifies (a Windows-style COM name on any host, and a + // nonexistent tty on Linux/macOS) is exactly None. Pins the + // shim's contract without duplicating the per-OS tests that + // live next to each selected implementation. assert_eq!(detect_port_kernel_class("COM3"), None); - assert_eq!(detect_port_kernel_class("COM42"), None); + assert_eq!(detect_port_kernel_class("/dev/does-not-exist-ttyX"), None); } } diff --git a/crates/fbuild-serial/src/ports.rs b/crates/fbuild-serial/src/ports.rs index 14265f10..517f36d1 100644 --- a/crates/fbuild-serial/src/ports.rs +++ b/crates/fbuild-serial/src/ports.rs @@ -1,10 +1,13 @@ //! Blessed cross-platform serial-port enumeration for fbuild. //! -//! On non-Windows platforms this delegates straight to -//! [`serialport::available_ports`]. On Windows it replaces the upstream -//! enumeration so that serial ports whose PnP devnode reports a **non-OK -//! problem status** (`CM_PROB_*`, phantom, composite `MI_00` interfaces) -//! are still listed. +//! Enumeration mechanics live behind +//! [`fbuild_core::platform::device::available_serial_ports`] — on Windows that +//! is a SetupAPI fork which (unlike upstream `serialport`) lists serial ports +//! whose PnP devnode reports a **non-OK problem status** (`CM_PROB_*`, +//! phantom, composite `MI_00` interfaces); elsewhere it delegates straight to +//! [`serialport::available_ports`]. This module keeps only the caller-facing +//! policy layer: flattening host observations into [`PortHealth`], and the +//! Linux sysfs health enrichment. //! //! ## Why fbuild forks the Windows enumeration //! @@ -15,12 +18,20 @@ //! composite `MI_00` interfaces that commonly report `Status = Unknown`, so //! upstream drops **every** Teensy COM port — a physically-attached Teensy is //! invisible to `fbuild port scan` and to the deploy port-discovery snapshot. -//! FastLED/fbuild#962. -//! -//! This module is a fork of serialport's `windows/enumerate.rs` (MIT/Apache-2.0) -//! with the single behavioural change of **not filtering on the problem code**, -//! plus population of the composite-interface index (`MI_xx`) so callers can -//! disambiguate a Teensy's Serial vs Serial+MIDI functions. +//! FastLED/fbuild#962. The fork lives at +//! `crates/fbuild-core/src/platform/windows/device.rs` (MIT/Apache-2.0, +//! upstream serialport) with the single behavioural change of **not filtering +//! on the problem code**, plus population of the composite-interface index +//! (`MI_xx`) so callers can disambiguate a Teensy's Serial vs Serial+MIDI +//! functions. + +use fbuild_core::platform::device::{DevNodeObservation, SerialPortFacts, SerialPortTypeFacts}; + +// The neutral fact records keep their historical caller-facing paths +// (`fbuild_serial::ports::UsbProblemDevice` etc.) — fbuild-deploy and the +// CLI construct them through this module. +pub use fbuild_core::platform::device::is_picotool_reset_compatible_id; +pub use fbuild_core::platform::device::{UsbProblemDevice, UsbResetInterface}; /// Current host health for an enumerated serial endpoint. /// @@ -122,42 +133,28 @@ impl DetectedPort { } } -// The classification pipeline below is fed by the Windows PnP enumeration in -// `imp` and exercised cross-platform by `health_tests`; on non-Windows, -// non-test builds it has no production caller, which `-D warnings` would -// otherwise turn into a hard error. -#[cfg_attr(not(windows), allow(dead_code))] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum PnpObservation { - Present { status: u32, problem_code: u32 }, - Phantom, - Unknown, -} - -#[cfg_attr(not(windows), allow(dead_code))] -fn classify_port_health(observation: PnpObservation) -> PortHealth { +fn classify_port_health(observation: DevNodeObservation) -> PortHealth { match observation { - PnpObservation::Present { + DevNodeObservation::Present { status: _, problem_code: 0, } => PortHealth::HealthyPresent, - PnpObservation::Present { + DevNodeObservation::Present { status, problem_code, } => PortHealth::PresentProblem { problem_code, status: Some(status), }, - PnpObservation::Phantom => PortHealth::Phantom { + DevNodeObservation::Phantom => PortHealth::Phantom { problem_code: None, status: None, }, - PnpObservation::Unknown => PortHealth::Unknown, + DevNodeObservation::Unknown => PortHealth::Unknown, } } -#[cfg_attr(not(windows), allow(dead_code))] -fn health_for_endpoint(observation: PnpObservation, is_usb: bool) -> PortHealth { +fn health_for_endpoint(observation: DevNodeObservation, is_usb: bool) -> PortHealth { if is_usb { classify_port_health(observation) } else { @@ -168,6 +165,29 @@ fn health_for_endpoint(observation: PnpObservation, is_usb: bool) -> PortHealth } } +/// Map neutral host facts back onto the upstream `serialport` shape that +/// [`DetectedPort`] exposes to callers. +fn port_info_from_facts(facts: &SerialPortFacts) -> serialport::SerialPortInfo { + let port_type = match &facts.port_type { + SerialPortTypeFacts::Usb(usb) => { + serialport::SerialPortType::UsbPort(serialport::UsbPortInfo { + vid: usb.vid, + pid: usb.pid, + serial_number: usb.serial_number.clone(), + manufacturer: usb.manufacturer.clone(), + product: usb.product.clone(), + interface: usb.interface, + }) + } + // PCI/Bluetooth endpoints carry no facts on any host today. + SerialPortTypeFacts::Unknown => serialport::SerialPortType::Unknown, + }; + serialport::SerialPortInfo { + port_name: facts.port_name.clone(), + port_type, + } +} + /// Enumerate every serial port currently visible to the OS. /// /// Unlike [`serialport::available_ports`], on Windows this includes ports @@ -179,27 +199,33 @@ fn health_for_endpoint(observation: PnpObservation, is_usb: bool) -> PortHealth /// `PortHealth::Unknown`, matching current (pre-#1091) behavior. macOS is /// unchanged (`Unknown`) — see the module doc comment on `sysfs_usb` for why. pub fn available_ports() -> serialport::Result> { - #[cfg(windows)] - { - imp::available_ports() - } - #[cfg(not(windows))] - { - let ports: Vec = serialport::available_ports()? - .into_iter() - .map(DetectedPort::unknown) - .collect(); - // Only Linux mutates the list (sysfs health enrichment). Binding the - // `mut` inside the cfg keeps non-Linux unix targets — macOS, the BSDs — - // from tripping `-D unused-mut`. - #[cfg(target_os = "linux")] - let ports = { - let mut ports = ports; - enrich_linux_port_health(&mut ports); - ports - }; - Ok(ports) - } + // `serialport::Error: From` carries the OS error through. + let facts = fbuild_core::platform::device::available_serial_ports()?; + let ports: Vec = facts + .iter() + .map(|facts| { + let is_usb = matches!(facts.port_type, SerialPortTypeFacts::Usb(_)); + DetectedPort { + info: port_info_from_facts(facts), + health: health_for_endpoint(facts.observation, is_usb), + instance_id: facts.instance_id.clone(), + parent_instance_id: facts.parent_instance_id.clone(), + ancestor_instance_ids: facts.ancestor_instance_ids.clone(), + location_paths: facts.location_paths.clone(), + } + }) + .collect(); + // Only Linux mutates the list (sysfs health enrichment). macOS and other + // unix targets keep every record at its enumeration-time health. Binding + // the `mut` inside the cfg keeps non-Linux unix targets from tripping + // `-D unused-mut`. + #[cfg(target_os = "linux")] + let ports = { + let mut ports = ports; + enrich_linux_port_health(&mut ports); + ports + }; + Ok(ports) } /// Overwrite `PortHealth::Unknown` entries with a concrete sysfs-derived @@ -207,7 +233,9 @@ pub fn available_ports() -> serialport::Result> { /// has no opinion about (non-USB ttys, ambiguous state) are left untouched. #[cfg(target_os = "linux")] fn enrich_linux_port_health(ports: &mut [DetectedPort]) { - let root = crate::sysfs_usb::live_root(); + let Some(root) = crate::sysfs_usb::live_root() else { + return; + }; for port in ports.iter_mut() { let Some(tty_name) = port.info.port_name.strip_prefix("/dev/") else { continue; @@ -219,73 +247,13 @@ fn enrich_linux_port_health(ports: &mut [DetectedPort]) { } } -/// A USB device that Windows has instantiated but could not start normally. -/// -/// These nodes may not have a usable VID/PID or serial number (for example, -/// Windows reports a descriptor failure as `VID_0000&PID_0002`). The result -/// is deliberately diagnostic only: callers must not treat one of these -/// nodes as a particular target board. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UsbProblemDevice { - pub instance_id: String, - pub problem_code: u32, - pub friendly_name: Option, - pub location: Option, - /// `Some(true)` means a USB device ancestor exists before the root hub; - /// `Some(false)` means the node reaches a root hub directly; `None` means - /// the host could not provide enough ancestry to classify it. - pub behind_external_hub: Option, - /// Immediate parent instance ID, when Config Manager can prove one. - /// Needed to compose an exact-device `UsbRecoveryRequest` for a problem - /// interface devnode (FastLED/fbuild#1152). - pub parent_instance_id: Option, - /// Windows device class (e.g. `Ports`, `USB`); `None` for driverless - /// devnodes that never got a class assigned. - pub device_class: Option, - /// Windows physical USB location paths for exact device-local - /// correlation. Human-readable `location` is not stable enough for this. - pub location_paths: Vec, -} - -/// A healthy, present Pico SDK application-mode USB reset interface. -/// -/// Arduino-Pico exposes this WinUSB function when `ENABLE_PICOTOOL_USB` is -/// enabled. It remains independently addressable when the sibling CDC -/// interface is missing or unusable, which lets the RP deployer recover the -/// exact application device without opening a stale COM endpoint. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UsbResetInterface { - pub instance_id: String, - pub parent_instance_id: String, - pub vid: u16, - pub pid: u16, - pub serial_number: String, - /// WinUSB device-interface path for the fixed Pico SDK reset GUID. - pub device_path: String, - /// USB interface number carried by the composite `MI_xx` devnode. - pub interface_number: u8, - pub location_paths: Vec, -} - -#[cfg(any(windows, test))] -fn is_picotool_reset_compatible_id(value: &str) -> bool { - value.eq_ignore_ascii_case("USB\\Class_ff&SubClass_00&Prot_01") -} - /// Best-effort enumeration of healthy Pico SDK application reset interfaces. /// /// Windows exposes the function as the standard Raspberry Pi reset-interface /// compatible ID. Other hosts currently return an empty list; their normal /// libusb/picotool path remains unchanged. pub fn present_usb_reset_interfaces() -> Vec { - #[cfg(windows)] - { - imp::present_usb_reset_interfaces() - } - #[cfg(not(windows))] - { - Vec::new() - } + fbuild_core::platform::device::present_usb_reset_interfaces() } /// Ask one exact Pico SDK WinUSB reset interface to enter BOOTSEL mode. @@ -296,18 +264,7 @@ pub fn present_usb_reset_interfaces() -> Vec { /// is the normal successful shape of the no-data control transfer, so the /// deployer confirms success by waiting for the target BOOTSEL transport. pub fn reset_usb_interface_to_bootsel(interface: &UsbResetInterface) -> std::io::Result<()> { - #[cfg(windows)] - { - imp::reset_usb_interface_to_bootsel(interface) - } - #[cfg(not(windows))] - { - let _ = interface; - Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "the native Pico reset interface is currently implemented only on Windows", - )) - } + fbuild_core::platform::device::reset_usb_interface_to_bootsel(interface) } /// Best-effort enumeration of present USB devnodes with a non-zero Windows @@ -320,17 +277,10 @@ pub fn reset_usb_interface_to_bootsel(interface: &UsbResetInterface) -> std::io: /// `present_usb_problem_devices_linux` instead (not an intra-doc link: that /// item is `cfg(target_os = "linux")`, so it does not exist to resolve /// against when these docs are built on a non-Linux host). macOS has no -/// equivalent -/// implemented yet (IOKit work is out of scope without a macOS host). +/// equivalent implemented yet (IOKit work is out of scope without a macOS +/// host). pub fn present_usb_problem_devices() -> Vec { - #[cfg(windows)] - { - imp::present_usb_problem_devices() - } - #[cfg(not(windows))] - { - Vec::new() - } + fbuild_core::platform::device::present_usb_problem_devices() } /// Linux sibling of [`present_usb_problem_devices`]: `sysfs`-derived USB @@ -340,7 +290,10 @@ pub fn present_usb_problem_devices() -> Vec { /// [`available_ports`]'s Linux enrichment). #[cfg(target_os = "linux")] pub fn present_usb_problem_devices_linux() -> Vec { - crate::sysfs_usb::linux_usb_problem_devices_from_root(&crate::sysfs_usb::live_root()) + match crate::sysfs_usb::live_root() { + Some(root) => crate::sysfs_usb::linux_usb_problem_devices_from_root(&root), + None => Vec::new(), + } } #[cfg(test)] @@ -395,14 +348,14 @@ mod health_tests { #[test] fn classifies_healthy_problem_phantom_and_unknown_endpoints() { assert_eq!( - classify_port_health(PnpObservation::Present { + classify_port_health(DevNodeObservation::Present { status: 0, problem_code: 0, }), PortHealth::HealthyPresent ); assert_eq!( - classify_port_health(PnpObservation::Present { + classify_port_health(DevNodeObservation::Present { status: 0x1234, problem_code: 31, }), @@ -412,19 +365,19 @@ mod health_tests { } ); assert_eq!( - classify_port_health(PnpObservation::Phantom), + classify_port_health(DevNodeObservation::Phantom), PortHealth::Phantom { problem_code: None, status: None, } ); assert_eq!( - classify_port_health(PnpObservation::Unknown), + classify_port_health(DevNodeObservation::Unknown), PortHealth::Unknown ); assert_eq!( health_for_endpoint( - PnpObservation::Present { + DevNodeObservation::Present { status: 0, problem_code: 31, }, @@ -456,6 +409,7 @@ mod health_tests { #[test] fn recognizes_only_the_pico_sdk_reset_interface_protocol() { + use fbuild_core::platform::device::is_picotool_reset_compatible_id; assert!(is_picotool_reset_compatible_id( "usb\\class_FF&subclass_00&prot_01" )); @@ -467,1098 +421,3 @@ mod health_tests { )); } } - -#[cfg(windows)] -mod imp { - use super::{ - DetectedPort, PnpObservation, UsbProblemDevice, UsbResetInterface, health_for_endpoint, - is_picotool_reset_compatible_id, - }; - use std::collections::{HashMap, HashSet}; - use std::io; - use std::ptr; - - use serialport::{SerialPortInfo, SerialPortType, UsbPortInfo}; - use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{ - CM_Get_DevNode_Status, CM_Get_Device_IDW, CM_Get_Parent, CR_NO_SUCH_DEVINST, CR_SUCCESS, - DICS_FLAG_GLOBAL, DIGCF_ALLCLASSES, DIGCF_DEVICEINTERFACE, DIGCF_PRESENT, DIREG_DEV, - HDEVINFO, MAX_DEVICE_ID_LEN, SP_DEVICE_INTERFACE_DATA, SP_DEVICE_INTERFACE_DETAIL_DATA_W, - SP_DEVINFO_DATA, SPDRP_CLASS, SPDRP_COMPATIBLEIDS, SPDRP_FRIENDLYNAME, SPDRP_HARDWAREID, - SPDRP_LOCATION_INFORMATION, SPDRP_MFG, SetupDiClassGuidsFromNameW, - SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInfo, SetupDiEnumDeviceInterfaces, - SetupDiGetClassDevsW, SetupDiGetDeviceInstanceIdW, SetupDiGetDeviceInterfaceDetailW, - SetupDiGetDevicePropertyW, SetupDiGetDeviceRegistryPropertyW, SetupDiOpenDevRegKey, - }; - use windows_sys::Win32::Devices::Properties::{ - DEVPKEY_Device_LocationPaths, DEVPROP_TYPE_STRING_LIST, - }; - use windows_sys::Win32::Devices::Usb::{ - WINUSB_INTERFACE_HANDLE, WINUSB_SETUP_PACKET, WinUsb_ControlTransfer, WinUsb_Free, - WinUsb_Initialize, - }; - use windows_sys::Win32::Foundation::{ - CloseHandle, FALSE, FILETIME, GENERIC_READ, GENERIC_WRITE, INVALID_HANDLE_VALUE, MAX_PATH, - }; - use windows_sys::Win32::Storage::FileSystem::{ - CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_FLAG_OVERLAPPED, FILE_SHARE_READ, - FILE_SHARE_WRITE, OPEN_EXISTING, - }; - - use windows_sys::Win32::System::Registry::{ - HKEY, HKEY_LOCAL_MACHINE, KEY_READ, REG_MULTI_SZ, REG_SZ, RegCloseKey, RegEnumValueW, - RegOpenKeyExW, RegQueryInfoKeyW, RegQueryValueExW, - }; - use windows_sys::core::GUID; - - const CONNECTOR_PUNCTUATION_SELECTION: &[char] = &[':', '_', '\u{ff3f}']; - const PICO_RESET_INTERFACE_GUID: GUID = GUID::from_u128(0xbc7398c1_73cd_4cb7_98b8_913a8fca7bf6); - const RESET_REQUEST_BOOTSEL: u8 = 0x01; - // USB_DIR_OUT | USB_TYPE_CLASS | USB_RECIP_INTERFACE. This exactly - // matches picotool's reset-interface request; the endpoint is vendor - // class, but the control request itself is class-scoped. - const RESET_REQUEST_TYPE: u8 = 0x21; - - fn as_utf16(utf8: &str) -> Vec { - utf8.encode_utf16().chain(Some(0)).collect() - } - - fn from_utf16_lossy_trimmed(utf16: &[u16]) -> String { - String::from_utf16_lossy(utf16) - .trim_end_matches(0 as char) - .to_string() - } - - fn get_ports_guids() -> serialport::Result> { - let class_names = ["Ports", "Modem"]; - let mut guids: Vec = Vec::new(); - for class_name in class_names { - let class_name_w = as_utf16(class_name); - let mut num_guids: u32 = 1; - let class_start_idx = guids.len(); - - for _ in 0..2 { - guids.resize(class_start_idx + num_guids as usize, GUID::from_u128(0)); - let guid_buffer = &mut guids[class_start_idx..]; - let res = unsafe { - SetupDiClassGuidsFromNameW( - class_name_w.as_ptr(), - guid_buffer.as_mut_ptr(), - guid_buffer.len() as u32, - &mut num_guids, - ) - }; - if res == FALSE { - return Err(serialport::Error::new( - serialport::ErrorKind::Unknown, - "Unable to determine number of Ports GUIDs", - )); - } - let len_cmp = guid_buffer.len().cmp(&(num_guids as usize)); - if len_cmp == std::cmp::Ordering::Less { - continue; - } else if len_cmp == std::cmp::Ordering::Greater { - guids.truncate(class_start_idx + num_guids as usize); - } - break; - } - } - Ok(guids) - } - - #[derive(Clone, Copy, Debug, Eq, PartialEq)] - struct HwidMatches<'hwid> { - vid: &'hwid str, - pid: &'hwid str, - serial: Option<&'hwid str>, - interface: Option<&'hwid str>, - } - - impl<'hwid> HwidMatches<'hwid> { - fn new(hwid: &'hwid str) -> Option { - let mut hwid_tail = hwid; - let vid_start = hwid.find("VID_")?; - let vid = hwid_tail.get(vid_start + 4..vid_start + 8)?; - hwid_tail = hwid_tail.get(vid_start + 8..)?; - - let pid = if hwid_tail.starts_with("&PID_") || hwid_tail.starts_with("+PID_") { - hwid_tail.get(5..9)? - } else { - return None; - }; - hwid_tail = hwid_tail.get(9..)?; - - let iid = if hwid_tail.starts_with("&MI_") || hwid_tail.starts_with("+MI_") { - let iid = hwid_tail.get(4..6); - hwid_tail = hwid_tail.get(6..).unwrap_or(hwid_tail); - iid - } else { - None - }; - - let serial = if hwid_tail.starts_with('\\') || hwid_tail.starts_with('+') { - hwid_tail.get(1..).and_then(|tail| { - let index = tail - .char_indices() - .find(|&(_, char)| { - !(char.is_alphanumeric() - || CONNECTOR_PUNCTUATION_SELECTION.contains(&char)) - }) - .map(|(index, _)| index) - .unwrap_or(tail.len()); - tail.get(..index) - }) - } else { - None - }; - - Some(Self { - vid, - pid, - serial, - interface: iid, - }) - } - } - - /// Parse a Windows HWID string into [`UsbPortInfo`] (with the composite - /// `MI_xx` interface index preserved). Pure — unit-tested below. - /// - /// VID/PID always come from the device's own hardware id (a composite - /// interface's `MI_xx` hwid carries the same VID/PID as its parent). Only - /// the serial number is taken from the parent for composite devices — and - /// if the parent isn't available (a **phantom** devnode whose live parent - /// no longer exists, i.e. the Status=Unknown Teensy case) we fall back to - /// the child's own serial tail rather than giving up. This is the key - /// difference from upstream serialport, which returns `None` (→ no VID/PID) - /// for a composite devnode with no reachable parent. FastLED/fbuild#962. - fn parse_usb_port_info( - hardware_id: &str, - parent_hardware_id: Option<&str>, - ) -> Option { - let child = HwidMatches::new(hardware_id)?; - let interface = child.interface.and_then(|m| u8::from_str_radix(m, 16).ok()); - let serial = if interface.is_some() { - parent_hardware_id - .and_then(HwidMatches::new) - .and_then(|p| p.serial) - .or(child.serial) - } else { - child.serial - }; - - Some(UsbPortInfo { - vid: u16::from_str_radix(child.vid, 16).ok()?, - pid: u16::from_str_radix(child.pid, 16).ok()?, - serial_number: serial.map(str::to_string), - manufacturer: None, - product: None, - // The workspace enables serialport's `usbportinfo-interface` - // feature (Cargo.toml) precisely so this field exists; it carries - // the `MI_xx` index used to disambiguate Teensy Serial vs MIDI. - interface, - }) - } - - struct PortDevices { - hdi: HDEVINFO, - dev_idx: u32, - } - - impl PortDevices { - fn new(guid: &GUID) -> Self { - PortDevices { - // flags = 0 (NOT `DIGCF_PRESENT`) so non-present / phantom / - // Status=Unknown devnodes — every PJRC/Teensy composite serial - // port — are enumerated too. We re-derive real presence below - // via `CM_Get_DevNode_Status`. FastLED/fbuild#962. - hdi: unsafe { SetupDiGetClassDevsW(guid, ptr::null(), 0, 0) }, - dev_idx: 0, - } - } - } - - impl Iterator for PortDevices { - type Item = PortDevice; - - fn next(&mut self) -> Option { - let mut port_dev = PortDevice { - hdi: self.hdi, - devinfo_data: SP_DEVINFO_DATA { - cbSize: std::mem::size_of::() as u32, - ClassGuid: GUID::from_u128(0), - DevInst: 0, - Reserved: 0, - }, - }; - let res = unsafe { - SetupDiEnumDeviceInfo(self.hdi, self.dev_idx, &mut port_dev.devinfo_data) - }; - if res == FALSE { - None - } else { - self.dev_idx += 1; - Some(port_dev) - } - } - } - - impl Drop for PortDevices { - fn drop(&mut self) { - unsafe { - SetupDiDestroyDeviceInfoList(self.hdi); - } - } - } - - struct PortDevice { - hdi: HDEVINFO, - devinfo_data: SP_DEVINFO_DATA, - } - - impl PortDevice { - fn parent_instance_id(&mut self) -> Option { - let mut result_buf = [0u16; MAX_PATH as usize]; - let mut parent_device_instance_id = 0; - let res = unsafe { - CM_Get_Parent(&mut parent_device_instance_id, self.devinfo_data.DevInst, 0) - }; - if res == CR_SUCCESS { - let buffer_len = result_buf.len() - 1; - let res = unsafe { - CM_Get_Device_IDW( - parent_device_instance_id, - result_buf.as_mut_ptr(), - buffer_len as u32, - 0, - ) - }; - if res == CR_SUCCESS { - Some(from_utf16_lossy_trimmed(&result_buf)) - } else { - None - } - } else { - None - } - } - - fn instance_id(&mut self) -> Option { - let mut result_buf = [0u16; MAX_DEVICE_ID_LEN as usize]; - let working_buffer_len = result_buf.len() - 1; - let mut desired_result_len = 0; - let res = unsafe { - SetupDiGetDeviceInstanceIdW( - self.hdi, - &self.devinfo_data, - result_buf.as_mut_ptr(), - working_buffer_len as u32, - &mut desired_result_len, - ) - }; - if res == FALSE { - self.property(SPDRP_HARDWAREID) - } else { - let actual_result_len = working_buffer_len.min(desired_result_len as usize); - Some(from_utf16_lossy_trimmed(&result_buf[..actual_result_len])) - } - } - - // Retrieves the port name (i.e. COM6) associated with this device. - fn name(&mut self) -> String { - let hkey = unsafe { - SetupDiOpenDevRegKey( - self.hdi, - &self.devinfo_data, - DICS_FLAG_GLOBAL, - 0, - DIREG_DEV, - KEY_READ, - ) - }; - if hkey == INVALID_HANDLE_VALUE { - return String::new(); - } - - let mut port_name_buffer = [0u16; MAX_PATH as usize]; - let buffer_byte_len = 2 * port_name_buffer.len() as u32; - let mut byte_len = buffer_byte_len; - let mut value_type = 0; - let value_name = as_utf16("PortName"); - let err = unsafe { - RegQueryValueExW( - hkey, - value_name.as_ptr(), - ptr::null_mut(), - &mut value_type, - port_name_buffer.as_mut_ptr() as *mut u8, - &mut byte_len, - ) - }; - unsafe { RegCloseKey(hkey) }; - if err != 0 { - return String::new(); - } - if value_type != REG_SZ || !byte_len.is_multiple_of(2) || byte_len > buffer_byte_len { - return String::new(); - } - let len = buffer_byte_len as usize / 2; - let port_name = &port_name_buffer[0..len]; - from_utf16_lossy_trimmed(port_name) - } - - /// Read the Config Manager observation without flattening its three - /// important outcomes. A missing live devnode is a phantom; a query - /// failure that is not that explicit state remains unknown. - fn pnp_observation(&mut self) -> PnpObservation { - let mut status = 0u32; - let mut problem = 0u32; - // SAFETY: `DevInst` comes from the live SetupAPI record and both - // output pointers reference initialized writable local storage. - let res = unsafe { - CM_Get_DevNode_Status(&mut status, &mut problem, self.devinfo_data.DevInst, 0) - }; - if res == CR_SUCCESS { - PnpObservation::Present { - status, - problem_code: problem, - } - } else if res == CR_NO_SUCH_DEVINST { - PnpObservation::Phantom - } else { - PnpObservation::Unknown - } - } - - fn port_type( - &mut self, - instance_id: Option<&str>, - parent_instance_id: Option<&str>, - ) -> SerialPortType { - instance_id - .and_then(|id| parse_usb_port_info(id, parent_instance_id)) - .map(|mut info: UsbPortInfo| { - info.manufacturer = self.property(SPDRP_MFG); - info.product = self.property(SPDRP_FRIENDLYNAME); - SerialPortType::UsbPort(info) - }) - .unwrap_or(SerialPortType::Unknown) - } - - fn property(&mut self, property_id: u32) -> Option { - let mut value_type = 0; - let mut property_buf = [0u16; MAX_PATH as usize]; - let res = unsafe { - SetupDiGetDeviceRegistryPropertyW( - self.hdi, - &self.devinfo_data, - property_id, - &mut value_type, - property_buf.as_mut_ptr() as *mut u8, - property_buf.len() as u32, - ptr::null_mut(), - ) - }; - if res == FALSE || value_type != REG_SZ { - return None; - } - from_utf16_lossy_trimmed(&property_buf) - .split(';') - .next_back() - .map(str::to_string) - } - } - - fn ancestor_ids(devinst: u32) -> Vec { - let mut ids = Vec::new(); - let mut current = devinst; - for _ in 0..16 { - let mut parent = 0; - let result = unsafe { CM_Get_Parent(&mut parent, current, 0) }; - if result != CR_SUCCESS { - break; - } - let mut buffer = [0u16; MAX_DEVICE_ID_LEN as usize]; - let result = - unsafe { CM_Get_Device_IDW(parent, buffer.as_mut_ptr(), buffer.len() as u32, 0) }; - if result != CR_SUCCESS { - break; - } - let length = buffer - .iter() - .position(|&unit| unit == 0) - .unwrap_or(buffer.len()); - ids.push(String::from_utf16_lossy(&buffer[..length])); - current = parent; - } - ids - } - - fn classify_usb_ancestry(devinst: u32) -> Option { - let ancestors = ancestor_ids(devinst); - let root_index = ancestors - .iter() - .position(|id| id.to_ascii_uppercase().starts_with("USB\\ROOT_HUB"))?; - Some(ancestors[..root_index].iter().any(|id| { - let upper = id.to_ascii_uppercase(); - upper.starts_with("USB\\VID_") && upper.contains("&PID_") - })) - } - - fn location_paths_from_info(hdi: HDEVINFO, info: &SP_DEVINFO_DATA) -> Vec { - let mut property_type = 0u32; - let mut required_bytes = 0u32; - // First call obtains the required byte count. SetupAPI reports - // insufficient buffer here, so the return value itself is not the - // success signal; a non-zero required size is. - unsafe { - SetupDiGetDevicePropertyW( - hdi, - info, - &DEVPKEY_Device_LocationPaths, - &mut property_type, - std::ptr::null_mut(), - 0, - &mut required_bytes, - 0, - ) - }; - if required_bytes < 2 { - return Vec::new(); - } - let mut buffer = vec![0u16; (required_bytes as usize).div_ceil(2)]; - let ok = unsafe { - SetupDiGetDevicePropertyW( - hdi, - info, - &DEVPKEY_Device_LocationPaths, - &mut property_type, - buffer.as_mut_ptr().cast(), - required_bytes, - &mut required_bytes, - 0, - ) - }; - if ok == FALSE || property_type != DEVPROP_TYPE_STRING_LIST { - return Vec::new(); - } - buffer - .split(|unit| *unit == 0) - .take_while(|segment| !segment.is_empty()) - .map(String::from_utf16_lossy) - .filter(|path| !path.is_empty()) - .collect() - } - - pub(super) fn present_usb_problem_devices() -> Vec { - // Enumerate by the `USB` *enumerator* with DIGCF_ALLCLASSES, not by - // the USB *setup class*: a driverless devnode (e.g. a BOOTSEL - // PICOBOOT interface stuck at CM_PROB_FAILED_INSTALL) has no setup - // class at all and is invisible to a class-scoped query, which hid - // exactly the problem interface the FastLED/fbuild#1152 recovery - // request needs to target. - let enumerator: Vec = "USB".encode_utf16().chain(Some(0)).collect(); - let hdi = unsafe { - SetupDiGetClassDevsW( - std::ptr::null(), - enumerator.as_ptr(), - 0, - DIGCF_PRESENT | DIGCF_ALLCLASSES, - ) - }; - if hdi == INVALID_HANDLE_VALUE { - return Vec::new(); - } - - let mut devices = Vec::new(); - let mut index = 0u32; - loop { - let mut info = SP_DEVINFO_DATA { - cbSize: std::mem::size_of::() as u32, - ClassGuid: GUID::from_u128(0), - DevInst: 0, - Reserved: 0, - }; - if unsafe { SetupDiEnumDeviceInfo(hdi, index, &mut info) } == FALSE { - break; - } - index += 1; - - let Some(instance_id) = device_instance_id_from_info(hdi, &info) else { - continue; - }; - if !instance_id.to_ascii_uppercase().starts_with("USB\\") { - continue; - } - let mut status = 0u32; - let mut problem_code = 0u32; - if unsafe { CM_Get_DevNode_Status(&mut status, &mut problem_code, info.DevInst, 0) } - != CR_SUCCESS - || problem_code == 0 - { - continue; - } - - devices.push(UsbProblemDevice { - instance_id, - problem_code, - friendly_name: property_from_info(hdi, &info, SPDRP_FRIENDLYNAME), - location: property_from_info(hdi, &info, SPDRP_LOCATION_INFORMATION), - behind_external_hub: classify_usb_ancestry(info.DevInst), - parent_instance_id: ancestor_ids(info.DevInst).into_iter().next(), - device_class: property_from_info(hdi, &info, SPDRP_CLASS), - location_paths: location_paths_from_info(hdi, &info), - }); - } - unsafe { - SetupDiDestroyDeviceInfoList(hdi); - } - devices - } - - pub(super) fn present_usb_reset_interfaces() -> Vec { - let device_paths = pico_reset_interface_paths(); - let enumerator: Vec = "USB".encode_utf16().chain(Some(0)).collect(); - let hdi = unsafe { - SetupDiGetClassDevsW( - std::ptr::null(), - enumerator.as_ptr(), - 0, - DIGCF_PRESENT | DIGCF_ALLCLASSES, - ) - }; - if hdi == INVALID_HANDLE_VALUE { - return Vec::new(); - } - - let mut devices = Vec::new(); - let mut index = 0u32; - loop { - let mut info = SP_DEVINFO_DATA { - cbSize: std::mem::size_of::() as u32, - ClassGuid: GUID::from_u128(0), - DevInst: 0, - Reserved: 0, - }; - if unsafe { SetupDiEnumDeviceInfo(hdi, index, &mut info) } == FALSE { - break; - } - index += 1; - - let compatible_ids = string_list_property_from_info(hdi, &info, SPDRP_COMPATIBLEIDS); - if !compatible_ids - .iter() - .any(|value| is_picotool_reset_compatible_id(value)) - { - continue; - } - let mut status = 0u32; - let mut problem_code = 0u32; - if unsafe { CM_Get_DevNode_Status(&mut status, &mut problem_code, info.DevInst, 0) } - != CR_SUCCESS - || problem_code != 0 - { - continue; - } - - let Some(instance_id) = device_instance_id_from_info(hdi, &info) else { - continue; - }; - let Some(parent_instance_id) = ancestor_ids(info.DevInst).into_iter().next() else { - continue; - }; - let Some(identity) = parse_usb_port_info(&instance_id, Some(&parent_instance_id)) - else { - continue; - }; - let Some(serial_number) = identity.serial_number else { - continue; - }; - let Some(interface_number) = identity.interface else { - continue; - }; - let Some(device_path) = device_paths.get(&instance_id.to_ascii_uppercase()) else { - continue; - }; - devices.push(UsbResetInterface { - instance_id, - parent_instance_id, - vid: identity.vid, - pid: identity.pid, - serial_number, - device_path: device_path.clone(), - interface_number, - location_paths: location_paths_from_info(hdi, &info), - }); - } - unsafe { - SetupDiDestroyDeviceInfoList(hdi); - } - devices.sort_by(|left, right| left.instance_id.cmp(&right.instance_id)); - devices - } - - fn pico_reset_interface_paths() -> HashMap { - let hdi = unsafe { - SetupDiGetClassDevsW( - &PICO_RESET_INTERFACE_GUID, - std::ptr::null(), - 0, - DIGCF_PRESENT | DIGCF_DEVICEINTERFACE, - ) - }; - if hdi == INVALID_HANDLE_VALUE { - return HashMap::new(); - } - - let mut paths = HashMap::new(); - let mut index = 0u32; - loop { - let mut interface = SP_DEVICE_INTERFACE_DATA { - cbSize: std::mem::size_of::() as u32, - InterfaceClassGuid: GUID::from_u128(0), - Flags: 0, - Reserved: 0, - }; - if unsafe { - SetupDiEnumDeviceInterfaces( - hdi, - std::ptr::null(), - &PICO_RESET_INTERFACE_GUID, - index, - &mut interface, - ) - } == FALSE - { - break; - } - index += 1; - - let mut required_bytes = 0u32; - unsafe { - SetupDiGetDeviceInterfaceDetailW( - hdi, - &interface, - std::ptr::null_mut(), - 0, - &mut required_bytes, - std::ptr::null_mut(), - ) - }; - if required_bytes < std::mem::size_of::() as u32 { - continue; - } - - // `Vec` provides pointer alignment suitable for the - // variable-sized SetupAPI detail record while still letting the - // API state its required byte count exactly. - let units = (required_bytes as usize).div_ceil(std::mem::size_of::()); - let mut storage = vec![0usize; units]; - let detail = storage - .as_mut_ptr() - .cast::(); - unsafe { - (*detail).cbSize = std::mem::size_of::() as u32; - } - let mut info = SP_DEVINFO_DATA { - cbSize: std::mem::size_of::() as u32, - ClassGuid: GUID::from_u128(0), - DevInst: 0, - Reserved: 0, - }; - if unsafe { - SetupDiGetDeviceInterfaceDetailW( - hdi, - &interface, - detail, - required_bytes, - &mut required_bytes, - &mut info, - ) - } == FALSE - { - continue; - } - let Some(instance_id) = device_instance_id_from_info(hdi, &info) else { - continue; - }; - let path_ptr = unsafe { std::ptr::addr_of!((*detail).DevicePath).cast::() }; - let path_offset = std::mem::offset_of!(SP_DEVICE_INTERFACE_DETAIL_DATA_W, DevicePath); - let max_units = (required_bytes as usize).saturating_sub(path_offset) / 2; - let path_units = unsafe { std::slice::from_raw_parts(path_ptr, max_units) }; - let length = path_units - .iter() - .position(|unit| *unit == 0) - .unwrap_or(path_units.len()); - if length != 0 { - paths.insert( - instance_id.to_ascii_uppercase(), - String::from_utf16_lossy(&path_units[..length]), - ); - } - } - unsafe { - SetupDiDestroyDeviceInfoList(hdi); - } - paths - } - - pub(super) fn reset_usb_interface_to_bootsel(interface: &UsbResetInterface) -> io::Result<()> { - let path = as_utf16(&interface.device_path); - let device = unsafe { - CreateFileW( - path.as_ptr(), - GENERIC_READ | GENERIC_WRITE, - FILE_SHARE_READ | FILE_SHARE_WRITE, - std::ptr::null(), - OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, - 0, - ) - }; - if device == INVALID_HANDLE_VALUE { - return Err(io::Error::last_os_error()); - } - - let mut winusb: WINUSB_INTERFACE_HANDLE = 0; - if unsafe { WinUsb_Initialize(device, &mut winusb) } == FALSE { - let error = io::Error::last_os_error(); - unsafe { - CloseHandle(device); - } - return Err(error); - } - - let setup = WINUSB_SETUP_PACKET { - RequestType: RESET_REQUEST_TYPE, - Request: RESET_REQUEST_BOOTSEL, - Value: 0, - Index: u16::from(interface.interface_number), - Length: 0, - }; - let mut transferred = 0u32; - let transfer_ok = unsafe { - WinUsb_ControlTransfer( - winusb, - setup, - std::ptr::null_mut(), - 0, - &mut transferred, - std::ptr::null(), - ) - }; - let transfer_error = (transfer_ok == FALSE).then(io::Error::last_os_error); - unsafe { - WinUsb_Free(winusb); - CloseHandle(device); - } - if let Some(error) = transfer_error { - // The reset handler does not return. Windows can therefore report - // the expected disconnect as a failed zero-length transfer even - // though the request was accepted. The deployer performs the - // authoritative BOOTSEL wait immediately after this call. - tracing::debug!( - instance_id = %interface.instance_id, - %error, - "Pico reset interface disconnected while handling the BOOTSEL request" - ); - } - Ok(()) - } - - fn device_instance_id_from_info(hdi: HDEVINFO, info: &SP_DEVINFO_DATA) -> Option { - let mut buffer = [0u16; MAX_DEVICE_ID_LEN as usize]; - let mut required = 0u32; - let ok = unsafe { - SetupDiGetDeviceInstanceIdW( - hdi, - info, - buffer.as_mut_ptr(), - buffer.len() as u32, - &mut required, - ) - }; - if ok == FALSE { - return None; - } - let length = buffer - .iter() - .position(|&unit| unit == 0) - .unwrap_or(buffer.len()); - Some(String::from_utf16_lossy(&buffer[..length])) - } - - fn property_from_info( - hdi: HDEVINFO, - info: &SP_DEVINFO_DATA, - property_id: u32, - ) -> Option { - let mut value_type = 0u32; - let mut buffer = [0u16; MAX_PATH as usize]; - let ok = unsafe { - SetupDiGetDeviceRegistryPropertyW( - hdi, - info, - property_id, - &mut value_type, - buffer.as_mut_ptr() as *mut u8, - (buffer.len() * 2) as u32, - std::ptr::null_mut(), - ) - }; - if ok == FALSE || value_type != REG_SZ { - return None; - } - let length = buffer - .iter() - .position(|&unit| unit == 0) - .unwrap_or(buffer.len()); - let value = String::from_utf16_lossy(&buffer[..length]); - (!value.is_empty()).then_some(value) - } - - fn string_list_property_from_info( - hdi: HDEVINFO, - info: &SP_DEVINFO_DATA, - property_id: u32, - ) -> Vec { - let mut value_type = 0u32; - let mut required_bytes = 0u32; - unsafe { - SetupDiGetDeviceRegistryPropertyW( - hdi, - info, - property_id, - &mut value_type, - std::ptr::null_mut(), - 0, - &mut required_bytes, - ) - }; - if required_bytes < 2 { - return Vec::new(); - } - let mut buffer = vec![0u16; (required_bytes as usize).div_ceil(2)]; - let ok = unsafe { - SetupDiGetDeviceRegistryPropertyW( - hdi, - info, - property_id, - &mut value_type, - buffer.as_mut_ptr().cast(), - required_bytes, - &mut required_bytes, - ) - }; - if ok == FALSE || value_type != REG_MULTI_SZ { - return Vec::new(); - } - buffer - .split(|unit| *unit == 0) - .take_while(|segment| !segment.is_empty()) - .map(String::from_utf16_lossy) - .filter(|value| !value.is_empty()) - .collect() - } - - /// COM ports listed under `HKLM\HARDWARE\DEVICEMAP\SERIALCOMM` that the - /// "Ports" class walk did not surface (parity with upstream serialport). - fn get_registry_com_ports() -> HashSet { - let mut ports_list = HashSet::new(); - let reg_key = as_utf16("HARDWARE\\DEVICEMAP\\SERIALCOMM"); - let mut ports_key: HKEY = 0; - let open_res = unsafe { - RegOpenKeyExW( - HKEY_LOCAL_MACHINE, - reg_key.as_ptr(), - 0, - KEY_READ, - &mut ports_key, - ) - }; - if open_res != 0 { - return ports_list; - } - let mut class_name_buff = [0u16; MAX_PATH as usize]; - let mut class_name_size = MAX_PATH; - let mut sub_key_count = 0; - let mut largest_sub_key = 0; - let mut largest_class_string = 0; - let mut num_key_values = 0; - let mut longest_value_name = 0; - let mut longest_value_data = 0; - let mut size_security_desc = 0; - let mut last_write_time = FILETIME { - dwLowDateTime: 0, - dwHighDateTime: 0, - }; - let query_res = unsafe { - RegQueryInfoKeyW( - ports_key, - class_name_buff.as_mut_ptr(), - &mut class_name_size, - ptr::null(), - &mut sub_key_count, - &mut largest_sub_key, - &mut largest_class_string, - &mut num_key_values, - &mut longest_value_name, - &mut longest_value_data, - &mut size_security_desc, - &mut last_write_time, - ) - }; - if query_res == 0 { - for idx in 0..num_key_values { - let mut val_name_buff = [0u16; MAX_PATH as usize]; - let mut val_name_size = MAX_PATH; - let mut value_type = 0; - let mut val_data = [0u16; MAX_PATH as usize]; - let buffer_byte_len = 2 * val_data.len() as u32; - let mut byte_len = buffer_byte_len; - let res = unsafe { - RegEnumValueW( - ports_key, - idx, - val_name_buff.as_mut_ptr(), - &mut val_name_size, - ptr::null(), - &mut value_type, - val_data.as_mut_ptr() as *mut u8, - &mut byte_len, - ) - }; - if res != 0 - || value_type != REG_SZ - || !byte_len.is_multiple_of(2) - || byte_len > buffer_byte_len - { - break; - } - let val_data = from_utf16_lossy_trimmed(unsafe { - let utf16_len = byte_len / 2; - std::slice::from_raw_parts(val_data.as_ptr(), utf16_len as usize) - }); - ports_list.insert(val_data); - } - } - unsafe { RegCloseKey(ports_key) }; - ports_list - } - - pub(super) fn available_ports() -> serialport::Result> { - let mut ports = Vec::new(); - let mut seen: HashSet = HashSet::new(); - for guid in get_ports_guids()? { - let port_devices = PortDevices::new(&guid); - for mut port_device in port_devices { - let port_name = port_device.name(); - if port_name.is_empty() { - // No PortName in the devnode registry key → not an actual - // COM port (e.g. a modem enumerator entry). Skip. - continue; - } - if port_name.starts_with("LPT") { - continue; - } - let instance_id = port_device.instance_id(); - let parent_instance_id = port_device.parent_instance_id(); - let ancestor_instance_ids = ancestor_ids(port_device.devinfo_data.DevInst); - let pnp_observation = port_device.pnp_observation(); - let port_type = - port_device.port_type(instance_id.as_deref(), parent_instance_id.as_deref()); - let is_usb = matches!(port_type, SerialPortType::UsbPort(_)); - // Include every present port (unchanged behaviour), PLUS - // non-present USB serial ports — the Status=Unknown Teensy - // case the whole fix exists for. A non-present *non-USB* - // devnode is a stale phantom with no VID:PID to act on, so we - // leave it out to avoid resurrecting ancient ACPI/BT junk. - // FastLED/fbuild#962. - if matches!(pnp_observation, super::PnpObservation::Phantom) && !is_usb { - continue; - } - let health = health_for_endpoint(pnp_observation, is_usb); - // A phantom devnode can be enumerated once per matching class - // GUID; de-dup on the COM name. - if !seen.insert(port_name.clone()) { - continue; - } - ports.push(DetectedPort { - info: SerialPortInfo { - port_name, - port_type, - }, - health, - instance_id, - parent_instance_id, - ancestor_instance_ids, - location_paths: location_paths_from_info( - port_device.hdi, - &port_device.devinfo_data, - ), - }); - } - } - - // Fold in any DEVICEMAP\SERIALCOMM ports not already found. - for raw_port in get_registry_com_ports() { - if seen.insert(raw_port.clone()) { - ports.push(DetectedPort::unknown(SerialPortInfo { - port_name: raw_port, - port_type: SerialPortType::Unknown, - })); - } - } - Ok(ports) - } - - #[cfg(test)] - mod tests { - use super::*; - - #[test] - fn parses_teensy_composite_serial_with_interface() { - // Teensy 4.x USB Serial enumerates as a composite MI_00 interface; - // the serial comes from the PARENT instance id, VID/PID from the child. - let child = r"USB\VID_16C0&PID_0483&MI_00\8&226AD2B7&0&0000"; - let parent = r"USB\VID_16C0&PID_0483\12345678"; - let info = parse_usb_port_info(child, Some(parent)).expect("parse"); - assert_eq!(info.vid, 0x16C0); - assert_eq!(info.pid, 0x0483); - assert_eq!(info.serial_number.as_deref(), Some("12345678")); - assert_eq!(info.interface, Some(0)); - } - - #[test] - fn parses_phantom_teensy_composite_without_parent() { - // The bug's core case: a Status=Unknown Teensy port is a phantom - // devnode whose live parent no longer exists, so no parent hwid is - // available. We must STILL recover VID/PID (16C0:0483) from the - // child's own MI_00 hardware id — upstream serialport returns None - // here, which is why the Teensy was invisible. FastLED/fbuild#962. - let child = r"USB\VID_16C0&PID_0483&MI_00\8&226AD2B7&0&0000"; - let info = parse_usb_port_info(child, None).expect("parse without parent"); - assert_eq!(info.vid, 0x16C0); - assert_eq!(info.pid, 0x0483); - assert_eq!(info.interface, Some(0)); - } - - #[test] - fn parses_teensy_serial_midi_audio_pid() { - let child = r"USB\VID_16C0&PID_0489&MI_00\9&32144BF9&0&0000"; - let parent = r"USB\VID_16C0&PID_0489\ABCDEF"; - let info = parse_usb_port_info(child, Some(parent)).expect("parse"); - assert_eq!(info.vid, 0x16C0); - assert_eq!(info.pid, 0x0489); - assert_eq!(info.interface, Some(0)); - } - - #[test] - fn non_composite_device_has_no_interface() { - let info = parse_usb_port_info(r"USB\VID_303A&PID_1001\B4:3A:45:B0:08:24", None) - .expect("parse"); - assert_eq!(info.vid, 0x303A); - assert_eq!(info.interface, None); - assert_eq!(info.serial_number.as_deref(), Some("B4:3A:45:B0:08:24")); - } - } -} diff --git a/crates/fbuild-serial/src/sysfs_usb.rs b/crates/fbuild-serial/src/sysfs_usb.rs index b1c30b24..d4c67022 100644 --- a/crates/fbuild-serial/src/sysfs_usb.rs +++ b/crates/fbuild-serial/src/sysfs_usb.rs @@ -9,7 +9,8 @@ //! parses that name — see `scan_device_interfaces` for why; it is private, so //! this is deliberately not an intra-doc link). This module //! walks that shape and turns it into the same kind of facts Windows gets -//! from SetupAPI/CfgMgr32 in `ports.rs::imp` — but it never touches a live +//! from SetupAPI/CfgMgr32 (see the facade's Windows implementation, +//! `fbuild_core::platform::windows::device`) — but it never touches a live //! filesystem itself. Every parsing function takes a `root: &Path`, so the //! whole module is exercised with fixture trees built under a //! `tempfile::TempDir` on any host OS, including this Windows dev box @@ -17,8 +18,9 @@ //! exactly why interface directories are identified structurally instead //! of by name). //! -//! The one `cfg(target_os = "linux")` boundary is `live_root` plus the -//! two thin wrappers that call it — everything else here is pure parsing +//! The one host boundary is `live_root`, which asks +//! [`fbuild_core::platform::device::live_sysfs_usb_root`] whether this OS has +//! a live sysfs USB topology at all — everything else here is pure parsing //! and runs (and is tested) everywhere. //! //! ## Design choices (FastLED/fbuild#1091) @@ -124,7 +126,8 @@ pub struct UsbDeviceNode { /// `Some(true)` when the directory-name port path has 2+ segments, /// meaning at least one hub sits between this device and the root hub. /// `Some(false)` when it is directly on a root hub port. Mirrors the - /// semantics of `ports::imp::classify_usb_ancestry` on Windows: + /// semantics of `classify_usb_ancestry` in the facade's Windows + /// implementation: /// "is there a USB device ancestor before the root hub". Root hub /// entries (`"usbN"`) themselves get `None` — the question doesn't /// apply to the hub itself. @@ -455,12 +458,11 @@ pub fn health_for_tty_from_root(root: &Path, tty_name: &str) -> PortHealth { PortHealth::Unknown } -/// Live sysfs root, gated to the one platform where `/sys/bus/usb/devices` -/// exists. Every other function in this module takes an explicit `root` -/// and has no idea what OS it's running on. -#[cfg(target_os = "linux")] -pub fn live_root() -> NormalizedPath { - NormalizedPath::from(DEFAULT_SYSFS_USB_ROOT) +/// Live sysfs root when the host provides one (`Some` only on Linux, where +/// `/sys/bus/usb/devices` exists). Every other function in this module takes +/// an explicit `root` and has no idea what OS it's running on. +pub fn live_root() -> Option { + fbuild_core::platform::device::live_sysfs_usb_root() } #[cfg(test)] diff --git a/crates/fbuild-serial/src/usb_recovery.rs b/crates/fbuild-serial/src/usb_recovery.rs index e1754b3d..a722b0d2 100644 --- a/crates/fbuild-serial/src/usb_recovery.rs +++ b/crates/fbuild-serial/src/usb_recovery.rs @@ -12,18 +12,10 @@ use fbuild_core::usb::{ normalize_physical_location, }; -/// A PnP devnode observed directly by the recovery backend. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UsbPnpDevice { - pub instance_id: String, - pub parent_instance_id: Option, - pub device_class: String, - pub vid: u16, - pub pid: u16, - pub serial: Option, - pub health: UsbRecoveryHealth, - pub location_paths: Vec, -} +/// A PnP devnode observed directly by the recovery backend. The record +/// itself is a host-neutral fact owned by the platform facade; the recovery +/// ladder only revalidates its fields. +pub use fbuild_core::platform::device::UsbPnpDevice; /// Narrow host boundary used by the elevated helper and deterministic tests. /// @@ -255,14 +247,8 @@ pub fn recover_windows_usb_device( request: &UsbRecoveryRequest, nonce: String, ) -> UsbRecoveryResult { - #[cfg(windows)] - { - let mut backend = windows::WindowsPnpBackend; - execute_recovery(request, nonce, &mut backend) - } - #[cfg(not(windows))] - { - UsbRecoveryResult { + if !fbuild_core::platform::host::is_windows() { + return UsbRecoveryResult { operation_id: request.operation_id.clone(), nonce, validated_instance_id: None, @@ -271,301 +257,51 @@ pub fn recover_windows_usb_device( after: UsbRecoveryHealth::Unknown, success: false, error_code: Some("windows-recovery-unavailable".to_string()), - } + }; } + let mut backend = PlatformPnpBackend; + execute_recovery(request, nonce, &mut backend) } -#[cfg(windows)] -mod windows { - use super::*; - use std::time::Duration; - use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{ - CM_Disable_DevNode, CM_Enable_DevNode, CM_Get_DevNode_PropertyW, CM_Get_DevNode_Status, - CM_Get_Device_IDW, CM_Get_Parent, CM_LOCATE_DEVNODE_NORMAL, CM_LOCATE_DEVNODE_PHANTOM, - CM_Locate_DevNodeW, CM_Reenumerate_DevNode, CR_NO_SUCH_DEVINST, CR_NO_SUCH_VALUE, - CR_SUCCESS, MAX_DEVICE_ID_LEN, - }; - use windows_sys::Win32::Devices::Properties::{ - DEVPKEY_Device_Class, DEVPKEY_Device_LocationPaths, DEVPROP_TYPE_STRING, - DEVPROP_TYPE_STRING_LIST, - }; - - /// Windows implementation is added below the common security ladder so - /// tests can exercise every allowlist decision without a privileged host. - pub(super) struct WindowsPnpBackend; - - impl UsbPnpBackend for WindowsPnpBackend { - type Error = String; - - fn inspect( - &mut self, - instance_id: &str, - allow_phantom: bool, - ) -> Result { - inspect_device(instance_id, allow_phantom) - } - - fn reenumerate_parent(&mut self, parent_instance_id: &str) -> Result<(), Self::Error> { - reenumerate_parent(parent_instance_id) - } +/// Real backend over the neutral device facade. The Config Manager writes it +/// reaches are confined to the exact devnodes `execute_recovery` revalidated; +/// on hosts without a Windows PnP surface every primitive fails closed, and +/// [`recover_windows_usb_device`] never even constructs this backend there. +struct PlatformPnpBackend; - fn restart_target(&mut self, instance_id: &str) -> Result<(), Self::Error> { - restart_target(instance_id) - } - - fn restart_verified_parent(&mut self, parent_instance_id: &str) -> Result<(), Self::Error> { - // Same bounded disable/enable as `restart_target`, applied to the - // parent composite that `execute_recovery` already re-proved live - // and identity-matched. Never reachable for a hub or controller: - // the ladder only passes a `USB\VID_...` composite here. - restart_target(parent_instance_id) - } +impl UsbPnpBackend for PlatformPnpBackend { + type Error = String; - fn post_operation_poll_attempts(&self) -> usize { - 8 - } - - fn wait_for_post_operation_poll(&mut self) { - std::thread::sleep(Duration::from_millis(250)); - } + fn inspect( + &mut self, + instance_id: &str, + allow_phantom: bool, + ) -> Result { + fbuild_core::platform::device::inspect_usb_pnp_device(instance_id, allow_phantom) } - // These Config Manager functions are intentionally the only real PnP - // writes in this module. Keeping them behind `UsbPnpBackend` makes it - // impossible for the helper to call a broader operation than the trait. - fn inspect_device(instance_id: &str, allow_phantom: bool) -> Result { - let devinst = locate(instance_id, allow_phantom)?; - let actual_instance_id = device_id(devinst)?; - let parent_instance_id = parent_id(devinst)?; - let device_class = device_class(devinst)?; - let health = device_health(devinst); - let location_paths = device_location_paths(devinst); - let (vid, pid, serial) = - parse_usb_identity(&actual_instance_id, parent_instance_id.as_deref()).ok_or_else( - || "device does not expose a canonical USB VID/PID identity".to_string(), - )?; - - Ok(UsbPnpDevice { - instance_id: actual_instance_id, - parent_instance_id, - device_class, - vid, - pid, - serial, - health, - location_paths, - }) - } - - fn reenumerate_parent(parent_instance_id: &str) -> Result<(), String> { - let parent = locate(parent_instance_id, false)?; - // SAFETY: `parent` was obtained from Config Manager for the exact - // verified live parent. Flags are zero, requesting no broad scan. - let result = unsafe { CM_Reenumerate_DevNode(parent, 0) }; - (result == CR_SUCCESS) - .then_some(()) - .ok_or_else(|| format!("CM_Reenumerate_DevNode failed ({result})")) - } - - fn restart_target(instance_id: &str) -> Result<(), String> { - let target = locate(instance_id, false)?; - // SAFETY: `target` was revalidated as the exact present problematic - // child. The helper never passes a parent/hub/controller to this call. - let disabled = unsafe { CM_Disable_DevNode(target, 0) }; - if disabled != CR_SUCCESS { - return Err(format!("CM_Disable_DevNode failed ({disabled})")); - } - // SAFETY: same exact child devinst as the immediately preceding - // disable. No other Config Manager action is performed here. - let enabled = unsafe { CM_Enable_DevNode(target, 0) }; - if enabled == CR_SUCCESS { - return Ok(()); - } - // Best-effort rollback for a transient Config Manager failure. This - // is still the same exact child and does not widen the allowlist; its - // result is retained in the diagnostic rather than silently leaving a - // potentially disabled endpoint behind. - // SAFETY: same exact validated child devinst; this is a bounded - // best-effort re-enable after the first enable reported failure. - let rollback = unsafe { CM_Enable_DevNode(target, 0) }; - Err(format!( - "CM_Enable_DevNode failed ({enabled}); rollback enable returned ({rollback})" - )) - } - - fn locate(instance_id: &str, allow_phantom: bool) -> Result { - let mut devinst = 0u32; - let utf16 = instance_id - .encode_utf16() - .chain(Some(0)) - .collect::>(); - let flags = if allow_phantom { - CM_LOCATE_DEVNODE_PHANTOM - } else { - CM_LOCATE_DEVNODE_NORMAL - }; - // SAFETY: `utf16` is NUL-terminated and remains alive for the call; - // `devinst` is writable local storage. - let result = unsafe { CM_Locate_DevNodeW(&mut devinst, utf16.as_ptr(), flags) }; - (result == CR_SUCCESS) - .then_some(devinst) - .ok_or_else(|| format!("CM_Locate_DevNodeW failed ({result})")) - } - - fn device_id(devinst: u32) -> Result { - let mut buffer = [0u16; MAX_DEVICE_ID_LEN as usize]; - // SAFETY: `buffer` is writable local UTF-16 storage sized according to - // the Config Manager API's documented maximum device ID length. - let result = unsafe { - CM_Get_Device_IDW(devinst, buffer.as_mut_ptr(), (buffer.len() - 1) as u32, 0) - }; - if result != CR_SUCCESS { - return Err(format!("CM_Get_Device_IDW failed ({result})")); - } - Ok(from_utf16(&buffer)) + fn reenumerate_parent(&mut self, parent_instance_id: &str) -> Result<(), Self::Error> { + fbuild_core::platform::device::reenumerate_usb_parent(parent_instance_id) } - fn parent_id(devinst: u32) -> Result, String> { - let mut parent = 0u32; - // SAFETY: `parent` is writable local storage and `devinst` came from - // Config Manager in the same process. - let result = unsafe { CM_Get_Parent(&mut parent, devinst, 0) }; - if result == CR_NO_SUCH_DEVINST { - return Ok(None); - } - if result != CR_SUCCESS { - return Err(format!("CM_Get_Parent failed ({result})")); - } - device_id(parent).map(Some) - } - - fn device_class(devinst: u32) -> Result { - let mut property_type = 0u32; - let mut buffer = [0u16; 256]; - let mut byte_len = (buffer.len() * std::mem::size_of::()) as u32; - // SAFETY: the property key and all output pointers remain valid for - // the call; the buffer size is provided in bytes as required by CM. - let result = unsafe { - CM_Get_DevNode_PropertyW( - devinst, - &DEVPKEY_Device_Class, - &mut property_type, - buffer.as_mut_ptr().cast(), - &mut byte_len, - 0, - ) - }; - if result == CR_NO_SUCH_VALUE { - // Driverless devnodes (e.g. a BOOTSEL PICOBOOT interface stuck at - // CM_PROB_FAILED_INSTALL) have no Device_Class property at all. - // Report the shared sentinel so identity revalidation treats the - // absence as an exact-match fact (FastLED/fbuild#1152). - return Ok(fbuild_core::usb::UNCLASSED_DEVICE_CLASS.to_string()); - } - if result != CR_SUCCESS || property_type != DEVPROP_TYPE_STRING { - return Err(format!( - "CM_Get_DevNode_PropertyW(Device_Class) failed ({result})" - )); - } - Ok(from_utf16(&buffer)) - } - - fn device_health(devinst: u32) -> UsbRecoveryHealth { - let mut status = 0u32; - let mut problem_code = 0u32; - // SAFETY: both output pointers are writable local storage and the - // devinst was returned by Config Manager. - let result = unsafe { CM_Get_DevNode_Status(&mut status, &mut problem_code, devinst, 0) }; - if result == CR_SUCCESS { - if problem_code == 0 { - UsbRecoveryHealth::HealthyPresent - } else { - UsbRecoveryHealth::PresentProblem { problem_code } - } - } else if result == CR_NO_SUCH_DEVINST { - UsbRecoveryHealth::Phantom { problem_code: None } - } else { - UsbRecoveryHealth::Unknown - } + fn restart_target(&mut self, instance_id: &str) -> Result<(), Self::Error> { + fbuild_core::platform::device::restart_usb_device(instance_id) } - fn device_location_paths(devinst: u32) -> Vec { - let mut property_type = 0u32; - let mut byte_len = 0u32; - unsafe { - CM_Get_DevNode_PropertyW( - devinst, - &DEVPKEY_Device_LocationPaths, - &mut property_type, - std::ptr::null_mut(), - &mut byte_len, - 0, - ) - }; - if byte_len < 2 { - return Vec::new(); - } - let mut buffer = vec![0u16; (byte_len as usize).div_ceil(2)]; - let result = unsafe { - CM_Get_DevNode_PropertyW( - devinst, - &DEVPKEY_Device_LocationPaths, - &mut property_type, - buffer.as_mut_ptr().cast(), - &mut byte_len, - 0, - ) - }; - if result != CR_SUCCESS || property_type != DEVPROP_TYPE_STRING_LIST { - return Vec::new(); - } - buffer - .split(|unit| *unit == 0) - .take_while(|segment| !segment.is_empty()) - .map(String::from_utf16_lossy) - .filter(|path| !path.is_empty()) - .collect() + fn restart_verified_parent(&mut self, parent_instance_id: &str) -> Result<(), Self::Error> { + // Same bounded disable/enable as `restart_target`, applied to the + // parent composite that `execute_recovery` already re-proved live + // and identity-matched. Never reachable for a hub or controller: + // the ladder only passes a `USB\VID_...` composite here. + fbuild_core::platform::device::restart_usb_device(parent_instance_id) } - fn parse_usb_identity( - instance_id: &str, - parent_instance_id: Option<&str>, - ) -> Option<(u16, u16, Option)> { - fn parse(id: &str) -> Option<(u16, u16, Option)> { - let mut parts = id.split('\\'); - if !parts.next()?.eq_ignore_ascii_case("USB") { - return None; - } - let hardware = parts.next()?.to_ascii_uppercase(); - let vid_start = hardware.find("VID_")? + 4; - let pid_start = hardware.find("PID_")? + 4; - let vid = u16::from_str_radix(hardware.get(vid_start..vid_start + 4)?, 16).ok()?; - let pid = u16::from_str_radix(hardware.get(pid_start..pid_start + 4)?, 16).ok()?; - let serial = parts - .next() - .filter(|value| !value.is_empty()) - .map(str::to_string); - Some((vid, pid, serial)) - } - - let (vid, pid, child_serial) = parse(instance_id)?; - let parent_serial = - parent_instance_id - .and_then(parse) - .and_then(|(parent_vid, parent_pid, serial)| { - (parent_vid == vid && parent_pid == pid) - .then_some(serial) - .flatten() - }); - Some((vid, pid, parent_serial.or(child_serial))) + fn post_operation_poll_attempts(&self) -> usize { + fbuild_core::platform::device::usb_pnp_post_operation_poll_attempts() } - fn from_utf16(buffer: &[u16]) -> String { - let length = buffer - .iter() - .position(|unit| *unit == 0) - .unwrap_or(buffer.len()); - String::from_utf16_lossy(&buffer[..length]) + fn wait_for_post_operation_poll(&mut self) { + std::thread::sleep(fbuild_core::platform::device::usb_pnp_post_operation_poll_interval()); } } diff --git a/dylints/ban_direct_serialport/src/allowlist.txt b/dylints/ban_direct_serialport/src/allowlist.txt index 55e71b2e..a44b5cbb 100644 --- a/dylints/ban_direct_serialport/src/allowlist.txt +++ b/dylints/ban_direct_serialport/src/allowlist.txt @@ -55,3 +55,12 @@ crates/fbuild-deploy/src/teensy/soft_reboot.rs # and drops it immediately; creating a manager session here would retain the # port and defeat the recovery handoff (FastLED/fbuild#1152). crates/fbuild-cli/src/cli/deploy.rs + +# Platform-boundary migration (FastLED/fbuild#1313): the neutral +# `platform::device` facade delegates to `serialport` for portable serial +# enumeration on Unix hosts and maps `SerialPortInfo` onto host-neutral +# facts. The Windows SetupAPI fork does not touch the library. These are +# the blessed wrapper's new home inside fbuild-core, not new callers. +crates/fbuild-core/src/platform/device.rs +crates/fbuild-core/src/platform/linux/device.rs +crates/fbuild-core/src/platform/macos/device.rs diff --git a/dylints/enforce_platform_boundary/src/baseline.txt b/dylints/enforce_platform_boundary/src/baseline.txt index 5872c7eb..8f9780b0 100644 --- a/dylints/enforce_platform_boundary/src/baseline.txt +++ b/dylints/enforce_platform_boundary/src/baseline.txt @@ -43,47 +43,9 @@ crates/fbuild-deploy/src/rp2040_topology.rs attr_cfg windows 9 crates/fbuild-deploy/src/rp2040_topology.rs native_import std::os::windows 0 crates/fbuild-library/src/library/library_spec.rs attr_cfg windows 0 crates/fbuild-paths/src/dev_daemon_namespace.rs native_import std::env::current_exe 0 -crates/fbuild-serial/src/port_class.rs attr_cfg target_os 0 -crates/fbuild-serial/src/port_class.rs attr_cfg target_os 1 -crates/fbuild-serial/src/port_class.rs attr_cfg target_os 2 -crates/fbuild-serial/src/port_class.rs attr_cfg target_os 3 -crates/fbuild-serial/src/port_class.rs attr_cfg target_os 4 -crates/fbuild-serial/src/port_class.rs attr_cfg target_os 5 -crates/fbuild-serial/src/port_class.rs attr_cfg target_os 6 -crates/fbuild-serial/src/port_class.rs attr_cfg target_os 7 -crates/fbuild-serial/src/port_class.rs attr_cfg target_os 8 -crates/fbuild-serial/src/port_class.rs attr_cfg target_os 9 -crates/fbuild-serial/src/port_class.rs attr_cfg target_os 10 crates/fbuild-serial/src/ports.rs attr_cfg target_os 0 crates/fbuild-serial/src/ports.rs attr_cfg target_os 1 crates/fbuild-serial/src/ports.rs attr_cfg target_os 2 -crates/fbuild-serial/src/ports.rs attr_cfg windows 0 -crates/fbuild-serial/src/ports.rs attr_cfg windows 1 -crates/fbuild-serial/src/ports.rs attr_cfg windows 2 -crates/fbuild-serial/src/ports.rs attr_cfg windows 3 -crates/fbuild-serial/src/ports.rs attr_cfg windows 4 -crates/fbuild-serial/src/ports.rs attr_cfg windows 5 -crates/fbuild-serial/src/ports.rs attr_cfg windows 6 -crates/fbuild-serial/src/ports.rs attr_cfg windows 7 -crates/fbuild-serial/src/ports.rs attr_cfg windows 8 -crates/fbuild-serial/src/ports.rs attr_cfg windows 9 -crates/fbuild-serial/src/ports.rs attr_cfg windows 10 -crates/fbuild-serial/src/ports.rs attr_cfg windows 11 -crates/fbuild-serial/src/ports.rs attr_cfg windows 12 -crates/fbuild-serial/src/ports.rs native_import windows_sys 0 -crates/fbuild-serial/src/ports.rs native_import windows_sys 1 -crates/fbuild-serial/src/ports.rs native_import windows_sys 2 -crates/fbuild-serial/src/ports.rs native_import windows_sys 3 -crates/fbuild-serial/src/ports.rs native_import windows_sys 4 -crates/fbuild-serial/src/ports.rs native_import windows_sys 5 -crates/fbuild-serial/src/ports.rs native_import windows_sys 6 -crates/fbuild-serial/src/sysfs_usb.rs attr_cfg target_os 0 -crates/fbuild-serial/src/usb_recovery.rs attr_cfg windows 0 -crates/fbuild-serial/src/usb_recovery.rs attr_cfg windows 1 -crates/fbuild-serial/src/usb_recovery.rs attr_cfg windows 2 -crates/fbuild-serial/src/usb_recovery.rs native_import windows 0 -crates/fbuild-serial/src/usb_recovery.rs native_import windows_sys 0 -crates/fbuild-serial/src/usb_recovery.rs native_import windows_sys 1 crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg target_os 0 crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg target_os 1 crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg windows 0