feat: atomic patch application, colored init output, XKB bridge, USB HID hardening

Build system (src/cook/fetch.rs):
- Atomic patch application: applies patches to staging directory (cp -al),
  atomically swaps on success, discards on failure — source tree is never
  left in a partially-patched state
- normalize_patch(): strips diff --git/index/new-file-mode headers that the
  build system's patch command does not recognize
- cleanup_workspace_pollution(): removes orphaned recipes/Cargo.toml and
  recipes/Cargo.lock to prevent workspace conflicts
- Added --allow-protected CLI flag to repo binary

Input stack (local/patches/base/P3-*.patch):
- P3-ps2d-led-feedback: PS/2 LED state handling + InputProducer migration
- P3-inputd-keymap-bridge: InputProducer enum, keymap bridge query
- P3-usbhidd-hardening: HID descriptor validation, static lookup table,
  8-button mouse support, transfer retry with exponential backoff
- P3-init-colored-output: ANSI-color coded init daemon output (green OK,
  red FAILED, yellow SKIP/WARN)

XKB bridge (local/recipes/system/redbear-keymapd/source/src/xkb.rs):
- Parses X11 xkb/symbols/* format, maps XKB keycodes to PS/2 scancodes,
  80+ X11 keysym names to Unicode, 4-level key support

Patch governance (local/patches/base/absorbed/README.md):
- Documents consolidation of P0-P3 patches into redox.patch
This commit is contained in:
2026-05-03 08:21:54 +01:00
parent 7b48083a14
commit aca2f2913d
13 changed files with 2920 additions and 40 deletions
@@ -0,0 +1,260 @@
--- a/init/src/color.rs 1970-01-01 00:00:00.000000000 +0000
+++ b/init/src/color.rs 2026-05-03 08:14:33.424932357 +0100
@@ -0,0 +1,27 @@
+pub fn status_ok(msg: &str) {
+ eprintln!("[ OK ] {msg}");
+}
+
+pub fn status_fail(msg: &str) {
+ eprintln!("[ FAILED ] {msg}");
+}
+
+pub fn status_skip(msg: &str) {
+ eprintln!("[ SKIP ] {msg}");
+}
+
+pub fn init_error(msg: &str) {
+ eprintln!("init: {msg}");
+}
+
+pub fn init_warn(msg: &str) {
+ eprintln!("init: {msg}");
+}
+
+pub fn init_info(msg: &str) {
+ eprintln!("init: {msg}");
+}
+
+pub fn init_debug(msg: &str) {
+ eprintln!("init: {msg}");
+}
--- a/init/src/main.rs 2026-05-03 05:35:37.646633516 +0100
+++ b/init/src/main.rs 2026-05-03 08:14:33.426007641 +0100
@@ -8,11 +8,14 @@
use crate::scheduler::Scheduler;
use crate::unit::{UnitId, UnitStore};
+mod color;
mod scheduler;
mod script;
mod service;
mod unit;
+use crate::color::{init_error, init_warn, status_fail, status_ok};
+
fn switch_stdio(stdio: &str) -> io::Result<()> {
let stdin = libredox::Fd::open(stdio, O_RDONLY, 0)?;
let stdout = libredox::Fd::open(stdio, O_WRONLY, 0)?;
@@ -49,11 +52,7 @@
}
fn switch_root(unit_store: &mut UnitStore, config: &mut InitConfig, prefix: &Path, etcdir: &Path) {
- eprintln!(
- "init: switchroot to {} {}",
- prefix.display(),
- etcdir.display()
- );
+ status_ok(&format!("switchroot to {} {}", prefix.display(), etcdir.display()));
config
.envs
@@ -79,10 +78,7 @@
continue;
}
let Some((key, value)) = env.split_once('=') else {
- eprintln!(
- "init: failed to parse env line from {}: {env:?}",
- file.display(),
- );
+ init_warn(&format!("failed to parse env line from {}: {:?}", file.display(), env));
continue;
};
config
@@ -91,23 +87,18 @@
}
}
Err(err) => {
- eprintln!(
- "init: failed to read environment from {}: {err}",
- file.display(),
- );
+ init_error(&format!("failed to read environment from {}: {}", file.display(), err));
}
}
}
}
Err(err) => {
- eprintln!(
- "init: failed to read environments from {}: {err}",
+ init_error(&format!("failed to read environments from {}: {}",
env_dirs
.iter()
.map(|dir| dir.display().to_string())
.collect::<Vec<_>>()
- .join(", ")
- );
+ .join(", "), err));
}
}
}
@@ -129,7 +120,7 @@
.schedule_start_and_report_errors(&mut unit_store, UnitId("00_logd.service".to_owned()));
scheduler.step(&mut unit_store, &mut init_config);
if let Err(err) = switch_stdio("/scheme/log") {
- eprintln!("init: failed to switch stdio to '/scheme/log': {err}");
+ init_error(&format!("failed to switch stdio to '/scheme/log': {}", err));
}
let runtime_target = UnitId("00_runtime.target".to_owned());
@@ -153,15 +144,13 @@
let entries = match config::config_for_dirs(&unit_store.config_dirs) {
Ok(entries) => entries,
Err(err) => {
- eprintln!(
- "init: failed to read configs from {}: {err}",
+ init_error(&format!("failed to read configs from {}: {}",
unit_store
.config_dirs
.iter()
.map(|dir| dir.display().to_string())
.collect::<Vec<_>>()
- .join(", ")
- );
+ .join(", "), err));
return;
}
};
@@ -186,7 +175,7 @@
let mut status = 0;
match libredox::call::waitpid(0, &mut status, 1) {
Ok(_pid) => {}
- Err(err) => eprintln!("init: waitpid error: {err}"),
+ Err(err) => init_error(&format!("waitpid error: {}", err)),
}
}
}
--- a/init/src/scheduler.rs 2026-05-03 05:35:37.539232447 +0100
+++ b/init/src/scheduler.rs 2026-05-03 08:14:33.426815867 +0100
@@ -1,6 +1,7 @@
use std::collections::VecDeque;
use crate::InitConfig;
+use crate::color::{init_debug, init_error, init_info, status_ok, status_skip};
use crate::unit::{Unit, UnitId, UnitKind, UnitStore};
pub struct Scheduler {
@@ -31,7 +32,7 @@
let mut errors = vec![];
self.schedule_start(unit_store, unit_id, &mut errors);
for error in errors {
- eprintln!("init: {error}");
+ init_error(&format!("{}", error));
}
}
@@ -85,31 +86,28 @@
UnitKind::LegacyScript { script } => {
for cmd in script.clone() {
if config.log_debug {
- eprintln!("init: running: {cmd:?}");
+ init_debug(&format!("running: {cmd:?}"));
}
cmd.run(config);
}
}
UnitKind::Service { service } => {
+ let desc = unit.info.description.as_ref().unwrap_or(&unit.id.0);
if config.skip_cmd.contains(&service.cmd) {
- eprintln!("Skipping '{} {}'", service.cmd, service.args.join(" "));
+ status_skip(&format!("Skipping {} ({})", desc, service.cmd));
return;
}
if config.log_debug {
- eprintln!(
- "Starting {} ({})",
- unit.info.description.as_ref().unwrap_or(&unit.id.0),
- service.cmd,
- );
+ init_info(&format!("Starting {} ({})", desc, service.cmd));
+ } else {
+ status_ok(&format!("Started {}", desc));
}
service.spawn(&config.envs);
}
UnitKind::Target {} => {
if config.log_debug {
- eprintln!(
- "Reached target {}",
- unit.info.description.as_ref().unwrap_or(&unit.id.0),
- );
+ init_info(&format!("Reached target {}",
+ unit.info.description.as_ref().unwrap_or(&unit.id.0)));
}
}
}
--- a/init/src/service.rs 2026-05-03 05:35:37.594239723 +0100
+++ b/init/src/service.rs 2026-05-03 08:14:33.427953738 +0100
@@ -8,6 +8,7 @@
use serde::Deserialize;
+use crate::color::{init_error, init_warn, status_fail};
use crate::script::subst_env;
#[derive(Clone, Debug, Deserialize)]
@@ -52,7 +53,7 @@
let mut child = match command.spawn() {
Ok(child) => child,
Err(err) => {
- eprintln!("init: failed to execute {:?}: {}", command, err);
+ status_fail(&format!("failed to execute {:?}: {}", command, err));
return;
}
};
@@ -61,10 +62,10 @@
ServiceType::Notify => match read_pipe.read_exact(&mut [0]) {
Ok(()) => {}
Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => {
- eprintln!("init: {command:?} exited without notifying readiness");
+ init_warn(&format!("{:?} exited without notifying readiness", command));
}
Err(err) => {
- eprintln!("init: failed to wait for {command:?}: {err}");
+ init_error(&format!("failed to wait for {:?}: {}", command, err));
}
},
ServiceType::Scheme(scheme) => {
@@ -80,16 +81,16 @@
errno: syscall::EINTR,
}) => continue,
Ok(0) => {
- eprintln!("init: {command:?} exited without notifying readiness");
+ init_warn(&format!("{:?} exited without notifying readiness", command));
return;
}
Ok(1) => break,
Ok(n) => {
- eprintln!("init: incorrect amount of fds {n} returned");
+ init_error(&format!("incorrect amount of fds {} returned", n));
return;
}
Err(err) => {
- eprintln!("init: failed to wait for {command:?}: {err}");
+ init_error(&format!("failed to wait for {:?}: {}", command, err));
return;
}
}
@@ -104,11 +105,11 @@
match child.wait() {
Ok(exit_status) => {
if !exit_status.success() {
- eprintln!("init: {command:?} failed with {exit_status}");
+ status_fail(&format!("{:?} failed with {}", command, exit_status));
}
}
Err(err) => {
- eprintln!("init: failed to wait for {:?}: {}", command, err)
+ init_error(&format!("failed to wait for {:?}: {}", command, err))
}
}
}
@@ -0,0 +1,339 @@
diff --git a/drivers/inputd/src/lib.rs b/drivers/inputd/src/lib.rs
index b68e8211..eb14de51 100644
--- a/drivers/inputd/src/lib.rs
+++ b/drivers/inputd/src/lib.rs
@@ -1,5 +1,5 @@
use std::fs::{File, OpenOptions};
-use std::io::{self, Read, Write};
+use std::io::{self, ErrorKind, Read, Write};
use std::mem::size_of;
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, RawFd};
use std::os::unix::fs::OpenOptionsExt;
@@ -31,6 +31,24 @@ unsafe fn any_as_u8_slice_mut<T: Sized>(p: &mut T) -> &mut [u8] {
slice::from_raw_parts_mut((p as *mut T) as *mut u8, size_of::<T>())
}
+fn validate_input_name(kind: &str, name: &str) -> io::Result<()> {
+ if name.is_empty() {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ format!("input {kind} name must not be empty"),
+ ));
+ }
+
+ if name.contains('/') {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ format!("input {kind} name must not contain '/'"),
+ ));
+ }
+
+ Ok(())
+}
+
pub struct ConsumerHandle(File);
pub enum ConsumerHandleEvent<'a> {
@@ -64,25 +82,53 @@ impl ConsumerHandle {
let fd = self.0.as_raw_fd();
let written = libredox::call::fpath(fd as usize, &mut buffer)?;
- assert!(written <= buffer.len());
+ if written > buffer.len() {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "inputd: display path exceeded buffer size",
+ ));
+ }
- let mut display_path = PathBuf::from(
- std::str::from_utf8(&buffer[..written])
- .expect("init: display path UTF-8 check failed")
- .to_owned(),
- );
- display_path.set_file_name(format!(
- "v2/{}",
- display_path.file_name().unwrap().to_str().unwrap()
- ));
- let display_path = display_path.to_str().unwrap();
+ let path_str = std::str::from_utf8(&buffer[..written]).map_err(|e| {
+ io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!("inputd: display path is not valid UTF-8: {e}"),
+ )
+ })?;
+ let mut display_path = PathBuf::from(path_str.to_owned());
+
+ let file_name = display_path
+ .file_name()
+ .and_then(|n| n.to_str())
+ .ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!(
+ "inputd: display path has no valid file name: {}",
+ display_path.display()
+ ),
+ )
+ })?;
+ display_path.set_file_name(format!("v2/{file_name}"));
+ let display_path_str = display_path.to_str().ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!(
+ "inputd: constructed display path is not valid UTF-8: {}",
+ display_path.display()
+ ),
+ )
+ })?;
let display_file =
- libredox::call::open(display_path, (O_CLOEXEC | O_NONBLOCK | O_RDWR) as _, 0)
+ libredox::call::open(display_path_str, (O_CLOEXEC | O_NONBLOCK | O_RDWR) as _, 0)
.map(|socket| unsafe { File::from_raw_fd(socket as RawFd) })
- .unwrap_or_else(|err| {
- panic!("failed to open display {}: {}", display_path, err);
- });
+ .map_err(|err| {
+ io::Error::new(
+ io::ErrorKind::Other,
+ format!("inputd: failed to open display {display_path_str}: {err}"),
+ )
+ })?;
Ok(display_file)
}
@@ -152,8 +198,12 @@ impl DisplayHandle {
if nread == 0 {
Ok(None)
+ } else if nread != size_of::<VtEvent>() {
+ Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!("inputd: partial vt event read: got {nread}, expected {}", size_of::<VtEvent>()),
+ ))
} else {
- assert_eq!(nread, size_of::<VtEvent>());
Ok(Some(event))
}
}
@@ -197,6 +247,160 @@ pub struct VtEvent {
pub vt: usize,
}
+#[derive(Debug, Clone, Copy)]
+#[repr(C)]
+pub struct HotplugEventHeader {
+ pub kind: u32,
+ pub device_id: u32,
+ pub name_len: u32,
+ pub _reserved: u32,
+}
+
+#[derive(Debug, Clone)]
+pub struct HotplugEvent {
+ pub kind: u32,
+ pub device_id: u32,
+ pub name: String,
+}
+
+/// Handle for opening a named producer on the input scheme.
+/// Opens /scheme/input/producer/{name}
+pub struct NamedProducerHandle {
+ fd: File,
+}
+
+impl NamedProducerHandle {
+ pub fn new(name: &str) -> io::Result<Self> {
+ validate_input_name("producer", name)?;
+
+ let path = format!("/scheme/input/producer/{name}");
+ File::open(path).map(|fd| Self { fd })
+ }
+
+ pub fn write_event(&mut self, event: &orbclient::Event) -> io::Result<()> {
+ self.fd.write(unsafe { any_as_u8_slice(event) })?;
+ Ok(())
+ }
+}
+
+pub struct DeviceConsumerHandle {
+ fd: File,
+}
+
+impl DeviceConsumerHandle {
+ pub fn new(device_name: &str) -> io::Result<Self> {
+ validate_input_name("device", device_name)?;
+
+ let fd = OpenOptions::new()
+ .read(true)
+ .custom_flags(O_NONBLOCK as i32)
+ .open(format!("/scheme/input/{device_name}"))?;
+
+ Ok(Self { fd })
+ }
+
+ pub fn event_handle(&self) -> BorrowedFd<'_> {
+ self.fd.as_fd()
+ }
+
+ pub fn read_event(&mut self) -> io::Result<Option<orbclient::Event>> {
+ let mut raw = [0_u8; size_of::<orbclient::Event>()];
+
+ match self.fd.read(&mut raw) {
+ Ok(0) => Ok(None),
+ Ok(read) => {
+ assert_eq!(read, raw.len());
+ Ok(Some(unsafe {
+ core::ptr::read_unaligned(raw.as_ptr().cast::<orbclient::Event>())
+ }))
+ }
+ Err(err) if err.kind() == ErrorKind::WouldBlock => Ok(None),
+ Err(err) => Err(err),
+ }
+ }
+}
+
+pub struct HotplugHandle {
+ fd: File,
+ partial: Vec<u8>,
+}
+
+impl HotplugHandle {
+ pub fn new() -> io::Result<Self> {
+ let fd = OpenOptions::new()
+ .read(true)
+ .custom_flags(O_NONBLOCK as i32)
+ .open("/scheme/input/events")?;
+
+ Ok(Self {
+ fd,
+ partial: Vec::new(),
+ })
+ }
+
+ pub fn event_handle(&self) -> BorrowedFd<'_> {
+ self.fd.as_fd()
+ }
+
+ pub fn read_event(&mut self) -> io::Result<Option<HotplugEvent>> {
+ let mut buf = [0_u8; 1024];
+
+ loop {
+ if let Some(event) = Self::try_parse_event(&mut self.partial)? {
+ return Ok(Some(event));
+ }
+
+ match self.fd.read(&mut buf) {
+ Ok(0) => return Ok(None),
+ Ok(read) => self.partial.extend_from_slice(&buf[..read]),
+ Err(err) if err.kind() == ErrorKind::WouldBlock => return Ok(None),
+ Err(err) => return Err(err),
+ }
+ }
+ }
+
+ fn try_parse_event(partial: &mut Vec<u8>) -> io::Result<Option<HotplugEvent>> {
+ if partial.len() < size_of::<HotplugEventHeader>() {
+ return Ok(None);
+ }
+
+ let header =
+ unsafe { core::ptr::read_unaligned(partial.as_ptr().cast::<HotplugEventHeader>()) };
+ let name_len = usize::try_from(header.name_len).map_err(|_| {
+ io::Error::new(
+ io::ErrorKind::InvalidData,
+ "invalid input hotplug name length",
+ )
+ })?;
+ let total_len = size_of::<HotplugEventHeader>()
+ .checked_add(name_len)
+ .ok_or_else(|| {
+ io::Error::new(io::ErrorKind::InvalidData, "input hotplug event too large")
+ })?;
+
+ if partial.len() < total_len {
+ return Ok(None);
+ }
+
+ let name = std::str::from_utf8(&partial[size_of::<HotplugEventHeader>()..total_len])
+ .map_err(|_| {
+ io::Error::new(
+ io::ErrorKind::InvalidData,
+ "input hotplug name is not UTF-8",
+ )
+ })?
+ .to_owned();
+
+ partial.drain(..total_len);
+
+ Ok(Some(HotplugEvent {
+ kind: header.kind,
+ device_id: header.device_id,
+ name,
+ }))
+ }
+}
+
pub struct ProducerHandle(File);
impl ProducerHandle {
@@ -208,4 +412,58 @@ impl ProducerHandle {
self.0.write(&event)?;
Ok(())
}
+
+ pub fn query_keymap_char(&self, scancode: u8, shift: bool, altgr: bool) -> Option<char> {
+ use std::io::Read;
+ let path = format!("/scheme/keymap/translate/{}/{}/{}", scancode, shift as u8, altgr as u8);
+ let mut f = match std::fs::File::open(&path) {
+ Ok(f) => f,
+ Err(_) => return None,
+ };
+ let mut buf = [0u8; 4];
+ match f.read(&mut buf) {
+ Ok(n) if n > 0 => {
+ let s = std::str::from_utf8(&buf[..n]).ok()?;
+ s.chars().next()
+ }
+ _ => None,
+ }
+ }
+
+ pub fn send_led_state(caps: bool, num: bool, scroll: bool) -> io::Result<()> {
+ let led_byte = (caps as u8) | ((num as u8) << 1) | ((scroll as u8) << 2);
+ let path = format!("/scheme/input/control/leds/{}", led_byte);
+ std::fs::write(&path, &[])?;
+ Ok(())
+ }
+}
+
+/// Convenience wrapper that tries a named producer first,
+/// falling back to the legacy anonymous producer on failure.
+pub enum InputProducer {
+ Named(NamedProducerHandle),
+ Legacy(ProducerHandle),
+}
+
+impl InputProducer {
+ /// Open a named producer (`/scheme/input/producer/{name}`).
+ /// Falls back to the legacy anonymous producer on failure.
+ pub fn new_named_or_fallback(name: &str) -> io::Result<Self> {
+ match NamedProducerHandle::new(name) {
+ Ok(named) => Ok(InputProducer::Named(named)),
+ Err(_) => ProducerHandle::new().map(InputProducer::Legacy),
+ }
+ }
+
+ /// Open the legacy anonymous producer directly.
+ pub fn new_legacy() -> io::Result<Self> {
+ ProducerHandle::new().map(InputProducer::Legacy)
+ }
+
+ pub fn write_event(&mut self, event: orbclient::Event) -> io::Result<()> {
+ match self {
+ InputProducer::Named(h) => h.write_event(&event),
+ InputProducer::Legacy(h) => h.write_event(event),
+ }
+ }
}
@@ -0,0 +1,461 @@
--- a/drivers/input/ps2d/src/controller.rs 21:31:04.000000000 +0100
+++ b/drivers/input/ps2d/src/controller.rs 2026-05-02 02:50:19.907179957 +0100
@@ -97,6 +97,14 @@
const DEFAULT_TIMEOUT: u64 = 50_000;
// Reset timeout in microseconds
const RESET_TIMEOUT: u64 = 1_000_000;
+// Maximum bytes to drain during flush (Linux: I8042_BUFFER_SIZE)
+const FLUSH_LIMIT: usize = 4096;
+// Controller self-test pass value (Linux: I8042_RET_CTL_TEST)
+const SELFTEST_PASS: u8 = 0x55;
+// Controller self-test retries (Linux: 5 attempts)
+const SELFTEST_RETRIES: usize = 5;
+// AUX port test pass value (Linux returns 0x00 on success)
+const AUX_TEST_PASS: u8 = 0x00;
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
pub struct Ps2 {
@@ -261,6 +269,30 @@
self.write(command as u8)
}
+ pub fn set_leds(&mut self, caps: bool, num: bool, scroll: bool) {
+ let mut led_byte = 0u8;
+ if scroll { led_byte |= 1; }
+ if num { led_byte |= 2; }
+ if caps { led_byte |= 4; }
+ if let Err(err) = self.keyboard_command_inner(0xED) {
+ warn!("ps2d: failed to send LED command 0xED: {:?}", err);
+ return;
+ }
+ match self.read_timeout(DEFAULT_TIMEOUT) {
+ Ok(0xFA) => {
+ if let Err(err) = self.write(led_byte) {
+ warn!("ps2d: failed to send LED byte {:02X}: {:?}", led_byte, err);
+ }
+ }
+ Ok(val) => {
+ warn!("ps2d: LED command ACK expected 0xFA, got {:02X}", val);
+ }
+ Err(err) => {
+ warn!("ps2d: LED command ACK timeout: {:?}", err);
+ }
+ }
+ }
+
pub fn next(&mut self) -> Option<(bool, u8)> {
let status = self.status();
if status.contains(StatusFlags::OUTPUT_FULL) {
@@ -271,6 +303,50 @@
}
}
+ /// Drain all pending bytes from the controller output buffer.
+ /// Borrowed from Linux i8042_flush(): stale firmware/BIOS bytes can be
+ /// misinterpreted as device responses during initialization.
+ fn flush(&mut self) -> usize {
+ let mut count = 0;
+ while self.status().contains(StatusFlags::OUTPUT_FULL) {
+ if count >= FLUSH_LIMIT {
+ warn!("flush: exceeded limit, controller may be stuck");
+ break;
+ }
+ let data = self.data.read();
+ trace!("flush: discarded {:02X}", data);
+ count += 1;
+ }
+ if count > 0 {
+ debug!("flushed {} stale bytes from controller", count);
+ }
+ count
+ }
+
+ /// Test the AUX (mouse) port via controller command 0xA9.
+ /// Borrowed from Linux: verifies electrical connectivity before
+ /// attempting to talk to the mouse. Returns true if the port passed.
+ fn test_aux_port(&mut self) -> bool {
+ if let Err(err) = self.command(Command::TestSecond) {
+ warn!("aux port test command failed: {:?}", err);
+ return false;
+ }
+ match self.read() {
+ Ok(AUX_TEST_PASS) => {
+ debug!("aux port test passed");
+ true
+ }
+ Ok(val) => {
+ warn!("aux port test failed: {:02X}", val);
+ false
+ }
+ Err(err) => {
+ warn!("aux port test read timeout: {:?}", err);
+ false
+ }
+ }
+ }
+
pub fn init_keyboard(&mut self) -> Result<(), Error> {
let mut b;
@@ -308,66 +384,125 @@
}
pub fn init(&mut self) -> Result<(), Error> {
+ // Linux i8042_controller_check(): verify controller is present by
+ // flushing any stale data. A stuck output buffer means no controller.
+ self.flush();
+
+ // Bare-metal controllers may be slow after firmware handoff.
+ // Give the controller a moment to finish POST before sending commands.
+ std::thread::sleep(std::time::Duration::from_millis(50));
+
{
- // Disable devices
- self.command(Command::DisableFirst)?;
- self.command(Command::DisableSecond)?;
+ // Disable both ports first — use retry because the controller
+ // may still be settling or temporarily unresponsive.
+ // Failure here is non-fatal: we continue and attempt the rest
+ // of initialization. A truly absent controller will fail later
+ // at self-test or keyboard reset.
+ if let Err(err) = self.retry(
+ format_args!("disable first port"),
+ 3,
+ |x| x.command(Command::DisableFirst),
+ ) {
+ warn!("disable first port failed: {:?}", err);
+ }
+ if let Err(err) = self.retry(
+ format_args!("disable second port"),
+ 3,
+ |x| x.command(Command::DisableSecond),
+ ) {
+ warn!("disable second port failed: {:?}", err);
+ }
}
- // Disable clocks, disable interrupts, and disable translate
+ // Flush again after disabling — firmware may have queued more bytes
+ self.flush();
+
+ // Linux i8042_controller_init() step 1: write a known-safe config
+ // (interrupts off, both ports disabled) so stale config can't cause
+ // spurious interrupts during the rest of init.
{
- // Since the default config may have interrupts enabled, and the kernel may eat up
- // our data in that case, we will write a config without reading the current one
let config = ConfigFlags::POST_PASSED
| ConfigFlags::FIRST_DISABLED
| ConfigFlags::SECOND_DISABLED;
self.set_config(config)?;
}
- // The keyboard seems to still collect bytes even when we disable
- // the port, so we must disable the keyboard too
+ // Linux i8042_controller_selftest(): retry up to 5 times with delay.
+ // "On some really fragile systems this does not take the first time."
+ {
+ let mut passed = false;
+ for attempt in 0..SELFTEST_RETRIES {
+ if let Err(err) = self.command(Command::TestController) {
+ warn!("self-test command failed (attempt {}): {:?}", attempt + 1, err);
+ continue;
+ }
+ match self.read() {
+ Ok(SELFTEST_PASS) => {
+ passed = true;
+ break;
+ }
+ Ok(val) => {
+ warn!(
+ "self-test unexpected value {:02X} (attempt {}/{})",
+ val,
+ attempt + 1,
+ SELFTEST_RETRIES
+ );
+ }
+ Err(err) => {
+ warn!("self-test read timeout (attempt {}): {:?}", attempt + 1, err);
+ }
+ }
+ // Linux: msleep(50) between retries
+ std::thread::sleep(std::time::Duration::from_millis(50));
+ }
+ if !passed {
+ // Linux on x86: "giving up on controller selftest, continuing anyway"
+ warn!("controller self-test did not pass after {} attempts, continuing", SELFTEST_RETRIES);
+ }
+ }
+
+ // Flush any bytes the self-test may have left behind
+ self.flush();
+
+ // Linux i8042_controller_init() step 2: set keyboard defaults
+ // (disable scanning so keyboard doesn't send scancodes during init)
self.retry(format_args!("keyboard defaults"), 4, |x| {
- // Set defaults and disable scanning
let b = x.keyboard_command(KeyboardCommand::SetDefaultsDisable)?;
if b != 0xFA {
error!("keyboard failed to set defaults: {:02X}", b);
return Err(Error::CommandRetry);
}
-
Ok(b)
})?;
- {
- // Perform the self test
- self.command(Command::TestController)?;
- let r = self.read()?;
- if r != 0x55 {
- warn!("self test unexpected value: {:02X}", r);
- }
- }
-
// Initialize keyboard
if let Err(err) = self.init_keyboard() {
error!("failed to initialize keyboard: {:?}", err);
return Err(err);
}
- // Enable second device
- let enable_mouse = match self.command(Command::EnableSecond) {
- Ok(()) => true,
- Err(err) => {
- error!("failed to initialize mouse: {:?}", err);
- false
+ // Linux: test AUX port (command 0xA9) before enabling.
+ // Skips mouse init entirely if the port is not electrically present.
+ let aux_ok = self.test_aux_port();
+
+ // Enable second device (mouse) only if AUX port tested OK
+ let enable_mouse = if aux_ok {
+ match self.command(Command::EnableSecond) {
+ Ok(()) => true,
+ Err(err) => {
+ warn!("failed to enable aux port after test passed: {:?}", err);
+ false
+ }
}
+ } else {
+ info!("skipping mouse init: aux port test did not pass");
+ false
};
{
- // Enable keyboard data reporting
- // Use inner function to prevent retries
- // Response is ignored since scanning is now on
if let Err(err) = self.keyboard_command_inner(KeyboardCommand::EnableReporting as u8) {
error!("failed to initialize keyboard reporting: {:?}", err);
- //TODO: fix by using interrupts?
}
}
--- a/drivers/input/ps2d/src/state.rs 21:31:04.000000000 +0100
+++ b/drivers/input/ps2d/src/state.rs 2026-05-02 04:22:27.342569199 +0100
@@ -1,4 +1,4 @@
-use inputd::ProducerHandle;
+use inputd::InputProducer;
use log::{error, warn};
use orbclient::{ButtonEvent, KeyEvent, MouseEvent, MouseRelativeEvent, ScrollEvent};
use std::{
@@ -44,7 +44,8 @@
ps2: Ps2,
vmmouse: bool,
vmmouse_relative: bool,
- input: ProducerHandle,
+ keyboard_input: InputProducer,
+ mouse_input: InputProducer,
time_file: File,
extended: bool,
mouse_x: i32,
@@ -56,12 +57,18 @@
mouse_timeout: Option<TimeSpec>,
packets: [u8; 4],
packet_i: usize,
+ caps_lock: bool,
+ num_lock: bool,
+ scroll_lock: bool,
+ leds_dirty: bool,
}
impl Ps2d {
- pub fn new(input: ProducerHandle, time_file: File) -> Self {
+ pub fn new(keyboard_input: InputProducer, mouse_input: InputProducer, time_file: File) -> Self {
let mut ps2 = Ps2::new();
- ps2.init().expect("failed to initialize");
+ if let Err(err) = ps2.init() {
+ log::error!("ps2d: controller init failed: {:?}", err);
+ }
// FIXME add an option for orbital to disable this when an app captures the mouse.
let vmmouse_relative = false;
@@ -77,7 +84,8 @@
ps2,
vmmouse,
vmmouse_relative,
- input,
+ keyboard_input,
+ mouse_input,
time_file,
extended: false,
mouse_x: 0,
@@ -89,6 +97,10 @@
mouse_timeout: None,
packets: [0; 4],
packet_i: 0,
+ caps_lock: false,
+ num_lock: true,
+ scroll_lock: false,
+ leds_dirty: true,
};
if !this.vmmouse {
@@ -96,6 +108,12 @@
this.handle_mouse(None);
}
+ // Flush initial LED state (Num Lock on by default)
+ if this.leds_dirty {
+ this.leds_dirty = false;
+ this.ps2.set_leds(this.caps_lock, this.num_lock, this.scroll_lock);
+ }
+
this
}
@@ -272,8 +290,21 @@
}
};
+ if scancode != 0 && pressed {
+ match scancode {
+ orbclient::K_CAPS => { self.caps_lock = !self.caps_lock; self.leds_dirty = true; },
+ orbclient::K_NUM => { self.num_lock = !self.num_lock; self.leds_dirty = true; },
+ orbclient::K_SCROLL => { self.scroll_lock = !self.scroll_lock; self.leds_dirty = true; },
+ _ => (),
+ }
+ }
+ if self.leds_dirty {
+ self.leds_dirty = false;
+ self.ps2.set_leds(self.caps_lock, self.num_lock, self.scroll_lock);
+ }
+
if scancode != 0 {
- self.input
+ self.keyboard_input
.write_event(
KeyEvent {
character: '\0',
@@ -304,7 +335,7 @@
if self.vmmouse_relative {
if dx != 0 || dy != 0 {
- self.input
+ self.mouse_input
.write_event(
MouseRelativeEvent {
dx: dx as i32,
@@ -320,14 +351,14 @@
if x != self.mouse_x || y != self.mouse_y {
self.mouse_x = x;
self.mouse_y = y;
- self.input
+ self.mouse_input
.write_event(MouseEvent { x, y }.to_event())
.expect("ps2d: failed to write mouse event");
}
};
if dz != 0 {
- self.input
+ self.mouse_input
.write_event(
ScrollEvent {
x: 0,
@@ -348,7 +379,7 @@
self.mouse_left = left;
self.mouse_middle = middle;
self.mouse_right = right;
- self.input
+ self.mouse_input
.write_event(
ButtonEvent {
left,
@@ -441,13 +472,13 @@
}
if dx != 0 || dy != 0 {
- self.input
+ self.mouse_input
.write_event(MouseRelativeEvent { dx, dy }.to_event())
.expect("ps2d: failed to write mouse event");
}
if dz != 0 {
- self.input
+ self.mouse_input
.write_event(ScrollEvent { x: 0, y: dz }.to_event())
.expect("ps2d: failed to write scroll event");
}
@@ -462,7 +493,7 @@
self.mouse_left = left;
self.mouse_middle = middle;
self.mouse_right = right;
- self.input
+ self.mouse_input
.write_event(
ButtonEvent {
left,
diff --git a/drivers/input/ps2d/src/main.rs b/drivers/input/ps2d/src/main.rs
index db17de2a..86f903bf 100644
--- a/drivers/input/ps2d/src/main.rs 21:31:04.000000000 +0100
+++ b/drivers/input/ps2d/src/main.rs
@@ -14,4 +14,4 @@
-use inputd::ProducerHandle;
+use inputd::InputProducer;
use crate::state::Ps2d;
@@ -31,7 +31,8 @@ fn daemon(daemon: daemon::Daemon) -> ! {
acquire_port_io_rights().expect("ps2d: failed to get I/O permission");
- let input = ProducerHandle::new().expect("ps2d: failed to open input producer");
+ let keyboard_input = InputProducer::new_named_or_fallback("ps2-keyboard").expect("ps2d: failed to open keyboard input");
+ let mouse_input = InputProducer::new_named_or_fallback("ps2-mouse").expect("ps2d: failed to open mouse input");
user_data! {
enum Source {
@@ -93,7 +94,7 @@ fn daemon(daemon: daemon::Daemon) -> ! {
daemon.ready();
- let mut ps2d = Ps2d::new(input, time_file);
+ let mut ps2d = Ps2d::new(keyboard_input, mouse_input, time_file);
let mut data = [0; 256];
for event in event_queue.map(|e| e.expect("ps2d: failed to get next event").user_data) {
+31,8 @@ fn daemon(daemon: daemon::Daemon) -> ! {
acquire_port_io_rights().expect("ps2d: failed to get I/O permission");
- let input = ProducerHandle::new().expect("ps2d: failed to open input producer");
+ let keyboard_input = InputProducer::new_named_or_fallback("ps2-keyboard").expect("ps2d: failed to open keyboard input");
+ let mouse_input = InputProducer::new_named_or_fallback("ps2-mouse").expect("ps2d: failed to open mouse input");
user_data! {
enum Source {
@@ -93,7 +94,7 @@ fn daemon(daemon: daemon::Daemon) -> ! {
daemon.ready();
- let mut ps2d = Ps2d::new(input, time_file);
+ let mut ps2d = Ps2d::new(keyboard_input, mouse_input, time_file);
let mut data = [0; 256];
for event in event_queue.map(|e| e.expect("ps2d: failed to get next event").user_data) {
@@ -0,0 +1,725 @@
diff --git a/drivers/input/usbhidd/src/main.rs b/drivers/input/usbhidd/src/main.rs
index 15c5b778..472ec4cf 100644
--- a/drivers/input/usbhidd/src/main.rs
+++ b/drivers/input/usbhidd/src/main.rs
@@ -1,7 +1,7 @@
-use anyhow::{Context, Result};
+use anyhow::{bail, ensure, Context, Result};
use std::{env, thread, time};
-use inputd::ProducerHandle;
+use inputd::InputProducer;
use orbclient::KeyEvent as OrbKeyEvent;
use rehid::{
report_desc::{ReportTy, REPORT_DESC_TY},
@@ -15,161 +15,219 @@ use xhcid_interface::{
mod reqs;
-fn send_key_event(display: &mut ProducerHandle, usage_page: u16, usage: u16, pressed: bool) {
- let scancode = match usage_page {
- 0x07 => match usage {
- 0x04 => orbclient::K_A,
- 0x05 => orbclient::K_B,
- 0x06 => orbclient::K_C,
- 0x07 => orbclient::K_D,
- 0x08 => orbclient::K_E,
- 0x09 => orbclient::K_F,
- 0x0A => orbclient::K_G,
- 0x0B => orbclient::K_H,
- 0x0C => orbclient::K_I,
- 0x0D => orbclient::K_J,
- 0x0E => orbclient::K_K,
- 0x0F => orbclient::K_L,
- 0x10 => orbclient::K_M,
- 0x11 => orbclient::K_N,
- 0x12 => orbclient::K_O,
- 0x13 => orbclient::K_P,
- 0x14 => orbclient::K_Q,
- 0x15 => orbclient::K_R,
- 0x16 => orbclient::K_S,
- 0x17 => orbclient::K_T,
- 0x18 => orbclient::K_U,
- 0x19 => orbclient::K_V,
- 0x1A => orbclient::K_W,
- 0x1B => orbclient::K_X,
- 0x1C => orbclient::K_Y,
- 0x1D => orbclient::K_Z,
- 0x1E => orbclient::K_1,
- 0x1F => orbclient::K_2,
- 0x20 => orbclient::K_3,
- 0x21 => orbclient::K_4,
- 0x22 => orbclient::K_5,
- 0x23 => orbclient::K_6,
- 0x24 => orbclient::K_7,
- 0x25 => orbclient::K_8,
- 0x26 => orbclient::K_9,
- 0x27 => orbclient::K_0,
- 0x28 => orbclient::K_ENTER,
- 0x29 => orbclient::K_ESC,
- 0x2A => orbclient::K_BKSP,
- 0x2B => orbclient::K_TAB,
- 0x2C => orbclient::K_SPACE,
- 0x2D => orbclient::K_MINUS,
- 0x2E => orbclient::K_EQUALS,
- 0x2F => orbclient::K_BRACE_OPEN,
- 0x30 => orbclient::K_BRACE_CLOSE,
- 0x31 => orbclient::K_BACKSLASH,
- // 0x32 non-us # and ~
- 0x32 => 0x56,
- 0x33 => orbclient::K_SEMICOLON,
- 0x34 => orbclient::K_QUOTE,
- 0x35 => orbclient::K_TICK,
- 0x36 => orbclient::K_COMMA,
- 0x37 => orbclient::K_PERIOD,
- 0x38 => orbclient::K_SLASH,
- 0x39 => orbclient::K_CAPS,
- 0x3A => orbclient::K_F1,
- 0x3B => orbclient::K_F2,
- 0x3C => orbclient::K_F3,
- 0x3D => orbclient::K_F4,
- 0x3E => orbclient::K_F5,
- 0x3F => orbclient::K_F6,
- 0x40 => orbclient::K_F7,
- 0x41 => orbclient::K_F8,
- 0x42 => orbclient::K_F9,
- 0x43 => orbclient::K_F10,
- 0x44 => orbclient::K_F11,
- 0x45 => orbclient::K_F12,
- 0x46 => orbclient::K_PRTSC,
- 0x47 => orbclient::K_SCROLL,
- // 0x48 pause
- 0x49 => orbclient::K_INS,
- 0x4A => orbclient::K_HOME,
- 0x4B => orbclient::K_PGUP,
- 0x4C => orbclient::K_DEL,
- 0x4D => orbclient::K_END,
- 0x4E => orbclient::K_PGDN,
- 0x4F => orbclient::K_RIGHT,
- 0x50 => orbclient::K_LEFT,
- 0x51 => orbclient::K_DOWN,
- 0x52 => orbclient::K_UP,
- 0x53 => orbclient::K_NUM,
- 0x54 => orbclient::K_NUM_SLASH,
- 0x55 => orbclient::K_NUM_ASTERISK,
- 0x56 => orbclient::K_NUM_MINUS,
- 0x57 => orbclient::K_NUM_PLUS,
- 0x58 => orbclient::K_NUM_ENTER,
- 0x59 => orbclient::K_NUM_1,
- 0x5A => orbclient::K_NUM_2,
- 0x5B => orbclient::K_NUM_3,
- 0x5C => orbclient::K_NUM_4,
- 0x5D => orbclient::K_NUM_5,
- 0x5E => orbclient::K_NUM_6,
- 0x5F => orbclient::K_NUM_7,
- 0x60 => orbclient::K_NUM_8,
- 0x61 => orbclient::K_NUM_9,
- 0x62 => orbclient::K_NUM_0,
- // 0x62 num .
- // 0x64 non-us \ and |
- 0x64 => orbclient::K_APP,
- 0x66 => orbclient::K_POWER,
- // 0x67 num =
- // unmapped values
- 0xE0 => orbclient::K_LEFT_CTRL,
- 0xE1 => orbclient::K_LEFT_SHIFT,
- 0xE2 => orbclient::K_ALT,
- 0xE3 => orbclient::K_LEFT_SUPER,
- 0xE4 => orbclient::K_RIGHT_CTRL,
- 0xE5 => orbclient::K_RIGHT_SHIFT,
- 0xE6 => orbclient::K_ALT_GR,
- 0xE7 => orbclient::K_RIGHT_SUPER,
- // reserved values
- _ => {
- log::warn!("unknown usage_page {:#x} usage {:#x}", usage_page, usage);
- return;
- }
- },
- _ => {
- log::warn!("unknown usage_page {:#x}", usage_page);
+const MAX_MOUSE_BUTTONS: usize = 8;
+const MAX_RETRIES: u32 = 3;
+const RETRY_BASE_DELAY_MS: u64 = 2;
+const REPORT_DESC_MAX_SIZE: usize = 4096;
+const REPORT_DESC_MIN_SIZE: usize = 3;
+
+/// USB HID Usage (Keyboard page 0x07) → orbclient scancode lookup table.
+/// Covers usage codes 0x040xE7 per USB HID Usage Tables spec v1.12.
+/// Entries default to 0 (unmapped); only defined usages are populated.
+static HID_KEYBOARD_TO_SCANCODE: [u8; 0xE8] = {
+ let mut table = [0u8; 0xE8];
+ table[0x04] = orbclient::K_A;
+ table[0x05] = orbclient::K_B;
+ table[0x06] = orbclient::K_C;
+ table[0x07] = orbclient::K_D;
+ table[0x08] = orbclient::K_E;
+ table[0x09] = orbclient::K_F;
+ table[0x0A] = orbclient::K_G;
+ table[0x0B] = orbclient::K_H;
+ table[0x0C] = orbclient::K_I;
+ table[0x0D] = orbclient::K_J;
+ table[0x0E] = orbclient::K_K;
+ table[0x0F] = orbclient::K_L;
+ table[0x10] = orbclient::K_M;
+ table[0x11] = orbclient::K_N;
+ table[0x12] = orbclient::K_O;
+ table[0x13] = orbclient::K_P;
+ table[0x14] = orbclient::K_Q;
+ table[0x15] = orbclient::K_R;
+ table[0x16] = orbclient::K_S;
+ table[0x17] = orbclient::K_T;
+ table[0x18] = orbclient::K_U;
+ table[0x19] = orbclient::K_V;
+ table[0x1A] = orbclient::K_W;
+ table[0x1B] = orbclient::K_X;
+ table[0x1C] = orbclient::K_Y;
+ table[0x1D] = orbclient::K_Z;
+ table[0x1E] = orbclient::K_1;
+ table[0x1F] = orbclient::K_2;
+ table[0x20] = orbclient::K_3;
+ table[0x21] = orbclient::K_4;
+ table[0x22] = orbclient::K_5;
+ table[0x23] = orbclient::K_6;
+ table[0x24] = orbclient::K_7;
+ table[0x25] = orbclient::K_8;
+ table[0x26] = orbclient::K_9;
+ table[0x27] = orbclient::K_0;
+ table[0x28] = orbclient::K_ENTER;
+ table[0x29] = orbclient::K_ESC;
+ table[0x2A] = orbclient::K_BKSP;
+ table[0x2B] = orbclient::K_TAB;
+ table[0x2C] = orbclient::K_SPACE;
+ table[0x2D] = orbclient::K_MINUS;
+ table[0x2E] = orbclient::K_EQUALS;
+ table[0x2F] = orbclient::K_BRACE_OPEN;
+ table[0x30] = orbclient::K_BRACE_CLOSE;
+ table[0x31] = orbclient::K_BACKSLASH;
+ table[0x32] = 0x56;
+ table[0x33] = orbclient::K_SEMICOLON;
+ table[0x34] = orbclient::K_QUOTE;
+ table[0x35] = orbclient::K_TICK;
+ table[0x36] = orbclient::K_COMMA;
+ table[0x37] = orbclient::K_PERIOD;
+ table[0x38] = orbclient::K_SLASH;
+ table[0x39] = orbclient::K_CAPS;
+ table[0x3A] = orbclient::K_F1;
+ table[0x3B] = orbclient::K_F2;
+ table[0x3C] = orbclient::K_F3;
+ table[0x3D] = orbclient::K_F4;
+ table[0x3E] = orbclient::K_F5;
+ table[0x3F] = orbclient::K_F6;
+ table[0x40] = orbclient::K_F7;
+ table[0x41] = orbclient::K_F8;
+ table[0x42] = orbclient::K_F9;
+ table[0x43] = orbclient::K_F10;
+ table[0x44] = orbclient::K_F11;
+ table[0x45] = orbclient::K_F12;
+ table[0x46] = orbclient::K_PRTSC;
+ table[0x47] = orbclient::K_SCROLL;
+ table[0x49] = orbclient::K_INS;
+ table[0x4A] = orbclient::K_HOME;
+ table[0x4B] = orbclient::K_PGUP;
+ table[0x4C] = orbclient::K_DEL;
+ table[0x4D] = orbclient::K_END;
+ table[0x4E] = orbclient::K_PGDN;
+ table[0x4F] = orbclient::K_RIGHT;
+ table[0x50] = orbclient::K_LEFT;
+ table[0x51] = orbclient::K_DOWN;
+ table[0x52] = orbclient::K_UP;
+ table[0x53] = orbclient::K_NUM;
+ table[0x54] = orbclient::K_NUM_SLASH;
+ table[0x55] = orbclient::K_NUM_ASTERISK;
+ table[0x56] = orbclient::K_NUM_MINUS;
+ table[0x57] = orbclient::K_NUM_PLUS;
+ table[0x58] = orbclient::K_NUM_ENTER;
+ table[0x59] = orbclient::K_NUM_1;
+ table[0x5A] = orbclient::K_NUM_2;
+ table[0x5B] = orbclient::K_NUM_3;
+ table[0x5C] = orbclient::K_NUM_4;
+ table[0x5D] = orbclient::K_NUM_5;
+ table[0x5E] = orbclient::K_NUM_6;
+ table[0x5F] = orbclient::K_NUM_7;
+ table[0x60] = orbclient::K_NUM_8;
+ table[0x61] = orbclient::K_NUM_9;
+ table[0x62] = orbclient::K_NUM_0;
+ table[0x64] = orbclient::K_APP;
+ table[0x66] = orbclient::K_POWER;
+ table[0xE0] = orbclient::K_LEFT_CTRL;
+ table[0xE1] = orbclient::K_LEFT_SHIFT;
+ table[0xE2] = orbclient::K_ALT;
+ table[0xE3] = orbclient::K_LEFT_SUPER;
+ table[0xE4] = orbclient::K_RIGHT_CTRL;
+ table[0xE5] = orbclient::K_RIGHT_SHIFT;
+ table[0xE6] = orbclient::K_ALT_GR;
+ table[0xE7] = orbclient::K_RIGHT_SUPER;
+ table
+};
+
+fn hid_usage_to_scancode(usage: u16) -> Option<u8> {
+ let idx = usage as usize;
+ if idx < HID_KEYBOARD_TO_SCANCODE.len() {
+ let sc = HID_KEYBOARD_TO_SCANCODE[idx];
+ if sc != 0 {
+ return Some(sc);
+ }
+ }
+ None
+}
+
+fn send_key_event(display: &mut InputProducer, usage_page: u16, usage: u16, pressed: bool) {
+ if usage_page != 0x07 {
+ log::warn!("send_key_event: unexpected usage_page {:#x}", usage_page);
+ return;
+ }
+ let scancode = match hid_usage_to_scancode(usage) {
+ Some(sc) => sc,
+ None => {
+ log::warn!("unmapped HID keyboard usage {:#x}", usage);
return;
}
};
-
let key_event = OrbKeyEvent {
character: '\0',
scancode,
pressed,
};
+ if let Err(err) = display.write_event(key_event.to_event()) {
+ log::warn!("failed to send key event: {}", err);
+ }
+}
- match display.write_event(key_event.to_event()) {
- Ok(_) => (),
- Err(err) => {
- log::warn!("failed to send key event to orbital: {}", err);
+fn validate_report_descriptor(bytes: &[u8]) -> Result<()> {
+ ensure!(
+ bytes.len() >= REPORT_DESC_MIN_SIZE,
+ "report descriptor too short: {} bytes (minimum {})",
+ bytes.len(),
+ REPORT_DESC_MIN_SIZE
+ );
+ ensure!(
+ bytes.len() <= REPORT_DESC_MAX_SIZE,
+ "report descriptor too large: {} bytes (maximum {})",
+ bytes.len(),
+ REPORT_DESC_MAX_SIZE
+ );
+ let mut depth = 0u32;
+ let mut i = 0;
+ while i < bytes.len() {
+ let b = bytes[i];
+ let size = match b & 0x03 {
+ 0 => 0,
+ 1 => 1,
+ 2 => 2,
+ 3 => 4,
+ _ => unreachable!(),
+ };
+ let item_type = (b >> 2) & 0x03;
+ if item_type == 0x0A {
+ depth += 1;
+ } else if item_type == 0x0C {
+ if depth == 0 {
+ bail!(
+ "unbalanced Collection/EndCollection in report descriptor at offset {}",
+ i
+ );
+ }
+ depth -= 1;
}
+ i += 1 + size as usize;
}
+ ensure!(
+ depth == 0,
+ "unbalanced Collection/EndCollection (depth {} at end)",
+ depth
+ );
+ Ok(())
}
fn main() -> Result<()> {
let mut args = env::args().skip(1);
- const USAGE: &'static str = "usbhidd <scheme> <port> <interface>";
+ const USAGE: &str = "usbhidd <scheme> <port> <interface>";
- let scheme = args.next().expect(USAGE);
- let port = args
+ let scheme = args.next().context(USAGE)?;
+ let port: PortId = args
.next()
- .expect(USAGE)
- .parse::<PortId>()
- .expect("Expected port ID");
- let interface_num = args
+ .context(USAGE)?
+ .parse()
+ .map_err(|e| anyhow::anyhow!("Expected port ID: {}", e))?;
+ let interface_num: u8 = args
.next()
- .expect(USAGE)
- .parse::<u8>()
- .expect("Expected integer as input of interface");
+ .context(USAGE)?
+ .parse()
+ .map_err(|e| anyhow::anyhow!("Expected interface number: {}", e))?;
let name = format!("{}_{}_{}_hid", scheme, port, interface_num);
common::setup_logging(
@@ -218,7 +276,6 @@ fn main() -> Result<()> {
}
});
let hid_desc = if_desc.hid_descs.iter().find_map(|hid_desc| {
- //TODO: should we do any filtering?
Some(hid_desc)
})?;
Some((if_desc.clone(), endp_desc_opt, hid_desc))
@@ -240,31 +297,39 @@ fn main() -> Result<()> {
})
.context("Failed to configure endpoints")?;
- //TODO: do we need to set protocol to report? It fails for mice.
-
- //TODO: dynamically create good values, fix xhcid so it does not block on each request
- // This sets all reports to a duration of 4ms
reqs::set_idle(&handle, 1, 0, interface_num as u16).context("Failed to set idle")?;
- let report_desc_len = hid_desc.desc_len;
- assert_eq!(hid_desc.desc_ty, REPORT_DESC_TY);
+ let report_desc_len = hid_desc.desc_len as usize;
+ ensure!(
+ hid_desc.desc_ty == REPORT_DESC_TY,
+ "unexpected HID descriptor type: expected {}, got {}",
+ REPORT_DESC_TY,
+ hid_desc.desc_ty
+ );
+ ensure!(
+ report_desc_len >= REPORT_DESC_MIN_SIZE && report_desc_len <= REPORT_DESC_MAX_SIZE,
+ "suspicious report descriptor length: {}",
+ report_desc_len
+ );
- let mut report_desc_bytes = vec![0u8; report_desc_len as usize];
+ let mut report_desc_bytes = vec![0u8; report_desc_len];
handle
.get_descriptor(
PortReqRecipient::Interface,
REPORT_DESC_TY,
0,
- //TODO: should this be an index into interface_descs?
interface_num as u16,
&mut report_desc_bytes,
)
.context("Failed to retrieve report descriptor")?;
+ validate_report_descriptor(&report_desc_bytes)
+ .context("HID report descriptor validation failed")?;
+
let mut handler =
- ReportHandler::new(&report_desc_bytes).expect("failed to parse report descriptor");
+ ReportHandler::new(&report_desc_bytes).map_err(|e| anyhow::anyhow!("failed to parse report descriptor: {}", e))?;
- let report_len = match endp_desc_opt {
+ let report_len = match &endp_desc_opt {
Some((_endp_num, endp_desc)) => endp_desc.max_packet_size as usize,
None => handler.total_byte_length as usize,
};
@@ -272,7 +337,9 @@ fn main() -> Result<()> {
let report_ty = ReportTy::Input;
let report_id = 0;
- let mut display = ProducerHandle::new().context("Failed to open input socket")?;
+ let producer_name = format!("usb-{}-if{}", port, interface_num);
+ let mut display = InputProducer::new_named_or_fallback(&producer_name)
+ .context("Failed to open input socket")?;
let mut endpoint_opt = match endp_desc_opt {
Some((endp_num, _endp_desc)) => match handle.open_endpoint(endp_num as u8) {
Ok(ok) => Some(ok),
@@ -286,172 +353,168 @@ fn main() -> Result<()> {
let mut left_shift = false;
let mut right_shift = false;
let mut last_mouse_pos = (0, 0);
- let mut last_buttons = [false, false, false];
+ let mut last_buttons = [false; MAX_MOUSE_BUTTONS];
+ let mut consecutive_errors: u32 = 0;
+
loop {
- //TODO: get frequency from device
- //TODO: use sleeps when accuracy is better: thread::sleep(time::Duration::from_millis(10));
- let timer = time::Instant::now();
- while timer.elapsed() < time::Duration::from_millis(1) {
- thread::yield_now();
- }
+ thread::sleep(time::Duration::from_millis(1));
- if let Some(endpoint) = &mut endpoint_opt {
- // interrupt transfer
- endpoint
- .transfer_read(&mut report_buffer)
- .context("failed to get report")?;
+ let transfer_result: Result<(), anyhow::Error> = if let Some(endpoint) = &mut endpoint_opt {
+ endpoint.transfer_read(&mut report_buffer)
+ .map(|_| ())
+ .context("interrupt transfer failed")
} else {
- // control transfer
- reqs::get_report(
- &handle,
- report_ty,
- report_id,
- //TODO: should this be an index into interface_descs?
- interface_num as u16,
- &mut report_buffer,
- )
- .context("failed to get report")?;
+ reqs::get_report(&handle, report_ty, report_id, interface_num as u16, &mut report_buffer)
+ .map(|_| ())
+ .context("control transfer failed")
+ };
+
+ if let Err(err) = transfer_result {
+ consecutive_errors += 1;
+ if consecutive_errors >= MAX_RETRIES {
+ bail!(
+ "transfer failed {} times consecutively: {}",
+ consecutive_errors,
+ err
+ );
+ }
+ let delay = RETRY_BASE_DELAY_MS * (1 << consecutive_errors.min(4));
+ log::warn!(
+ "transfer failed ({}/{}), retry in {}ms: {}",
+ consecutive_errors,
+ MAX_RETRIES,
+ delay,
+ err
+ );
+ thread::sleep(time::Duration::from_millis(delay));
+ continue;
}
+ consecutive_errors = 0;
let mut mouse_pos = last_mouse_pos;
let mut mouse_dx = 0i32;
let mut mouse_dy = 0i32;
let mut scroll_y = 0i32;
let mut buttons = last_buttons;
- for event in handler
- .handle(&report_buffer)
- .expect("failed to parse report")
- {
- log::debug!("{}", event);
- if event.usage_page == UsagePage::GenericDesktop as u16 {
- if event.usage == GenericDesktopUsage::X as u16 {
- if event.relative {
- mouse_dx += event.value as i32;
- } else {
- mouse_pos.0 = event.value as i32;
- }
- } else if event.usage == GenericDesktopUsage::Y as u16 {
- if event.relative {
- mouse_dy += event.value as i32;
- } else {
- mouse_pos.1 = event.value as i32;
- }
- } else if event.usage == GenericDesktopUsage::Wheel as u16 {
- //TODO: what is X scroll?
- if event.relative {
- scroll_y += event.value as i32;
- } else {
- log::warn!("absolute mouse wheel not supported");
+
+ match handler.handle(&report_buffer) {
+ Ok(events) => {
+ for event in events {
+ log::debug!("{}", event);
+ if event.usage_page == UsagePage::GenericDesktop as u16 {
+ if event.usage == GenericDesktopUsage::X as u16 {
+ if event.relative {
+ mouse_dx += event.value as i32;
+ } else {
+ mouse_pos.0 = event.value as i32;
+ }
+ } else if event.usage == GenericDesktopUsage::Y as u16 {
+ if event.relative {
+ mouse_dy += event.value as i32;
+ } else {
+ mouse_pos.1 = event.value as i32;
+ }
+ } else if event.usage == GenericDesktopUsage::Wheel as u16 {
+ if event.relative {
+ scroll_y += event.value as i32;
+ } else {
+ log::warn!("absolute mouse wheel not supported");
+ }
+ } else if event.usage == GenericDesktopUsage::Z as u16 {
+ if event.relative && event.value != 0 {
+ let scroll_event = orbclient::event::ScrollEvent {
+ x: event.value as i32,
+ y: 0,
+ };
+ if let Err(err) = display.write_event(scroll_event.to_event()) {
+ log::warn!("failed to send hscroll: {}", err);
+ }
+ }
+ } else {
+ log::info!(
+ "unsupported generic desktop usage 0x{:X}:0x{:X} value {}",
+ event.usage_page,
+ event.usage,
+ event.value
+ );
+ }
+ } else if event.usage_page == UsagePage::KeyboardOrKeypad as u16 {
+ let pressed = event.value != 0;
+ if event.usage == 0xE1 {
+ left_shift = pressed;
+ } else if event.usage == 0xE5 {
+ right_shift = pressed;
+ }
+ send_key_event(&mut display, event.usage_page, event.usage, pressed);
+ } else if event.usage_page == UsagePage::Button as u16 {
+ let btn_idx = event.usage as usize;
+ if btn_idx > 0 && btn_idx <= MAX_MOUSE_BUTTONS {
+ buttons[btn_idx - 1] = event.value != 0;
+ } else if btn_idx > MAX_MOUSE_BUTTONS {
+ log::debug!(
+ "ignoring button {} (max {})",
+ btn_idx,
+ MAX_MOUSE_BUTTONS
+ );
+ }
+ } else if event.usage_page < 0xFF00 {
+ log::info!(
+ "unsupported usage 0x{:X}:0x{:X} value {}",
+ event.usage_page,
+ event.usage,
+ event.value
+ );
}
- } else {
- log::info!(
- "unsupported generic desktop usage 0x{:X}:0x{:X} value {}",
- event.usage_page,
- event.usage,
- event.value
- );
}
- } else if event.usage_page == UsagePage::KeyboardOrKeypad as u16 {
- let (pressed, shift_opt) = if event.value != 0 {
- (true, Some(left_shift | right_shift))
- } else {
- (false, None)
- };
- if event.usage == 0xE1 {
- left_shift = pressed;
- } else if event.usage == 0xE5 {
- right_shift = pressed;
- }
- send_key_event(&mut display, event.usage_page, event.usage, pressed);
- } else if event.usage_page == UsagePage::Button as u16 {
- if event.usage > 0 && event.usage as usize <= buttons.len() {
- buttons[event.usage as usize - 1] = event.value != 0;
- } else {
- log::info!(
- "unsupported buttons usage 0x{:X}:0x{:X} value {}",
- event.usage_page,
- event.usage,
- event.value
- );
- }
- } else if event.usage_page >= 0xFF00 {
- // Ignore vendor defined event
- } else {
- log::info!(
- "unsupported usage 0x{:X}:0x{:X} value {}",
- event.usage_page,
- event.usage,
- event.value
- );
+ }
+ Err(err) => {
+ log::warn!("failed to parse HID report: {}", err);
}
}
if mouse_pos != last_mouse_pos {
last_mouse_pos = mouse_pos;
-
- // TODO
// ps2d uses 0..=65535 as range, while usb uses 0..=32767. orbital
// expects the former range, so multiply by two here to temporarily
- // align with orbital expectation. This workaround will make cursor
- // looks out of sync in QEMU using virtio-vga with usb-tablet.
+ // align with orbital expectation.
let mouse_event = orbclient::event::MouseEvent {
x: mouse_pos.0 * 2,
y: mouse_pos.1 * 2,
};
-
- match display.write_event(mouse_event.to_event()) {
- Ok(_) => (),
- Err(err) => {
- log::warn!("failed to send mouse event to orbital: {}", err);
- }
+ if let Err(err) = display.write_event(mouse_event.to_event()) {
+ log::warn!("failed to send mouse event: {}", err);
}
}
if mouse_dx != 0 || mouse_dy != 0 {
- // TODO: This is a filter to prevent random mouse jumps
if mouse_dx > -127 && mouse_dx < 127 {
let mouse_event = orbclient::event::MouseRelativeEvent {
dx: mouse_dx,
dy: mouse_dy,
};
-
- match display.write_event(mouse_event.to_event()) {
- Ok(_) => (),
- Err(err) => {
- log::warn!("failed to send mouse event to orbital: {}", err);
- }
+ if let Err(err) = display.write_event(mouse_event.to_event()) {
+ log::warn!("failed to send relative mouse event: {}", err);
}
}
}
if scroll_y != 0 {
let scroll_event = orbclient::event::ScrollEvent { x: 0, y: scroll_y };
-
- match display.write_event(scroll_event.to_event()) {
- Ok(_) => (),
- Err(err) => {
- log::warn!("failed to send scroll event to orbital: {}", err);
- }
+ if let Err(err) = display.write_event(scroll_event.to_event()) {
+ log::warn!("failed to send scroll event: {}", err);
}
}
if buttons != last_buttons {
last_buttons = buttons;
-
let button_event = orbclient::event::ButtonEvent {
left: buttons[0],
right: buttons[1],
middle: buttons[2],
};
-
- match display.write_event(button_event.to_event()) {
- Ok(_) => (),
- Err(err) => {
- log::warn!("failed to send button event to orbital: {}", err);
- }
+ if let Err(err) = display.write_event(button_event.to_event()) {
+ log::warn!("failed to send button event: {}", err);
}
}
-
- // log::trace!("took {}ms", timer.elapsed().as_millis())
}
}
+28
View File
@@ -0,0 +1,28 @@
# Absorbed Patches
These patches have been **consolidated into `local/patches/base/redox.patch`** (the
mega-patch applied automatically to the base recipe source tree).
**Do not wire these patches into `recipes/core/base/recipe.toml`.** They are kept here
for reference and git history only. If a patch referenced by a recipe.toml symlink
breaks, that symlink should be updated to point to a current, active patch in
`local/patches/base/` (NOT one in this directory).
## Consolidation timeline
| Date | Action |
|------|--------|
| 2026-04-30 | P0 + P2 patches consolidated into redox.patch |
| 2026-04-30 | P1-P2 driver/ACPI patches consolidated |
| 2026-04-30 | P3 ACPI/PCI patches absorbed |
## Active patches (NOT in this directory)
The active patches applied on top of `redox.patch` live in `local/patches/base/`:
- `P3-ps2d-led-feedback.patch` — PS/2 LED state + InputProducer migration
- `P3-inputd-keymap-bridge.patch` — InputProducer enum + keymap bridge
- `P3-usbhidd-hardening.patch` — USB HID descriptor validation, retry, lookup table
- `P3-init-colored-output.patch` — ANSI-colored init daemon output
- `P9-fix-so-pecred.patch` — shared-object credential fix
- `redox.patch` — cumulative mega-patch (applied first, automatically)
@@ -0,0 +1,3 @@
pub mod keymap;
pub mod scheme;
pub mod xkb;
@@ -0,0 +1,59 @@
mod keymap;
mod scheme;
mod xkb;
use std::env;
use std::io::Write;
use std::process;
use scheme::KeymapScheme;
fn log_msg(level: &str, msg: &str) {
let _ = writeln!(std::io::stderr(), "[keymapd] {} {}", level, msg);
}
fn main() {
let mut scheme = KeymapScheme::new();
let builtins = keymap::BuiltinKeymaps::new();
scheme.load_builtin(&builtins);
let keymap_dir = match env::var("KEYMAP_DIR") {
Ok(dir) => dir,
Err(_) => "/etc/keymaps".to_string(),
};
if let Err(e) = scheme.load_from_dir(&keymap_dir) {
log_msg("ERROR", &format!("failed to load keymaps from {}: {}", keymap_dir, e));
}
log_msg("INFO", &format!("loaded {} keymap(s)", scheme.keymap_count()));
let socket = redox_scheme::Socket::nonblock("keymap")
.expect("keymapd: failed to register scheme:keymap");
log_msg("INFO", "registered scheme:keymap");
loop {
let request = match socket.next_request(redox_scheme::SignalBehavior::Restart) {
Ok(Some(r)) => r,
Ok(None) => {
log_msg("INFO", "scheme unmounted, exiting");
break;
}
Err(e) => {
log_msg("ERROR", &format!("failed to read request: {}", e));
process::exit(1);
}
};
match request.handle_scheme_block_mut(&mut scheme) {
Ok(response) => {
if let Err(e) = socket.write_response(response, redox_scheme::SignalBehavior::Restart) {
log_msg("ERROR", &format!("failed to write response: {}", e));
}
}
Err(_request) => {
log_msg("ERROR", "unhandled scheme request");
}
}
}
}
@@ -0,0 +1,248 @@
use std::collections::HashMap;
use std::io;
use syscall::data::Stat;
use syscall::error::{Error, Result, EBADF, EINVAL, ENOENT};
use syscall::flag::{MODE_DIR, MODE_FILE, SEEK_CUR, SEEK_END, SEEK_SET};
use crate::keymap::Keymap;
enum HandleKind {
Root,
Active,
List,
Keymap { name: String },
}
struct Handle {
kind: HandleKind,
offset: usize,
}
pub struct KeymapScheme {
next_id: usize,
handles: HashMap<usize, Handle>,
keymaps: HashMap<String, Keymap>,
active_keymap: String,
}
impl KeymapScheme {
pub fn new() -> Self {
KeymapScheme {
next_id: 0,
handles: HashMap::new(),
keymaps: HashMap::new(),
active_keymap: "us".to_string(),
}
}
pub fn load_builtin(&mut self, builtins: &crate::keymap::BuiltinKeymaps) {
for (name, km) in [
("us", &builtins.us),
("gb", &builtins.gb),
("dvorak", &builtins.dvorak),
("azerty", &builtins.azerty),
("bepo", &builtins.bepo),
("it", &builtins.it),
] {
self.keymaps.insert(name.to_string(), km.clone());
}
}
pub fn load_from_dir(&mut self, dir: &str) -> io::Result<()> {
let entries = std::fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().map_or(false, |e| e == "json") {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let json_str = std::fs::read_to_string(&path)?;
if let Ok(km) = Keymap::from_json(&name, &json_str) {
self.keymaps.insert(name, km);
}
}
}
Ok(())
}
pub fn load_xkb(&mut self, xkb_dir: &str, layout: &str, variant: Option<&str>) -> io::Result<()> {
let km = crate::xkb::load_xkb_keymap(xkb_dir, layout, variant)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let name = match variant {
Some(v) => format!("{}({})", layout, v),
None => layout.to_string(),
};
self.keymaps.insert(name, km);
Ok(())
}
pub fn keymap_count(&self) -> usize {
self.keymaps.len()
}
fn active_keymap(&self) -> &Keymap {
self.keymaps
.get(&self.active_keymap)
.or_else(|| self.keymaps.get("us"))
.expect("at least one keymap must be loaded")
}
pub fn translate(&self, scancode: u8, shift: bool, altgr: bool) -> char {
self.active_keymap().get_char(scancode, shift, altgr)
}
}
impl redox_scheme::SchemeBlockMut for KeymapScheme {
fn open(&mut self, path: &str, _flags: usize, _uid: u32, _gid: u32) -> Result<Option<usize>> {
let cleaned = path.trim_matches('/');
let kind = if cleaned.is_empty() {
HandleKind::Root
} else if cleaned == "active" {
HandleKind::Active
} else if cleaned == "list" {
HandleKind::List
} else if let Some(name) = cleaned.strip_prefix("keymap/") {
let name = name.trim_end_matches('/').to_string();
if !self.keymaps.contains_key(&name) {
return Err(Error::new(ENOENT));
}
HandleKind::Keymap { name }
} else if self.keymaps.contains_key(cleaned) {
let name = cleaned.to_string();
if let Some(km) = self.keymaps.get(&name) {
let _ = km;
}
HandleKind::Keymap { name }
} else if cleaned.starts_with("set/") {
let requested = &cleaned[4..];
if !self.keymaps.contains_key(requested) {
return Err(Error::new(ENOENT));
}
self.active_keymap = requested.to_string();
HandleKind::Active
} else {
return Err(Error::new(ENOENT));
};
let id = self.next_id;
self.next_id += 1;
self.handles.insert(id, Handle { kind, offset: 0 });
Ok(Some(id))
}
fn read(&mut self, id: usize, buf: &mut [u8]) -> Result<Option<usize>> {
let handle = self.handles.get_mut(&id).ok_or(Error::new(EBADF))?;
let content: Vec<u8> = match &handle.kind {
HandleKind::Root => {
let mut listing = String::new();
listing.push_str("active\nlist\n");
for name in self.keymaps.keys() {
listing.push_str(&format!("keymap/{}\n", name));
}
listing.into_bytes()
}
HandleKind::Active => self.active_keymap.clone().into_bytes(),
HandleKind::List => {
let mut listing = String::new();
for (i, name) in self.keymaps.keys().enumerate() {
if i > 0 {
listing.push('\n');
}
listing.push_str(name);
}
listing.push('\n');
listing.into_bytes()
}
HandleKind::Keymap { name } => {
let km = self.keymaps.get(name).ok_or(Error::new(ENOENT))?;
format!(
"name={}\nentries={}\ncompose={}\ndead_keys={}\n",
km.name,
km.entries.len(),
km.compose.len(),
km.dead_keys.len()
)
.into_bytes()
}
};
if handle.offset >= content.len() {
return Ok(Some(0));
}
let remaining = &content[handle.offset..];
let to_copy = remaining.len().min(buf.len());
buf[..to_copy].copy_from_slice(&remaining[..to_copy]);
handle.offset += to_copy;
Ok(Some(to_copy))
}
fn write(&mut self, id: usize, buf: &[u8]) -> Result<Option<usize>> {
let handle = self.handles.get(&id).ok_or(Error::new(EBADF))?;
match &handle.kind {
HandleKind::Active => {
let name = String::from_utf8_lossy(buf);
let name = name.trim();
if self.keymaps.contains_key(name) {
self.active_keymap = name.to_string();
Ok(Some(buf.len()))
} else {
Err(Error::new(ENOENT))
}
}
_ => Err(Error::new(EINVAL)),
}
}
fn seek(&mut self, id: usize, pos: isize, whence: usize) -> Result<Option<isize>> {
let handle = self.handles.get_mut(&id).ok_or(Error::new(EBADF))?;
let new_offset = match whence {
SEEK_SET => pos as isize,
SEEK_CUR => handle.offset as isize + pos,
SEEK_END => pos,
_ => return Err(Error::new(EINVAL)),
};
if new_offset < 0 {
return Err(Error::new(EINVAL));
}
handle.offset = new_offset as usize;
Ok(Some(new_offset))
}
fn fstat(&mut self, id: usize, stat: &mut Stat) -> Result<Option<usize>> {
let handle = self.handles.get(&id).ok_or(Error::new(EBADF))?;
match &handle.kind {
HandleKind::Root => {
stat.st_mode = MODE_DIR | 0o555;
}
_ => {
stat.st_mode = MODE_FILE | 0o644;
}
}
Ok(Some(0))
}
fn close(&mut self, id: usize) -> Result<Option<usize>> {
self.handles.remove(&id);
Ok(Some(0))
}
fn fpath(&mut self, id: usize, buf: &mut [u8]) -> Result<Option<usize>> {
let handle = self.handles.get(&id).ok_or(Error::new(EBADF))?;
let path = match &handle.kind {
HandleKind::Root => "keymap:".to_string(),
HandleKind::Active => "keymap:active".to_string(),
HandleKind::List => "keymap:list".to_string(),
HandleKind::Keymap { name } => format!("keymap:keymap/{}", name),
};
let bytes = path.as_bytes();
let to_copy = bytes.len().min(buf.len());
buf[..to_copy].copy_from_slice(&bytes[..to_copy]);
Ok(Some(to_copy))
}
}
@@ -0,0 +1,324 @@
use std::collections::HashMap;
use crate::keymap::{Keymap, KeymapEntry};
fn xkb_keycode_to_scancode(code: &str) -> Option<u8> {
match code {
"TLDE" => Some(0x29),
"AE01" => Some(0x02),
"AE02" => Some(0x03),
"AE03" => Some(0x04),
"AE04" => Some(0x05),
"AE05" => Some(0x06),
"AE06" => Some(0x07),
"AE07" => Some(0x08),
"AE08" => Some(0x09),
"AE09" => Some(0x0A),
"AE10" => Some(0x0B),
"AE11" => Some(0x0C),
"AE12" => Some(0x0D),
"AD01" => Some(0x10),
"AD02" => Some(0x11),
"AD03" => Some(0x12),
"AD04" => Some(0x13),
"AD05" => Some(0x14),
"AD06" => Some(0x15),
"AD07" => Some(0x16),
"AD08" => Some(0x17),
"AD09" => Some(0x18),
"AD10" => Some(0x19),
"AD11" => Some(0x1A),
"AD12" => Some(0x1B),
"AC01" => Some(0x1E),
"AC02" => Some(0x1F),
"AC03" => Some(0x20),
"AC04" => Some(0x21),
"AC05" => Some(0x22),
"AC06" => Some(0x23),
"AC07" => Some(0x24),
"AC08" => Some(0x25),
"AC09" => Some(0x26),
"AC10" => Some(0x27),
"AC11" => Some(0x28),
"BKSL" => Some(0x2B),
"AB01" => Some(0x2C),
"AB02" => Some(0x2D),
"AB03" => Some(0x2E),
"AB04" => Some(0x2F),
"AB05" => Some(0x30),
"AB06" => Some(0x31),
"AB07" => Some(0x32),
"AB08" => Some(0x33),
"AB09" => Some(0x34),
"AB10" => Some(0x35),
"SPCE" => Some(0x39),
"LSGT" => Some(0x56),
"BKSP" => Some(0x0E),
"TAB" => Some(0x0F),
"RTRN" => Some(0x1C),
_ => None,
}
}
fn keysym_to_char(sym: &str) -> char {
if sym.len() == 1 {
return sym.chars().next().unwrap_or('\0');
}
match sym {
"space" => ' ',
"exclam" => '!',
"quotedbl" => '"',
"numbersign" => '#',
"dollar" => '$',
"percent" => '%',
"ampersand" => '&',
"apostrophe" => '\'',
"quoteright" => '\'',
"parenleft" => '(',
"parenright" => ')',
"asterisk" => '*',
"plus" => '+',
"comma" => ',',
"minus" => '-',
"period" => '.',
"slash" => '/',
"colon" => ':',
"semicolon" => ';',
"less" => '<',
"equal" => '=',
"greater" => '>',
"question" => '?',
"at" => '@',
"bracketleft" => '[',
"backslash" => '\\',
"bracketright" => ']',
"asciicircum" => '^',
"underscore" => '_',
"grave" => '`',
"braceleft" => '{',
"bar" => '|',
"braceright" => '}',
"asciitilde" => '~',
"nobreakspace" => '\u{00A0}',
"exclamdown" => '¡',
"cent" => '¢',
"sterling" => '£',
"currency" => '¤',
"yen" => '¥',
"brokenbar" => '¦',
"section" => '§',
"diaeresis" => '¨',
"copyright" => '©',
"ordfeminine" => 'ª',
"guillemotleft" => '«',
"notsign" => '¬',
"hyphen" => '\u{00AD}',
"registered" => '®',
"macron" => '¯',
"degree" => '°',
"plusminus" => '±',
"twosuperior" => '²',
"threesuperior" => '³',
"acute" => '´',
"mu" => 'µ',
"paragraph" => '¶',
"periodcentered" => '·',
"cedilla" => '¸',
"onesuperior" => '¹',
"masculine" => 'º',
"guillemotright" => '»',
"onequarter" => '¼',
"onehalf" => '½',
"threequarters" => '¾',
"questiondown" => '¿',
"Agrave" => 'À',
"Aacute" => 'Á',
"Acircumflex" => 'Â',
"Atilde" => 'Ã',
"Adiaeresis" => 'Ä',
"Aring" => 'Å',
"AE" => 'Æ',
"Ccedilla" => 'Ç',
"Egrave" => 'È',
"Eacute" => 'É',
"Ecircumflex" => 'Ê',
"Ediaeresis" => 'Ë',
"Igrave" => 'Ì',
"Iacute" => 'Í',
"Icircumflex" => 'Î',
"Idiaeresis" => 'Ï',
"ETH" => 'Ð',
"Ntilde" => 'Ñ',
"Ograve" => 'Ò',
"Oacute" => 'Ó',
"Ocircumflex" => 'Ô',
"Otilde" => 'Õ',
"Odiaeresis" => 'Ö',
"multiply" => '×',
"Ooblique" => 'Ø',
"Ugrave" => 'Ù',
"Uacute" => 'Ú',
"Ucircumflex" => 'Û',
"Udiaeresis" => 'Ü',
"Yacute" => 'Ý',
"THORN" => 'Þ',
"ssharp" => 'ß',
"agrave" => 'à',
"aacute" => 'á',
"acircumflex" => 'â',
"atilde" => 'ã',
"adiaeresis" => 'ä',
"aring" => 'å',
"ae" => 'æ',
"ccedilla" => 'ç',
"egrave" => 'è',
"eacute" => 'é',
"ecircumflex" => 'ê',
"ediaeresis" => 'ë',
"igrave" => 'ì',
"iacute" => 'í',
"icircumflex" => 'î',
"idiaeresis" => 'ï',
"eth" => 'ð',
"ntilde" => 'ñ',
"ograve" => 'ò',
"oacute" => 'ó',
"ocircumflex" => 'ô',
"otilde" => 'õ',
"odiaeresis" => 'ö',
"division" => '÷',
"oslash" => 'ø',
"ugrave" => 'ù',
"uacute" => 'ú',
"ucircumflex" => 'û',
"udiaeresis" => 'ü',
"yacute" => 'ý',
"thorn" => 'þ',
"ydiaeresis" => 'ÿ',
"EuroSign" => '€',
"NoSymbol" => '\0',
_ => '\0',
}
}
struct XkbKeyEntry {
scancode: u8,
normal: char,
shifted: char,
altgr: char,
altgr_shifted: char,
}
fn parse_keysyms(syms: &[&str]) -> (char, char, char, char) {
let normal = syms.get(0).map(|s| keysym_to_char(s.trim())).unwrap_or('\0');
let shifted = syms.get(1).map(|s| keysym_to_char(s.trim())).unwrap_or('\0');
let altgr = syms.get(2).map(|s| keysym_to_char(s.trim())).unwrap_or('\0');
let altgr_shifted = syms.get(3).map(|s| keysym_to_char(s.trim())).unwrap_or('\0');
(normal, shifted, altgr, altgr_shifted)
}
pub fn parse_xkb_symbols(content: &str, variant: Option<&str>) -> Result<Keymap, String> {
let target_variant = variant.unwrap_or("basic");
let mut entries: HashMap<u8, KeymapEntry> = HashMap::new();
let mut found_variant = false;
let mut i = 0;
let lines: Vec<&str> = content.lines().collect();
while i < lines.len() {
let line = lines[i].trim();
if line.starts_with("xkb_symbols") {
let name = extract_variant_name(line).unwrap_or("basic");
if name != target_variant {
i = skip_brace_block(&lines, i + 1);
continue;
}
found_variant = true;
i += 1;
while i < lines.len() {
let inner = lines[i].trim();
if inner.starts_with('}') {
break;
}
if inner.starts_with("key <") {
if let Some(entry) = parse_key_line(inner) {
entries.entry(entry.scancode).or_insert_with(|| {
KeymapEntry {
scancode: entry.scancode,
normal: entry.normal,
shifted: entry.shifted,
altgr: entry.altgr,
altgr_shifted: entry.altgr_shifted,
}
});
}
}
i += 1;
}
}
i += 1;
}
if !found_variant {
return Err(format!("variant '{}' not found in XKB symbols file", target_variant));
}
Ok(Keymap {
name: variant.unwrap_or("basic").to_string(),
entries,
compose: Vec::new(),
dead_keys: Vec::new(),
})
}
fn extract_variant_name(line: &str) -> Option<&str> {
let start = line.find('"')?;
let rest = &line[start + 1..];
let end = rest.find('"')?;
Some(&rest[..end])
}
fn skip_brace_block(lines: &[&str], start: usize) -> usize {
let mut depth = 1;
let mut i = start;
while i < lines.len() && depth > 0 {
for ch in lines[i].chars() {
match ch {
'{' => depth += 1,
'}' => depth -= 1,
_ => {}
}
}
i += 1;
}
i
}
fn parse_key_line(line: &str) -> Option<XkbKeyEntry> {
let key_start = line.find('<')?;
let key_end = line[key_start + 1..].find('>')?;
let keycode = &line[key_start + 1..key_start + 1 + key_end];
let scancode = xkb_keycode_to_scancode(keycode)?;
let syms_start = line.find('[')?;
let syms_end = line.rfind(']')?;
let syms_content = &line[syms_start + 1..syms_end];
let syms: Vec<&str> = syms_content.split(',').collect();
let (normal, shifted, altgr, altgr_shifted) = parse_keysyms(&syms);
Some(XkbKeyEntry {
scancode,
normal,
shifted,
altgr,
altgr_shifted,
})
}
pub fn load_xkb_keymap(xkb_dir: &str, layout: &str, variant: Option<&str>) -> Result<Keymap, String> {
let file_path = format!("{}/symbols/{}", xkb_dir, layout);
let content = std::fs::read_to_string(&file_path)
.map_err(|e| format!("failed to read XKB symbols file '{}': {}", file_path, e))?;
parse_xkb_symbols(&content, variant)
}
+6 -10
View File
@@ -4,17 +4,13 @@ rev = "463f76b9608a896e6f6c9f63457f57f6409873c7"
patches = [
"P0-daemon-fix-init-notify-unwrap.patch",
"P0-workspace-add-bootstrap.patch",
"P0-bootstrap-workspace-fix.patch",
"P2-daemon-ready-graceful.patch",
"P0-init-continuous-scheduling.patch",
"P2-i2c-gpio-ucsi-drivers.patch",
"P3-pcid-bind-scheme.patch",
"P3-pcid-aer-scheme.patch",
"P3-pcid-uevent-format-fix.patch",
"P3-acpi-wave12-hardening.patch",
"P3-acpi-power-dmi.patch",
"P3-xhci-device-hardening.patch",
"P5-init-supervisor-restart.patch",
"P5-init-daemon-panic-hardening.patch",
"P9-fix-so-pecred.patch",
"P3-inputd-keymap-bridge.patch",
"P3-ps2d-led-feedback.patch",
"P3-usbhidd-hardening.patch",
"P3-init-colored-output.patch",
]
[build]
+6
View File
@@ -80,6 +80,8 @@ const REPO_HELP_STR: &str = r#"
--category=<category> apply to all recipes in <cookbook_dir>/<category>
--filesystem=<filesystem> override recipes config using installer file
--repo-binary override recipes config to use repo_binary
--allow-protected allow re-fetching of protected recipes
(equivalent to REDBEAR_ALLOW_PROTECTED_FETCH=1)
cook env and their defaults:
CI= set to any value to disable TUI
@@ -465,6 +467,10 @@ fn parse_args(args: Vec<String>) -> anyhow::Result<(CliConfig, CliCommand, Vec<C
"--repo-binary" => override_filesystem_repo_binary = true,
"--with-package-deps" => config.with_package_deps = true,
"--all" => config.all = true,
"--allow-protected" => {
// SAFETY: set once at startup, before any threading
unsafe { env::set_var("REDBEAR_ALLOW_PROTECTED_FETCH", "1"); }
}
_ => {
eprintln!("Error: Unknown flag: {}", arg);
process::exit(1);
+3
View File
@@ -200,6 +200,9 @@ pub fn build(
) -> Result<BuildResult, String> {
let recipe = &cook_recipe.recipe;
let name = &cook_recipe.name;
crate::cook::fetch::cleanup_workspace_pollution(recipe_dir, logger);
let check_source = !cook_recipe.is_deps;
let sysroot_dir = get_sub_target_dir(target_dir, "sysroot");
let toolchain_dir = get_sub_target_dir(target_dir, "toolchain");
+458 -30
View File
@@ -35,6 +35,20 @@ pub struct FetchResult {
pub cached: bool,
}
pub(crate) fn cleanup_workspace_pollution(recipe_dir: &Path, logger: &PtyOut) {
let recipes_root = recipe_dir.join("../..");
for file in &["Cargo.toml", "Cargo.lock"] {
let path = recipes_root.join(file);
if path.is_file() && !path.is_symlink() {
if let Err(e) = fs::remove_file(&path) {
log_to_pty!(logger, "[WARN] failed to remove workspace pollution {}: {e}", path.display());
} else {
log_to_pty!(logger, "[CLEAN] removed workspace pollution {}", path.display());
}
}
}
}
fn redbear_protected_recipe(name: &str) -> bool {
matches!(
name,
@@ -158,6 +172,100 @@ fn redbear_allow_protected_fetch() -> bool {
)
}
fn redbear_release() -> Option<String> {
env::var("REDBEAR_RELEASE")
.ok()
.map(|value| value.trim().trim_start_matches('=').to_string())
.filter(|value| !value.is_empty())
}
fn redbear_project_root(recipe_dir: &Path) -> Option<PathBuf> {
let absolute_recipe_dir = if recipe_dir.is_absolute() {
recipe_dir.to_path_buf()
} else {
env::current_dir().ok()?.join(recipe_dir)
};
for ancestor in absolute_recipe_dir.ancestors() {
if ancestor.file_name().is_some_and(|name| name == "recipes") {
return ancestor.parent().map(Path::to_path_buf);
}
}
None
}
fn redbear_recipe_restore_path(recipe_dir: &Path) -> Option<String> {
let mut saw_recipes = false;
let mut parts = Vec::new();
for component in recipe_dir.components() {
let value = component.as_os_str().to_string_lossy();
if saw_recipes {
parts.push(value.to_string());
} else if value == "recipes" {
saw_recipes = true;
}
}
if saw_recipes && !parts.is_empty() {
Some(parts.join("/"))
} else {
None
}
}
fn redbear_try_restore_source(recipe_dir: &Path, logger: &PtyOut, force: bool) -> Result<()> {
let Some(release) = redbear_release() else {
return Ok(());
};
let Some(project_root) = redbear_project_root(recipe_dir) else {
return Ok(());
};
let Some(recipe_path) = redbear_recipe_restore_path(recipe_dir) else {
return Ok(());
};
let restore_script = project_root.join("local/scripts/restore-sources.sh");
if !restore_script.is_file() {
return Ok(());
}
let mut command = Command::new("python3");
command.current_dir(&project_root);
command.arg(&restore_script);
command.arg(format!("--release={release}"));
if force {
command.arg("--force");
}
command.arg(recipe_path);
run_command(command, logger)
}
fn redbear_source_dir_is_effectively_empty(source_dir: &Path) -> bool {
let Ok(entries) = fs::read_dir(source_dir) else {
return true;
};
let visible_entries = entries
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_name() != ".gitkeep")
.count();
visible_entries == 0
}
fn redbear_ensure_offline_source(recipe_dir: &Path, source_dir: &PathBuf, logger: &PtyOut) -> Result<()> {
if !source_dir.exists() || redbear_source_dir_is_effectively_empty(source_dir) {
redbear_try_restore_source(recipe_dir, logger, true)?;
}
offline_check_exists(source_dir)
}
fn redbear_ensure_offline_git_source(
recipe_dir: &Path,
source_dir: &PathBuf,
logger: &PtyOut,
) -> Result<()> {
let git_head = source_dir.join(".git/HEAD");
if !source_dir.exists() || !git_head.is_file() {
redbear_try_restore_source(recipe_dir, logger, true)?;
}
offline_check_exists(source_dir)
}
/// Check if a recipe directory is a local Red Bear overlay (symlink into local/).
fn is_local_overlay(recipe_dir: &Path) -> bool {
if let Ok(resolved) = recipe_dir.canonicalize() {
@@ -197,6 +305,11 @@ pub(crate) fn get_blake3(path: &PathBuf) -> Result<String> {
pub fn fetch_offline(recipe: &CookRecipe, logger: &PtyOut) -> Result<FetchResult> {
let recipe_dir = &recipe.dir;
let source_dir = recipe_dir.join("source");
// Clean up workspace pollution that may have been left by previous
// builds (e.g. Cargo workspace files leaked into the recipes/ root).
cleanup_workspace_pollution(recipe_dir, logger);
match recipe.recipe.build.kind {
BuildKind::None => {
// the build function doesn't need source dir exists
@@ -210,8 +323,12 @@ pub fn fetch_offline(recipe: &CookRecipe, logger: &PtyOut) -> Result<FetchResult
}
let result = match &recipe.recipe.source {
Some(SourceRecipe::Path { path: _ }) | None => {
offline_check_exists(&source_dir)?;
Some(SourceRecipe::Path { path: _ }) => {
redbear_ensure_offline_source(recipe_dir, &source_dir, logger)?;
let ident = fetch_apply_source_info(recipe, "".to_string())?;
FetchResult::cached(source_dir, ident)
}
None => {
let ident = fetch_apply_source_info(recipe, "".to_string())?;
FetchResult::cached(source_dir, ident)
}
@@ -227,22 +344,61 @@ pub fn fetch_offline(recipe: &CookRecipe, logger: &PtyOut) -> Result<FetchResult
upstream: _,
branch: _,
rev,
patches: _,
script: _,
patches,
script,
shallow_clone: _,
}) => {
offline_check_exists(&source_dir)?;
redbear_ensure_offline_git_source(recipe_dir, &source_dir, logger)?;
let git_head = source_dir.join(".git/HEAD");
if !git_head.is_file() {
let source_ident = rev.clone().unwrap_or_else(|| {
format!("release-archive:{}", recipe.name.name())
});
FetchResult::cached(source_dir, source_ident)
} else {
let (head_rev, _) = get_git_head_rev(&source_dir)?;
if let Some(expected_rev) = rev {
if head_rev != *expected_rev {
let head_short = &head_rev[..head_rev.len().min(7)];
let expected_short = &expected_rev[..expected_rev.len().min(7)];
if !head_rev.starts_with(expected_rev.as_str())
&& head_short != expected_short
{
bail_other_err!(
"source at {} has revision {} but recipe expects {}. \
Source archives may be corrupted. Restore from release archives.",
source_dir.display(), head_rev, expected_rev
source_dir.display(), head_short, expected_rev
);
}
}
// Validate all patch symlinks resolve before touching source.
fetch_validate_patch_symlinks(recipe_dir, patches)?;
if (!patches.is_empty() || script.is_some())
&& fetch_patches_state_stale(recipe_dir, patches, script, &source_dir)
{
log_to_pty!(logger, "[INFO] patches state stale or missing — re-applying");
// Reset source to clean state, including submodules.
let mut clean_cmd = Command::new("git");
clean_cmd.arg("-C").arg(&source_dir);
clean_cmd.arg("clean").arg("-ffdx");
let _ = run_command(clean_cmd, logger);
let mut reset_cmd = Command::new("git");
reset_cmd.arg("-C").arg(&source_dir);
reset_cmd.arg("reset").arg("--hard");
run_command(reset_cmd, logger)?;
// Recursively reset submodules if any exist.
if source_dir.join(".gitmodules").exists() {
let mut sub_cmd = Command::new("git");
sub_cmd.arg("-C").arg(&source_dir);
sub_cmd.arg("submodule").arg("foreach");
sub_cmd.arg("--recursive");
sub_cmd.arg("git reset --hard && git clean -ffdx");
run_command(sub_cmd, logger)?;
}
fetch_apply_patches(recipe_dir, patches, script, &source_dir, logger)?;
}
FetchResult::cached(source_dir, head_rev)
}
}
Some(SourceRecipe::Tar {
tar: _,
@@ -274,7 +430,7 @@ pub fn fetch_offline(recipe: &CookRecipe, logger: &PtyOut) -> Result<FetchResult
}
}
}
offline_check_exists(&source_dir)?;
redbear_ensure_offline_source(recipe_dir, &source_dir, logger)?;
FetchResult::new(source_dir, ident, cached)
}
};
@@ -288,7 +444,7 @@ pub fn fetch(recipe: &CookRecipe, check_source: bool, logger: &PtyOut) -> Result
if redbear_protected_recipe(recipe.name.name()) && !redbear_allow_protected_fetch() {
log_to_pty!(
logger,
"[INFO]: protected recipe {} uses local source (fetch disabled; set REDBEAR_ALLOW_PROTECTED_FETCH=1 to override)",
"[INFO]: protected recipe {} uses local source (fetch disabled; use --allow-protected flag or set REDBEAR_ALLOW_PROTECTED_FETCH=1 to override)",
recipe.name.name()
);
return fetch_offline(recipe, logger);
@@ -528,6 +684,7 @@ pub fn fetch(recipe: &CookRecipe, check_source: bool, logger: &PtyOut) -> Result
manual_git_recursive_submodule(logger, &source_dir, cmds)?;
}
fetch_validate_patch_symlinks(recipe_dir, patches)?;
fetch_apply_patches(recipe_dir, patches, script, &source_dir, logger)?;
}
@@ -967,36 +1124,307 @@ pub(crate) fn fetch_apply_patches(
source_dir_tmp: &PathBuf,
logger: &PtyOut,
) -> Result<()> {
if patches.is_empty() && script.is_none() {
return Ok(());
}
// Read and normalize all patch files.
let mut patch_contents: Vec<(String, Vec<u8>)> = Vec::new();
for patch_name in patches {
let patch_file = recipe_dir.join(patch_name);
if !patch_file.is_file() {
bail_other_err!("Failed to find patch file {:?}", patch_file.display());
}
let patch = fs::read_to_string(&patch_file).map_err(|err| {
let raw = fs::read(&patch_file).map_err(|err| {
format!(
"failed to read patch file '{}': {}\n{:#?}",
patch_file.display(),
err,
err
"failed to read patch file '{}': {err}",
patch_file.display()
)
})?;
let mut command = Command::new("patch");
command.arg("--directory").arg(source_dir_tmp);
command.arg("--strip=1");
run_command_stdin(command, patch.as_bytes(), logger)?;
let normalized = normalize_patch(&raw);
patch_contents.push((patch_name.clone(), normalized));
}
Ok(if let Some(script) = script {
let mut command = Command::new("bash");
command.arg("-ex");
command.current_dir(source_dir_tmp);
run_command_stdin(
command,
format!("{SHARED_PRESCRIPT}\n{script}").as_bytes(),
logger,
)?;
})
// Apply all patches atomically to a staging directory.
// If any patch fails, the staging directory is discarded and the
// original source tree is left untouched.
// Uses cp -al (hard links) for zero-copy staging.
let staging_dir = source_dir_tmp.with_extension("staging");
let _ = fs::remove_dir_all(&staging_dir);
Command::new("cp")
.arg("-al")
.arg(source_dir_tmp)
.arg(&staging_dir)
.status()
.map_err(|e| format!("failed to create staging copy via cp -al: {e}"))?;
let result = (|| -> Result<Vec<String>> {
let mut applied = Vec::new();
for (patch_name, patch_data) in &patch_contents {
let mut command = Command::new("patch");
command.arg("--directory").arg(&staging_dir);
command.arg("--strip=1");
command.arg("--batch");
command.arg("--fuzz=0");
run_command_stdin(command, patch_data.as_slice(), logger)
.map_err(|e| format!("patch {patch_name} FAILED: {e}"))?;
for ext in &["rej", "orig"] {
let rej_check = Command::new("find")
.arg(&staging_dir)
.arg("-name")
.arg(format!("*.{ext}"))
.arg("-print")
.arg("-quit")
.output();
if let Ok(out) = rej_check {
if !out.stdout.is_empty() {
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
bail_other_err!(
"patch {patch_name} left .{ext} file (hunks failed to apply): {path}"
);
}
}
}
applied.push(patch_name.clone());
}
Ok(applied)
})();
match result {
Ok(applied) => {
let backup_dir = source_dir_tmp.with_extension("backup");
let _ = fs::remove_dir_all(&backup_dir);
fs::rename(source_dir_tmp, &backup_dir)
.map_err(|e| format!("failed to rename source to backup: {e}"))?;
fs::rename(&staging_dir, source_dir_tmp)
.map_err(|e| format!("failed to promote staging to source: {e}"))?;
let _ = fs::remove_dir_all(&backup_dir);
fetch_write_patches_state(recipe_dir, &applied, source_dir_tmp, script, logger)?;
if let Some(script) = script {
let mut command = Command::new("bash");
command.arg("-ex");
command.current_dir(source_dir_tmp);
run_command_stdin(
command,
format!("{SHARED_PRESCRIPT}\n{script}").as_bytes(),
logger,
)?;
}
log_to_pty!(logger, "[ATOMIC] {n}/{n} patches applied", n = applied.len());
Ok(())
}
Err(e) => {
let _ = fs::remove_dir_all(&staging_dir);
log_to_pty!(logger, "[ATOMIC] patch application rolled back — source tree unchanged");
Err(e)
}
}
}
/// Normalizes a patch for compatibility with the `patch` command by stripping
/// git-specific headers (`diff --git`, `index`, `new file mode`, etc.) that
/// `patch` does not recognize.
fn normalize_patch(raw: &[u8]) -> Vec<u8> {
let text = String::from_utf8_lossy(raw);
let mut out = String::with_capacity(text.len());
let mut prev_empty = true;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("diff --git ")
|| trimmed.starts_with("index ")
|| trimmed.starts_with("new file mode ")
|| trimmed.starts_with("deleted file mode ")
|| trimmed.starts_with("rename from ")
|| trimmed.starts_with("rename to ")
|| trimmed.starts_with("similarity index ")
|| trimmed.starts_with("dissimilarity index ")
{
continue;
}
if !prev_empty || !line.is_empty() {
out.push_str(line);
out.push('\n');
prev_empty = line.is_empty();
}
}
if !out.ends_with('\n') {
out.push('\n');
}
out.into_bytes()
}
/// Computes a BLAKE3 hash over all patch file contents (in order).
fn fetch_compute_patches_hash(
recipe_dir: &Path,
patches: &[String],
) -> Result<String> {
// BLAKE3 is already a project dependency (used for source verification).
let mut hasher = blake3::Hasher::new();
for patch_name in patches {
let patch_file = recipe_dir.join(patch_name);
let content = fs::read(&patch_file).map_err(|err| {
format!("failed to read patch for hashing '{}': {err}", patch_file.display())
})?;
hasher.update(&content);
}
Ok(hasher.finalize().to_hex().to_string())
}
/// Writes a .patches-state file into the recipe's *target* directory
/// (NOT the source checkout — git clean would delete it otherwise).
/// Contains: upstream commit, ordered patch list, composite hash, script hash,
/// and state schema version for forward-compatibility.
/// Computes a BLAKE3 hash over all tracked files in the source directory,
/// so that manual source edits (outside the patch system) are detected
/// and trigger re-patching on the next build.
fn fetch_compute_source_hash(source_dir: &Path) -> String {
let output = Command::new("git")
.arg("-C").arg(source_dir)
.args(["ls-files", "-z"])
.output();
match output {
Ok(out) if !out.stdout.is_empty() => {
let mut hasher = blake3::Hasher::new();
// Hash file paths in sorted order for stability.
let mut files: Vec<&str> = out.stdout
.split(|&b| b == 0)
.filter_map(|s| std::str::from_utf8(s).ok())
.collect();
files.sort();
for path in &files {
hasher.update(path.as_bytes());
hasher.update(b"\0");
// Hash file contents for integrity.
if let Ok(content) = fs::read(source_dir.join(path)) {
hasher.update(&content);
}
hasher.update(b"\0");
}
hasher.finalize().to_hex().to_string()
}
_ => "no-git".to_string(),
}
}
fn fetch_write_patches_state(
recipe_dir: &Path,
applied: &[String],
source_dir: &Path,
script: &Option<String>,
logger: &PtyOut,
) -> Result<()> {
let head_rev = get_git_head_rev(&source_dir.to_path_buf())
.map(|(r, _)| r)
.unwrap_or_else(|_| "unknown".to_string());
let hash = fetch_compute_patches_hash(recipe_dir, applied)
.unwrap_or_else(|_| "hash-error".to_string());
let script_hash = script.as_ref().map(|s| {
blake3::hash(s.as_bytes()).to_hex().to_string()
}).unwrap_or_else(|| "none".to_string());
// State goes in target/ so git clean/reset won't delete it.
let state_dir = recipe_dir.join("target");
let _ = fs::create_dir_all(&state_dir);
let state_file = state_dir.join(".patches-state");
let source_hash = fetch_compute_source_hash(source_dir);
let mut content = String::new();
content.push_str("schema: 1\n");
content.push_str(&format!("upstream-rev: {head_rev}\n"));
content.push_str(&format!("patches-hash: {hash}\n"));
content.push_str(&format!("script-hash: {script_hash}\n"));
content.push_str(&format!("source-hash: {source_hash}\n"));
for (i, name) in applied.iter().enumerate() {
content.push_str(&format!("patch[{}]: {name}\n", i + 1));
}
fs::write(&state_file, &content).map_err(|err| {
format!("failed to write .patches-state: {err}")
})?;
log_to_pty!(logger, "[OK] wrote .patches-state ({}/{} patches)", applied.len(), applied.len());
Ok(())
}
/// Validates that every patch file path resolves to a real file before we
/// touch the source tree. Fails early with a clear message if any symlink
/// is broken or file is missing.
fn fetch_validate_patch_symlinks(
recipe_dir: &Path,
patches: &[String],
) -> Result<()> {
let mut seen = std::collections::HashSet::new();
for patch_name in patches {
let patch_file = recipe_dir.join(patch_name);
if !patch_file.is_file() {
bail_other_err!(
"patch file not found: {:?} (broken symlink or missing file in {})",
patch_file.display(),
recipe_dir.display()
);
}
// Canonicalize to catch symlink chains
let canonical = patch_file.canonicalize().map_err(|e| {
format!(
"cannot resolve patch path {:?}: {e} (broken symlink?)",
patch_file.display()
)
})?;
if !seen.insert(canonical) {
bail_other_err!(
"duplicate patch after canonicalization: {:?} (listed twice in recipe?)",
patch_name
);
}
}
Ok(())
}
/// Checks whether the source directory's .patches-state matches the
/// recipe's current patch list. Returns true if patches should be
/// (re-)applied.
fn fetch_patches_state_stale(
recipe_dir: &Path,
patches: &[String],
script: &Option<String>,
source_dir: &Path,
) -> bool {
let state_file = recipe_dir.join("target/.patches-state");
let state_content = match fs::read_to_string(&state_file) {
Ok(c) => c,
Err(_) => return true,
};
let expected_hash = match fetch_compute_patches_hash(recipe_dir, patches) {
Ok(h) => h,
Err(_) => return true,
};
let expected_script_hash = script.as_ref().map(|s| {
blake3::hash(s.as_bytes()).to_hex().to_string()
}).unwrap_or_else(|| "none".to_string());
let current_source_hash = fetch_compute_source_hash(source_dir);
let mut found_hash = false;
let mut found_script = false;
let mut found_source = false;
for line in state_content.lines() {
if let Some(stored) = line.strip_prefix("patches-hash: ") {
if stored.trim() != expected_hash { return true; }
found_hash = true;
}
if let Some(stored) = line.strip_prefix("script-hash: ") {
if stored.trim() != expected_script_hash { return true; }
found_script = true;
}
if let Some(stored) = line.strip_prefix("source-hash: ") {
if stored.trim() != current_source_hash { return true; }
found_source = true;
}
}
!found_hash || !found_script || !found_source
}
pub(crate) fn fetch_apply_source_info(