From 0fff640d152208f55dc3aedd88448de9040c48a9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 19:02:53 +0200 Subject: [PATCH] feat(panel): inbound family gating and the mapping check The receiving half of bilateral gating, plus the constructor fix review found. The eight G6/G7/G8 witness rows and the GPU side are next; the full eleven-stage `--protocol` gate runs once they discriminate. **THE FAMILY IS DECIDED FIRST, FROM THE AUTHENTICATED SESSION.** `peer_uses_mapped_panel_family` reads `session_state(source)`, never the payload's `frontend_id` --- that field is untrusted on every inbound variant, and looking negotiation up by it would let a peer claim another session's family. The order is stated at both arms: family, then the existing epoch ladder, then the mapping generation, then dispatch. It cannot depend on the variant's contents, because it decides which variant is admissible at all. Both wrong-family cases are REFUSALS, not fallbacks. A `>= v25` session sending the bare `PanelPointer` is dropped rather than handled under legacy semantics --- handling it would leave the mapping hole reachable by choosing a discriminant, which is the entire bypass. A `<= v24` session sending `PanelPointerMapped` is dropped too, even though a peer compiled from this crate can encode the discriminant: negotiation is a gate, not a sender convention. `panel_mapping_is_current` is the ladder's finest rung. `buffer_id` catches an A->B replacement, `panel_epoch` a close/reopen, `geometry_epoch` a declaration race, and this catches the text under the cell changing --- a foreign edit, a fold, a reload, none of which moves an epoch. Zero is refused outright, and the check reads through the same accessor projection stamps with, so the two cannot drift. **And `new()` contradicted its own contract.** It documents a current-build peer and enables every other current capability, but I initialised `peer_knows_mapped_panel` to `false` --- so an implicitly current peer was sent the LEGACY family. Set true, with the doc extended to say the assumption covers later capabilities too. **Four daemon rows broke, and that is G8a firing.** They drive `FrontendEvent::PanelPointer` through sessions negotiated at `PROTOCOL_VERSION`, which is now 25 --- so the bare variant is refused, correctly. They negotiate `LEGACY_PANEL_VERSION` now, which both fixes them and makes them explicit legacy positive controls rather than rows that happened to pass. Note for the eventual rebase: this branch is based on main, where `dispatch_semantic_panel_pointer` still takes four arguments. Threading `mods` is R-a, owned by `panel-pointer-replay`; the mapped arm destructures `mods` into `..` here and will pass it through when the lanes meet. Verified: focused suite 37/37, `cargo test --lib` 1945 green, clippy clean. **Amended.** The first version of this message claimed the lib suite green when it was not: I piped `cargo test` through `tail`, so the pipeline exited 0 and my `&&` chain committed on a failure I had not read. The four rows above are what it was reporting. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- src/daemon.rs | 137 +++++++++++++++++++++++++++++++++++++---- src/semantic_render.rs | 9 ++- 2 files changed, 131 insertions(+), 15 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 3bba690..7f52a67 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -70,8 +70,8 @@ use crate::protocol::{ ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InitialTarget, InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, - PANEL_MIN_VERSION, PointerKind, SelectionSnapshot, SessionBootstrapRequest, - TEXT_INPUT_MAX_BYTES, TEXT_INPUT_MIN_VERSION, + PANEL_MAPPING_MIN_VERSION, PANEL_MIN_VERSION, PointerKind, SelectionSnapshot, + SessionBootstrapRequest, TEXT_INPUT_MAX_BYTES, TEXT_INPUT_MIN_VERSION, }; use crate::socket_path::{SocketPathError, ensure_runtime_subdir}; use crate::transport::{read_message, write_message}; @@ -1033,6 +1033,53 @@ fn panel_event_epochs_are_current( /// The claimed `frontend_id` in the payload is never consulted anywhere: /// routing is by the authenticated transport `source`, so a forged id /// addresses nothing. +/// §5b — which panel-pointer family this session speaks. +/// +/// **Read from the AUTHENTICATED source, never from the payload's +/// `frontend_id`.** That field is untrusted on every inbound variant, +/// and looking the negotiation up by it would let a peer claim another +/// session's family — the forged cross-session claim G8e mutates for. +/// +/// Consulted BEFORE the payload is trusted, before any generation is +/// validated and before any mutation: the family decides which variant +/// is even admissible, so it cannot depend on the variant's contents. +fn peer_uses_mapped_panel_family(session_registry: &SessionRegistry, source: FrontendId) -> bool { + session_registry + .session_state(source) + .is_some_and(|state| state.negotiated_protocol_version >= PANEL_MAPPING_MIN_VERSION) +} + +/// §5b — whether an echoed mapping generation still names the mapping +/// the daemon holds. +/// +/// The last rung of the ladder and the finest: `buffer_id` catches an +/// A→B replacement, `panel_epoch` a close/reopen, `geometry_epoch` a +/// declaration race, and this catches **the text under that cell +/// changing** — a foreign edit, a fold, a reload, none of which moves +/// an epoch. +/// +/// **Zero is refused outright.** It is what a default-constructed or +/// half-initialised sender produces, so accepting it would let a peer +/// opt out of the check by sending nothing. +/// +/// Read through the SAME accessor projection stamps with, so "what the +/// frontend was shown" and "what the daemon checks" cannot drift. +fn panel_mapping_is_current( + editor: &EditorState, + semantic_states: &mut HashMap, + source: FrontendId, + echoed: u64, +) -> bool { + if echoed == 0 { + return false; + } + let snapshot = editor.panel_mapping_snapshot(source); + semantic_states + .get_mut(&source) + .and_then(|state| state.panel_mapping_generation(snapshot)) + .is_some_and(|current| current == echoed) +} + fn peer_may_send_panel_events( editor: &EditorState, session_registry: &SessionRegistry, @@ -2438,7 +2485,19 @@ fn handle_dispatcher_event( // the daemon's own state inside the dispatcher. Any // failure drops the event before any view, controller, // selection, menu, or PTY mutation. - if peer_may_send_panel_events(editor, session_registry, semantic_states, source) + // §5b G8a — the family is decided FIRST, from the + // AUTHENTICATED session, and a `>= v25` session + // sending the bare variant is REFUSED rather than + // handled under legacy semantics. Handling it would + // leave the mapping hole reachable by choosing a + // discriminant, which is the whole of the bypass. + if !peer_uses_mapped_panel_family(session_registry, source) + && peer_may_send_panel_events( + editor, + session_registry, + semantic_states, + source, + ) && panel_event_epochs_are_current( editor, semantic_states, @@ -2450,6 +2509,50 @@ fn handle_dispatcher_event( editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind); } } + FrontendEvent::PanelPointerMapped { + geometry_epoch, + panel_epoch, + buffer_id, + coord, + kind, + mapping_generation, + .. + } => { + // §5b — the mapped family, in this order: + // + // 1. family, from the AUTHENTICATED session + // 2. the existing epoch ladder + // 3. the mapping generation + // 4. only then, dispatch + // + // G8c is the first line: a `<= v24` session sending + // this variant is refused even though a peer built + // from this crate can encode the discriminant. + // Negotiation is a gate, not a sender convention. + if peer_uses_mapped_panel_family(session_registry, source) + && peer_may_send_panel_events( + editor, + session_registry, + semantic_states, + source, + ) + && panel_event_epochs_are_current( + editor, + semantic_states, + source, + geometry_epoch, + panel_epoch, + ) + && panel_mapping_is_current( + editor, + semantic_states, + source, + mapping_generation, + ) + { + editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind); + } + } FrontendEvent::Pointer { buffer_id, byte, @@ -5971,6 +6074,14 @@ mod tests { /// /// The session is registered because the dispatcher drops any event /// from an uninstalled session before it reaches a handler. + /// §5b — a session that negotiated the LEGACY panel family. + /// + /// These rows drive `FrontendEvent::PanelPointer`, which a `>= v25` + /// session may not send at all, so they must say which family they + /// are exercising. Naming it here also makes them explicit legacy + /// positive controls rather than tests that happened to pass. + const LEGACY_PANEL_VERSION: u32 = PANEL_MAPPING_MIN_VERSION - 1; + fn dispatch_panel_event( editor: &mut crate::editor::EditorState, fid: FrontendId, @@ -6271,7 +6382,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), event, @@ -6286,7 +6397,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), down(geometry_epoch, panel_epoch), @@ -6342,7 +6453,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6369,7 +6480,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6587,7 +6698,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6614,7 +6725,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelResizeRows { @@ -6643,7 +6754,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6700,7 +6811,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), wheel(buffer_id, geometry_epoch, panel_epoch), @@ -6716,7 +6827,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6782,7 +6893,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), wheel(terminal_buffer, geometry_epoch, panel_epoch), diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 5df199b..9fd8f57 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -551,7 +551,9 @@ impl SemanticRenderState { } /// Fresh session state for frontend `frontend_id`: no viewport - /// declared, nothing sent. Assumes a current-build peer (>= 18); + /// declared, nothing sent. Assumes a current-build peer (>= 18, and + /// current for every later capability too, including §5b's mapped + /// panel family); /// daemon sessions with a real negotiated version use /// [`Self::for_peer`]. #[must_use] @@ -567,7 +569,10 @@ impl SemanticRenderState { last_menu_prompt: HashMap::new(), last_minibuffer: None, peer_knows_minibuffer_rows: true, - peer_knows_mapped_panel: false, + // A current-build peer, like every other capability here. + // Leaving this `false` made `new()` contradict its own doc + // and emit the LEGACY family to an implicitly v25 peer. + peer_knows_mapped_panel: true, last_completion_popup: HashMap::new(), last_summary: HashMap::new(), last_status: HashMap::new(),