From a4a5219b9ef1e5c3dc12af25df1ec5a7b2d3fe1e Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Thu, 13 Aug 2026 20:34:42 +0700 Subject: [PATCH] feat(server): carry reply traffic class --- crates/irtt-server/src/core.rs | 118 +++++++++++++-- crates/irtt-server/src/lib.rs | 13 +- crates/irtt-server/src/runtime.rs | 4 +- crates/irtt-server/src/tests/echo.rs | 23 +-- crates/irtt-server/src/tests/hmac.rs | 10 +- crates/irtt-server/src/tests/lifecycle.rs | 18 ++- crates/irtt-server/src/tests/mod.rs | 1 + crates/irtt-server/src/tests/no_test.rs | 2 +- crates/irtt-server/src/tests/open.rs | 10 +- crates/irtt-server/src/tests/rate.rs | 8 +- crates/irtt-server/src/tests/support.rs | 26 ++-- crates/irtt-server/src/tests/traffic_class.rs | 134 ++++++++++++++++++ 12 files changed, 310 insertions(+), 57 deletions(-) create mode 100644 crates/irtt-server/src/tests/traffic_class.rs diff --git a/crates/irtt-server/src/core.rs b/crates/irtt-server/src/core.rs index e9e2de3..b13bd9f 100644 --- a/crates/irtt-server/src/core.rs +++ b/crates/irtt-server/src/core.rs @@ -32,6 +32,11 @@ use crate::{ /// -> None -> send nothing /// ``` /// +/// A reply is an [`OutboundDatagram`]: the bytes to send plus the transport +/// policy that sending them requires. The core is where session policy lives, so +/// it hands that policy out with the packet rather than leaving the runtime to +/// rediscover it from the session table or from the packet it is about to send. +/// /// It is public as a low-level engine for a pre-1.0 crate, not as a promise of /// runtime independence. The server is intentionally Tokio-native; this is not /// a transport abstraction, and there is no blocking or alternate-runtime @@ -41,11 +46,12 @@ use crate::{ /// /// The core implements open handling, session creation and negotiation, /// normal echo processing, per-session rate limiting, idle expiry, -/// maximum-duration close and client-initiated close. DSCP application and the -/// full server fill policy are separate slices, and echo payloads are -/// zero-filled. Incoming authenticated requests enforce logical expiry exactly; -/// the runtime also calls a private maintenance hook to reclaim idle sessions -/// when no traffic arrives. +/// maximum-duration close and client-initiated close. It decides the traffic +/// class each reply is to be sent with; actually applying it to a socket is the +/// runtime's job. The full server fill policy is a separate slice, and echo +/// payloads are zero-filled. Incoming authenticated requests enforce logical +/// expiry exactly; the runtime also calls a private maintenance hook to reclaim +/// idle sessions when no traffic arrives. #[derive(Debug)] pub struct ServerCore { config: ServerConfig, @@ -111,6 +117,14 @@ impl ServerCore { /// Handles one received datagram, returning the reply to send, if any. /// + /// A reply is an [`OutboundDatagram`]: the encoded packet and the raw + /// traffic class it must be sent with. Open and no-test replies are always + /// unmarked, and an echo reply — including one carrying the + /// maximum-duration close — takes its session's negotiated class. The + /// runtime applies that class to the socket before sending; nothing here + /// touches a socket, and a class this core hands out is a request, not a + /// promise that the host can honor it. + /// /// `Ok(None)` means "answer nothing". The protocol defines no error reply, /// no reset and no NACK, so every rejection is a silent discard and a /// client distinguishes "rejected" from "lost" only by timing out. A @@ -149,7 +163,7 @@ impl ServerCore { &mut self, peer: SocketAddr, packet: &[u8], - ) -> Result>, ServerError> { + ) -> Result, ServerError> { let Ok(request) = decode_request(packet) else { return Ok(None); }; @@ -295,7 +309,7 @@ impl ServerCore { &mut self, peer: SocketAddr, request: EchoRequest, - ) -> Result>, ServerError> { + ) -> Result, ServerError> { // Resource policy, applied to the length actually received. This is // `irtt-rs` policy rather than upstream-mechanism emulation: there is // no interface-MTU effective length here, just the datagram the caller @@ -341,6 +355,10 @@ impl ServerCore { // it. let sent_at = self.clock.sample(); let params = session.params(); + // Read before the session can be released below: a maximum-duration + // close is an ordinary echo reply and carries the session's marking like + // any other. + let traffic_class = transport_traffic_class(params); let reply = EchoReply { // Open must be clear or upstream clients abort. Close is set only // for the maximum-duration limit, which is the sole trigger this @@ -376,7 +394,7 @@ impl ServerCore { session.commit_receive_state(next); session.commit_lifetime_state(next_lifetime); } - Ok(Some(packet)) + Ok(Some(OutboundDatagram::new(packet, traffic_class))) } /// Negotiates an open request and, unless it is a no-test one, creates the @@ -393,7 +411,7 @@ impl ServerCore { no_test: bool, encoded_params: &[u8], now_ns: i64, - ) -> Result>, ServerError> { + ) -> Result, ServerError> { // The parameter decoder already rejects a truncated tag or value, a // varint overflow, an out-of-range stats/stamp-at/clock enum, and a // server-fill value that is oversized, short-buffered or not UTF-8. @@ -419,7 +437,9 @@ impl ServerCore { token: 0, params, }; - return self.encode_reply(&reply).map(Some); + return self + .encode_reply(&reply) + .map(|packet| Some(OutboundDatagram::unmarked(packet))); } if self.sessions.len() >= self.config.max_sessions() { @@ -442,7 +462,7 @@ impl ServerCore { token, Session::new(peer, reply.params, &self.config, now_ns), ); - Ok(Some(packet)) + Ok(Some(OutboundDatagram::unmarked(packet))) } /// Releases the session a close request names, if that request owns one. @@ -493,6 +513,82 @@ impl ServerCore { } } +/// One reply the core prepared, together with the transport policy it must be +/// sent under. +/// +/// The bytes alone are not a complete instruction. A session negotiates a +/// traffic class, and only the core knows which session a reply belongs to, so +/// the class travels out with the packet instead of the runtime decoding the +/// outgoing datagram again or keeping a second copy of session state. +/// +/// [`traffic_class`](Self::traffic_class) is the **raw 8-bit IPv4 TOS / IPv6 +/// Traffic Class byte**, not a six-bit codepoint: the codepoint is its upper six +/// bits and the low two are ECN. Zero is a value like any other and means +/// unmarked — it is deliberately not `Option`, because a runtime sharing one +/// socket between sessions has to apply zero as explicitly as it applies +/// anything else, or an unmarked reply inherits the previous one's marking. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutboundDatagram { + bytes: Vec, + traffic_class: u8, +} + +impl OutboundDatagram { + /// A reply to send with an explicit raw traffic-class byte. + fn new(bytes: Vec, traffic_class: u8) -> Self { + Self { + bytes, + traffic_class, + } + } + + /// A reply to send unmarked. + fn unmarked(bytes: Vec) -> Self { + Self::new(bytes, 0) + } + + /// The encoded reply. + #[must_use] + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + /// The raw IPv4 TOS / IPv6 Traffic Class byte this reply is to be sent + /// with. Zero means unmarked. + #[must_use] + pub fn traffic_class(&self) -> u8 { + self.traffic_class + } + + /// Consumes the datagram, yielding its encoded reply. + #[must_use] + pub fn into_bytes(self) -> Vec { + self.bytes + } +} + +impl AsRef<[u8]> for OutboundDatagram { + fn as_ref(&self) -> &[u8] { + self.bytes() + } +} + +/// The raw traffic-class byte a session's negotiated DSCP parameter asks for. +/// +/// The wire parameter is an `i64` and this server deliberately accepts values +/// outside `0..=255` during negotiation, returning and storing them unchanged. +/// A value that is not a byte cannot be a TOS / Traffic Class byte, so the +/// transport treats it as unmarked. That is **`irtt-rs` policy, not observed +/// behavior**: the clean evidence records the reference host's handling of +/// negative and oversized values as platform-specific and explicitly not a +/// compatibility requirement, so there is nothing to reproduce. Zero is chosen +/// over `as`-style truncation or two's-complement wrapping because neither +/// −1 → 255 nor 256 → 0 is a marking the client asked for, and over refusing the +/// session because a malformed-but-accepted open should stay usable. +fn transport_traffic_class(params: &Params) -> u8 { + u8::try_from(params.dscp).unwrap_or(0) +} + /// A structurally decoded request kind, with an echo's arrival instant already /// sampled. /// diff --git a/crates/irtt-server/src/lib.rs b/crates/irtt-server/src/lib.rs index bfeab27..b83d16c 100644 --- a/crates/irtt-server/src/lib.rs +++ b/crates/irtt-server/src/lib.rs @@ -10,11 +10,12 @@ //! receive state, rate and lifetime policy, resource decisions and reply //! construction. It performs no I/O; the clock its timestamps and deadlines //! come from is a private injected seam, not a runtime abstraction, so the -//! engine stays testable. [`Server`] owns one Tokio UDP listener and one core, -//! and provides sequential receive, reply, scheduled expiry maintenance and -//! caller-controlled shutdown. The crate is intentionally Tokio-native; there -//! is no blocking or alternate-runtime counterpart and no transport -//! abstraction. +//! engine stays testable. Each reply it produces is an [`OutboundDatagram`]: +//! the packet and the raw traffic class it must be sent with. [`Server`] owns +//! one Tokio UDP listener and one core, and provides sequential receive, reply, +//! scheduled expiry maintenance and caller-controlled shutdown. The crate is +//! intentionally Tokio-native; there is no blocking or alternate-runtime +//! counterpart and no transport abstraction. //! //! # Rejection is silence //! @@ -63,6 +64,6 @@ pub use config::{ ServerConfig, DEFAULT_BURST_ALLOWANCE, DEFAULT_IDLE_TIMEOUT, DEFAULT_MAX_PACKET_LENGTH, DEFAULT_MAX_SESSIONS, DEFAULT_MIN_SEND_INTERVAL, }; -pub use core::ServerCore; +pub use core::{OutboundDatagram, ServerCore}; pub use error::ServerError; pub use runtime::{Server, ServerRuntimeError}; diff --git a/crates/irtt-server/src/runtime.rs b/crates/irtt-server/src/runtime.rs index 158196a..81ba79b 100644 --- a/crates/irtt-server/src/runtime.rs +++ b/crates/irtt-server/src/runtime.rs @@ -90,7 +90,7 @@ impl Server { } if let Some(reply) = self.core.handle_datagram(peer, &self.recv_buffer[..len])? { - let send = self.socket.send_to(&reply, peer); + let send = self.socket.send_to(reply.bytes(), peer); tokio::pin!(send); let sent = loop { tokio::select! { @@ -99,7 +99,7 @@ impl Server { sent = &mut send => break sent, } }; - if !matches!(sent, Ok(len) if len == reply.len()) { + if !matches!(sent, Ok(len) if len == reply.bytes().len()) { continue; } } diff --git a/crates/irtt-server/src/tests/echo.rs b/crates/irtt-server/src/tests/echo.rs index a3d8966..b2247ed 100644 --- a/crates/irtt-server/src/tests/echo.rs +++ b/crates/irtt-server/src/tests/echo.rs @@ -93,8 +93,15 @@ fn a_valid_echo_is_answered_from_the_negotiated_params() { "no timestamps were negotiated, so none are emitted" ); assert!(reply.payload.is_empty()); - assert_eq!(packet.len(), echo_packet_len(false, &negotiated).unwrap()); - assert_eq!(packet.len(), 16, "magic, flags, token and sequence only"); + assert_eq!( + packet.bytes().len(), + echo_packet_len(false, &negotiated).unwrap() + ); + assert_eq!( + packet.bytes().len(), + 16, + "magic, flags, token and sequence only" + ); assert_eq!( core.session_count(), @@ -149,7 +156,7 @@ fn a_reply_is_sized_by_the_session_not_by_the_request() { let reply = expect_echo_reply(&packet, &negotiated, None); assert_eq!( - packet.len(), + packet.bytes().len(), 64, "{name}: the reply is the negotiated length" ); @@ -418,7 +425,7 @@ fn an_authenticated_session_answers_only_authenticated_echoes() { .unwrap() .expect("a correctly authenticated echo must be answered"); assert!( - verify_packet_hmac(KEY, &packet).is_ok(), + verify_packet_hmac(KEY, packet.bytes()).is_ok(), "the reply must carry a MAC that verifies with the server's key" ); // `expect_echo_reply` pins the HMAC flag and verifies the MAC again while @@ -596,7 +603,7 @@ fn only_the_negotiated_timestamps_are_emitted() { // failure as much as a selected one carrying the wrong instant. assert_eq!(reply.timestamps, expected, "{stamp_at:?} on {clock:?}"); assert_eq!( - packet.len(), + packet.bytes().len(), echo_packet_len(false, &negotiated).unwrap(), "{stamp_at:?} on {clock:?} must be the negotiated layout's length" ); @@ -704,12 +711,12 @@ fn a_single_clock_midpoint_emits_exactly_one_field() { let reply = expect_echo_reply(&packet, &negotiated, None); assert_eq!( - packet.len(), + packet.bytes().len(), echo_packet_len(false, &negotiated).unwrap(), "{clock:?}: the negotiated length, not the compatibility one" ); assert_eq!( - packet.len(), + packet.bytes().len(), 24, "{clock:?}: one 8-byte midpoint field, not the upstream two" ); @@ -734,7 +741,7 @@ fn a_reply_payload_is_zero_filled_and_never_reflects_the_request() { .expect("an admissible echo must be answered"); let reply = expect_echo_reply(&packet, &negotiated, None); - assert_eq!(packet.len(), 64); + assert_eq!(packet.bytes().len(), 64); assert_eq!(reply.payload.len(), 48); assert!( reply.payload.iter().all(|byte| *byte == 0), diff --git a/crates/irtt-server/src/tests/hmac.rs b/crates/irtt-server/src/tests/hmac.rs index 9970da5..ff90cc8 100644 --- a/crates/irtt-server/src/tests/hmac.rs +++ b/crates/irtt-server/src/tests/hmac.rs @@ -94,8 +94,12 @@ fn an_authenticating_server_answers_a_valid_open_with_an_authenticated_reply() { let reply = expect_normal_open_reply(&packet, Some(KEY)); assert_eq!(reply.token, TOKEN_A); - assert_ne!(packet[3] & FLAG_HMAC, 0, "the reply must set FLAG_HMAC"); - verify_packet_hmac(KEY, &packet).expect("the reply must carry a valid MAC"); + assert_ne!( + packet.bytes()[3] & FLAG_HMAC, + 0, + "the reply must set FLAG_HMAC" + ); + verify_packet_hmac(KEY, packet.bytes()).expect("the reply must carry a valid MAC"); assert_eq!(core.session_count(), 1); } @@ -109,7 +113,7 @@ fn a_non_authenticating_server_answers_an_ordinary_open() { .expect("an unauthenticated open must be answered"); expect_normal_open_reply(&packet, None); - assert_eq!(packet[3] & FLAG_HMAC, 0); + assert_eq!(packet.bytes()[3] & FLAG_HMAC, 0); assert_eq!(core.session_count(), 1); } diff --git a/crates/irtt-server/src/tests/lifecycle.rs b/crates/irtt-server/src/tests/lifecycle.rs index ea67c95..0d54449 100644 --- a/crates/irtt-server/src/tests/lifecycle.rs +++ b/crates/irtt-server/src/tests/lifecycle.rs @@ -22,7 +22,7 @@ use super::support::{ expect_closing_echo_reply, expect_echo_reply, expect_no_test_reply, manual_core, no_test_request, open_request, other_peer, peer, ManualClock, ScriptedTokens, KEY, }; -use crate::ServerConfig; +use crate::{OutboundDatagram, ServerConfig}; const TOKEN_A: u64 = 0x0102_0304_0506_0708; const TOKEN_B: u64 = 0x1112_1314_1516_1718; @@ -50,9 +50,9 @@ fn idle_after(idle: Duration) -> ServerConfig { .with_min_send_interval(Duration::ZERO) } -fn count_of(packet: Option>, params: &Params) -> Option { - let packet = packet?; - expect_echo_reply(&packet, params, None).recv_count +fn count_of(reply: Option, params: &Params) -> Option { + let reply = reply?; + expect_echo_reply(reply, params, None).recv_count } #[test] @@ -451,10 +451,15 @@ fn the_first_echo_past_the_maximum_duration_carries_close_and_ends_the_session() ); assert!(reply.payload.is_empty()); assert_eq!( - packet.len(), + packet.bytes().len(), irtt_proto::echo_packet_len(false, &negotiated).unwrap(), "the negotiated layout, unchanged by the flag" ); + assert_eq!( + i64::from(packet.traffic_class()), + negotiated.dscp, + "a server close is an echo reply and keeps the session's marking" + ); // The token is unusable from that reply onward. assert_eq!(core.session_count(), 0); @@ -595,6 +600,7 @@ fn an_authenticated_session_closes_through_the_ordinary_reply_path() { // together. let reply = expect_closing_echo_reply(&packet, &negotiated, Some(KEY)); assert_eq!(reply.recv_count, Some(2)); - irtt_proto::verify_packet_hmac(KEY, &packet).expect("the close-flagged reply is authenticated"); + irtt_proto::verify_packet_hmac(KEY, packet.bytes()) + .expect("the close-flagged reply is authenticated"); assert_eq!(core.session_count(), 0); } diff --git a/crates/irtt-server/src/tests/mod.rs b/crates/irtt-server/src/tests/mod.rs index 278e5d9..513687e 100644 --- a/crates/irtt-server/src/tests/mod.rs +++ b/crates/irtt-server/src/tests/mod.rs @@ -24,3 +24,4 @@ mod rate; mod sessions; mod support; mod tokens; +mod traffic_class; diff --git a/crates/irtt-server/src/tests/no_test.rs b/crates/irtt-server/src/tests/no_test.rs index 9c70597..ce3687f 100644 --- a/crates/irtt-server/src/tests/no_test.rs +++ b/crates/irtt-server/src/tests/no_test.rs @@ -194,7 +194,7 @@ fn an_authenticated_no_test_reply_is_authenticated_and_still_creates_nothing() { .expect("an authenticated no-test open must be answered"); expect_no_test_reply(&packet, Some(KEY)); - verify_packet_hmac(KEY, &packet).expect("the no-test reply must carry a valid MAC"); + verify_packet_hmac(KEY, packet.bytes()).expect("the no-test reply must carry a valid MAC"); assert_eq!(core.session_count(), 0); } diff --git a/crates/irtt-server/src/tests/open.rs b/crates/irtt-server/src/tests/open.rs index 3cf7c35..93d5913 100644 --- a/crates/irtt-server/src/tests/open.rs +++ b/crates/irtt-server/src/tests/open.rs @@ -98,7 +98,7 @@ fn an_empty_parameter_payload_is_answered_with_only_a_version() { // header (4) + token (8) + ProtocolVersion=1 (2). Every other parameter // negotiates to its wire-default zero and is omitted entirely, which is // only reachable if absent parameters really did decode as zero. - assert_eq!(packet.len(), 14); + assert_eq!(packet.bytes().len(), 14); let reply = expect_normal_open_reply(&packet, None); assert_eq!( @@ -139,7 +139,7 @@ fn unknown_parameter_tags_are_ignored_and_not_reflected_in_the_reply() { ); // The reply is the same 14 bytes an empty payload produces: no unknown tag // was copied through. - assert_eq!(packet.len(), 14); + assert_eq!(packet.bytes().len(), 14); assert_eq!(core.session_count(), 1); } @@ -366,9 +366,9 @@ fn authentication_counts_toward_the_executable_echo_size() { #[test] fn negative_and_out_of_range_length_and_dscp_survive_negotiation() { // These lengths are all at or below the default maximum, and DSCP is - // accepted as decoded in this slice, including values a socket could never - // carry. Restricting a DSCP the server cannot apply is a later policy - // decision, not an admission rule. + // accepted as decoded, including values a socket could never carry: + // negotiation restricts neither, and a value that is not a byte is simply + // transported unmarked (see `traffic_class`). // // A negative length in particular stays negative in both the reply and the // stored session; the packet-length policy reduces oversized *positive* diff --git a/crates/irtt-server/src/tests/rate.rs b/crates/irtt-server/src/tests/rate.rs index aa0486c..4a3dccb 100644 --- a/crates/irtt-server/src/tests/rate.rs +++ b/crates/irtt-server/src/tests/rate.rs @@ -18,7 +18,7 @@ use super::support::{ core_with_sources, echo_at, echo_params, echo_request, expect_echo_reply, manual_core, open_negotiated, other_peer, peer, ManualClock, ScriptedTokens, }; -use crate::ServerConfig; +use crate::{OutboundDatagram, ServerConfig}; const TOKEN_A: u64 = 0x0102_0304_0506_0708; const TOKEN_B: u64 = 0x1112_1314_1516_1718; @@ -41,9 +41,9 @@ fn params_at(interval_ns: i64) -> Params { } /// The received count a reply reported, or `None` if there was no reply. -fn count_of(packet: Option>, params: &Params) -> Option { - let packet = packet?; - expect_echo_reply(&packet, params, None).recv_count +fn count_of(reply: Option, params: &Params) -> Option { + let reply = reply?; + expect_echo_reply(reply, params, None).recv_count } #[test] diff --git a/crates/irtt-server/src/tests/support.rs b/crates/irtt-server/src/tests/support.rs index 068d5eb..e42ef74 100644 --- a/crates/irtt-server/src/tests/support.rs +++ b/crates/irtt-server/src/tests/support.rs @@ -12,7 +12,7 @@ use irtt_proto::{ use crate::{ clock::{ClockSample, ClockSource}, - core::ServerCore, + core::{OutboundDatagram, ServerCore}, error::ServerError, token::TokenSource, ServerConfig, @@ -231,7 +231,7 @@ pub(crate) fn echo_at( sequence: u32, params: &Params, hmac_key: Option<&[u8]>, -) -> Option> { +) -> Option { clock.set(at_ns); core.handle_datagram( peer(), @@ -314,11 +314,12 @@ pub(crate) fn echo_request( /// Decodes an echo reply the server produced and checks the flags every reply /// must carry. Decoding verifies the MAC against `hmac_key` on the way. pub(crate) fn expect_echo_reply( - packet: &[u8], + packet: impl AsRef<[u8]>, params: &Params, hmac_key: Option<&[u8]>, ) -> EchoReply { - let reply = decode_echo_reply(packet, params, hmac_key).expect("server echo reply must decode"); + let reply = decode_echo_reply(packet.as_ref(), params, hmac_key) + .expect("server echo reply must decode"); // Exact equality, so Open and Close are pinned clear as well: Open makes an // upstream client abort, and Close belongs to the server-initiated close // slice. @@ -334,12 +335,12 @@ pub(crate) fn expect_echo_reply( /// by the same path as any other, which is the point: the close is not a /// separate packet kind. pub(crate) fn expect_closing_echo_reply( - packet: &[u8], + packet: impl AsRef<[u8]>, params: &Params, hmac_key: Option<&[u8]>, ) -> EchoReply { - let reply = - decode_echo_reply(packet, params, hmac_key).expect("a closing echo reply must decode"); + let reply = decode_echo_reply(packet.as_ref(), params, hmac_key) + .expect("a closing echo reply must decode"); let expected = FLAG_REPLY | FLAG_CLOSE | if hmac_key.is_some() { FLAG_HMAC } else { 0 }; assert_eq!(reply.flags, expected, "closing echo reply flags"); reply @@ -452,11 +453,14 @@ pub(crate) fn client_params() -> Params { } /// Decodes a reply the server produced, checking it against the server's key. -pub(crate) fn decode_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> OpenReply { - decode_open_reply(packet, hmac_key).expect("server reply must decode") +pub(crate) fn decode_reply(packet: impl AsRef<[u8]>, hmac_key: Option<&[u8]>) -> OpenReply { + decode_open_reply(packet.as_ref(), hmac_key).expect("server reply must decode") } -pub(crate) fn expect_normal_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> OpenReply { +pub(crate) fn expect_normal_open_reply( + packet: impl AsRef<[u8]>, + hmac_key: Option<&[u8]>, +) -> OpenReply { let reply = decode_reply(packet, hmac_key); let expected = FLAG_OPEN | FLAG_REPLY | if hmac_key.is_some() { FLAG_HMAC } else { 0 }; assert_eq!(reply.flags, expected, "normal open reply flags"); @@ -465,7 +469,7 @@ pub(crate) fn expect_normal_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) - reply } -pub(crate) fn expect_no_test_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> OpenReply { +pub(crate) fn expect_no_test_reply(packet: impl AsRef<[u8]>, hmac_key: Option<&[u8]>) -> OpenReply { let reply = decode_reply(packet, hmac_key); let expected = FLAG_OPEN | FLAG_REPLY | FLAG_CLOSE | if hmac_key.is_some() { FLAG_HMAC } else { 0 }; diff --git a/crates/irtt-server/src/tests/traffic_class.rs b/crates/irtt-server/src/tests/traffic_class.rs new file mode 100644 index 0000000..3620b8d --- /dev/null +++ b/crates/irtt-server/src/tests/traffic_class.rs @@ -0,0 +1,134 @@ +//! Which traffic class the core asks for each reply to be sent with. +//! +//! The negotiated DSCP parameter is the **raw IPv4 TOS / IPv6 Traffic Class +//! byte**, not a six-bit codepoint: the codepoint is its upper six bits and the +//! low two are ECN. Nothing here shifts, masks or rounds it, and these tests +//! exist to keep it that way. +//! +//! The clean specification's Section 20 settles the observable rules for values +//! in `0..=255`: an echo reply carries its own session's value verbatim, the +//! marking follows the session rather than the listener, and an open reply is +//! unmarked even on a listener that has already sent a marked echo reply. The +//! server-close case is covered where the maximum-duration close is tested, and +//! the socket-level proof that these bytes reach the wire lives in the runtime's +//! Linux tests. + +use irtt_proto::Params; + +use super::support::{ + client_params, core_with_tokens, echo_request, expect_echo_reply, expect_no_test_reply, + expect_normal_open_reply, no_test_request, open_request, peer, unthrottled, ScriptedTokens, +}; + +const TOKEN_A: u64 = 0x7896_b6ab_8771_5213; + +/// A raw byte whose low two bits are set on purpose: ECN is part of the value, +/// and a server rebuilding the byte from a codepoint would lose them. +const RAW_TRAFFIC_CLASS: i64 = 0xbb; + +#[test] +fn an_open_reply_is_unmarked_whatever_the_session_negotiates() { + let requested = Params { + dscp: 0xb8, + ..client_params() + }; + let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A])); + + let reply = core + .handle_datagram(peer(), &open_request(&requested, None)) + .unwrap() + .expect("an ordinary open must be answered"); + + assert_eq!( + expect_normal_open_reply(&reply, None).params.dscp, + 0xb8, + "the negotiated value is returned to the client" + ); + assert_eq!( + reply.traffic_class(), + 0, + "an open reply is unmarked however the session it creates is marked" + ); + + let no_test = core + .handle_datagram(peer(), &no_test_request(&requested, None)) + .unwrap() + .expect("a no-test open must be answered"); + assert_eq!(expect_no_test_reply(&no_test, None).params.dscp, 0xb8); + assert_eq!( + no_test.traffic_class(), + 0, + "a no-test reply is unmarked too" + ); +} + +#[test] +fn an_echo_reply_carries_the_sessions_raw_negotiated_byte() { + let requested = Params { + dscp: RAW_TRAFFIC_CLASS, + ..client_params() + }; + let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A])); + let open = core + .handle_datagram(peer(), &open_request(&requested, None)) + .unwrap() + .expect("the session-creating open must be answered"); + let negotiated = expect_normal_open_reply(&open, None).params; + + let reply = core + .handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None)) + .unwrap() + .expect("an admissible echo must be answered"); + + expect_echo_reply(&reply, &negotiated, None); + assert_eq!( + i64::from(reply.traffic_class()), + RAW_TRAFFIC_CLASS, + "the raw byte, unshifted and unmasked" + ); +} + +#[test] +fn a_negotiated_value_that_is_not_a_byte_is_transported_unmarked() { + // `irtt-rs` policy, not observed behavior. The server deliberately accepts + // a DSCP parameter outside `0..=255` and returns and stores it unchanged; + // the clean specification records the reference host's handling of such + // values as platform-specific and explicitly not a compatibility + // requirement. A value that is not a byte cannot be a traffic-class byte, + // so the transport asks for none — rather than wrapping −1 into 255, + // truncating 256 to 0, or refusing a session the client can otherwise use. + for dscp in [-1, 256, i64::MAX] { + let requested = Params { + dscp, + ..client_params() + }; + let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A])); + + let open = core + .handle_datagram(peer(), &open_request(&requested, None)) + .unwrap() + .unwrap_or_else(|| panic!("an open requesting DSCP {dscp} must still be answered")); + let negotiated = expect_normal_open_reply(&open, None).params; + assert_eq!(negotiated.dscp, dscp, "the value is negotiated unchanged"); + assert_eq!(open.traffic_class(), 0); + assert_eq!( + core.session(TOKEN_A) + .expect("the session must be live") + .params() + .dscp, + dscp, + "and stored unchanged" + ); + + let reply = core + .handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None)) + .unwrap() + .unwrap_or_else(|| panic!("the session must still serve echoes with DSCP {dscp}")); + assert_eq!( + expect_echo_reply(&reply, &negotiated, None).recv_count, + Some(1), + "an unmarkable session is an ordinary session in every other way" + ); + assert_eq!(reply.traffic_class(), 0, "transported unmarked"); + } +}