From e908c4ce4f7ba5fe207acb1fe841349813bbe8f1 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Tue, 4 Aug 2026 00:27:45 +0700 Subject: [PATCH 1/3] refactor(proto): centralize packet envelope handling --- crates/irtt-proto/src/close.rs | 33 +++--- crates/irtt-proto/src/echo.rs | 94 ++++++++-------- crates/irtt-proto/src/envelope.rs | 180 ++++++++++++++++++++++++++++++ crates/irtt-proto/src/lib.rs | 3 + crates/irtt-proto/src/open.rs | 83 +++++--------- 5 files changed, 273 insertions(+), 120 deletions(-) create mode 100644 crates/irtt-proto/src/envelope.rs diff --git a/crates/irtt-proto/src/close.rs b/crates/irtt-proto/src/close.rs index 4f876d9..f1a8d6b 100644 --- a/crates/irtt-proto/src/close.rs +++ b/crates/irtt-proto/src/close.rs @@ -1,6 +1,8 @@ use crate::{ - flags::{FLAG_CLOSE, FLAG_HMAC}, - hmac, write_header, Result, HMAC_SIZE, + envelope::{self, FlagRule}, + flags::{FLAG_CLOSE, FLAG_OPEN, FLAG_REPLY}, + layout::PacketLayout, + Result, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -9,27 +11,24 @@ pub struct CloseRequest { } pub fn encode_close_request(request: &CloseRequest, hmac_key: Option<&[u8]>) -> Result> { - let mut flags = FLAG_CLOSE; - if hmac_key.is_some() { - flags |= FLAG_HMAC; - } - - let mut out = Vec::new(); - write_header(&mut out, flags); - if hmac_key.is_some() { - out.extend_from_slice(&[0; HMAC_SIZE]); - } + let mut out = envelope::begin( + FLAG_CLOSE, + hmac_key, + &[ + FlagRule::Require(FLAG_CLOSE), + FlagRule::Reject(FLAG_OPEN), + FlagRule::Reject(FLAG_REPLY), + ], + PacketLayout::close_request(hmac_key.is_some()).header_len(), + )?; out.extend_from_slice(&request.token.to_le_bytes()); - if let Some(key) = hmac_key { - hmac::compute_hmac_in_place(key, &mut out, hmac::hmac_offset())?; - } - Ok(out) + envelope::finish(out, hmac_key) } #[cfg(test)] mod tests { use super::*; - use crate::{hmac, FLAG_HMAC}; + use crate::{hmac, FLAG_HMAC, HMAC_SIZE}; #[test] fn hmac_close_request_places_token_after_hmac() { diff --git a/crates/irtt-proto/src/echo.rs b/crates/irtt-proto/src/echo.rs index 04bf2ed..56983e3 100644 --- a/crates/irtt-proto/src/echo.rs +++ b/crates/irtt-proto/src/echo.rs @@ -1,11 +1,9 @@ use crate::{ - flags::{has, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY}, - hmac, + envelope::{self, FlagRule}, + flags::{FLAG_CLOSE, FLAG_OPEN, FLAG_REPLY}, layout::{echo_packet_len, PacketLayout}, - open::{check_hmac_presence, reject}, params::Params, - validate_header, write_header, ProtoError, Result, HEADER_SIZE, HMAC_SIZE, RECV_COUNT_SIZE, - RECV_WINDOW_SIZE, SEQ_SIZE, TIMESTAMP_SIZE, TOKEN_SIZE, + ProtoError, Result, RECV_COUNT_SIZE, RECV_WINDOW_SIZE, SEQ_SIZE, TIMESTAMP_SIZE, TOKEN_SIZE, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -38,10 +36,6 @@ pub struct TimestampFields { } pub fn encode_echo_request(request: &EchoRequest, hmac_key: Option<&[u8]>) -> Result> { - let mut flags = 0; - if hmac_key.is_some() { - flags |= FLAG_HMAC; - } let layout = PacketLayout::echo(hmac_key.is_some(), &request.params); let len = echo_packet_len(hmac_key.is_some(), &request.params)?; let payload_offset = layout.header_len(); @@ -52,21 +46,23 @@ pub fn encode_echo_request(request: &EchoRequest, hmac_key: Option<&[u8]>) -> Re provided: request.payload.len(), }); } - let mut out = Vec::with_capacity(len); - write_header(&mut out, flags); - if hmac_key.is_some() { - out.extend_from_slice(&[0; HMAC_SIZE]); - } + let mut out = envelope::begin( + 0, + hmac_key, + &[ + FlagRule::Reject(FLAG_OPEN), + FlagRule::Reject(FLAG_REPLY), + FlagRule::Reject(FLAG_CLOSE), + ], + len, + )?; out.extend_from_slice(&request.token.to_le_bytes()); out.extend_from_slice(&request.sequence.to_le_bytes()); push_zeroed_layout_tail(layout, &mut out); out.resize(len, 0); out[payload_offset..payload_offset + request.payload.len()].copy_from_slice(&request.payload); - if let Some(key) = hmac_key { - hmac::compute_hmac_in_place(key, &mut out, hmac::hmac_offset())?; - } - Ok(out) + envelope::finish(out, hmac_key) } pub fn decode_echo_reply( @@ -74,35 +70,17 @@ pub fn decode_echo_reply( params: &Params, hmac_key: Option<&[u8]>, ) -> Result { - let flags = validate_header(packet)?; - reject(flags, FLAG_OPEN)?; - crate::open::require(flags, FLAG_REPLY)?; - check_hmac_presence(flags, hmac_key)?; + let envelope = envelope::decode( + packet, + hmac_key, + &[FlagRule::Reject(FLAG_OPEN), FlagRule::Require(FLAG_REPLY)], + )?; + let layout = PacketLayout::echo(hmac_key.is_some(), params); + validate_echo_length(packet, params, layout)?; + envelope::verify(packet, hmac_key)?; - let layout = PacketLayout::echo(has(flags, FLAG_HMAC), params); let header_len = layout.header_len(); - if packet.len() < header_len { - return Err(ProtoError::PacketTooShort { - needed: header_len, - actual: packet.len(), - }); - } - let expected_len = echo_packet_len(has(flags, FLAG_HMAC), params)?; - if packet.len() != expected_len { - return Err(ProtoError::PacketLengthMismatch { - expected: expected_len, - actual: packet.len(), - }); - } - - if let Some(key) = hmac_key { - hmac::verify_hmac(key, packet, hmac::hmac_offset())?; - } - - let mut pos = HEADER_SIZE; - if layout.hmac { - pos += HMAC_SIZE; - } + let mut pos = envelope.body_offset; let token = read_u64(packet, &mut pos); let sequence = read_u32(packet, &mut pos); let recv_count = layout.recv_count.then(|| read_u32(packet, &mut pos)); @@ -117,7 +95,7 @@ pub fn decode_echo_reply( }; Ok(EchoReply { - flags, + flags: envelope.flags, token, sequence, recv_count, @@ -127,6 +105,24 @@ pub fn decode_echo_reply( }) } +fn validate_echo_length(packet: &[u8], params: &Params, layout: PacketLayout) -> Result<()> { + let header_len = layout.header_len(); + if packet.len() < header_len { + return Err(ProtoError::PacketTooShort { + needed: header_len, + actual: packet.len(), + }); + } + let expected_len = echo_packet_len(layout.hmac, params)?; + if packet.len() != expected_len { + return Err(ProtoError::PacketLengthMismatch { + expected: expected_len, + actual: packet.len(), + }); + } + Ok(()) +} + fn push_zeroed_layout_tail(layout: PacketLayout, out: &mut Vec) { if layout.recv_count { out.extend_from_slice(&[0; RECV_COUNT_SIZE]); @@ -160,7 +156,11 @@ fn read_i64(packet: &[u8], pos: &mut usize) -> i64 { #[cfg(test)] mod tests { use super::*; - use crate::params::{Clock, ReceivedStats, StampAt}; + use crate::{ + hmac, + params::{Clock, ReceivedStats, StampAt}, + write_header, FLAG_HMAC, HMAC_SIZE, + }; fn default_params() -> Params { Params { diff --git a/crates/irtt-proto/src/envelope.rs b/crates/irtt-proto/src/envelope.rs new file mode 100644 index 0000000..cce9920 --- /dev/null +++ b/crates/irtt-proto/src/envelope.rs @@ -0,0 +1,180 @@ +use crate::{ + flags::{self, has, FLAG_HMAC}, + hmac, validate_header, write_header, ProtoError, Result, HEADER_SIZE, HMAC_SIZE, +}; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum FlagRule { + Require(u8), + Reject(u8), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Envelope { + pub(crate) flags: u8, + pub(crate) body_offset: usize, +} + +pub(crate) fn decode( + packet: &[u8], + hmac_key: Option<&[u8]>, + rules: &[FlagRule], +) -> Result { + let flags = validate_header(packet)?; + validate_packet_type(flags, rules)?; + check_hmac_presence(flags, hmac_key)?; + + Ok(Envelope { + flags, + body_offset: HEADER_SIZE + if has(flags, FLAG_HMAC) { HMAC_SIZE } else { 0 }, + }) +} + +pub(crate) fn begin( + flags: u8, + hmac_key: Option<&[u8]>, + rules: &[FlagRule], + capacity: usize, +) -> Result> { + // The encoder key is authoritative: authenticated encoders set FLAG_HMAC, + // while unauthenticated encoders clear any caller-supplied FLAG_HMAC. + let flags = with_hmac_flag(flags, hmac_key.is_some()); + flags::validate_flags(flags)?; + validate_packet_type(flags, rules)?; + + let minimum_capacity = HEADER_SIZE + if hmac_key.is_some() { HMAC_SIZE } else { 0 }; + let mut out = Vec::with_capacity(capacity.max(minimum_capacity)); + write_header(&mut out, flags); + if hmac_key.is_some() { + out.extend_from_slice(&[0; HMAC_SIZE]); + } + Ok(out) +} + +pub(crate) fn verify(packet: &[u8], hmac_key: Option<&[u8]>) -> Result<()> { + if let Some(key) = hmac_key { + hmac::verify_hmac(key, packet, hmac::hmac_offset())?; + } + Ok(()) +} + +pub(crate) fn finish(mut packet: Vec, hmac_key: Option<&[u8]>) -> Result> { + if let Some(key) = hmac_key { + hmac::compute_hmac_in_place(key, &mut packet, hmac::hmac_offset())?; + } + Ok(packet) +} + +fn validate_packet_type(flags: u8, rules: &[FlagRule]) -> Result<()> { + for rule in rules { + match *rule { + FlagRule::Require(flag) if !has(flags, flag) => { + return Err(ProtoError::MissingFlag(flag)); + } + FlagRule::Reject(flag) if has(flags, flag) => { + return Err(ProtoError::UnexpectedFlag(flag)); + } + FlagRule::Require(_) | FlagRule::Reject(_) => {} + } + } + Ok(()) +} + +fn check_hmac_presence(flags: u8, hmac_key: Option<&[u8]>) -> Result<()> { + if has(flags, FLAG_HMAC) == hmac_key.is_some() { + Ok(()) + } else { + Err(ProtoError::HmacPresenceMismatch) + } +} + +fn with_hmac_flag(flags: u8, authenticated: bool) -> u8 { + if authenticated { + flags | FLAG_HMAC + } else { + flags & !FLAG_HMAC + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + flags::{FLAG_OPEN, FLAG_REPLY}, + MAGIC, + }; + + const KEY: &[u8] = b"testkey"; + const OPEN_REQUEST_RULES: &[FlagRule] = + &[FlagRule::Require(FLAG_OPEN), FlagRule::Reject(FLAG_REPLY)]; + + fn header(flags: u8) -> Vec { + let mut packet = MAGIC.to_vec(); + packet.push(flags); + packet + } + + #[test] + fn decode_rejects_bad_magic_and_reserved_flags() { + assert_eq!( + decode(&[0, MAGIC[1], MAGIC[2], FLAG_OPEN], None, &[]), + Err(ProtoError::BadMagic) + ); + assert_eq!( + decode(&header(FLAG_OPEN | 0x10), None, &[]), + Err(ProtoError::ReservedFlags(0x10)) + ); + } + + #[test] + fn decode_enforces_required_and_rejected_flags() { + assert_eq!( + decode(&header(0), None, OPEN_REQUEST_RULES), + Err(ProtoError::MissingFlag(FLAG_OPEN)) + ); + assert_eq!( + decode(&header(FLAG_OPEN | FLAG_REPLY), None, OPEN_REQUEST_RULES), + Err(ProtoError::UnexpectedFlag(FLAG_REPLY)) + ); + } + + #[test] + fn decode_rejects_hmac_presence_mismatch() { + let mut authenticated = header(FLAG_OPEN | FLAG_HMAC); + authenticated.extend_from_slice(&[0; HMAC_SIZE]); + assert_eq!( + decode(&authenticated, None, OPEN_REQUEST_RULES), + Err(ProtoError::HmacPresenceMismatch) + ); + assert_eq!( + decode(&header(FLAG_OPEN), Some(KEY), OPEN_REQUEST_RULES), + Err(ProtoError::HmacPresenceMismatch) + ); + } + + #[test] + fn verify_rejects_bad_hmac() { + let mut packet = begin(FLAG_OPEN, Some(KEY), OPEN_REQUEST_RULES, 22).unwrap(); + packet.extend_from_slice(&[1, 2]); + let mut packet = finish(packet, Some(KEY)).unwrap(); + *packet.last_mut().unwrap() ^= 0x80; + + decode(&packet, Some(KEY), OPEN_REQUEST_RULES).unwrap(); + assert_eq!(verify(&packet, Some(KEY)), Err(ProtoError::BadHmac)); + } + + #[test] + fn encoder_key_authoritatively_normalizes_hmac_flag() { + let plain = begin(FLAG_OPEN | FLAG_HMAC, None, OPEN_REQUEST_RULES, HEADER_SIZE).unwrap(); + assert_eq!(plain[3], FLAG_OPEN); + + let authenticated = begin( + FLAG_OPEN, + Some(KEY), + OPEN_REQUEST_RULES, + HEADER_SIZE + HMAC_SIZE, + ) + .unwrap(); + assert_eq!(authenticated[3], FLAG_OPEN | FLAG_HMAC); + } +} diff --git a/crates/irtt-proto/src/lib.rs b/crates/irtt-proto/src/lib.rs index e185347..c72a6c5 100644 --- a/crates/irtt-proto/src/lib.rs +++ b/crates/irtt-proto/src/lib.rs @@ -15,11 +15,14 @@ //! The crate also provides packet layout calculation, open/echo/close packet //! encoding and decoding, and optional HMAC placement, computation, and //! verification helpers. +//! Encoder `hmac_key` arguments are authoritative: `Some(key)` adds +//! `FLAG_HMAC`, while `None` removes it from caller-supplied reply flags. //! #![forbid(unsafe_code)] pub mod close; pub mod echo; +mod envelope; pub mod error; pub mod flags; pub mod hmac; diff --git a/crates/irtt-proto/src/open.rs b/crates/irtt-proto/src/open.rs index 23c1940..aeb5261 100644 --- a/crates/irtt-proto/src/open.rs +++ b/crates/irtt-proto/src/open.rs @@ -1,8 +1,9 @@ use crate::{ - flags::{has, FLAG_CLOSE, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY}, - hmac, + envelope::{self, FlagRule}, + flags::{has, FLAG_CLOSE, FLAG_OPEN, FLAG_REPLY}, + layout::PacketLayout, params::Params, - validate_header, write_header, ProtoError, Result, HEADER_SIZE, HMAC_SIZE, TOKEN_SIZE, + ProtoError, Result, TOKEN_SIZE, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -23,36 +24,26 @@ pub fn encode_open_request(request: &OpenRequest, hmac_key: Option<&[u8]>) -> Re if request.close { flags |= FLAG_CLOSE; } - if hmac_key.is_some() { - flags |= FLAG_HMAC; - } - - let mut out = Vec::new(); - write_header(&mut out, flags); - if hmac_key.is_some() { - out.extend_from_slice(&[0; HMAC_SIZE]); - } - out.extend_from_slice(&request.params.encode()); - - if let Some(key) = hmac_key { - hmac::compute_hmac_in_place(key, &mut out, hmac::hmac_offset())?; - } - Ok(out) + let params = request.params.encode(); + let mut out = envelope::begin( + flags, + hmac_key, + &[FlagRule::Require(FLAG_OPEN), FlagRule::Reject(FLAG_REPLY)], + PacketLayout::open_request(hmac_key.is_some()).header_len() + params.len(), + )?; + out.extend_from_slice(¶ms); + envelope::finish(out, hmac_key) } pub fn decode_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> Result { - let flags = validate_header(packet)?; - require(flags, FLAG_OPEN)?; - require(flags, FLAG_REPLY)?; - check_hmac_presence(flags, hmac_key)?; - if let Some(key) = hmac_key { - hmac::verify_hmac(key, packet, hmac::hmac_offset())?; - } + let envelope = envelope::decode( + packet, + hmac_key, + &[FlagRule::Require(FLAG_OPEN), FlagRule::Require(FLAG_REPLY)], + )?; + envelope::verify(packet, hmac_key)?; - let mut pos = HEADER_SIZE; - if has(flags, FLAG_HMAC) { - pos += HMAC_SIZE; - } + let mut pos = envelope.body_offset; let needed = pos + TOKEN_SIZE; if packet.len() < needed { return Err(ProtoError::PacketTooShort { @@ -62,45 +53,25 @@ pub fn decode_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> Result Result<()> { - if has(flags, flag) { - Ok(()) - } else { - Err(ProtoError::MissingFlag(flag)) - } -} - -pub(crate) fn reject(flags: u8, flag: u8) -> Result<()> { - if has(flags, flag) { - Err(ProtoError::UnexpectedFlag(flag)) - } else { - Ok(()) - } -} - -pub(crate) fn check_hmac_presence(flags: u8, hmac_key: Option<&[u8]>) -> Result<()> { - if has(flags, FLAG_HMAC) == hmac_key.is_some() { - Ok(()) - } else { - Err(ProtoError::HmacPresenceMismatch) - } -} - #[cfg(test)] mod tests { use super::*; - use crate::params::{Clock, ReceivedStats, StampAt}; + use crate::{ + hmac, + params::{Clock, ReceivedStats, StampAt}, + FLAG_HMAC, HMAC_SIZE, + }; fn default_params() -> Params { Params { From cc7f7fc9b2da4f13e4f12dd5a48a3cf6242eba3a Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Tue, 4 Aug 2026 00:30:08 +0700 Subject: [PATCH 2/3] feat(proto): add directional open and close codecs --- crates/irtt-proto/src/close.rs | 33 ++++- crates/irtt-proto/src/lib.rs | 7 +- crates/irtt-proto/src/open.rs | 31 ++++ crates/irtt-proto/tests/directional_codecs.rs | 135 ++++++++++++++++++ crates/irtt-proto/tests/vectors.rs | 41 +++++- 5 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 crates/irtt-proto/tests/directional_codecs.rs diff --git a/crates/irtt-proto/src/close.rs b/crates/irtt-proto/src/close.rs index f1a8d6b..9e031f0 100644 --- a/crates/irtt-proto/src/close.rs +++ b/crates/irtt-proto/src/close.rs @@ -1,8 +1,8 @@ use crate::{ envelope::{self, FlagRule}, - flags::{FLAG_CLOSE, FLAG_OPEN, FLAG_REPLY}, + flags::{has, FLAG_CLOSE, FLAG_OPEN, FLAG_REPLY}, layout::PacketLayout, - Result, + ProtoError, Result, TOKEN_SIZE, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -25,6 +25,35 @@ pub fn encode_close_request(request: &CloseRequest, hmac_key: Option<&[u8]>) -> envelope::finish(out, hmac_key) } +pub fn decode_close_request(packet: &[u8], hmac_key: Option<&[u8]>) -> Result { + let envelope = envelope::decode( + packet, + hmac_key, + &[ + FlagRule::Require(FLAG_CLOSE), + FlagRule::Reject(FLAG_OPEN), + FlagRule::Reject(FLAG_REPLY), + ], + )?; + let expected_len = + PacketLayout::close_request(has(envelope.flags, crate::FLAG_HMAC)).header_len(); + if packet.len() != expected_len { + return Err(ProtoError::PacketLengthMismatch { + expected: expected_len, + actual: packet.len(), + }); + } + envelope::verify(packet, hmac_key)?; + + Ok(CloseRequest { + token: u64::from_le_bytes( + packet[envelope.body_offset..envelope.body_offset + TOKEN_SIZE] + .try_into() + .unwrap(), + ), + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/irtt-proto/src/lib.rs b/crates/irtt-proto/src/lib.rs index c72a6c5..a4d007c 100644 --- a/crates/irtt-proto/src/lib.rs +++ b/crates/irtt-proto/src/lib.rs @@ -31,13 +31,16 @@ pub mod open; pub mod params; pub mod varint; -pub use close::{encode_close_request, CloseRequest}; +pub use close::{decode_close_request, encode_close_request, CloseRequest}; pub use echo::{decode_echo_reply, encode_echo_request, EchoReply, EchoRequest, TimestampFields}; pub use error::{ProtoError, Result}; pub use flags::*; pub use hmac::{compute_hmac, compute_hmac_in_place, verify_hmac}; pub use layout::{echo_header_len, echo_packet_len, PacketLayout}; -pub use open::{decode_open_reply, encode_open_request, OpenReply, OpenRequest}; +pub use open::{ + decode_open_reply, decode_open_request, encode_open_reply, encode_open_request, OpenReply, + OpenRequest, +}; pub use params::{Clock, Params, ReceivedStats, ServerFill, StampAt, MAX_SERVER_FILL_BYTES}; pub const MAGIC: [u8; 3] = [0x14, 0xA7, 0x5B]; diff --git a/crates/irtt-proto/src/open.rs b/crates/irtt-proto/src/open.rs index aeb5261..d48f041 100644 --- a/crates/irtt-proto/src/open.rs +++ b/crates/irtt-proto/src/open.rs @@ -35,6 +35,37 @@ pub fn encode_open_request(request: &OpenRequest, hmac_key: Option<&[u8]>) -> Re envelope::finish(out, hmac_key) } +pub fn decode_open_request(packet: &[u8], hmac_key: Option<&[u8]>) -> Result { + let envelope = envelope::decode( + packet, + hmac_key, + &[FlagRule::Require(FLAG_OPEN), FlagRule::Reject(FLAG_REPLY)], + )?; + envelope::verify(packet, hmac_key)?; + + Ok(OpenRequest { + params: Params::decode(&packet[envelope.body_offset..])?, + close: has(envelope.flags, FLAG_CLOSE), + }) +} + +pub fn encode_open_reply(reply: &OpenReply, hmac_key: Option<&[u8]>) -> Result> { + let params = reply.params.encode(); + let mut out = envelope::begin( + reply.flags, + hmac_key, + &[FlagRule::Require(FLAG_OPEN), FlagRule::Require(FLAG_REPLY)], + PacketLayout::open_reply(hmac_key.is_some()).header_len() + params.len(), + )?; + if reply.token == 0 && !has(reply.flags, FLAG_CLOSE) { + return Err(ProtoError::ZeroToken); + } + + out.extend_from_slice(&reply.token.to_le_bytes()); + out.extend_from_slice(¶ms); + envelope::finish(out, hmac_key) +} + pub fn decode_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> Result { let envelope = envelope::decode( packet, diff --git a/crates/irtt-proto/tests/directional_codecs.rs b/crates/irtt-proto/tests/directional_codecs.rs new file mode 100644 index 0000000..cf985c6 --- /dev/null +++ b/crates/irtt-proto/tests/directional_codecs.rs @@ -0,0 +1,135 @@ +use irtt_proto::{ + decode_close_request, decode_open_reply, decode_open_request, encode_close_request, + encode_open_reply, encode_open_request, CloseRequest, OpenReply, OpenRequest, Params, + ProtoError, FLAG_CLOSE, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY, MAGIC, +}; + +const KEY: &[u8] = b"testkey"; +const TOKEN: u64 = 0x7896_b6ab_8771_5213; + +#[test] +fn authenticated_open_reply_encoder_sets_hmac_from_key() { + let reply = OpenReply { + flags: FLAG_OPEN | FLAG_REPLY, + token: TOKEN, + params: Params { + protocol_version: 1, + ..Params::default() + }, + }; + let packet = encode_open_reply(&reply, Some(KEY)).unwrap(); + + assert_eq!(packet[3], FLAG_OPEN | FLAG_REPLY | FLAG_HMAC); + assert_eq!( + decode_open_reply(&packet, Some(KEY)), + Ok(OpenReply { + flags: FLAG_OPEN | FLAG_REPLY | FLAG_HMAC, + ..reply + }) + ); +} + +#[test] +fn rejected_open_reply_round_trips_and_zero_token_requires_close() { + let rejected = OpenReply { + flags: FLAG_OPEN | FLAG_REPLY | FLAG_CLOSE, + token: 0, + params: Params { + protocol_version: 1, + ..Params::default() + }, + }; + let packet = encode_open_reply(&rejected, None).unwrap(); + assert_eq!(decode_open_reply(&packet, None), Ok(rejected.clone())); + + assert_eq!( + encode_open_reply( + &OpenReply { + flags: FLAG_OPEN | FLAG_REPLY, + ..rejected + }, + None, + ), + Err(ProtoError::ZeroToken) + ); +} + +#[test] +fn open_and_close_codecs_enforce_directional_flags() { + let params = Params::default(); + let open_reply_packet = encode_open_reply( + &OpenReply { + flags: FLAG_OPEN | FLAG_REPLY, + token: TOKEN, + params: params.clone(), + }, + None, + ) + .unwrap(); + assert_eq!( + decode_open_request(&open_reply_packet, None), + Err(ProtoError::UnexpectedFlag(FLAG_REPLY)) + ); + assert_eq!( + encode_open_reply( + &OpenReply { + flags: FLAG_REPLY, + token: TOKEN, + params: params.clone(), + }, + None, + ), + Err(ProtoError::MissingFlag(FLAG_OPEN)) + ); + + let no_test_packet = encode_open_request( + &OpenRequest { + params, + close: true, + }, + None, + ) + .unwrap(); + assert_eq!( + decode_close_request(&no_test_packet, None), + Err(ProtoError::UnexpectedFlag(FLAG_OPEN)) + ); +} + +#[test] +fn open_and_close_malformed_bodies_and_exact_lengths_are_rejected() { + let mut malformed_open_request = MAGIC.to_vec(); + malformed_open_request.push(FLAG_OPEN); + malformed_open_request.extend_from_slice(&[1, 0x80]); + assert_eq!( + decode_open_request(&malformed_open_request, None), + Err(ProtoError::TruncatedVarint) + ); + + let mut malformed_open_reply = MAGIC.to_vec(); + malformed_open_reply.push(FLAG_OPEN | FLAG_REPLY); + malformed_open_reply.extend_from_slice(&TOKEN.to_le_bytes()); + malformed_open_reply.extend_from_slice(&[1, 0x80]); + assert_eq!( + decode_open_reply(&malformed_open_reply, None), + Err(ProtoError::TruncatedVarint) + ); + + let close_packet = encode_close_request(&CloseRequest { token: TOKEN }, None).unwrap(); + assert_eq!( + decode_close_request(&close_packet[..11], None), + Err(ProtoError::PacketLengthMismatch { + expected: 12, + actual: 11, + }) + ); + let mut long_close_packet = close_packet; + long_close_packet.push(0); + assert_eq!( + decode_close_request(&long_close_packet, None), + Err(ProtoError::PacketLengthMismatch { + expected: 12, + actual: 13, + }) + ); +} diff --git a/crates/irtt-proto/tests/vectors.rs b/crates/irtt-proto/tests/vectors.rs index 93bdf5a..8d5dea9 100644 --- a/crates/irtt-proto/tests/vectors.rs +++ b/crates/irtt-proto/tests/vectors.rs @@ -1,7 +1,7 @@ use irtt_proto::{ - decode_echo_reply, decode_open_reply, encode_close_request, encode_echo_request, - encode_open_request, verify_hmac, Clock, CloseRequest, EchoRequest, OpenRequest, Params, - ReceivedStats, StampAt, + decode_close_request, decode_echo_reply, decode_open_reply, decode_open_request, + encode_close_request, encode_echo_request, encode_open_reply, encode_open_request, verify_hmac, + Clock, CloseRequest, EchoRequest, OpenRequest, Params, ReceivedStats, StampAt, }; fn hex(input: &str) -> Vec { @@ -50,6 +50,13 @@ fn vector_1_open_request_no_hmac() { ) .unwrap(); assert_eq!(packet, expected); + assert_eq!( + decode_open_request(&expected, None), + Ok(OpenRequest { + params: default_params_3s(), + close: false, + }) + ); } #[test] @@ -59,6 +66,7 @@ fn vector_2_open_reply_no_hmac() { let reply = decode_open_reply(&packet, None).unwrap(); assert_eq!(reply.token, 0x7896_b6ab_8771_5213); assert_eq!(reply.params, default_params_3s()); + assert_eq!(encode_open_reply(&reply, None).unwrap(), packet); } #[test] @@ -103,6 +111,12 @@ fn vector_5_close_request_no_hmac() { ) .unwrap(); assert_eq!(packet, expected); + assert_eq!( + decode_close_request(&expected, None), + Ok(CloseRequest { + token: 0x7896_b6ab_8771_5213, + }) + ); } #[test] @@ -120,6 +134,13 @@ fn vector_6_hmac_open_request() { .unwrap(); assert_eq!(packet, expected); verify_hmac(b"testkey", &packet, 4).unwrap(); + assert_eq!( + decode_open_request(&expected, Some(b"testkey")), + Ok(OpenRequest { + params: hmac_params_2s(), + close: false, + }) + ); } #[test] @@ -154,6 +175,12 @@ fn vector_8_hmac_close_request() { ) .unwrap(); assert_eq!(packet, expected); + assert_eq!( + decode_close_request(&expected, Some(b"testkey")), + Ok(CloseRequest { + token: 0x4387_e9eb_5d3f_ca59, + }) + ); } #[test] @@ -169,6 +196,13 @@ fn vector_9_no_test_open_close_request() { ) .unwrap(); assert_eq!(packet, expected); + assert_eq!( + decode_open_request(&expected, None), + Ok(OpenRequest { + params: default_params_60s(), + close: true, + }) + ); } #[test] @@ -179,6 +213,7 @@ fn vector_10_no_test_open_close_reply() { let reply = decode_open_reply(&packet, None).unwrap(); assert_eq!(reply.token, 0); assert_eq!(reply.params, default_params_60s()); + assert_eq!(encode_open_reply(&reply, None).unwrap(), packet); } #[test] From cabe0f6c24cb72536e6a9bff53fa76436770d6be Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Tue, 4 Aug 2026 00:34:12 +0700 Subject: [PATCH 3/3] feat(proto): add directional echo codecs --- crates/irtt-client/src/session/machine.rs | 3 +- crates/irtt-proto/src/echo.rs | 149 ++++++++-- crates/irtt-proto/src/error.rs | 4 + crates/irtt-proto/src/lib.rs | 5 +- crates/irtt-proto/tests/directional_codecs.rs | 254 +++++++++++++++++- crates/irtt-proto/tests/vectors.rs | 82 +++++- 6 files changed, 447 insertions(+), 50 deletions(-) diff --git a/crates/irtt-client/src/session/machine.rs b/crates/irtt-client/src/session/machine.rs index 6ac80aa..86d862b 100644 --- a/crates/irtt-client/src/session/machine.rs +++ b/crates/irtt-client/src/session/machine.rs @@ -218,10 +218,9 @@ impl SessionMachine { let request = EchoRequest { token: session.token, sequence: wire_seq, - params: session.negotiated.params.clone(), payload: vec![], }; - let packet = encode_echo_request(&request, hmac_key)?; + let packet = encode_echo_request(&request, &session.negotiated.params, hmac_key)?; let send_result = send(&packet)?; let pending = PendingProbe { diff --git a/crates/irtt-proto/src/echo.rs b/crates/irtt-proto/src/echo.rs index 56983e3..c924551 100644 --- a/crates/irtt-proto/src/echo.rs +++ b/crates/irtt-proto/src/echo.rs @@ -10,7 +10,6 @@ use crate::{ pub struct EchoRequest { pub token: u64, pub sequence: u32, - pub params: Params, pub payload: Vec, } @@ -35,9 +34,13 @@ pub struct TimestampFields { pub send_mono: Option, } -pub fn encode_echo_request(request: &EchoRequest, hmac_key: Option<&[u8]>) -> Result> { - let layout = PacketLayout::echo(hmac_key.is_some(), &request.params); - let len = echo_packet_len(hmac_key.is_some(), &request.params)?; +pub fn encode_echo_request( + request: &EchoRequest, + params: &Params, + hmac_key: Option<&[u8]>, +) -> Result> { + 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 request.payload.len() > available_payload_len { @@ -65,6 +68,66 @@ pub fn encode_echo_request(request: &EchoRequest, hmac_key: Option<&[u8]>) -> Re envelope::finish(out, hmac_key) } +pub fn decode_echo_request( + packet: &[u8], + params: &Params, + hmac_key: Option<&[u8]>, +) -> Result { + let envelope = envelope::decode( + packet, + hmac_key, + &[ + FlagRule::Reject(FLAG_OPEN), + FlagRule::Reject(FLAG_REPLY), + FlagRule::Reject(FLAG_CLOSE), + ], + )?; + let layout = PacketLayout::echo(hmac_key.is_some(), params); + validate_echo_length(packet, params, layout)?; + envelope::verify(packet, hmac_key)?; + + let mut pos = envelope.body_offset; + let token = read_u64(packet, &mut pos); + let sequence = read_u32(packet, &mut pos); + + Ok(EchoRequest { + token, + sequence, + payload: packet[layout.header_len()..].to_vec(), + }) +} + +pub fn encode_echo_reply( + reply: &EchoReply, + params: &Params, + hmac_key: Option<&[u8]>, +) -> Result> { + 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 reply.payload.len() > available_payload_len { + return Err(ProtoError::PayloadTooLarge { + available: available_payload_len, + provided: reply.payload.len(), + }); + } + + let mut out = envelope::begin( + reply.flags, + hmac_key, + &[FlagRule::Reject(FLAG_OPEN), FlagRule::Require(FLAG_REPLY)], + len, + )?; + out.extend_from_slice(&reply.token.to_le_bytes()); + out.extend_from_slice(&reply.sequence.to_le_bytes()); + push_echo_reply_tail(reply, layout, &mut out)?; + out.resize(len, 0); + out[payload_offset..payload_offset + reply.payload.len()].copy_from_slice(&reply.payload); + + envelope::finish(out, hmac_key) +} + pub fn decode_echo_reply( packet: &[u8], params: &Params, @@ -135,6 +198,47 @@ fn push_zeroed_layout_tail(layout: PacketLayout, out: &mut Vec) { } } +fn push_echo_reply_tail(reply: &EchoReply, layout: PacketLayout, out: &mut Vec) -> Result<()> { + if let Some(value) = field_value(layout.recv_count, reply.recv_count, "recv_count")? { + out.extend_from_slice(&value.to_le_bytes()); + } + if let Some(value) = field_value(layout.recv_window, reply.recv_window, "recv_window")? { + out.extend_from_slice(&value.to_le_bytes()); + } + + let timestamps = &reply.timestamps; + for (present, value, name) in [ + (layout.recv_wall, timestamps.recv_wall, "recv_wall"), + (layout.recv_mono, timestamps.recv_mono, "recv_mono"), + ( + layout.midpoint_wall, + timestamps.midpoint_wall, + "midpoint_wall", + ), + ( + layout.midpoint_mono, + timestamps.midpoint_mono, + "midpoint_mono", + ), + (layout.send_wall, timestamps.send_wall, "send_wall"), + (layout.send_mono, timestamps.send_mono, "send_mono"), + ] { + if let Some(value) = field_value(present, value, name)? { + out.extend_from_slice(&value.to_le_bytes()); + } + } + Ok(()) +} + +fn field_value(present: bool, value: Option, name: &'static str) -> Result> { + match (present, value) { + (true, Some(value)) => Ok(Some(value)), + (true, None) => Err(ProtoError::MissingField(name)), + (false, Some(_)) => Err(ProtoError::UnexpectedField(name)), + (false, None) => Ok(None), + } +} + fn read_u32(packet: &[u8], pos: &mut usize) -> u32 { let value = u32::from_le_bytes(packet[*pos..*pos + SEQ_SIZE].try_into().unwrap()); *pos += SEQ_SIZE; @@ -179,24 +283,24 @@ mod tests { } } - fn echo_request_with_payload(payload_space: usize, payload: Vec) -> EchoRequest { + fn echo_request_with_payload(payload: Vec) -> EchoRequest { EchoRequest { token: 0x7896_b6ab_8771_5213, sequence: 9, - params: params_with_payload_space(payload_space), payload, } } #[test] fn echo_request_encodes_default_placeholders() { + let params = default_params(); let packet = encode_echo_request( &EchoRequest { token: 0x7896_b6ab_8771_5213, sequence: 0, - params: default_params(), payload: Vec::new(), }, + ¶ms, None, ) .unwrap(); @@ -208,29 +312,32 @@ mod tests { #[test] fn echo_request_encodes_exact_fit_payload() { - let request = echo_request_with_payload(4, vec![1, 2, 3, 4]); - let packet = encode_echo_request(&request, None).unwrap(); - let payload_offset = PacketLayout::echo(false, &request.params).header_len(); + let params = params_with_payload_space(4); + let request = echo_request_with_payload(vec![1, 2, 3, 4]); + let packet = encode_echo_request(&request, ¶ms, None).unwrap(); + let payload_offset = PacketLayout::echo(false, ¶ms).header_len(); assert_eq!(&packet[payload_offset..], &[1, 2, 3, 4]); } #[test] fn echo_request_encodes_shorter_payload_and_zero_fills_remainder() { - let request = echo_request_with_payload(4, vec![1, 2]); - let packet = encode_echo_request(&request, None).unwrap(); - let payload_offset = PacketLayout::echo(false, &request.params).header_len(); + let params = params_with_payload_space(4); + let request = echo_request_with_payload(vec![1, 2]); + let packet = encode_echo_request(&request, ¶ms, None).unwrap(); + let payload_offset = PacketLayout::echo(false, ¶ms).header_len(); assert_eq!(&packet[payload_offset..], &[1, 2, 0, 0]); } #[test] fn echo_request_rejects_oversized_payload() { - let request = echo_request_with_payload(4, vec![1, 2, 3, 4, 5]); + let params = params_with_payload_space(4); + let request = echo_request_with_payload(vec![1, 2, 3, 4, 5]); let original = request.clone(); assert_eq!( - encode_echo_request(&request, None), + encode_echo_request(&request, ¶ms, None), Err(ProtoError::PayloadTooLarge { available: 4, provided: 5, @@ -241,24 +348,28 @@ mod tests { #[test] fn echo_request_rejects_negative_requested_length() { - let mut request = echo_request_with_payload(0, Vec::new()); - request.params.length = -1; + let request = echo_request_with_payload(Vec::new()); + let params = Params { + length: -1, + ..Params::default() + }; assert_eq!( - encode_echo_request(&request, None), + encode_echo_request(&request, ¶ms, None), Err(ProtoError::NegativePacketLength { length: -1 }) ); } #[test] fn hmac_echo_request_places_token_and_sequence_after_hmac() { + let params = default_params(); let packet = encode_echo_request( &EchoRequest { token: 0x7896_b6ab_8771_5213, sequence: 9, - params: default_params(), payload: Vec::new(), }, + ¶ms, Some(b"testkey"), ) .unwrap(); diff --git a/crates/irtt-proto/src/error.rs b/crates/irtt-proto/src/error.rs index cd30a0e..1e69546 100644 --- a/crates/irtt-proto/src/error.rs +++ b/crates/irtt-proto/src/error.rs @@ -40,4 +40,8 @@ pub enum ProtoError { MalformedParams, #[error("payload is too large: {provided} bytes provided, {available} bytes available")] PayloadTooLarge { available: usize, provided: usize }, + #[error("required packet field {0} is missing")] + MissingField(&'static str), + #[error("packet field {0} is not present in the negotiated layout")] + UnexpectedField(&'static str), } diff --git a/crates/irtt-proto/src/lib.rs b/crates/irtt-proto/src/lib.rs index a4d007c..5922929 100644 --- a/crates/irtt-proto/src/lib.rs +++ b/crates/irtt-proto/src/lib.rs @@ -32,7 +32,10 @@ pub mod params; pub mod varint; pub use close::{decode_close_request, encode_close_request, CloseRequest}; -pub use echo::{decode_echo_reply, encode_echo_request, EchoReply, EchoRequest, TimestampFields}; +pub use echo::{ + decode_echo_reply, decode_echo_request, encode_echo_reply, encode_echo_request, EchoReply, + EchoRequest, TimestampFields, +}; pub use error::{ProtoError, Result}; pub use flags::*; pub use hmac::{compute_hmac, compute_hmac_in_place, verify_hmac}; diff --git a/crates/irtt-proto/tests/directional_codecs.rs b/crates/irtt-proto/tests/directional_codecs.rs index cf985c6..c8828e6 100644 --- a/crates/irtt-proto/tests/directional_codecs.rs +++ b/crates/irtt-proto/tests/directional_codecs.rs @@ -1,30 +1,80 @@ use irtt_proto::{ - decode_close_request, decode_open_reply, decode_open_request, encode_close_request, - encode_open_reply, encode_open_request, CloseRequest, OpenReply, OpenRequest, Params, - ProtoError, FLAG_CLOSE, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY, MAGIC, + decode_close_request, decode_echo_reply, decode_echo_request, decode_open_reply, + decode_open_request, encode_close_request, encode_echo_reply, encode_echo_request, + encode_open_reply, encode_open_request, Clock, CloseRequest, EchoReply, EchoRequest, OpenReply, + OpenRequest, PacketLayout, Params, ProtoError, ReceivedStats, StampAt, TimestampFields, + FLAG_CLOSE, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY, MAGIC, }; const KEY: &[u8] = b"testkey"; const TOKEN: u64 = 0x7896_b6ab_8771_5213; +fn params() -> Params { + Params { + protocol_version: 1, + duration_ns: 3_000_000_000, + interval_ns: 1_000_000_000, + received_stats: ReceivedStats::Both, + stamp_at: StampAt::Both, + clock: Clock::Both, + ..Params::default() + } +} + +fn echo_request(payload: Vec) -> EchoRequest { + EchoRequest { + token: TOKEN, + sequence: 17, + payload, + } +} + +fn echo_reply(params: &Params, flags: u8) -> EchoReply { + let layout = PacketLayout::echo(false, params); + EchoReply { + flags, + token: TOKEN, + sequence: u32::MAX, + recv_count: layout.recv_count.then_some(u32::MAX), + recv_window: layout.recv_window.then_some(u64::MAX), + timestamps: TimestampFields { + recv_wall: layout.recv_wall.then_some(i64::MIN), + recv_mono: layout.recv_mono.then_some(-2), + midpoint_wall: layout.midpoint_wall.then_some(-1), + midpoint_mono: layout.midpoint_mono.then_some(0), + send_wall: layout.send_wall.then_some(1), + send_mono: layout.send_mono.then_some(i64::MAX), + }, + payload: Vec::new(), + } +} + #[test] -fn authenticated_open_reply_encoder_sets_hmac_from_key() { - let reply = OpenReply { +fn authenticated_reply_encoders_set_hmac_from_key() { + let params = params(); + let open_reply = OpenReply { flags: FLAG_OPEN | FLAG_REPLY, token: TOKEN, - params: Params { - protocol_version: 1, - ..Params::default() - }, + params: params.clone(), }; - let packet = encode_open_reply(&reply, Some(KEY)).unwrap(); - + let packet = encode_open_reply(&open_reply, Some(KEY)).unwrap(); assert_eq!(packet[3], FLAG_OPEN | FLAG_REPLY | FLAG_HMAC); assert_eq!( decode_open_reply(&packet, Some(KEY)), Ok(OpenReply { flags: FLAG_OPEN | FLAG_REPLY | FLAG_HMAC, - ..reply + ..open_reply + }) + ); + + let echo_reply = echo_reply(¶ms, FLAG_REPLY); + let packet = encode_echo_reply(&echo_reply, ¶ms, Some(KEY)).unwrap(); + assert_eq!(packet[3], FLAG_REPLY | FLAG_HMAC); + assert_eq!( + decode_echo_reply(&packet, ¶ms, Some(KEY)), + Ok(EchoReply { + flags: FLAG_REPLY | FLAG_HMAC, + ..echo_reply }) ); } @@ -55,7 +105,17 @@ fn rejected_open_reply_round_trips_and_zero_token_requires_close() { } #[test] -fn open_and_close_codecs_enforce_directional_flags() { +fn peer_close_echo_reply_round_trips_without_a_close_reply_codec() { + let params = Params::default(); + let reply = echo_reply(¶ms, FLAG_REPLY | FLAG_CLOSE); + let packet = encode_echo_reply(&reply, ¶ms, None).unwrap(); + + assert_eq!(packet[3], FLAG_REPLY | FLAG_CLOSE); + assert_eq!(decode_echo_reply(&packet, ¶ms, None), Ok(reply)); +} + +#[test] +fn inverse_codecs_supply_their_packet_type_rules() { let params = Params::default(); let open_reply_packet = encode_open_reply( &OpenReply { @@ -82,6 +142,30 @@ fn open_and_close_codecs_enforce_directional_flags() { Err(ProtoError::MissingFlag(FLAG_OPEN)) ); + let echo_reply_packet = + encode_echo_reply(&echo_reply(¶ms, FLAG_REPLY), ¶ms, None).unwrap(); + assert_eq!( + decode_echo_request(&echo_reply_packet, ¶ms, None), + Err(ProtoError::UnexpectedFlag(FLAG_REPLY)) + ); + assert_eq!( + encode_echo_reply( + &EchoReply { + flags: 0, + ..echo_reply(¶ms, FLAG_REPLY) + }, + ¶ms, + None, + ), + Err(ProtoError::MissingFlag(FLAG_REPLY)) + ); + + let close_packet = encode_close_request(&CloseRequest { token: TOKEN }, None).unwrap(); + assert_eq!( + decode_echo_request(&close_packet, ¶ms, None), + Err(ProtoError::UnexpectedFlag(FLAG_CLOSE)) + ); + let no_test_packet = encode_open_request( &OpenRequest { params, @@ -97,7 +181,7 @@ fn open_and_close_codecs_enforce_directional_flags() { } #[test] -fn open_and_close_malformed_bodies_and_exact_lengths_are_rejected() { +fn codec_specific_malformed_bodies_and_exact_lengths_are_rejected() { let mut malformed_open_request = MAGIC.to_vec(); malformed_open_request.push(FLAG_OPEN); malformed_open_request.extend_from_slice(&[1, 0x80]); @@ -115,6 +199,28 @@ fn open_and_close_malformed_bodies_and_exact_lengths_are_rejected() { Err(ProtoError::TruncatedVarint) ); + let params = Params { + length: 20, + ..Params::default() + }; + let echo_packet = encode_echo_request(&echo_request(Vec::new()), ¶ms, None).unwrap(); + assert_eq!( + decode_echo_request(&echo_packet[..19], ¶ms, None), + Err(ProtoError::PacketLengthMismatch { + expected: 20, + actual: 19, + }) + ); + let mut long_echo_packet = echo_packet; + long_echo_packet.push(0); + assert_eq!( + decode_echo_request(&long_echo_packet, ¶ms, None), + Err(ProtoError::PacketLengthMismatch { + expected: 20, + actual: 21, + }) + ); + let close_packet = encode_close_request(&CloseRequest { token: TOKEN }, None).unwrap(); assert_eq!( decode_close_request(&close_packet[..11], None), @@ -133,3 +239,123 @@ fn open_and_close_malformed_bodies_and_exact_lengths_are_rejected() { }) ); } + +#[test] +fn echo_layout_combinations_round_trip() { + for received_stats in [ + ReceivedStats::None, + ReceivedStats::Count, + ReceivedStats::Window, + ReceivedStats::Both, + ] { + for stamp_at in [ + StampAt::None, + StampAt::Send, + StampAt::Receive, + StampAt::Both, + StampAt::Midpoint, + ] { + for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] { + for key in [None, Some(KEY)] { + let params = Params { + protocol_version: 1, + received_stats, + stamp_at, + clock, + ..Params::default() + }; + let reply = echo_reply(¶ms, FLAG_REPLY); + let packet = encode_echo_reply(&reply, ¶ms, key).unwrap(); + let decoded = decode_echo_reply(&packet, ¶ms, key).unwrap(); + + assert_eq!(decoded.token, reply.token); + assert_eq!(decoded.sequence, reply.sequence); + assert_eq!(decoded.recv_count, reply.recv_count); + assert_eq!(decoded.recv_window, reply.recv_window); + assert_eq!(decoded.timestamps, reply.timestamps); + assert_eq!(decoded.payload, reply.payload); + } + } + } + } +} + +#[test] +fn echo_reply_requires_exact_negotiated_optional_fields() { + let params = Params { + received_stats: ReceivedStats::Count, + stamp_at: StampAt::Send, + clock: Clock::Wall, + ..Params::default() + }; + let missing_count = EchoReply { + recv_count: None, + ..echo_reply(¶ms, FLAG_REPLY) + }; + assert_eq!( + encode_echo_reply(&missing_count, ¶ms, None), + Err(ProtoError::MissingField("recv_count")) + ); + + let no_optional_params = Params::default(); + let unexpected_timestamp = EchoReply { + timestamps: TimestampFields { + send_wall: Some(1), + ..TimestampFields::default() + }, + ..echo_reply(&no_optional_params, FLAG_REPLY) + }; + assert_eq!( + encode_echo_reply(&unexpected_timestamp, &no_optional_params, None), + Err(ProtoError::UnexpectedField("send_wall")) + ); +} + +#[test] +fn echo_payload_is_copied_zero_filled_and_bounded_in_both_directions() { + let params = Params { + length: 20, + ..Params::default() + }; + + let request = echo_request(vec![1, 2]); + let packet = encode_echo_request(&request, ¶ms, None).unwrap(); + assert_eq!(&packet[16..], &[1, 2, 0, 0]); + let decoded = decode_echo_request(&packet, ¶ms, None).unwrap(); + assert_eq!(decoded.payload, vec![1, 2, 0, 0]); + assert_eq!( + encode_echo_request(&decoded, ¶ms, None).unwrap(), + packet + ); + assert_eq!( + encode_echo_request(&echo_request(vec![0; 5]), ¶ms, None), + Err(ProtoError::PayloadTooLarge { + available: 4, + provided: 5, + }) + ); + + let reply = EchoReply { + payload: vec![3, 4], + ..echo_reply(¶ms, FLAG_REPLY) + }; + let packet = encode_echo_reply(&reply, ¶ms, None).unwrap(); + assert_eq!(&packet[16..], &[3, 4, 0, 0]); + let decoded = decode_echo_reply(&packet, ¶ms, None).unwrap(); + assert_eq!(decoded.payload, vec![3, 4, 0, 0]); + assert_eq!(encode_echo_reply(&decoded, ¶ms, None).unwrap(), packet); + assert_eq!( + encode_echo_reply( + &EchoReply { + payload: vec![0; 5], + ..echo_reply(¶ms, FLAG_REPLY) + }, + ¶ms, + None, + ), + Err(ProtoError::PayloadTooLarge { + available: 4, + provided: 5, + }) + ); +} diff --git a/crates/irtt-proto/tests/vectors.rs b/crates/irtt-proto/tests/vectors.rs index 8d5dea9..e97f2bf 100644 --- a/crates/irtt-proto/tests/vectors.rs +++ b/crates/irtt-proto/tests/vectors.rs @@ -1,7 +1,8 @@ use irtt_proto::{ - decode_close_request, decode_echo_reply, decode_open_reply, decode_open_request, - encode_close_request, encode_echo_request, encode_open_reply, encode_open_request, verify_hmac, - Clock, CloseRequest, EchoRequest, OpenRequest, Params, ReceivedStats, StampAt, + decode_close_request, decode_echo_reply, decode_echo_request, decode_open_reply, + decode_open_request, encode_close_request, encode_echo_reply, encode_echo_request, + encode_open_reply, encode_open_request, verify_hmac, Clock, CloseRequest, EchoRequest, + OpenRequest, Params, ProtoError, ReceivedStats, StampAt, FLAG_REPLY, }; fn hex(input: &str) -> Vec { @@ -79,13 +80,21 @@ fn vector_3_echo_request_no_hmac() { &EchoRequest { token: 0x7896_b6ab_8771_5213, sequence: 0, - params: default_params_3s(), payload: Vec::new(), }, + &default_params_3s(), None, ) .unwrap(); assert_eq!(packet, expected); + assert_eq!( + decode_echo_request(&expected, &default_params_3s(), None), + Ok(EchoRequest { + token: 0x7896_b6ab_8771_5213, + sequence: 0, + payload: Vec::new(), + }) + ); } #[test] @@ -98,6 +107,10 @@ fn vector_4_echo_reply_no_hmac() { assert_eq!(reply.sequence, 2); assert_eq!(reply.recv_count, Some(3)); assert_eq!(reply.recv_window, Some(7)); + assert_eq!( + encode_echo_reply(&reply, &default_params_3s(), None).unwrap(), + packet + ); } #[test] @@ -154,13 +167,21 @@ fn vector_7_hmac_echo_request() { &EchoRequest { token: 0x4387_e9eb_5d3f_ca59, sequence: 0, - params: default_params_3s(), payload: Vec::new(), }, + &default_params_3s(), Some(b"testkey"), ) .unwrap(); assert_eq!(packet, expected); + assert_eq!( + decode_echo_request(&expected, &default_params_3s(), Some(b"testkey")), + Ok(EchoRequest { + token: 0x4387_e9eb_5d3f_ca59, + sequence: 0, + payload: Vec::new(), + }) + ); } #[test] @@ -223,13 +244,21 @@ fn vector_11_minimal_echo_packet() { &EchoRequest { token: 0xa031_6fa2_5c61_154e, sequence: 0, - params: Params::default(), payload: Vec::new(), }, + &Params::default(), None, ) .unwrap(); assert_eq!(packet, expected); + assert_eq!( + decode_echo_request(&expected, &Params::default(), None), + Ok(EchoRequest { + token: 0xa031_6fa2_5c61_154e, + sequence: 0, + payload: Vec::new(), + }) + ); } #[test] @@ -249,6 +278,7 @@ fn vector_12_midpoint_reply() { assert!(reply.timestamps.midpoint_wall.is_some()); assert!(reply.timestamps.recv_wall.is_none()); assert!(reply.timestamps.send_wall.is_none()); + assert_eq!(encode_echo_reply(&reply, ¶ms, None).unwrap(), packet); } #[test] @@ -270,27 +300,51 @@ fn spec_18_1_hmac_echo_request_fixture() { &EchoRequest { token: 0x886b_c9a7_22b3_3eea, sequence: 0x6fe2_a1bb, - params, payload: [0xff, 0xfe, 0xfd, 0xfc].repeat(4), }, + ¶ms, Some(&[0x3c, 0x68, 0x1d, 0x39, 0x41, 0x1d, 0x72, 0x43]), ) .unwrap(); assert_eq!(packet, expected); + assert_eq!( + decode_echo_request( + &expected, + &Params { + length: 92, + received_stats: ReceivedStats::Both, + stamp_at: StampAt::Both, + clock: Clock::Both, + ..Params::default() + }, + Some(&[0x3c, 0x68, 0x1d, 0x39, 0x41, 0x1d, 0x72, 0x43]), + ) + .unwrap() + .payload, + [0xff, 0xfe, 0xfd, 0xfc].repeat(4) + ); } #[test] -fn spec_18_2_hmac_echo_reply_fixture() { +fn spec_18_2_ambiguous_hmac_fixture_omits_reply_flag() { let packet = hex("14 a7 5b 08 d2 98 a3 4a 6a 13 41 02 68 b2 67 a8 d6 7e 28 25 c3 cb 6f 76 b6 ce 66 e6 06 07 3b 1d 19 9f bc a3 9b 3b f8 86 e5 39 e9 d7 a1 75 2a ee 3d f1 5a 52 9a c6 4a 3e 22 62 55 2d 69 3f 29 46 d4 05 97 58 66 50 2c dd 2f f1 1d 46 ff fe fd fc ff fe fd fc ff fe fd fc ff fe fd fc"); - verify_hmac( - &[0xda, 0xb3, 0xe9, 0x04, 0xa6, 0x87, 0x92, 0x49], - &packet, - 4, - ) - .unwrap(); + let key = [0xda, 0xb3, 0xe9, 0x04, 0xa6, 0x87, 0x92, 0x49]; + verify_hmac(&key, &packet, 4).unwrap(); + + let params = Params { + length: 92, + received_stats: ReceivedStats::Both, + stamp_at: StampAt::Both, + clock: Clock::Both, + ..Params::default() + }; + assert_eq!( + decode_echo_reply(&packet, ¶ms, Some(&key)), + Err(ProtoError::MissingFlag(FLAG_REPLY)) + ); }