use std::fmt;
#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
use std::fs;
use std::io;
#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
use std::path::Path;
use std::path::PathBuf;
pub const HID_USAGE_PAGE_FIDO: u16 = 0xF1D0;
pub const HID_USAGE_FIDO_AUTHENTICATOR: u16 = 0x01;
#[derive(Debug)]
pub enum HidError {
Io(io::Error),
Parse(&'static str),
Backend(String),
}
impl fmt::Display for HidError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HidError::Io(e) => write!(f, "HID I/O error: {}", e),
HidError::Parse(s) => write!(f, "HID parse error: {}", s),
HidError::Backend(s) => write!(f, "HID backend error: {}", s),
}
}
}
impl std::error::Error for HidError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
HidError::Io(e) => Some(e),
HidError::Parse(_) | HidError::Backend(_) => None,
}
}
}
impl From<io::Error> for HidError {
fn from(e: io::Error) -> Self {
HidError::Io(e)
}
}
#[derive(Debug, Clone)]
pub struct HidDevice {
pub path: PathBuf,
pub vendor_id: u16,
pub product_id: u16,
pub product_name: String,
pub usage_page: u16,
pub usage: u16,
pub serial_number: Option<String>,
pub usb_bus: Option<u8>,
pub usb_address: Option<u8>,
}
const KNOWN_BOOTLOADERS: &[(u16, u16, &str)] =
&[(0x1209, 0xb000, "Solo 2 / Nitrokey 3 in bootloader/DFU mode")];
impl HidDevice {
pub fn is_fido(&self) -> bool {
self.usage_page == HID_USAGE_PAGE_FIDO
}
pub fn bootloader_label(&self) -> Option<&'static str> {
KNOWN_BOOTLOADERS
.iter()
.find(|(vid, pid, _)| *vid == self.vendor_id && *pid == self.product_id)
.map(|(_, _, label)| *label)
}
}
pub fn bootloader_device_present() -> Option<&'static str> {
enumerate()
.ok()?
.iter()
.find_map(HidDevice::bootloader_label)
}
#[must_use]
pub fn hid_supported() -> bool {
cfg!(any(
target_os = "linux",
target_os = "macos",
target_os = "windows"
))
}
pub fn enumerate() -> Result<Vec<HidDevice>, HidError> {
#[cfg(any(not(target_os = "linux"), feature = "hidapi-backend"))]
{
enumerate_hidapi()
}
#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
{
enumerate_sysfs()
}
}
#[cfg(any(not(target_os = "linux"), feature = "hidapi-backend"))]
fn enumerate_hidapi() -> Result<Vec<HidDevice>, HidError> {
let api = hidapi::HidApi::new().map_err(|e| HidError::Backend(e.to_string()))?;
let mut devices: Vec<HidDevice> = api
.device_list()
.map(|info| HidDevice {
path: PathBuf::from(info.path().to_string_lossy().into_owned()),
vendor_id: info.vendor_id(),
product_id: info.product_id(),
product_name: info.product_string().unwrap_or_default().to_string(),
usage_page: info.usage_page(),
usage: info.usage(),
serial_number: info.serial_number().map(str::to_owned),
usb_bus: None,
usb_address: None,
})
.collect();
devices.sort_by(|a, b| a.path.cmp(&b.path));
Ok(devices)
}
#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
fn enumerate_sysfs() -> Result<Vec<HidDevice>, HidError> {
let entries = match fs::read_dir("/sys/class/hidraw") {
Ok(e) => e,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(HidError::Io(e)),
};
let mut devices = Vec::new();
for entry in entries {
let entry = entry?;
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
if !name_str.starts_with("hidraw") {
continue;
}
if let Ok(dev) = read_one(name_str, &entry.path()) {
devices.push(dev);
}
}
devices.sort_by(|a, b| a.path.cmp(&b.path));
Ok(devices)
}
#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
fn read_one(name: &str, sysfs: &Path) -> Result<HidDevice, HidError> {
let uevent = fs::read_to_string(sysfs.join("device/uevent"))?;
let mut vendor_id: u16 = 0;
let mut product_id: u16 = 0;
let mut product_name = String::new();
for line in uevent.lines() {
if let Some(rest) = line.strip_prefix("HID_ID=") {
let parts: Vec<&str> = rest.split(':').collect();
if parts.len() != 3 {
return Err(HidError::Parse("HID_ID format"));
}
vendor_id = parse_hex_u16(parts[1]).ok_or(HidError::Parse("HID_ID vendor"))?;
product_id = parse_hex_u16(parts[2]).ok_or(HidError::Parse("HID_ID product"))?;
} else if let Some(rest) = line.strip_prefix("HID_NAME=") {
product_name = rest.to_string();
}
}
let report_desc = fs::read(sysfs.join("device/report_descriptor")).unwrap_or_default();
let (usage_page, usage) = parse_top_usage(&report_desc).unwrap_or((0, 0));
let (serial_number, usb_bus, usb_address) = match usb_device_dir(&sysfs.join("device")) {
Some(dir) => (
read_usb_serial(&dir),
read_sysfs_u8(&dir.join("busnum")),
read_sysfs_u8(&dir.join("devnum")),
),
None => (None, None, None),
};
Ok(HidDevice {
path: PathBuf::from(format!("/dev/{}", name)),
vendor_id,
product_id,
product_name,
usage_page,
usage,
serial_number,
usb_bus,
usb_address,
})
}
#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
fn usb_device_dir(device_link: &Path) -> Option<PathBuf> {
let mut dir = fs::canonicalize(device_link).ok()?;
loop {
if dir.join("idVendor").exists() {
return Some(dir);
}
dir = dir.parent()?.to_path_buf();
}
}
#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
fn read_usb_serial(usb_dir: &Path) -> Option<String> {
let serial = fs::read_to_string(usb_dir.join("serial")).ok()?;
let serial = serial.trim();
(!serial.is_empty()).then(|| serial.to_string())
}
#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
fn read_sysfs_u8(path: &Path) -> Option<u8> {
fs::read_to_string(path).ok()?.trim().parse().ok()
}
#[cfg_attr(
any(not(target_os = "linux"), feature = "hidapi-backend"),
allow(dead_code)
)]
fn parse_hex_u16(s: &str) -> Option<u16> {
let v = u32::from_str_radix(s.trim(), 16).ok()?;
Some((v & 0xFFFF) as u16)
}
#[cfg_attr(
any(not(target_os = "linux"), feature = "hidapi-backend"),
allow(dead_code)
)]
fn parse_top_usage(desc: &[u8]) -> Option<(u16, u16)> {
let mut i = 0;
let mut usage_page: Option<u16> = None;
while i < desc.len() {
let prefix = desc[i];
if prefix == 0xFE {
if i + 1 >= desc.len() {
break;
}
let size = desc[i + 1] as usize;
i = i.saturating_add(3).saturating_add(size);
continue;
}
let size = match prefix & 0b11 {
0 => 0,
1 => 1,
2 => 2,
3 => 4,
_ => 0,
};
let typ = (prefix >> 2) & 0b11;
let tag = (prefix >> 4) & 0xF;
if i + 1 + size > desc.len() {
break;
}
let data = &desc[i + 1..i + 1 + size];
let value: u32 = match size {
0 => 0,
1 => data[0] as u32,
2 => u16::from_le_bytes([data[0], data[1]]) as u32,
4 => u32::from_le_bytes([data[0], data[1], data[2], data[3]]),
_ => 0,
};
if typ == 1 && tag == 0 {
usage_page = Some((value & 0xFFFF) as u16);
}
if typ == 2 && tag == 0 {
if let Some(page) = usage_page {
return Some((page, (value & 0xFFFF) as u16));
}
}
i += 1 + size;
}
usage_page.map(|p| (p, 0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hid_id_field_parses_8_char_hex() {
assert_eq!(parse_hex_u16("00001050"), Some(0x1050));
assert_eq!(parse_hex_u16("00000407"), Some(0x0407));
assert_eq!(parse_hex_u16("1050"), Some(0x1050));
assert!(parse_hex_u16("xyz").is_none());
}
#[test]
fn fido_descriptor_yields_f1d0_01() {
let desc = [0x06, 0xD0, 0xF1, 0x09, 0x01, 0xA1, 0x01];
let (page, usage) = parse_top_usage(&desc).expect("usage pair present");
assert_eq!(page, 0xF1D0);
assert_eq!(usage, 0x01);
}
#[test]
fn keyboard_descriptor_yields_generic_desktop_keyboard() {
let desc = [0x05, 0x01, 0x09, 0x06];
let (page, usage) = parse_top_usage(&desc).expect("usage pair present");
assert_eq!(page, 0x01);
assert_eq!(usage, 0x06);
}
#[test]
fn empty_descriptor_yields_none() {
assert!(parse_top_usage(&[]).is_none());
}
#[test]
fn fido_helper_only_matches_fido_page() {
let fido = HidDevice {
path: PathBuf::from("/dev/hidraw0"),
vendor_id: 0x1050,
product_id: 0x0407,
product_name: "YubiKey".into(),
usage_page: HID_USAGE_PAGE_FIDO,
usage: HID_USAGE_FIDO_AUTHENTICATOR,
serial_number: None,
usb_bus: None,
usb_address: None,
};
let kbd = HidDevice {
usage_page: 0x01,
..fido.clone()
};
assert!(fido.is_fido());
assert!(!kbd.is_fido());
}
#[test]
fn bootloader_label_matches_known_dfu_id() {
let fido = HidDevice {
path: PathBuf::from("/dev/hidraw0"),
vendor_id: 0x1050,
product_id: 0x0407,
product_name: "YubiKey".into(),
usage_page: HID_USAGE_PAGE_FIDO,
usage: HID_USAGE_FIDO_AUTHENTICATOR,
serial_number: None,
usb_bus: None,
usb_address: None,
};
assert!(fido.bootloader_label().is_none());
let dfu = HidDevice {
vendor_id: 0x1209,
product_id: 0xb000,
usage_page: 0x01,
..fido
};
assert!(dfu.bootloader_label().is_some());
}
}