Skip to main content

kbd_evdev/
devices.rs

1//! Device discovery, hotplug, and capability detection.
2//!
3//! Manages the set of active input devices. Uses inotify to watch
4//! `/dev/input/` for device add/remove events. Probes new devices for
5//! keyboard capabilities before adding them to the poll set.
6//!
7
8use std::collections::HashMap;
9use std::collections::HashSet;
10use std::ffi::CString;
11use std::io;
12use std::mem::size_of;
13use std::os::fd::AsRawFd;
14use std::os::fd::FromRawFd;
15use std::os::fd::OwnedFd;
16use std::os::fd::RawFd;
17use std::os::unix::ffi::OsStrExt;
18use std::path::Path;
19use std::path::PathBuf;
20
21use evdev::Device;
22use evdev::EventSummary;
23use evdev::InputEvent;
24use evdev::KeyCode;
25use kbd::device::DeviceInfo;
26use kbd::key::Key;
27use kbd::key_state::KeyTransition;
28
29use crate::convert::EvdevKeyCodeExt;
30use crate::forwarder::VIRTUAL_DEVICE_NAME;
31
32/// Default path to the Linux input device directory.
33///
34/// [`DeviceManager`] scans this directory for `event*` device nodes and
35/// watches it with inotify for hotplug events.
36pub const INPUT_DIRECTORY: &str = "/dev/input";
37const HOTPLUG_BUFFER_SIZE: usize = 4096;
38
39/// Whether devices should be grabbed for exclusive access.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum DeviceGrabMode {
42    /// Normal mode — listen passively, events reach other applications.
43    Shared,
44    /// Grab mode — exclusive access, events only reach us.
45    Exclusive,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub(crate) struct HotplugFsEvent {
50    pub(crate) mask: u32,
51    pub(crate) device_name: String,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub(crate) enum HotplugPathChange {
56    Added(PathBuf),
57    Removed(PathBuf),
58    Unchanged,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub(crate) enum DiscoveryOutcome {
63    Keyboard,
64    NotKeyboard,
65    Skip,
66}
67
68#[derive(Debug)]
69struct ManagedDevice {
70    path: PathBuf,
71    device: Device,
72    info: DeviceInfo,
73}
74
75/// A key event from a specific device.
76///
77/// Pairs a [`Key`] and [`KeyTransition`] with the file descriptor of the
78/// device that produced it, so callers can track per-device key state.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct DeviceKeyEvent {
81    /// File descriptor of the input device that produced this event.
82    pub device_fd: RawFd,
83    /// The key that was pressed, released, or repeated.
84    pub key: Key,
85    /// Whether this is a press, release, or repeat event.
86    pub transition: KeyTransition,
87}
88
89/// Result of processing polled events.
90///
91/// Separates key events from device disconnections so the caller can
92/// update its own key state tracking without `DeviceManager` needing
93/// access to `KeyState`.
94#[derive(Debug)]
95pub struct PollResult {
96    /// Key events from devices that had data ready.
97    pub key_events: Vec<DeviceKeyEvent>,
98    /// File descriptors of devices that were removed during this poll
99    /// (due to hotplug removal or device errors).
100    pub disconnected_devices: Vec<RawFd>,
101}
102
103/// Manages discovered input devices and watches for hotplug events.
104///
105/// On creation, scans the input directory for keyboard devices and sets
106/// up an inotify watch for add/remove events. Call [`poll_fds`](Self::poll_fds)
107/// to get file descriptors for `poll(2)`, then pass the results to
108/// [`process_polled_events`](Self::process_polled_events) to get key events.
109///
110/// # Lifecycle
111///
112/// 1. Create with [`DeviceManager::new`]
113/// 2. Loop:
114///    a. Call `poll(2)` on [`poll_fds()`](Self::poll_fds)
115///    b. Pass the polled fds to [`process_polled_events`](Self::process_polled_events)
116///    c. Handle the returned [`PollResult`]
117#[derive(Debug)]
118pub struct DeviceManager {
119    input_dir: PathBuf,
120    grab_mode: DeviceGrabMode,
121    inotify_fd: Option<OwnedFd>,
122    devices: HashMap<RawFd, ManagedDevice>,
123    poll_fds: Vec<RawFd>,
124}
125
126impl Default for DeviceManager {
127    fn default() -> Self {
128        Self::new(Path::new(INPUT_DIRECTORY), DeviceGrabMode::Shared)
129    }
130}
131
132impl DeviceManager {
133    /// Create a new device manager for the given input directory.
134    ///
135    /// Scans `input_dir` for existing keyboard devices and sets up an
136    /// inotify watch for hotplug events. If inotify initialization fails,
137    /// hotplug detection is silently disabled.
138    ///
139    /// Use [`DeviceGrabMode::Exclusive`] to grab devices for exclusive
140    /// access (requires write permission on the device nodes).
141    #[must_use]
142    pub fn new(input_dir: &Path, grab_mode: DeviceGrabMode) -> Self {
143        let mut manager = Self {
144            input_dir: input_dir.to_path_buf(),
145            grab_mode,
146            inotify_fd: initialize_inotify(input_dir).ok(),
147            devices: HashMap::new(),
148            poll_fds: Vec::new(),
149        };
150
151        manager.discover_existing_devices();
152        manager.rebuild_poll_fds();
153        manager
154    }
155
156    fn discover_existing_devices(&mut self) {
157        let discover_result =
158            discover_devices_in_dir_with(&self.input_dir, DiscoveryOutcome::probe);
159
160        if let Ok(paths) = discover_result {
161            for path in paths {
162                self.add_device_path(&path);
163            }
164        }
165    }
166
167    fn add_device_path(&mut self, path: &Path) {
168        if self.devices.values().any(|device| device.path == path) {
169            return;
170        }
171
172        let open_result = ManagedDevice::open(path, self.grab_mode);
173        let Some(device) = open_result.ok().flatten() else {
174            return;
175        };
176
177        let fd = device.device.as_raw_fd();
178        self.devices.insert(fd, device);
179        self.rebuild_poll_fds();
180    }
181
182    fn remove_device_fd(&mut self, fd: RawFd) -> bool {
183        if self.devices.remove(&fd).is_some() {
184            self.rebuild_poll_fds();
185            true
186        } else {
187            false
188        }
189    }
190
191    fn remove_device_path(&mut self, path: &Path) -> Option<RawFd> {
192        let fd = self
193            .devices
194            .iter()
195            .find_map(|(&fd, device)| (device.path == path).then_some(fd))?;
196
197        if self.remove_device_fd(fd) {
198            Some(fd)
199        } else {
200            None
201        }
202    }
203
204    fn rebuild_poll_fds(&mut self) {
205        self.poll_fds.clear();
206
207        if let Some(inotify_fd) = self.inotify_fd.as_ref() {
208            self.poll_fds.push(inotify_fd.as_raw_fd());
209        }
210
211        let mut device_fds: Vec<_> = self.devices.keys().copied().collect();
212        device_fds.sort_unstable();
213        self.poll_fds.extend(device_fds);
214    }
215
216    fn process_hotplug_events(&mut self, disconnected: &mut Vec<RawFd>) {
217        let Some(inotify_fd) = self.inotify_fd.as_ref().map(AsRawFd::as_raw_fd) else {
218            return;
219        };
220
221        let mut buffer = [0_u8; HOTPLUG_BUFFER_SIZE];
222        let mut known_paths: HashSet<PathBuf> = self
223            .devices
224            .values()
225            .map(|device| device.path.clone())
226            .collect();
227
228        loop {
229            // SAFETY: `buffer` is valid writable memory and `inotify_fd`
230            // references an open inotify descriptor.
231            let read_result = unsafe {
232                libc::read(
233                    inotify_fd,
234                    (&raw mut buffer).cast::<libc::c_void>(),
235                    buffer.len(),
236                )
237            };
238
239            if read_result < 0 {
240                let error = io::Error::last_os_error();
241                if error.kind() == io::ErrorKind::Interrupted {
242                    continue;
243                }
244                if error.kind() == io::ErrorKind::WouldBlock {
245                    break;
246                }
247                break;
248            }
249
250            if read_result == 0 {
251                break;
252            }
253
254            let bytes_read = usize::try_from(read_result).unwrap_or(0);
255            for event in parse_hotplug_events(&buffer, bytes_read) {
256                match event.classify_change(&mut known_paths, &self.input_dir) {
257                    HotplugPathChange::Added(path) => {
258                        self.add_device_path(&path);
259                    }
260                    HotplugPathChange::Removed(path) => {
261                        if let Some(fd) = self.remove_device_path(&path) {
262                            disconnected.push(fd);
263                        }
264                    }
265                    HotplugPathChange::Unchanged => {}
266                }
267            }
268        }
269    }
270
271    fn process_device_fd(
272        &mut self,
273        fd: RawFd,
274        revents: i16,
275        collected_events: &mut Vec<DeviceKeyEvent>,
276        disconnected: &mut Vec<RawFd>,
277    ) {
278        if (revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL)) != 0 {
279            if self.remove_device_fd(fd) {
280                disconnected.push(fd);
281            }
282            return;
283        }
284
285        if (revents & libc::POLLIN) == 0 {
286            return;
287        }
288
289        let Some(device) = self.devices.get_mut(&fd) else {
290            return;
291        };
292
293        match device.device.read_key_events() {
294            Ok(events) => {
295                for event in events {
296                    collected_events.push(DeviceKeyEvent {
297                        device_fd: fd,
298                        key: event.key,
299                        transition: event.transition,
300                    });
301                }
302            }
303            Err(error) if should_drop_device(&error) => {
304                if self.remove_device_fd(fd) {
305                    disconnected.push(fd);
306                }
307            }
308            Err(_) => {}
309        }
310    }
311
312    /// File descriptors to pass to `poll(2)`.
313    ///
314    /// Includes the inotify watch descriptor (if active) followed by all
315    /// open device descriptors. The order may change after hotplug events.
316    #[must_use]
317    pub fn poll_fds(&self) -> &[RawFd] {
318        &self.poll_fds
319    }
320
321    /// Get device info for a device identified by file descriptor.
322    ///
323    /// Returns `None` if the fd doesn't correspond to a managed device.
324    #[must_use]
325    pub fn device_info(&self, fd: RawFd) -> Option<&DeviceInfo> {
326        self.devices.get(&fd).map(|managed| &managed.info)
327    }
328
329    /// Process all ready file descriptors from a completed poll.
330    ///
331    /// Returns key events and a list of device fds that were disconnected,
332    /// so the caller can update its own key state tracking.
333    pub fn process_polled_events(&mut self, polled_fds: &[libc::pollfd]) -> PollResult {
334        let mut key_events = Vec::new();
335        let mut disconnected_devices = Vec::new();
336
337        let ready_fds: Vec<_> = polled_fds
338            .iter()
339            .filter(|pollfd| pollfd.revents != 0)
340            .map(|pollfd| (pollfd.fd, pollfd.revents))
341            .collect();
342
343        for (fd, revents) in ready_fds {
344            if self
345                .inotify_fd
346                .as_ref()
347                .is_some_and(|inotify_fd| inotify_fd.as_raw_fd() == fd)
348            {
349                self.process_hotplug_events(&mut disconnected_devices);
350            } else {
351                self.process_device_fd(fd, revents, &mut key_events, &mut disconnected_devices);
352            }
353        }
354
355        PollResult {
356            key_events,
357            disconnected_devices,
358        }
359    }
360}
361
362fn initialize_inotify(input_dir: &Path) -> io::Result<OwnedFd> {
363    // SAFETY: Calls libc with constant flags.
364    let raw_fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) };
365    if raw_fd < 0 {
366        return Err(io::Error::last_os_error());
367    }
368
369    // SAFETY: `raw_fd` is an owned descriptor returned by `inotify_init1`.
370    let fd = unsafe { OwnedFd::from_raw_fd(raw_fd) };
371    let path_cstr = CString::new(input_dir.as_os_str().as_bytes()).map_err(|_| {
372        io::Error::new(
373            io::ErrorKind::InvalidInput,
374            "input directory contained interior NUL byte",
375        )
376    })?;
377
378    // SAFETY: `fd` is a valid inotify descriptor and `path_cstr` points to a
379    // valid NUL-terminated string.
380    let watch_result = unsafe {
381        libc::inotify_add_watch(
382            fd.as_raw_fd(),
383            path_cstr.as_ptr(),
384            libc::IN_CREATE
385                | libc::IN_DELETE
386                | libc::IN_MOVED_FROM
387                | libc::IN_MOVED_TO
388                | libc::IN_DELETE_SELF
389                | libc::IN_MOVE_SELF,
390        )
391    };
392
393    if watch_result < 0 {
394        return Err(io::Error::last_os_error());
395    }
396
397    Ok(fd)
398}
399
400impl DiscoveryOutcome {
401    fn probe(path: &Path) -> Self {
402        let Ok(device) = Device::open(path) else {
403            return Self::Skip;
404        };
405
406        if device.is_virtual_forwarder() {
407            return Self::Skip;
408        }
409
410        if device.is_keyboard() {
411            Self::Keyboard
412        } else {
413            Self::NotKeyboard
414        }
415    }
416}
417
418impl ManagedDevice {
419    fn open(path: &Path, grab_mode: DeviceGrabMode) -> io::Result<Option<Self>> {
420        let mut device = Device::open(path)?;
421
422        if !device.is_keyboard() {
423            return Ok(None);
424        }
425
426        if device.is_virtual_forwarder() {
427            return Ok(None);
428        }
429
430        let input_id = device.input_id();
431        let info = DeviceInfo::new(
432            device.name().unwrap_or(""),
433            input_id.vendor(),
434            input_id.product(),
435        );
436
437        if matches!(grab_mode, DeviceGrabMode::Exclusive) {
438            device.grab()?;
439        }
440
441        device.set_nonblocking(true)?;
442
443        Ok(Some(Self {
444            path: path.to_path_buf(),
445            device,
446            info,
447        }))
448    }
449}
450
451trait DeviceExt {
452    /// Returns `true` if this device is our own virtual forwarder.
453    ///
454    /// Used to prevent feedback loops: the forwarder creates a virtual keyboard
455    /// device, and without this check we'd discover and grab our own output device.
456    fn is_virtual_forwarder(&self) -> bool;
457
458    /// Returns `true` if this device looks like a keyboard (supports A-Z + Enter).
459    fn is_keyboard(&self) -> bool;
460
461    /// Reads pending events and converts them to domain key events.
462    fn read_key_events(&mut self) -> io::Result<Vec<ObservedKeyEvent>>;
463}
464
465impl DeviceExt for Device {
466    fn is_virtual_forwarder(&self) -> bool {
467        self.name().is_some_and(|name| name == VIRTUAL_DEVICE_NAME)
468    }
469
470    fn is_keyboard(&self) -> bool {
471        self.supported_keys().is_some_and(|supported_keys| {
472            supported_keys.contains(KeyCode::KEY_A)
473                && supported_keys.contains(KeyCode::KEY_Z)
474                && supported_keys.contains(KeyCode::KEY_ENTER)
475        })
476    }
477
478    fn read_key_events(&mut self) -> io::Result<Vec<ObservedKeyEvent>> {
479        let mut events = Vec::new();
480
481        for event in self.fetch_events()? {
482            if let Some(observed) = ObservedKeyEvent::from_input_event(event) {
483                events.push(observed);
484            }
485        }
486
487        Ok(events)
488    }
489}
490
491pub(crate) fn discover_devices_in_dir_with<F>(
492    input_dir: &Path,
493    mut classify: F,
494) -> io::Result<Vec<PathBuf>>
495where
496    F: FnMut(&Path) -> DiscoveryOutcome,
497{
498    let mut device_paths = Vec::new();
499
500    for entry_result in std::fs::read_dir(input_dir)? {
501        let Ok(entry) = entry_result else {
502            continue;
503        };
504
505        let path = entry.path();
506        let Some(name) = path.file_name().and_then(|candidate| candidate.to_str()) else {
507            continue;
508        };
509
510        if !name.starts_with("event") {
511            continue;
512        }
513
514        if matches!(classify(&path), DiscoveryOutcome::Keyboard) {
515            device_paths.push(path);
516        }
517    }
518
519    device_paths.sort_unstable();
520    Ok(device_paths)
521}
522
523#[derive(Debug, Clone, Copy, PartialEq, Eq)]
524struct ObservedKeyEvent {
525    key: Key,
526    transition: KeyTransition,
527}
528
529impl ObservedKeyEvent {
530    fn from_input_event(event: InputEvent) -> Option<Self> {
531        match event.destructure() {
532            EventSummary::Key(_, key_code, value) => {
533                let transition = key_transition(value)?;
534                let key = key_code.to_key();
535                if key == Key::UNIDENTIFIED {
536                    return None;
537                }
538                Some(Self { key, transition })
539            }
540            _ => None,
541        }
542    }
543}
544
545fn key_transition(value: i32) -> Option<KeyTransition> {
546    match value {
547        1 => Some(KeyTransition::Press),
548        0 => Some(KeyTransition::Release),
549        2 => Some(KeyTransition::Repeat),
550        _ => None,
551    }
552}
553
554fn should_drop_device(error: &io::Error) -> bool {
555    error.raw_os_error() == Some(libc::ENODEV)
556        || error.kind() == io::ErrorKind::NotFound
557        || error.kind() == io::ErrorKind::UnexpectedEof
558}
559
560impl HotplugFsEvent {
561    pub fn classify_change(
562        &self,
563        known_paths: &mut HashSet<PathBuf>,
564        input_dir: &Path,
565    ) -> HotplugPathChange {
566        if !self.device_name.starts_with("event") {
567            return HotplugPathChange::Unchanged;
568        }
569
570        let path = input_dir.join(&self.device_name);
571
572        if self.mask & (libc::IN_CREATE | libc::IN_MOVED_TO) != 0
573            && known_paths.insert(path.clone())
574        {
575            return HotplugPathChange::Added(path);
576        }
577
578        if self.mask
579            & (libc::IN_DELETE | libc::IN_MOVED_FROM | libc::IN_DELETE_SELF | libc::IN_MOVE_SELF)
580            != 0
581        {
582            known_paths.remove(&path);
583            return HotplugPathChange::Removed(path);
584        }
585
586        HotplugPathChange::Unchanged
587    }
588}
589
590#[must_use]
591pub(crate) fn parse_hotplug_events(buffer: &[u8], bytes_read: usize) -> Vec<HotplugFsEvent> {
592    let mut events = Vec::new();
593    let mut offset = 0_usize;
594
595    while offset + size_of::<libc::inotify_event>() <= bytes_read {
596        #[allow(clippy::cast_ptr_alignment)]
597        let event_ptr = buffer[offset..].as_ptr().cast::<libc::inotify_event>();
598        // SAFETY: `event_ptr` points into `buffer`. We only read when enough
599        // bytes are available, and use unaligned reads to handle kernel-packed
600        // event boundaries.
601        let event = unsafe { std::ptr::read_unaligned(event_ptr) };
602
603        let name_start = offset + size_of::<libc::inotify_event>();
604        let Some(name_end) = name_start.checked_add(event.len as usize) else {
605            break;
606        };
607
608        let device_name = if event.len > 0 && name_end <= bytes_read {
609            parse_hotplug_name(&buffer[name_start..name_end])
610        } else {
611            String::new()
612        };
613
614        events.push(HotplugFsEvent {
615            mask: event.mask,
616            device_name,
617        });
618
619        let Some(next_offset) =
620            offset.checked_add(size_of::<libc::inotify_event>() + event.len as usize)
621        else {
622            break;
623        };
624
625        offset = next_offset;
626    }
627
628    events
629}
630
631fn parse_hotplug_name(name_bytes: &[u8]) -> String {
632    let name_end = name_bytes
633        .iter()
634        .position(|&byte| byte == 0)
635        .unwrap_or(name_bytes.len());
636
637    String::from_utf8_lossy(&name_bytes[..name_end]).into_owned()
638}
639
640#[cfg(test)]
641mod tests {
642    use std::path::Path;
643    use std::time::SystemTime;
644    use std::time::UNIX_EPOCH;
645
646    use evdev::EventType;
647    use evdev::InputEvent;
648    use evdev::KeyCode;
649    use kbd::key::Key;
650    use kbd::key_state::KeyTransition;
651
652    use super::DiscoveryOutcome;
653    use super::HotplugFsEvent;
654    use super::HotplugPathChange;
655    use super::ObservedKeyEvent;
656    use super::discover_devices_in_dir_with;
657    use super::parse_hotplug_events;
658    use crate::forwarder::VIRTUAL_DEVICE_NAME;
659
660    #[test]
661    fn discover_event_devices_ignores_non_event_entries_and_non_keyboards() {
662        let temp = unique_test_dir();
663
664        std::fs::create_dir_all(&temp).expect("temp dir should be created");
665        std::fs::File::create(temp.join("event0"))
666            .expect("event0 should be created for discovery test");
667        std::fs::File::create(temp.join("event1"))
668            .expect("event1 should be created for discovery test");
669        std::fs::File::create(temp.join("mouse0"))
670            .expect("mouse0 should be created for discovery test");
671
672        let keyboards = discover_devices_in_dir_with(&temp, |path| {
673            match path.file_name().and_then(|name| name.to_str()) {
674                Some("event0") => DiscoveryOutcome::Keyboard,
675                Some("event1") => DiscoveryOutcome::NotKeyboard,
676                _ => DiscoveryOutcome::Skip,
677            }
678        })
679        .expect("discovery should succeed for temp dir");
680
681        assert_eq!(keyboards, vec![temp.join("event0")]);
682
683        std::fs::remove_dir_all(temp).expect("temp dir should be removed");
684    }
685
686    #[test]
687    fn parse_hotplug_events_extracts_device_names() {
688        let mut buffer = Vec::new();
689
690        append_inotify_event(&mut buffer, libc::IN_CREATE, "event3");
691        append_inotify_event(&mut buffer, libc::IN_DELETE, "mouse0");
692
693        let events = parse_hotplug_events(&buffer, buffer.len());
694        assert_eq!(
695            events,
696            vec![
697                HotplugFsEvent {
698                    mask: libc::IN_CREATE,
699                    device_name: "event3".into(),
700                },
701                HotplugFsEvent {
702                    mask: libc::IN_DELETE,
703                    device_name: "mouse0".into(),
704                },
705            ]
706        );
707    }
708
709    #[test]
710    fn classify_hotplug_change_distinguishes_add_remove_and_ignore() {
711        let mut known_paths = std::collections::HashSet::new();
712        let input_dir = Path::new("/dev/input");
713
714        let add_event = HotplugFsEvent {
715            mask: libc::IN_CREATE,
716            device_name: "event7".into(),
717        };
718        let added = add_event.classify_change(&mut known_paths, input_dir);
719        assert_eq!(added, HotplugPathChange::Added(input_dir.join("event7")));
720
721        let remove_event = HotplugFsEvent {
722            mask: libc::IN_DELETE,
723            device_name: "event7".into(),
724        };
725        let removed = remove_event.classify_change(&mut known_paths, input_dir);
726        assert_eq!(
727            removed,
728            HotplugPathChange::Removed(input_dir.join("event7"))
729        );
730
731        let ignored = HotplugFsEvent {
732            mask: libc::IN_CREATE,
733            device_name: "js0".into(),
734        }
735        .classify_change(&mut known_paths, input_dir);
736        assert_eq!(ignored, HotplugPathChange::Unchanged);
737    }
738
739    #[test]
740    fn virtual_forwarder_name_is_detected() {
741        let is_forwarder = |name: &str| name == VIRTUAL_DEVICE_NAME;
742
743        assert!(is_forwarder(VIRTUAL_DEVICE_NAME));
744        assert!(!is_forwarder("AT Translated Set 2 keyboard"));
745        assert!(!is_forwarder("Logitech USB Keyboard"));
746        assert!(!is_forwarder(""));
747    }
748
749    #[test]
750    fn key_input_events_are_converted_to_domain_keys() {
751        let press = ObservedKeyEvent::from_input_event(InputEvent::new(
752            EventType::KEY.0,
753            KeyCode::KEY_C.0,
754            1,
755        ));
756        assert_eq!(
757            press,
758            Some(ObservedKeyEvent {
759                key: Key::C,
760                transition: KeyTransition::Press,
761            })
762        );
763
764        let release = ObservedKeyEvent::from_input_event(InputEvent::new(
765            EventType::KEY.0,
766            KeyCode::KEY_C.0,
767            0,
768        ));
769        assert_eq!(
770            release,
771            Some(ObservedKeyEvent {
772                key: Key::C,
773                transition: KeyTransition::Release,
774            })
775        );
776
777        let repeat = ObservedKeyEvent::from_input_event(InputEvent::new(
778            EventType::KEY.0,
779            KeyCode::KEY_C.0,
780            2,
781        ));
782        assert_eq!(
783            repeat,
784            Some(ObservedKeyEvent {
785                key: Key::C,
786                transition: KeyTransition::Repeat,
787            })
788        );
789
790        let ignored = ObservedKeyEvent::from_input_event(InputEvent::new(
791            EventType::KEY.0,
792            KeyCode::new(1023).0,
793            1,
794        ));
795        assert_eq!(ignored, None);
796    }
797
798    fn unique_test_dir() -> std::path::PathBuf {
799        let nanos = SystemTime::now()
800            .duration_since(UNIX_EPOCH)
801            .expect("system clock should be after unix epoch")
802            .as_nanos();
803
804        std::env::temp_dir().join(format!(
805            "kbd-discovery-test-{}-{}",
806            std::process::id(),
807            nanos
808        ))
809    }
810
811    fn append_inotify_event(buffer: &mut Vec<u8>, mask: u32, name: &str) {
812        let mut name_bytes = name.as_bytes().to_vec();
813        name_bytes.push(0);
814
815        let event = libc::inotify_event {
816            wd: 1,
817            mask,
818            cookie: 0,
819            len: u32::try_from(name_bytes.len()).expect("name should fit in u32"),
820        };
821
822        // SAFETY: We are serializing a POD C struct to a byte buffer.
823        let event_bytes = unsafe {
824            std::slice::from_raw_parts(
825                (&raw const event).cast::<u8>(),
826                std::mem::size_of::<libc::inotify_event>(),
827            )
828        };
829
830        buffer.extend_from_slice(event_bytes);
831        buffer.extend_from_slice(&name_bytes);
832    }
833}