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:
parent
7da2f34b94
commit
0fff640d15
137
src/daemon.rs
137
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<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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue