use std::path::Path;
use serde_json::Value;
use tokio::net::UnixStream;
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
use crate::codec::{FrameReader, Inbound, MAX_FRAME_BYTES, write_frame};
use crate::protocol::{BlobFetchResult, BlobPublishResult, Hello, Request};
#[derive(Debug)]
pub struct ControlClient {
hello: Hello,
reader: FrameReader<OwnedReadHalf>,
writer: OwnedWriteHalf,
}
#[derive(Debug)]
pub enum ClientError {
Io(std::io::Error),
Closed(&'static str),
Malformed(&'static str),
WrongApi { got: String, want: &'static str },
Api(Value),
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClientError::Io(err) => write!(f, "io: {err}"),
ClientError::Closed(what) => write!(f, "connection closed before {what}"),
ClientError::Malformed(what) => write!(f, "malformed {what} frame"),
ClientError::WrongApi { got, want } => {
write!(f, "unexpected api: got {got:?}, want {want:?}")
}
ClientError::Api(err) => write!(f, "control API error: {err}"),
}
}
}
impl std::error::Error for ClientError {}
impl From<std::io::Error> for ClientError {
fn from(err: std::io::Error) -> Self {
ClientError::Io(err)
}
}
impl ControlClient {
pub fn hello(&self) -> &Hello {
&self.hello
}
pub async fn request(&mut self, request: Request) -> Result<Value, ClientError> {
let frame = serde_json::to_value(&request).expect("Request serializes");
self.request_value(&frame).await
}
pub async fn request_value(&mut self, request: &Value) -> Result<Value, ClientError> {
write_frame(&mut self.writer, request).await?;
match self.reader.next().await? {
Some(Inbound::Frame(resp)) => {
if let Some(err) = resp.get("error") {
return Err(ClientError::Api(err.clone()));
}
Ok(resp.get("result").cloned().unwrap_or(Value::Null))
}
Some(Inbound::Violation(_)) => Err(ClientError::Malformed("response")),
None => Err(ClientError::Closed("response")),
}
}
pub async fn open_session(
mut self,
peer: String,
service: String,
) -> Result<(FrameReader<OwnedReadHalf>, OwnedWriteHalf), ClientError> {
let frame = serde_json::to_value(Request::OpenSession { peer, service })
.expect("Request serializes");
write_frame(&mut self.writer, &frame).await?;
Ok((self.reader, self.writer))
}
pub async fn open_stream(
mut self,
method: &str,
) -> Result<(FrameReader<OwnedReadHalf>, OwnedWriteHalf), ClientError> {
let frame = serde_json::json!({ "method": method });
write_frame(&mut self.writer, &frame).await?;
Ok((self.reader, self.writer))
}
pub async fn blob_publish(
&mut self,
scope: &str,
path: &str,
) -> Result<BlobPublishResult, ClientError> {
let v = self
.request(Request::BlobPublish {
scope: scope.to_string(),
path: path.to_string(),
})
.await?;
serde_json::from_value(v).map_err(|_| ClientError::Malformed("blob_publish result"))
}
pub async fn blob_fetch(
&mut self,
ticket: &str,
dest_path: &str,
) -> Result<BlobFetchResult, ClientError> {
let v = self
.request(Request::BlobFetch {
ticket: ticket.to_string(),
dest_path: dest_path.to_string(),
})
.await?;
serde_json::from_value(v).map_err(|_| ClientError::Malformed("blob_fetch result"))
}
pub async fn blob_grant(&mut self, scope: &str, principal: &str) -> Result<(), ClientError> {
self.request(Request::BlobGrant {
scope: scope.to_string(),
principal: principal.to_string(),
})
.await
.map(|_| ())
}
}
pub async fn connect_control(path: &Path) -> Result<ControlClient, ClientError> {
let stream = UnixStream::connect(path).await?;
let (read_half, writer) = stream.into_split();
let mut reader = FrameReader::new(read_half, MAX_FRAME_BYTES);
let hello: Hello = match reader.next().await? {
Some(Inbound::Frame(v)) => {
serde_json::from_value(v).map_err(|_| ClientError::Malformed("hello"))?
}
Some(Inbound::Violation(_)) => return Err(ClientError::Malformed("hello")),
None => return Err(ClientError::Closed("hello")),
};
if hello.api != crate::protocol::API_NAME {
return Err(ClientError::WrongApi {
got: hello.api,
want: crate::protocol::API_NAME,
});
}
Ok(ControlClient {
hello,
reader,
writer,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{API_NAME, API_VERSION, BackendKind, ServiceInfo, StatusResult};
use tokio::io::AsyncWriteExt;
use tokio::net::UnixListener;
async fn stub_daemon(listener: UnixListener) {
let (stream, _) = listener.accept().await.unwrap();
let (read_half, mut writer) = stream.into_split();
write_frame(
&mut writer,
&serde_json::to_value(Hello {
api: API_NAME.into(),
api_version: API_VERSION.into(),
stack_version: "0.1.0".into(),
})
.unwrap(),
)
.await
.unwrap();
let mut reader = FrameReader::new(read_half, MAX_FRAME_BYTES);
let req = match reader.next().await.unwrap().unwrap() {
Inbound::Frame(v) => v,
Inbound::Violation(_) => panic!("violation"),
};
assert_eq!(req["method"], "status");
let result = StatusResult {
stack_version: "0.1.0".into(),
services: vec![ServiceInfo {
name: "kb".into(),
allow: vec![],
backend: BackendKind::Socket,
}],
peers: vec![],
roster: None,
presence: vec![],
self_user_id: None,
recent_pairings: vec![],
reachability: vec![],
};
write_frame(
&mut writer,
&serde_json::json!({ "jsonrpc": "2.0", "id": 1, "result": result }),
)
.await
.unwrap();
writer.flush().await.unwrap();
}
#[tokio::test]
async fn connect_reads_hello_asserts_api_and_requests() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("mcpmesh.sock");
let listener = UnixListener::bind(&sock).unwrap();
let server = tokio::spawn(stub_daemon(listener));
let mut client = connect_control(&sock).await.unwrap();
assert_eq!(client.hello().api, API_NAME);
let result = client.request(Request::Status).await.unwrap();
assert_eq!(result["services"][0]["name"], "kb");
assert_eq!(result["services"][0]["backend"], "socket");
server.await.unwrap();
}
#[tokio::test]
async fn wrong_api_hello_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("x.sock");
let listener = UnixListener::bind(&sock).unwrap();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (_r, mut w) = stream.into_split();
write_frame(
&mut w,
&serde_json::json!({"api":"other/1","api_version":"1.0","stack_version":"0"}),
)
.await
.unwrap();
w.flush().await.unwrap();
});
match connect_control(&sock).await {
Err(ClientError::WrongApi { got, want }) => {
assert_eq!(got, "other/1");
assert_eq!(want, API_NAME);
}
other => panic!("expected WrongApi, got {other:?}"),
}
}
#[tokio::test]
async fn blob_fetch_and_publish_deserialize_typed_results() {
use crate::protocol::{BlobFetchResult, BlobPublishResult};
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("m.sock");
let listener = UnixListener::bind(&sock).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (read_half, mut writer) = stream.into_split();
write_frame(
&mut writer,
&serde_json::to_value(Hello {
api: API_NAME.into(),
api_version: API_VERSION.into(),
stack_version: "0.1.0".into(),
})
.unwrap(),
)
.await
.unwrap();
let mut reader = FrameReader::new(read_half, MAX_FRAME_BYTES);
let req = match reader.next().await.unwrap().unwrap() {
Inbound::Frame(v) => v,
Inbound::Violation(_) => panic!("violation"),
};
assert_eq!(req["method"], "blob_publish");
assert_eq!(req["params"]["scope"], "eng");
write_frame(
&mut writer,
&serde_json::json!({"jsonrpc":"2.0","id":1,"result":{"ticket":"blobT","hash":"ab"}}),
)
.await
.unwrap();
let req = match reader.next().await.unwrap().unwrap() {
Inbound::Frame(v) => v,
Inbound::Violation(_) => panic!("violation"),
};
assert_eq!(req["method"], "blob_fetch");
assert_eq!(req["params"]["ticket"], "blobT");
assert_eq!(req["params"]["dest_path"], "/tmp/out.bin");
write_frame(
&mut writer,
&serde_json::json!({"jsonrpc":"2.0","id":2,"result":{"hash":"cd","bytes_len":7}}),
)
.await
.unwrap();
let _ = (
BlobFetchResult {
hash: "cd".into(),
bytes_len: 7,
},
BlobPublishResult {
ticket: "blobT".into(),
hash: "ab".into(),
},
);
});
let mut client = connect_control(&sock).await.unwrap();
let pub_res = client.blob_publish("eng", "/tmp/a.bin").await.unwrap();
assert_eq!(pub_res.ticket, "blobT");
assert_eq!(pub_res.hash, "ab");
let fetch_res = client.blob_fetch("blobT", "/tmp/out.bin").await.unwrap();
assert_eq!(fetch_res.hash, "cd");
assert_eq!(fetch_res.bytes_len, 7);
server.await.unwrap();
}
#[tokio::test]
async fn frame_pipelined_behind_hello_survives_open_session_rebox() {
use tokio::io::AsyncRead;
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("pipelined.sock");
let listener = UnixListener::bind(&sock).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (read_half, mut writer) = stream.into_split();
let mut bytes = serde_json::to_vec(
&serde_json::to_value(Hello {
api: API_NAME.into(),
api_version: API_VERSION.into(),
stack_version: "0.1.0".into(),
})
.unwrap(),
)
.unwrap();
bytes.push(b'\n');
bytes.extend_from_slice(b"{\"jsonrpc\":\"2.0\",\"id\":42,\"result\":{}}\n");
writer.write_all(&bytes).await.unwrap();
writer.flush().await.unwrap();
let mut reader = FrameReader::new(read_half, MAX_FRAME_BYTES);
let req = match reader.next().await.unwrap().unwrap() {
Inbound::Frame(v) => v,
Inbound::Violation(_) => panic!("violation"),
};
assert_eq!(req["method"], "open_session");
});
let client = connect_control(&sock).await.unwrap();
let (reader, _writer) = client
.open_session("peer".into(), "kb".into())
.await
.unwrap();
let boxed: Box<dyn AsyncRead + Unpin + Send> = Box::new(reader.into_inner());
let mut reframed = FrameReader::new(boxed, MAX_FRAME_BYTES);
match reframed.next().await.unwrap() {
Some(Inbound::Frame(v)) => assert_eq!(v["id"], 42),
other => panic!("pipelined frame was lost across the rebox: {other:?}"),
}
server.await.unwrap();
}
#[tokio::test]
async fn blob_grant_issues_request_and_acks() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("g.sock");
let listener = UnixListener::bind(&sock).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (read_half, mut writer) = stream.into_split();
write_frame(
&mut writer,
&serde_json::to_value(Hello {
api: API_NAME.into(),
api_version: API_VERSION.into(),
stack_version: "0.1.0".into(),
})
.unwrap(),
)
.await
.unwrap();
let mut reader = FrameReader::new(read_half, MAX_FRAME_BYTES);
let req = match reader.next().await.unwrap().unwrap() {
Inbound::Frame(v) => v,
Inbound::Violation(_) => panic!("violation"),
};
assert_eq!(req["method"], "blob_grant");
assert_eq!(req["params"]["scope"], "kb-sync");
assert_eq!(req["params"]["principal"], "alice");
write_frame(
&mut writer,
&serde_json::json!({"jsonrpc":"2.0","id":1,"result":{"ok":true}}),
)
.await
.unwrap();
});
let mut client = connect_control(&sock).await.unwrap();
client.blob_grant("kb-sync", "alice").await.unwrap();
server.await.unwrap();
}
}