use std::collections::HashMap;
#[cfg(feature = "stream")]
use std::future::Future;
#[cfg(feature = "uds")]
use std::path::Path;
#[cfg(feature = "stream")]
use std::pin::Pin;
use std::sync::{Arc, atomic::AtomicU32};
#[cfg(feature = "stream")]
use std::time::Duration;
#[cfg(feature = "stream")]
use microsandbox_protocol::message::FLAG_TERMINAL;
#[cfg(feature = "stream")]
use microsandbox_protocol::{codec::MAX_FRAME_SIZE, message::FRAME_HEADER_SIZE};
use microsandbox_protocol::{
codec::{self, RawFrame},
core::Ready,
message::{Message, MessageType, PROTOCOL_VERSION},
};
use serde::Serialize;
#[cfg(feature = "stream")]
use tokio::io::{AsyncRead, AsyncWrite};
#[cfg(feature = "uds")]
use tokio::net::UnixStream;
use tokio::sync::{Mutex, mpsc, oneshot};
use tokio::task::JoinHandle;
#[cfg(feature = "stream")]
use tokio::time::Instant;
use super::error::{AgentClientError, AgentClientResult};
#[cfg(feature = "stream")]
const DEFAULT_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
#[cfg(feature = "stream")]
const WRITER_QUEUE_CAPACITY: usize = 1024;
const REQUEST_QUEUE_CAPACITY: usize = 1;
const STREAM_QUEUE_CAPACITY: usize = 1024;
const LEGACY_PROTOCOL_VERSION: u8 = 1;
#[cfg(feature = "stream")]
const LEGACY_RELAY_ID_RANGE_STEP: u32 = u32::MAX / 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentProtocol {
Current,
LegacyV1,
}
pub struct AgentClient {
writer: mpsc::Sender<WriterCommand>,
next_id: AtomicU32,
id_min: u32,
id_max: u32,
protocol: AgentProtocol,
negotiated_version: u8,
pending: Arc<Mutex<HashMap<u32, mpsc::Sender<RawFrame>>>>,
reader_handle: JoinHandle<()>,
writer_handle: JoinHandle<()>,
ready_body: Vec<u8>,
ready: Ready,
}
#[cfg(feature = "stream")]
struct AgentHandshake {
id_min: u32,
id_max: u32,
protocol: AgentProtocol,
negotiated_version: u8,
ready_body: Vec<u8>,
ready: Ready,
}
#[cfg_attr(not(feature = "stream"), allow(dead_code))]
struct WriterCommand {
frame: RawFrame,
ack: oneshot::Sender<AgentClientResult<()>>,
}
#[cfg(feature = "stream")]
trait HandshakeReader {
fn read_exact_handshake<'a>(
&'a mut self,
out: &'a mut [u8],
) -> Pin<Box<dyn Future<Output = AgentClientResult<()>> + Send + 'a>>;
fn read_frame_handshake<'a>(
&'a mut self,
) -> Pin<Box<dyn Future<Output = AgentClientResult<RawFrame>> + Send + 'a>>;
}
impl AgentProtocol {
fn version(self) -> u8 {
match self {
Self::Current => PROTOCOL_VERSION,
Self::LegacyV1 => LEGACY_PROTOCOL_VERSION,
}
}
}
impl AgentClient {
#[cfg(feature = "uds")]
pub async fn connect(sock_path: impl AsRef<Path>) -> AgentClientResult<Self> {
Self::connect_with_timeout(sock_path, DEFAULT_HANDSHAKE_TIMEOUT).await
}
#[cfg(feature = "uds")]
pub async fn connect_with_timeout(
sock_path: impl AsRef<Path>,
timeout: Duration,
) -> AgentClientResult<Self> {
let deadline = Instant::now() + timeout;
Self::connect_with_deadline(sock_path, deadline).await
}
#[cfg(feature = "uds")]
pub async fn connect_with_deadline(
sock_path: impl AsRef<Path>,
deadline: Instant,
) -> AgentClientResult<Self> {
let sock_path = sock_path.as_ref();
let stream =
UnixStream::connect(sock_path)
.await
.map_err(|source| AgentClientError::Connect {
path: sock_path.to_path_buf(),
source,
})?;
Self::connect_stream_with_deadline(stream, deadline).await
}
#[cfg(feature = "stream")]
pub async fn connect_stream<S>(stream: S) -> AgentClientResult<Self>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
Self::connect_stream_with_timeout(stream, DEFAULT_HANDSHAKE_TIMEOUT).await
}
#[cfg(feature = "stream")]
pub async fn connect_stream_with_timeout<S>(
stream: S,
timeout: Duration,
) -> AgentClientResult<Self>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let deadline = Instant::now() + timeout;
Self::connect_stream_with_deadline(stream, deadline).await
}
#[cfg(feature = "stream")]
pub async fn connect_stream_with_deadline<S>(
stream: S,
deadline: Instant,
) -> AgentClientResult<Self>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let (mut reader, writer) = tokio::io::split(stream);
let handshake = perform_handshake(&mut reader, deadline).await?;
tracing::info!(
id_min = handshake.id_min,
id_max = handshake.id_max,
protocol = ?handshake.protocol,
ready_bytes = handshake.ready_body.len(),
boot_time_ns = handshake.ready.boot_time_ns,
"agent client: connected to relay"
);
if handshake.protocol == AgentProtocol::LegacyV1 {
tracing::warn!(
"agent client: connected to a sandbox started before microsandbox 0.5; exec compatibility is temporary and filesystem/SFTP require stop/start"
);
}
let pending: Arc<Mutex<HashMap<u32, mpsc::Sender<RawFrame>>>> =
Arc::new(Mutex::new(HashMap::new()));
let (writer_tx, writer_rx) = mpsc::channel(WRITER_QUEUE_CAPACITY);
let reader_handle = tokio::spawn(reader_loop(reader, Arc::clone(&pending)));
let writer_handle = tokio::spawn(stream_writer_loop(writer, writer_rx));
Ok(Self {
writer: writer_tx,
next_id: AtomicU32::new(first_request_id(handshake.id_min)),
id_min: handshake.id_min,
id_max: handshake.id_max,
protocol: handshake.protocol,
negotiated_version: handshake.negotiated_version,
pending,
reader_handle,
writer_handle,
ready_body: handshake.ready_body,
ready: handshake.ready,
})
}
pub async fn close(self) {
}
}
impl AgentClient {
pub async fn request_raw(&self, flags: u8, body: Vec<u8>) -> AgentClientResult<RawFrame> {
let (tx, mut rx) = mpsc::channel(REQUEST_QUEUE_CAPACITY);
let id = self.reserve_id(tx).await?;
if let Err(e) = self.write_frame_owned(id, flags, body).await {
self.pending.lock().await.remove(&id);
return Err(e);
}
let frame = rx.recv().await.ok_or(AgentClientError::ReaderClosed(id))?;
self.pending.lock().await.remove(&id);
Ok(frame)
}
pub async fn stream_raw(
&self,
flags: u8,
body: Vec<u8>,
) -> AgentClientResult<(u32, mpsc::Receiver<RawFrame>)> {
let (tx, rx) = mpsc::channel(STREAM_QUEUE_CAPACITY);
let id = self.reserve_id(tx).await?;
if let Err(e) = self.write_frame_owned(id, flags, body).await {
self.pending.lock().await.remove(&id);
return Err(e);
}
Ok((id, rx))
}
pub async fn send_raw(&self, id: u32, flags: u8, body: &[u8]) -> AgentClientResult<()> {
self.write_frame(id, flags, body).await
}
pub fn ready_bytes(&self) -> &[u8] {
&self.ready_body
}
pub fn protocol(&self) -> AgentProtocol {
self.protocol
}
pub fn is_legacy_protocol(&self) -> bool {
self.protocol == AgentProtocol::LegacyV1
}
pub fn negotiated_version(&self) -> u8 {
self.negotiated_version
}
pub fn agent_version(&self) -> &str {
&self.ready.agent_version
}
pub fn supports(&self, t: MessageType) -> bool {
t.min_protocol_version() <= self.negotiated_version
}
pub fn ensure_version_compat(&self, t: MessageType) -> AgentClientResult<()> {
Self::ensure_version_compat_for(t, self.negotiated_version)
}
pub fn ensure_version_compat_for(t: MessageType, negotiated: u8) -> AgentClientResult<()> {
if t.is_available_at(negotiated) {
return Ok(());
}
Err(AgentClientError::UnsupportedOperation {
msg_type: t.as_str(),
needs: t.min_protocol_version(),
peer: negotiated,
})
}
}
impl AgentClient {
pub async fn request<T: Serialize>(
&self,
t: MessageType,
payload: &T,
) -> AgentClientResult<Message> {
self.ensure_version_compat(t)?;
let flags = t.flags();
let body = encode_message_body(self.protocol.version(), t, payload)?;
let frame = self.request_raw(flags, body).await?;
Ok(codec::raw_frame_to_message(frame)?)
}
pub async fn stream<T: Serialize>(
&self,
t: MessageType,
payload: &T,
) -> AgentClientResult<(u32, mpsc::Receiver<Message>)> {
self.ensure_version_compat(t)?;
let flags = t.flags();
let body = encode_message_body(self.protocol.version(), t, payload)?;
let (id, raw_rx) = self.stream_raw(flags, body).await?;
let (tx, rx) = mpsc::channel(STREAM_QUEUE_CAPACITY);
tokio::spawn(decode_stream_task(raw_rx, tx));
Ok((id, rx))
}
pub async fn send<T: Serialize>(
&self,
id: u32,
t: MessageType,
payload: &T,
) -> AgentClientResult<()> {
self.ensure_version_compat(t)?;
let flags = t.flags();
let body = encode_message_body(self.protocol.version(), t, payload)?;
self.write_frame_owned(id, flags, body).await
}
pub fn ready(&self) -> AgentClientResult<Ready> {
Ok(self.ready.clone())
}
}
impl AgentClient {
async fn reserve_id(&self, tx: mpsc::Sender<RawFrame>) -> AgentClientResult<u32> {
let mut pending = self.pending.lock().await;
let attempts = usable_id_count(self.id_min, self.id_max);
for _ in 0..attempts {
let id = self
.next_id
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if self.next_id.load(std::sync::atomic::Ordering::Relaxed) >= self.id_max {
self.next_id.store(
first_request_id(self.id_min),
std::sync::atomic::Ordering::Relaxed,
);
}
if id == 0 || id < self.id_min || id >= self.id_max || pending.contains_key(&id) {
continue;
}
pending.insert(id, tx);
return Ok(id);
}
Err(AgentClientError::IdRangeExhausted)
}
async fn write_frame(&self, id: u32, flags: u8, body: &[u8]) -> AgentClientResult<()> {
self.write_frame_owned(id, flags, body.to_vec()).await
}
async fn write_frame_owned(&self, id: u32, flags: u8, body: Vec<u8>) -> AgentClientResult<()> {
let (ack, written) = oneshot::channel();
self.writer
.send(WriterCommand {
frame: RawFrame { id, flags, body },
ack,
})
.await
.map_err(|_| AgentClientError::Closed)?;
written.await.map_err(|_| AgentClientError::Closed)?
}
}
#[cfg(feature = "stream")]
async fn perform_handshake<R>(
reader: &mut R,
deadline: Instant,
) -> AgentClientResult<AgentHandshake>
where
R: HandshakeReader + ?Sized,
{
let mut range_buf = [0u8; 8];
tokio::time::timeout_at(deadline, reader.read_exact_handshake(&mut range_buf))
.await
.map_err(|_| {
AgentClientError::Handshake("read id range: timed out before relay sent bytes".into())
})??;
let id_start_or_offset = u32::from_be_bytes(range_buf[0..4].try_into().unwrap());
let id_max_or_frame_len = u32::from_be_bytes(range_buf[4..8].try_into().unwrap());
let legacy_handshake =
looks_like_legacy_relay_handshake(id_start_or_offset, id_max_or_frame_len);
let (id_min, id_max, ready_frame, protocol) = if legacy_handshake {
let id_offset = id_start_or_offset;
let ready_frame =
read_raw_frame_after_len_prefix(reader, range_buf[4..8].try_into().unwrap(), deadline)
.await?;
(
id_offset.saturating_add(1),
id_offset.saturating_add(LEGACY_RELAY_ID_RANGE_STEP),
ready_frame,
AgentProtocol::LegacyV1,
)
} else if id_start_or_offset >= id_max_or_frame_len {
return Err(AgentClientError::Handshake(format!(
"invalid relay id range: start={id_start_or_offset}, end={id_max_or_frame_len}"
)));
} else {
let ready_frame = tokio::time::timeout_at(deadline, reader.read_frame_handshake())
.await
.map_err(|_| {
AgentClientError::Handshake(
"read ready frame: timed out before relay sent frame".into(),
)
})?
.map_err(|e| AgentClientError::Handshake(format!("read ready frame: {e}")))?;
(
id_start_or_offset,
id_max_or_frame_len,
ready_frame,
AgentProtocol::Current,
)
};
ensure_usable_id_range(id_min, id_max)?;
let ready_msg = codec::raw_frame_to_message(ready_frame.clone())
.map_err(|e| AgentClientError::Handshake(format!("decode ready frame: {e}")))?;
if ready_msg.t != MessageType::Ready {
return Err(AgentClientError::Handshake(format!(
"expected core.ready frame, got {}",
ready_msg.t.as_str()
)));
}
let ready: Ready = ready_msg
.payload()
.map_err(|e| AgentClientError::Handshake(format!("decode ready payload: {e}")))?;
let negotiated_version = protocol.version().min(ready_msg.v);
Ok(AgentHandshake {
id_min,
id_max,
protocol,
negotiated_version,
ready_body: ready_frame.body,
ready,
})
}
fn first_request_id(id_min: u32) -> u32 {
id_min.max(1)
}
#[cfg(feature = "stream")]
fn ensure_usable_id_range(id_min: u32, id_max: u32) -> AgentClientResult<()> {
if usable_id_count(id_min, id_max) == 0 {
return Err(AgentClientError::Handshake(format!(
"relay id range contains no usable nonzero ids: start={id_min}, end={id_max}"
)));
}
Ok(())
}
fn usable_id_count(id_min: u32, id_max: u32) -> u32 {
id_max.saturating_sub(first_request_id(id_min))
}
#[cfg(feature = "stream")]
fn looks_like_legacy_relay_handshake(id_min: u32, id_max: u32) -> bool {
id_max >= FRAME_HEADER_SIZE as u32
&& id_max <= MAX_FRAME_SIZE
&& (id_min == 0 || id_min >= id_max)
}
#[cfg(feature = "stream")]
async fn read_raw_frame_after_len_prefix<R>(
reader: &mut R,
len_buf: [u8; 4],
deadline: Instant,
) -> AgentClientResult<RawFrame>
where
R: HandshakeReader + ?Sized,
{
let frame_len = u32::from_be_bytes(len_buf);
if frame_len > MAX_FRAME_SIZE {
return Err(AgentClientError::Handshake(format!(
"legacy ready frame too large: {frame_len} bytes (max {MAX_FRAME_SIZE})"
)));
}
if frame_len < FRAME_HEADER_SIZE as u32 {
return Err(AgentClientError::Handshake(format!(
"legacy ready frame too short: {frame_len} bytes"
)));
}
let mut data = vec![0u8; frame_len as usize];
tokio::time::timeout_at(deadline, reader.read_exact_handshake(&mut data))
.await
.map_err(|_| {
AgentClientError::Handshake(
"read legacy ready frame: timed out before relay sent frame".into(),
)
})?
.map_err(|e| AgentClientError::Handshake(format!("read legacy ready frame: {e}")))?;
let id = u32::from_be_bytes(data[0..4].try_into().unwrap());
let flags = data[4];
let body = data[FRAME_HEADER_SIZE..].to_vec();
Ok(RawFrame { id, flags, body })
}
#[cfg(feature = "stream")]
impl<R> HandshakeReader for R
where
R: tokio::io::AsyncRead + Unpin + Send,
{
fn read_exact_handshake<'a>(
&'a mut self,
out: &'a mut [u8],
) -> Pin<Box<dyn Future<Output = AgentClientResult<()>> + Send + 'a>> {
Box::pin(async move {
tokio::io::AsyncReadExt::read_exact(self, out)
.await
.map(|_| ())
.map_err(|e| AgentClientError::Handshake(e.to_string()))
})
}
fn read_frame_handshake<'a>(
&'a mut self,
) -> Pin<Box<dyn Future<Output = AgentClientResult<RawFrame>> + Send + 'a>> {
Box::pin(async move {
codec::read_raw_frame(self)
.await
.map_err(AgentClientError::Protocol)
})
}
}
#[cfg(feature = "stream")]
async fn stream_writer_loop<W>(mut writer: W, mut rx: mpsc::Receiver<WriterCommand>)
where
W: tokio::io::AsyncWrite + Unpin,
{
while let Some(command) = rx.recv().await {
if let Err(e) = codec::write_raw_frame(&mut writer, &command.frame).await {
tracing::debug!("agent client: stream writer error: {e}");
let _ = command.ack.send(Err(AgentClientError::Protocol(e)));
break;
}
let _ = command.ack.send(Ok(()));
}
}
#[cfg(feature = "stream")]
async fn reader_loop<R>(mut reader: R, pending: Arc<Mutex<HashMap<u32, mpsc::Sender<RawFrame>>>>)
where
R: tokio::io::AsyncRead + Unpin,
{
loop {
let frame = match codec::read_raw_frame(&mut reader).await {
Ok(frame) => frame,
Err(e) => {
tracing::debug!("agent client: reader EOF or error: {e}");
break;
}
};
dispatch_frame(frame, &pending).await;
}
let mut map = pending.lock().await;
map.clear();
}
#[cfg(feature = "stream")]
async fn dispatch_frame(
frame: RawFrame,
pending: &Arc<Mutex<HashMap<u32, mpsc::Sender<RawFrame>>>>,
) {
let id = frame.id;
let is_terminal = (frame.flags & FLAG_TERMINAL) != 0;
let tx = {
let mut map = pending.lock().await;
let Some(tx) = map.get(&id).cloned() else {
tracing::trace!("agent client: no pending handler for id={id}");
return;
};
if is_terminal {
map.remove(&id);
}
tx
};
if tx.send(frame).await.is_err() {
pending.lock().await.remove(&id);
}
}
async fn decode_stream_task(mut raw_rx: mpsc::Receiver<RawFrame>, tx: mpsc::Sender<Message>) {
while let Some(frame) = raw_rx.recv().await {
match codec::raw_frame_to_message(frame) {
Ok(msg) => {
if tx.send(msg).await.is_err() {
break;
}
}
Err(e) => {
tracing::warn!("agent client: failed to decode frame in stream: {e}");
}
}
}
}
fn encode_message_body<T: Serialize>(
version: u8,
t: MessageType,
payload: &T,
) -> AgentClientResult<Vec<u8>> {
let mut msg = Message::with_payload(t, 0, payload)?;
msg.v = version;
let mut body = Vec::new();
ciborium::into_writer(&msg, &mut body).map_err(microsandbox_protocol::ProtocolError::from)?;
Ok(body)
}
#[cfg(test)]
mod tests {
#[cfg(feature = "uds")]
use microsandbox_protocol::core::Ready;
#[cfg(feature = "uds")]
use microsandbox_protocol::exec::ExecRequest;
#[cfg(feature = "uds")]
use microsandbox_protocol::message::PROTOCOL_VERSION;
#[cfg(feature = "uds")]
use tokio::io::AsyncWriteExt;
#[cfg(feature = "uds")]
use tokio::net::UnixListener;
#[cfg(feature = "uds")]
use tokio::sync::oneshot;
use super::*;
#[cfg(feature = "uds")]
#[tokio::test]
async fn connect_decodes_ready_payload() {
let temp = tempfile::tempdir().unwrap();
let sock_path = temp.path().join("agent.sock");
let listener = UnixListener::bind(&sock_path).unwrap();
let ready = Ready {
boot_time_ns: 11,
init_time_ns: 22,
ready_time_ns: 33,
agent_version: "9.9.9".to_string(),
};
let ready_msg = Message::with_payload(MessageType::Ready, 0, &ready).unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket.write_all(&1u32.to_be_bytes()).await.unwrap();
socket.write_all(&8u32.to_be_bytes()).await.unwrap();
codec::write_message(&mut socket, &ready_msg).await.unwrap();
});
let client =
AgentClient::connect_with_deadline(&sock_path, Instant::now() + Duration::from_secs(1))
.await
.unwrap();
assert_eq!(client.protocol(), AgentProtocol::Current);
assert_eq!(client.negotiated_version(), PROTOCOL_VERSION);
assert!(client.supports(MessageType::FsRequest));
assert_eq!(client.agent_version(), "9.9.9");
let decoded = client.ready().unwrap();
assert_eq!(decoded.boot_time_ns, ready.boot_time_ns);
assert_eq!(decoded.init_time_ns, ready.init_time_ns);
assert_eq!(decoded.ready_time_ns, ready.ready_time_ns);
let raw_msg: Message = ciborium::from_reader(client.ready_bytes()).unwrap();
assert_eq!(raw_msg.t, MessageType::Ready);
}
#[cfg(feature = "uds")]
#[tokio::test]
async fn connect_negotiates_down_to_older_guest_generation() {
let temp = tempfile::tempdir().unwrap();
let sock_path = temp.path().join("agent.sock");
let listener = UnixListener::bind(&sock_path).unwrap();
let ready = Ready {
boot_time_ns: 1,
init_time_ns: 2,
ready_time_ns: 3,
..Default::default()
};
let mut ready_msg = Message::with_payload(MessageType::Ready, 0, &ready).unwrap();
ready_msg.v = 1;
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket.write_all(&1u32.to_be_bytes()).await.unwrap();
socket
.write_all(µsandbox_protocol::AGENT_RELAY_ID_RANGE_STEP.to_be_bytes())
.await
.unwrap();
codec::write_message(&mut socket, &ready_msg).await.unwrap();
});
let client =
AgentClient::connect_with_deadline(&sock_path, Instant::now() + Duration::from_secs(1))
.await
.unwrap();
assert_eq!(client.protocol(), AgentProtocol::Current);
assert_eq!(client.negotiated_version(), 1);
assert!(client.supports(MessageType::ExecRequest));
assert!(!client.supports(MessageType::FsRequest));
}
#[cfg(feature = "uds")]
#[tokio::test]
async fn connect_accepts_legacy_relay_handshake() {
assert_accepts_legacy_relay_handshake(0).await;
assert_accepts_legacy_relay_handshake(268_435_455).await;
}
#[cfg(feature = "uds")]
#[tokio::test]
async fn legacy_relay_requests_use_v1_and_legacy_id_range() {
let temp = tempfile::tempdir().unwrap();
let sock_path = temp.path().join("agent.sock");
let listener = UnixListener::bind(&sock_path).unwrap();
let ready = Ready {
boot_time_ns: 11,
init_time_ns: 22,
ready_time_ns: 33,
..Default::default()
};
let ready_msg = Message::with_payload(MessageType::Ready, 0, &ready).unwrap();
let id_offset = 268_435_455u32;
let (frame_tx, frame_rx) = oneshot::channel();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket.write_all(&id_offset.to_be_bytes()).await.unwrap();
codec::write_message(&mut socket, &ready_msg).await.unwrap();
let frame = codec::read_raw_frame(&mut socket).await.unwrap();
frame_tx.send(frame).unwrap();
});
let client =
AgentClient::connect_with_deadline(&sock_path, Instant::now() + Duration::from_secs(1))
.await
.unwrap();
let request = ExecRequest {
cmd: "/bin/true".into(),
args: Vec::new(),
env: Vec::new(),
cwd: None,
user: None,
tty: false,
rows: 24,
cols: 80,
rlimits: Vec::new(),
};
let (id, _rx) = client
.stream(MessageType::ExecRequest, &request)
.await
.unwrap();
let frame = frame_rx.await.unwrap();
let message = codec::raw_frame_to_message(frame).unwrap();
assert_eq!(id, id_offset + 1);
assert_eq!(message.id, id_offset + 1);
assert_eq!(message.v, LEGACY_PROTOCOL_VERSION);
assert_eq!(message.t, MessageType::ExecRequest);
}
#[test]
fn version_compat_across_generations() {
use MessageType::{ExecRequest, FsRequest};
let cases = [
(ExecRequest, 1, true),
(ExecRequest, 2, true),
(ExecRequest, 3, true),
(FsRequest, 1, false),
(FsRequest, 2, true),
(FsRequest, 3, true),
];
for (t, generation, allowed) in cases {
assert_eq!(
AgentClient::ensure_version_compat_for(t, generation).is_ok(),
allowed,
"{t:?} at generation {generation}"
);
}
}
#[test]
fn version_compat_rejection_is_typed() {
let err =
AgentClient::ensure_version_compat_for(MessageType::FsRequest, LEGACY_PROTOCOL_VERSION)
.unwrap_err();
assert!(matches!(
err,
AgentClientError::UnsupportedOperation {
needs: 2,
peer: 1,
..
}
));
}
#[cfg(feature = "uds")]
#[tokio::test]
async fn connect_preserves_current_peer_protocol_version() {
let temp = tempfile::tempdir().unwrap();
let sock_path = temp.path().join("agent.sock");
let listener = UnixListener::bind(&sock_path).unwrap();
let ready = Ready {
boot_time_ns: 11,
init_time_ns: 22,
ready_time_ns: 33,
..Default::default()
};
let mut ready_msg = Message::with_payload(MessageType::Ready, 0, &ready).unwrap();
ready_msg.v = 2;
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket.write_all(&1u32.to_be_bytes()).await.unwrap();
socket
.write_all(µsandbox_protocol::AGENT_RELAY_ID_RANGE_STEP.to_be_bytes())
.await
.unwrap();
codec::write_message(&mut socket, &ready_msg).await.unwrap();
});
let client =
AgentClient::connect_with_deadline(&sock_path, Instant::now() + Duration::from_secs(1))
.await
.unwrap();
assert_eq!(client.protocol(), AgentProtocol::Current);
assert_eq!(client.negotiated_version(), 2);
assert!(!client.supports(MessageType::TcpConnect));
}
#[cfg(feature = "uds")]
async fn assert_accepts_legacy_relay_handshake(id_offset: u32) {
let temp = tempfile::tempdir().unwrap();
let sock_path = temp.path().join("agent.sock");
let listener = UnixListener::bind(&sock_path).unwrap();
let ready = Ready {
boot_time_ns: 11,
init_time_ns: 22,
ready_time_ns: 33,
..Default::default()
};
let ready_msg = Message::with_payload(MessageType::Ready, 0, &ready).unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket.write_all(&id_offset.to_be_bytes()).await.unwrap();
codec::write_message(&mut socket, &ready_msg).await.unwrap();
});
let client =
AgentClient::connect_with_deadline(&sock_path, Instant::now() + Duration::from_secs(1))
.await
.unwrap();
assert_eq!(client.protocol(), AgentProtocol::LegacyV1);
assert_eq!(client.negotiated_version(), LEGACY_PROTOCOL_VERSION);
let decoded = client.ready().unwrap();
assert_eq!(decoded.boot_time_ns, ready.boot_time_ns);
assert_eq!(decoded.init_time_ns, ready.init_time_ns);
assert_eq!(decoded.ready_time_ns, ready.ready_time_ns);
}
#[cfg(feature = "stream")]
#[tokio::test]
async fn connect_stream_handshakes_and_streams_exec() {
use microsandbox_protocol::exec::{ExecExited, ExecRequest, ExecStdout};
use tokio::io::AsyncWriteExt;
let (client_io, mut server_io) = tokio::io::duplex(64 * 1024);
let ready = Ready {
boot_time_ns: 11,
init_time_ns: 22,
ready_time_ns: 33,
agent_version: "stream-test".to_string(),
};
let ready_msg = Message::with_payload(MessageType::Ready, 0, &ready).unwrap();
tokio::spawn(async move {
server_io.write_all(&1u32.to_be_bytes()).await.unwrap();
server_io.write_all(&1024u32.to_be_bytes()).await.unwrap();
codec::write_message(&mut server_io, &ready_msg)
.await
.unwrap();
let request = codec::read_raw_frame(&mut server_io).await.unwrap();
let stdout = Message::with_payload(
MessageType::ExecStdout,
request.id,
&ExecStdout {
data: b"hi".to_vec(),
},
)
.unwrap();
codec::write_message(&mut server_io, &stdout).await.unwrap();
let exited =
Message::with_payload(MessageType::ExecExited, request.id, &ExecExited { code: 0 })
.unwrap();
codec::write_message(&mut server_io, &exited).await.unwrap();
});
let client = AgentClient::connect_stream_with_deadline(
client_io,
Instant::now() + Duration::from_secs(1),
)
.await
.unwrap();
assert_eq!(client.protocol(), AgentProtocol::Current);
assert_eq!(client.agent_version(), "stream-test");
assert!(client.supports(MessageType::ExecRequest));
let request = ExecRequest {
cmd: "echo".into(),
args: vec!["hi".into()],
env: Vec::new(),
cwd: None,
user: None,
tty: false,
rows: 24,
cols: 80,
rlimits: Vec::new(),
};
let (_id, mut rx) = client
.stream(MessageType::ExecRequest, &request)
.await
.unwrap();
let first = rx.recv().await.unwrap();
assert_eq!(first.t, MessageType::ExecStdout);
let out: ExecStdout = first.payload().unwrap();
assert_eq!(out.data, b"hi");
let second = rx.recv().await.unwrap();
assert_eq!(second.t, MessageType::ExecExited);
let exit: ExecExited = second.payload().unwrap();
assert_eq!(exit.code, 0);
}
}
impl Drop for AgentClient {
fn drop(&mut self) {
self.reader_handle.abort();
self.writer_handle.abort();
}
}