aboutsummaryrefslogtreecommitdiff
path: root/src/protocol
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-02 02:45:20 +0300
committerGitHub <noreply@github.com>2026-09-02 02:45:20 +0300
commitcc88b3b8e5bb9f7d9f23ed6ae85a52fd7b5b9ed6 (patch)
tree4d37c760099070f1a1488b61c7b7271875aafef8 /src/protocol
parent18e69891dca486d669a584facd80644bb51f54a2 (diff)
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
Diffstat (limited to 'src/protocol')
-rw-r--r--src/protocol/endpoint.rs275
-rw-r--r--src/protocol/mod.rs1
-rw-r--r--src/protocol/wire.rs337
3 files changed, 605 insertions, 8 deletions
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<String>,
+ #[serde(default)]
+ pub surface_codecs: Vec<String>,
+ #[serde(default)]
+ pub input_codecs: Vec<String>,
+ #[serde(default)]
+ pub blob_codecs: Vec<String>,
+}
+
+#[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<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub error: Option<EndpointHandshakeError>,
+}
+
+pub fn snapshot_message(snapshot: &ClientShellSnapshot) -> serde_json::Result<ServerMessage> {
+ 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<String>) -> 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<String>) -> 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::<serde_json::Value>(&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<crate::api::schema::AgentStatus, D::Error>
+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<crate::config::CustomCommandAction> for ClientShellCommandAction {
@@ -934,13 +969,16 @@ impl From<crate::config::CustomCommandAction> for ClientShellCommandAction {
}
}
-impl From<ClientShellCommandAction> for crate::config::CustomCommandAction {
- fn from(action: ClientShellCommandAction) -> Self {
+impl TryFrom<ClientShellCommandAction> for crate::config::CustomCommandAction {
+ type Error = ();
+
+ fn try_from(action: ClientShellCommandAction) -> Result<Self, Self::Error> {
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<ClientShellWorktree>,
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<String>,
pub terminal_title: Option<String>,
pub terminal_title_stripped: Option<String>,
+ #[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 ----
@@ -1697,6 +1758,29 @@ mod tests {
}
#[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 {
cell_width_px: 8,
@@ -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]
@@ -1806,6 +1894,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),
position: ClientMousePosition::Cell { column: 10, row: 5 },
@@ -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]