feat(tty): implement raw chunk codec (ChunkReader/ChunkWriter, RawError)
Port of the alknet-tty POC's raw.rs into crates/alknet-tty/src/wire.rs. The Phase 2 'raw carriage' wire format (ADR-052): [stream_type: u8][length: u32 be][payload bytes]. - STREAM_STDIN/STDOUT/STDERR/CONTROL constants (0-3) - CHUNK_HEADER_LEN (5) and MAX_CHUNK_LEN (16 MiB) - RawError: Io / ConnectionClosed / InvalidStreamType / ChunkTooLarge - Chunk struct with stdin/stdout/stderr/control constructors - ChunkReader::read_chunk validates stream_type and length, returns ConnectionClosed on clean UnexpectedEof (header or payload) - ChunkWriter::write_chunk/write_stdin/write_control_json write header + payload + flush - Round-trip tests for all four stream types, InvalidStreamType (4), ChunkTooLarge (> MAX_CHUNK_LEN), ConnectionClosed on truncated header and payload, via tokio::io::duplex
This commit is contained in:
1 parent
8c2fc42280
commit
17713a5c27
1 file changed
+375
-3
@@ -1,4 +1,376 @@
|
|||||||
//! Raw chunk wire format: `ChunkReader`/`ChunkWriter`, `Chunk`, `RawError`,
|
//! Raw chunk codec for the `alknet/tty` bidi stream (ADR-052, Phase 2
|
||||||
//! stream-type constants, and `MAX_CHUNK_LEN`.
|
//! "raw carriage").
|
||||||
|
//!
|
||||||
|
//! Wire format:
|
||||||
|
//! ```text
|
||||||
|
//! [stream_type: u8][length: u32 be][payload bytes]
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! `stream_type`:
|
||||||
|
//! - 0 = stdin (client→server, raw bytes)
|
||||||
|
//! - 1 = stdout (server→client, raw bytes)
|
||||||
|
//! - 2 = stderr (server→client, raw bytes)
|
||||||
|
//! - 3 = control (bidirectional, JSON control message — see [`crate::control`])
|
||||||
|
//!
|
||||||
|
//! Zero-length data chunks are sentinels: a zero-length stdin chunk is EOF
|
||||||
|
//! from the client; a zero-length stdout chunk is "drained" from the
|
||||||
|
//! server. Control chunks are never zero-length (the JSON payload is at
|
||||||
|
//! least `{}`). The codec does not special-case sentinels — they are just
|
||||||
|
//! chunks with `length == 0`; the adapter interprets them. See
|
||||||
|
//! `docs/architecture/crates/tty/tty-wire.md` §"Sentinels".
|
||||||
|
|
||||||
// TODO: implement
|
use std::io;
|
||||||
|
|
||||||
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||||
|
|
||||||
|
/// stdin channel (client→server, raw bytes).
|
||||||
|
pub const STREAM_STDIN: u8 = 0;
|
||||||
|
/// stdout channel (server→client, raw bytes).
|
||||||
|
pub const STREAM_STDOUT: u8 = 1;
|
||||||
|
/// stderr channel (server→client, raw bytes).
|
||||||
|
pub const STREAM_STDERR: u8 = 2;
|
||||||
|
/// control channel (bidirectional, JSON control message).
|
||||||
|
pub const STREAM_CONTROL: u8 = 3;
|
||||||
|
|
||||||
|
/// Chunk header length in bytes: 1 byte `stream_type` + 4 bytes `length`.
|
||||||
|
pub const CHUNK_HEADER_LEN: usize = 5;
|
||||||
|
/// Maximum payload length. A larger chunk is a `ChunkTooLarge` protocol
|
||||||
|
/// error. Shared with the negotiation module so error frames (which reuse
|
||||||
|
/// the 4-byte length-prefix framing) stay under 16 MiB — this keeps the
|
||||||
|
/// high byte of the length prefix `0x00`, which is what makes the
|
||||||
|
/// framing-disambiguation trick sound (see ADR-052 §5).
|
||||||
|
pub const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Errors from the raw chunk codec.
|
||||||
|
///
|
||||||
|
/// `ConnectionClosed` is returned (rather than `Io`) when `read_chunk`
|
||||||
|
/// hits a clean `UnexpectedEof` reading either the header or the payload —
|
||||||
|
/// the peer closed the stream cleanly rather than failing the transport.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum RawError {
|
||||||
|
/// Underlying transport I/O error (not a clean EOF).
|
||||||
|
#[error("io: {0}")]
|
||||||
|
Io(#[from] io::Error),
|
||||||
|
/// The peer closed the stream cleanly (unexpected EOF on header or payload).
|
||||||
|
#[error("connection closed")]
|
||||||
|
ConnectionClosed,
|
||||||
|
/// The chunk header's `stream_type` byte was > 3.
|
||||||
|
#[error("invalid chunk header: stream type {0}")]
|
||||||
|
InvalidStreamType(u8),
|
||||||
|
/// The chunk payload length exceeded `MAX_CHUNK_LEN`.
|
||||||
|
#[error("chunk too large: {0}")]
|
||||||
|
ChunkTooLarge(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single raw chunk on the wire: a `stream_type` byte's channel and the
|
||||||
|
/// payload bytes.
|
||||||
|
///
|
||||||
|
/// Construct with [`Chunk::stdin`], [`Chunk::stdout`], [`Chunk::stderr`],
|
||||||
|
/// or [`Chunk::control`] for the four fixed channels.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Chunk {
|
||||||
|
/// The channel: one of [`STREAM_STDIN`], [`STREAM_STDOUT`],
|
||||||
|
/// [`STREAM_STDERR`], [`STREAM_CONTROL`].
|
||||||
|
pub stream_type: u8,
|
||||||
|
/// The payload bytes (raw for data channels, UTF-8 JSON for control).
|
||||||
|
pub bytes: bytes::Bytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Chunk {
|
||||||
|
/// A stdin chunk (stream_type 0).
|
||||||
|
pub fn stdin(bytes: bytes::Bytes) -> Self {
|
||||||
|
Self {
|
||||||
|
stream_type: STREAM_STDIN,
|
||||||
|
bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stdout chunk (stream_type 1).
|
||||||
|
pub fn stdout(bytes: bytes::Bytes) -> Self {
|
||||||
|
Self {
|
||||||
|
stream_type: STREAM_STDOUT,
|
||||||
|
bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stderr chunk (stream_type 2).
|
||||||
|
pub fn stderr(bytes: bytes::Bytes) -> Self {
|
||||||
|
Self {
|
||||||
|
stream_type: STREAM_STDERR,
|
||||||
|
bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A control chunk (stream_type 3).
|
||||||
|
pub fn control(bytes: bytes::Bytes) -> Self {
|
||||||
|
Self {
|
||||||
|
stream_type: STREAM_CONTROL,
|
||||||
|
bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads raw chunks from an [`AsyncRead`] transport.
|
||||||
|
///
|
||||||
|
/// [`ChunkReader::read_chunk`] reads the 5-byte header, validates the
|
||||||
|
/// `stream_type` (≤ 3, else [`RawError::InvalidStreamType`]) and the
|
||||||
|
/// payload length (≤ [`MAX_CHUNK_LEN`], else [`RawError::ChunkTooLarge`]),
|
||||||
|
/// then reads the payload. On a clean `UnexpectedEof` reading either the
|
||||||
|
/// header or the payload, it returns [`RawError::ConnectionClosed`] — the
|
||||||
|
/// stream ended cleanly, not with a transport error.
|
||||||
|
pub struct ChunkReader<R: AsyncRead + Unpin> {
|
||||||
|
reader: R,
|
||||||
|
header: [u8; CHUNK_HEADER_LEN],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: AsyncRead + Unpin> ChunkReader<R> {
|
||||||
|
/// Wrap an [`AsyncRead`] transport in a chunk reader.
|
||||||
|
pub fn new(reader: R) -> Self {
|
||||||
|
Self {
|
||||||
|
reader,
|
||||||
|
header: [0u8; CHUNK_HEADER_LEN],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume the reader and return the underlying transport.
|
||||||
|
pub fn into_inner(self) -> R {
|
||||||
|
self.reader
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one chunk: header, validate, payload.
|
||||||
|
pub async fn read_chunk(&mut self) -> Result<Chunk, RawError> {
|
||||||
|
match self.reader.read_exact(&mut self.header).await {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
|
||||||
|
return Err(RawError::ConnectionClosed);
|
||||||
|
}
|
||||||
|
Err(e) => return Err(RawError::Io(e)),
|
||||||
|
}
|
||||||
|
|
||||||
|
let stream_type = self.header[0];
|
||||||
|
if stream_type > 3 {
|
||||||
|
return Err(RawError::InvalidStreamType(stream_type));
|
||||||
|
}
|
||||||
|
|
||||||
|
let length = u32::from_be_bytes([
|
||||||
|
self.header[1],
|
||||||
|
self.header[2],
|
||||||
|
self.header[3],
|
||||||
|
self.header[4],
|
||||||
|
]);
|
||||||
|
if length > MAX_CHUNK_LEN {
|
||||||
|
return Err(RawError::ChunkTooLarge(length));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut buf = vec![0u8; length as usize];
|
||||||
|
if length > 0 {
|
||||||
|
match self.reader.read_exact(&mut buf).await {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
|
||||||
|
return Err(RawError::ConnectionClosed);
|
||||||
|
}
|
||||||
|
Err(e) => return Err(RawError::Io(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Chunk {
|
||||||
|
stream_type,
|
||||||
|
bytes: bytes::Bytes::from(buf),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes raw chunks to an [`AsyncWrite`] transport.
|
||||||
|
///
|
||||||
|
/// [`ChunkWriter::write_chunk`] writes the 5-byte header then the payload
|
||||||
|
/// (if non-empty), then flushes. [`ChunkWriter::write_stdin`] and
|
||||||
|
/// [`ChunkWriter::write_control_json`] are convenience helpers for the
|
||||||
|
/// two most common write paths.
|
||||||
|
pub struct ChunkWriter<W: AsyncWrite + Unpin> {
|
||||||
|
writer: W,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W: AsyncWrite + Unpin> ChunkWriter<W> {
|
||||||
|
/// Wrap an [`AsyncWrite`] transport in a chunk writer.
|
||||||
|
pub fn new(writer: W) -> Self {
|
||||||
|
Self { writer }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume the writer and return the underlying transport.
|
||||||
|
pub fn into_inner(self) -> W {
|
||||||
|
self.writer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a chunk: header + payload (if non-empty) + flush.
|
||||||
|
pub async fn write_chunk(&mut self, chunk: &Chunk) -> Result<(), RawError> {
|
||||||
|
let mut header = [0u8; CHUNK_HEADER_LEN];
|
||||||
|
header[0] = chunk.stream_type;
|
||||||
|
let len = chunk.bytes.len() as u32;
|
||||||
|
header[1..].copy_from_slice(&len.to_be_bytes());
|
||||||
|
self.writer.write_all(&header).await?;
|
||||||
|
if !chunk.bytes.is_empty() {
|
||||||
|
self.writer.write_all(&chunk.bytes).await?;
|
||||||
|
}
|
||||||
|
self.writer.flush().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a stdin chunk (stream_type 0) directly from a byte slice.
|
||||||
|
pub async fn write_stdin(&mut self, bytes: &[u8]) -> Result<(), RawError> {
|
||||||
|
let mut header = [0u8; CHUNK_HEADER_LEN];
|
||||||
|
header[0] = STREAM_STDIN;
|
||||||
|
let len = bytes.len() as u32;
|
||||||
|
header[1..].copy_from_slice(&len.to_be_bytes());
|
||||||
|
self.writer.write_all(&header).await?;
|
||||||
|
if !bytes.is_empty() {
|
||||||
|
self.writer.write_all(bytes).await?;
|
||||||
|
}
|
||||||
|
self.writer.flush().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a control chunk (stream_type 3) carrying a JSON payload.
|
||||||
|
pub async fn write_control_json(&mut self, json: &[u8]) -> Result<(), RawError> {
|
||||||
|
let mut header = [0u8; CHUNK_HEADER_LEN];
|
||||||
|
header[0] = STREAM_CONTROL;
|
||||||
|
let len = json.len() as u32;
|
||||||
|
header[1..].copy_from_slice(&len.to_be_bytes());
|
||||||
|
self.writer.write_all(&header).await?;
|
||||||
|
self.writer.write_all(json).await?;
|
||||||
|
self.writer.flush().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use tokio::io::{duplex, AsyncWriteExt};
|
||||||
|
|
||||||
|
async fn round_trip(stream_type: u8, payload: &[u8]) {
|
||||||
|
let (mut a, mut b) = duplex(8 * 1024);
|
||||||
|
let mut writer = ChunkWriter::new(&mut a);
|
||||||
|
let mut reader = ChunkReader::new(&mut b);
|
||||||
|
|
||||||
|
let chunk = Chunk {
|
||||||
|
stream_type,
|
||||||
|
bytes: Bytes::copy_from_slice(payload),
|
||||||
|
};
|
||||||
|
writer.write_chunk(&chunk).await.unwrap();
|
||||||
|
|
||||||
|
let read = reader.read_chunk().await.unwrap();
|
||||||
|
assert_eq!(read.stream_type, stream_type);
|
||||||
|
assert_eq!(read.bytes.as_ref(), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn round_trip_stdin() {
|
||||||
|
round_trip(STREAM_STDIN, b"hello stdin").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn round_trip_stdout() {
|
||||||
|
round_trip(STREAM_STDOUT, b"hello stdout").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn round_trip_stderr() {
|
||||||
|
round_trip(STREAM_STDERR, b"hello stderr").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn round_trip_control() {
|
||||||
|
round_trip(STREAM_CONTROL, br#"{"type":"eof"}"#).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn round_trip_empty_payload() {
|
||||||
|
round_trip(STREAM_STDIN, b"").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn round_trip_write_stdin_helper() {
|
||||||
|
let (mut a, mut b) = duplex(8 * 1024);
|
||||||
|
let mut writer = ChunkWriter::new(&mut a);
|
||||||
|
let mut reader = ChunkReader::new(&mut b);
|
||||||
|
|
||||||
|
writer.write_stdin(b"piped").await.unwrap();
|
||||||
|
let read = reader.read_chunk().await.unwrap();
|
||||||
|
assert_eq!(read.stream_type, STREAM_STDIN);
|
||||||
|
assert_eq!(read.bytes.as_ref(), b"piped");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn round_trip_write_control_json_helper() {
|
||||||
|
let (mut a, mut b) = duplex(8 * 1024);
|
||||||
|
let mut writer = ChunkWriter::new(&mut a);
|
||||||
|
let mut reader = ChunkReader::new(&mut b);
|
||||||
|
|
||||||
|
let json = br#"{"type":"resize","cols":80,"rows":24}"#;
|
||||||
|
writer.write_control_json(json).await.unwrap();
|
||||||
|
let read = reader.read_chunk().await.unwrap();
|
||||||
|
assert_eq!(read.stream_type, STREAM_CONTROL);
|
||||||
|
assert_eq!(read.bytes.as_ref(), json);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_stream_type() {
|
||||||
|
let (mut a, mut b) = duplex(8 * 1024);
|
||||||
|
a.write_all(&[4u8, 0, 0, 0, 0]).await.unwrap();
|
||||||
|
a.flush().await.unwrap();
|
||||||
|
|
||||||
|
let mut reader = ChunkReader::new(&mut b);
|
||||||
|
let err = reader.read_chunk().await.unwrap_err();
|
||||||
|
assert!(matches!(err, RawError::InvalidStreamType(4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn chunk_too_large() {
|
||||||
|
let (mut a, mut b) = duplex(8 * 1024);
|
||||||
|
let over = MAX_CHUNK_LEN + 1;
|
||||||
|
a.write_all(&[0u8]).await.unwrap();
|
||||||
|
a.write_all(&over.to_be_bytes()).await.unwrap();
|
||||||
|
a.flush().await.unwrap();
|
||||||
|
|
||||||
|
let mut reader = ChunkReader::new(&mut b);
|
||||||
|
let err = reader.read_chunk().await.unwrap_err();
|
||||||
|
assert!(matches!(err, RawError::ChunkTooLarge(v) if v == over));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connection_closed_truncated_header() {
|
||||||
|
let (mut a, mut b) = duplex(8 * 1024);
|
||||||
|
a.write_all(&[0u8, 0]).await.unwrap();
|
||||||
|
a.flush().await.unwrap();
|
||||||
|
a.shutdown().await.unwrap();
|
||||||
|
|
||||||
|
let mut reader = ChunkReader::new(&mut b);
|
||||||
|
let err = reader.read_chunk().await.unwrap_err();
|
||||||
|
assert!(matches!(err, RawError::ConnectionClosed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connection_closed_truncated_payload() {
|
||||||
|
let (mut a, mut b) = duplex(8 * 1024);
|
||||||
|
a.write_all(&[0u8, 0, 0, 0, 8]).await.unwrap();
|
||||||
|
a.write_all(b"short").await.unwrap();
|
||||||
|
a.flush().await.unwrap();
|
||||||
|
a.shutdown().await.unwrap();
|
||||||
|
|
||||||
|
let mut reader = ChunkReader::new(&mut b);
|
||||||
|
let err = reader.read_chunk().await.unwrap_err();
|
||||||
|
assert!(matches!(err, RawError::ConnectionClosed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connection_closed_clean_close_no_bytes() {
|
||||||
|
let (mut a, mut b) = duplex(8 * 1024);
|
||||||
|
a.shutdown().await.unwrap();
|
||||||
|
|
||||||
|
let mut reader = ChunkReader::new(&mut b);
|
||||||
|
let err = reader.read_chunk().await.unwrap_err();
|
||||||
|
assert!(matches!(err, RawError::ConnectionClosed));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user