From 328ff1790189b5eea6139e796cf01eba72d558f5 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Fri, 18 Sep 2026 09:13:41 +0700 Subject: [PATCH] docs(irtt-proto): add runnable doctests for public API 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. --- crates/irtt-proto/src/echo.rs | 44 ++++++++++++++++++++++++++++++++ crates/irtt-proto/src/hmac.rs | 17 ++++++++++++ crates/irtt-proto/src/layout.rs | 14 ++++++++++ crates/irtt-proto/src/lib.rs | 33 ++++++++++++++++++++++++ crates/irtt-proto/src/open.rs | 40 +++++++++++++++++++++++++++++ crates/irtt-proto/src/params.rs | 37 +++++++++++++++++++++++++++ crates/irtt-proto/src/request.rs | 35 +++++++++++++++++++++++++ crates/irtt-proto/src/varint.rs | 16 ++++++++++++ 8 files changed, 236 insertions(+) diff --git a/crates/irtt-proto/src/echo.rs b/crates/irtt-proto/src/echo.rs index a17731d..f65ad3e 100644 --- a/crates/irtt-proto/src/echo.rs +++ b/crates/irtt-proto/src/echo.rs @@ -60,6 +60,50 @@ pub struct TimestampFields { pub send_mono: Option, } +/// Encodes an ECHO reply. +/// +/// `params` supplies the negotiated layout and length, exactly as on the +/// request side, and every optional field the layout requires must be present +/// in `reply`: a `Some` where the layout has no field, or a `None` where it has +/// one, is an error. `hmac_key` is authoritative: `Some(key)` sets `FLAG_HMAC` +/// and signs the finished datagram. +/// +/// # Errors +/// +/// Returns [`ProtoError::MissingFlag`] or [`ProtoError::UnexpectedFlag`] when +/// the reply does not carry exactly the packet-type flags this encoder +/// requires, [`ProtoError::MissingField`] or [`ProtoError::UnexpectedField`] +/// when `reply`'s optional fields do not match the negotiated layout, and +/// [`ProtoError::PayloadTooLarge`] when the payload does not fit the negotiated +/// length. +/// +/// # Example +/// +/// ``` +/// use irtt_proto::{ +/// decode_echo_reply, encode_echo_reply, EchoReply, Params, ReceivedStats, +/// TimestampFields, FLAG_REPLY, +/// }; +/// +/// // `received_stats` negotiates a receive counter; four payload bytes. +/// let params = Params { +/// length: 24, +/// received_stats: ReceivedStats::Count, +/// ..Params::default() +/// }; +/// let reply = EchoReply { +/// flags: FLAG_REPLY, +/// token: 0x1234_5678_9abc_def0, +/// sequence: 7, +/// recv_count: Some(3), +/// recv_window: None, +/// timestamps: TimestampFields::default(), +/// payload: vec![1, 2, 3, 4], +/// }; +/// +/// let packet = encode_echo_reply(&reply, ¶ms, None).unwrap(); +/// assert_eq!(decode_echo_reply(&packet, ¶ms, None).unwrap(), reply); +/// ``` pub fn encode_echo_reply( reply: &EchoReply, params: &Params, diff --git a/crates/irtt-proto/src/hmac.rs b/crates/irtt-proto/src/hmac.rs index ca82fb8..7baae5f 100644 --- a/crates/irtt-proto/src/hmac.rs +++ b/crates/irtt-proto/src/hmac.rs @@ -68,6 +68,23 @@ pub fn verify_hmac(key: &[u8], packet: &[u8], hmac_offset: usize) -> Result<()> /// [`ProtoError::InvalidHmacOffset`] when the field is truncated, and /// [`ProtoError::BadHmac`] when the MAC does not match. The packet is never /// modified. +/// +/// # Example +/// +/// ``` +/// use irtt_proto::{encode_request, verify_packet_hmac, Params, RequestToEncode}; +/// +/// // Synthetic example key — use a real shared secret in production. +/// let key = b"example key"; +/// let params = Params::with_protocol_defaults(); +/// let packet = encode_request( +/// RequestToEncode::Open { params: ¶ms, no_test: false }, +/// Some(key), +/// ).unwrap(); +/// +/// verify_packet_hmac(key, &packet).unwrap(); +/// assert!(verify_packet_hmac(b"wrong key", &packet).is_err()); +/// ``` pub fn verify_packet_hmac(key: &[u8], packet: &[u8]) -> Result<()> { let envelope = envelope::decode_structural(packet)?; if !envelope.hmac_present { diff --git a/crates/irtt-proto/src/layout.rs b/crates/irtt-proto/src/layout.rs index 7c486a0..bf1749f 100644 --- a/crates/irtt-proto/src/layout.rs +++ b/crates/irtt-proto/src/layout.rs @@ -135,6 +135,20 @@ pub fn echo_header_len(hmac: bool, params: &Params) -> usize { /// A ceiling on what a negotiated length may legitimately *be* — an MTU, a /// resource bound, a maximum packet size — is deliberately not here. That is /// server and runtime policy, and it belongs where the negotiation happens. +/// +/// # Example +/// +/// ``` +/// use irtt_proto::{echo_packet_len, Params}; +/// +/// // The default layout's mandatory field block is 16 bytes (header, token, +/// // sequence), so a smaller requested length is floored up to it. +/// assert_eq!(echo_packet_len(false, &Params::default()).unwrap(), 16); +/// assert_eq!( +/// echo_packet_len(false, &Params { length: 40, ..Params::default() }).unwrap(), +/// 40, +/// ); +/// ``` pub fn echo_packet_len(hmac: bool, params: &Params) -> Result { let header_len = echo_header_len(hmac, params); let requested = if params.length <= 0 { diff --git a/crates/irtt-proto/src/lib.rs b/crates/irtt-proto/src/lib.rs index 3a8e7f1..d100953 100644 --- a/crates/irtt-proto/src/lib.rs +++ b/crates/irtt-proto/src/lib.rs @@ -42,6 +42,39 @@ //! which reply kind to expect, and the two need genuinely different semantic //! context. //! +//! # Example +//! +//! The normal path is directional: a sender encodes with [`encode_request`], a +//! receiver classifies with [`decode_request`], and authentication is a +//! separate step once the applicable key is known. +//! +//! ``` +//! use irtt_proto::{ +//! decode_request, encode_request, verify_packet_hmac, DecodedRequestKind, Params, +//! RequestToEncode, +//! }; +//! +//! // Sender side: encode an authenticated open request. +//! let params = Params::with_protocol_defaults(); +//! let key = b"example key"; +//! let packet = encode_request( +//! RequestToEncode::Open { params: ¶ms, no_test: false }, +//! Some(key), +//! ).unwrap(); +//! +//! // Receiver side: classify structurally, before any key is known. +//! let request = decode_request(&packet).unwrap(); +//! assert!(request.hmac_present); +//! let DecodedRequestKind::Open { no_test, params: encoded } = request.kind else { +//! panic!("expected an open request"); +//! }; +//! assert!(!no_test); +//! assert_eq!(Params::decode(encoded).unwrap(), params); +//! +//! // Authentication is a separate step, once the applicable key is known. +//! verify_packet_hmac(key, &packet).unwrap(); +//! ``` +//! #![forbid(unsafe_code)] pub mod echo; diff --git a/crates/irtt-proto/src/open.rs b/crates/irtt-proto/src/open.rs index 84e4791..7ed768f 100644 --- a/crates/irtt-proto/src/open.rs +++ b/crates/irtt-proto/src/open.rs @@ -13,6 +13,34 @@ pub struct OpenReply { pub params: Params, } +/// Encodes an open reply. +/// +/// `hmac_key` is authoritative: `Some(key)` sets `FLAG_HMAC` and signs the +/// finished datagram, while `None` produces an unauthenticated reply and clears +/// any caller-supplied `FLAG_HMAC`. A zero token is rejected unless the reply +/// also carries `FLAG_CLOSE` (a rejected open). +/// +/// # Errors +/// +/// Returns [`ProtoError::MissingFlag`] when the reply lacks `FLAG_OPEN` or +/// `FLAG_REPLY`, and [`ProtoError::ZeroToken`] for a zero token without +/// `FLAG_CLOSE`. +/// +/// # Example +/// +/// ``` +/// use irtt_proto::{ +/// decode_open_reply, encode_open_reply, OpenReply, Params, FLAG_OPEN, FLAG_REPLY, +/// }; +/// +/// let reply = OpenReply { +/// flags: FLAG_OPEN | FLAG_REPLY, +/// token: 0x1234_5678_9abc_def0, +/// params: Params::with_protocol_defaults(), +/// }; +/// let packet = encode_open_reply(&reply, None).unwrap(); +/// assert_eq!(decode_open_reply(&packet, None).unwrap(), reply); +/// ``` pub fn encode_open_reply(reply: &OpenReply, hmac_key: Option<&[u8]>) -> Result> { let params = reply.params.encode(); let mut out = envelope::begin_checked( @@ -30,6 +58,18 @@ pub fn encode_open_reply(reply: &OpenReply, hmac_key: Option<&[u8]>) -> Result) -> Result { let envelope = envelope::decode( packet, diff --git a/crates/irtt-proto/src/params.rs b/crates/irtt-proto/src/params.rs index ce74859..f579d41 100644 --- a/crates/irtt-proto/src/params.rs +++ b/crates/irtt-proto/src/params.rs @@ -16,6 +16,29 @@ pub const MAX_SERVER_FILL_BYTES: usize = 32; /// [`clock`](Params::clock) is [`Clock::Unspecified`], which is what an open /// request with an empty parameter payload means. It is not a set of sensible /// client settings; a client builds its request from its own configuration. +/// +/// # Example +/// +/// Construct a `Params`, encode it, and decode it back: +/// +/// ``` +/// use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt}; +/// +/// let params = Params { +/// protocol_version: 1, +/// duration_ns: 3_000_000_000, +/// interval_ns: 1_000_000_000, +/// length: 1472, +/// received_stats: ReceivedStats::Both, +/// stamp_at: StampAt::Both, +/// clock: Clock::Both, +/// server_fill: Some(ServerFill { value: "rand".to_owned() }), +/// ..Params::default() +/// }; +/// +/// let encoded = params.encode(); +/// assert_eq!(Params::decode(&encoded).unwrap(), params); +/// ``` #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Params { pub protocol_version: i64, @@ -88,6 +111,20 @@ impl Params { /// Presence means the tag appeared at least once. Repeated known tags keep /// last-value-wins, and unknown tags remain ignored and untracked. /// + /// # Example + /// + /// ``` + /// use irtt_proto::Params; + /// + /// // Wire bytes: tag 2 (duration) carrying an explicit zero. Interval + /// // (tag 3) is absent, so both fields decode to zero but only one is + /// // reported present. + /// let decoded = Params::decode_with_presence(&[0x02, 0x00]).unwrap(); + /// assert_eq!(decoded.params.duration_ns, 0); + /// assert!(decoded.presence.duration_ns); + /// assert!(!decoded.presence.interval_ns); + /// ``` + /// /// [`decode`]: Params::decode pub fn decode_with_presence(input: &[u8]) -> Result { let mut params = Self::default(); diff --git a/crates/irtt-proto/src/request.rs b/crates/irtt-proto/src/request.rs index 834e37c..5e4a61a 100644 --- a/crates/irtt-proto/src/request.rs +++ b/crates/irtt-proto/src/request.rs @@ -88,6 +88,26 @@ pub enum DecodedRequestKind<'a> { /// 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> { match request { RequestToEncode::Open { params, no_test } => encode_open(params, no_test, hmac_key), @@ -138,6 +158,21 @@ pub fn encode_request(request: RequestToEncode<'_>, hmac_key: Option<&[u8]>) -> /// 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> { let envelope = envelope::decode_structural(packet)?; if has(envelope.flags, FLAG_REPLY) { diff --git a/crates/irtt-proto/src/varint.rs b/crates/irtt-proto/src/varint.rs index 4f27ef3..82bf6f2 100644 --- a/crates/irtt-proto/src/varint.rs +++ b/crates/irtt-proto/src/varint.rs @@ -1,3 +1,19 @@ +//! Variable-length integer encoding for IRTT wire values. +//! +//! Tags use an unsigned base-128 varint ([`encode_uvarint`]), and signed +//! integers (such as parameter values) use the same encoding after a zigzag +//! transform ([`encode_varint`]), which keeps small negative numbers compact. +//! +//! # Example +//! +//! ``` +//! use irtt_proto::varint::{decode_varint, encode_varint}; +//! +//! let mut out = Vec::new(); +//! encode_varint(1_000_000_000, &mut out); +//! assert_eq!(decode_varint(&out).unwrap(), (1_000_000_000, out.len())); +//! ``` + use crate::{ProtoError, Result}; pub fn encode_uvarint(mut value: u64, out: &mut Vec) {