diff --git a/local/patches/base/P3-init-colored-output.patch b/local/patches/base/P3-init-colored-output.patch new file mode 100644 index 00000000..e27d6361 --- /dev/null +++ b/local/patches/base/P3-init-colored-output.patch @@ -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::>() +- .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::>() +- .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)) + } + } + } diff --git a/local/patches/base/P3-inputd-keymap-bridge.patch b/local/patches/base/P3-inputd-keymap-bridge.patch new file mode 100644 index 00000000..2a94764a --- /dev/null +++ b/local/patches/base/P3-inputd-keymap-bridge.patch @@ -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(p: &mut T) -> &mut [u8] { + slice::from_raw_parts_mut((p as *mut T) as *mut u8, size_of::()) + } + ++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::() { ++ Err(io::Error::new( ++ io::ErrorKind::InvalidData, ++ format!("inputd: partial vt event read: got {nread}, expected {}", size_of::()), ++ )) + } else { +- assert_eq!(nread, size_of::()); + 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 { ++ 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 { ++ 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> { ++ let mut raw = [0_u8; size_of::()]; ++ ++ 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::()) ++ })) ++ } ++ Err(err) if err.kind() == ErrorKind::WouldBlock => Ok(None), ++ Err(err) => Err(err), ++ } ++ } ++} ++ ++pub struct HotplugHandle { ++ fd: File, ++ partial: Vec, ++} ++ ++impl HotplugHandle { ++ pub fn new() -> io::Result { ++ 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> { ++ 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) -> io::Result> { ++ if partial.len() < size_of::() { ++ return Ok(None); ++ } ++ ++ let header = ++ unsafe { core::ptr::read_unaligned(partial.as_ptr().cast::()) }; ++ 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::() ++ .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::()..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 { ++ 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 { ++ 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 { ++ 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), ++ } ++ } + } diff --git a/local/patches/base/P3-ps2d-led-feedback.patch b/local/patches/base/P3-ps2d-led-feedback.patch new file mode 100644 index 00000000..b83be9e8 --- /dev/null +++ b/local/patches/base/P3-ps2d-led-feedback.patch @@ -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, + 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) { diff --git a/local/patches/base/P3-usbhidd-hardening.patch b/local/patches/base/P3-usbhidd-hardening.patch new file mode 100644 index 00000000..ab8e88eb --- /dev/null +++ b/local/patches/base/P3-usbhidd-hardening.patch @@ -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 0x04–0xE7 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 { ++ 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 "; ++ const USAGE: &str = "usbhidd "; + +- let scheme = args.next().expect(USAGE); +- let port = args ++ let scheme = args.next().context(USAGE)?; ++ let port: PortId = args + .next() +- .expect(USAGE) +- .parse::() +- .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::() +- .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()) + } + } diff --git a/local/patches/base/absorbed/README.md b/local/patches/base/absorbed/README.md new file mode 100644 index 00000000..2edf929b --- /dev/null +++ b/local/patches/base/absorbed/README.md @@ -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) diff --git a/local/recipes/system/redbear-keymapd/source/src/lib.rs b/local/recipes/system/redbear-keymapd/source/src/lib.rs new file mode 100644 index 00000000..8aef3e5f --- /dev/null +++ b/local/recipes/system/redbear-keymapd/source/src/lib.rs @@ -0,0 +1,3 @@ +pub mod keymap; +pub mod scheme; +pub mod xkb; diff --git a/local/recipes/system/redbear-keymapd/source/src/main.rs b/local/recipes/system/redbear-keymapd/source/src/main.rs new file mode 100644 index 00000000..952b3758 --- /dev/null +++ b/local/recipes/system/redbear-keymapd/source/src/main.rs @@ -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"); + } + } + } +} diff --git a/local/recipes/system/redbear-keymapd/source/src/scheme.rs b/local/recipes/system/redbear-keymapd/source/src/scheme.rs new file mode 100644 index 00000000..fa4b50d5 --- /dev/null +++ b/local/recipes/system/redbear-keymapd/source/src/scheme.rs @@ -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, + keymaps: HashMap, + 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> { + 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> { + let handle = self.handles.get_mut(&id).ok_or(Error::new(EBADF))?; + + let content: Vec = 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> { + 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> { + 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> { + 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> { + self.handles.remove(&id); + Ok(Some(0)) + } + + fn fpath(&mut self, id: usize, buf: &mut [u8]) -> Result> { + 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)) + } +} diff --git a/local/recipes/system/redbear-keymapd/source/src/xkb.rs b/local/recipes/system/redbear-keymapd/source/src/xkb.rs new file mode 100644 index 00000000..576f817e --- /dev/null +++ b/local/recipes/system/redbear-keymapd/source/src/xkb.rs @@ -0,0 +1,324 @@ +use std::collections::HashMap; + +use crate::keymap::{Keymap, KeymapEntry}; + +fn xkb_keycode_to_scancode(code: &str) -> Option { + 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 { + let target_variant = variant.unwrap_or("basic"); + let mut entries: HashMap = 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 { + 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 { + 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) +} diff --git a/recipes/core/base/recipe.toml b/recipes/core/base/recipe.toml index 45a07d2d..5a5db015 100644 --- a/recipes/core/base/recipe.toml +++ b/recipes/core/base/recipe.toml @@ -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] diff --git a/src/bin/repo.rs b/src/bin/repo.rs index a5a19036..cec63d07 100644 --- a/src/bin/repo.rs +++ b/src/bin/repo.rs @@ -80,6 +80,8 @@ const REPO_HELP_STR: &str = r#" --category= apply to all recipes in / --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) -> anyhow::Result<(CliConfig, CliCommand, Vec 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); diff --git a/src/cook/cook_build.rs b/src/cook/cook_build.rs index a6380a38..7760cd21 100644 --- a/src/cook/cook_build.rs +++ b/src/cook/cook_build.rs @@ -200,6 +200,9 @@ pub fn build( ) -> Result { 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"); diff --git a/src/cook/fetch.rs b/src/cook/fetch.rs index 47ae22d9..3b337405 100644 --- a/src/cook/fetch.rs +++ b/src/cook/fetch.rs @@ -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 { + 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 { + 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 { + 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 { pub fn fetch_offline(recipe: &CookRecipe, logger: &PtyOut) -> Result { 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 { - 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 { - 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 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)> = 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> { + 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 { + 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 { + // 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, + 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, + 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(