From 2dac6c425066a2a375dfc8dbdfc23e11bab0351c Mon Sep 17 00:00:00 2001 From: killerra <25255685+killerra@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:03:31 +0200 Subject: [PATCH 1/5] Improve Linux process implementation - Implement Process::state() via /proc//stat: zombie/dead states map to ProcessState::Dead (with exit_code when readable), a vanished PID maps to Dead, and permission errors stay Unknown. - Use the module base address as the opaque module handle instead of an index into a freshly parsed maps list, eliminating the race between module_address_list_callback and module_by_address. - Resolve primary_module_address() through /proc//exe instead of assuming the 0th mapping, falling back to the first mapping. - Report real in-process addresses for environment variables using env_start from /proc//stat, and return the env block address from environment_block_address() (plugin API 2). --- src/linux/process.rs | 80 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 62 insertions(+), 18 deletions(-) diff --git a/src/linux/process.rs b/src/linux/process.rs index 0df66e0..1436daa 100644 --- a/src/linux/process.rs +++ b/src/linux/process.rs @@ -115,17 +115,32 @@ impl LinuxProcess { let data = std::fs::read(path) .map_err(|_| Error(ErrorOrigin::OsLayer, ErrorKind::EnvarNotFound))?; + // /proc//environ is exactly the memory range env_start..env_end, + // so in-process addresses can be derived from byte offsets within it. + let env_start = self + .proc_handle() + .ok() + .and_then(|p| p.stat().ok()) + .and_then(|stat| stat.env_start); + let mut out = Vec::new(); - for entry in data.split(|b| *b == 0).filter(|entry| !entry.is_empty()) { - let entry = String::from_utf8_lossy(entry); - if let Some((name, value)) = entry.split_once('=') { - out.push(EnvVarInfo { - name: ReprCString::from(name), - value: ReprCString::from(value), - address: Address::NULL, - arch: self.info.proc_arch, - }); + let mut offset = 0u64; + for entry in data.split(|b| *b == 0) { + let entry_len = entry.len() as u64; + if !entry.is_empty() { + let entry = String::from_utf8_lossy(entry); + if let Some((name, value)) = entry.split_once('=') { + out.push(EnvVarInfo { + name: ReprCString::from(name), + value: ReprCString::from(value), + address: env_start + .map(|start| Address::from(start + offset)) + .unwrap_or(Address::NULL), + arch: self.info.proc_arch, + }); + } } + offset += entry_len + 1; } Ok(out) @@ -152,11 +167,10 @@ impl Process for LinuxProcess { module_maps .iter() - .enumerate() .filter(|_| target_arch.is_none() || Some(&self.info().sys_arch) == target_arch) - .take_while(|(i, _)| { + .take_while(|map| { callback.call(ModuleAddressInfo { - address: Address::from(*i as u64), + address: Address::from(map.address.0), arch: self.info.proc_arch, }) }) @@ -182,7 +196,8 @@ impl Process for LinuxProcess { let module_maps = self.module_maps()?; module_maps - .get(address.to_umem() as usize) + .iter() + .find(|map| Address::from(map.address.0) == address) .map(|map| ModuleInfo { address, parent_process: self.info.address, @@ -223,8 +238,23 @@ impl Process for LinuxProcess { /// /// This will generally be for the initial executable that was run fn primary_module_address(&mut self) -> Result
{ - // TODO: Is it always 0th mod? - Ok(Address::from(0)) + let exe = self.proc_handle()?.exe().ok(); + let module_maps = self.module_maps()?; + + if let Some(exe) = exe { + if let Some(map) = module_maps + .iter() + .find(|m| matches!(&m.pathname, MMapPath::Path(p) if *p == exe)) + { + return Ok(Address::from(map.address.0)); + } + } + + // exe link unreadable or unmatched (e.g. deleted binary) - fall back to first mapping + module_maps + .first() + .map(|m| Address::from(m.address.0)) + .ok_or(Error(ErrorOrigin::OsLayer, ErrorKind::NotFound)) } /// Retrieves the process info @@ -234,7 +264,16 @@ impl Process for LinuxProcess { /// Retrieves the state of the process fn state(&mut self) -> ProcessState { - ProcessState::Unknown + match procfs::process::Process::new(self.pid).and_then(|p| p.stat()) { + Ok(stat) => match stat.state { + // Z = zombie (exited, unreaped), X/x = dead + 'Z' | 'X' | 'x' => ProcessState::Dead(stat.exit_code.unwrap_or(0)), + _ => ProcessState::Alive, + }, + // /proc/ vanished -> the process was reaped; exit code is no longer recoverable + Err(procfs::ProcError::NotFound(_)) => ProcessState::Dead(0), + Err(_) => ProcessState::Unknown, + } } /// Changes the dtb this process uses for memory translations. @@ -321,8 +360,13 @@ impl Process for LinuxProcess { #[cfg(memflow_plugin_api = "2")] fn environment_block_address(&mut self, _architecture: ArchitectureIdent) -> Result
{ - // Linux does not expose a stable public env-block pointer through procfs. - Ok(Address::NULL) + // env_start is only exposed on kernel >= 3.5 and may be hidden by permission checks. + self.proc_handle()? + .stat() + .map_err(|_| Error(ErrorOrigin::OsLayer, ErrorKind::UnableToReadFile))? + .env_start + .map(Address::from) + .ok_or(Error(ErrorOrigin::OsLayer, ErrorKind::NotSupported)) } #[cfg(memflow_plugin_api = "2")] From cbe66e3959f157cc57dceb7d99b5ac3c5639e4d7 Mon Sep 17 00:00:00 2001 From: killerra <25255685+killerra@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:40:34 +0200 Subject: [PATCH 2/5] Fix partial-transfer retry window in process_rw After a partial process_vm_readv/writev, the retry syscall resumes at iov offset `offset`, but the result-dispatch loop iterated the local/ remote iovecs and temp_meta from index 0. On every pass after the first, already-reported entries were re-reported with the wrong metadata and local slices, the entries the retry actually transferred were never reported, byte accounting used the wrong iov_lens, and out_fail flagged the wrong element. This corrupted result attribution for any batched read/write spanning an unmapped hole. Align dispatch and accounting with the syscall window by skipping the first `win` entries on all three iterators. Add a regression test that batches [valid, unmapped, valid] reads against our own PID; it fails against the previous code (first region duplicated, third dropped). --- src/linux/mem.rs | 101 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 5 deletions(-) diff --git a/src/linux/mem.rs b/src/linux/mem.rs index 08f2722..d220690 100644 --- a/src/linux/mem.rs +++ b/src/linux/mem.rs @@ -181,11 +181,18 @@ impl ProcessVirtualMemory { let mut remaining_written = if libcret == -1 { 0 } else { libcret as usize }; - for (liof, (_, meta)) in iov_local - .iter() - .take(cnt) - .zip(iov_remote.iter().zip(self.temp_meta.iter())) - { + // The syscall above operated on the window [win, win + cnt), + // so result dispatch and byte accounting must start at `win` + // too. `offset` is advanced inside the loop, so snapshot it + // before iterating. + let win = offset; + + for (liof, (_, meta)) in iov_local.iter().skip(win).take(cnt).zip( + iov_remote + .iter() + .skip(win) + .zip(self.temp_meta.iter().skip(win)), + ) { offset += 1; let to_write = remaining_written; @@ -250,3 +257,87 @@ impl MemoryView for ProcessVirtualMemory { } } } + +#[cfg(test)] +mod tests { + use super::*; + use memflow::cglue::CTup2; + + fn vmem_for_pid(pid: pid_t) -> ProcessVirtualMemory { + const IOV_MAX: usize = 1024; + ProcessVirtualMemory { + pid, + temp_iov: vec![ + IoSendVec(iovec { + iov_base: std::ptr::null_mut::(), + iov_len: 0, + }); + IOV_MAX * 2 + ] + .into_boxed_slice(), + temp_meta: vec![Address::INVALID; IOV_MAX].into_boxed_slice(), + } + } + + // Regression test for the partial-transfer retry window in `process_rw`. + // + // A batched read of [valid, unmapped, valid] forces `process_vm_readv` to transfer + // the first region, fault on the middle one, and require a retry for the third. + // Before the `.skip(win)` fix the retry dispatched from index 0, re-reporting the + // first region and silently dropping the third. Reading from our own PID lets us + // exercise this without spawning a child. + #[test] + fn partial_read_across_hole_reports_each_region_once() { + let src_a = [0xAAu8; 8]; + let src_c = [0xCCu8; 8]; + + let addr_a = Address::from(src_a.as_ptr() as u64); + let addr_c = Address::from(src_c.as_ptr() as u64); + // Below the default mmap_min_addr, so reliably unmapped (EFAULT on read). + let addr_bad = Address::from(0x1000u64); + + let mut dst_a = [0u8; 8]; + let mut dst_b = [0u8; 8]; + let mut dst_c = [0u8; 8]; + + let mut ok: Vec<(Address, Vec)> = Vec::new(); + let mut fail: Vec
= Vec::new(); + + { + let inp = vec![ + CTup2(addr_a, (&mut dst_a[..]).into()), + CTup2(addr_bad, (&mut dst_b[..]).into()), + CTup2(addr_c, (&mut dst_c[..]).into()), + ]; + + let mut ok_cb = |CTup2(a, d): ReadData| { + ok.push((a, d.to_vec())); + true + }; + let mut fail_cb = |CTup2(a, _): ReadData| { + fail.push(a); + true + }; + let mut ok_oc: ReadCallback = (&mut ok_cb).into(); + let mut fail_oc: ReadCallback = (&mut fail_cb).into(); + + let mut mem = vmem_for_pid(unsafe { libc::getpid() }); + mem.read_iter(inp.into_iter(), Some(&mut ok_oc), Some(&mut fail_oc)) + .unwrap(); + } + + ok.sort_by_key(|(a, _)| a.to_umem()); + let mut expected = vec![(addr_a, vec![0xAAu8; 8]), (addr_c, vec![0xCCu8; 8])]; + expected.sort_by_key(|(a, _)| a.to_umem()); + + assert_eq!( + ok, expected, + "each readable region must be reported exactly once with correct data" + ); + assert_eq!( + fail, + vec![addr_bad], + "the unmapped region must be the only failure" + ); + } +} From 7a0104c6fa688ee54d188340d2ac3113c4c8e171 Mon Sep 17 00:00:00 2001 From: killerra <25255685+killerra@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:40:47 +0200 Subject: [PATCH 3/5] Harden Linux process/OS metadata reporting Follow-ups from the Linux backend soundness audit: - Decode the waitpid(2)-style status word from /proc//stat into a real exit code in Process::state(): normal exits report the exit(3) code, signal deaths report the negated signal number. Previously the raw status word leaked through (exit(3) surfaced as Dead(768)). - Derive OsInfo.arch and ProcessInfo::{sys_arch,proc_arch} from the compile target (x86_64/x86/aarch64) instead of hardcoding x86-64, and make the process module list callback emit the same arch field that module_by_address keys on. - Replace OS kernel-module handles (indices into a name-sorted snapshot that shift on module load/unload) with a stable hash of the module name (std DefaultHasher, fixed-seeded), resolved against the live snapshot. - Report the real process state in process_info_by_pid via the shared process_state() helper instead of hardcoding Alive. --- src/linux/mod.rs | 67 ++++++++++++++++++++++++++++++++++++----- src/linux/process.rs | 72 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 121 insertions(+), 18 deletions(-) diff --git a/src/linux/mod.rs b/src/linux/mod.rs index 8105b51..c4c58ca 100644 --- a/src/linux/mod.rs +++ b/src/linux/mod.rs @@ -8,12 +8,61 @@ use procfs::KernelModule; use itertools::Itertools; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + pub mod mem; use mem::ProcessVirtualMemory; pub mod process; +use process::process_state; pub use process::LinuxProcess; +/// Architecture of the host the backend is running on. +/// +/// memflow-native works through native syscalls, so the inspected processes always run +/// under the same kernel/ISA as this build. We therefore report the compile target's +/// architecture rather than assuming x86-64. 32-bit processes running under a 64-bit +/// kernel are still reported as 64-bit here; distinguishing them would require sniffing +/// the ELF class of `/proc//exe`. +fn host_arch() -> ArchitectureIdent { + #[cfg(target_arch = "x86_64")] + { + ArchitectureIdent::X86(64, false) + } + #[cfg(target_arch = "x86")] + { + ArchitectureIdent::X86(32, false) + } + #[cfg(target_arch = "aarch64")] + { + // Page size is read at runtime; only 4k is currently supported by memflow. + let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; + ArchitectureIdent::AArch64(if page_size > 0 { + page_size as usize + } else { + 0x1000 + }) + } + #[cfg(not(any(target_arch = "x86_64", target_arch = "x86", target_arch = "aarch64")))] + { + ArchitectureIdent::Unknown(0) + } +} + +/// Stable, ordering-independent handle for a kernel module, derived from its name. +/// `procfs::KernelModule` exposes no kernel address we could reuse, so hashing the name +/// keeps the handle valid across module load/unload churn (unlike a list index). +/// +/// `DefaultHasher` is fixed-seeded (unlike the randomized `RandomState` behind +/// `HashMap`), so the handle is consistent across the two lookups within a process run, +/// which is all this handle needs. +fn module_handle(name: &str) -> Address { + let mut hasher = DefaultHasher::new(); + name.hash(&mut hasher); + Address::from(hasher.finish()) +} + pub struct LinuxOs { info: OsInfo, } @@ -46,7 +95,7 @@ impl Default for LinuxOs { let info = OsInfo { base: Address::NULL, size: 0, - arch: ArchitectureIdent::X86(64, false), + arch: host_arch(), }; Self { info } @@ -108,15 +157,17 @@ impl Os for LinuxOs { let path = path.into(); + let arch = host_arch(); + Ok(ProcessInfo { address: (proc.pid() as umem).into(), pid, command_line, path, name, - sys_arch: ArchitectureIdent::X86(64, false), - proc_arch: ArchitectureIdent::X86(64, false), - state: ProcessState::Alive, + sys_arch: arch, + proc_arch: arch, + state: process_state(pid as pid_t), // dtb is not known/used here dtb1: Address::invalid(), dtb2: Address::invalid(), @@ -145,8 +196,9 @@ impl Os for LinuxOs { fn module_address_list_callback(&mut self, mut callback: AddressCallback) -> Result<()> { let modules = self.kernel_modules_sorted()?; - (0..modules.len()) - .map(Address::from) + modules + .iter() + .map(|km| module_handle(&km.name)) .take_while(|a| callback.call(*a)) .for_each(|_| {}); @@ -161,7 +213,8 @@ impl Os for LinuxOs { let modules = self.kernel_modules_sorted()?; modules - .get(address.to_umem() as usize) + .iter() + .find(|km| module_handle(&km.name) == address) .map(|km| ModuleInfo { address, size: km.size as umem, diff --git a/src/linux/process.rs b/src/linux/process.rs index 1436daa..5422109 100644 --- a/src/linux/process.rs +++ b/src/linux/process.rs @@ -147,6 +147,41 @@ impl LinuxProcess { } } +/// Decodes a waitpid(2)-style status word (as exposed in `/proc//stat` field 52) +/// into a memflow [`ExitCode`]. +/// +/// A normal exit reports its `exit(3)` code; a process killed by a signal reports the +/// negated signal number so the two cases stay distinguishable. +fn decode_exit_code(status: i32) -> i32 { + if status & 0x7f == 0 { + // WIFEXITED: low 7 bits clear -> exit code is in bits 8..16. + (status >> 8) & 0xff + } else { + // WIFSIGNALED: low 7 bits carry the terminating signal. + -(status & 0x7f) + } +} + +/// Resolves the [`ProcessState`] of `pid` from `/proc//stat`. +/// +/// Zombie/dead states map to [`ProcessState::Dead`] (carrying the decoded exit code +/// when the kernel exposes it); a vanished PID is treated as reaped (`Dead(0)`), and a +/// stat that fails for any other reason (e.g. permissions) stays [`ProcessState::Unknown`]. +pub(crate) fn process_state(pid: pid_t) -> ProcessState { + match procfs::process::Process::new(pid).and_then(|p| p.stat()) { + Ok(stat) => match stat.state { + // Z = zombie (exited, unreaped), X/x = dead + 'Z' | 'X' | 'x' => { + ProcessState::Dead(stat.exit_code.map(decode_exit_code).unwrap_or(0)) + } + _ => ProcessState::Alive, + }, + // /proc/ vanished -> the process was reaped; exit code is no longer recoverable + Err(procfs::ProcError::NotFound(_)) => ProcessState::Dead(0), + Err(_) => ProcessState::Unknown, + } +} + cglue_impl_group!(LinuxProcess, ProcessInstance, {}); cglue_impl_group!(LinuxProcess, IntoProcessInstance, {}); @@ -171,7 +206,9 @@ impl Process for LinuxProcess { .take_while(|map| { callback.call(ModuleAddressInfo { address: Address::from(map.address.0), - arch: self.info.proc_arch, + // Match `module_by_address`, which keys on `sys_arch`; the maps are + // also filtered above by `sys_arch`. + arch: self.info.sys_arch, }) }) .for_each(|_| {}); @@ -264,16 +301,7 @@ impl Process for LinuxProcess { /// Retrieves the state of the process fn state(&mut self) -> ProcessState { - match procfs::process::Process::new(self.pid).and_then(|p| p.stat()) { - Ok(stat) => match stat.state { - // Z = zombie (exited, unreaped), X/x = dead - 'Z' | 'X' | 'x' => ProcessState::Dead(stat.exit_code.unwrap_or(0)), - _ => ProcessState::Alive, - }, - // /proc/ vanished -> the process was reaped; exit code is no longer recoverable - Err(procfs::ProcError::NotFound(_)) => ProcessState::Dead(0), - Err(_) => ProcessState::Unknown, - } + process_state(self.pid) } /// Changes the dtb this process uses for memory translations. @@ -393,3 +421,25 @@ impl MemoryView for LinuxProcess { self.virt_mem.metadata() } } + +#[cfg(test)] +mod tests { + use super::decode_exit_code; + + #[test] + fn normal_exit_decodes_to_exit_code() { + // exit(0) and exit(3) as reported by waitpid: code in bits 8..16. + assert_eq!(decode_exit_code(0x0000), 0); + assert_eq!(decode_exit_code(3 << 8), 3); + assert_eq!(decode_exit_code(255 << 8), 255); + } + + #[test] + fn signalled_exit_decodes_to_negative_signal() { + // Killed by SIGKILL (9) / SIGSEGV (11): low 7 bits carry the signal. + assert_eq!(decode_exit_code(9), -9); + assert_eq!(decode_exit_code(11), -11); + // Core-dump flag (0x80) must not bleed into the signal number. + assert_eq!(decode_exit_code(11 | 0x80), -11); + } +} From e82c52aa4dac78151b8ea89f7790d38c4f069680 Mon Sep 17 00:00:00 2001 From: killerra <25255685+killerra@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:55:09 +0200 Subject: [PATCH 4/5] Add Linux keyboard support via evdev Implement OsKeyboard for LinuxOs with LinuxKeyboard/LinuxKeyboardState, polling key state through the EVIOCGKEY ioctl (evdev's get_key_state) on /dev/input/event* devices - the direct analog of GetKeyboardState on Windows. Requires root or membership in the 'input' group. is_down() accepts Microsoft virtual-key codes like the Windows backend, translated through a static VK -> evdev table (US layout). Side-agnostic modifiers check both sides, VK_RETURN covers both enter keys, and mouse buttons cover both side-button report styles (BTN_SIDE/BTN_EXTRA and BTN_BACK/BTN_FORWARD). Unmapped VKs report not-pressed, matching Windows. Key state is ORed across all keyboard-capable devices; unplugged devices are dropped on the fly with a one-shot re-enumeration when none remain. --- Cargo.lock | 70 +++++++++++++ Cargo.toml | 1 + src/lib.rs | 10 +- src/linux/keyboard.rs | 194 ++++++++++++++++++++++++++++++++++++ src/linux/keymap.rs | 227 ++++++++++++++++++++++++++++++++++++++++++ src/linux/mod.rs | 17 ++++ 6 files changed, 516 insertions(+), 3 deletions(-) create mode 100644 src/linux/keyboard.rs create mode 100644 src/linux/keymap.rs diff --git a/Cargo.lock b/Cargo.lock index 643209a..53f2c8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,6 +186,18 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "bumpalo" version = "3.17.0" @@ -222,6 +234,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "cglue" version = "0.2.14" @@ -487,6 +505,18 @@ dependencies = [ "libc", ] +[[package]] +name = "evdev" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b686663ba7f08d92880ff6ba22170f1df4e83629341cba34cf82cd65ebea99" +dependencies = [ + "bitvec", + "cfg-if", + "libc", + "nix", +] + [[package]] name = "fixed-slice-vec" version = "0.10.0" @@ -509,6 +539,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "generational-arena" version = "0.2.9" @@ -873,6 +909,7 @@ dependencies = [ name = "memflow-native" version = "0.2.6" dependencies = [ + "evdev", "goblin 0.9.3", "itertools 0.14.0", "libc", @@ -910,6 +947,18 @@ dependencies = [ "adler2", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.9.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "no-std-compat" version = "0.4.1" @@ -1053,6 +1102,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rangemap" version = "1.5.1" @@ -1311,6 +1366,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tarc" version = "0.1.6" @@ -1889,6 +1950,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x86_64" version = "0.14.13" diff --git a/Cargo.toml b/Cargo.toml index bd65bc1..0742a20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ memflow = { version = "0.2", features = ["plugins"] } # tracking issue: https://github.com/eminence/procfs/pull/309 [target.'cfg(target_os = "linux")'.dependencies] procfs = { version = "=0.15.1", features = ["backtrace"] } +evdev = "0.13" [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.61", features = [ diff --git a/src/lib.rs b/src/lib.rs index b619fe4..52c78b3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,10 @@ #[cfg(target_os = "linux")] pub mod linux; #[cfg(target_os = "linux")] +pub use linux::LinuxKeyboard as NativeKeyboard; +#[cfg(target_os = "linux")] +pub use linux::LinuxKeyboardState as NativeKeyboardState; +#[cfg(target_os = "linux")] pub use linux::LinuxOs as NativeOs; #[cfg(target_os = "linux")] pub use linux::LinuxProcess as NativeProcess; @@ -22,15 +26,15 @@ pub use self::windows::WindowsKeyboardState as NativeKeyboardState; pub use self::windows::WindowsOs as NativeOs; #[cfg(target_os = "windows")] pub use self::windows::WindowsProcess as NativeProcess; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "linux"))] use crate::keyboard::OsKeyboardVtbl; use memflow::cglue; use memflow::prelude::v1::*; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "linux"))] cglue_impl_group!(NativeOs, OsInstance, { OsKeyboard }); -#[cfg(not(target_os = "windows"))] +#[cfg(not(any(target_os = "windows", target_os = "linux")))] cglue_impl_group!(NativeOs, OsInstance, {}); #[cfg_attr(feature = "plugins", os(name = "native", return_wrapped = true))] diff --git a/src/linux/keyboard.rs b/src/linux/keyboard.rs new file mode 100644 index 0000000..6ced2aa --- /dev/null +++ b/src/linux/keyboard.rs @@ -0,0 +1,194 @@ +use memflow::cglue; +use memflow::os::keyboard::*; +use memflow::prelude::v1::*; + +use std::sync::{Arc, Mutex}; + +use evdev::{Device, EventType, KeyCode}; + +use super::keymap::vk_to_keycodes; + +/// Number of bits in the key state bitset, covering evdev codes `0..=KEY_MAX` (0x2ff). +const KEY_STATE_BITS: usize = 0x300; +const KEY_STATE_WORDS: usize = KEY_STATE_BITS / 64; + +/// Keyboard state reader backed by evdev (`/dev/input/event*`). +/// +/// Key state is polled via the `EVIOCGKEY` ioctl (the analog of Windows' +/// `GetKeyboardState`), which reads the currently-pressed key bitmap without consuming +/// input events. Requires read access to the device nodes, i.e. root or membership in +/// the `input` group. +/// +/// Devices are enumerated once on construction. Devices that disappear (unplug) are +/// dropped on the fly, and a full re-enumeration is attempted whenever none of the +/// cached devices remain readable; keyboards plugged in while cached devices still work +/// are not picked up. +#[derive(Clone)] +pub struct LinuxKeyboard { + devices: Arc>>, +} + +cglue_impl_group!(LinuxKeyboard, IntoKeyboard); + +impl LinuxKeyboard { + pub fn new() -> Result { + let devices = Self::enumerate_devices(); + + if devices.is_empty() { + return Err(Error(ErrorOrigin::OsLayer, ErrorKind::NotFound).log_error( + "no readable keyboard devices in /dev/input (requires root or membership in the 'input' group)", + )); + } + + Ok(Self { + devices: Arc::new(Mutex::new(devices)), + }) + } + + fn enumerate_devices() -> Vec { + evdev::enumerate() + .filter_map(|(path, device)| { + if Self::is_key_input_device(&device) { + Some(device) + } else { + log::debug!("skipping non-keyboard input device {}", path.display()); + None + } + }) + .collect() + } + + /// Accepts keyboards (identified by a representative set of keys, which weeds out + /// power buttons and lid switches) and pointer devices (needed for the mouse button + /// virtual-key codes that `GetKeyState` supports on Windows). + fn is_key_input_device(device: &Device) -> bool { + if !device.supported_events().contains(EventType::KEY) { + return false; + } + + let Some(keys) = device.supported_keys() else { + return false; + }; + + let is_keyboard = [ + KeyCode::KEY_A, + KeyCode::KEY_Z, + KeyCode::KEY_ENTER, + KeyCode::KEY_SPACE, + ] + .into_iter() + .all(|key| keys.contains(key)); + + is_keyboard || keys.contains(KeyCode::BTN_LEFT) + } + + /// ORs the pressed-key bitmap of every device into `buffer`, dropping devices whose + /// state can no longer be read. + fn poll_devices(devices: &mut Vec, buffer: &mut [u64; KEY_STATE_WORDS]) { + devices.retain(|device| match device.get_key_state() { + Ok(keys) => { + for key in keys.iter() { + let code = key.code() as usize; + if code < KEY_STATE_BITS { + buffer[code / 64] |= 1 << (code % 64); + } + } + true + } + Err(err) => { + log::debug!("dropping input device after key state read failure: {err}"); + false + } + }); + } +} + +impl Keyboard for LinuxKeyboard { + type KeyboardStateType = LinuxKeyboardState; + + /// Returns true wether the given key was pressed. + /// This function accepts a valid microsoft virtual keycode. + /// In case of supplying a invalid key this function will just return false cleanly. + /// + /// A list of all Keycodes can be found on the [msdn](https://docs.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes). + fn is_down(&mut self, vk: i32) -> bool { + self.state().map(|s| s.is_down(vk)).unwrap_or(false) + } + + fn set_down(&mut self, _vk: i32, _down: bool) { + // TODO: input injection would require uinput; matches the Windows stub. + } + + /// Reads the entire keyboard state. + fn state(&mut self) -> Result { + let mut devices = self.devices.lock().unwrap_or_else(|e| e.into_inner()); + + let mut buffer = [0u64; KEY_STATE_WORDS]; + Self::poll_devices(&mut devices, &mut buffer); + + if devices.is_empty() { + *devices = Self::enumerate_devices(); + buffer = [0u64; KEY_STATE_WORDS]; + Self::poll_devices(&mut devices, &mut buffer); + + if devices.is_empty() { + return Err(Error(ErrorOrigin::OsLayer, ErrorKind::NotFound).log_error( + "no readable keyboard devices in /dev/input (requires root or membership in the 'input' group)", + )); + } + } + + Ok(LinuxKeyboardState { buffer }) + } +} + +/// Represents the current Keyboardstate. +#[derive(Clone)] +pub struct LinuxKeyboardState { + /// Bitset over evdev key codes `0..=KEY_MAX`. + buffer: [u64; KEY_STATE_WORDS], +} + +impl KeyboardState for LinuxKeyboardState { + /// Returns true wether the given key was pressed. + /// This function accepts a valid microsoft virtual keycode. + /// In case of supplying a invalid key this function will just return false cleanly. + /// + /// A list of all Keycodes can be found on the [msdn](https://docs.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes). + fn is_down(&self, vk: i32) -> bool { + vk_to_keycodes(vk).iter().any(|&code| { + let code = code as usize; + code < KEY_STATE_BITS && self.buffer[code / 64] & (1 << (code % 64)) != 0 + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state_with_codes(codes: &[u16]) -> LinuxKeyboardState { + let mut buffer = [0u64; KEY_STATE_WORDS]; + for &code in codes { + let code = code as usize; + buffer[code / 64] |= 1 << (code % 64); + } + LinuxKeyboardState { buffer } + } + + #[test] + fn state_translates_vks_to_evdev_bits() { + let state = state_with_codes(&[30, 54, 0x116]); // KEY_A, KEY_RIGHTSHIFT, BTN_BACK + + assert!(state.is_down(0x41)); // 'A' + assert!(state.is_down(0x10)); // VK_SHIFT via right shift + assert!(state.is_down(0xA1)); // VK_RSHIFT + assert!(state.is_down(0x05)); // VK_XBUTTON1 via BTN_BACK + + assert!(!state.is_down(0xA0)); // VK_LSHIFT not pressed + assert!(!state.is_down(0x42)); // 'B' not pressed + assert!(!state.is_down(0x07)); // unmapped VK + assert!(!state.is_down(-1)); + assert!(!state.is_down(1000)); + } +} diff --git a/src/linux/keymap.rs b/src/linux/keymap.rs new file mode 100644 index 0000000..58f8b0e --- /dev/null +++ b/src/linux/keymap.rs @@ -0,0 +1,227 @@ +//! Microsoft virtual-key code -> evdev `KEY_*`/`BTN_*` translation (US layout). +//! +//! The Linux keyboard backend accepts the same virtual-key codes as the Windows one so +//! that consumers of the memflow keyboard trait work unchanged across both OSes. Raw +//! `u16` literals from `linux/input-event-codes.h` are used because evdev's `KeyCode` +//! constants are not usable in `const` slices; the corresponding constant name is noted +//! next to each code. + +/// Translates a Microsoft virtual-key code into the evdev key codes that represent it. +/// +/// Side-agnostic VKs (e.g. `VK_SHIFT`) expand to every matching evdev code, and mouse +/// side buttons cover both report styles (`BTN_SIDE`/`BTN_EXTRA` vs +/// `BTN_BACK`/`BTN_FORWARD`). Unmapped VKs yield an empty slice, which callers treat as +/// "not pressed", matching the Windows backend's behavior for invalid keycodes. +pub(crate) fn vk_to_keycodes(vk: i32) -> &'static [u16] { + match vk { + // Mouse buttons + 0x01 => &[0x110], // VK_LBUTTON -> BTN_LEFT + 0x02 => &[0x111], // VK_RBUTTON -> BTN_RIGHT + 0x04 => &[0x112], // VK_MBUTTON -> BTN_MIDDLE + 0x05 => &[0x113, 0x116], // VK_XBUTTON1 -> BTN_SIDE | BTN_BACK + 0x06 => &[0x114, 0x115], // VK_XBUTTON2 -> BTN_EXTRA | BTN_FORWARD + + // Editing / control + 0x08 => &[14], // VK_BACK -> KEY_BACKSPACE + 0x09 => &[15], // VK_TAB -> KEY_TAB + 0x0D => &[28, 96], // VK_RETURN -> KEY_ENTER | KEY_KPENTER + 0x13 => &[119], // VK_PAUSE -> KEY_PAUSE + 0x14 => &[58], // VK_CAPITAL -> KEY_CAPSLOCK + 0x1B => &[1], // VK_ESCAPE -> KEY_ESC + 0x20 => &[57], // VK_SPACE -> KEY_SPACE + + // Side-agnostic modifiers expand to both sides + 0x10 => &[42, 54], // VK_SHIFT -> KEY_LEFTSHIFT | KEY_RIGHTSHIFT + 0x11 => &[29, 97], // VK_CONTROL -> KEY_LEFTCTRL | KEY_RIGHTCTRL + 0x12 => &[56, 100], // VK_MENU -> KEY_LEFTALT | KEY_RIGHTALT + + // Navigation + 0x21 => &[104], // VK_PRIOR -> KEY_PAGEUP + 0x22 => &[109], // VK_NEXT -> KEY_PAGEDOWN + 0x23 => &[107], // VK_END -> KEY_END + 0x24 => &[102], // VK_HOME -> KEY_HOME + 0x25 => &[105], // VK_LEFT -> KEY_LEFT + 0x26 => &[103], // VK_UP -> KEY_UP + 0x27 => &[106], // VK_RIGHT -> KEY_RIGHT + 0x28 => &[108], // VK_DOWN -> KEY_DOWN + 0x2C => &[99], // VK_SNAPSHOT -> KEY_SYSRQ + 0x2D => &[110], // VK_INSERT -> KEY_INSERT + 0x2E => &[111], // VK_DELETE -> KEY_DELETE + + // Digits (evdev places KEY_0 after KEY_9) + 0x30 => &[11], // '0' -> KEY_0 + 0x31 => &[2], // '1' -> KEY_1 + 0x32 => &[3], // '2' -> KEY_2 + 0x33 => &[4], // '3' -> KEY_3 + 0x34 => &[5], // '4' -> KEY_4 + 0x35 => &[6], // '5' -> KEY_5 + 0x36 => &[7], // '6' -> KEY_6 + 0x37 => &[8], // '7' -> KEY_7 + 0x38 => &[9], // '8' -> KEY_8 + 0x39 => &[10], // '9' -> KEY_9 + + // Letters (evdev codes follow QWERTY row order, not alphabetical) + 0x41 => &[30], // 'A' -> KEY_A + 0x42 => &[48], // 'B' -> KEY_B + 0x43 => &[46], // 'C' -> KEY_C + 0x44 => &[32], // 'D' -> KEY_D + 0x45 => &[18], // 'E' -> KEY_E + 0x46 => &[33], // 'F' -> KEY_F + 0x47 => &[34], // 'G' -> KEY_G + 0x48 => &[35], // 'H' -> KEY_H + 0x49 => &[23], // 'I' -> KEY_I + 0x4A => &[36], // 'J' -> KEY_J + 0x4B => &[37], // 'K' -> KEY_K + 0x4C => &[38], // 'L' -> KEY_L + 0x4D => &[50], // 'M' -> KEY_M + 0x4E => &[49], // 'N' -> KEY_N + 0x4F => &[24], // 'O' -> KEY_O + 0x50 => &[25], // 'P' -> KEY_P + 0x51 => &[16], // 'Q' -> KEY_Q + 0x52 => &[19], // 'R' -> KEY_R + 0x53 => &[31], // 'S' -> KEY_S + 0x54 => &[20], // 'T' -> KEY_T + 0x55 => &[22], // 'U' -> KEY_U + 0x56 => &[47], // 'V' -> KEY_V + 0x57 => &[17], // 'W' -> KEY_W + 0x58 => &[45], // 'X' -> KEY_X + 0x59 => &[21], // 'Y' -> KEY_Y + 0x5A => &[44], // 'Z' -> KEY_Z + + // Windows / menu keys + 0x5B => &[125], // VK_LWIN -> KEY_LEFTMETA + 0x5C => &[126], // VK_RWIN -> KEY_RIGHTMETA + 0x5D => &[127], // VK_APPS -> KEY_COMPOSE + + // Numpad + 0x60 => &[82], // VK_NUMPAD0 -> KEY_KP0 + 0x61 => &[79], // VK_NUMPAD1 -> KEY_KP1 + 0x62 => &[80], // VK_NUMPAD2 -> KEY_KP2 + 0x63 => &[81], // VK_NUMPAD3 -> KEY_KP3 + 0x64 => &[75], // VK_NUMPAD4 -> KEY_KP4 + 0x65 => &[76], // VK_NUMPAD5 -> KEY_KP5 + 0x66 => &[77], // VK_NUMPAD6 -> KEY_KP6 + 0x67 => &[71], // VK_NUMPAD7 -> KEY_KP7 + 0x68 => &[72], // VK_NUMPAD8 -> KEY_KP8 + 0x69 => &[73], // VK_NUMPAD9 -> KEY_KP9 + 0x6A => &[55], // VK_MULTIPLY -> KEY_KPASTERISK + 0x6B => &[78], // VK_ADD -> KEY_KPPLUS + 0x6D => &[74], // VK_SUBTRACT -> KEY_KPMINUS + 0x6E => &[83], // VK_DECIMAL -> KEY_KPDOT + 0x6F => &[98], // VK_DIVIDE -> KEY_KPSLASH + + // Function keys + 0x70 => &[59], // VK_F1 -> KEY_F1 + 0x71 => &[60], // VK_F2 -> KEY_F2 + 0x72 => &[61], // VK_F3 -> KEY_F3 + 0x73 => &[62], // VK_F4 -> KEY_F4 + 0x74 => &[63], // VK_F5 -> KEY_F5 + 0x75 => &[64], // VK_F6 -> KEY_F6 + 0x76 => &[65], // VK_F7 -> KEY_F7 + 0x77 => &[66], // VK_F8 -> KEY_F8 + 0x78 => &[67], // VK_F9 -> KEY_F9 + 0x79 => &[68], // VK_F10 -> KEY_F10 + 0x7A => &[87], // VK_F11 -> KEY_F11 + 0x7B => &[88], // VK_F12 -> KEY_F12 + 0x7C => &[183], // VK_F13 -> KEY_F13 + 0x7D => &[184], // VK_F14 -> KEY_F14 + 0x7E => &[185], // VK_F15 -> KEY_F15 + 0x7F => &[186], // VK_F16 -> KEY_F16 + 0x80 => &[187], // VK_F17 -> KEY_F17 + 0x81 => &[188], // VK_F18 -> KEY_F18 + 0x82 => &[189], // VK_F19 -> KEY_F19 + 0x83 => &[190], // VK_F20 -> KEY_F20 + 0x84 => &[191], // VK_F21 -> KEY_F21 + 0x85 => &[192], // VK_F22 -> KEY_F22 + 0x86 => &[193], // VK_F23 -> KEY_F23 + 0x87 => &[194], // VK_F24 -> KEY_F24 + + // Locks + 0x90 => &[69], // VK_NUMLOCK -> KEY_NUMLOCK + 0x91 => &[70], // VK_SCROLL -> KEY_SCROLLLOCK + + // Sided modifiers + 0xA0 => &[42], // VK_LSHIFT -> KEY_LEFTSHIFT + 0xA1 => &[54], // VK_RSHIFT -> KEY_RIGHTSHIFT + 0xA2 => &[29], // VK_LCONTROL -> KEY_LEFTCTRL + 0xA3 => &[97], // VK_RCONTROL -> KEY_RIGHTCTRL + 0xA4 => &[56], // VK_LMENU -> KEY_LEFTALT + 0xA5 => &[100], // VK_RMENU -> KEY_RIGHTALT + + // OEM punctuation (US layout) + 0xBA => &[39], // VK_OEM_1 -> KEY_SEMICOLON + 0xBB => &[13], // VK_OEM_PLUS -> KEY_EQUAL + 0xBC => &[51], // VK_OEM_COMMA -> KEY_COMMA + 0xBD => &[12], // VK_OEM_MINUS -> KEY_MINUS + 0xBE => &[52], // VK_OEM_PERIOD -> KEY_DOT + 0xBF => &[53], // VK_OEM_2 -> KEY_SLASH + 0xC0 => &[41], // VK_OEM_3 -> KEY_GRAVE + 0xDB => &[26], // VK_OEM_4 -> KEY_LEFTBRACE + 0xDC => &[43], // VK_OEM_5 -> KEY_BACKSLASH + 0xDD => &[27], // VK_OEM_6 -> KEY_RIGHTBRACE + 0xDE => &[40], // VK_OEM_7 -> KEY_APOSTROPHE + 0xE2 => &[86], // VK_OEM_102 -> KEY_102ND + + _ => &[], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn letters_map_to_qwerty_codes() { + assert_eq!(vk_to_keycodes(0x41), &[30]); // 'A' -> KEY_A + assert_eq!(vk_to_keycodes(0x51), &[16]); // 'Q' -> KEY_Q + assert_eq!(vk_to_keycodes(0x5A), &[44]); // 'Z' -> KEY_Z + } + + #[test] + fn digits_wrap_zero_after_nine() { + assert_eq!(vk_to_keycodes(0x30), &[11]); // '0' -> KEY_0 + assert_eq!(vk_to_keycodes(0x31), &[2]); // '1' -> KEY_1 + assert_eq!(vk_to_keycodes(0x39), &[10]); // '9' -> KEY_9 + } + + #[test] + fn side_agnostic_modifiers_expand_to_both_sides() { + assert_eq!(vk_to_keycodes(0x10), &[42, 54]); // VK_SHIFT + assert_eq!(vk_to_keycodes(0x11), &[29, 97]); // VK_CONTROL + assert_eq!(vk_to_keycodes(0x12), &[56, 100]); // VK_MENU + } + + #[test] + fn return_covers_both_enter_keys() { + assert_eq!(vk_to_keycodes(0x0D), &[28, 96]); + } + + #[test] + fn mouse_buttons_cover_both_report_styles() { + assert_eq!(vk_to_keycodes(0x01), &[0x110]); // VK_LBUTTON -> BTN_LEFT + assert_eq!(vk_to_keycodes(0x05), &[0x113, 0x116]); // VK_XBUTTON1 + assert_eq!(vk_to_keycodes(0x06), &[0x114, 0x115]); // VK_XBUTTON2 + } + + #[test] + fn unmapped_vks_yield_empty_slices() { + for vk in [0x07, 0xFF, -1, 1000] { + assert!( + vk_to_keycodes(vk).is_empty(), + "vk {vk:#x} should be unmapped" + ); + } + } + + #[test] + fn all_emitted_codes_fit_the_state_bitset() { + for vk in i32::from(u8::MIN)..=i32::from(u8::MAX) { + for &code in vk_to_keycodes(vk) { + assert!( + code <= 0x2FF, + "vk {vk:#x} emits out-of-range code {code:#x}" + ); + } + } + } +} diff --git a/src/linux/mod.rs b/src/linux/mod.rs index c4c58ca..e404ed5 100644 --- a/src/linux/mod.rs +++ b/src/linux/mod.rs @@ -11,6 +11,10 @@ use itertools::Itertools; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; +pub mod keyboard; +mod keymap; +pub use keyboard::{LinuxKeyboard, LinuxKeyboardState}; + pub mod mem; use mem::ProcessVirtualMemory; @@ -276,3 +280,16 @@ impl Os for LinuxOs { &self.info } } + +impl OsKeyboard for LinuxOs { + type KeyboardType<'a> = LinuxKeyboard; + type IntoKeyboardType = LinuxKeyboard; + + fn keyboard(&mut self) -> Result> { + LinuxKeyboard::new() + } + + fn into_keyboard(self) -> Result { + LinuxKeyboard::new() + } +} From d29a14240eee6d6a82f3647eb8e1fe6417408563 Mon Sep 17 00:00:00 2001 From: killerra <25255685+killerra@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:12:08 +0200 Subject: [PATCH 5/5] Implement Keyboard::set_down on Linux and Windows Linux injects an EV_KEY + SYN_REPORT pair into the first open device that advertises the key (the kernel drops events a device does not support), using evdev's send_events on the already-open device nodes. Side-agnostic virtual-key codes press their left-side variant. Injection needs write access to /dev/input, which the root/'input'-group requirement already grants. Windows uses SendInput: keyboard keys as KEYBDINPUT with the scancode filled in via MapVirtualKeyW (so raw-input/DirectInput consumers see the key) and KEYEVENTF_EXTENDEDKEY applied to the extended-range set; mouse button VKs as MOUSEINPUT with the matching MOUSEEVENTF flags, including X buttons. The trait's set_down returns nothing, so failures are logged instead of propagated. Unmapped or out-of-range keycodes are ignored cleanly, matching is_down. --- src/linux/keyboard.rs | 55 +++++++++++++++++++--- src/windows/keyboard.rs | 102 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 148 insertions(+), 9 deletions(-) diff --git a/src/linux/keyboard.rs b/src/linux/keyboard.rs index 6ced2aa..5adae3c 100644 --- a/src/linux/keyboard.rs +++ b/src/linux/keyboard.rs @@ -4,7 +4,7 @@ use memflow::prelude::v1::*; use std::sync::{Arc, Mutex}; -use evdev::{Device, EventType, KeyCode}; +use evdev::{Device, EventType, InputEvent, KeyCode, SynchronizationCode}; use super::keymap::vk_to_keycodes; @@ -12,12 +12,15 @@ use super::keymap::vk_to_keycodes; const KEY_STATE_BITS: usize = 0x300; const KEY_STATE_WORDS: usize = KEY_STATE_BITS / 64; -/// Keyboard state reader backed by evdev (`/dev/input/event*`). +/// Keyboard state reader and injector backed by evdev (`/dev/input/event*`). /// /// Key state is polled via the `EVIOCGKEY` ioctl (the analog of Windows' /// `GetKeyboardState`), which reads the currently-pressed key bitmap without consuming -/// input events. Requires read access to the device nodes, i.e. root or membership in -/// the `input` group. +/// input events. Key presses are injected by writing `EV_KEY` events to a device node, +/// which the kernel propagates to every reader as if the key had been typed. Reading +/// requires read access to the device nodes and injection requires write access; the +/// nodes default to `root:input` 0660, so root or membership in the `input` group +/// grants both. /// /// Devices are enumerated once on construction. Devices that disappear (unplug) are /// dropped on the fly, and a full re-enumeration is attempted whenever none of the @@ -115,8 +118,48 @@ impl Keyboard for LinuxKeyboard { self.state().map(|s| s.is_down(vk)).unwrap_or(false) } - fn set_down(&mut self, _vk: i32, _down: bool) { - // TODO: input injection would require uinput; matches the Windows stub. + /// Presses or releases the given key by injecting an `EV_KEY` event into the first + /// device that supports it (the kernel drops injected events a device does not + /// advertise). Side-agnostic virtual-key codes press their left-side variant. + /// Invalid or unmapped keycodes are ignored cleanly; injection failures are logged, + /// as this interface has no error channel. + fn set_down(&mut self, vk: i32, down: bool) { + let Some(&code) = vk_to_keycodes(vk).first() else { + return; + }; + + let events = [ + InputEvent::new(EventType::KEY.0, code, i32::from(down)), + InputEvent::new( + EventType::SYNCHRONIZATION.0, + SynchronizationCode::SYN_REPORT.0, + 0, + ), + ]; + + let mut devices = self.devices.lock().unwrap_or_else(|e| e.into_inner()); + + let injected = devices + .iter_mut() + .filter(|device| { + device + .supported_keys() + .map(|keys| keys.contains(KeyCode(code))) + .unwrap_or(false) + }) + .any(|device| match device.send_events(&events) { + Ok(()) => true, + Err(err) => { + log::debug!("key injection failed on a device: {err}"); + false + } + }); + + if !injected { + log::warn!( + "unable to inject key event for vk {vk:#x} (requires write access to /dev/input and a device supporting the key)" + ); + } } /// Reads the entire keyboard state. diff --git a/src/windows/keyboard.rs b/src/windows/keyboard.rs index 8ce5b50..9f4d9b7 100644 --- a/src/windows/keyboard.rs +++ b/src/windows/keyboard.rs @@ -2,7 +2,35 @@ use memflow::cglue; use memflow::os::keyboard::*; use memflow::prelude::v1::*; -use windows::Win32::UI::Input::KeyboardAndMouse::{GetKeyState, GetKeyboardState}; +use windows::Win32::Foundation::GetLastError; +use windows::Win32::UI::Input::KeyboardAndMouse::{ + GetKeyState, GetKeyboardState, MapVirtualKeyW, SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, + INPUT_MOUSE, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP, + MAPVK_VK_TO_VSC, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, + MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_XDOWN, + MOUSEEVENTF_XUP, MOUSEINPUT, VIRTUAL_KEY, +}; + +// `XBUTTON1`/`XBUTTON2` live in `Win32_UI_WindowsAndMessaging`; inline them rather than +// pulling in that whole feature for two constants. +const XBUTTON1: u32 = 1; +const XBUTTON2: u32 = 2; + +/// Virtual-key codes whose scancodes live in the extended range; `SendInput` needs +/// `KEYEVENTF_EXTENDEDKEY` for these so consumers can tell them apart from their +/// base-key twins (e.g. arrow keys vs. numpad arrows, right ctrl vs. left ctrl). +fn is_extended_key(vk: i32) -> bool { + matches!( + vk, + 0x21..=0x28 // VK_PRIOR, VK_NEXT, VK_END, VK_HOME, arrow keys + | 0x2C..=0x2E // VK_SNAPSHOT, VK_INSERT, VK_DELETE + | 0x5B..=0x5D // VK_LWIN, VK_RWIN, VK_APPS + | 0x6F // VK_DIVIDE + | 0x90 // VK_NUMLOCK + | 0xA3 // VK_RCONTROL + | 0xA5 // VK_RMENU + ) +} #[derive(Default, Clone)] pub struct WindowsKeyboard {} @@ -28,8 +56,76 @@ impl Keyboard for WindowsKeyboard { key_state as u16 & 0x8000 != 0 } - fn set_down(&mut self, _vk: i32, _down: bool) { - // TODO: + /// Presses or releases the given key via `SendInput`. Mouse button virtual-key + /// codes are injected as mouse input, everything else as keyboard input. Invalid + /// keycodes are ignored cleanly; injection failures are logged, as this interface + /// has no error channel. + fn set_down(&mut self, vk: i32, down: bool) { + if !(0..=0xFF).contains(&vk) { + return; + } + + let input = match vk { + // VK_LBUTTON, VK_RBUTTON, VK_MBUTTON, VK_XBUTTON1, VK_XBUTTON2 + 0x01 | 0x02 | 0x04 | 0x05 | 0x06 => { + let (flags, data) = match (vk, down) { + (0x01, true) => (MOUSEEVENTF_LEFTDOWN, 0), + (0x01, false) => (MOUSEEVENTF_LEFTUP, 0), + (0x02, true) => (MOUSEEVENTF_RIGHTDOWN, 0), + (0x02, false) => (MOUSEEVENTF_RIGHTUP, 0), + (0x04, true) => (MOUSEEVENTF_MIDDLEDOWN, 0), + (0x04, false) => (MOUSEEVENTF_MIDDLEUP, 0), + (0x05, true) => (MOUSEEVENTF_XDOWN, XBUTTON1), + (0x05, false) => (MOUSEEVENTF_XUP, XBUTTON1), + (0x06, true) => (MOUSEEVENTF_XDOWN, XBUTTON2), + (0x06, false) => (MOUSEEVENTF_XUP, XBUTTON2), + _ => unreachable!(), + }; + INPUT { + r#type: INPUT_MOUSE, + Anonymous: INPUT_0 { + mi: MOUSEINPUT { + dx: 0, + dy: 0, + mouseData: data, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + } + } + _ => { + let mut flags = KEYBD_EVENT_FLAGS(0); + if !down { + flags |= KEYEVENTF_KEYUP; + } + if is_extended_key(vk) { + flags |= KEYEVENTF_EXTENDEDKEY; + } + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: VIRTUAL_KEY(vk as u16), + // Fill in the scancode so consumers reading scancodes + // (DirectInput / raw input) see the key as well. + wScan: unsafe { MapVirtualKeyW(vk as u32, MAPVK_VK_TO_VSC) } as u16, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + } + } + }; + + if unsafe { SendInput(&[input], core::mem::size_of::() as i32) } == 0 { + log::warn!( + "unable to inject input event for vk {vk:#x}: {:?}", + unsafe { GetLastError() } + ); + } } /// Reads the entire keyboard state.