use anyhow::{Context, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UsbVersion {
Usb1,
#[default]
Usb2,
Usb3,
}
impl UsbVersion {
pub fn from_speed(speed: &str) -> Self {
match speed.trim() {
"1.5" | "12" => UsbVersion::Usb1,
"480" => UsbVersion::Usb2,
"5000" | "10000" | "20000" => UsbVersion::Usb3,
_ => UsbVersion::Usb2, }
}
pub fn from_bcd_usb(bcd: u16) -> Self {
if bcd >= 0x0300 {
UsbVersion::Usb3
} else if bcd >= 0x0200 {
UsbVersion::Usb2
} else {
UsbVersion::Usb1
}
}
pub fn is_usb3(&self) -> bool {
matches!(self, UsbVersion::Usb3)
}
}
#[derive(Debug, Clone)]
pub struct UsbDevice {
pub vendor_id: u16,
pub product_id: u16,
pub vendor_name: String,
pub product_name: String,
#[allow(dead_code)]
pub bus_num: u8,
#[allow(dead_code)]
pub dev_num: u8,
pub device_class: u8,
pub usb_version: UsbVersion,
}
impl UsbDevice {
pub fn is_hub(&self) -> bool {
self.device_class == 0x09
}
pub fn display_name(&self) -> String {
if !self.product_name.is_empty() {
if !self.vendor_name.is_empty() {
format!("{} {}", self.vendor_name, self.product_name)
} else {
self.product_name.clone()
}
} else {
format!("USB Device {:04x}:{:04x}", self.vendor_id, self.product_id)
}
}
}
pub fn enumerate_usb_devices() -> Result<Vec<UsbDevice>> {
let mut devices = match enumerate_via_udev() {
Ok(devs) => devs,
Err(e) => {
eprintln!("vm-curator: libudev enumeration failed ({}), falling back to sysfs", e);
enumerate_via_sysfs().unwrap_or_default()
}
};
devices.retain(|d| !d.is_hub());
Ok(devices)
}
fn enumerate_via_udev() -> Result<Vec<UsbDevice>> {
use libudev::Context;
let context = Context::new().context("Failed to create udev context")?;
let mut enumerator = libudev::Enumerator::new(&context)
.context("Failed to create udev enumerator")?;
enumerator.match_subsystem("usb")
.context("Failed to match USB subsystem")?;
let mut devices = Vec::new();
for device in enumerator.scan_devices()? {
if device.devtype().map(|t| t == "usb_device").unwrap_or(false) {
let vendor_id = device
.attribute_value("idVendor")
.and_then(|v| v.to_str())
.and_then(|s| u16::from_str_radix(s, 16).ok())
.unwrap_or(0);
let product_id = device
.attribute_value("idProduct")
.and_then(|v| v.to_str())
.and_then(|s| u16::from_str_radix(s, 16).ok())
.unwrap_or(0);
if vendor_id == 0x1d6b {
continue;
}
let vendor_name = device
.attribute_value("manufacturer")
.and_then(|v| v.to_str())
.unwrap_or("")
.to_string();
let product_name = device
.attribute_value("product")
.and_then(|v| v.to_str())
.unwrap_or("")
.to_string();
let bus_num = device
.attribute_value("busnum")
.and_then(|v| v.to_str())
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let dev_num = device
.attribute_value("devnum")
.and_then(|v| v.to_str())
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let device_class = device
.attribute_value("bDeviceClass")
.and_then(|v| v.to_str())
.and_then(|s| u8::from_str_radix(s, 16).ok())
.unwrap_or(0);
let usb_version = device
.attribute_value("speed")
.and_then(|v| v.to_str())
.map(UsbVersion::from_speed)
.unwrap_or_else(|| {
device
.attribute_value("bcdUSB")
.and_then(|v| v.to_str())
.and_then(|s| u16::from_str_radix(s, 16).ok())
.map(UsbVersion::from_bcd_usb)
.unwrap_or_default()
});
devices.push(UsbDevice {
vendor_id,
product_id,
vendor_name,
product_name,
bus_num,
dev_num,
device_class,
usb_version,
});
}
}
Ok(devices)
}
fn enumerate_via_sysfs() -> Result<Vec<UsbDevice>> {
let mut devices = Vec::new();
let sysfs_path = std::path::Path::new("/sys/bus/usb/devices");
if !sysfs_path.exists() {
return Ok(devices);
}
for entry in std::fs::read_dir(sysfs_path)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.contains(':') {
continue;
}
let vendor_id = read_sysfs_hex(&path, "idVendor").unwrap_or(0);
let product_id = read_sysfs_hex(&path, "idProduct").unwrap_or(0);
if vendor_id == 0 && product_id == 0 {
continue;
}
if vendor_id == 0x1d6b {
continue;
}
let vendor_name = read_sysfs_string(&path, "manufacturer").unwrap_or_default();
let product_name = read_sysfs_string(&path, "product").unwrap_or_default();
let bus_num = read_sysfs_decimal(&path, "busnum").unwrap_or(0) as u8;
let dev_num = read_sysfs_decimal(&path, "devnum").unwrap_or(0) as u8;
let device_class = read_sysfs_hex(&path, "bDeviceClass").unwrap_or(0) as u8;
let usb_version = read_sysfs_string(&path, "speed")
.map(|s| UsbVersion::from_speed(&s))
.unwrap_or_else(|| {
read_sysfs_hex(&path, "bcdUSB")
.map(UsbVersion::from_bcd_usb)
.unwrap_or_default()
});
devices.push(UsbDevice {
vendor_id,
product_id,
vendor_name,
product_name,
bus_num,
dev_num,
device_class,
usb_version,
});
}
Ok(devices)
}
fn read_sysfs_hex(path: &std::path::Path, attr: &str) -> Option<u16> {
let value = std::fs::read_to_string(path.join(attr)).ok()?;
u16::from_str_radix(value.trim(), 16).ok()
}
fn read_sysfs_decimal(path: &std::path::Path, attr: &str) -> Option<u32> {
let value = std::fs::read_to_string(path.join(attr)).ok()?;
value.trim().parse().ok()
}
fn read_sysfs_string(path: &std::path::Path, attr: &str) -> Option<String> {
std::fs::read_to_string(path.join(attr))
.ok()
.map(|s| s.trim().to_string())
}
#[derive(Debug)]
pub enum UdevInstallResult {
Success,
NeedsReboot,
PermissionDenied,
Error(String),
}
pub fn generate_udev_rules(devices: &[UsbDevice]) -> String {
let mut rules = String::new();
rules.push_str("# USB Passthrough rules for QEMU (managed by vm-curator)\n");
rules.push_str("# These rules allow non-root users to access USB devices for VM passthrough\n\n");
let mut seen_vendors = std::collections::HashSet::new();
for device in devices {
if seen_vendors.insert(device.vendor_id) {
rules.push_str(&format!(
"# {} devices\n",
if device.vendor_name.is_empty() {
format!("Vendor {:04x}", device.vendor_id)
} else {
device.vendor_name.clone()
}
));
rules.push_str(&format!(
"SUBSYSTEM==\"usb\", ATTR{{idVendor}}==\"{:04x}\", MODE=\"0666\"\n\n",
device.vendor_id
));
}
}
rules
}
pub fn install_udev_rules(devices: &[UsbDevice]) -> UdevInstallResult {
use std::os::unix::fs::PermissionsExt;
if devices.is_empty() {
return UdevInstallResult::Error("No devices selected".to_string());
}
let rules_content = generate_udev_rules(devices);
let rules_path = "/etc/udev/rules.d/99-vm-curator-usb.rules";
let temp_path = format!(
"/tmp/vm-curator-usb-rules-{}-{}.tmp",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let file_result = std::fs::File::create(&temp_path)
.and_then(|file| {
let mut perms = file.metadata()?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&temp_path, perms)?;
Ok(())
})
.and_then(|_| std::fs::write(&temp_path, &rules_content));
if let Err(e) = file_result {
let _ = std::fs::remove_file(&temp_path);
return UdevInstallResult::Error(format!("Failed to write temp file: {}", e));
}
let install_result = try_pkexec_install(&temp_path, rules_path)
.or_else(|| try_sudo_install(&temp_path, rules_path));
let _ = std::fs::remove_file(&temp_path);
match install_result {
Some(true) => {
let reload_result = reload_udev_rules();
if reload_result {
UdevInstallResult::Success
} else {
UdevInstallResult::NeedsReboot
}
}
Some(false) => UdevInstallResult::PermissionDenied,
None => UdevInstallResult::Error("No suitable privilege escalation method found".to_string()),
}
}
fn try_pkexec_install(temp_path: &str, rules_path: &str) -> Option<bool> {
use std::process::Command;
if Command::new("which").arg("pkexec").output().ok()?.status.success() {
let status = Command::new("pkexec")
.args(["cp", temp_path, rules_path])
.status()
.ok()?;
Some(status.success())
} else {
None
}
}
fn try_sudo_install(temp_path: &str, rules_path: &str) -> Option<bool> {
use std::process::{Command, Stdio};
if !Command::new("which").arg("sudo").output().ok()?.status.success() {
return None;
}
let status = Command::new("sudo")
.args(["cp", temp_path, rules_path])
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.ok()?;
Some(status.success())
}
fn reload_udev_rules() -> bool {
use std::process::Command;
let reload_cmd = "udevadm control --reload-rules && udevadm trigger";
if let Ok(status) = Command::new("pkexec")
.args(["sh", "-c", reload_cmd])
.status()
{
if status.success() {
return true;
}
}
if let Ok(status) = Command::new("sudo")
.args(["sh", "-c", reload_cmd])
.status()
{
return status.success();
}
false
}
#[cfg(test)]
#[path = "tests/usb.rs"]
mod tests;