From 7e81dec637f2788ec23acad3c5f733cc5747bc8e Mon Sep 17 00:00:00 2001 From: Votre Nom Date: Wed, 13 May 2026 11:57:21 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20Phase=207.5=20=E2=80=94=20robust?= =?UTF-8?q?esse=20paquet=20A=20valid=C3=A9e=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compositor anti-panic face à 4 cas malformés exercés en succession : brutal exit sans destroy, ack_configure avec mauvais serial, create_buffer avec dimensions nulles, create_buffer avec stride incohérent. Aucun crash, aucun blocage, ticks compositor continus 30s+ après la fin du fuzz. Frontend hardening : - BufferData.valid: bool, mis à false dans wl_shm_pool.create_buffer si dimensions/stride/offset incohérents avec la taille du pool. Le wl_buffer est quand même créé (contrat wayland-server) mais ignoré au commit. - ShmPool::read_argb signature passée de Vec à Option>. Refuse de lire si w/h/stride invalides ou si l'accès final dépasse self.size. Calculs en checked_add/checked_mul pour éviter tout overflow sur des params adversariaux. Évite tout accès UB. - xdg_surface.ack_configure refuse les serials > last_sent (log + ignore, pas de post_error pour 7.5 — tolérance volontaire). - wl_surface.commit court-circuite la lecture pour les buffers invalides ou si read_argb retourne None (log warning, surface garde son ancien contenu). Nouveau crate : redox-wl-test-fuzz-protocol (~370 lignes) - fork() pour chaque cas afin qu'un crash potentiel d'un cas ne contamine pas les suivants - 4 cas : brutal exit, bad ack serial, null dimensions, bad stride - Le parent attend chaque enfant via waitpid avant le suivant Validation runtime QEMU : - [fuzz1..4] tous PASS, [fuzz] PASS final - [frontend] xdg_surface.ack_configure: serial 99999 > last_sent 2, ignoring - [frontend] wl_shm_pool.create_buffer rejected: offset=0 width=0 height=0 stride=0 - [frontend] wl_shm_pool.create_buffer rejected: offset=0 width=100 height=10 stride=10 - Compositor continue à ticker 30+ s post-fuzz, curseur actif, surfaces des fuzz suivants créées et focusées normalement. Sub-bug documenté (à corriger 7.6) : la surface du fuzz1 (exit brutal sans destroy) persiste après la déconnexion du client. wayland-server détecte le close socket mais ne réveille pas automatiquement le wl_surface.Destroy handler. À hooker dans DumbClientData::disconnected pour le cleanup explicite. Doc complète : docs/phase7-5-robustness.md. Leyoda 2026 – GPLv3 --- crates/redox-wl-test-fuzz-protocol/Cargo.toml | 10 + .../redox-wl-test-fuzz-protocol/src/main.rs | 440 ++++++++++++++++++ crates/redox-wl-wayland-frontend/src/lib.rs | 109 ++++- docs/phase7-5-after-fuzz.png | Bin 0 -> 973 bytes docs/phase7-5-robustness.md | 190 ++++++++ 5 files changed, 731 insertions(+), 18 deletions(-) create mode 100644 crates/redox-wl-test-fuzz-protocol/Cargo.toml create mode 100644 crates/redox-wl-test-fuzz-protocol/src/main.rs create mode 100644 docs/phase7-5-after-fuzz.png create mode 100644 docs/phase7-5-robustness.md diff --git a/crates/redox-wl-test-fuzz-protocol/Cargo.toml b/crates/redox-wl-test-fuzz-protocol/Cargo.toml new file mode 100644 index 0000000..b44d79f --- /dev/null +++ b/crates/redox-wl-test-fuzz-protocol/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "redox-wl-test-fuzz-protocol" +version = "0.1.0" +edition = "2021" + +[dependencies] +wayland-client = { path = "../../../wayland-rs/wayland-client", default-features = false } +wayland-backend = { path = "../../../wayland-rs/wayland-backend", default-features = false } +wayland-protocols = { path = "../../../wayland-rs/wayland-protocols", default-features = false, features = ["client"] } +libc = "0.2" diff --git a/crates/redox-wl-test-fuzz-protocol/src/main.rs b/crates/redox-wl-test-fuzz-protocol/src/main.rs new file mode 100644 index 0000000..38ca1e5 --- /dev/null +++ b/crates/redox-wl-test-fuzz-protocol/src/main.rs @@ -0,0 +1,440 @@ +//! Phase 7.5 — Tests négatifs (paquet A) sur le protocole Wayland. +//! +//! Lance N sous-processus séquentiellement, chacun exerce un cas +//! malformé puis exit. Entre chaque, le parent vérifie indirectement +//! que le compositor est toujours là (le test suivant arrive à se +//! connecter au socket). +//! +//! Cas couverts : +//! 1. Exit brutal sans destroy : connect → create_surface → +//! xdg_toplevel + ack + commit normal → process::exit(0) immédiat +//! sans destroy(). Doit forcer le serveur à nettoyer via les Drop +//! handlers (DumbClientData::disconnected). +//! 2. AckConfigure avec mauvais serial : connect → toplevel → +//! attendre configure → ack(99999) → ack(real_serial). Le serveur +//! doit refuser le 99999 et accepter le real_serial. +//! 3. create_buffer dimensions invalides : connect → create_pool de +//! 1024 bytes → create_buffer(width=0, height=0, stride=0) → attach +//! + commit. Le serveur doit accepter le wl_buffer mais ignorer +//! son contenu au commit (pas de buffer overrun). +//! 4. create_buffer stride trop petit : create_pool(4096) → +//! create_buffer(w=100, h=10, stride=10) [stride < w*4]. Idem : +//! accepté mais ignoré au commit. +//! +//! Validation : à la fin, vérifier dans le serial / comp.log que le +//! compositor a logué les warnings appropriés et que ses ticks +//! continuent sans interruption (donc aucun panic, aucun deadlock). + +use std::ffi::CString; +use std::fs::OpenOptions; +use std::io::Write; +use std::os::fd::{AsFd, FromRawFd, OwnedFd}; +use std::os::unix::net::UnixStream; +use std::process::{self, ExitCode}; +use std::ptr; +use std::sync::{Mutex, OnceLock}; +use std::thread; +use std::time::Duration; + +use wayland_client::{ + Connection, Dispatch, EventQueue, Proxy, QueueHandle, + backend::Backend, + protocol::{ + wl_buffer::WlBuffer, wl_compositor::WlCompositor, wl_registry, wl_shm::WlShm, + wl_shm_pool::WlShmPool, wl_surface::WlSurface, + }, +}; +use wayland_protocols::xdg::shell::client::{ + xdg_surface::{self, XdgSurface}, + xdg_toplevel::{self, XdgToplevel}, + xdg_wm_base::{self, XdgWmBase}, +}; + +const SOCKET_PATH: &str = "/tmp/redox-wl-comp.sock"; + +struct DebugSink(Mutex>); +impl DebugSink { + fn new() -> Self { + Self(Mutex::new( + OpenOptions::new().write(true).open("/scheme/debug").ok(), + )) + } + fn writeln(&self, s: &str) { + println!("{s}"); + if let Ok(mut g) = self.0.lock() { + if let Some(f) = g.as_mut() { + let _ = writeln!(f, "{s}"); + } + } + } +} +fn dlog(s: &str) { + static SINK: OnceLock = OnceLock::new(); + SINK.get_or_init(DebugSink::new).writeln(s); +} + +#[derive(Default)] +struct ClientState { + compositor: Option, + shm: Option, + wm_base: Option, + pending_serial: Option, + configured: bool, +} + +impl Dispatch for ClientState { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _data: &(), + _conn: &Connection, + qh: &QueueHandle, + ) { + if let wl_registry::Event::Global { name, interface, version } = event { + match interface.as_str() { + "wl_compositor" => { + state.compositor = Some(registry.bind(name, version.min(5), qh, ())); + } + "wl_shm" => { + state.shm = Some(registry.bind(name, version.min(1), qh, ())); + } + "xdg_wm_base" => { + state.wm_base = Some(registry.bind(name, version.min(5), qh, ())); + } + _ => {} + } + } + } +} + +macro_rules! noop { + ($ty:ty) => { + impl Dispatch<$ty, ()> for ClientState { + fn event( + _state: &mut Self, + _r: &$ty, + _ev: <$ty as Proxy>::Event, + _: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + } + } + }; +} +noop!(WlCompositor); +noop!(WlShm); +noop!(WlShmPool); +noop!(WlBuffer); +noop!(WlSurface); +noop!(XdgToplevel); + +impl Dispatch for ClientState { + fn event( + _state: &mut Self, + wm_base: &XdgWmBase, + event: xdg_wm_base::Event, + _: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + if let xdg_wm_base::Event::Ping { serial } = event { + wm_base.pong(serial); + } + } +} + +impl Dispatch for ClientState { + fn event( + state: &mut Self, + _xdg_surf: &XdgSurface, + event: xdg_surface::Event, + _: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + if let xdg_surface::Event::Configure { serial } = event { + state.pending_serial = Some(serial); + state.configured = true; + } + } +} + +fn wait_socket() -> Result<(), String> { + for _ in 0..50 { + if std::path::Path::new(SOCKET_PATH).exists() { + return Ok(()); + } + thread::sleep(Duration::from_millis(100)); + } + Err("compositor socket missing".into()) +} + +fn connect() -> Result<(Connection, EventQueue, ClientState), Box> { + wait_socket()?; + let stream = UnixStream::connect(SOCKET_PATH)?; + let backend = Backend::connect(stream)?; + let conn = Connection::from_backend(backend); + let mut event_queue: EventQueue = conn.new_event_queue(); + let qh = event_queue.handle(); + let _registry = conn.display().get_registry(&qh, ()); + let mut state = ClientState::default(); + event_queue.roundtrip(&mut state)?; + Ok((conn, event_queue, state)) +} + +unsafe fn alloc_shm(name: &str, size: i32) -> Result { + let cname = CString::new(name).unwrap(); + let _ = libc::shm_unlink(cname.as_ptr()); + let fd = libc::shm_open(cname.as_ptr(), libc::O_RDWR | libc::O_CREAT, 0o600); + if fd < 0 { + return Err("shm_open failed".into()); + } + if libc::ftruncate(fd, size as _) != 0 { + libc::close(fd); + return Err("ftruncate failed".into()); + } + Ok(OwnedFd::from_raw_fd(fd)) +} + +/// Cas 1 : exit brutal sans destroy. +fn case_brutal_exit() -> Result<(), Box> { + dlog("[fuzz1] brutal exit without destroy"); + let (_conn, mut event_queue, mut state) = connect()?; + let qh = event_queue.handle(); + let compositor = state.compositor.clone().ok_or("no wl_compositor")?; + let wm_base = state.wm_base.clone().ok_or("no xdg_wm_base")?; + let shm = state.shm.clone().ok_or("no wl_shm")?; + + let surface = compositor.create_surface(&qh, ()); + let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ()); + let _toplevel = xdg_surface.get_toplevel(&qh, ()); + surface.commit(); + + let start = std::time::Instant::now(); + while !state.configured && start.elapsed() < Duration::from_secs(3) { + event_queue.roundtrip(&mut state)?; + thread::sleep(Duration::from_millis(50)); + } + if let Some(serial) = state.pending_serial { + xdg_surface.ack_configure(serial); + } + + // Allouer un buffer + attach + commit pour avoir un état complet + let size = 320 * 240 * 4; + let fd = unsafe { alloc_shm("/redox-wl-fuzz-1", size) }?; + let pool = shm.create_pool(fd.as_fd(), size, &qh, ()); + let buffer = pool.create_buffer( + 0, + 320, + 240, + 320 * 4, + wayland_client::protocol::wl_shm::Format::Argb8888, + &qh, + (), + ); + surface.attach(Some(&buffer), 0, 0); + surface.commit(); + event_queue.flush()?; + + dlog("[fuzz1] state complete, exiting brutally NOW"); + // Pas de destroy, pas de disconnect, pas de drop. Le kernel ferme + // les fds quand le process exit. C'est le serveur qui doit nettoyer. + process::exit(0); +} + +/// Cas 2 : ack_configure avec mauvais serial. +fn case_bad_ack_serial() -> Result<(), Box> { + dlog("[fuzz2] ack_configure with bad serial 99999"); + let (_conn, mut event_queue, mut state) = connect()?; + let qh = event_queue.handle(); + let compositor = state.compositor.clone().ok_or("no wl_compositor")?; + let wm_base = state.wm_base.clone().ok_or("no xdg_wm_base")?; + + let surface = compositor.create_surface(&qh, ()); + let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ()); + let _toplevel = xdg_surface.get_toplevel(&qh, ()); + surface.commit(); + + let start = std::time::Instant::now(); + while !state.configured && start.elapsed() < Duration::from_secs(3) { + event_queue.roundtrip(&mut state)?; + thread::sleep(Duration::from_millis(50)); + } + let real_serial = state.pending_serial.ok_or("no configure")?; + + // 1) Mauvais serial (compositor doit ignorer) + dlog("[fuzz2] sending ack_configure(99999) — should be refused"); + xdg_surface.ack_configure(99999); + event_queue.flush()?; + thread::sleep(Duration::from_millis(200)); + + // 2) Bon serial (compositor doit accepter) + dlog(&format!( + "[fuzz2] sending ack_configure({real_serial}) — should be accepted" + )); + xdg_surface.ack_configure(real_serial); + event_queue.flush()?; + thread::sleep(Duration::from_millis(500)); + + // Cleanup propre pour vérifier que la séquence post-bad-ack continue OK + surface.destroy(); + event_queue.flush()?; + dlog("[fuzz2] PASS"); + Ok(()) +} + +/// Cas 3 : create_buffer avec dimensions nulles. +fn case_null_dimensions() -> Result<(), Box> { + dlog("[fuzz3] create_buffer with null dimensions"); + let (_conn, mut event_queue, mut state) = connect()?; + let qh = event_queue.handle(); + let compositor = state.compositor.clone().ok_or("no wl_compositor")?; + let shm = state.shm.clone().ok_or("no wl_shm")?; + let wm_base = state.wm_base.clone().ok_or("no xdg_wm_base")?; + + let surface = compositor.create_surface(&qh, ()); + let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ()); + let _toplevel = xdg_surface.get_toplevel(&qh, ()); + surface.commit(); + + let start = std::time::Instant::now(); + while !state.configured && start.elapsed() < Duration::from_secs(3) { + event_queue.roundtrip(&mut state)?; + thread::sleep(Duration::from_millis(50)); + } + if let Some(s) = state.pending_serial { + xdg_surface.ack_configure(s); + } + + // Pool de 1024 octets + let fd = unsafe { alloc_shm("/redox-wl-fuzz-3", 1024) }?; + let pool = shm.create_pool(fd.as_fd(), 1024, &qh, ()); + + // Buffer 0x0 + let buffer = pool.create_buffer( + 0, + 0, + 0, + 0, + wayland_client::protocol::wl_shm::Format::Argb8888, + &qh, + (), + ); + surface.attach(Some(&buffer), 0, 0); + surface.commit(); + event_queue.flush()?; + thread::sleep(Duration::from_millis(300)); + + surface.destroy(); + event_queue.flush()?; + dlog("[fuzz3] PASS"); + Ok(()) +} + +/// Cas 4 : stride < width*4. +fn case_bad_stride() -> Result<(), Box> { + dlog("[fuzz4] create_buffer with stride < width*4"); + let (_conn, mut event_queue, mut state) = connect()?; + let qh = event_queue.handle(); + let compositor = state.compositor.clone().ok_or("no wl_compositor")?; + let shm = state.shm.clone().ok_or("no wl_shm")?; + let wm_base = state.wm_base.clone().ok_or("no xdg_wm_base")?; + + let surface = compositor.create_surface(&qh, ()); + let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ()); + let _toplevel = xdg_surface.get_toplevel(&qh, ()); + surface.commit(); + + let start = std::time::Instant::now(); + while !state.configured && start.elapsed() < Duration::from_secs(3) { + event_queue.roundtrip(&mut state)?; + thread::sleep(Duration::from_millis(50)); + } + if let Some(s) = state.pending_serial { + xdg_surface.ack_configure(s); + } + + // Pool de 4096 octets, buffer 100x10 avec stride=10 (au lieu de 400) + let fd = unsafe { alloc_shm("/redox-wl-fuzz-4", 4096) }?; + let pool = shm.create_pool(fd.as_fd(), 4096, &qh, ()); + let buffer = pool.create_buffer( + 0, + 100, + 10, + 10, // stride invalide + wayland_client::protocol::wl_shm::Format::Argb8888, + &qh, + (), + ); + surface.attach(Some(&buffer), 0, 0); + surface.commit(); + event_queue.flush()?; + thread::sleep(Duration::from_millis(300)); + + surface.destroy(); + event_queue.flush()?; + dlog("[fuzz4] PASS"); + Ok(()) +} + +fn run_in_child(label: &str, f: F) +where + F: FnOnce() -> Result<(), Box>, +{ + let pid = unsafe { libc::fork() }; + if pid < 0 { + dlog(&format!("[fuzz] fork failed for {label}")); + return; + } + if pid == 0 { + // Enfant : exécute le cas et exit. Si erreur, log. + if let Err(e) = f() { + dlog(&format!("[{label}] error: {e}")); + } + process::exit(0); + } + // Parent : attend l'enfant + let mut status: i32 = 0; + unsafe { + libc::waitpid(pid, &mut status, 0); + } + dlog(&format!("[fuzz] child {label} exited (status={status})")); +} + +fn run() -> Result<(), Box> { + dlog("[fuzz] phase 7.5 — protocole tests négatifs"); + + // Attente initiale pour que le compositor démarre + wait_socket()?; + thread::sleep(Duration::from_millis(500)); + + run_in_child("fuzz1", case_brutal_exit); + thread::sleep(Duration::from_secs(1)); + + run_in_child("fuzz2", case_bad_ack_serial); + thread::sleep(Duration::from_secs(1)); + + run_in_child("fuzz3", case_null_dimensions); + thread::sleep(Duration::from_secs(1)); + + run_in_child("fuzz4", case_bad_stride); + thread::sleep(Duration::from_secs(1)); + + dlog("[fuzz] all cases exercised, compositor should still be up"); + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => { + dlog("[fuzz] PASS"); + ExitCode::SUCCESS + } + Err(e) => { + dlog(&format!("[fuzz] FAIL: {e}")); + ExitCode::FAILURE + } + } +} diff --git a/crates/redox-wl-wayland-frontend/src/lib.rs b/crates/redox-wl-wayland-frontend/src/lib.rs index aecde46..4e63f05 100644 --- a/crates/redox-wl-wayland-frontend/src/lib.rs +++ b/crates/redox-wl-wayland-frontend/src/lib.rs @@ -87,11 +87,27 @@ impl ShmPool { }) } - /// Lit les pixels du buffer à partir de l'offset, taille connue. - /// Pas de validation alignment ; le caller doit fournir des params - /// cohérents (offset + h * stride <= self.size, stride == w*4). - unsafe fn read_argb(&self, offset: usize, w: u32, h: u32, stride: i32) -> Vec { + /// Lit les pixels du buffer à partir de l'offset. + /// + /// Phase 7.5 : retourne `None` si les paramètres demandent une lecture + /// hors-pool (overrun) ou si `stride < w*4`. Le caller doit alors + /// ignorer le buffer côté composition. Évite tout accès mémoire UB + /// face à un client malformé. + unsafe fn read_argb(&self, offset: usize, w: u32, h: u32, stride: i32) -> Option> { + if w == 0 || h == 0 || stride <= 0 { + return None; + } let stride = stride as usize; + if stride < (w as usize).saturating_mul(4) { + return None; + } + // last_byte = offset + (h-1)*stride + w*4 + let last_byte = offset + .checked_add(stride.checked_mul((h as usize).saturating_sub(1))?) + .and_then(|v| v.checked_add((w as usize).checked_mul(4)?))?; + if last_byte > self.size { + return None; + } let n = (w as usize) * (h as usize); let mut out = Vec::with_capacity(n); for y in 0..h as usize { @@ -100,7 +116,7 @@ impl ShmPool { out.push(unsafe { *row_ptr.add(x) }); } } - out + Some(out) } } @@ -123,6 +139,9 @@ struct BufferData { stride: i32, /// Format Wayland brut (Argb8888 = 0, Xrgb8888 = 1) format: wl_shm::Format, + /// Phase 7.5 : true si les paramètres sont valides (width/height/stride + /// cohérents avec la taille du pool). Si false, le commit ignore ce buffer. + valid: bool, } /// Données par-surface : SurfaceId du compositor + buffer attaché en pending. @@ -863,6 +882,35 @@ impl wayland_server::Dispatch>> for W Ok(f) => f, Err(_) => return, // format inconnu, on ignore }; + // Phase 7.5 : valider les dimensions et la cohérence avec la + // taille du pool. La spec wl_shm.create_buffer demande + // width/height/stride positifs et offset+stride*height <= pool size. + // Si invalide, on init quand même le wl_buffer (sinon le client + // aurait un id orphelin) mais on le marque `valid = false` pour + // que le commit l'ignore. + let mut valid = true; + if width <= 0 || height <= 0 || stride <= 0 || offset < 0 { + valid = false; + } + if valid && stride < width.saturating_mul(4) { + valid = false; + } + if valid { + let pool_size = pool.lock().unwrap().size as i64; + let needed = (offset as i64).saturating_add( + (stride as i64).saturating_mul((height as i64).saturating_sub(1)) + .saturating_add((width as i64).saturating_mul(4)), + ); + if needed > pool_size { + valid = false; + } + } + if !valid { + println!( + "[frontend] wl_shm_pool.create_buffer rejected: \ + offset={offset} width={width} height={height} stride={stride}" + ); + } let bd = BufferData { pool: Arc::clone(pool), offset, @@ -870,6 +918,7 @@ impl wayland_server::Dispatch>> for W height: height as u32, stride, format, + valid, }; data_init.init(id, bd); } @@ -953,19 +1002,30 @@ impl wayland_server::Dispatch> for Wayla // Lire le buffer attaché (s'il y en a un) let bd_opt = data.pending_buffer.lock().unwrap().clone(); if let Some(bd) = bd_opt { - // Lire les pixels et créer un SurfaceBuffer compositor-core - let pool = bd.pool.lock().unwrap(); - let pixels = unsafe { - pool.read_argb(bd.offset as usize, bd.width, bd.height, bd.stride) - }; - let sb = SurfaceBuffer::from_pixels(bd.width, bd.height, pixels); - // Pour une surface curseur, on stocke le buffer mais on - // garde visible=false (la surface ne doit pas apparaître - // dans la composition normale, seulement via draw_cursor). - state.registry.modify_pending(id, |s| { - s.buffer = Some(sb); - s.visible = !is_cursor; - }); + if !bd.valid { + // Phase 7.5 : buffer marqué invalide à la création + // (dimensions ou offset incohérents avec le pool). + // On ignore plutôt que de lire des octets hors-pool. + } else { + // Lire les pixels et créer un SurfaceBuffer compositor-core + let pool = bd.pool.lock().unwrap(); + let pixels_opt = unsafe { + pool.read_argb(bd.offset as usize, bd.width, bd.height, bd.stride) + }; + if let Some(pixels) = pixels_opt { + let sb = SurfaceBuffer::from_pixels(bd.width, bd.height, pixels); + // Pour une surface curseur, on stocke le buffer + // mais visible=false (cf draw_cursor). + state.registry.modify_pending(id, |s| { + s.buffer = Some(sb); + s.visible = !is_cursor; + }); + } else { + println!( + "[frontend] commit: read_argb refused buffer (overrun guard)" + ); + } + } } state.registry.commit(id); if !is_cursor { @@ -1170,6 +1230,19 @@ impl wayland_server::Dispatch> for // sert pour les decorations CSD que nous ne supportons pas). } xdg_surface::Request::AckConfigure { serial } => { + // Phase 7.5 : refuser un ack pour un serial jamais émis. + // Spec xdg-shell : si le client ack un serial inconnu, c'est + // une erreur de protocole. Pour 7.5 on tolère (ignore silent + + // log) plutôt que post_error, le temps de mesurer si des + // toolkits réels en abusent. À durcir si on observe des + // abus délibérés. + let last = *data.last_serial.lock().unwrap(); + if serial > last { + println!( + "[frontend] xdg_surface.ack_configure: serial {serial} > last_sent {last}, ignoring" + ); + return; + } *data.acked_serial.lock().unwrap() = serial; // Si c'était l'initial configure, débloquer l'affichage if let Some(sd) = data.wl_surface.data::>() { diff --git a/docs/phase7-5-after-fuzz.png b/docs/phase7-5-after-fuzz.png new file mode 100644 index 0000000000000000000000000000000000000000..7b88d99e3f21ec3daaa70e061697bcfeef4ac79c GIT binary patch literal 973 zcmeAS@N?(olHy`uVBq!ia0y~yU=ES4z)+>ez|hdb!0-zw z)bN6Vq11qZ;Z*_ygVhWM2JwP9y8>;15}W}(A+7=v3P9%n|Ns5}&07d$F(!GtyD+73 z*Q5YB>?NMQuI#T^c{$7!{ytEB0~As%ag8WRNi0dVN-jzTQVd20MrOJOrn-j4AqEy! zCKgu47TN{|Rt5&Eo}@oR(U6;;l9^VCTf^<0_oILsG~hOrWag$8mn7yEpy@F+u`)J- zSn@eF!Z?73nbaE7NbrkYhz{bh)prIwOx#KQF$fLyc)p2tdMIZdD zUh$cq9cT#}$k_FF^UL>w8{8ZiS$%{992hUNhy)xkVPq9C@EsMRLr5^9MF<-A$L`Nx z8g`uq;@?1g9EhKCyo)^llC|)4dX?jyDqsDM=zRBr(^b|@(bwLWRyo~1ti0*@JO8Tl pt_OV8*MFGz_zRkebmQ(j$nO67%Z(K$YPN#x^mO%eS?83{1OP5v Document produit le 2026-05-13 dans le cadre du plan directeur +> `REDOX_COSMIC_XWAYLAND_RS_PLAN.md`. +> +> **Scope strict** : +> - le compositor ne DOIT JAMAIS paniquer face à un client malformé +> - face à des paramètres invalides, soit ignore proprement (silent +> drop), soit `post_error` sur la resource fautive +> - 4 cas exercés a minima : exit brutal, mauvais ack serial, +> dimensions buffer nulles, stride incohérent avec width +> +> **Hors scope 7.5** : fuzzing exhaustif (à brancher dans CI plus +> tard), filtrage multi-client (7.6), move/resize (7.7). + +## Verdict + +**✅ Compositor anti-panic validé runtime sur 4 cas malformés +exécutés en succession dans des sous-processus séparés.** + +Capture : ![](phase7-5-after-fuzz.png) — état du framebuffer après +les 4 cas. Le rectangle noir à `(60, 60)` est la surface persistante +du fuzz1 (commit-puis-exit-brutal, buffer ARGB rempli de 0). Le +curseur software est toujours actif au centre, fond compositor bleu +nuit, aucun crash ni "Display output not active". Le compositor a +continué à ticker pendant 30+ secondes après la fin du fuzz. + +Logs `/tmp/fuzz.log` : +``` +[fuzz] phase 7.5 — protocole tests négatifs +[fuzz1] brutal exit without destroy +[fuzz1] state complete, exiting brutally NOW +[fuzz] child fuzz1 exited (status=0) +[fuzz2] ack_configure with bad serial 99999 +[fuzz2] sending ack_configure(99999) — should be refused +[fuzz2] sending ack_configure(2) — should be accepted +[fuzz2] PASS +[fuzz3] create_buffer with null dimensions +[fuzz3] PASS +[fuzz4] create_buffer with stride < width*4 +[fuzz4] PASS +[fuzz] all cases exercised, compositor should still be up +[fuzz] PASS +``` + +Logs côté compositor `/tmp/comp.log` filtrés : +``` +[frontend] xdg_surface.ack_configure: serial 99999 > last_sent 2, ignoring +[frontend] wl_shm_pool.create_buffer rejected: offset=0 width=0 height=0 stride=0 +[frontend] wl_shm_pool.create_buffer rejected: offset=0 width=100 height=10 stride=10 +``` + +Chaque garde a été déclenché exactement une fois, dans l'ordre +attendu. Aucun panic, aucun deadlock, aucune corruption d'état (les +surfaces des fuzz suivants se créent et reçoivent bien le focus). + +## Modifications apportées + +### `redox-wl-wayland-frontend` + +**`xdg_surface::Request::AckConfigure`** : vérifie que `serial <= +last_serial` avant de l'enregistrer dans `acked_serial`. Si le client +ack un serial jamais émis, on log et on ignore. Pas de `post_error` +pour 7.5 (à durcir si on observe des abus délibérés ; les toolkits +réels peuvent envoyer des ack stale légitimes après un reconfigure +race). + +**`BufferData`** : nouveau champ `valid: bool`. Mis à `false` au +`wl_shm_pool::Request::CreateBuffer` si l'une des conditions est +violée : +- `width <= 0 || height <= 0 || stride <= 0 || offset < 0` +- `stride < width * 4` (pour Argb8888 ARGB 4 octets/pixel) +- `offset + (height-1)*stride + width*4 > pool.size` + +Le buffer wl_buffer est quand même initialisé via `data_init.init` +(pour ne pas violer le contrat wayland-server et laisser un id +orphelin côté client), mais marqué invalide. Le commit handler +court-circuite la lecture pour ces buffers. + +**`ShmPool::read_argb`** : signature passée de `Vec` à +`Option>`. Refuse de lire si `w == 0`, `h == 0`, +`stride <= 0`, `stride < w*4`, ou si l'accès final dépasse `self.size`. +Calculs en `checked_add` / `checked_mul` pour éviter tout overflow +sur des params adversariaux. Évite tout accès mémoire UB. + +**`wl_surface::Request::Commit`** : vérifie `bd.valid` avant de +tenter la lecture, et gère le `None` retourné par `read_argb` comme +un buffer ignoré (log warning, surface garde son ancien contenu). + +### `redox-wl-test-fuzz-protocol` (nouveau crate, ~370 lignes) + +Binaire qui exerce 4 cas malformés, chacun dans un `fork()` séparé +pour qu'un éventuel crash d'un cas ne contamine pas les suivants : + +| Cas | Description | +|---|---| +| fuzz1 | Connect → surface + xdg_toplevel + ack + buffer + commit → `process::exit(0)` brutal sans destroy. Le kernel ferme les fds, le compositor doit gérer la déconnexion via les Drop des UserData wayland-server. | +| fuzz2 | Connect → toplevel → attendre configure → `ack_configure(99999)` puis `ack_configure(real_serial)`. Le compositor doit refuser le premier (log warning, ignore) et accepter le second. | +| fuzz3 | Connect → toplevel → `create_buffer(width=0, height=0, stride=0)` → attach + commit. Le compositor doit accepter le wl_buffer (pour ne pas violer le protocole client) mais marquer invalid et ignorer au commit. | +| fuzz4 | Idem, avec `width=100, height=10, stride=10` (au lieu de stride=400). Le guard `stride < width*4` doit déclencher. | + +Le parent attend chaque enfant via `waitpid` puis sleep 1 s avant le +suivant, pour laisser au compositor le temps de logger sa réaction +au cas précédent. À la fin, log `[fuzz] PASS` si tout s'est exécuté +sans crash côté client. + +## Validation runtime + +QEMU headless, image Redox boot complet, service init +`40_phase75_fuzz` qui lance le compositor puis le fuzz binary +4 secondes plus tard via un wrapper script `/usr/bin/launch_phase75.sh` +(rappel 7.4 : `nowait sh -c "..."` direct ne passe pas le parsing +init Redox, il faut un script). + +Vérification post-run : +- ✅ Tous les sous-processus enfants exit avec status=0 +- ✅ `[fuzz] PASS` final +- ✅ Tous les logs `[frontend]` attendus sont présents +- ✅ Le compositor continue à ticker pendant 30+ s après le PASS +- ✅ Pas de message `panicked at` dans `/tmp/comp.log` +- ✅ Pas de blocage : les surfaces des fuzz suivants se créent + normalement, les focus changes s'enchaînent (SurfaceId(0), 2, 3 + vus dans les logs — les ids 1, 4 correspondent probablement aux + xdg_surface comptés à part) + +## Limitations connues (à traiter en sous-tickets ultérieurs) + +- **Surface "fantôme" du fuzz1 persiste après l'exit brutal du + client** : visible comme un rectangle noir à (60,60) dans la + capture finale. Le client a fait `commit` (donc buffer copié dans + le registry) puis exit. wayland-server détecte la déconnexion mais + le `wl_surface::Destroy` handler n'est PAS appelé automatiquement + pour les resources d'un client qui ferme socket sans destroy + propre — il faudrait hooker `DumbClientData::disconnected` pour + faire le cleanup explicite. À ajouter en 7.6 (multi-clients) où ce + cas deviendra fréquent. +- **Pas de `post_error` envoyé** : pour 7.5 on ignore silencieusement + (avec log). Plus permissif que la spec. Si un toolkit réel envoie + un mauvais serial par bug, on reste tolérant. À durcir si on + observe des bugs systématiques côté serveur causés par ce laxisme. +- **Pas testés en 7.5** : + - `xdg_surface.destroy` avant `xdg_toplevel.destroy` (spec : + error, mais pas dangereux côté serveur — on ignore) + - destroy d'un wl_buffer attaché à une surface (la sémantique + Wayland est qu'on doit envoyer `wl_buffer.release` avant ; on + n'envoie jamais `release` actuellement — TODO 7.6) + - `wl_shm_pool.resize` (no-op actuellement — TODO) + - Format `wl_shm` non advertised (formats inconnus déjà ignorés) + - Surface avec rôle déjà assigné (`wl_data_source` n'existe pas, + le rôle xdg_surface est protégé par `xdg_pending_initial_configure`) +- **Pas de fuzzing automatique avec corpus random** : reportable + beaucoup plus tard, avec libfuzzer + cargo-fuzz. Pour 7.5 on se + contente de cas dirigés. + +## Critère de fin 7.5 + +> Face aux 4 cas malformés exercés par le fuzz protocol binary, le +> compositor ne panique pas, ne se bloque pas, et reste pleinement +> opérationnel pour les clients suivants. + +**✅ Validé.** Le compositor a tourné 30+ s post-fuzz avec ticks +continus, le curseur reste actif, les surfaces des fuzz suivants se +créent et reçoivent leur focus. + +## Code + +``` +crates/redox-wl-wayland-frontend/ # +~50 lignes (BufferData.valid, + # AckConfigure validation, read_argb safe) +crates/redox-wl-test-fuzz-protocol/ # nouveau crate (~370 lignes) +``` + +## Suite phase 7.6 + +Multi-clients paquet B : +- Au moins 3 clients fork parallèle, chacun avec sa fenêtre + frame + callbacks rapides +- Vérifier que les frame callbacks `done` arrivent à chaque client +- Vérifier l'ordre Z stable sous load +- Filtrage des events button/key au client focused uniquement + (au lieu du broadcast actuel) +- Cleanup automatique post-disconnect (corrige le sub-bug 7.5 : + surface fantôme du fuzz1) +- `wl_buffer.release` envoyé après chaque copy au commit + +Estimé : 1-2 sessions. + +--- + +*Fin du document de phase 7.5.*