use bevy_ecs::prelude::*; use rustix::{fs as rfs, io, ioctl}; use std::collections::HashMap; use std::os::fd::{AsFd, OwnedFd}; use std::os::unix::fs::{FileTypeExt, MetadataExt}; use std::{fs, path}; const MAX_SEAT_DEVICES: usize = 128; #[derive(Resource, Default)] pub struct DeviceIndex { by_object: HashMap, by_client_path: HashMap<(Entity, path::PathBuf), u64>, devices: HashMap, next_device: u64, next_id: u32, } struct DeviceHandle { client: Entity, device: u64, id: u32, } struct DeviceEntry { client: Entity, path: path::PathBuf, kind: DeviceKind, fd: OwnedFd, handles: usize, active: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct DeviceInfo { pub id: u32, pub path: path::PathBuf, pub kind: DeviceKind, pub active: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DeviceKind { Evdev, Drm, Wscons, Hidraw, } impl DeviceKind { pub fn classify(metadata: &DeviceMetadata, path: &path::Path) -> Option { match (metadata.subsystem.as_str(), metadata.sysname.as_str()) { ("input", name) if name.starts_with("event") => Some(Self::Evdev), ("drm", _) => Some(Self::Drm), ("hidraw", _) => Some(Self::Hidraw), _ if path.to_str().is_some_and(|value| { value.starts_with("/dev/wskbd") || value.starts_with("/dev/wsmouse") || value.starts_with("/dev/wsmux") }) => { Some(Self::Wscons) } _ => None, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeviceMetadata { pub subsystem: String, pub sysname: String, pub syspath: path::PathBuf, } pub fn metadata_for_path(path: &path::Path) -> anyhow::Result { let metadata = fs::metadata(path)?; anyhow::ensure!( metadata.file_type().is_char_device(), "device is not a character device" ); metadata_for_devnum( path::Path::new("/sys/dev/char"), rfs::major(metadata.rdev()), rfs::minor(metadata.rdev()), ) } fn metadata_for_devnum( root: &path::Path, major: u32, minor: u32, ) -> anyhow::Result { let syspath = fs::canonicalize(root.join(format!("{major}:{minor}")))?; let sysname = syspath .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| anyhow::anyhow!("sysfs device has no valid sysname"))? .to_owned(); let subsystem = fs::canonicalize(syspath.join("subsystem"))? .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| anyhow::anyhow!("sysfs device has no valid subsystem"))? .to_owned(); Ok(DeviceMetadata { subsystem, sysname, syspath, }) } pub fn open( world: &mut World, client_entity: Entity, object: crate::seat_v1::device::Device, requested_path: &str, ) { if !client_can_open(world, client_entity) { object.send_failed( crate::seat_v1::DeviceError::SeatDisabled, io::Errno::PERM.raw_os_error().cast_unsigned(), "open is only valid for the active seat client", ); return; } let path = match fs::canonicalize(requested_path) { Ok(path) => path, Err(error) => { object.send_failed( crate::seat_v1::DeviceError::OpenFailed, error .raw_os_error() .unwrap_or(io::Errno::NOENT.raw_os_error()) .cast_unsigned(), "could not canonicalize device path", ); return; } }; let metadata = match metadata_for_path(&path) { Ok(metadata) => metadata, Err(error) => { object.send_failed( crate::seat_v1::DeviceError::OpenFailed, io::Errno::NODEV.raw_os_error().cast_unsigned(), format!("could not resolve sysfs device: {error}"), ); return; } }; let Some(kind) = DeviceKind::classify(&metadata, &path) else { object.send_failed( crate::seat_v1::DeviceError::OpenFailed, io::Errno::INVAL.raw_os_error().cast_unsigned(), "unsupported device type", ); return; }; let assigned_seat = { let config = world.non_send::(); config.seats.seat_for_device( &config.lua, crate::config::Device { subsystem: metadata.subsystem.clone(), sysname: metadata.sysname.clone(), syspath: metadata.syspath.to_string_lossy().into_owned(), }, ) }; let assigned_seat = match assigned_seat { Ok(seat) => seat, Err(error) => { object.send_failed( crate::seat_v1::DeviceError::OpenFailed, io::Errno::IO.raw_os_error().cast_unsigned(), format!("device matcher failed: {error}"), ); return; } }; let client_seat = world .get::(client_entity) .and_then(|session| world.get::(session.0)) .and_then(|seat| world.get::(seat.0)) .map(|name| name.0.as_str()); if client_seat != Some(assigned_seat.as_str()) { object.send_failed( crate::seat_v1::DeviceError::OpenFailed, io::Errno::PERM.raw_os_error().cast_unsigned(), "device belongs to another seat", ); return; } let client_device_count = world .resource::() .by_object .values() .filter(|handle| handle.client == client_entity) .count(); if client_device_count >= MAX_SEAT_DEVICES { object.send_failed( crate::seat_v1::DeviceError::OpenFailed, io::Errno::MFILE.raw_os_error().cast_unsigned(), "client exceeded max seat devices", ); return; } let existing = world .resource::() .by_client_path .get(&(client_entity, path.clone())) .copied(); if let Some(device_key) = existing { let mut index = world.resource_mut::(); let id = index.allocate_id(); let Some(entry) = index.devices.get_mut(&device_key) else { index.by_client_path.remove(&(client_entity, path)); object.send_failed( crate::seat_v1::DeviceError::OpenFailed, io::Errno::IO.raw_os_error().cast_unsigned(), "invalid shared device state", ); return; }; entry.handles += 1; let path_string = entry.path.to_string_lossy().into_owned(); object.send_opened(id, &entry.fd, &path_string); index.by_object.insert( object, DeviceHandle { client: client_entity, device: device_key, id, }, ); log::info!(client:? = client_entity, device_id = id, path = path_string.as_str(); "device handle reused"); return; } let fd = match rfs::open( &path, rfs::OFlags::RDWR | rfs::OFlags::NOCTTY | rfs::OFlags::NOFOLLOW | rfs::OFlags::CLOEXEC | rfs::OFlags::NONBLOCK, rfs::Mode::empty(), ) { Ok(fd) => fd, Err(error) => { object.send_failed( crate::seat_v1::DeviceError::OpenFailed, error.raw_os_error().cast_unsigned(), "could not open device", ); return; } }; if kind == DeviceKind::Drm && let Err(error) = drm_set_master(&fd) { object.send_failed( crate::seat_v1::DeviceError::OpenFailed, error.raw_os_error().cast_unsigned(), "could not acquire DRM master", ); return; } let path_string = path.to_string_lossy().into_owned(); let mut index = world.resource_mut::(); let id = index.allocate_id(); index.next_device = index.next_device.saturating_add(1).max(1); let device_key = index.next_device; object.send_opened(id, &fd, &path_string); index.devices.insert( device_key, DeviceEntry { client: client_entity, path: path.clone(), kind, fd, handles: 1, active: true, }, ); index .by_client_path .insert((client_entity, path), device_key); index.by_object.insert( object, DeviceHandle { client: client_entity, device: device_key, id, }, ); log::info!(client:? = client_entity, device_id = id, path = path_string.as_str(); "device opened"); } impl DeviceIndex { pub(crate) fn ids_for_client(&self, client: Entity) -> Vec { self.by_object .values() .filter(|handle| handle.client == client) .map(|handle| handle.id) .collect() } pub(crate) fn info_for_client_id(&self, client: Entity, id: u32) -> Option { self.by_object.values().find_map(|handle| { (handle.client == client && handle.id == id).then(|| { let entry = self.devices.get(&handle.device)?; Some(DeviceInfo { id, path: entry.path.clone(), kind: entry.kind, active: entry.active, }) })? }) } fn allocate_id(&mut self) -> u32 { self.next_id = self.next_id.saturating_add(1).max(1); self.next_id } fn release_handle(&mut self, handle: DeviceHandle) { let Some(entry) = self.devices.get_mut(&handle.device) else { return; }; entry.handles = entry.handles.saturating_sub(1); log::info!(client:? = handle.client, device_id = handle.id, path = entry.path.display().to_string().as_str(); "device handle closed"); if entry.handles != 0 { return; } let Some(entry) = self.devices.remove(&handle.device) else { return; }; let path_key = (entry.client, entry.path.clone()); if self.by_client_path.get(&path_key) == Some(&handle.device) { self.by_client_path.remove(&path_key); } release_entry(&entry); } } pub fn close(world: &mut World, object: &crate::seat_v1::device::Device) { let Some(handle) = world.resource_mut::().by_object.remove(object) else { log::warn!("received destroy for unknown device object"); return; }; world.resource_mut::().release_handle(handle); } pub fn close_client_devices(index: &mut DeviceIndex, client: Entity) { let objects: Vec<_> = index .by_object .iter() .filter_map(|(object, handle)| (handle.client == client).then_some(object.clone())) .collect(); for object in objects { if let Some(handle) = index.by_object.remove(&object) { index.release_handle(handle); } } } pub fn deactivate_client_devices(index: &mut DeviceIndex, client: Entity) { let keys: Vec<_> = index .devices .iter() .filter_map(|(key, entry)| (entry.client == client && entry.active).then_some(*key)) .collect(); for key in keys { let Some(entry) = index.devices.get_mut(&key) else { continue; }; let result = match entry.kind { DeviceKind::Drm => drm_drop_master(&entry.fd), DeviceKind::Evdev => evdev_revoke(&entry.fd), DeviceKind::Hidraw => hidraw_revoke(&entry.fd), DeviceKind::Wscons => Ok(()), }; if let Err(error) = result { log::warn!(path = entry.path.display().to_string().as_str(), error = error.to_string().as_str(); "failed to revoke device"); } entry.active = false; if matches!(entry.kind, DeviceKind::Evdev | DeviceKind::Hidraw) { index .by_client_path .remove(&(entry.client, entry.path.clone())); } } } pub fn activate_client_devices(index: &mut DeviceIndex, client: Entity) -> anyhow::Result<()> { for entry in index .devices .values_mut() .filter(|entry| entry.client == client && !entry.active) { match entry.kind { DeviceKind::Drm => drm_set_master(&entry.fd)?, DeviceKind::Wscons => {} DeviceKind::Evdev | DeviceKind::Hidraw => continue, } entry.active = true; } Ok(()) } fn release_entry(entry: &DeviceEntry) { if entry.active && entry.kind == DeviceKind::Drm && let Err(error) = drm_drop_master(&entry.fd) { log::warn!(path = entry.path.display().to_string().as_str(), error = error.to_string().as_str(); "failed to release DRM master"); } } fn client_can_open(world: &World, client: Entity) -> bool { if world .get::(client) .is_none_or(|state| *state != crate::ecs::ClientState::Enabled) { return false; } let Some(session) = world .get::(client) .map(|value| value.0) else { return false; }; let Some(seat) = world .get::(session) .map(|value| value.0) else { return false; }; world .get::(seat) .is_some_and(|current| current.0 == Some(client)) && world .get::(session) .is_some_and(|state| state.0 == crate::session_core_v1::ActiveState::Active) } const DRM_SET_MASTER: ioctl::Opcode = ioctl::opcode::none(b'd', 0x1e); const DRM_DROP_MASTER: ioctl::Opcode = ioctl::opcode::none(b'd', 0x1f); const EVIOCREVOKE: ioctl::Opcode = ioctl::opcode::write::(b'E', 0x91); const HIDIOCREVOKE: ioctl::Opcode = ioctl::opcode::write::(b'H', 0x0d); fn drm_set_master(fd: Fd) -> io::Result<()> { unsafe { ioctl::ioctl(fd, ioctl::NoArg::::new()) } } fn drm_drop_master(fd: Fd) -> io::Result<()> { unsafe { ioctl::ioctl(fd, ioctl::NoArg::::new()) } } fn evdev_revoke(fd: Fd) -> io::Result<()> { unsafe { ioctl::ioctl(fd, ioctl::IntegerSetter::::new_usize(0)) } } fn hidraw_revoke(fd: Fd) -> io::Result<()> { unsafe { ioctl::ioctl(fd, ioctl::IntegerSetter::::new_usize(0)) } } #[cfg(test)] mod tests { use super::*; use std::os::unix::fs::symlink; #[test] fn classifies_supported_devices() { let metadata = |subsystem: &str, sysname: &str| DeviceMetadata { subsystem: subsystem.to_owned(), sysname: sysname.to_owned(), syspath: path::PathBuf::from("/sys/devices/test"), }; assert_eq!( DeviceKind::classify( &metadata("input", "event4"), path::Path::new("/dev/input/event4") ), Some(DeviceKind::Evdev) ); assert_eq!( DeviceKind::classify(&metadata("drm", "card0"), path::Path::new("/dev/dri/card0")), Some(DeviceKind::Drm) ); assert_eq!( DeviceKind::classify( &metadata("hidraw", "hidraw0"), path::Path::new("/dev/hidraw0") ), Some(DeviceKind::Hidraw) ); assert_eq!( DeviceKind::classify( &metadata("sound", "controlC0"), path::Path::new("/dev/snd/controlC0") ), None ); } #[test] fn resolves_sysfs_metadata() -> anyhow::Result<()> { let root = std::env::temp_dir().join(format!("sessiond-seat-sysfs-{}", std::process::id())); let device = root.join("devices/pci/card0"); let subsystem = root.join("class/drm"); fs::create_dir_all(&device)?; fs::create_dir_all(&subsystem)?; fs::create_dir_all(root.join("dev/char"))?; symlink(&subsystem, device.join("subsystem"))?; symlink(&device, root.join("dev/char/226:0"))?; let metadata = metadata_for_devnum(&root.join("dev/char"), 226, 0)?; assert_eq!(metadata.subsystem, "drm"); assert_eq!(metadata.sysname, "card0"); assert_eq!(metadata.syspath, fs::canonicalize(device)?); fs::remove_dir_all(root)?; Ok(()) } #[test] fn final_handle_releases_underlying_device() -> anyhow::Result<()> { let mut index = DeviceIndex::default(); let client = Entity::from_bits(1); let path = path::PathBuf::from("/dev/null"); let fd = rfs::open(&path, rfs::OFlags::RDWR, rfs::Mode::empty())?; index.devices.insert( 1, DeviceEntry { client, path: path.clone(), kind: DeviceKind::Wscons, fd, handles: 2, active: true, }, ); index.by_client_path.insert((client, path), 1); index.release_handle(DeviceHandle { client, device: 1, id: 1, }); assert_eq!(index.devices.get(&1).map(|entry| entry.handles), Some(1)); index .by_client_path .insert((client, path::PathBuf::from("/dev/null")), 2); index.release_handle(DeviceHandle { client, device: 1, id: 2, }); assert!(!index.devices.contains_key(&1)); assert_eq!( index .by_client_path .get(&(client, path::PathBuf::from("/dev/null"))), Some(&2) ); Ok(()) } }