Add executable doctests documenting the consumer-facing contract of irtt-proto: the directional request encode/decode/verify split, Params construction and round-trip, omitted-vs-explicit-zero presence, open and echo reply round trips, packet length calculation, and HMAC verification. No conventional tests are migrated: every existing irtt-proto test exercises wire vectors, flag matrices, malformed input, layout rules, HMAC error behavior, or encode/decode round trips, which the crate's AGENTS.md treats as durable compatibility coverage rather than documentation cleanup targets.
283 lines
10 KiB
Rust
283 lines
10 KiB
Rust
//! Directional request codecs: one sender-side encoder and one receiver-side
|
|
//! structural decoder.
|
|
//!
|
|
//! The two directions are deliberately asymmetric. Encoding an ECHO request
|
|
//! needs the negotiated [`Params`] because the sender constructs a
|
|
//! negotiated-length datagram; decoding one does not, because a receiver reads
|
|
//! only the token and sequence number and treats the rest as opaque.
|
|
|
|
use crate::{
|
|
envelope,
|
|
flags::{has, FLAG_CLOSE, FLAG_OPEN, FLAG_REPLY},
|
|
layout::{echo_packet_len, PacketLayout},
|
|
params::Params,
|
|
ProtoError, Result, SEQ_SIZE, TOKEN_SIZE,
|
|
};
|
|
|
|
/// A request to construct, described in sender-side semantic terms.
|
|
///
|
|
/// Every variant borrows what it needs; the encoder owns nothing.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RequestToEncode<'a> {
|
|
/// Session-opening request. `no_test` selects the *no-test* form, which
|
|
/// adds `FLAG_CLOSE` and asks the peer to answer without creating a
|
|
/// session.
|
|
Open { params: &'a Params, no_test: bool },
|
|
/// Session teardown request.
|
|
Close { token: u64 },
|
|
/// Round-trip probe. `params` supplies the negotiated packet layout and
|
|
/// length, and `payload` is placed at the negotiated payload offset.
|
|
Echo {
|
|
token: u64,
|
|
sequence: u32,
|
|
params: &'a Params,
|
|
payload: &'a [u8],
|
|
},
|
|
}
|
|
|
|
/// A structurally decoded inbound request, borrowed from the packet.
|
|
///
|
|
/// `hmac_present` reports only that `FLAG_HMAC` was set and that the packet is
|
|
/// laid out with an authentication field. It is **not** an authentication
|
|
/// result: the field may hold arbitrary bytes. Use [`verify_packet_hmac`] to
|
|
/// check the MAC once the applicable key is known.
|
|
///
|
|
/// [`verify_packet_hmac`]: crate::verify_packet_hmac
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct DecodedRequest<'a> {
|
|
pub hmac_present: bool,
|
|
pub kind: DecodedRequestKind<'a>,
|
|
}
|
|
|
|
/// The request kinds a receiver can distinguish from the packet alone.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DecodedRequestKind<'a> {
|
|
/// `FLAG_OPEN` was set. `params` is the still-encoded parameter payload;
|
|
/// see [`decode_request`] for why it is not decoded here.
|
|
Open { no_test: bool, params: &'a [u8] },
|
|
/// `FLAG_CLOSE` was set without `FLAG_OPEN`. Trailing bytes after the token
|
|
/// are accepted and not exposed, because they carry no meaning.
|
|
Close { token: u64 },
|
|
/// Neither `FLAG_OPEN` nor `FLAG_CLOSE` was set. `tail` is everything after
|
|
/// the sequence number, which is opaque to a receiver.
|
|
Echo {
|
|
token: u64,
|
|
sequence: u32,
|
|
tail: &'a [u8],
|
|
},
|
|
}
|
|
|
|
/// Encodes an outbound request.
|
|
///
|
|
/// This is sender-side semantic construction: the variant determines the flags,
|
|
/// so callers never assemble flag bits themselves.
|
|
///
|
|
/// `hmac_key` is authoritative. `Some(key)` sets `FLAG_HMAC`, reserves the
|
|
/// 16-byte authentication field after the header, and signs the finished
|
|
/// datagram; `None` produces an unauthenticated packet.
|
|
///
|
|
/// [`RequestToEncode::Echo`] requires the negotiated [`Params`] because the
|
|
/// sender must build a datagram of the negotiated length with the payload at
|
|
/// the negotiated offset. The receiving side needs no `Params` — see
|
|
/// [`decode_request`].
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`ProtoError::PayloadTooLarge`] when an ECHO payload does not fit
|
|
/// the negotiated length, and [`ProtoError::PacketLengthUnrepresentable`] when
|
|
/// the negotiated length is positive and wider than `usize`. A *negative*
|
|
/// negotiated length is not an error: see [`echo_packet_len`], which floors it
|
|
/// at the mandatory field block.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```
|
|
/// use irtt_proto::{encode_request, Params, RequestToEncode};
|
|
///
|
|
/// // An unauthenticated ECHO probe. `params` supplies the negotiated length,
|
|
/// // which reserves four payload bytes beyond the 16-byte echo header.
|
|
/// let params = Params { length: 20, ..Params::default() };
|
|
/// let packet = encode_request(
|
|
/// RequestToEncode::Echo {
|
|
/// token: 0x1234_5678_9abc_def0,
|
|
/// sequence: 7,
|
|
/// params: ¶ms,
|
|
/// payload: &[1, 2, 3, 4],
|
|
/// },
|
|
/// None,
|
|
/// ).unwrap();
|
|
/// assert_eq!(packet.len(), 20);
|
|
/// ```
|
|
pub fn encode_request(request: RequestToEncode<'_>, hmac_key: Option<&[u8]>) -> Result<Vec<u8>> {
|
|
match request {
|
|
RequestToEncode::Open { params, no_test } => encode_open(params, no_test, hmac_key),
|
|
RequestToEncode::Close { token } => encode_close(token, hmac_key),
|
|
RequestToEncode::Echo {
|
|
token,
|
|
sequence,
|
|
params,
|
|
payload,
|
|
} => encode_echo(token, sequence, params, payload, hmac_key),
|
|
}
|
|
}
|
|
|
|
/// Structurally decodes an inbound request.
|
|
///
|
|
/// This is packet structure only — it is **not** admission control. It performs
|
|
/// no token or session lookup, applies no server policy, and needs no HMAC key,
|
|
/// precisely so that a receiver can extract a token *before* it knows which
|
|
/// session and key apply.
|
|
///
|
|
/// Classification, for a packet with valid magic and no reserved flag bits set:
|
|
///
|
|
/// - `FLAG_REPLY` set — rejected; a reply is never an inbound request.
|
|
/// - `FLAG_OPEN` set — [`DecodedRequestKind::Open`], with `no_test` from
|
|
/// `FLAG_CLOSE`. **Open takes precedence over Close and over an echo-shaped
|
|
/// body**: an `OPEN | CLOSE` packet is a no-test open, and bytes that look
|
|
/// like a token and sequence number are parameter data.
|
|
/// - otherwise `FLAG_CLOSE` set — [`DecodedRequestKind::Close`].
|
|
/// - otherwise — [`DecodedRequestKind::Echo`].
|
|
///
|
|
/// `FLAG_HMAC` is orthogonal to all of the above and is reported as
|
|
/// [`DecodedRequest::hmac_present`], which indicates field presence rather than
|
|
/// valid authentication.
|
|
///
|
|
/// Open parameters are returned as encoded bytes rather than a decoded
|
|
/// [`Params`], so that an authenticating receiver can verify a packet before
|
|
/// spending effort on untrusted parameter data. A packet with malformed
|
|
/// parameters decodes structurally and fails later at [`Params::decode`].
|
|
///
|
|
/// Only structural minimum lengths are enforced — 4/12/16 bytes for
|
|
/// Open/Close/Echo, plus 16 with `FLAG_HMAC`. Trailing bytes beyond a kind's
|
|
/// fields are tolerated, no negotiated length ceiling is applied, and a zero
|
|
/// token is accepted; discarding stale, unknown, or oversized requests is the
|
|
/// receiving layer's policy.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`ProtoError::PacketTooShort`], [`ProtoError::BadMagic`],
|
|
/// [`ProtoError::ReservedFlags`], or [`ProtoError::UnexpectedFlag`] for
|
|
/// `FLAG_REPLY`.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```
|
|
/// use irtt_proto::{decode_request, encode_request, DecodedRequestKind, RequestToEncode};
|
|
///
|
|
/// let packet = encode_request(
|
|
/// RequestToEncode::Close { token: 0x1234_5678_9abc_def0 },
|
|
/// None,
|
|
/// ).unwrap();
|
|
///
|
|
/// let request = decode_request(&packet).unwrap();
|
|
/// assert!(!request.hmac_present);
|
|
/// assert_eq!(request.kind, DecodedRequestKind::Close { token: 0x1234_5678_9abc_def0 });
|
|
/// ```
|
|
pub fn decode_request(packet: &[u8]) -> Result<DecodedRequest<'_>> {
|
|
let envelope = envelope::decode_structural(packet)?;
|
|
if has(envelope.flags, FLAG_REPLY) {
|
|
return Err(ProtoError::UnexpectedFlag(FLAG_REPLY));
|
|
}
|
|
|
|
let body = envelope.body_offset;
|
|
let kind = if has(envelope.flags, FLAG_OPEN) {
|
|
// Open wins over Close and over an echo-shaped body.
|
|
require_len(packet, body)?;
|
|
DecodedRequestKind::Open {
|
|
no_test: has(envelope.flags, FLAG_CLOSE),
|
|
params: &packet[body..],
|
|
}
|
|
} else if has(envelope.flags, FLAG_CLOSE) {
|
|
require_len(packet, body + TOKEN_SIZE)?;
|
|
DecodedRequestKind::Close {
|
|
token: read_token(packet, body),
|
|
}
|
|
} else {
|
|
let tail_offset = body + TOKEN_SIZE + SEQ_SIZE;
|
|
require_len(packet, tail_offset)?;
|
|
DecodedRequestKind::Echo {
|
|
token: read_token(packet, body),
|
|
sequence: read_sequence(packet, body + TOKEN_SIZE),
|
|
tail: &packet[tail_offset..],
|
|
}
|
|
};
|
|
|
|
Ok(DecodedRequest {
|
|
hmac_present: envelope.hmac_present,
|
|
kind,
|
|
})
|
|
}
|
|
|
|
fn encode_open(params: &Params, no_test: bool, hmac_key: Option<&[u8]>) -> Result<Vec<u8>> {
|
|
let flags = if no_test {
|
|
FLAG_OPEN | FLAG_CLOSE
|
|
} else {
|
|
FLAG_OPEN
|
|
};
|
|
let encoded = params.encode();
|
|
let mut out = envelope::begin(
|
|
flags,
|
|
hmac_key,
|
|
PacketLayout::open_request(hmac_key.is_some()).header_len() + encoded.len(),
|
|
)?;
|
|
out.extend_from_slice(&encoded);
|
|
envelope::finish(out, hmac_key)
|
|
}
|
|
|
|
fn encode_close(token: u64, hmac_key: Option<&[u8]>) -> Result<Vec<u8>> {
|
|
let mut out = envelope::begin(
|
|
FLAG_CLOSE,
|
|
hmac_key,
|
|
PacketLayout::close_request(hmac_key.is_some()).header_len(),
|
|
)?;
|
|
out.extend_from_slice(&token.to_le_bytes());
|
|
envelope::finish(out, hmac_key)
|
|
}
|
|
|
|
fn encode_echo(
|
|
token: u64,
|
|
sequence: u32,
|
|
params: &Params,
|
|
payload: &[u8],
|
|
hmac_key: Option<&[u8]>,
|
|
) -> Result<Vec<u8>> {
|
|
let layout = PacketLayout::echo(hmac_key.is_some(), params);
|
|
let len = echo_packet_len(hmac_key.is_some(), params)?;
|
|
let payload_offset = layout.header_len();
|
|
let available_payload_len = len.saturating_sub(payload_offset);
|
|
if payload.len() > available_payload_len {
|
|
return Err(ProtoError::PayloadTooLarge {
|
|
available: available_payload_len,
|
|
provided: payload.len(),
|
|
});
|
|
}
|
|
|
|
let mut out = envelope::begin(0, hmac_key, len)?;
|
|
out.extend_from_slice(&token.to_le_bytes());
|
|
out.extend_from_slice(&sequence.to_le_bytes());
|
|
// Everything from here to the negotiated length is zeroed: the reply-field
|
|
// positions the sender does not fill in, then the payload region.
|
|
out.resize(len, 0);
|
|
out[payload_offset..payload_offset + payload.len()].copy_from_slice(payload);
|
|
|
|
envelope::finish(out, hmac_key)
|
|
}
|
|
|
|
fn require_len(packet: &[u8], needed: usize) -> Result<()> {
|
|
if packet.len() < needed {
|
|
return Err(ProtoError::PacketTooShort {
|
|
needed,
|
|
actual: packet.len(),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn read_token(packet: &[u8], offset: usize) -> u64 {
|
|
u64::from_le_bytes(packet[offset..offset + TOKEN_SIZE].try_into().unwrap())
|
|
}
|
|
|
|
fn read_sequence(packet: &[u8], offset: usize) -> u32 {
|
|
u32::from_le_bytes(packet[offset..offset + SEQ_SIZE].try_into().unwrap())
|
|
}
|