From 00e9825226df339c74a2697d8f380c9b3a65d622 Mon Sep 17 00:00:00 2001 From: James Reilly Date: Tue, 10 Nov 2026 21:16:23 +0000 Subject: [PATCH] fix(compositor): offer zwlr_layer_shell_v1 only to the supervised shell (#1048) Create the layer-shell global with a filter: a connection is admitted when the runtime accepts it from the shell child it spawned and still holds (pidfd, else SO_PEERCRED). Ordinary clients neither see nor can force-bind it, so an application can no longer place surfaces over the panel or the lock. Tests now bind layer-shell through a shell connection; a new test covers the ordinary/shell split. The Marlin VM constraint fixture becomes a fullscreen xdg_toplevel and V-HOT-CORNER-CONSTRAINTS asserts the corner stays closed over it (GNOME's inFullscreen guard), with pressure positives after it quits. Signed-off-by: James Reilly --- crates/compositor/src/layer.rs | 34 ++++- crates/compositor/src/lib.rs | 46 +++++- crates/compositor/src/runtime.rs | 4 +- crates/compositor/tests/input_protocols.rs | 23 +-- crates/compositor/tests/layer.rs | 5 +- crates/compositor/tests/layer_teardown.rs | 6 +- crates/compositor/tests/popups.rs | 14 +- crates/compositor/tests/protocols.rs | 83 +++++++++- crates/compositor/tests/windows.rs | 29 +++- crates/compositor/tests/x11_gates.rs | 4 +- crates/shell-host/src/panel.rs | 18 ++- docs/environment.md | 2 +- docs/protocols.md | 12 +- docs/security-model.md | 26 ++-- packaging/marlin/vm-lane/Containerfile | 2 +- .../marlin/vm-lane/Containerfile.published | 2 +- .../marlin/vm-lane/tuna-vm-constraints-build | 9 +- .../marlin/vm-lane/tuna-vm-constraints.c | 103 ++++++++----- scripts/lib/vm-boot-tests.py | 14 ++ scripts/tuna-vm-lane | 144 ++++++++++++------ 20 files changed, 427 insertions(+), 153 deletions(-) diff --git a/crates/compositor/src/layer.rs b/crates/compositor/src/layer.rs index 4273cfdac..9ec49920e 100644 --- a/crates/compositor/src/layer.rs +++ b/crates/compositor/src/layer.rs @@ -8,6 +8,19 @@ //! positions anything here; placement and rendering belong to later //! tasks. Behavioral reference: cosmic-comp's layer-shell handler (record //! on map, configure on map) with original code throughout. +//! +//! Admission (#1048): the global is visible, and bindable, only to the +//! supervised shell's connection — the native connection whose peer is +//! the shell child the runtime spawned and still holds ([`is_shell_peer`], +//! the same pid identity the session lock and control socket use). Every +//! other client's registry omits it and a blind bind by name is a protocol +//! error. GNOME 51 has no client-placed shell layers at all; Mutter hides +//! its privileged globals per client through a global filter +//! (meta-wayland-filter-manager.c `global_filter_func`, +//! meta-wayland-x11-interop.c `x11_interop_filter`), the model followed +//! here. Recorded divergence: Mutter keys admission to a capability on a +//! compositor-created client; ours is the accepted connection's pid +//! until the shell gets an inherited socket (spek 004, ADR 0005). use smithay::{ delegate_layer_shell, @@ -39,6 +52,14 @@ pub use smithay::wayland::shell::wlr_layer::Layer; /// from the same source to prevent drift. pub use tuna_shell_control::{BANNER_NAMESPACE, OVERVIEW_NAMESPACE, PANEL_HEIGHT, PANEL_NAMESPACE}; +/// Whether a native connection's peer is the supervised shell: its pid is +/// the shell child the runtime holds. An unknown pid on either side (no +/// shell running, between restarts, or unreadable peer credentials) never +/// matches. +pub fn is_shell_peer(peer_pid: Option, shell_pid: Option) -> bool { + matches!((peer_pid, shell_pid), (Some(peer), Some(shell)) if peer == shell) +} + /// One layer surface's placement request, as committed. #[derive(Debug, Clone, Copy, PartialEq)] pub struct LayerRequest { @@ -572,7 +593,18 @@ impl State { #[cfg(test)] mod tests { - use super::{front_to_back, hit_layer_rect, press_takes_keyboard, KeyboardInteractivity}; + use super::{ + front_to_back, hit_layer_rect, is_shell_peer, press_takes_keyboard, KeyboardInteractivity, + }; + + #[test] + fn only_the_held_shell_child_is_a_shell_peer() { + assert!(is_shell_peer(Some(4242), Some(4242))); + assert!(!is_shell_peer(Some(4243), Some(4242)), "another process"); + assert!(!is_shell_peer(Some(4242), None), "no shell running"); + assert!(!is_shell_peer(None, Some(4242)), "unreadable peer"); + assert!(!is_shell_peer(None, None)); + } #[test] fn a_press_never_gives_the_keyboard_to_a_surface_that_wants_none() { diff --git a/crates/compositor/src/lib.rs b/crates/compositor/src/lib.rs index 5a2106216..bf9b52fe1 100644 --- a/crates/compositor/src/lib.rs +++ b/crates/compositor/src/lib.rs @@ -329,6 +329,10 @@ pub(crate) struct ClientState { /// one client allowed a virtual keyboard (to hand back the keys /// IBus does not take). pub(crate) ime_bridge: bool, + /// The supervised shell's own connection: the one client offered + /// `zwlr_layer_shell_v1` (#1048). Set at accept time only when the + /// peer is the shell child the runtime holds (`layer::is_shell_peer`). + pub(crate) supervised_shell: bool, /// Optional tag applied to windows of a D-Bus-created ordinary client. pub(crate) connection_window_tag: Option, /// Optional liveness marker for an ordinary service-channel connection. @@ -369,6 +373,27 @@ impl ClientState { ..Self::default() } } + /// State for the supervised shell's connection (layer-shell admitted). + pub(crate) fn supervised_shell() -> Self { + Self { + supervised_shell: true, + ..Self::default() + } + } + /// Admit an accepted native connection to layer-shell when its peer is + /// the shell child `shell_pid` the runtime still holds. The peer is the + /// pinned original process when the kernel supplied a pidfd (no identity + /// once it exited), else its `SO_PEERCRED` pid, as the session lock's + /// shell check reads it. + pub(crate) fn admit_supervised_shell(&mut self, socket: &UnixStream, shell_pid: Option) { + let peer = match &self.original_credentials { + Some(credentials) => crate::capture_security::pinned_process_id(credentials), + None => rustix::net::sockopt::socket_peercred(socket) + .ok() + .and_then(|peer| u32::try_from(peer.pid.as_raw_pid()).ok()), + }; + self.supervised_shell = crate::layer::is_shell_peer(peer, shell_pid); + } } /// Client-initiated window state request (002 window actions). @@ -1071,7 +1096,15 @@ impl State { seat_state, seat, dh: dh.clone(), - layer_shell_state: WlrLayerShellState::new::(dh), + // Only the supervised shell may place layer surfaces (#1048): + // GNOME has no client-placed shell layers at all, and Mutter + // hides privileged globals from clients that lack the + // capability (meta-wayland-x11-interop.c `x11_interop_filter`). + layer_shell_state: WlrLayerShellState::new_with_filter::(dh, |client| { + client + .get_data::() + .is_some_and(|data| data.supervised_shell) + }), data_device_state: DataDeviceState::new::(dh), dnd_icon: None, cursor_image: smithay::input::pointer::CursorImageStatus::default_named(), @@ -1495,6 +1528,17 @@ impl TestCompositor { .unwrap() } + /// Attach the supervised shell's connection in native protocol + /// fixtures: the one client offered layer-shell. The runtime grants + /// this only to the shell child's own pid; public clients use + /// `add_client`. + pub fn add_shell_client(&mut self, stream: UnixStream) -> Client { + self.display + .handle() + .insert_client(stream, Arc::new(ClientState::supervised_shell())) + .unwrap() + } + /// Dispatch pending client requests and flush replies. Drives the /// display without an event loop; tests interleave this with client /// actions instead of blocking on either side. diff --git a/crates/compositor/src/runtime.rs b/crates/compositor/src/runtime.rs index e81ffcf08..8a5abcff7 100644 --- a/crates/compositor/src/runtime.rs +++ b/crates/compositor/src/runtime.rs @@ -1166,7 +1166,9 @@ impl Runtime { event_loop .handle() .insert_source(socket, |stream, _, runtime: &mut Runtime| { - let client_data = ClientState::native_connection(&stream); + let mut client_data = ClientState::native_connection(&stream); + // Layer-shell is the supervised shell's alone (#1048). + client_data.admit_supervised_shell(&stream, runtime.shell.child_pid()); if let Ok(client) = runtime .display .handle() diff --git a/crates/compositor/tests/input_protocols.rs b/crates/compositor/tests/input_protocols.rs index 01464ab02..cd6effe38 100644 --- a/crates/compositor/tests/input_protocols.rs +++ b/crates/compositor/tests/input_protocols.rs @@ -427,20 +427,24 @@ struct Peer { } fn connect(comp: &mut TestCompositor) -> Peer { - connect_with_admission(comp, false) + connect_with_admission(comp, TestCompositor::add_client) } fn connect_ime_bridge(comp: &mut TestCompositor) -> Peer { - connect_with_admission(comp, true) + connect_with_admission(comp, TestCompositor::add_ime_bridge_client) } -fn connect_with_admission(comp: &mut TestCompositor, ime_bridge: bool) -> Peer { +/// The supervised shell's connection: the only client offered layer-shell. +fn connect_shell(comp: &mut TestCompositor) -> Peer { + connect_with_admission(comp, TestCompositor::add_shell_client) +} + +fn connect_with_admission( + comp: &mut TestCompositor, + admit: fn(&mut TestCompositor, UnixStream) -> wayland_server::Client, +) -> Peer { let (server, stream) = UnixStream::pair().unwrap(); - let server = if ime_bridge { - comp.add_ime_bridge_client(server) - } else { - comp.add_client(server) - }; + let server = admit(comp, server); let conn = Connection::from_socket(stream).unwrap(); let queue = conn.new_event_queue(); let registry = conn.display().get_registry(&queue.handle(), ()); @@ -829,7 +833,8 @@ fn layer_constraints_use_actual_origin_and_commit_regions_before_activation() { comp.state.set_output_size(1280, 800); comp.state.set_output_location("tuna-0", (-400, 100)); let mut manager = comp.window_manager(); - let mut app = connect(&mut comp); + // Layer-shell is the supervised shell's alone (#1048). + let mut app = connect_shell(&mut comp); pump(&mut comp, &mut manager, &mut [&mut app]); let qh = app.queue.handle(); let surface = app diff --git a/crates/compositor/tests/layer.rs b/crates/compositor/tests/layer.rs index bf67232dc..47fa52985 100644 --- a/crates/compositor/tests/layer.rs +++ b/crates/compositor/tests/layer.rs @@ -197,10 +197,11 @@ fn pump( panic!("pump budget exhausted"); } -/// Connect one client and bind compositor plus layer shell. +/// Connect the supervised shell (the only client offered layer-shell, +/// #1048) and bind compositor plus layer shell. fn connect(comp: &mut TestCompositor) -> (Connection, EventQueue, Client) { let (server_stream, client_stream) = UnixStream::pair().unwrap(); - comp.add_client(server_stream); + comp.add_shell_client(server_stream); let conn = Connection::from_socket(client_stream).unwrap(); let mut queue = conn.new_event_queue(); let qh = queue.handle(); diff --git a/crates/compositor/tests/layer_teardown.rs b/crates/compositor/tests/layer_teardown.rs index 3a4f861d9..2d3938678 100644 --- a/crates/compositor/tests/layer_teardown.rs +++ b/crates/compositor/tests/layer_teardown.rs @@ -140,7 +140,9 @@ fn destroying_a_layer_role_then_committing_keeps_the_client_connected() { let mut comp = TestCompositor::new(); let _manager = comp.window_manager(); let (server, stream) = UnixStream::pair().unwrap(); - comp.add_client(server); + // gtk4-layer-shell runs in the supervised shell, the only client + // offered layer-shell (#1048). + comp.add_shell_client(server); let conn = Connection::from_socket(stream).unwrap(); let mut queue = conn.new_event_queue(); let qh = queue.handle(); @@ -246,7 +248,7 @@ fn destroying_a_layer_role_releases_its_shm_backing() { let mut comp = TestCompositor::new(); let _manager = comp.window_manager(); let (server, stream) = UnixStream::pair().unwrap(); - comp.add_client(server); + comp.add_shell_client(server); let conn = Connection::from_socket(stream).unwrap(); let mut queue = conn.new_event_queue(); let qh = queue.handle(); diff --git a/crates/compositor/tests/popups.rs b/crates/compositor/tests/popups.rs index 962302a49..263bed6db 100644 --- a/crates/compositor/tests/popups.rs +++ b/crates/compositor/tests/popups.rs @@ -306,10 +306,20 @@ fn sync(f: &mut Fixture) { } fn fixture() -> Fixture { + fixture_with(TestCompositor::add_client) +} + +/// The fixture on the supervised shell's connection: the only client +/// offered layer-shell (#1048), as the GTK shell's panel is. +fn shell_fixture() -> Fixture { + fixture_with(TestCompositor::add_shell_client) +} + +fn fixture_with(admit: fn(&mut TestCompositor, UnixStream) -> wayland_server::Client) -> Fixture { let mut comp = TestCompositor::new(); let manager = comp.window_manager(); let (server, client_stream) = UnixStream::pair().unwrap(); - comp.add_client(server); + admit(&mut comp, server); let conn = Connection::from_socket(client_stream).unwrap(); let mut queue = conn.new_event_queue(); let qh = queue.handle(); @@ -635,7 +645,7 @@ fn keyboard_focus_returns_to_the_exclusive_layer_after_the_grab() { // and keyboard focus must return to the Exclusive panel — not to // the focused window (or nothing), or the bar goes inactive with // GTK focus intact and the unwind assertion fails. - let mut f = fixture(); + let mut f = shell_fixture(); let layer = map_exclusive_layer(&mut f); assert_eq!( f.client.keyboard_entered.last().copied(), diff --git a/crates/compositor/tests/protocols.rs b/crates/compositor/tests/protocols.rs index e8d4ba566..eb6c5097d 100644 --- a/crates/compositor/tests/protocols.rs +++ b/crates/compositor/tests/protocols.rs @@ -3,6 +3,8 @@ //! The globals list is golden: adding or dropping a protocol is a //! deliberate change that updates `EXPECTED` and `docs/protocols.md` //! together. xdg-decoration stays absent on purpose (Mutter parity). +//! `EXPECTED` is what an ordinary client sees; `SHELL_ONLY` holds the +//! globals the supervised shell's connection sees on top of it (#1048). use std::collections::BTreeMap; use std::os::unix::net::UnixStream; @@ -40,12 +42,14 @@ wayland_client::delegate_noop!(Client: ignore XdgToplevelIconV1); wayland_client::delegate_noop!(Client: ignore wayland_client::protocol::wl_shm::WlShm); wayland_client::delegate_noop!(Client: ignore wayland_client::protocol::wl_shm_pool::WlShmPool); wayland_client::delegate_noop!(Client: ignore wayland_client::protocol::wl_buffer::WlBuffer); +use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1; +wayland_client::delegate_noop!(Client: ignore ZwlrLayerShellV1); const ROUNDS: usize = 200; -/// Every global a client sees on a headless compositor, by version. -/// linux-dmabuf joins once a renderer reports formats (see below), and -/// wl_output once an output is connected. +/// Every global an ordinary client sees on a headless compositor, by +/// version. linux-dmabuf joins once a renderer reports formats (see +/// below), and wl_output once an output is connected. const EXPECTED: &[(&str, u32)] = &[ ("ext_session_lock_manager_v1", 1), ("wl_compositor", 6), @@ -68,7 +72,6 @@ const EXPECTED: &[(&str, u32)] = &[ ("xdg_toplevel_tag_manager_v1", 1), ("xdg_wm_base", 6), ("xdg_wm_dialog_v1", 1), - ("zwlr_layer_shell_v1", 4), ("zwp_idle_inhibit_manager_v1", 1), ("zwp_keyboard_shortcuts_inhibit_manager_v1", 1), ("zwp_pointer_constraints_v1", 1), @@ -82,6 +85,10 @@ const EXPECTED: &[(&str, u32)] = &[ ("zxdg_output_manager_v1", 3), ]; +/// Globals filtered to the supervised shell's connection (#1048): its +/// registry is `EXPECTED` plus these, an ordinary client's lacks them. +const SHELL_ONLY: &[(&str, u32)] = &[("zwlr_layer_shell_v1", 4)]; + #[derive(Default)] struct Client { globals: BTreeMap, @@ -215,18 +222,27 @@ wayland_client::delegate_noop!(Client: ignore XdgActivationV1); struct Peer { _conn: Connection, + registry: WlRegistry, queue: EventQueue, client: Client, } fn connect(comp: &mut TestCompositor) -> Peer { + connect_with(comp, TestCompositor::add_client) +} + +fn connect_with( + comp: &mut TestCompositor, + admit: fn(&mut TestCompositor, UnixStream) -> wayland_server::Client, +) -> Peer { let (server, stream) = UnixStream::pair().unwrap(); - comp.add_client(server); + admit(comp, server); let conn = Connection::from_socket(stream).unwrap(); let queue = conn.new_event_queue(); - conn.display().get_registry(&queue.handle(), ()); + let registry = conn.display().get_registry(&queue.handle(), ()); Peer { _conn: conn, + registry, queue, client: Client::default(), } @@ -310,6 +326,61 @@ fn advertised_globals_match_the_golden_list() { ); } +fn seen_globals(peer: &Peer) -> Vec<(String, u32)> { + peer.client + .globals + .iter() + .map(|(k, (_, v))| (k.clone(), *v)) + .collect() +} + +#[test] +fn layer_shell_is_visible_and_bindable_only_by_the_supervised_shell() { + let mut comp = TestCompositor::new(); + let mut manager = comp.window_manager(); + let mut app = connect(&mut comp); + let mut shell = connect_with(&mut comp, TestCompositor::add_shell_client); + pump(&mut comp, &mut manager, &mut [&mut app, &mut shell]); + + // The shell's registry is the ordinary one plus the filtered globals. + let mut expected: Vec<(String, u32)> = EXPECTED + .iter() + .chain(SHELL_ONLY) + .map(|(k, v)| ((*k).to_owned(), *v)) + .collect(); + expected.sort(); + assert_eq!(seen_globals(&shell), expected); + for (interface, _) in SHELL_ONLY { + assert!( + !app.client.globals.contains_key(*interface), + "{interface} must stay out of an ordinary client's registry" + ); + } + + // Knowing the global's name (from the shell's registry) must not let + // an ordinary client bind it blind: the bind is a protocol error. + let (name, _) = shell.client.globals["zwlr_layer_shell_v1"]; + let _forced: ZwlrLayerShellV1 = app.registry.bind(name, 4, &app.queue.handle(), ()); + let mut denied = false; + for _ in 0..ROUNDS { + let _ = app.queue.flush(); + comp.pump(); + if let Some(guard) = app.queue.prepare_read() { + let _ = guard.read(); + } + let _ = app.queue.dispatch_pending(&mut app.client); + if app._conn.backend().last_error().is_some() { + denied = true; + break; + } + } + assert!(denied, "a blind bind of the shell-only global must fail"); + // The shell's own connection is untouched by the denial. + let _bound: ZwlrLayerShellV1 = shell.registry.bind(name, 4, &shell.queue.handle(), ()); + pump(&mut comp, &mut manager, &mut [&mut shell]); + assert!(shell._conn.backend().last_error().is_none()); +} + #[test] fn dmabuf_is_advertised_only_with_renderer_formats() { let mut comp = TestCompositor::new(); diff --git a/crates/compositor/tests/windows.rs b/crates/compositor/tests/windows.rs index 2bda76df8..621072f36 100644 --- a/crates/compositor/tests/windows.rs +++ b/crates/compositor/tests/windows.rs @@ -467,8 +467,21 @@ fn pump( /// Connect one client and bind globals (compositor, seat with keyboard /// and pointer, xdg shell). fn connect(comp: &mut TestCompositor) -> (Connection, EventQueue, Client) { + connect_with(comp, TestCompositor::add_client) +} + +/// Connect the supervised shell: the only client offered layer-shell +/// (#1048), so every shell layer surface below comes from here. +fn connect_shell(comp: &mut TestCompositor) -> (Connection, EventQueue, Client) { + connect_with(comp, TestCompositor::add_shell_client) +} + +fn connect_with( + comp: &mut TestCompositor, + admit: fn(&mut TestCompositor, UnixStream) -> wayland_server::Client, +) -> (Connection, EventQueue, Client) { let (server_stream, client_stream) = UnixStream::pair().unwrap(); - comp.add_client(server_stream); + admit(comp, server_stream); let conn = Connection::from_socket(client_stream).unwrap(); let mut queue = conn.new_event_queue(); let qh = queue.handle(); @@ -1902,7 +1915,7 @@ fn overview_parks_keyboard_focus_and_restores_on_dismiss() { assert!(f.manager.focus(&mut f.comp.state, Some(f.id_a))); // Third client: the shell overview layer surface. - let (conn_c, mut queue_c, mut client_c) = connect(&mut f.comp); + let (conn_c, mut queue_c, mut client_c) = connect_shell(&mut f.comp); let qh_c = queue_c.handle(); let surface_c = client_c .compositor @@ -2010,7 +2023,7 @@ fn shell_layer( WlSurface, ZwlrLayerSurfaceV1, ) { - let (conn, mut queue, mut client) = connect(comp); + let (conn, mut queue, mut client) = connect_shell(comp); let qh = queue.handle(); let surface = client.compositor.as_ref().unwrap().create_surface(&qh, ()); let layer = client.layer_shell.as_ref().unwrap().get_layer_surface( @@ -3557,7 +3570,7 @@ fn picking_a_window_in_the_overview_survives_the_close() { assert!(f.manager.focus(&mut f.comp.state, Some(f.id_a))); // Third client: the shell overview layer surface. - let (conn_c, mut queue_c, mut client_c) = connect(&mut f.comp); + let (conn_c, mut queue_c, mut client_c) = connect_shell(&mut f.comp); let qh_c = queue_c.handle(); let surface_c = client_c .compositor @@ -3608,7 +3621,7 @@ fn a_window_launched_from_the_overview_is_focused_when_it_closes() { assert!(f.manager.focus(&mut f.comp.state, Some(f.id_a))); // Third client: the shell overview layer surface. - let (conn_c, mut queue_c, mut client_c) = connect(&mut f.comp); + let (conn_c, mut queue_c, mut client_c) = connect_shell(&mut f.comp); let qh_c = queue_c.handle(); let surface_c = client_c .compositor @@ -3721,7 +3734,7 @@ fn strip_columns_resize_on_primary_output_failover_without_losing_focus() { fn exclusive_overlay_receives_navigation_and_retains_popup_system_accelerators() { use tuna_shell_control::{Accelerator, MODE_NORMAL, MODE_POPUP, MOD_ALT, MOD_CTRL}; let mut f = two_windows(); - let (conn, mut queue, mut client) = connect(&mut f.comp); + let (conn, mut queue, mut client) = connect_shell(&mut f.comp); let qh = queue.handle(); let surface = client.compositor.as_ref().unwrap().create_surface(&qh, ()); let layer = client.layer_shell.as_ref().unwrap().get_layer_surface( @@ -3825,7 +3838,7 @@ fn panel_dynamic_exclusive_hold_suppresses_normal_accelerators() { // Evdev `s` (KEY_S): the Super+S quick-settings chord shape. const S_KEYCODE: u32 = 31; let mut f = two_windows(); - let (conn, mut queue, mut client) = connect(&mut f.comp); + let (conn, mut queue, mut client) = connect_shell(&mut f.comp); let qh = queue.handle(); let surface = client.compositor.as_ref().unwrap().create_surface(&qh, ()); let layer = client.layer_shell.as_ref().unwrap().get_layer_surface( @@ -3894,7 +3907,7 @@ fn panel_dynamic_exclusive_hold_suppresses_normal_accelerators() { fn exclusive_overview_keeps_overview_accelerators() { use tuna_shell_control::{Accelerator, MODE_OVERVIEW, MOD_LOGO, OVERVIEW_NAMESPACE}; let mut f = two_windows(); - let (conn, mut queue, mut client) = connect(&mut f.comp); + let (conn, mut queue, mut client) = connect_shell(&mut f.comp); let qh = queue.handle(); let surface = client.compositor.as_ref().unwrap().create_surface(&qh, ()); let layer = client.layer_shell.as_ref().unwrap().get_layer_surface( diff --git a/crates/compositor/tests/x11_gates.rs b/crates/compositor/tests/x11_gates.rs index d5044df59..8b564258a 100644 --- a/crates/compositor/tests/x11_gates.rs +++ b/crates/compositor/tests/x11_gates.rs @@ -232,7 +232,9 @@ const FORBIDDEN_GLOBAL_SUBSTRINGS: &[&str] = &[ fn no_capture_or_toplevel_control_global_advertised() { let (server_stream, client_stream) = UnixStream::pair().unwrap(); let mut comp = TestCompositor::new(); - comp.add_client(server_stream); + // The supervised shell's registry is the widest a native client gets: + // the ordinary globals plus layer-shell (#1048). None may capture. + comp.add_shell_client(server_stream); let conn = Connection::from_socket(client_stream).unwrap(); let mut queue = conn.new_event_queue(); diff --git a/crates/shell-host/src/panel.rs b/crates/shell-host/src/panel.rs index d054cbeff..969b133f1 100644 --- a/crates/shell-host/src/panel.rs +++ b/crates/shell-host/src/panel.rs @@ -6184,7 +6184,9 @@ mod tests { use crate::watcher::{IndicatorIcon, IndicatorItem}; let mut comp = TestCompositor::new(); let (server_stream, client_stream) = UnixStream::pair().unwrap(); - comp.add_client(server_stream); + // The supervised shell's connection: the only one offered + // layer-shell (#1048). + comp.add_shell_client(server_stream); let conn = Connection::from_socket(client_stream).unwrap(); let mut queue = conn.new_event_queue(); let (mut host, _dir) = test_host(); @@ -6240,7 +6242,7 @@ mod tests { let mut comp = TestCompositor::new(); comp.state.set_output_size(1280, 800); let (server_stream, client_stream) = UnixStream::pair().unwrap(); - comp.add_client(server_stream); + comp.add_shell_client(server_stream); let conn = Connection::from_socket(client_stream).unwrap(); let mut queue = conn.new_event_queue(); let (mut host, _dir) = test_host(); @@ -6293,7 +6295,7 @@ mod tests { let mut comp = TestCompositor::new(); comp.state.set_output_size(1280, 800); let (server_stream, client_stream) = UnixStream::pair().unwrap(); - comp.add_client(server_stream); + comp.add_shell_client(server_stream); let conn = Connection::from_socket(client_stream).unwrap(); let mut queue = conn.new_event_queue(); let (mut host, _dir) = test_host(); @@ -6341,7 +6343,7 @@ mod tests { let mut comp = TestCompositor::new(); comp.state.set_output_size(1280, 800); let (server_stream, client_stream) = UnixStream::pair().unwrap(); - comp.add_client(server_stream); + comp.add_shell_client(server_stream); let conn = Connection::from_socket(client_stream).unwrap(); let mut queue = conn.new_event_queue(); let (mut host, _dir) = test_host(); @@ -6394,7 +6396,7 @@ mod tests { let mut comp = TestCompositor::new(); comp.state.set_output_size(1280, 800); let (server_stream, client_stream) = UnixStream::pair().unwrap(); - comp.add_client(server_stream); + comp.add_shell_client(server_stream); let conn = Connection::from_socket(client_stream).unwrap(); let mut queue = conn.new_event_queue(); let (mut host, _dir) = test_host(); @@ -6462,7 +6464,7 @@ mod tests { let mut comp = TestCompositor::new(); comp.state.set_output_size(1280, 800); let (server_stream, client_stream) = UnixStream::pair().unwrap(); - comp.add_client(server_stream); + comp.add_shell_client(server_stream); let conn = Connection::from_socket(client_stream).unwrap(); let mut queue = conn.new_event_queue(); let (mut host, _dir) = keyed_host(); @@ -6735,7 +6737,7 @@ mod tests { fn panel_attaches_acknowledges_and_stops_on_close() { let mut comp = TestCompositor::new(); let (server_stream, client_stream) = UnixStream::pair().unwrap(); - comp.add_client(server_stream); + comp.add_shell_client(server_stream); let conn = Connection::from_socket(client_stream).unwrap(); // Learn global names on a throwaway queue first, then bind @@ -6822,7 +6824,7 @@ mod tests { let mut comp = TestCompositor::new(); comp.state.set_output_size(1280, 800); let (server_stream, client_stream) = UnixStream::pair().unwrap(); - comp.add_client(server_stream); + comp.add_shell_client(server_stream); let conn = Connection::from_socket(client_stream).unwrap(); let mut queue = conn.new_event_queue(); let (mut host, _dir) = test_host(); diff --git a/docs/environment.md b/docs/environment.md index d0d3542e5..552bfd1f5 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -248,7 +248,7 @@ a private `XDG_RUNTIME_DIR`; it unsets `DISPLAY`, `WAYLAND_DISPLAY`, |---|---|---| | `TUNA_LOCAL_SRC` | `packaging/arch/PKGBUILD:5-7`, `:65-70` | Build the Arch package from this checkout instead of fetching the release tag: `TUNA_LOCAL_SRC="$PWD" makepkg -f --dir packaging/arch`. | | `TUNA_PKG`, `TUNA_PKG_SHA256` | `packaging/marlin/Containerfile:18-26` | Build arguments (`--build-arg`), not environment variables: the Arch package file to layer into the Marlin image, and its checksum. | -| `TUNA_CI_ONLY` | `packaging/marlin/vm-lane/tuna-vm-constraints-build:4` | Must be `1`; stops the CI-only VM layer helper from running anywhere else. | +| `TUNA_CI_ONLY` | `packaging/marlin/vm-lane/tuna-vm-constraints-build:4` | Must be `1`; stops the CI-only VM pointer-constraint client build from running anywhere else. | | `TUNAOS_GDM_DEFAULT_ROOT`, `TUNAOS_GDM_SESSIONS_ROOT`, `TUNAOS_GDM_THEME_ROOT` | `packaging/marlin/vm-lane/tunaos/tuna-gdm-*.sh`, `tuna-hide-other-sessions.sh` | Root directory prefix the VM-lane GDM helpers write under, so tests can run them on a scratch tree. | ### Proof scripts diff --git a/docs/protocols.md b/docs/protocols.md index 0431e68a9..19b9c4221 100644 --- a/docs/protocols.md +++ b/docs/protocols.md @@ -45,8 +45,10 @@ is represented by a match, a version floor or a recorded deviation; a source-only floor is never described as a measured native version. **Golden test.** `crates/compositor/tests/protocols.rs` asserts the exact -list and versions below. Adding or dropping a protocol updates the test -and this page together. +list and versions an ordinary client sees (`EXPECTED`), and that the +supervised shell's connection sees exactly that plus `SHELL_ONLY` +(layer-shell). Adding or dropping a protocol updates the test and this +page together. ## Advertised by Tuna Desktop @@ -61,7 +63,7 @@ and this page together. | zwp_primary_selection_device_manager_v1 | 1 | | | xdg_wm_base | 6 | popups with positioner constraints and grabs | | zxdg_output_manager_v1 | 3 | | -| zwlr_layer_shell_v1 | 4 | Tuna Desktop's own shell draws through it | +| zwlr_layer_shell_v1 | 4 | Tuna Desktop's own shell draws through it. Only the supervised shell's connection may bind it: the native connection whose peer pid is the shell child the compositor spawned and still holds (`tuna-shell-gtk`, or the legacy `tuna-shell-host`), the same identity the session lock and control socket check. Every other client's registry omits it and a blind bind by name is a protocol error (#1048; `ClientState::admit_supervised_shell`, `layer::is_shell_peer`) | | zwp_linux_dmabuf_v1 | 3 | only when the renderer reports formats | | xdg_activation_v1 | 1 | focus policy below | | wp_viewporter | 1 | | @@ -186,6 +188,8 @@ GNOME's version. - **xdg-decoration is absent,** as in Mutter. GNOME is client-side decorations only, and the golden test asserts the absence. - **layer-shell is present,** unlike Mutter. Tuna Desktop's shell is a separate - process and draws its panel, overview and banners through it. + process and draws its panel, overview and banners through it. Only that + supervised shell can see or bind it; applications cannot, so they have no + more surface-placement reach than on GNOME. `xdg_toplevel_icon_manager_v1` v1 accepts square SHM icons and sanitized theme names, applied on the next surface commit. PNGs live in a private, bounded compositor cache. X11 `_NET_WM_ICON` and legacy square TrueColor `WM_HINTS` pixmaps (24/32-bit color, optional 1-bit transparency mask) are read on a bounded worker for mapped window identities. Palette and unsupported visuals fall back to a generic app icon. diff --git a/docs/security-model.md b/docs/security-model.md index 9008956bc..bde82e986 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -32,9 +32,9 @@ labelled **target**. ## Adversaries in scope 1. **Ordinary Wayland clients**, sandboxed or not. They may send arbitrary - protocol requests, claim any app ID, title or layer-shell namespace, and - connect and disconnect at will. They must not capture the screen, type - into other clients, see other windows' contents or get past the lock. + protocol requests, claim any app ID or title, and connect and disconnect + at will. They must not capture the screen, type into other clients, see + other windows' contents, place shell layers or get past the lock. 2. **Session-bus callers.** Any same-user process on the session bus may call the compositor's and shell's D-Bus interfaces, and may claim well-known names that nobody owns yet. Capture, remote input and window @@ -81,8 +81,8 @@ labelled **target**. | GDM | Login authentication; starts `tuna-session` from `share/wayland-sessions/tuna.desktop` | Platform. Since #866 GDM is the only supported login path. Tuna Desktop ships no greeter. | `share/wayland-sessions/tuna.desktop` | | `tuna-compositor` | Everything below. Owns focus, stacking, input routing, the lock flag, capture authorization and privileged protocol exposure ([architecture](architecture.md#process-and-authority-boundaries)) | The trusted computing base of the session | `crates/compositor` | | Private PAM helper | Running the `tuna-lock` PAM transaction | Re-executes the compositor's own held image. It never takes an executable path from UI, environment or configuration ([private PAM helper](security/private-pam-helper.md)). | `crates/compositor/src/pam_helper.rs` | -| Supervised shell (`tuna-shell-gtk` by default, legacy `tuna-shell-host`) | UI intent, lock surfaces, the shell's own capture UI | PID of the compositor's own shell child (`Authority::publish`, `runtime.rs:4881-4890`). Between restarts no process is trusted. | `crates/shell-gtk`, `crates/shell-host` | -| Compositor's IBus bridge | Typing for other clients (virtual keyboard) | A Wayland connection the compositor inserts itself with the `ime_bridge` flag (`ime.rs:360`, `lib.rs:321-327`) | `crates/compositor/src/ime.rs` | +| Supervised shell (`tuna-shell-gtk` by default, legacy `tuna-shell-host`) | UI intent, lock surfaces, layer surfaces (panel, overview, banners, dialogs), the shell's own capture UI | PID of the compositor's own shell child (`Authority::publish`, `runtime.rs:4881-4890`); for its Wayland connection, the accepted peer's PID at connect time (`runtime.rs:1169-1171`). Between restarts no process is trusted. | `crates/shell-gtk`, `crates/shell-host` | +| Compositor's IBus bridge | Typing for other clients (virtual keyboard) | A Wayland connection the compositor inserts itself with the `ime_bridge` flag (`ime.rs:360`, `lib.rs:328-331`) | `crates/compositor/src/ime.rs` | | GNOME portal backend (`xdg-desktop-portal-gnome`) | Consent broker for ScreenCast, Screenshot and RemoteDesktop | Caller's bus-authenticated PID resolves to a root-owned, non-group/world-writable installed executable, and the caller currently owns `org.freedesktop.impl.portal.desktop.gnome` (`capture_security.rs:176-277`) | `crates/compositor/src/capture_security.rs` | | GNOME providers (Nautilus file chooser, global-shortcuts provider) | Opening an ordinary display connection, and `mutter_x11_interop` | Same installed-executable check for the requested service type only (`capture_security.rs:9-40`). Confers no capture or input authority ([service channel](portal-service-channel.md)). | `crates/compositor/src/mutter.rs:198-293`, `x11_interop.rs` | | Applications | Nothing beyond their own surfaces | Ordinary clients. Discovered Flatpak/Snap identity is metadata only ([service channel](portal-service-channel.md#optional-gnome-sandbox-identities-410)). | `crates/compositor/src/sandbox_identity.rs` | @@ -150,6 +150,17 @@ refuse locked frames. Full detail and the qualification evidence: - `zwp_virtual_keyboard_manager_v1` is visible only to the compositor's IBus bridge (`protocols.rs:171-181`). +- `zwlr_layer_shell_v1` is visible and bindable only to the supervised + shell's connection (#1048). The compositor marks a native connection as + the shell's when it accepts it, if the peer is the shell child it spawned + and still holds: the pidfd-pinned peer process, or the `SO_PEERCRED` PID + where the kernel has no `SO_PEERPIDFD` (`lib.rs:383-396`, + `layer.rs:55-61`, `runtime.rs:1169-1171`). The global filter hides it from + every other client and a blind bind by name is a protocol error + (`lib.rs:1099-1107`). Shell surfaces are still told apart by namespace, + but only the shell can create one. Evidence: + `crates/compositor/tests/protocols.rs` + (`layer_shell_is_visible_and_bindable_only_by_the_supervised_shell`). - `mutter_x11_interop` is visible only to typed service-channel connections (`x11_interop.rs:65-67`). - `ext_session_lock_manager_v1` is advertised to every client, but a lock @@ -239,11 +250,6 @@ UI classification remain required for both legs. whoever owns an allowlisted well-known name, as GNOME does. A same-user process that claims `org.gnome.Settings` first passes it. Those methods only show OSDs and open search or the app grid. -- **Layer shell is open.** `zwlr_layer_shell_v1` is created without a client - filter (`crates/compositor/src/lib.rs:999`). The compositor identifies - shell surfaces by their client-chosen namespace, which names a role, not - an owner (`layer.rs:492-510`). The R14 "unauthorized layer" cases are not - qualified. - **Extension sandbox is in-process.** The Rhai engine sets an operation budget but no string, array or memory limits (`extensions.rs:103-146`), so a script can exhaust the legacy shell host's memory. A Rhai engine bug is diff --git a/packaging/marlin/vm-lane/Containerfile b/packaging/marlin/vm-lane/Containerfile index 7621ba1bc..42d408ad4 100644 --- a/packaging/marlin/vm-lane/Containerfile +++ b/packaging/marlin/vm-lane/Containerfile @@ -41,7 +41,7 @@ ARG BASE_IMAGE=localhost/marlin-tuna-preview:ci FROM ${BASE_IMAGE} AS constraint-builder COPY packaging/marlin/vm-lane/tuna-vm-constraints.c /build/tuna-vm-constraints.c COPY packaging/marlin/vm-lane/tuna-vm-constraints-build /build/tuna-vm-constraints-build -RUN pacman -S --needed --noconfirm gcc pkgconf wayland wayland-protocols wlr-protocols \ +RUN pacman -S --needed --noconfirm gcc pkgconf wayland wayland-protocols \ && TUNA_CI_ONLY=1 /build/tuna-vm-constraints-build FROM ${BASE_IMAGE} diff --git a/packaging/marlin/vm-lane/Containerfile.published b/packaging/marlin/vm-lane/Containerfile.published index d4beead76..4a7bb9c63 100644 --- a/packaging/marlin/vm-lane/Containerfile.published +++ b/packaging/marlin/vm-lane/Containerfile.published @@ -11,7 +11,7 @@ COPY packaging/marlin/vm-lane/tuna-vm-constraints.c /build/tuna-vm-constraints.c COPY packaging/marlin/vm-lane/tuna-vm-constraints-build /build/tuna-vm-constraints-build COPY scripts/tuna-frame-pacer-build /build/soak/tuna-frame-pacer-build COPY scripts/lib/tuna-frame-pacer.c /build/soak/lib/tuna-frame-pacer.c -RUN bash /build/tuna-vm-published-pacman gcc pkgconf wayland wayland-protocols wlr-protocols \ +RUN bash /build/tuna-vm-published-pacman gcc pkgconf wayland wayland-protocols \ && TUNA_CI_ONLY=1 /build/tuna-vm-constraints-build \ && bash /build/soak/tuna-frame-pacer-build /build/soak/out diff --git a/packaging/marlin/vm-lane/tuna-vm-constraints-build b/packaging/marlin/vm-lane/tuna-vm-constraints-build index 65e1b8196..b436fd4cb 100755 --- a/packaging/marlin/vm-lane/tuna-vm-constraints-build +++ b/packaging/marlin/vm-lane/tuna-vm-constraints-build @@ -4,14 +4,13 @@ set -euo pipefail [[ ${TUNA_CI_ONLY:-} == 1 ]] || { echo "constraint client build is CI-only" >&2; exit 1; } build_dir=$(mktemp -d) trap 'rm -rf "$build_dir"' EXIT -# Layer-shell's protocol interface table references xdg_popup_interface even -# though this ordinary layer client never binds xdg-shell or creates a popup. -for item in xdg-shell pointer-constraints relative-pointer layer-shell; do +# The ordinary principal is a fullscreen xdg_toplevel: ordinary clients +# cannot bind layer-shell (#1048). +for item in xdg-shell pointer-constraints relative-pointer; do case "$item" in xdg-shell) xml=/usr/share/wayland-protocols/stable/xdg-shell/xdg-shell.xml ;; pointer-constraints) xml=/usr/share/wayland-protocols/unstable/pointer-constraints/pointer-constraints-unstable-v1.xml ;; relative-pointer) xml=/usr/share/wayland-protocols/unstable/relative-pointer/relative-pointer-unstable-v1.xml ;; - layer-shell) xml=/usr/share/wlr-protocols/unstable/wlr-layer-shell-unstable-v1.xml ;; esac wayland-scanner client-header "$xml" "$build_dir/$item-client.h" wayland-scanner private-code "$xml" "$build_dir/$item-protocol.c" @@ -22,6 +21,6 @@ cc -std=c11 -O2 -Wall -Wextra -Werror -I"$build_dir" \ $(pkg-config --cflags --libs wayland-client) -o "$build_dir/tuna-vm-constraints" install -Dm755 "$build_dir/tuna-vm-constraints" /usr/libexec/tuna-vm-constraints install -Dm644 "$build_dir/source-sha256sums" /usr/share/tuna-vm-constraints/source-sha256sums -pacman -Q wayland wayland-protocols wlr-protocols gcc > /usr/share/tuna-vm-constraints/packages.txt +pacman -Q wayland wayland-protocols gcc > /usr/share/tuna-vm-constraints/packages.txt cc --version > /usr/share/tuna-vm-constraints/compiler.txt sha256sum /build/tuna-vm-constraints.c /usr/libexec/tuna-vm-constraints > /usr/share/tuna-vm-constraints/build-sha256sums diff --git a/packaging/marlin/vm-lane/tuna-vm-constraints.c b/packaging/marlin/vm-lane/tuna-vm-constraints.c index 0f38fe83f..16917b6e1 100644 --- a/packaging/marlin/vm-lane/tuna-vm-constraints.c +++ b/packaging/marlin/vm-lane/tuna-vm-constraints.c @@ -15,42 +15,50 @@ #include #include "pointer-constraints-client.h" #include "relative-pointer-client.h" -#include "layer-shell-client.h" +#include "xdg-shell-client.h" -/* CI-only ordinary session client. No shell control or privileged globals. */ +/* CI-only ordinary session client. No shell control or privileged globals. + * Ordinary clients cannot bind layer-shell (#1048), so the principal is a + * fullscreen xdg_toplevel: at the primary output's origin its surface-local + * coordinates equal global ones, and the 64x64 constraint region keeps its + * output-relative top-left place. */ static struct wl_display *display; static struct wl_compositor *compositor; static struct wl_shm *shm; static struct wl_seat *seat; static struct wl_pointer *pointer; static struct wl_surface *surface; -static struct zwlr_layer_shell_v1 *layers; -static struct zwlr_layer_surface_v1 *layer; +static struct xdg_wm_base *wm_base; +static struct xdg_surface *xsurface; +static struct xdg_toplevel *toplevel; +static struct wl_buffer *shm_buffer; static struct zwp_pointer_constraints_v1 *constraints; static struct zwp_relative_pointer_manager_v1 *relative_manager; static struct zwp_relative_pointer_v1 *relative; static struct zwp_locked_pointer_v1 *locked; static struct zwp_confined_pointer_v1 *confined; static unsigned configured, entered, enter_count, leave_count, motions; +static int32_t pending_w, pending_h; +static unsigned pending_fullscreen, fullscreen, buffer_w, buffer_h; static unsigned locks, unlocks, confines, unconfines, receipts; static double pointer_x, pointer_y; static int mode, running = 1; static char socket_path[108]; static struct { double dx, dy, ux, uy; uint64_t time; } events[128]; -static struct { uint32_t serial, width, height; } configure_events[16]; +static struct { uint32_t serial, width, height, chosen_w, chosen_h, fullscreen; } configure_events[64]; static void die(const char *message) { perror(message); exit(1); } static uint32_t id(void *proxy) { return proxy ? wl_proxy_get_id(proxy) : 0; } static void snapshot(char *buffer, size_t capacity) { int n = snprintf(buffer, capacity, - "{\"pid\":%ld,\"uid\":%ld,\"configured\":%u,\"entered\":%u,\"enter_count\":%u,\"leave_count\":%u,\"motions\":%u,\"local\":[%.17g,%.17g],\"locked\":%u,\"unlocked\":%u,\"confined\":%u,\"unconfined\":%u,\"mode\":%d,\"surface_id\":%u,\"pointer_id\":%u,\"relative_id\":%u,\"constraint_id\":%u,\"globals\":{\"compositor\":%u,\"shm\":%u,\"seat\":%u,\"layer_shell\":%u,\"constraints\":%u,\"relative_manager\":%u},\"buffer_size\":[%u,%u],\"configure_events\":[", + "{\"pid\":%ld,\"uid\":%ld,\"configured\":%u,\"entered\":%u,\"enter_count\":%u,\"leave_count\":%u,\"motions\":%u,\"local\":[%.17g,%.17g],\"locked\":%u,\"unlocked\":%u,\"confined\":%u,\"unconfined\":%u,\"mode\":%d,\"surface_id\":%u,\"pointer_id\":%u,\"relative_id\":%u,\"constraint_id\":%u,\"globals\":{\"compositor\":%u,\"shm\":%u,\"seat\":%u,\"wm_base\":%u,\"constraints\":%u,\"relative_manager\":%u},\"fullscreen\":%u,\"buffer_size\":[%u,%u],\"configure_events\":[", (long)getpid(), (long)getuid(), configured, entered, enter_count, leave_count, motions, pointer_x, pointer_y, locks, unlocks, confines, unconfines, mode, id(surface), id(pointer), id(relative), id(locked ? (void *)locked : (void *)confined), - id(compositor), id(shm), id(seat), id(layers), id(constraints), id(relative_manager), configured ? 128u : 0u, configured ? 128u : 0u); + id(compositor), id(shm), id(seat), id(wm_base), id(constraints), id(relative_manager), fullscreen, buffer_w, buffer_h); if (n < 0 || (size_t)n >= capacity) die("snapshot bound"); size_t used = (size_t)n; for (unsigned i = 0; i < configured; i++) { - n = snprintf(buffer + used, capacity - used, "%s{\"serial\":%u,\"raw\":[%u,%u],\"chosen\":[128,128]}", i ? "," : "", configure_events[i].serial, configure_events[i].width, configure_events[i].height); + n = snprintf(buffer + used, capacity - used, "%s{\"serial\":%u,\"raw\":[%u,%u],\"chosen\":[%u,%u],\"fullscreen\":%u}", i ? "," : "", configure_events[i].serial, configure_events[i].width, configure_events[i].height, configure_events[i].chosen_w, configure_events[i].chosen_h, configure_events[i].fullscreen); if (n < 0 || (size_t)n >= capacity - used) die("snapshot configure bound"); used += (size_t)n; } @@ -116,7 +124,7 @@ static void global(void *d, struct wl_registry *registry, uint32_t name, const c seat = wl_registry_bind(registry,name,&wl_seat_interface,7); wl_seat_add_listener(seat,&seat_listener,NULL); } - else BIND(zwlr_layer_shell_v1,layers,4) + else BIND(xdg_wm_base,wm_base,1) else BIND(zwp_pointer_constraints_v1,constraints,1) else BIND(zwp_relative_pointer_manager_v1,relative_manager,1) #undef BIND @@ -127,34 +135,46 @@ static void shm_format(void *d, struct wl_shm *s, uint32_t format) { (void)d;(vo static const struct wl_shm_listener shm_listener = { shm_format }; static void buffer_release(void *d, struct wl_buffer *b) { (void)d;(void)b; } static const struct wl_buffer_listener buffer_listener = { buffer_release }; -static void configure(void *d, struct zwlr_layer_surface_v1 *l, uint32_t serial, uint32_t w, uint32_t h) { +static void ping(void *d, struct xdg_wm_base *b, uint32_t serial) { (void)d; xdg_wm_base_pong(b,serial); } +static const struct xdg_wm_base_listener wm_base_listener = { .ping = ping }; +static void toplevel_configure(void *d, struct xdg_toplevel *t, int32_t w, int32_t h, struct wl_array *states) { + (void)d; (void)t; + if (w < 0 || h < 0 || w > 8192 || h > 8192) { errno = EPROTO; die("actual configured size bound"); } + pending_w = w; pending_h = h; pending_fullscreen = 0; + uint32_t *state; + wl_array_for_each(state, states) if (*state == XDG_TOPLEVEL_STATE_FULLSCREEN) pending_fullscreen = 1; +} +static void toplevel_close(void *d, struct xdg_toplevel *t) { (void)d;(void)t;die("actual toplevel close requested"); } +static const struct xdg_toplevel_listener toplevel_listener = { .configure = toplevel_configure, .close = toplevel_close }; +static void attach_buffer(uint32_t w, uint32_t h) { + size_t stride = (size_t)w * 4, size = stride * h; + int fd = memfd_create("tuna-vm-constraint-buffer",MFD_CLOEXEC); + if (fd < 0 || ftruncate(fd,(off_t)size)) die("actual shm buffer"); + uint32_t *pixels = mmap(NULL,size,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0); + if (pixels == MAP_FAILED) die("mmap"); + for (size_t i=0;i<(size_t)w*h;i++) pixels[i]=0xff203040; + struct wl_shm_pool *pool=wl_shm_create_pool(shm,fd,(int32_t)size); + struct wl_buffer *next=wl_shm_pool_create_buffer(pool,0,(int32_t)w,(int32_t)h,(int32_t)stride,WL_SHM_FORMAT_ARGB8888); + wl_buffer_add_listener(next,&buffer_listener,NULL); + wl_surface_attach(surface,next,0,0);wl_surface_damage(surface,0,0,(int32_t)w,(int32_t)h); + wl_shm_pool_destroy(pool);munmap(pixels,size);close(fd); + if (shm_buffer) wl_buffer_destroy(shm_buffer); + shm_buffer = next; buffer_w = w; buffer_h = h; +} +static void surface_configure(void *d, struct xdg_surface *x, uint32_t serial) { (void)d; - // Layer-shell zero dimensions explicitly leave the choice to the client. - // Keep the actual requested/buffer geometry fixed at 128; reject every - // nonzero suggestion that would change this principal's test geometry. - if ((w && w != 128) || (h && h != 128)) { - fprintf(stderr,"actual configured dimensions: %u,%u\n",w,h); - errno = EPROTO; die("actual configured size"); - } - if (configured >= 16) { errno = EOVERFLOW; die("actual configure receipt bound"); } - configure_events[configured].serial=serial;configure_events[configured].width=w;configure_events[configured].height=h; - zwlr_layer_surface_v1_ack_configure(l,serial); - if (!configured) { - int fd = memfd_create("tuna-vm-constraint-buffer",MFD_CLOEXEC); - if (fd < 0 || ftruncate(fd,128*128*4)) die("actual shm buffer"); - uint32_t *pixels = mmap(NULL,128*128*4,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0); - if (pixels == MAP_FAILED) die("mmap"); - for (int i=0;i<128*128;i++) pixels[i]=0xff203040; - struct wl_shm_pool *pool=wl_shm_create_pool(shm,fd,128*128*4); - struct wl_buffer *buffer=wl_shm_pool_create_buffer(pool,0,128,128,128*4,WL_SHM_FORMAT_ARGB8888); - wl_buffer_add_listener(buffer,&buffer_listener,NULL); - wl_surface_attach(surface,buffer,0,0);wl_surface_damage(surface,0,0,128,128); - wl_shm_pool_destroy(pool);munmap(pixels,128*128*4);close(fd); - } + // A zero dimension leaves the choice to the client; keep the former + // 128 test geometry there. Fullscreen configures name the output size. + uint32_t w = pending_w ? (uint32_t)pending_w : 128u, h = pending_h ? (uint32_t)pending_h : 128u; + if (configured >= 64) { errno = EOVERFLOW; die("actual configure receipt bound"); } + configure_events[configured].serial=serial;configure_events[configured].width=(uint32_t)pending_w;configure_events[configured].height=(uint32_t)pending_h; + configure_events[configured].chosen_w=w;configure_events[configured].chosen_h=h;configure_events[configured].fullscreen=pending_fullscreen; + xdg_surface_ack_configure(x,serial); + fullscreen = pending_fullscreen; + if (w != buffer_w || h != buffer_h) attach_buffer(w,h); configured++;wl_surface_commit(surface); } -static void closed(void *d, struct zwlr_layer_surface_v1 *l) { (void)d;(void)l;die("actual layer admission closed"); } -static const struct zwlr_layer_surface_v1_listener layer_listener = {configure,closed}; +static const struct xdg_surface_listener xsurface_listener = { .configure = surface_configure }; static int allowed(const char *command) { return !strcmp(command,"status")||!strcmp(command,"lock")||!strcmp(command,"confine")||!strcmp(command,"destroy")||!strcmp(command,"empty")||!strcmp(command,"restore")||!strcmp(command,"quit"); } @@ -204,15 +224,16 @@ int main(int argc,char **argv) { if(bind(control,(struct sockaddr*)&address,sizeof(address))||listen(control,1))die("unique session control bind"); display=wl_display_connect(NULL);if(!display)die("actual Wayland connect"); struct wl_registry *registry=wl_display_get_registry(display);wl_registry_add_listener(registry,®istry_listener,NULL); - if(wl_display_roundtrip(display)<0||!compositor||!shm||!seat||!layers||!constraints||!relative_manager)die("actual globals"); + if(wl_display_roundtrip(display)<0||!compositor||!shm||!seat||!wm_base||!constraints||!relative_manager)die("actual globals"); + xdg_wm_base_add_listener(wm_base,&wm_base_listener,NULL); if(wl_display_roundtrip(display)<0||!pointer)die("actual seat pointer"); surface=wl_compositor_create_surface(compositor); relative=zwp_relative_pointer_manager_v1_get_relative_pointer(relative_manager,pointer);zwp_relative_pointer_v1_add_listener(relative,&relative_listener,NULL); - layer=zwlr_layer_shell_v1_get_layer_surface(layers,surface,NULL,ZWLR_LAYER_SHELL_V1_LAYER_OVERLAY,"tuna-vm-constraints"); - zwlr_layer_surface_v1_add_listener(layer,&layer_listener,NULL); - zwlr_layer_surface_v1_set_size(layer,128,128);zwlr_layer_surface_v1_set_anchor(layer,ZWLR_LAYER_SURFACE_V1_ANCHOR_TOP|ZWLR_LAYER_SURFACE_V1_ANCHOR_LEFT); - /* Ordinary layer protocol: extend this test overlay to its anchored edges. */ - zwlr_layer_surface_v1_set_exclusive_zone(layer,-1);zwlr_layer_surface_v1_set_keyboard_interactivity(layer,ZWLR_LAYER_SURFACE_V1_KEYBOARD_INTERACTIVITY_NONE);wl_surface_commit(surface); + xsurface=xdg_wm_base_get_xdg_surface(wm_base,surface);xdg_surface_add_listener(xsurface,&xsurface_listener,NULL); + toplevel=xdg_surface_get_toplevel(xsurface);xdg_toplevel_add_listener(toplevel,&toplevel_listener,NULL); + xdg_toplevel_set_app_id(toplevel,"tuna-vm-constraints");xdg_toplevel_set_title(toplevel,"tuna-vm-constraints"); + /* Ordinary fullscreen request before the first commit: no layer role. */ + xdg_toplevel_set_fullscreen(toplevel,NULL);wl_surface_commit(surface); time_t deadline=time(NULL)+300; while(running&&time(NULL) 1 for i in range(2))): + raise RuntimeError("actual original fullscreen origin/configuration receipt unavailable") # Retain both actual coordinate spaces, including wire quantization; # this precedes pressure and every real constraint creation. origin_receipt = {"global": position, "local": local["local"], "global_minus_local": [position[i] - local["local"][i] for i in range(2)], "client": local, "state": global_state} - constraint_observations.append({"label": label + "-actual-layer-origin", **origin_receipt}) - with open(os.path.join(evidence, label + "-actual-layer-origin.json"), "w") as stream: + constraint_observations.append({"label": label + "-actual-fullscreen-origin", **origin_receipt}) + with open(os.path.join(evidence, label + "-actual-fullscreen-origin.json"), "w") as stream: json.dump(origin_receipt, stream, indent=2) - corner_move(0, 10) + corner_move(*PRINCIPAL_ENTRY) await_state(lambda value: value["state"]["pointer_position"] is not None - and value["state"]["pointer_position"][0] == 0 - and 1 < value["state"]["pointer_position"][1] < 32, label=label + "-pointer") - return await_client(lambda value: value["entered"] == 1 and value["local"][0] == 0 - and 1 < value["local"][1] < 32, label + "-actual-enter") + and 7 < value["state"]["pointer_position"][0] < 9 + and 47 < value["state"]["pointer_position"][1] < 49, label=label + "-pointer") + return await_client(lambda value: value["entered"] == 1 and 7 < value["local"][0] < 9 + and 47 < value["local"][1] < 49, label + "-actual-enter") def leave_original(label): - # Actual observed approach within the same 128x128 surface. Large + # Actual observed approach within the same fullscreen surface. Large # acceleration must not silently turn this into another principal. for step in range(4): relative_receipt(20, 20, label + "-step-" + str(step)) position = agent.run("inventory")["state"]["pointer_position"] - if not position or not 0 <= position[0] < 128 or not 0 <= position[1] < 128: + if (not position or not 0 <= position[0] < principal_size[0] + or not 0 <= position[1] < principal_size[1]): raise RuntimeError("native leave lost original principal geometry") if position[0] > 32 and position[1] > 32: constraint_snapshot(label + "-left-both") return raise RuntimeError("genuine native motion did not leave both barriers") + def leave_barriers(label): + # Native motion only: the panel, not the principal (if any), is + # under the corner, so no client relative receipt is required. + for step in range(4): + corner_move(20, 20, relative=True) + position = agent.run("inventory")["state"]["pointer_position"] + if position and position[0] > 32 and position[1] > 32: + corner_observations.append({"label": label + "-left-both", "pointer_position": position}) + return + raise RuntimeError("genuine native motion did not leave both barriers: " + label) + def fullscreen_corner_negative(label): + # GNOME's inFullscreen guard: genuine native pressure at the corner + # over the focused fullscreen principal never opens the overview. + corner_move(-2000, 0, relative=True) + corner_move(0, -2000, relative=True) + corner_move(-200, 0, relative=True) + for index in range(10): + if agent.run("inventory")["state"]["overview_open"]: + raise RuntimeError("hot corner opened over the fullscreen principal") + time.sleep(0.05) + observe_corner(label, False) + if constraint_snapshot(label + "-principal")["fullscreen"] != 1: + raise RuntimeError("original principal left fullscreen during the corner negative") + # Genuine native leaving resets both held barriers. + leave_barriers(label + "-leave") def genuine_positive(label): - enter_original(label) - relative_receipt(-200, 0, label) + # The principal has quit: genuine native crossing into the corner + # opens the overview again. + corner_move(-2000, 0, relative=True) + corner_move(0, -2000, relative=True) observe_corner(label, True) qmp.keys("esc") observe_corner(label + "-escape-close", False) - # Genuine native leaving resets both held barriers. No absolute - # warp can serve as the rearm evidence after destruction. - leave_original(label + "-leave") + leave_barriers(label + "-leave") if older_guest: # No pointer instrumentation exists to observe, so the constraint # proof cannot run; no constraint principal is ever started. @@ -2186,12 +2224,16 @@ def run_lifecycle(qmp, agent, out, serial_path, record, strip_image=None, reconn started = agent.run("constraint-start") principal = started["principal"] constraint_observations.append({"label":"original-start", **started}) - await_client(lambda value: value["configured"] > 0 and all(value["globals"].values()) - and value["buffer_size"] == [128, 128] and value["configure_events"] - and all(event["chosen"] == [128, 128] - and all(dimension in (0, 128) for dimension in event["raw"]) - for event in value["configure_events"]), "actual-layer-admission") - genuine_positive("constraint-original-unconstrained-positive") + admitted = await_client(lambda value: value["configured"] > 0 and all(value["globals"].values()) + and value["fullscreen"] == 1 and value["configure_events"] + and value["buffer_size"] == value["configure_events"][-1]["chosen"] + and all(dimension >= 128 for dimension in value["buffer_size"]) + and all(event["chosen"] == [dimension or 128 for dimension in event["raw"]] + for event in value["configure_events"]), "actual-fullscreen-admission") + principal_size = admitted["buffer_size"] + enter_original("constraint-original-unconstrained") + relative_receipt(-200, 0, "constraint-original-unconstrained") + fullscreen_corner_negative("constraint-original-fullscreen-corner-negative") for mode in ("lock", "confine"): original = enter_original(mode + "-principal") start_position = agent.run("inventory")["state"]["pointer_position"] @@ -2215,7 +2257,7 @@ def run_lifecycle(qmp, agent, out, serial_path, record, strip_image=None, reconn raise RuntimeError("real locked pointer moved") if mode == "confine" and (not position or not 0 <= position[0] < 64 or not 0 <= position[1] < 64 or not 0 <= actual["local"][0] < 64 or not 0 <= actual["local"][1] < 64): - raise RuntimeError("real confined pointer escaped its committed layer region") + raise RuntimeError("real confined pointer escaped its committed region") time.sleep(0.05) frame(mode + "-actual-constrained-negative") # Real committed empty region causes observed deactivation; @@ -2225,14 +2267,13 @@ def run_lifecycle(qmp, agent, out, serial_path, record, strip_image=None, reconn relative_receipt(-200, 0, mode + "-empty-region-motion") await_client(lambda value: value["unlocked" if mode == "lock" else "unconfined"] > deactivated_count, mode + "-actual-deactivation") - # Empty regions no longer own motion. Preserve any actual - # unconstrained activation, close it with balanced real input, - # and observe the same original surface before restoring. + # Empty regions no longer own motion. The guard keeps the + # corner closed over the fullscreen principal; observe the + # same original surface before restoring. empty_state = agent.run("inventory") constraint_snapshot(mode + "-deactivated-desktop") if empty_state["state"]["overview_open"]: - qmp.keys("esc") - observe_corner(mode + "-empty-overview-close", False) + raise RuntimeError("hot corner opened over the fullscreen principal") enter_original(mode + "-persistent-original-reenter") active_count = activated["locked" if mode == "lock" else "confined"] constraint_snapshot(mode + "-restore-region-commit", "restore") @@ -2242,23 +2283,25 @@ def run_lifecycle(qmp, agent, out, serial_path, record, strip_image=None, reconn destroyed = constraint_snapshot(mode + "-destroy-roundtrip", "destroy") if destroyed["constraint_id"] or destroyed["mode"]: raise RuntimeError("original constraint survived real destruction") - # Entirely relative leave, approach and crossing: unchanged - # original surface receives genuine native payloads again. + # Entirely relative leave and approach: the unchanged + # original surface receives genuine native payloads again, + # and the corner stays closed over it. leave_original(mode + "-destroyed-leave") relative_receipt(-2000, 0, mode + "-relative-left-edge") - relative_receipt(0, -2000, mode + "-relative-cross-positive") - observe_corner(mode + "-destroyed-original-positive", True) - qmp.keys("esc") - observe_corner(mode + "-destroyed-escape-close", False) - leave_original(mode + "-post-positive-leave") + fullscreen_corner_negative(mode + "-destroyed-fullscreen-corner-negative") + constraint_snapshot("constraint-principal-quit", "quit") + principal_quit = True + for name in ("unconstrained", "lock-destroyed", "confine-destroyed"): + genuine_positive("constraint-released-" + name + "-positive") record("V-HOT-CORNER-CONSTRAINTS", True, - "same actual session layer principal receives relative payloads; real persistent lock/confine activates, suppresses pressure, deactivates/reactivates on committed region updates, and destruction restores actual native pressure") + "same actual fullscreen session principal receives relative payloads; real persistent lock/confine activates, keeps the pointer and overview still, deactivates/reactivates on committed region updates; GNOME's fullscreen guard keeps the corner closed over the principal before constraints and after each destruction, and genuine native pressure opens it once the principal quits") except Exception as error: constraint_primary_error = {"type": type(error).__name__, "message": str(error)} raise finally: finish_constraint_proof(agent, qmp, original_mouse, evidence, - {"principal": principal, "observations": constraint_observations, + {"principal": principal, "principal_quit": principal_quit, + "observations": constraint_observations, "devices": mice, "relative_inputs": corner_inputs, "primary_error": constraint_primary_error}) @@ -3808,10 +3851,13 @@ def finish_orca_activation(agent, evidence, observations): def finish_constraint_proof(agent, qmp, original_mouse, evidence, report): """Retain all cleanup outcomes without replacing an original proof error.""" cleanup_errors = [] - try: - agent.run("constraint-quit") - except Exception as error: - cleanup_errors.append({"action": "constraint-quit", "message": str(error)}) + # The proof itself quits the fullscreen principal before its pressure + # positives; quitting it again would only fail on a gone process. + if not report.get("principal_quit"): + try: + agent.run("constraint-quit") + except Exception as error: + cleanup_errors.append({"action": "constraint-quit", "message": str(error)}) try: restored = restore_corner_mouse(qmp, original_mouse) with open(os.path.join(evidence, "hot-corner-constraint-mouse-restoration.json"), "w") as stream: