From cc88b3b8e5bb9f7d9f23ed6ae85a52fd7b5b9ed6 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Wed, 2 Sep 2026 02:45:20 +0300 Subject: feat: add stable client endpoint compatibility (#3509) * feat: add stable client endpoint compatibility * fix: keep portable-pty patch checks composable * ci: run maintenance checks in pull requests * fix: reject endpoint errors from terminal clients * feat: keep compatible servers running across updates --- src/protocol/endpoint.rs | 275 ++++++++++++++++++++++++++++++++++++++ src/protocol/mod.rs | 1 + src/protocol/wire.rs | 337 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 605 insertions(+), 8 deletions(-) create mode 100644 src/protocol/endpoint.rs (limited to 'src/protocol') diff --git a/src/protocol/endpoint.rs b/src/protocol/endpoint.rs new file mode 100644 index 00000000..b2955ec8 --- /dev/null +++ b/src/protocol/endpoint.rs @@ -0,0 +1,275 @@ +//! Stable endpoint compatibility contract for client-owned shells. +//! +//! The endpoint generation is intentionally independent from the private +//! binary protocol used by same-install CLI, direct-terminal, and handoff +//! paths. Generation 1 is the compatibility floor for Local, SSH, and Cloud +//! shell endpoints and must remain available indefinitely unless retired for a +//! security reason. New JSON fields must be optional or have serde defaults; +//! new enum values need an `Unknown` fallback. Unknown named controls are +//! optional and ignored unless negotiated as part of the core. + +use serde::{Deserialize, Serialize}; + +use super::{ClientShellSnapshot, ClientSurfaceSize, ServerMessage}; + +pub const ENDPOINT_PROTOCOL_GENERATION: u32 = 1; +pub const ENDPOINT_HELLO_KIND: &str = "endpoint.hello.v1"; +pub const ENDPOINT_WELCOME_KIND: &str = "endpoint.welcome.v1"; +pub const SNAPSHOT_CODEC_V1: &str = "shell.snapshot.v1"; +pub const ENDPOINT_SNAPSHOT_KIND: &str = SNAPSHOT_CODEC_V1; +pub const SURFACE_CODEC_V1: &str = "shell.surface.v1"; +pub const INPUT_CODEC_V1: &str = "shell.input.semantic.v1"; +pub const BLOB_CODEC_V1: &str = "shell.blob.v1"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EndpointClientHello { + pub generation: u32, + pub cell_width_px: u32, + pub cell_height_px: u32, + pub surface_size: ClientSurfaceSize, + pub pixel_mouse: bool, + pub direct_graphics: bool, + pub endpoint_keybindings: bool, + pub mouse_capture: bool, + #[serde(default)] + pub snapshot_codecs: Vec, + #[serde(default)] + pub surface_codecs: Vec, + #[serde(default)] + pub input_codecs: Vec, + #[serde(default)] + pub blob_codecs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EndpointHandshakeError { + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EndpointServerWelcome { + pub generation: u32, + pub server_version: String, + pub snapshot_codec: String, + pub surface_codec: String, + pub input_codec: String, + pub blob_codec: String, + #[serde(default)] + pub methods: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +pub fn snapshot_message(snapshot: &ClientShellSnapshot) -> serde_json::Result { + Ok(ServerMessage::EndpointControl { + kind: ENDPOINT_SNAPSHOT_KIND.into(), + data: serde_json::to_string(snapshot)?, + }) +} + +impl EndpointClientHello { + pub fn supports_required_codecs(&self) -> bool { + self.snapshot_codecs + .iter() + .any(|codec| codec == SNAPSHOT_CODEC_V1) + && self + .surface_codecs + .iter() + .any(|codec| codec == SURFACE_CODEC_V1) + && self + .input_codecs + .iter() + .any(|codec| codec == INPUT_CODEC_V1) + && self.blob_codecs.iter().any(|codec| codec == BLOB_CODEC_V1) + } +} + +impl EndpointServerWelcome { + pub fn compatible(methods: Vec) -> Self { + Self { + generation: ENDPOINT_PROTOCOL_GENERATION, + server_version: crate::build_info::version(), + snapshot_codec: SNAPSHOT_CODEC_V1.into(), + surface_codec: SURFACE_CODEC_V1.into(), + input_codec: INPUT_CODEC_V1.into(), + blob_codec: BLOB_CODEC_V1.into(), + methods, + error: None, + } + } + + pub fn incompatible(code: &str, message: impl Into) -> Self { + Self { + generation: ENDPOINT_PROTOCOL_GENERATION, + server_version: crate::build_info::version(), + snapshot_codec: SNAPSHOT_CODEC_V1.into(), + surface_codec: SURFACE_CODEC_V1.into(), + input_codec: INPUT_CODEC_V1.into(), + blob_codec: BLOB_CODEC_V1.into(), + methods: Vec::new(), + error: Some(EndpointHandshakeError { + code: code.into(), + message: message.into(), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hello() -> EndpointClientHello { + EndpointClientHello { + generation: ENDPOINT_PROTOCOL_GENERATION, + cell_width_px: 8, + cell_height_px: 16, + surface_size: ClientSurfaceSize { cols: 80, rows: 24 }, + pixel_mouse: true, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: true, + snapshot_codecs: vec![SNAPSHOT_CODEC_V1.into()], + surface_codecs: vec![SURFACE_CODEC_V1.into()], + input_codecs: vec![INPUT_CODEC_V1.into()], + blob_codecs: vec![BLOB_CODEC_V1.into()], + } + } + + fn snapshot() -> ClientShellSnapshot { + ClientShellSnapshot { + boot_id: "boot".into(), + revision: 1, + config_diagnostic: None, + product_announcement: None, + update_available: None, + update_install_command: "herdr update".into(), + server_keybindings_toml: None, + latest_release_notes_available: false, + integration_updates_available: false, + worktree_directory: String::new(), + release_notes: None, + focused_workspace_id: None, + focused_tab_id: None, + focused_pane_id: None, + tab_bar_right: Vec::new(), + tab_bar_right_separator: String::new(), + agent_view_label: None, + agent_order: Vec::new(), + workspaces: Vec::new(), + tabs: Vec::new(), + panes: Vec::new(), + agents: Vec::new(), + commands: Vec::new(), + } + } + + #[test] + fn hello_ignores_future_named_fields() { + let mut value = serde_json::to_value(hello()).unwrap(); + value["future_feature"] = serde_json::json!({"enabled": true}); + let decoded: EndpointClientHello = serde_json::from_value(value).unwrap(); + assert_eq!(decoded, hello()); + } + + #[test] + fn frozen_generation_one_handshake_decodes() { + let hello: EndpointClientHello = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/endpoint-hello-v1.json" + ))) + .unwrap(); + assert!(hello.supports_required_codecs()); + + let welcome: EndpointServerWelcome = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/endpoint-welcome-v1.json" + ))) + .unwrap(); + assert_eq!(welcome.generation, ENDPOINT_PROTOCOL_GENERATION); + assert_eq!(welcome.snapshot_codec, SNAPSHOT_CODEC_V1); + assert_eq!(welcome.surface_codec, SURFACE_CODEC_V1); + assert_eq!(welcome.input_codec, INPUT_CODEC_V1); + assert_eq!(welcome.blob_codec, BLOB_CODEC_V1); + } + + #[test] + fn frozen_generation_one_snapshot_decodes() { + let snapshot: ClientShellSnapshot = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/endpoint-snapshot-v1.json" + ))) + .unwrap(); + assert_eq!(snapshot.boot_id, "boot-v1"); + assert_eq!( + snapshot.workspaces[0].agent_status, + crate::api::schema::AgentStatus::Unknown + ); + } + + #[test] + fn snapshot_message_uses_named_json_control() { + let snapshot = snapshot(); + let ServerMessage::EndpointControl { kind, data } = snapshot_message(&snapshot).unwrap() + else { + panic!("snapshot should use endpoint control"); + }; + assert_eq!(kind, ENDPOINT_SNAPSHOT_KIND); + let decoded: ClientShellSnapshot = serde_json::from_str(&data).unwrap(); + assert_eq!(decoded, snapshot); + } + + #[test] + fn snapshot_json_tolerates_future_fields_and_command_actions() { + let mut snapshot = match snapshot_message(&snapshot()).unwrap() { + ServerMessage::EndpointControl { data, .. } => { + serde_json::from_str::(&data).unwrap() + } + _ => unreachable!(), + }; + snapshot["future_projection"] = serde_json::json!({"enabled": true}); + snapshot["commands"] = serde_json::json!([{ + "command_id": "future", + "binding_label": "x", + "binding_labels": ["x"], + "action": "FutureAction", + "description": null + }]); + + let decoded: ClientShellSnapshot = serde_json::from_value(snapshot).unwrap(); + assert_eq!( + decoded.commands[0].action, + crate::protocol::ClientShellCommandAction::Unknown + ); + } + + #[test] + fn required_codecs_are_explicit() { + let mut value = hello(); + assert!(value.supports_required_codecs()); + value.snapshot_codecs.clear(); + assert!(!value.supports_required_codecs()); + + let mut value = hello(); + value.surface_codecs.clear(); + assert!(!value.supports_required_codecs()); + + let mut value = hello(); + value.input_codecs.clear(); + assert!(!value.supports_required_codecs()); + + let mut value = hello(); + value.blob_codecs.clear(); + assert!(!value.supports_required_codecs()); + } + + #[test] + fn welcome_ignores_future_named_fields() { + let welcome = EndpointServerWelcome::compatible(vec!["pane.close".into()]); + let mut value = serde_json::to_value(&welcome).unwrap(); + value["future_service"] = serde_json::json!("v2"); + let decoded: EndpointServerWelcome = serde_json::from_value(value).unwrap(); + assert_eq!(decoded, welcome); + } +} diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 0cdeecbb..7ee6e8de 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -1,5 +1,6 @@ //! Shared wire protocol and presentation encoding code. +pub mod endpoint; pub(crate) mod render_ansi; mod wire; diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index be084aa6..cbca829a 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -1,7 +1,11 @@ //! Wire protocol for herdr server/client communication. //! //! Defines the message types, framing, version negotiation, and safety -//! constraints for the binary protocol over Unix domain sockets. +//! constraints for the binary protocol over local sockets. +//! +//! `PROTOCOL_VERSION` still guards same-install direct-terminal and internal +//! operations. Client-owned shells negotiate the independent stable endpoint +//! contract in [`super::endpoint`]. use std::collections::HashMap; use std::io::{self, Read, Write}; @@ -449,6 +453,9 @@ impl ClientInputEvent { } /// Messages sent from the client to the server over the client protocol socket. +/// +/// Variant order is frozen for endpoint generation 1. Add compatible endpoint +/// behavior through `EndpointControl` or advertised API methods, not new enum variants. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientMessage { /// Direct terminal handshake: announces protocol version and terminal dimensions. @@ -597,6 +604,12 @@ pub enum ClientMessage { /// Update this client's shell mouse-capture preference after config reload. ClientShellMouseCapture { enabled: bool }, + + /// Extensible named control message for the stable client-owned endpoint protocol. + /// + /// This variant is append-only. Its bincode tag and two-string payload are part + /// of endpoint generation 1 and must not change. + EndpointControl { kind: String, data: String }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -860,7 +873,26 @@ impl FrameData { } } -/// Initial resource projection used by the experimental client-owned shell. +fn deserialize_client_shell_agent_status<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + if !deserializer.is_human_readable() { + return crate::api::schema::AgentStatus::deserialize(deserializer); + } + let value = String::deserialize(deserializer)?; + Ok(match value.as_str() { + "idle" => crate::api::schema::AgentStatus::Idle, + "working" => crate::api::schema::AgentStatus::Working, + "blocked" => crate::api::schema::AgentStatus::Blocked, + "done" => crate::api::schema::AgentStatus::Done, + _ => crate::api::schema::AgentStatus::Unknown, + }) +} + +/// Initial resource projection used by the stable client-owned shell. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ClientShellSnapshot { /// Changes whenever the endpoint process restarts. @@ -921,6 +953,9 @@ pub enum ClientShellCommandAction { Pane, Popup, PluginAction, + /// A future endpoint action kind that this client cannot execute. + #[serde(other)] + Unknown, } impl From for ClientShellCommandAction { @@ -934,13 +969,16 @@ impl From for ClientShellCommandAction { } } -impl From for crate::config::CustomCommandAction { - fn from(action: ClientShellCommandAction) -> Self { +impl TryFrom for crate::config::CustomCommandAction { + type Error = (); + + fn try_from(action: ClientShellCommandAction) -> Result { match action { - ClientShellCommandAction::Shell => Self::Shell, - ClientShellCommandAction::Pane => Self::Pane, - ClientShellCommandAction::Popup => Self::Popup, - ClientShellCommandAction::PluginAction => Self::PluginAction, + ClientShellCommandAction::Shell => Ok(Self::Shell), + ClientShellCommandAction::Pane => Ok(Self::Pane), + ClientShellCommandAction::Popup => Ok(Self::Popup), + ClientShellCommandAction::PluginAction => Ok(Self::PluginAction), + ClientShellCommandAction::Unknown => Err(()), } } } @@ -973,6 +1011,7 @@ pub struct ClientShellWorkspace { pub tokens: Vec<(String, String)>, pub worktree: Option, pub focused: bool, + #[serde(deserialize_with = "deserialize_client_shell_agent_status")] pub agent_status: crate::api::schema::AgentStatus, } @@ -992,6 +1031,7 @@ pub struct ClientShellTab { pub custom_label: bool, pub zoomed: bool, pub focused: bool, + #[serde(deserialize_with = "deserialize_client_shell_agent_status")] pub agent_status: crate::api::schema::AgentStatus, } @@ -1018,6 +1058,7 @@ pub struct ClientShellAgent { pub title: Option, pub terminal_title: Option, pub terminal_title_stripped: Option, + #[serde(deserialize_with = "deserialize_client_shell_agent_status")] pub agent_status: crate::api::schema::AgentStatus, pub state_change_seq: u64, pub state_labels: Vec<(String, String)>, @@ -1271,6 +1312,9 @@ pub struct SemanticNotification { } /// Messages sent from the server to the client over the client protocol socket. +/// +/// Variant order is frozen for endpoint generation 1. Add compatible endpoint +/// behavior through `EndpointControl`, and ignore unrecognized named controls. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ServerMessage { /// Handshake response: server acknowledges (or rejects) the client. @@ -1386,6 +1430,12 @@ pub enum ServerMessage { /// Incremental terminal-cell update for a previously committed pane surface. PaneSurfacePatch(PaneSurfacePatch), + + /// Extensible named control message for the stable client-owned endpoint protocol. + /// + /// This variant is append-only. Its bincode tag and two-string payload are part + /// of endpoint generation 1 and must not change. + EndpointControl { kind: String, data: String }, } // --------------------------------------------------------------------------- @@ -1659,6 +1709,17 @@ pub fn check_client_version(client_version: u32) -> VersionCheck { mod tests { use super::*; use ratatui::style::{Color, Modifier}; + use sha2::{Digest, Sha256}; + + fn encoded_sha256(value: &impl Serialize) -> String { + let encoded = bincode::serde::encode_to_vec(value, bincode::config::standard()).unwrap(); + format!("{:x}", Sha256::digest(encoded)) + } + + // These digests freeze representative generation-1 bincode payloads. A mismatch + // requires a new named codec; do not update a v1 digest to bless a wire change. + // They do not detect appended enum variants, so every type reachable from a v1 + // payload is also append-closed. // ---- Round-trip: ClientMessage ---- @@ -1696,6 +1757,29 @@ mod tests { assert_eq!(msg, decoded); } + #[test] + fn endpoint_control_roundtrip() { + let msg = ClientMessage::EndpointControl { + kind: "endpoint.hello.v1".into(), + data: r#"{"generation":1}"#.into(), + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + assert_eq!( + bincode::serde::encode_to_vec( + ClientMessage::EndpointControl { + kind: String::new(), + data: String::new(), + }, + bincode::config::standard(), + ) + .unwrap(), + [20, 0, 0] + ); + } + #[test] fn client_shell_resize_roundtrip() { let msg = ClientMessage::ClientShellResize { @@ -1708,6 +1792,10 @@ mod tests { let (decoded, _): (ClientMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); + assert_eq!( + encoded_sha256(&msg), + "676d6376202750e72c45ff511e256b6154d3d20c0ee088d3792fa3a69d9704b9" + ); } #[test] @@ -1805,6 +1893,51 @@ mod tests { }), 8 ); + assert_eq!( + tag(&ClientMessage::GraphicsTransmissionResult { + transfer_id: 1, + image_id: 2, + success: true, + }), + 9 + ); + assert_eq!( + tag(&ClientMessage::GraphicsTransmissionStarted { + transfer_id: 1, + image_id: 2, + }), + 10 + ); + assert_eq!( + tag(&ClientMessage::ClientShellResize { + cell_width_px: 8, + cell_height_px: 16, + surface_size: ClientSurfaceSize { cols: 80, rows: 29 }, + pixel_mouse: false, + }), + 12 + ); + assert_eq!( + tag(&ClientMessage::ClientShellPaneInput { + pane_id: "pane".into(), + events: Vec::new(), + }), + 13 + ); + assert_eq!( + tag(&ClientMessage::ClientShellPopupInput { + terminal_id: "popup".into(), + events: Vec::new(), + }), + 14 + ); + assert_eq!( + tag(&ClientMessage::ClientShellEndpointRequest { + boot_id: "boot".into(), + request: "{}".into(), + }), + 15 + ); assert_eq!( tag(&ClientMessage::AttachMouse { kind: ClientMouseKind::Down(ClientMouseButton::Left), @@ -1826,6 +1959,13 @@ mod tests { tag(&ClientMessage::ClientShellMouseCapture { enabled: true }), 19 ); + assert_eq!( + tag(&ClientMessage::EndpointControl { + kind: String::new(), + data: String::new(), + }), + 20 + ); } #[test] @@ -1871,6 +2011,10 @@ mod tests { bincode::serde::decode_from_slice(&encoded, bincode::config::standard()) .expect("decode targeted semantic input"); assert_eq!(decoded, message); + assert_eq!( + encoded_sha256(&message), + "f558384bb53dfd2baf1fa72e1709d88be6891da79e51f88513905afc085065e6" + ); let ClientMessage::ClientShellPaneInput { events, .. } = decoded else { panic!("expected targeted semantic input"); }; @@ -1977,6 +2121,10 @@ mod tests { bincode::serde::decode_from_slice(&encoded, bincode::config::standard()) .expect("decode endpoint request"); assert_eq!(decoded, request); + assert_eq!( + encoded_sha256(&request), + "de5693585a01f6b0d5ee07c51b6ddf79ee9f67dbf183255822d31f35210f5ffb" + ); let response = ServerMessage::ClientShellEndpointResponseChunk { boot_id: "boot-a".into(), @@ -1990,7 +2138,12 @@ mod tests { bincode::serde::decode_from_slice(&encoded, bincode::config::standard()) .expect("decode endpoint response"); assert_eq!(decoded, response); + assert_eq!( + encoded_sha256(&response), + "bc14dbb5263d3097fe6d3e70a4b6d71aa9c2fa4ae3206d692a7182512bffdd1d" + ); } + #[test] fn client_clipboard_image_roundtrip() { let msg = ClientMessage::ClipboardImage { @@ -2002,6 +2155,10 @@ mod tests { let (decoded, _): (ClientMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); + assert_eq!( + encoded_sha256(&msg), + "1c02110be0671faf318b3f4d8f507748d5a0a98cd474832669c5290d97ef19ab" + ); } #[test] @@ -2203,6 +2360,10 @@ mod tests { let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); + assert_eq!( + encoded_sha256(&msg), + "7c016f7b21ddb5ac79212cf65a968b93eb292b5305b941263e89ffaa40158ee3" + ); match decoded { ServerMessage::PaneSurface(surface) => { assert_eq!(surface.frame.cells[2].hyperlink, Some(0)); @@ -2246,6 +2407,162 @@ mod tests { let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(decoded, msg); + assert_eq!( + encoded_sha256(&msg), + "0814b99a1dc6eaf7918424aa416c066509cbfb73b72344a809c27cde78cb6dbd" + ); + } + + #[test] + fn client_shell_graphics_payload_codec_is_frozen() { + let key = SurfaceGraphicsAssetKey { + source: SurfaceGraphicsSource::Terminal { + target: SurfaceGraphicsTarget::Pane { + pane_id: "w1:p1".into(), + }, + image_id: 7, + }, + image_width: 2, + image_height: 1, + format: SurfaceGraphicsFormat::Rgba, + data_len: 8, + data_fingerprint: 42, + }; + let message = ServerMessage::PaneSurface(PaneSurfaceFrame { + boot_id: "boot-1".into(), + projection_revision: 2, + surface_revision: 3, + frame: FrameData { + cells: Vec::new(), + width: 0, + height: 0, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }, + panes: Vec::new(), + splits: Vec::new(), + popup: None, + graphics: SurfaceGraphicsScene { + assets: vec![SurfaceGraphicsAsset { + key: key.clone(), + data: vec![255, 0, 0, 255, 0, 255, 0, 255], + }], + placements: vec![SurfaceGraphicsPlacement { + asset: key, + logical_placement_id: 9, + x: 1, + y: 2, + cols: 2, + rows: 1, + source_x: 0, + source_y: 0, + source_width: 2, + source_height: 1, + x_offset: 0, + y_offset: 0, + z: -1, + scrollback_offset: 0, + }], + retained_assets: Vec::new(), + }, + }); + + assert_eq!( + encoded_sha256(&message), + "49c4efec0f1456c8ca4112ddf6ead1ab75d0224007576c2ccc18c3fca55a69f0" + ); + } + + #[test] + fn server_endpoint_control_tag_is_frozen() { + let message = ServerMessage::EndpointControl { + kind: "endpoint.welcome.v1".into(), + data: r#"{"generation":1}"#.into(), + }; + let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap(); + assert_eq!(encoded.first(), Some(&20)); + assert_eq!( + bincode::serde::encode_to_vec( + ServerMessage::EndpointControl { + kind: String::new(), + data: String::new(), + }, + bincode::config::standard(), + ) + .unwrap(), + [20, 0, 0] + ); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(decoded, message); + } + + #[test] + fn client_shell_server_message_tags_are_frozen() { + fn tag(message: &ServerMessage) -> u8 { + *bincode::serde::encode_to_vec(message, bincode::config::standard()) + .unwrap() + .first() + .expect("encoded server message should include enum tag") + } + + let empty_frame = || PaneSurfaceFrame { + boot_id: "boot".into(), + projection_revision: 1, + surface_revision: 1, + frame: FrameData { + cells: Vec::new(), + width: 0, + height: 0, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }, + panes: Vec::new(), + splits: Vec::new(), + popup: None, + graphics: SurfaceGraphicsScene::default(), + }; + assert_eq!(tag(&ServerMessage::PaneSurface(empty_frame())), 13); + assert_eq!( + tag(&ServerMessage::ClientShellError { + message: String::new(), + }), + 15 + ); + assert_eq!( + tag(&ServerMessage::ClientShellKeyboardReportAll { enabled: false }), + 17 + ); + assert_eq!( + tag(&ServerMessage::ClientShellEndpointResponseChunk { + boot_id: String::new(), + request_id: String::new(), + final_chunk: true, + data: Vec::new(), + }), + 18 + ); + assert_eq!( + tag(&ServerMessage::PaneSurfacePatch(PaneSurfacePatch { + boot_id: String::new(), + projection_revision: 0, + base_surface_revision: 0, + surface_revision: 0, + rows: Vec::new(), + panes: Vec::new(), + cursor: None, + })), + 19 + ); + assert_eq!( + tag(&ServerMessage::EndpointControl { + kind: String::new(), + data: String::new(), + }), + 20 + ); } #[test] @@ -2411,6 +2728,10 @@ mod tests { let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); + assert_eq!( + encoded_sha256(&msg), + "28a420f92e0e05e6760a8c140baf307c360c6d1b1aa68027481b324f87e22c44" + ); } #[test] -- cgit v1.2.3