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