1use std::fmt;
14#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
15use std::fs;
16use std::io;
17#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
18use std::path::Path;
19use std::path::PathBuf;
20
21pub const HID_USAGE_PAGE_FIDO: u16 = 0xF1D0;
23pub const HID_USAGE_FIDO_AUTHENTICATOR: u16 = 0x01;
25
26#[derive(Debug)]
28pub enum HidError {
29 Io(io::Error),
31 Parse(&'static str),
33 Backend(String),
35}
36
37impl fmt::Display for HidError {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 match self {
40 HidError::Io(e) => write!(f, "HID I/O error: {}", e),
41 HidError::Parse(s) => write!(f, "HID parse error: {}", s),
42 HidError::Backend(s) => write!(f, "HID backend error: {}", s),
43 }
44 }
45}
46
47impl std::error::Error for HidError {
48 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
49 match self {
50 HidError::Io(e) => Some(e),
51 HidError::Parse(_) | HidError::Backend(_) => None,
52 }
53 }
54}
55
56impl From<io::Error> for HidError {
57 fn from(e: io::Error) -> Self {
58 HidError::Io(e)
59 }
60}
61
62#[derive(Debug, Clone)]
64pub struct HidDevice {
65 pub path: PathBuf,
67 pub vendor_id: u16,
69 pub product_id: u16,
71 pub product_name: String,
73 pub usage_page: u16,
75 pub usage: u16,
77 pub serial_number: Option<String>,
81 pub usb_bus: Option<u8>,
86 pub usb_address: Option<u8>,
88}
89
90const KNOWN_BOOTLOADERS: &[(u16, u16, &str)] =
95 &[(0x1209, 0xb000, "Solo 2 / Nitrokey 3 in bootloader/DFU mode")];
96
97impl HidDevice {
98 pub fn is_fido(&self) -> bool {
100 self.usage_page == HID_USAGE_PAGE_FIDO
101 }
102
103 pub fn bootloader_label(&self) -> Option<&'static str> {
109 KNOWN_BOOTLOADERS
110 .iter()
111 .find(|(vid, pid, _)| *vid == self.vendor_id && *pid == self.product_id)
112 .map(|(_, _, label)| *label)
113 }
114}
115
116pub fn bootloader_device_present() -> Option<&'static str> {
121 enumerate()
122 .ok()?
123 .iter()
124 .find_map(HidDevice::bootloader_label)
125}
126
127#[must_use]
133pub fn hid_supported() -> bool {
134 cfg!(any(
135 target_os = "linux",
136 target_os = "macos",
137 target_os = "windows"
138 ))
139}
140
141pub fn enumerate() -> Result<Vec<HidDevice>, HidError> {
147 #[cfg(any(not(target_os = "linux"), feature = "hidapi-backend"))]
151 {
152 enumerate_hidapi()
153 }
154 #[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
155 {
156 enumerate_sysfs()
157 }
158}
159
160#[cfg(any(not(target_os = "linux"), feature = "hidapi-backend"))]
165fn enumerate_hidapi() -> Result<Vec<HidDevice>, HidError> {
166 let api = hidapi::HidApi::new().map_err(|e| HidError::Backend(e.to_string()))?;
167 let mut devices: Vec<HidDevice> = api
168 .device_list()
169 .map(|info| HidDevice {
170 path: PathBuf::from(info.path().to_string_lossy().into_owned()),
171 vendor_id: info.vendor_id(),
172 product_id: info.product_id(),
173 product_name: info.product_string().unwrap_or_default().to_string(),
174 usage_page: info.usage_page(),
175 usage: info.usage(),
176 serial_number: info.serial_number().map(str::to_owned),
177 usb_bus: None,
178 usb_address: None,
179 })
180 .collect();
181 devices.sort_by(|a, b| a.path.cmp(&b.path));
182 Ok(devices)
183}
184
185#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
187fn enumerate_sysfs() -> Result<Vec<HidDevice>, HidError> {
188 let entries = match fs::read_dir("/sys/class/hidraw") {
189 Ok(e) => e,
190 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
191 Err(e) => return Err(HidError::Io(e)),
192 };
193
194 let mut devices = Vec::new();
195 for entry in entries {
196 let entry = entry?;
197 let name = entry.file_name();
198 let Some(name_str) = name.to_str() else {
199 continue;
200 };
201 if !name_str.starts_with("hidraw") {
202 continue;
203 }
204 if let Ok(dev) = read_one(name_str, &entry.path()) {
205 devices.push(dev);
206 }
207 }
208 devices.sort_by(|a, b| a.path.cmp(&b.path));
209 Ok(devices)
210}
211
212#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
213fn read_one(name: &str, sysfs: &Path) -> Result<HidDevice, HidError> {
214 let uevent = fs::read_to_string(sysfs.join("device/uevent"))?;
215 let mut vendor_id: u16 = 0;
216 let mut product_id: u16 = 0;
217 let mut product_name = String::new();
218 for line in uevent.lines() {
219 if let Some(rest) = line.strip_prefix("HID_ID=") {
220 let parts: Vec<&str> = rest.split(':').collect();
221 if parts.len() != 3 {
222 return Err(HidError::Parse("HID_ID format"));
223 }
224 vendor_id = parse_hex_u16(parts[1]).ok_or(HidError::Parse("HID_ID vendor"))?;
225 product_id = parse_hex_u16(parts[2]).ok_or(HidError::Parse("HID_ID product"))?;
226 } else if let Some(rest) = line.strip_prefix("HID_NAME=") {
227 product_name = rest.to_string();
228 }
229 }
230
231 let report_desc = fs::read(sysfs.join("device/report_descriptor")).unwrap_or_default();
232 let (usage_page, usage) = parse_top_usage(&report_desc).unwrap_or((0, 0));
233
234 let (serial_number, usb_bus, usb_address) = match usb_device_dir(&sysfs.join("device")) {
236 Some(dir) => (
237 read_usb_serial(&dir),
238 read_sysfs_u8(&dir.join("busnum")),
239 read_sysfs_u8(&dir.join("devnum")),
240 ),
241 None => (None, None, None),
242 };
243
244 Ok(HidDevice {
245 path: PathBuf::from(format!("/dev/{}", name)),
246 vendor_id,
247 product_id,
248 product_name,
249 usage_page,
250 usage,
251 serial_number,
252 usb_bus,
253 usb_address,
254 })
255}
256
257#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
261fn usb_device_dir(device_link: &Path) -> Option<PathBuf> {
262 let mut dir = fs::canonicalize(device_link).ok()?;
263 loop {
264 if dir.join("idVendor").exists() {
265 return Some(dir);
266 }
267 dir = dir.parent()?.to_path_buf();
268 }
269}
270
271#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
275fn read_usb_serial(usb_dir: &Path) -> Option<String> {
276 let serial = fs::read_to_string(usb_dir.join("serial")).ok()?;
277 let serial = serial.trim();
278 (!serial.is_empty()).then(|| serial.to_string())
279}
280
281#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
283fn read_sysfs_u8(path: &Path) -> Option<u8> {
284 fs::read_to_string(path).ok()?.trim().parse().ok()
285}
286
287#[cfg_attr(
289 any(not(target_os = "linux"), feature = "hidapi-backend"),
290 allow(dead_code)
291)]
292fn parse_hex_u16(s: &str) -> Option<u16> {
293 let v = u32::from_str_radix(s.trim(), 16).ok()?;
295 Some((v & 0xFFFF) as u16)
296}
297
298#[cfg_attr(
303 any(not(target_os = "linux"), feature = "hidapi-backend"),
304 allow(dead_code)
305)]
306fn parse_top_usage(desc: &[u8]) -> Option<(u16, u16)> {
307 let mut i = 0;
308 let mut usage_page: Option<u16> = None;
309
310 while i < desc.len() {
311 let prefix = desc[i];
312 if prefix == 0xFE {
314 if i + 1 >= desc.len() {
315 break;
316 }
317 let size = desc[i + 1] as usize;
318 i = i.saturating_add(3).saturating_add(size);
319 continue;
320 }
321 let size = match prefix & 0b11 {
322 0 => 0,
323 1 => 1,
324 2 => 2,
325 3 => 4,
326 _ => 0,
327 };
328 let typ = (prefix >> 2) & 0b11;
329 let tag = (prefix >> 4) & 0xF;
330
331 if i + 1 + size > desc.len() {
332 break;
333 }
334 let data = &desc[i + 1..i + 1 + size];
335 let value: u32 = match size {
336 0 => 0,
337 1 => data[0] as u32,
338 2 => u16::from_le_bytes([data[0], data[1]]) as u32,
339 4 => u32::from_le_bytes([data[0], data[1], data[2], data[3]]),
340 _ => 0,
341 };
342
343 if typ == 1 && tag == 0 {
345 usage_page = Some((value & 0xFFFF) as u16);
346 }
347 if typ == 2 && tag == 0 {
349 if let Some(page) = usage_page {
350 return Some((page, (value & 0xFFFF) as u16));
351 }
352 }
353
354 i += 1 + size;
355 }
356
357 usage_page.map(|p| (p, 0))
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn hid_id_field_parses_8_char_hex() {
366 assert_eq!(parse_hex_u16("00001050"), Some(0x1050));
367 assert_eq!(parse_hex_u16("00000407"), Some(0x0407));
368 assert_eq!(parse_hex_u16("1050"), Some(0x1050));
369 assert!(parse_hex_u16("xyz").is_none());
370 }
371
372 #[test]
373 fn fido_descriptor_yields_f1d0_01() {
374 let desc = [0x06, 0xD0, 0xF1, 0x09, 0x01, 0xA1, 0x01];
376 let (page, usage) = parse_top_usage(&desc).expect("usage pair present");
377 assert_eq!(page, 0xF1D0);
378 assert_eq!(usage, 0x01);
379 }
380
381 #[test]
382 fn keyboard_descriptor_yields_generic_desktop_keyboard() {
383 let desc = [0x05, 0x01, 0x09, 0x06];
385 let (page, usage) = parse_top_usage(&desc).expect("usage pair present");
386 assert_eq!(page, 0x01);
387 assert_eq!(usage, 0x06);
388 }
389
390 #[test]
391 fn empty_descriptor_yields_none() {
392 assert!(parse_top_usage(&[]).is_none());
393 }
394
395 #[test]
396 fn fido_helper_only_matches_fido_page() {
397 let fido = HidDevice {
398 path: PathBuf::from("/dev/hidraw0"),
399 vendor_id: 0x1050,
400 product_id: 0x0407,
401 product_name: "YubiKey".into(),
402 usage_page: HID_USAGE_PAGE_FIDO,
403 usage: HID_USAGE_FIDO_AUTHENTICATOR,
404 serial_number: None,
405 usb_bus: None,
406 usb_address: None,
407 };
408 let kbd = HidDevice {
409 usage_page: 0x01,
410 ..fido.clone()
411 };
412 assert!(fido.is_fido());
413 assert!(!kbd.is_fido());
414 }
415
416 #[test]
417 fn bootloader_label_matches_known_dfu_id() {
418 let fido = HidDevice {
419 path: PathBuf::from("/dev/hidraw0"),
420 vendor_id: 0x1050,
421 product_id: 0x0407,
422 product_name: "YubiKey".into(),
423 usage_page: HID_USAGE_PAGE_FIDO,
424 usage: HID_USAGE_FIDO_AUTHENTICATOR,
425 serial_number: None,
426 usb_bus: None,
427 usb_address: None,
428 };
429 assert!(fido.bootloader_label().is_none());
431 let dfu = HidDevice {
433 vendor_id: 0x1209,
434 product_id: 0xb000,
435 usage_page: 0x01,
436 ..fido
437 };
438 assert!(dfu.bootloader_label().is_some());
439 }
440}