use std::io::{self, Read, Write};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::agent::AgentState;
pub const PROTOCOL_VERSION: u32 = 2;
pub const KRAAL_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Feature {
pub name: &'static str,
pub does: &'static str,
pub check_first: bool,
}
pub const LIST_AGENTS: Feature =
Feature { name: "list_agents", does: "list the agents of the sessions on your servers", check_first: false };
pub const BUFFERS: Feature = Feature { name: "buffers", does: "keep paste buffers", check_first: false };
pub const EDIT_SUDO: Feature = Feature { name: "edit_sudo", does: "edit a server's files as root", check_first: true };
pub const FEATURES: [Feature; 3] = [LIST_AGENTS, BUFFERS, EDIT_SUDO];
pub fn feature_names() -> Vec<String> {
FEATURES.iter().map(|f| f.name.to_string()).collect()
}
pub const LENIENT: &str = "lenient";
pub const CLIENT_FEATURES: [&str; 1] = [LENIENT];
pub fn client_features() -> Vec<String> {
CLIENT_FEATURES.iter().map(|f| f.to_string()).collect()
}
pub const UNSUPPORTED: &str = "unsupported request";
pub fn unsupported(msg: &serde_json::Value) -> String {
let name = msg.get("cmd").or_else(|| msg.get("type")).and_then(|n| n.as_str()).unwrap_or("?");
format!("{UNSUPPORTED} `{name}`: kraal {KRAAL_VERSION} does not know it")
}
pub fn refused_as_unknown(error: &str) -> bool {
error.starts_with(UNSUPPORTED) || error.starts_with("bad message:")
}
pub fn newer(a: &str, b: &str) -> bool {
use std::cmp::Ordering;
fn parse(v: &str) -> Option<([u64; 3], Vec<&str>)> {
let v = v.trim();
let v = v.strip_prefix('v').unwrap_or(v);
let v = v.split('+').next().unwrap_or(v);
let (core, pre) = match v.split_once('-') {
Some((core, pre)) => (core, pre.split('.').collect()),
None => (v, Vec::new()),
};
let mut nums = [0u64; 3];
let parts: Vec<&str> = core.split('.').collect();
if parts.len() > 3 {
return None;
}
for (n, p) in nums.iter_mut().zip(parts) {
*n = p.parse().ok()?;
}
Some((nums, pre))
}
let (Some((a_nums, a_pre)), Some((b_nums, b_pre))) = (parse(a), parse(b)) else { return false };
let pre = || {
if a_pre.is_empty() || b_pre.is_empty() {
return b_pre.len().min(1).cmp(&a_pre.len().min(1));
}
for (x, y) in a_pre.iter().zip(&b_pre) {
let order = match (x.parse::<u64>(), y.parse::<u64>()) {
(Ok(x), Ok(y)) => x.cmp(&y),
(Ok(_), Err(_)) => Ordering::Less,
(Err(_), Ok(_)) => Ordering::Greater,
(Err(_), Err(_)) => x.cmp(y),
};
if order != Ordering::Equal {
return order;
}
}
a_pre.len().cmp(&b_pre.len())
};
a_nums.cmp(&b_nums).then_with(pre) == Ordering::Greater
}
pub const JSON: u8 = 0;
pub const BYTES: u8 = 1;
const MAX_FRAME: usize = 64 << 20;
pub fn write_frame(w: &mut impl Write, kind: u8, payload: &[u8]) -> io::Result<()> {
if payload.len() > MAX_FRAME {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "frame too large"));
}
let mut header = [0u8; 5];
header[..4].copy_from_slice(&(payload.len() as u32).to_le_bytes());
header[4] = kind;
if payload.len() <= 4096 {
let mut frame = Vec::with_capacity(header.len() + payload.len());
frame.extend_from_slice(&header);
frame.extend_from_slice(payload);
w.write_all(&frame)?;
} else {
w.write_all(&header)?;
w.write_all(payload)?;
}
w.flush()
}
pub fn write_json<T: Serialize>(w: &mut impl Write, msg: &T) -> io::Result<()> {
let payload = serde_json::to_vec(msg).map_err(io::Error::other)?;
write_frame(w, JSON, &payload)
}
pub fn read_frame(r: &mut impl Read) -> io::Result<Option<(u8, Vec<u8>)>> {
let mut header = [0u8; 5];
match r.read_exact(&mut header) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e),
}
let len = u32::from_le_bytes(header[..4].try_into().unwrap()) as usize;
if len > MAX_FRAME {
return Err(io::Error::new(io::ErrorKind::InvalidData, "frame too large"));
}
let mut payload = vec![0u8; len];
r.read_exact(&mut payload)?;
Ok(Some((header[4], payload)))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMsg {
Attach {
version: u32,
cols: u16,
rows: u16,
workspace: Option<String>,
#[serde(default)]
remote: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pane: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
installed: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
features: Vec<String>,
},
Resize {
cols: u16,
rows: u16,
},
Detach,
Request {
version: u32,
request: Request,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
features: Vec<String>,
},
#[serde(untagged)]
Unknown(serde_json::Value),
}
impl ClientMsg {
pub fn request(request: Request) -> ClientMsg {
ClientMsg::Request { version: PROTOCOL_VERSION, request, features: client_features() }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMsg {
Exit {
reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
reattach: Option<String>,
},
Attached {
version: String,
#[serde(default)]
features: Option<Vec<String>>,
},
Response {
result: Result<Response, String>,
},
Open {
url: String,
},
Event {
event: serde_json::Value,
},
Events {
events: Vec<serde_json::Value>,
},
#[serde(untagged)]
Unknown(serde_json::Value),
}
impl ServerMsg {
pub fn exit(reason: impl Into<String>) -> ServerMsg {
ServerMsg::Exit { reason: reason.into(), reattach: None }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Placement {
Right,
Down,
Floating,
Tab,
#[default]
#[serde(other)]
Auto,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum Request {
Ping,
ListPanes,
ListAgents,
ListWorkspaces,
OpenWorkspace {
name: String,
},
NewPane {
target: Option<String>,
command: Vec<String>,
cwd: Option<PathBuf>,
name: Option<String>,
placement: Placement,
preview: Option<PathBuf>,
focus: bool,
#[serde(default)]
close_on_exit: bool,
},
ClosePane {
pane: String,
},
FocusPane {
pane: String,
},
RenamePane {
pane: String,
name: String,
},
SendText {
pane: String,
text: String,
#[serde(default)]
production: bool,
},
SendKeys {
pane: String,
keys: Vec<String>,
#[serde(default)]
production: bool,
},
ReadPane {
pane: String,
lines: Option<usize>,
},
Wait {
pane: String,
until: Vec<String>,
timeout_ms: Option<u64>,
},
AgentReport {
pane: String,
agent: String,
#[serde(default, deserialize_with = "known_state")]
state: Option<AgentState>,
session_id: Option<String>,
message: Option<String>,
},
Notify {
title: String,
body: String,
},
RunbookPlan {
path: PathBuf,
target: Option<String>,
params: Vec<(String, String)>,
pane: Option<String>,
},
RunbookRun {
token: String,
},
Subscribe {
events: Vec<String>,
#[serde(default)]
since: Option<u64>,
#[serde(default)]
boot: Option<u64>,
},
OpenSsh {
target: String,
group: bool,
placement: Placement,
near: Option<String>,
#[serde(default)]
command: Vec<String>,
},
OpenAgent {
agent: String,
account: Option<String>,
placement: Placement,
near: Option<String>,
cwd: Option<PathBuf>,
#[serde(default)]
args: Vec<String>,
},
TunnelAdd {
server: String,
remote: String,
local: Option<u16>,
},
TunnelRemove {
key: String,
},
TunnelList,
OpenFiles {
left: String,
right: String,
near: Option<String>,
},
EditRemote {
target: String,
line: Option<u32>,
near: Option<String>,
#[serde(default)]
production: bool,
#[serde(default)]
sudo: bool,
},
SaveWorkspace {
workspace: Option<String>,
name: Option<String>,
},
BufferList,
BufferShow {
buffer: Option<String>,
},
BufferSet {
name: Option<String>,
text: String,
},
BufferPaste {
buffer: Option<String>,
pane: String,
#[serde(default)]
production: bool,
},
BufferDelete {
buffer: Option<String>,
#[serde(default)]
all: bool,
},
ReloadConfig,
KillServer {
#[serde(default)]
restart: bool,
},
#[serde(untagged)]
Unknown(serde_json::Value),
}
impl Request {
pub fn needs(&self) -> Option<Feature> {
match self {
Request::ListAgents => Some(LIST_AGENTS),
Request::BufferList
| Request::BufferShow { .. }
| Request::BufferSet { .. }
| Request::BufferPaste { .. }
| Request::BufferDelete { .. } => Some(BUFFERS),
Request::EditRemote { sudo: true, .. } => Some(EDIT_SUDO),
Request::KillServer { .. } => None,
Request::Ping
| Request::ListPanes
| Request::ListWorkspaces
| Request::OpenWorkspace { .. }
| Request::NewPane { .. }
| Request::ClosePane { .. }
| Request::FocusPane { .. }
| Request::RenamePane { .. }
| Request::SendText { .. }
| Request::SendKeys { .. }
| Request::ReadPane { .. }
| Request::Wait { .. }
| Request::AgentReport { .. }
| Request::Notify { .. }
| Request::RunbookPlan { .. }
| Request::RunbookRun { .. }
| Request::Subscribe { .. }
| Request::OpenSsh { .. }
| Request::OpenAgent { .. }
| Request::TunnelAdd { .. }
| Request::TunnelRemove { .. }
| Request::TunnelList
| Request::OpenFiles { .. }
| Request::EditRemote { .. }
| Request::SaveWorkspace { .. }
| Request::ReloadConfig
| Request::Unknown(_) => None,
}
}
pub fn name(&self) -> String {
let v = serde_json::to_value(self).unwrap_or_default();
v.get("cmd").and_then(|c| c.as_str()).unwrap_or("?").to_string()
}
}
fn known_state<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<AgentState>, D::Error> {
let v = Option::<serde_json::Value>::deserialize(d)?;
Ok(v.as_ref().and_then(|v| v.as_str()).and_then(AgentState::parse))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
pub enum Response {
Ok,
Pong {
pid: u32,
session: String,
version: String,
#[serde(default)]
features: Option<Vec<String>>,
},
Pane(Box<PaneInfo>),
Panes(Vec<PaneInfo>),
Workspaces(Vec<WorkspaceInfo>),
Text(String),
Plan {
text: String,
token: String,
},
Path(PathBuf),
Tunnels(Vec<crate::server::net::TunnelInfo>),
Buffers(Vec<BufferInfo>),
#[serde(untagged)]
Unknown(serde_json::Value),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BufferInfo {
pub buffer: String,
pub named: bool,
pub bytes: usize,
pub lines: usize,
pub at: i64,
pub preview: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaneInfo {
pub id: u32,
pub name: Option<String>,
pub title: String,
pub workspace: String,
pub tab: String,
pub kind: String,
pub command: Vec<String>,
pub cwd: Option<PathBuf>,
pub agent: Option<String>,
pub state: AgentState,
pub exit_code: Option<i32>,
#[serde(default)]
pub suspended: bool,
#[serde(default)]
pub keyboard: u8,
#[serde(default)]
pub server: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
#[serde(default)]
pub production: Option<String>,
#[serde(default)]
pub contexts: Vec<String>,
pub focused: bool,
pub floating: bool,
pub cols: u16,
pub rows: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceInfo {
pub name: String,
pub open: bool,
pub definition: Option<PathBuf>,
pub tabs: usize,
pub panes: usize,
pub working: usize,
pub blocked: usize,
pub done: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn releases_compare_as_semver() {
assert!(newer("0.2.1", "0.2.0"));
assert!(newer("0.10.0", "0.9.9"));
assert!(newer("v1.0.0", "0.99.0"));
assert!(!newer("0.2.0", "0.2.0"));
assert!(!newer("0.2.0", "0.2.1"));
assert!(newer("0.3.0", "0.3.0-rc.1"));
assert!(!newer("0.3.0-rc.1", "0.3.0"));
assert!(newer("0.3.0-rc.10", "0.3.0-rc.9"));
assert!(newer("0.3.0-rc.1", "0.3.0-beta.2"));
assert!(newer("0.3.0-rc.1.1", "0.3.0-rc.1"));
assert!(newer("0.2.1+build.5", "0.2.0"));
assert!(newer("0.3", "0.2.9"));
assert!(!newer("garbage", "0.2.0") && !newer("0.2.0", "garbage") && !newer("", ""));
assert!(!newer(KRAAL_VERSION, KRAAL_VERSION));
}
#[test]
fn older_and_newer_peers_read_what_they_know() {
#[derive(Deserialize)]
#[serde(tag = "cmd", rename_all = "snake_case")]
enum OldRequest {
KillServer,
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum OldServerMsg {
Exit { reason: String },
}
let kill = serde_json::to_string(&Request::KillServer { restart: true }).unwrap();
assert!(matches!(serde_json::from_str::<OldRequest>(&kill), Ok(OldRequest::KillServer)), "{kill}");
let exit =
serde_json::to_string(&ServerMsg::Exit { reason: "bye".into(), reattach: Some("w".into()) }).unwrap();
assert!(
matches!(serde_json::from_str::<OldServerMsg>(&exit), Ok(OldServerMsg::Exit { reason }) if reason == "bye")
);
let attached =
serde_json::to_string(&ServerMsg::Attached { version: "0.2.1".into(), features: Some(feature_names()) })
.unwrap();
assert!(attached.starts_with(r#"{"type":"attached","version":"0.2.1","features":["#), "{attached}");
assert!(serde_json::from_str::<OldServerMsg>(&attached).is_err(), "an older client skips it");
assert!(matches!(
serde_json::from_str::<Request>(r#"{"cmd":"kill_server"}"#),
Ok(Request::KillServer { restart: false })
));
assert!(matches!(
serde_json::from_str::<ServerMsg>(r#"{"type":"exit","reason":"detached"}"#),
Ok(ServerMsg::Exit { reattach: None, .. })
));
assert!(matches!(
serde_json::from_str::<ServerMsg>(r#"{"type":"attached","version":"0.2.0"}"#),
Ok(ServerMsg::Attached { features: None, .. })
));
let hello = r#"{"type":"attach","version":2,"cols":80,"rows":24,"workspace":null,"remote":false}"#;
assert!(matches!(
serde_json::from_str::<ClientMsg>(hello),
Ok(ClientMsg::Attach { installed: None, ref features, .. }) if features.is_empty()
));
}
#[test]
fn answers_of_the_oldest_supported_release_read() {
let pong = r#"{"type":"response","result":{"Ok":{"kind":"pong","data":{"pid":7,"session":"main","version":"0.2.0"}}}}"#;
let Ok(ServerMsg::Response { result: Ok(Response::Pong { pid: 7, features: None, .. }) }) =
serde_json::from_str::<ServerMsg>(pong)
else {
panic!("{pong}")
};
let pane = r#"{"id":3,"name":null,"title":"sh","workspace":"w","tab":"t","kind":"shell","command":[],"cwd":null,"agent":"claude","state":"working","exit_code":null,"focused":true,"floating":false,"cols":80,"rows":24}"#;
let panes = format!(r#"{{"type":"response","result":{{"Ok":{{"kind":"panes","data":[{pane}]}}}}}}"#);
let Ok(ServerMsg::Response { result: Ok(Response::Panes(list)) }) = serde_json::from_str::<ServerMsg>(&panes)
else {
panic!("{panes}")
};
assert_eq!((list[0].id, list[0].state, list[0].session.as_deref()), (3, AgentState::Working, None));
let refused = r#"{"type":"response","result":{"Err":"bad message: unknown variant `buffer_list`, expected one of `ping`"}}"#;
let Ok(ServerMsg::Response { result: Err(e) }) = serde_json::from_str::<ServerMsg>(refused) else {
panic!("{refused}")
};
assert!(refused_as_unknown(&e));
let request = r#"{"type":"request","version":2,"request":{"cmd":"list_panes"}}"#;
assert!(matches!(
serde_json::from_str::<ClientMsg>(request),
Ok(ClientMsg::Request { request: Request::ListPanes, ref features, .. }) if features.is_empty()
));
}
#[test]
fn what_this_kraal_does_not_know_is_refused_by_name() {
let from_newer = r#"{"type":"request","version":2,"request":{"cmd":"teleport","to":"mars"},"features":["x"]}"#;
let Ok(ClientMsg::Request { request: Request::Unknown(request), .. }) =
serde_json::from_str::<ClientMsg>(from_newer)
else {
panic!("{from_newer}")
};
let answer = unsupported(&request);
assert!(answer.starts_with(UNSUPPORTED) && answer.contains("`teleport`"), "{answer}");
assert!(refused_as_unknown(&answer));
assert!(!refused_as_unknown("no such pane: 12"));
let Ok(ClientMsg::Unknown(msg)) = serde_json::from_str::<ClientMsg>(r#"{"type":"zoom","factor":2}"#) else {
panic!()
};
assert!(unsupported(&msg).contains("`zoom`"));
assert_eq!(Request::BufferList.name(), "buffer_list");
}
#[test]
fn a_hook_state_this_kraal_does_not_know_changes_nothing() {
let report = |state: &str| {
let json = format!(r#"{{"cmd":"agent_report","pane":"1","agent":"claude","state":{state}}}"#);
match serde_json::from_str::<Request>(&json) {
Ok(Request::AgentReport { state, .. }) => state,
other => panic!("{json}: {other:?}"),
}
};
assert_eq!(report(r#""compacting""#), None);
assert_eq!(report("null"), None);
assert_eq!(report(r#""unknown""#), Some(AgentState::Unknown), "the agent's session ended");
assert_eq!(report(r#""blocked""#), Some(AgentState::Blocked));
assert_eq!(serde_json::from_str::<AgentState>(r#""compacting""#).unwrap(), AgentState::Unknown);
assert_eq!(serde_json::to_string(&AgentState::Unknown).unwrap(), r#""unknown""#);
}
#[test]
fn requests_say_what_an_older_server_lacks() {
assert_eq!(Request::ListAgents.needs(), Some(LIST_AGENTS));
assert_eq!(Request::BufferDelete { buffer: None, all: true }.needs(), Some(BUFFERS));
let edit = |sudo| Request::EditRemote { target: "a:b".into(), line: None, near: None, production: false, sudo };
assert_eq!(edit(true).needs(), Some(EDIT_SUDO));
assert_eq!(edit(false).needs(), None);
assert_eq!(Request::KillServer { restart: true }.needs(), None);
assert_eq!(Request::ListPanes.needs(), None);
let names = feature_names();
assert_eq!(names.iter().collect::<std::collections::HashSet<_>>().len(), names.len());
}
fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
let s = |v: &str| v.to_string();
let p = |v: &str| PathBuf::from(v);
let requests = vec![
Request::Ping,
Request::ListPanes,
Request::ListAgents,
Request::ListWorkspaces,
Request::OpenWorkspace { name: s("w") },
Request::NewPane {
target: Some(s("1")),
command: vec![s("sh")],
cwd: Some(p("/tmp")),
name: Some(s("n")),
placement: Placement::Right,
preview: Some(p("/tmp/a.md")),
focus: true,
close_on_exit: true,
},
Request::ClosePane { pane: s("1") },
Request::FocusPane { pane: s("1") },
Request::RenamePane { pane: s("1"), name: s("n") },
Request::SendText { pane: s("1"), text: s("hi"), production: true },
Request::SendKeys { pane: s("1"), keys: vec![s("ctrl+c")], production: false },
Request::ReadPane { pane: s("1"), lines: Some(5) },
Request::Wait { pane: s("1"), until: vec![s("done")], timeout_ms: Some(10) },
Request::AgentReport {
pane: s("1"),
agent: s("claude"),
state: Some(AgentState::Working),
session_id: Some(s("x")),
message: Some(s("m")),
},
Request::Notify { title: s("t"), body: s("b") },
Request::RunbookPlan {
path: p("/r.toml"),
target: Some(s("web")),
params: vec![(s("A"), s("1"))],
pane: Some(s("1")),
},
Request::RunbookRun { token: s("t") },
Request::Subscribe { events: vec![s("pane.*")], since: Some(3), boot: Some(4) },
Request::OpenSsh {
target: s("web"),
group: false,
placement: Placement::Tab,
near: Some(s("1")),
command: vec![s("top")],
},
Request::OpenAgent {
agent: s("claude"),
account: Some(s("a")),
placement: Placement::Floating,
near: None,
cwd: Some(p("/")),
args: vec![s("-c")],
},
Request::TunnelAdd { server: s("web"), remote: s("80"), local: Some(8080) },
Request::TunnelRemove { key: s("8080") },
Request::TunnelList,
Request::OpenFiles { left: s("/"), right: s("web:/"), near: None },
Request::EditRemote { target: s("web:/a"), line: Some(3), near: None, production: true, sudo: true },
Request::SaveWorkspace { workspace: Some(s("w")), name: Some(s("n")) },
Request::BufferList,
Request::BufferShow { buffer: Some(s("0")) },
Request::BufferSet { name: Some(s("n")), text: s("t") },
Request::BufferPaste { buffer: None, pane: s("1"), production: false },
Request::BufferDelete { buffer: Some(s("n")), all: false },
Request::ReloadConfig,
Request::KillServer { restart: true },
];
let mut client: Vec<ClientMsg> = requests.into_iter().map(ClientMsg::request).collect();
client.extend([
ClientMsg::Attach {
version: PROTOCOL_VERSION,
cols: 80,
rows: 24,
workspace: Some(s("w")),
remote: true,
pane: Some(s("3")),
installed: Some(s("0.2.0")),
features: client_features(),
},
ClientMsg::Resize { cols: 80, rows: 24 },
ClientMsg::Detach,
]);
let pane = PaneInfo {
id: 1,
name: Some(s("n")),
title: s("t"),
workspace: s("w"),
tab: s("t"),
kind: s("shell"),
command: vec![s("sh")],
cwd: Some(p("/")),
agent: Some(s("claude")),
state: AgentState::Blocked,
exit_code: Some(1),
suspended: true,
keyboard: 1,
server: Some(s("web")),
session: Some(s("main")),
production: Some(s("server web")),
contexts: vec![s("tf:prod")],
focused: true,
floating: false,
cols: 80,
rows: 24,
};
let tunnel = crate::server::net::TunnelInfo {
id: 1,
spec: crate::server::net::TunnelSpec {
server: s("web"),
via: Some(crate::servers::Server { name: s("web"), host: s("10.0.0.1"), ..Default::default() }),
local: 8080,
remote_host: s("localhost"),
remote_port: 80,
},
state: crate::server::net::TunnelState::Connecting,
error: Some(s("e")),
};
let answers = vec![
Response::Ok,
Response::Pong { pid: 1, session: s("main"), version: s("0.2.0"), features: Some(feature_names()) },
Response::Pane(Box::new(pane.clone())),
Response::Panes(vec![pane]),
Response::Workspaces(vec![WorkspaceInfo {
name: s("w"),
open: true,
definition: Some(p("/w.kdl")),
tabs: 1,
panes: 2,
working: 1,
blocked: 0,
done: 0,
}]),
Response::Text(s("t")),
Response::Plan { text: s("t"), token: s("k") },
Response::Path(p("/p")),
Response::Tunnels(vec![tunnel]),
Response::Buffers(vec![BufferInfo {
buffer: s("0"),
named: false,
bytes: 2,
lines: 1,
at: 5,
preview: s("hi"),
}]),
];
let mut server: Vec<ServerMsg> = answers.into_iter().map(|a| ServerMsg::Response { result: Ok(a) }).collect();
server.extend([
ServerMsg::Response { result: Err(s("no")) },
ServerMsg::Exit { reason: s("bye"), reattach: Some(s("w")) },
ServerMsg::Attached { version: s("0.2.0"), features: Some(feature_names()) },
ServerMsg::Open { url: s("https://example.com") },
ServerMsg::Event { event: serde_json::json!({"v": 1, "type": "pane.opened"}) },
ServerMsg::Events { events: vec![serde_json::json!({"v": 1, "type": "pane.closed"})] },
]);
(client, server)
}
fn with_new_fields(v: &serde_json::Value) -> serde_json::Value {
use serde_json::Value;
match v {
Value::Object(map) => {
let result = map.len() == 1 && (map.contains_key("Ok") || map.contains_key("Err"));
let mut out: serde_json::Map<String, Value> = map
.iter()
.map(|(k, v)| {
let v = if k == "event" || k == "events" { v.clone() } else { with_new_fields(v) };
(k.clone(), v)
})
.collect();
if !result {
out.insert("zz_new".into(), serde_json::json!({"a": [1, "b", null]}));
}
Value::Object(out)
}
Value::Array(items) => Value::Array(items.iter().map(with_new_fields).collect()),
v => v.clone(),
}
}
fn strings(v: &serde_json::Value, at: String, out: &mut Vec<String>) {
match v {
serde_json::Value::String(_) => out.push(at),
serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| strings(v, format!("{at}/{k}"), out)),
serde_json::Value::Array(items) => {
items.iter().enumerate().for_each(|(i, v)| strings(v, format!("{at}/{i}"), out))
}
_ => {}
}
}
#[test]
fn every_message_reads_whatever_a_newer_peer_adds() {
const TAGS: [&str; 3] = ["/type", "/request/cmd", "/result/Ok/kind"];
fn check<T: Serialize + serde::de::DeserializeOwned>(msg: &T, unknown: fn(&T) -> bool) {
let v = serde_json::to_value(msg).unwrap();
let extended = with_new_fields(&v);
let read: T = serde_json::from_value(extended.clone()).unwrap_or_else(|e| panic!("{extended}: {e}"));
assert_eq!(serde_json::to_value(&read).unwrap(), v, "new fields changed {extended}");
let mut pointers = Vec::new();
strings(&v, String::new(), &mut pointers);
for at in pointers {
let mut changed = v.clone();
*changed.pointer_mut(&at).unwrap() = "zz_new".into();
let read = serde_json::from_value::<T>(changed.clone())
.unwrap_or_else(|e| panic!("a new value at {at} fails {changed}: {e}"));
assert_eq!(unknown(&read), TAGS.contains(&at.as_str()), "a new value at {at}: {changed}");
}
}
let (client, server) = samples();
for msg in &client {
check(msg, |m| {
matches!(m, ClientMsg::Unknown(_) | ClientMsg::Request { request: Request::Unknown(_), .. })
});
}
for msg in &server {
check(msg, |m| {
matches!(m, ServerMsg::Unknown(_) | ServerMsg::Response { result: Ok(Response::Unknown(_)) })
});
}
for answer in [r#"{"kind":"maps","data":[{"x":1}]}"#, r#"{"kind":"ok","data":{"x":1}}"#] {
let msg = format!(r#"{{"type":"response","result":{{"Ok":{answer}}}}}"#);
assert!(
matches!(
serde_json::from_str::<ServerMsg>(&msg),
Ok(ServerMsg::Response { result: Ok(Response::Unknown(_)) })
),
"{msg}"
);
}
}
#[test]
fn fuzz_protocol() {
crate::fuzz::run("protocol", 300, |rng| {
let mut wire = Vec::new();
for _ in 0..rng.range(1, 6) {
let payload: Vec<u8> = match rng.below(4) {
0 => serde_json::to_vec(&ClientMsg::Attach {
version: PROTOCOL_VERSION,
cols: rng.number() as u16,
rows: rng.number() as u16,
workspace: None,
remote: rng.one_in(2),
pane: None,
installed: None,
features: client_features(),
})
.unwrap(),
1 => br#"{"type":"exit","reason":"x"}"#.to_vec(),
2 => br#"{"cmd":"kill_server"}"#.to_vec(),
_ => {
let n = rng.size(64);
rng.bytes(n)
}
};
write_frame(&mut wire, rng.below(3) as u8, &payload).unwrap();
}
if rng.one_in(2) {
rng.mutate(&mut wire, &[&[0xff, 0xff, 0xff, 0x7f], &[0, 0, 0, 0], b"{", b"\""]);
}
let mut r = std::io::Cursor::new(wire);
while let Ok(Some((_, payload))) = read_frame(&mut r) {
assert!(payload.len() <= MAX_FRAME);
let _ = serde_json::from_slice::<ClientMsg>(&payload);
let _ = serde_json::from_slice::<ServerMsg>(&payload);
let _ = serde_json::from_slice::<Request>(&payload);
}
let v = String::from_utf8_lossy(&rng.text(20)).into_owned();
let _ = newer(&v, KRAAL_VERSION) || newer(KRAAL_VERSION, &v);
});
}
}