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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-15 19:02:53 +02:00
parent 7da2f34b94
commit 0fff640d15
No known key found for this signature in database
2 changed files with 131 additions and 15 deletions

View File

@ -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<FrontendId, crate::semantic_render::SemanticRenderState>,
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),

View File

@ -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(),