feat(server): implement echo core

This commit is contained in:
Nils Andreas Svee committed 2026-08-12 23:21:02 +07:00
1 parent 5901c52323
commit 8abab7ee38
13 files changed
+1426 -144

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
//! Clock sampling for echo timestamps.
//!
//! An echo reply reports when the server received the request and when it sent
//! the answer, so the core needs a clock. It takes one through this private
//! seam rather than reading the system clock inline, so that timestamp behavior
//! is deterministically testable without a runtime, sleeps or timing
//! tolerances.
//!
//! This is the clock counterpart of [`TokenSource`](crate::token::TokenSource)
//! and nothing more. It is **not** a runtime abstraction, not a transport
//! boundary and not a pluggable product API: it is crate-private, the public
//! constructor still takes only a [`ServerConfig`](crate::ServerConfig), and
//! callers never pass timestamps into the core.
use std::{
fmt,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
/// One instant, read from both clock domains together.
///
/// Both fields are signed nanoseconds, matching the wire encoding: `wall_ns`
/// counts from the Unix epoch, and `mono_ns` from an origin the clock source
/// owns. The specification lets a server pick any monotonic origin as long as
/// it is stable for as long as any session it stamps may live, so `mono_ns` is
/// deliberately not process uptime or anything else externally meaningful —
/// only the difference between two samples from the same source has meaning.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ClockSample {
pub(crate) wall_ns: i64,
pub(crate) mono_ns: i64,
}
impl ClockSample {
/// The arithmetic mean of two samples, taken separately per clock domain.
///
/// This is what a midpoint timestamp is: the mean of one reply's receive
/// and send instants. The two domains never mix, and the mean goes through
/// `i128` because `a + b` can overflow `i64` while the mean itself never
/// can.
pub(crate) fn midpoint(self, other: Self) -> Self {
Self {
wall_ns: mean_ns(self.wall_ns, other.wall_ns),
mono_ns: mean_ns(self.mono_ns, other.mono_ns),
}
}
}
/// A source of clock samples.
///
/// Deliberately private, like [`TokenSource`](crate::token::TokenSource): it
/// exists so tests can script the receive and send instants of a reply, not as
/// an extension point. The production implementation is [`SystemClock`].
///
/// Sampling is infallible. A clock that could refuse would make an admitted
/// echo fail for a reason that is neither the peer's fault nor recoverable, so
/// the production source saturates rather than reporting an error.
pub(crate) trait ClockSource: fmt::Debug + Send {
fn sample(&mut self) -> ClockSample;
}
/// Reads the wall clock from [`SystemTime`] and the monotonic clock from an
/// [`Instant`] captured when the source was created.
///
/// That instant is the monotonic origin, so it is fixed for the life of the
/// source and shared by every session the core using it holds — which is
/// exactly the stability the specification asks for.
#[derive(Debug)]
pub(crate) struct SystemClock {
origin: Instant,
}
impl SystemClock {
pub(crate) fn new() -> Self {
Self {
origin: Instant::now(),
}
}
}
impl Default for SystemClock {
fn default() -> Self {
Self::new()
}
}
impl ClockSource for SystemClock {
fn sample(&mut self) -> ClockSample {
ClockSample {
wall_ns: wall_ns(),
mono_ns: saturating_ns(self.origin.elapsed()),
}
}
}
/// Nanoseconds since the Unix epoch, negative before it.
fn wall_ns() -> i64 {
match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(since_epoch) => saturating_ns(since_epoch),
Err(before_epoch) => saturating_ns(before_epoch.duration()).saturating_neg(),
}
}
/// A duration as nanoseconds, saturating rather than panicking.
///
/// [`Duration`] counts nanoseconds in `u128` and reaches far beyond `i64`,
/// which the wire field is. A host clock set past the year 2262 is a local
/// misconfiguration; it must not become a panic that a remote peer can reach by
/// sending an echo request.
fn saturating_ns(duration: Duration) -> i64 {
i64::try_from(duration.as_nanos()).unwrap_or(i64::MAX)
}
/// The mean of two nanosecond values, computed without overflowing.
fn mean_ns(a: i64, b: i64) -> i64 {
let mean = (i128::from(a) + i128::from(b)) / 2;
// The mean of two `i64` values is always an `i64`, so this conversion
// cannot actually fail. Clamping rather than unwrapping keeps timestamp
// arithmetic free of any panic at all.
i64::try_from(mean).unwrap_or(if mean.is_negative() {
i64::MIN
} else {
i64::MAX
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Constructed values only: the production clock's real readings are not
/// assertable without a timing tolerance, and the behavioral echo tests use
/// a scripted source instead.
#[test]
fn a_midpoint_is_the_per_domain_mean_and_cannot_overflow() {
let receive = ClockSample {
wall_ns: 1_000,
mono_ns: 10_000,
};
let send = ClockSample {
wall_ns: 1_200,
mono_ns: 10_300,
};
assert_eq!(
receive.midpoint(send),
ClockSample {
wall_ns: 1_100,
mono_ns: 10_150,
}
);
// The sum overflows `i64`; the mean does not.
let high = ClockSample {
wall_ns: i64::MAX,
mono_ns: i64::MAX,
};
assert_eq!(
high.midpoint(high),
ClockSample {
wall_ns: i64::MAX,
mono_ns: i64::MAX,
}
);
let low = ClockSample {
wall_ns: i64::MIN,
mono_ns: i64::MIN,
};
assert_eq!(
low.midpoint(low),
ClockSample {
wall_ns: i64::MIN,
mono_ns: i64::MIN,
}
);
assert_eq!(
high.midpoint(low),
ClockSample {
wall_ns: 0,
mono_ns: 0,
}
);
}
#[test]
fn a_duration_beyond_the_wire_field_saturates_rather_than_panicking() {
assert_eq!(saturating_ns(Duration::from_nanos(1_500)), 1_500);
assert_eq!(saturating_ns(Duration::MAX), i64::MAX);
}
}
+2 -2
View File
@@ -71,8 +71,8 @@ impl ServerConfig {
/// reduced to it during negotiation, and an open whose mandatory echo field
/// block would not fit is silently refused, since the server could not emit
/// a compliant reply for it. Authentication counts toward that field block.
/// The echo slice will additionally use this limit to admit inbound echo
/// request datagrams by their received length.
/// The same limit additionally admits inbound echo request datagrams by
/// their received length, before any receive state moves.
///
/// The value is trusted local configuration, like
/// [`with_max_sessions`](Self::with_max_sessions): an operator may
+221 -27
View File
@@ -1,11 +1,13 @@
use std::{collections::HashMap, net::SocketAddr};
use irtt_proto::{
decode_request, echo_packet_len, encode_open_reply, verify_packet_hmac, Clock, DecodedParams,
DecodedRequestKind, OpenReply, Params, StampAt, FLAG_CLOSE, FLAG_OPEN, FLAG_REPLY,
decode_request, echo_packet_len, encode_echo_reply, encode_open_reply, verify_packet_hmac,
Clock, DecodedParams, DecodedRequestKind, EchoReply, OpenReply, Params, StampAt,
TimestampFields, FLAG_CLOSE, FLAG_OPEN, FLAG_REPLY,
};
use crate::{
clock::{ClockSample, ClockSource, SystemClock},
config::ServerConfig,
error::ServerError,
negotiate::negotiate_params,
@@ -16,10 +18,12 @@ use crate::{
/// The deterministic protocol and session engine of the server.
///
/// `ServerCore` owns admission, negotiation, the session table and reply
/// construction. It performs no I/O and keeps no clock: a caller hands it a
/// source endpoint and the bytes of one received datagram, and gets back the
/// bytes to send to that endpoint, or nothing. Everything it does is a pure
/// function of its state and that input, except for drawing session tokens.
/// construction. It performs no I/O: a caller hands it a source endpoint and
/// the bytes of one received datagram, and gets back the bytes to send to that
/// endpoint, or nothing. Everything it does is a pure function of its state and
/// that input, except for drawing session tokens and sampling the clock an echo
/// reply's timestamps come from — both of which are private injected seams, so
/// the whole engine stays deterministically testable.
///
/// The Tokio runtime slice will wrap it directly:
///
@@ -35,29 +39,45 @@ use crate::{
///
/// # Scope
///
/// This slice implements open handling, session creation and client-initiated
/// close. Echo requests are structurally recognized and then deliberately
/// ignored — no reply, no session mutation — until their own slice.
/// This slice implements open handling, session creation, normal echo
/// processing and client-initiated close. Rate limiting, session lifetime and
/// expiry, server-initiated close, DSCP application and the server fill policy
/// are separate slices, so every otherwise admissible echo is answered and no
/// session ever ages out.
#[derive(Debug)]
pub struct ServerCore {
config: ServerConfig,
sessions: HashMap<u64, Session>,
tokens: Box<dyn TokenSource>,
clock: Box<dyn ClockSource>,
}
impl ServerCore {
/// Creates a server core drawing session tokens from the operating system's
/// random source.
/// random source and timestamps from the system clock.
#[must_use]
pub fn new(config: ServerConfig) -> Self {
Self::with_token_source(config, Box::new(OsTokenSource))
}
/// A core with a chosen token source and the production clock, which is
/// what tests that care about session identity but not about timestamp
/// values want.
pub(crate) fn with_token_source(config: ServerConfig, tokens: Box<dyn TokenSource>) -> Self {
Self::with_sources(config, tokens, Box::new(SystemClock::new()))
}
/// A core with both nondeterministic sources chosen.
pub(crate) fn with_sources(
config: ServerConfig,
tokens: Box<dyn TokenSource>,
clock: Box<dyn ClockSource>,
) -> Self {
Self {
config,
sessions: HashMap::new(),
tokens,
clock,
}
}
@@ -96,17 +116,19 @@ impl ServerCore {
/// flag, disagrees with this server's authentication configuration, fails
/// authentication, carries a malformed or out-of-range open parameter
/// payload, negotiates to parameters the server could not execute, would
/// exceed the session bound, or is a request kind this slice does not
/// implement. None of that mutates any live session.
/// exceed the session bound, is longer than the configured maximum packet
/// length, or names no live session from the endpoint that opened it. None
/// of that mutates any live session.
///
/// `Ok(None)` is not only a rejection, though. A close request that does
/// release its session is answered with nothing as well, because protocol
/// version 1 defines no acknowledgement for one.
///
/// Authentication is checked before open parameters are decoded. That
/// ordering is deliberate: a packet that fails authentication is discarded
/// without parsing untrusted parameter data, and no reply behavior may
/// reveal which stage rejected it.
/// Authentication is checked before open parameters are decoded and before
/// any session is looked up. That ordering is deliberate: a packet that
/// fails authentication is discarded without parsing untrusted parameter
/// data or consulting the session table, and no reply behavior may reveal
/// which stage rejected it.
///
/// # Errors
///
@@ -122,6 +144,16 @@ impl ServerCore {
let Ok(request) = decode_request(packet) else {
return Ok(None);
};
// An echo's arrival instant is sampled the moment the datagram is known
// to be one, so that everything the reply's receive timestamp should
// bracket — authentication, the session lookup, the receive-state
// transition and encoding — happens after it. A datagram rejected later
// has consumed one clock sample and nothing else; sampling is not
// protocol state, and it is a strictly better trade than looking a
// session up before authentication to discover whether it even wanted
// timestamps.
let kind = self.classify(&request.kind, packet.len());
// The HMAC flag must agree with this server's configuration in both
// directions of mismatch, and the MAC itself is verified separately.
if request.hmac_present != self.config.hmac_key().is_some() {
@@ -133,22 +165,116 @@ impl ServerCore {
}
}
match request.kind {
DecodedRequestKind::Open { no_test, params } => self.handle_open(peer, no_test, params),
DecodedRequestKind::Close { token } => {
match kind {
ClassifiedKind::Open { no_test, params } => self.handle_open(peer, no_test, params),
ClassifiedKind::Close { token } => {
self.handle_close(peer, token);
Ok(None)
}
// Not implemented in this slice. An echo must not advance receive
// state until the slice that implements it; emitting a placeholder
// reply now would be worse than silence. That slice must admit an
// echo by its received datagram length against
// `max_packet_length` before it touches any receive state; adding
// that branch now would only be dead code guarding nothing.
DecodedRequestKind::Echo { .. } => Ok(None),
ClassifiedKind::Echo(echo) => self.handle_echo(peer, echo),
}
}
/// Pairs a structurally decoded request with the clock sample an echo
/// needs, leaving every other kind untouched.
fn classify<'a>(
&mut self,
kind: &DecodedRequestKind<'a>,
datagram_len: usize,
) -> ClassifiedKind<'a> {
match *kind {
DecodedRequestKind::Open { no_test, params } => {
ClassifiedKind::Open { no_test, params }
}
DecodedRequestKind::Close { token } => ClassifiedKind::Close { token },
// Everything after the sequence number is opaque, so the tail is
// read no further than its length.
DecodedRequestKind::Echo {
token, sequence, ..
} => ClassifiedKind::Echo(EchoRequest {
token,
sequence,
datagram_len,
received_at: self.clock.sample(),
}),
}
}
/// Answers an echo request that names one of this server's sessions.
///
/// Admission runs before any state moves, and each step is a silent
/// discard: the datagram must not be longer than
/// [`max_packet_length`](ServerConfig::max_packet_length), its token must
/// name a live session, and it must have arrived from that session's own
/// endpoint. Authentication was already settled by the caller. A request
/// failing any of these leaves the receive state — of this session and of
/// every other — exactly where it was.
///
/// Only the token and the sequence number mean anything. The request's own
/// length is judged against the configured maximum and then forgotten: it
/// does not have to equal the negotiated length, and it never sizes the
/// reply, which is laid out and sized from the session's negotiated
/// parameters alone.
///
/// The next receive state is computed but not committed until the reply has
/// been encoded, so a reply this server failed to build cannot leave a
/// session claiming to have answered a request it never did.
fn handle_echo(
&mut self,
peer: SocketAddr,
request: EchoRequest,
) -> Result<Option<Vec<u8>>, 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
// handed over. The comparison is strict — a request of exactly the
// maximum is served.
if request.datagram_len > self.config.max_packet_length() {
return Ok(None);
}
let Some(session) = self.sessions.get_mut(&request.token) else {
return Ok(None);
};
if !same_endpoint(session.peer(), peer) {
return Ok(None);
}
let next = session.receive_state().accepted(request.sequence);
// The departure side, sampled as late as the core can: after admission
// and the transition, immediately before the reply is built. There is
// no socket here, so this is the core's departure instant rather than a
// kernel transmission time; the runtime slice does not need to rewrite
// it.
let sent_at = self.clock.sample();
let params = session.params();
let reply = EchoReply {
// Reply only. Open must be clear or upstream clients abort, and
// Close belongs to the server-initiated close slice. The
// authentication flag and field are the encoder's, decided by the
// key it is given.
flags: FLAG_REPLY,
// Both are copied through unchanged: no token is allocated for an
// echo, and no sequence number is normalized or range-checked.
token: request.token,
sequence: request.sequence,
recv_count: params.received_stats.has_count().then_some(next.count),
recv_window: params.received_stats.has_window().then_some(next.window),
timestamps: timestamp_fields(params, request.received_at, sent_at),
// No payload bytes of our own: the encoder zero-fills the whole
// region between the field block and the negotiated length. That is
// the initial safe fill policy — payload bytes carry no protocol
// meaning, and zeroes cannot disclose residue from another request
// or another client. Request payload bytes never reach a reply.
payload: Vec::new(),
};
let packet = encode_echo_reply(&reply, params, self.config.hmac_key())
.map_err(|source| ServerError::ReplyEncoding { source })?;
// The last fallible step is behind us, so the transition can be made.
session.commit_receive_state(next);
Ok(Some(packet))
}
fn handle_open(
&mut self,
peer: SocketAddr,
@@ -252,6 +378,74 @@ impl ServerCore {
}
}
/// A structurally decoded request kind, with an echo's arrival instant already
/// sampled.
///
/// This mirrors [`DecodedRequestKind`] rather than replacing it: the decoder
/// classifies the packet, and this records what the core had to capture at that
/// moment. Keeping the two apart is what lets the clock be read before
/// authentication without any handler having to ask whether a sample exists.
enum ClassifiedKind<'a> {
Open { no_test: bool, params: &'a [u8] },
Close { token: u64 },
Echo(EchoRequest),
}
/// The whole of what an echo request means to this server.
///
/// Everything after the sequence number is opaque, so nothing beyond these
/// fields is carried forward. `datagram_len` is the length the datagram
/// actually arrived with, which the configured maximum judges; it is not
/// required to match the negotiated length, and it never sizes the reply.
#[derive(Debug, Clone, Copy)]
struct EchoRequest {
token: u64,
sequence: u32,
datagram_len: usize,
received_at: ClockSample,
}
/// Builds the timestamp fields a session negotiated, and only those.
///
/// [`StampAt`] selects which instants are reported and [`Clock`] selects which
/// domains, exactly as the reply layout does, so a field this returns as `Some`
/// is a field the encoder has a slot for. A midpoint is the per-domain mean of
/// the two instants.
///
/// A single-clock midpoint therefore produces exactly one midpoint field. That
/// is the conforming representation; upstream 0.9.1's habit of emitting both
/// midpoint fields regardless of the negotiated clock is a defect this server
/// deliberately does not reproduce. `irtt-proto` still *decodes* that form, so
/// interoperating with a peer that sends it is unaffected.
///
/// [`Clock::Unspecified`] selects no domain and so produces no field. It cannot
/// be reached with a non-none `StampAt` on an acknowledged session, because
/// that combination is refused at open; nothing here needs to repair it.
fn timestamp_fields(params: &Params, received: ClockSample, sent: ClockSample) -> TimestampFields {
let per_clock = |sample: ClockSample| {
(
params.clock.has_wall().then_some(sample.wall_ns),
params.clock.has_mono().then_some(sample.mono_ns),
)
};
let absent = (None, None);
let (recv, midpoint, send) = match params.stamp_at {
StampAt::None => (absent, absent, absent),
StampAt::Receive => (per_clock(received), absent, absent),
StampAt::Send => (absent, absent, per_clock(sent)),
StampAt::Both => (per_clock(received), absent, per_clock(sent)),
StampAt::Midpoint => (absent, per_clock(received.midpoint(sent)), absent),
};
TimestampFields {
recv_wall: recv.0,
recv_mono: recv.1,
midpoint_wall: midpoint.0,
midpoint_mono: midpoint.1,
send_wall: send.0,
send_mono: send.1,
}
}
/// Whether two source endpoints identify the same session endpoint.
///
/// The address family, the IP address and the UDP source port are established
@@ -352,7 +546,7 @@ fn open_params_are_admissible(decoded: &DecodedParams) -> bool {
/// open rather than acknowledged and then found unserviceable.
///
/// The size comes from [`echo_packet_len`], the one packet-size authority, so
/// this cannot drift from what the echo slice will actually encode. Its
/// this cannot drift from what an echo reply actually encodes. Its
/// unrepresentable-length error is a rejection here, not a [`ServerError`]: a
/// pathological requested length is remote input, not an internal failure. The
/// default policy makes it unreachable for an acknowledged session anyway, since
+9 -5
View File
@@ -7,7 +7,9 @@
//!
//! [`ServerCore`] is the deterministic protocol and session engine: packet
//! admission, authentication policy, open negotiation, the session table,
//! resource decisions and reply construction. It performs no I/O. Socket and
//! echo receive state, resource decisions and reply construction. It performs
//! no I/O; the clock its echo timestamps come from is a private injected seam,
//! not a runtime abstraction, so the engine stays testable. Socket and
//! runtime orchestration — the UDP listener, address handling, timers, expiry
//! and shutdown — will live around it and is intentionally Tokio-native. There
//! is no blocking or alternate-runtime counterpart and no transport
@@ -33,12 +35,14 @@
//!
//! # Current scope
//!
//! Open handling, session creation and client-initiated close. Echo
//! processing, server-initiated close, session lifetime and expiry, and the
//! Tokio runtime are separate slices; a structurally valid echo request is
//! currently accepted by the admission boundary and then ignored.
//! Open handling, session creation, normal echo processing and
//! client-initiated close. Rate limiting, session lifetime and expiry,
//! server-initiated close, DSCP application, the server fill policy and the
//! Tokio runtime are separate slices: every otherwise admissible echo is
//! answered, and a session lives until its client closes it.
#![forbid(unsafe_code)]
mod clock;
mod config;
mod core;
mod error;
+106 -20
View File
@@ -4,25 +4,29 @@ use irtt_proto::Params;
/// A live session created by an accepted open request.
///
/// This slice records only what identifies the session and what was negotiated
/// for it. Echo receive state (counts, windows, sequence tracking) and lifetime
/// state (activity, deadlines, expiry) belong to the slices that implement
/// those behaviors, and are deliberately absent rather than present as
/// placeholders.
/// A session records what identifies it, what was negotiated for it, and what
/// its accepted echo requests have added up to. Lifetime state (activity,
/// deadlines, expiry) and rate state belong to the slices that implement those
/// behaviors, and are deliberately absent rather than present as placeholders.
///
/// Deliberately crate-private. The next slices are all but certain to add and
/// restructure fields here, and nothing outside the crate needs a session yet;
/// publishing internal session state before ECHO even exists would buy nothing
/// and commit us to a shape we are about to change.
/// Deliberately crate-private. The remaining slices are all but certain to add
/// and restructure fields here, and nothing outside the crate needs a session;
/// publishing internal session state would buy nothing and commit us to a shape
/// we are about to change.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Session {
peer: SocketAddr,
params: Params,
receive: ReceiveState,
}
impl Session {
pub(crate) fn new(peer: SocketAddr, params: Params) -> Self {
Self { peer, params }
Self {
peer,
params,
receive: ReceiveState::default(),
}
}
/// The exact source endpoint the open request arrived from.
@@ -30,10 +34,10 @@ impl Session {
/// A session is bound to this endpoint, not merely to its address: the
/// address family, the address and the UDP source port all form part of its
/// identity, as — by `irtt-rs` policy rather than observed behavior — does
/// the IPv6 scope identifier. Close processing requires both a token match
/// and an endpoint match before removing a session, and echo processing
/// will require the same; `same_endpoint` in `core` is where the comparison
/// and its reasoning live.
/// the IPv6 scope identifier. Close and echo processing both require a
/// token match *and* an endpoint match before touching the session;
/// `same_endpoint` in `core` is where the comparison and its reasoning
/// live.
///
/// The address is stored exactly as received, including the flow label that
/// identity ignores, so a reply goes back to the endpoint the request
@@ -42,13 +46,95 @@ impl Session {
self.peer
}
/// The negotiated parameters the server returned for this session and will
/// enforce for it.
//
// Also awaiting its production consumer: echo replies are built from the
// negotiated params.
#[allow(dead_code)]
/// The negotiated parameters the server returned for this session and
/// enforces for it.
///
/// Echo replies are laid out and sized from these, never from the request
/// that provoked them.
pub(crate) fn params(&self) -> &Params {
&self.params
}
/// What this session's accepted echo requests have added up to so far.
pub(crate) fn receive_state(&self) -> ReceiveState {
self.receive
}
/// Adopts the state computed for an accepted echo request.
///
/// Kept separate from [`ReceiveState::accepted`] on purpose. The transition
/// is pure and this is the irreversible step, so echo handling runs every
/// fallible part — building and encoding the reply — before calling it, and
/// a reply that fails to encode leaves the session exactly as it was.
pub(crate) fn commit_receive_state(&mut self, next: ReceiveState) {
self.receive = next;
}
}
/// What a session's accepted echo requests have added up to.
///
/// The semantics are those of the clean server specification, Section 10, and
/// the input → output vectors in `test-vectors/SERVER_BEHAVIORAL_VECTORS.md`
/// Section 1. Both values are per session, start at zero, and move only for a
/// request that reaches the reply stage: a datagram dropped for its size, its
/// authentication, an unknown token or a foreign endpoint leaves them alone.
///
/// The state is maintained whatever the session negotiated. Which of the two
/// values actually appears in a reply is a separate question, answered by the
/// negotiated `ReceivedStats` when the reply is built.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct ReceiveState {
/// Accepted echo requests, including the one currently being answered.
pub(crate) count: u32,
/// The received window: bit 0 is the request being answered and bit *k* is
/// the request with sequence number (current − *k*), for 1 ≤ *k* ≤ 63.
pub(crate) window: u64,
/// The sequence number the previous accepted request carried, which the
/// next transition is measured against. `None` before the session's first.
last_sequence: Option<u32>,
}
impl ReceiveState {
/// The state after accepting a request carrying `sequence`.
///
/// Pure, so the caller can compute the next state, build and encode a reply
/// from it, and only then commit it.
///
/// The window transition is the observed one, and it is **not** a
/// selective-acknowledgement bitmap: history is shifted along by the
/// unsigned distance from the previous accepted sequence number, and a
/// distance it cannot represent throws all of it away. The consequence is
/// the distinctive part — because the distance is unsigned, a *late or
/// reordered* request produces a very large one, so it resets the window to
/// `0x1` instead of setting an old bit, and the history it discards is
/// never recovered. That late sequence number then becomes the reference
/// for the next transition like any other.
pub(crate) fn accepted(self, sequence: u32) -> Self {
let window = match self.last_sequence {
// The first accepted request of a session reports only itself,
// whatever its sequence number.
None => 0x1,
Some(last) => match sequence.wrapping_sub(last) {
// A duplicate of the most recent sequence number leaves the
// window unchanged, since bit 0 is already set for it.
0 => self.window | 1,
// In order, or a gap the window can still represent: shift the
// history along by the distance and record this request.
distance @ 1..=63 => (self.window << distance) | 1,
// A gap of 64 or more discards every earlier bit — and so does
// any late or reordered request, which lands here because the
// unsigned distance wrapped.
_ => 0x1,
},
};
Self {
// The wire field is 32 bits. No clean evidence reaches 2^32
// requests in one session, so the wrap is not verified behavior: it
// is simply what maintaining a fixed-width counter means, and it is
// chosen over a debug-build overflow panic a peer could drive.
count: self.count.wrapping_add(1),
window,
last_sequence: Some(sequence),
}
}
}
+42
View File
@@ -0,0 +1,42 @@
//! The structural admission boundary.
//!
//! A datagram that never decodes as a request is discarded before any request
//! kind is chosen, so it can reach neither the session table nor an echo's
//! receive state.
use super::support::{client_params, core_for, open_request, open_session, peer, ScriptedTokens};
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
#[test]
fn structurally_invalid_datagrams_are_discarded() {
let mut core = core_for(None, ScriptedTokens::new([TOKEN_A]));
open_session(&mut core, peer(), None);
let session = core.session(TOKEN_A).cloned().expect("session must exist");
let mut bad_magic = open_request(&client_params(), None);
bad_magic[0] ^= 0x01;
let mut reserved_flag = open_request(&client_params(), None);
reserved_flag[3] |= 0x10;
let mut reply_flag = open_request(&client_params(), None);
reply_flag[3] |= irtt_proto::FLAG_REPLY;
for (name, packet) in [
("empty datagram", Vec::new()),
("datagram shorter than the header", vec![0x14, 0xa7, 0x5b]),
("bad magic", bad_magic),
("reserved flag bit", reserved_flag),
("reply flag", reply_flag),
] {
assert_eq!(
core.handle_datagram(peer(), &packet).unwrap(),
None,
"{name} must be discarded"
);
}
assert_eq!(core.session_count(), 1);
assert_eq!(core.session(TOKEN_A), Some(&session));
}
+2 -2
View File
@@ -63,8 +63,8 @@ fn a_repeated_close_is_a_stale_token_and_changes_nothing() {
#[test]
fn an_echo_after_a_close_is_a_stale_token() {
// Echo processing does not exist yet; the point here is that the token is
// gone, so the request has no session to reach even once it does.
// The same request would have been answered a moment earlier: releasing the
// session takes its token with it, so there is nothing left to reach.
for hmac_key in [None, Some(KEY)] {
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
let token = open_session(&mut core, peer(), hmac_key);
+670
View File
@@ -0,0 +1,670 @@
//! Normal echo processing.
//!
//! An echo request that authenticates, fits the configured maximum, names a
//! live session and arrives from that session's endpoint is answered from the
//! session's negotiated parameters. Everything else is silence that leaves the
//! receive state alone, which these tests prove from the wire — the count and
//! window in the *next* accepted reply are what show that a rejected datagram
//! changed nothing.
//!
//! The count and window vectors come from the clean
//! `test-vectors/SERVER_BEHAVIORAL_VECTORS.md` Section 1 and are hard-coded
//! here rather than recomputed, so that replacing the observed semantics with a
//! conventional selective-acknowledgement bitmap fails these tests.
use std::net::{SocketAddr, SocketAddrV6};
use irtt_proto::{
echo_packet_len, verify_packet_hmac, Clock, Params, ReceivedStats, StampAt, TimestampFields,
};
use super::support::{
core_for, core_with_sources, core_with_tokens, echo_params, echo_request, expect_echo_reply,
open_negotiated, other_peer, peer, sample, ScriptedClock, ScriptedTokens, KEY, OTHER_KEY,
};
use crate::ServerConfig;
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
const UNKNOWN_TOKEN: u64 = 0x2122_2324_2526_2728;
/// Params selecting no optional field at all, so the echo layout is the
/// mandatory field block and nothing else.
fn bare_params() -> Params {
echo_params(
ReceivedStats::None,
StampAt::None,
Clock::Unspecified,
/* length */ 0,
)
}
/// Params reporting both statistics, which is what most vectors are stated in.
fn counting_params(length: i64) -> Params {
echo_params(
ReceivedStats::Both,
StampAt::None,
Clock::Unspecified,
length,
)
}
/// Opens one session on a default core and returns the core, the token and the
/// negotiated params.
fn session_with(params: &Params) -> (crate::ServerCore, u64, Params) {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let (token, negotiated) = open_negotiated(&mut core, peer(), params, None);
(core, token, negotiated)
}
#[test]
fn a_valid_echo_is_answered_from_the_negotiated_params() {
let (mut core, token, negotiated) = session_with(&bare_params());
let packet = core
.handle_datagram(peer(), &echo_request(token, 42, &negotiated, &[], None))
.unwrap()
.expect("an admissible echo must be answered");
let reply = expect_echo_reply(&packet, &negotiated, None);
assert_eq!(reply.token, token, "the session token is copied through");
assert_eq!(
reply.sequence, 42,
"the sequence number is copied unchanged"
);
assert_eq!(reply.recv_count, None, "no statistics were negotiated");
assert_eq!(reply.recv_window, None);
assert_eq!(
reply.timestamps,
TimestampFields::default(),
"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!(
core.session_count(),
1,
"an echo neither creates nor releases a session"
);
}
#[test]
fn a_reply_is_sized_by_the_session_not_by_the_request() {
// A request may be shorter or longer than the negotiated length, and its
// bytes past the sequence number are opaque either way.
let (mut core, token, negotiated) = session_with(&counting_params(64));
assert_eq!(negotiated.length, 64);
let short = Params {
length: 0,
..negotiated.clone()
};
// Requesting no statistics moves this request's payload region up to where
// the *session's* reply keeps its count and window, so the opaque bytes
// land squarely on fields a server must not read from a request.
let long = Params {
length: 100,
received_stats: ReceivedStats::None,
..negotiated.clone()
};
for (sequence, name, sizing, payload, expected) in [
(
0,
"a request shorter than the negotiated length",
&short,
&[][..],
(1, 0x1),
),
(
1,
"a request longer than the negotiated length, with opaque bytes",
&long,
&[0xa5; 40][..],
(2, 0x3),
),
] {
let request = echo_request(token, sequence, sizing, payload, None);
assert_ne!(request.len(), 64, "{name} must not be the reply's length");
let packet = core
.handle_datagram(peer(), &request)
.unwrap()
.unwrap_or_else(|| panic!("{name} must be answered"));
let reply = expect_echo_reply(&packet, &negotiated, None);
assert_eq!(
packet.len(),
64,
"{name}: the reply is the negotiated length"
);
assert_eq!(reply.sequence, sequence);
assert_eq!(
(reply.recv_count, reply.recv_window),
(Some(expected.0), Some(expected.1)),
"{name}: the opaque tail must not influence the reply"
);
}
}
#[test]
fn an_echo_over_the_configured_maximum_is_dropped_before_anything_advances() {
let config = ServerConfig::default().with_max_packet_length(64);
let mut core = core_with_tokens(config, ScriptedTokens::new([TOKEN_A]));
let (token, negotiated) = open_negotiated(&mut core, peer(), &counting_params(64), None);
assert_eq!(negotiated.length, 64);
let at_maximum = echo_request(token, 0, &negotiated, &[], None);
assert_eq!(at_maximum.len(), 64);
let over_maximum = echo_request(
token,
1,
&Params {
length: 65,
..negotiated.clone()
},
&[],
None,
);
assert_eq!(over_maximum.len(), 65);
// The comparison is strict, so exactly the maximum is served.
let packet = core
.handle_datagram(peer(), &at_maximum)
.unwrap()
.expect("a request of exactly the maximum must be answered");
let reply = expect_echo_reply(&packet, &negotiated, None);
assert_eq!((reply.recv_count, reply.recv_window), (Some(1), Some(0x1)));
assert_eq!(
core.handle_datagram(peer(), &over_maximum).unwrap(),
None,
"one byte over the maximum must be answered with silence"
);
// The next admissible request is what proves the drop advanced nothing: had
// the oversized one counted, this would report 3 rather than 2.
let packet = core
.handle_datagram(peer(), &echo_request(token, 1, &negotiated, &[], None))
.unwrap()
.expect("an admissible echo after a dropped one must be answered");
let reply = expect_echo_reply(&packet, &negotiated, None);
assert_eq!((reply.recv_count, reply.recv_window), (Some(2), Some(0x3)));
}
#[test]
fn an_echo_that_owns_no_session_here_is_answered_with_silence() {
let (mut core, token, negotiated) = session_with(&counting_params(0));
for (name, endpoint, request_token) in [
("an unknown token", peer(), UNKNOWN_TOKEN),
("a zero token", peer(), 0),
(
"a valid token from another source port",
other_peer(),
token,
),
] {
let request = echo_request(request_token, 0, &negotiated, &[], None);
assert_eq!(
core.handle_datagram(endpoint, &request).unwrap(),
None,
"{name} must be answered with silence"
);
}
// Count 1 and window 0x1 are the first-request values, so none of the three
// rejected datagrams touched this session's receive state.
let packet = core
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
.unwrap()
.expect("the session's own echo must still be answered");
let reply = expect_echo_reply(&packet, &negotiated, None);
assert_eq!((reply.recv_count, reply.recv_window), (Some(1), Some(0x1)));
assert_eq!(core.session_count(), 1);
}
#[test]
fn an_echo_resolves_ipv6_endpoints_by_the_same_identity_rule_as_a_close() {
// The comparator itself is pinned by the close tests; this is the echo path
// reaching the same one rather than growing a second rule.
let global = "2001:db8::1".parse().unwrap();
let link_local = "fe80::1".parse().unwrap();
let unlabeled = SocketAddr::V6(SocketAddrV6::new(global, 2112, 0, 0));
let labeled = SocketAddr::V6(SocketAddrV6::new(global, 2112, 0x000f_1234, 0));
let zone_three = SocketAddr::V6(SocketAddrV6::new(link_local, 2112, 0, 3));
let zone_four = SocketAddr::V6(SocketAddrV6::new(link_local, 2112, 0, 4));
for (bound, sender, answered) in [
// Flow information is routing metadata that identifies no endpoint.
(unlabeled, labeled, true),
(labeled, unlabeled, true),
// The zone is identity by `irtt-rs` policy, not by observed behavior.
(zone_three, zone_four, false),
] {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let (token, negotiated) = open_negotiated(&mut core, bound, &counting_params(0), None);
let reply = core
.handle_datagram(sender, &echo_request(token, 0, &negotiated, &[], None))
.unwrap();
assert_eq!(
reply.is_some(),
answered,
"an echo from {sender} on a session opened from {bound}"
);
}
}
#[test]
fn received_count_and_window_follow_the_observed_vectors() {
// Every row is `(request sequence, reply count, reply window)` exactly as
// measured in Section 1 of the behavioral vectors.
for (name, vectors) in [
(
"1.1 sequential, no loss",
&[
(0, 1, 0x1),
(1, 2, 0x3),
(2, 3, 0x7),
(3, 4, 0xf),
(4, 5, 0x1f),
(5, 6, 0x3f),
][..],
),
(
"1.2 one gap",
&[(0, 1, 0x1), (1, 2, 0x3), (3, 3, 0xd), (4, 4, 0x1b)][..],
),
(
"1.3 multiple gaps",
&[(0, 1, 0x1), (3, 2, 0x9), (7, 3, 0x91), (8, 4, 0x123)][..],
),
(
// Filling a gap late discards the history rather than setting the
// bit the gap left clear.
"1.4 later gap fill",
&[(0, 1, 0x1), (1, 2, 0x3), (5, 3, 0x31), (3, 4, 0x1)][..],
),
(
"1.5 duplicate sequence number",
&[(0, 1, 0x1), (1, 2, 0x3), (1, 3, 0x3), (2, 4, 0x7)][..],
),
(
// Row 5 rebuilds from the reset: bit 1 stays clear even though
// sequence 2 was received before the reordering.
"1.6 reordering",
&[
(0, 1, 0x1),
(1, 2, 0x3),
(2, 3, 0x7),
(1, 4, 0x1),
(3, 5, 0x5),
][..],
),
(
"1.7 strictly descending sequence numbers",
&[
(5, 1, 0x1),
(4, 2, 0x1),
(3, 3, 0x1),
(2, 4, 0x1),
(1, 5, 0x1),
(0, 6, 0x1),
][..],
),
(
"1.8 a gap of exactly 63",
&[(0, 1, 0x1), (63, 2, 0x8000_0000_0000_0001)][..],
),
("1.8 a gap of 64", &[(0, 1, 0x1), (64, 2, 0x1)][..]),
("1.8 a gap of 65", &[(0, 1, 0x1), (65, 2, 0x1)][..]),
("1.8 a gap of 1000", &[(0, 1, 0x1), (1000, 2, 0x1)][..]),
(
"1.9 a non-zero starting sequence number",
&[(100, 1, 0x1), (101, 2, 0x3), (102, 3, 0x7)][..],
),
(
"1.10 32-bit sequence wraparound",
&[
(0xffff_fffd, 1, 0x1),
(0xffff_fffe, 2, 0x3),
(0xffff_ffff, 3, 0x7),
(0, 4, 0xf),
(1, 5, 0x1f),
][..],
),
] {
let (mut core, token, negotiated) = session_with(&counting_params(0));
for &(sequence, count, window) in vectors {
let packet = core
.handle_datagram(
peer(),
&echo_request(token, sequence, &negotiated, &[], None),
)
.unwrap()
.unwrap_or_else(|| panic!("{name}: sequence {sequence} must be answered"));
let reply = expect_echo_reply(&packet, &negotiated, None);
assert_eq!(
reply.recv_count,
Some(count),
"{name}: count after sequence {sequence}"
);
assert_eq!(
reply.recv_window,
Some(window),
"{name}: window after sequence {sequence}"
);
}
}
}
#[test]
fn only_the_negotiated_received_statistics_are_reported() {
// Field presence only; the transition itself is pinned by the vectors
// above, so two in-order requests are enough here.
for (stats, count, window) in [
(ReceivedStats::None, None, None),
(ReceivedStats::Count, Some(2), None),
(ReceivedStats::Window, None, Some(0x3)),
(ReceivedStats::Both, Some(2), Some(0x3)),
] {
let params = echo_params(stats, StampAt::None, Clock::Unspecified, 0);
let (mut core, token, negotiated) = session_with(&params);
let mut reply = None;
for sequence in 0..2 {
let packet = core
.handle_datagram(
peer(),
&echo_request(token, sequence, &negotiated, &[], None),
)
.unwrap()
.unwrap_or_else(|| panic!("{stats:?}: sequence {sequence} must be answered"));
reply = Some(expect_echo_reply(&packet, &negotiated, None));
}
let reply = reply.expect("two requests were sent");
assert_eq!(reply.recv_count, count, "count presence for {stats:?}");
assert_eq!(reply.recv_window, window, "window presence for {stats:?}");
}
}
#[test]
fn an_authenticated_session_answers_only_authenticated_echoes() {
let mut core = core_for(Some(KEY), ScriptedTokens::new([TOKEN_A]));
let (token, negotiated) = open_negotiated(&mut core, peer(), &counting_params(0), Some(KEY));
let packet = core
.handle_datagram(peer(), &echo_request(token, 3, &negotiated, &[], Some(KEY)))
.unwrap()
.expect("a correctly authenticated echo must be answered");
assert!(
verify_packet_hmac(KEY, &packet).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
// decoding, so a reply that authenticated but omitted the flag would fail
// here — the failure mode that silently breaks upstream clients.
let reply = expect_echo_reply(&packet, &negotiated, Some(KEY));
assert_eq!(reply.token, token);
assert_eq!(reply.sequence, 3);
assert_eq!((reply.recv_count, reply.recv_window), (Some(1), Some(0x1)));
let mut corrupted = echo_request(token, 4, &negotiated, &[], Some(KEY));
corrupted[5] ^= 0x80;
for (name, packet) in [
(
"an echo with no MAC field at all",
echo_request(token, 4, &negotiated, &[], None),
),
(
"an echo signed with a different key",
echo_request(token, 4, &negotiated, &[], Some(OTHER_KEY)),
),
("an echo with one bit flipped in the MAC", corrupted),
] {
assert_eq!(
core.handle_datagram(peer(), &packet).unwrap(),
None,
"{name} must be answered with silence"
);
}
let packet = core
.handle_datagram(peer(), &echo_request(token, 4, &negotiated, &[], Some(KEY)))
.unwrap()
.expect("an authenticated echo after failed ones must be answered");
let reply = expect_echo_reply(&packet, &negotiated, Some(KEY));
assert_eq!(
(reply.recv_count, reply.recv_window),
(Some(2), Some(0x3)),
"none of the packets that failed authentication may be counted"
);
}
#[test]
fn only_the_negotiated_timestamps_are_emitted() {
let received = sample(1_000, 10_000);
let sent = sample(1_200, 10_300);
for (stamp_at, clock, expected) in [
(
StampAt::Send,
Clock::Wall,
TimestampFields {
send_wall: Some(1_200),
..TimestampFields::default()
},
),
(
StampAt::Send,
Clock::Monotonic,
TimestampFields {
send_mono: Some(10_300),
..TimestampFields::default()
},
),
(
StampAt::Send,
Clock::Both,
TimestampFields {
send_wall: Some(1_200),
send_mono: Some(10_300),
..TimestampFields::default()
},
),
(
StampAt::Receive,
Clock::Wall,
TimestampFields {
recv_wall: Some(1_000),
..TimestampFields::default()
},
),
(
StampAt::Receive,
Clock::Monotonic,
TimestampFields {
recv_mono: Some(10_000),
..TimestampFields::default()
},
),
(
StampAt::Receive,
Clock::Both,
TimestampFields {
recv_wall: Some(1_000),
recv_mono: Some(10_000),
..TimestampFields::default()
},
),
(
StampAt::Both,
Clock::Wall,
TimestampFields {
recv_wall: Some(1_000),
send_wall: Some(1_200),
..TimestampFields::default()
},
),
(
StampAt::Both,
Clock::Monotonic,
TimestampFields {
recv_mono: Some(10_000),
send_mono: Some(10_300),
..TimestampFields::default()
},
),
(
StampAt::Both,
Clock::Both,
TimestampFields {
recv_wall: Some(1_000),
recv_mono: Some(10_000),
send_wall: Some(1_200),
send_mono: Some(10_300),
..TimestampFields::default()
},
),
(
StampAt::Midpoint,
Clock::Wall,
TimestampFields {
midpoint_wall: Some(1_100),
..TimestampFields::default()
},
),
(
StampAt::Midpoint,
Clock::Monotonic,
TimestampFields {
midpoint_mono: Some(10_150),
..TimestampFields::default()
},
),
(
StampAt::Midpoint,
Clock::Both,
TimestampFields {
midpoint_wall: Some(1_100),
midpoint_mono: Some(10_150),
..TimestampFields::default()
},
),
] {
let clock_source = ScriptedClock::new([received, sent]);
let mut core = core_with_sources(
ServerConfig::default(),
ScriptedTokens::new([TOKEN_A]),
clock_source.clone(),
);
let params = echo_params(ReceivedStats::None, stamp_at, clock, 0);
let (token, negotiated) = open_negotiated(&mut core, peer(), &params, None);
assert_eq!(
clock_source.remaining(),
2,
"an open must not consume a clock sample"
);
let packet = core
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
.unwrap()
.unwrap_or_else(|| panic!("{stamp_at:?} on {clock:?} must be answered"));
let reply = expect_echo_reply(&packet, &negotiated, None);
// Equality over the whole struct, so an unselected field appearing is a
// failure as much as a selected one carrying the wrong instant.
assert_eq!(reply.timestamps, expected, "{stamp_at:?} on {clock:?}");
assert_eq!(
packet.len(),
echo_packet_len(false, &negotiated).unwrap(),
"{stamp_at:?} on {clock:?} must be the negotiated layout's length"
);
assert_eq!(
clock_source.remaining(),
0,
"{stamp_at:?} on {clock:?}: one receive and one send sample"
);
}
}
#[test]
fn a_single_clock_midpoint_emits_exactly_one_field() {
// A guard against reproducing upstream 0.9.1's dual-field midpoint, which
// emits both midpoint fields whatever the negotiated clock. `irtt-proto`
// still decodes that form from a peer; this is about what this server
// sends.
for (clock, expected) in [
(
Clock::Wall,
TimestampFields {
midpoint_wall: Some(1_100),
..TimestampFields::default()
},
),
(
Clock::Monotonic,
TimestampFields {
midpoint_mono: Some(10_150),
..TimestampFields::default()
},
),
] {
let mut core = core_with_sources(
ServerConfig::default(),
ScriptedTokens::new([TOKEN_A]),
ScriptedClock::new([sample(1_000, 10_000), sample(1_200, 10_300)]),
);
let params = echo_params(ReceivedStats::None, StampAt::Midpoint, clock, 0);
let (token, negotiated) = open_negotiated(&mut core, peer(), &params, None);
let packet = core
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
.unwrap()
.unwrap_or_else(|| panic!("a midpoint echo on {clock:?} must be answered"));
let reply = expect_echo_reply(&packet, &negotiated, None);
assert_eq!(
packet.len(),
echo_packet_len(false, &negotiated).unwrap(),
"{clock:?}: the negotiated length, not the compatibility one"
);
assert_eq!(
packet.len(),
24,
"{clock:?}: one 8-byte midpoint field, not the upstream two"
);
assert_eq!(reply.timestamps, expected);
}
}
#[test]
fn a_reply_payload_is_zero_filled_and_never_reflects_the_request() {
let params = echo_params(ReceivedStats::None, StampAt::None, Clock::Unspecified, 64);
let (mut core, token, negotiated) = session_with(&params);
let request = echo_request(token, 0, &negotiated, &[0xa5; 48], None);
assert!(
request[16..].iter().all(|byte| *byte == 0xa5),
"the request must carry non-zero payload bytes to reflect"
);
let packet = core
.handle_datagram(peer(), &request)
.unwrap()
.expect("an admissible echo must be answered");
let reply = expect_echo_reply(&packet, &negotiated, None);
assert_eq!(packet.len(), 64);
assert_eq!(reply.payload.len(), 48);
assert!(
reply.payload.iter().all(|byte| *byte == 0),
"the payload region must be zero-filled, never request or buffer residue"
);
}
+7 -3
View File
@@ -5,10 +5,15 @@
//! production encoders wherever the wire form is well-formed, and by hand only
//! where the point of the test is a payload a compliant encoder cannot produce.
//!
//! Tokens come from a scripted source so session identity, collisions and
//! allocation failure are all assertable.
//! The two nondeterministic parts are injected. Tokens come from a scripted
//! source so session identity, collisions and allocation failure are all
//! assertable; the clock an echo reply's timestamps come from is scripted the
//! same way, so timestamp behavior is pinned exactly, with no sleeps, no
//! runtime and no timing tolerances.
mod admission;
mod close;
mod echo;
mod hmac;
mod no_test;
mod open;
@@ -16,4 +21,3 @@ mod params;
mod sessions;
mod support;
mod tokens;
mod unsupported;
+136 -4
View File
@@ -5,11 +5,17 @@ use std::{
};
use irtt_proto::{
decode_open_reply, encode_request, varint, OpenReply, PacketLayout, Params, RequestToEncode,
FLAG_CLOSE, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY,
decode_echo_reply, decode_open_reply, encode_request, varint, EchoReply, OpenReply,
PacketLayout, Params, RequestToEncode, FLAG_CLOSE, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY,
};
use crate::{core::ServerCore, error::ServerError, token::TokenSource, ServerConfig};
use crate::{
clock::{ClockSample, ClockSource},
core::ServerCore,
error::ServerError,
token::TokenSource,
ServerConfig,
};
pub(crate) const KEY: &[u8] = b"correctkey";
pub(crate) const OTHER_KEY: &[u8] = b"wrongkey";
@@ -67,11 +73,63 @@ impl TokenSource for ScriptedTokens {
}
}
/// A core with deterministic tokens, so session identity is assertable.
/// A clock handing out a fixed script of samples, then failing loudly.
///
/// Running the script dry is a test bug, not a wrap-around: a test that asserts
/// timestamps must know exactly which sample each field came from. The core
/// takes one sample when it classifies a datagram as an echo, and a second when
/// an admitted echo is about to be answered.
#[derive(Debug, Clone)]
pub(crate) struct ScriptedClock {
samples: Arc<Mutex<VecDeque<ClockSample>>>,
}
impl ScriptedClock {
pub(crate) fn new<I>(samples: I) -> Self
where
I: IntoIterator<Item = ClockSample>,
{
Self {
samples: Arc::new(Mutex::new(samples.into_iter().collect())),
}
}
/// How many scripted samples are still unused.
pub(crate) fn remaining(&self) -> usize {
self.samples.lock().unwrap().len()
}
}
impl ClockSource for ScriptedClock {
fn sample(&mut self) -> ClockSample {
self.samples
.lock()
.unwrap()
.pop_front()
.expect("the scripted clock ran out: script one sample per echo received, and one more per echo answered")
}
}
pub(crate) fn sample(wall_ns: i64, mono_ns: i64) -> ClockSample {
ClockSample { wall_ns, mono_ns }
}
/// A core with deterministic tokens, so session identity is assertable. Its
/// clock is the production one, which is fine for everything that does not
/// assert timestamp values.
pub(crate) fn core_with_tokens(config: ServerConfig, tokens: ScriptedTokens) -> ServerCore {
ServerCore::with_token_source(config, Box::new(tokens))
}
/// A core with both nondeterministic sources scripted, for timestamp tests.
pub(crate) fn core_with_sources(
config: ServerConfig,
tokens: ScriptedTokens,
clock: ScriptedClock,
) -> ServerCore {
ServerCore::with_sources(config, Box::new(tokens), Box::new(clock))
}
/// An otherwise default core that authenticates iff `hmac_key` is set.
///
/// Most behavior has to hold identically with and without authentication, so
@@ -97,6 +155,80 @@ pub(crate) fn open_session(
expect_normal_open_reply(&packet, hmac_key).token
}
/// Opens one session carrying `params` and returns its token together with the
/// parameters the server negotiated for it.
///
/// Echo tests need both: the token addresses the session, and the negotiated
/// params are what the reply is laid out from and what decoding it requires.
pub(crate) fn open_negotiated(
core: &mut ServerCore,
endpoint: SocketAddr,
params: &Params,
hmac_key: Option<&[u8]>,
) -> (u64, Params) {
let packet = core
.handle_datagram(endpoint, &open_request(params, hmac_key))
.unwrap()
.expect("the session-creating open must be answered");
let reply = expect_normal_open_reply(&packet, hmac_key);
(reply.token, reply.params)
}
/// Params requesting exactly the echo layout a test cares about, with the
/// duration and interval a well-behaved client sends.
pub(crate) fn echo_params(
received_stats: irtt_proto::ReceivedStats,
stamp_at: irtt_proto::StampAt,
clock: irtt_proto::Clock,
length: i64,
) -> Params {
Params {
received_stats,
stamp_at,
clock,
length,
..client_params()
}
}
/// Encodes an echo request. `params` supplies only the *request's* own sizing;
/// it is deliberately a separate argument from the session's negotiated params
/// so tests can send requests shorter and longer than the session negotiated.
pub(crate) fn echo_request(
token: u64,
sequence: u32,
params: &Params,
payload: &[u8],
hmac_key: Option<&[u8]>,
) -> Vec<u8> {
encode_request(
RequestToEncode::Echo {
token,
sequence,
params,
payload,
},
hmac_key,
)
.unwrap()
}
/// 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],
params: &Params,
hmac_key: Option<&[u8]>,
) -> EchoReply {
let reply = decode_echo_reply(packet, 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.
let expected = FLAG_REPLY | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
assert_eq!(reply.flags, expected, "echo reply flags");
reply
}
/// Encodes a normal open request carrying `params`.
pub(crate) fn open_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec<u8> {
encode_request(
@@ -1,74 +0,0 @@
//! Requests this slice deliberately does not implement.
//!
//! Echo is recognized by the admission boundary and then ignored. These tests
//! exist to keep the scope boundary honest: receive state must not appear by
//! accident before the slice that owns it.
use irtt_proto::{encode_request, RequestToEncode};
use super::support::{
client_params, core_for, open_request, open_session, peer, ScriptedTokens, KEY,
};
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
fn open_one_session(hmac_key: Option<&[u8]>) -> crate::ServerCore {
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
open_session(&mut core, peer(), hmac_key);
core
}
#[test]
fn a_valid_echo_is_neither_answered_nor_counted_yet() {
for hmac_key in [None, Some(KEY)] {
let mut core = open_one_session(hmac_key);
let session = core.session(TOKEN_A).cloned().expect("session must exist");
let echo = encode_request(
RequestToEncode::Echo {
token: TOKEN_A,
sequence: 0,
params: session.params(),
payload: &[],
},
hmac_key,
)
.unwrap();
assert_eq!(core.handle_datagram(peer(), &echo).unwrap(), None);
assert_eq!(core.session_count(), 1);
assert_eq!(core.session(TOKEN_A), Some(&session));
}
}
#[test]
fn structurally_invalid_datagrams_are_discarded() {
let mut core = open_one_session(None);
let session = core.session(TOKEN_A).cloned().expect("session must exist");
let mut bad_magic = open_request(&client_params(), None);
bad_magic[0] ^= 0x01;
let mut reserved_flag = open_request(&client_params(), None);
reserved_flag[3] |= 0x10;
let mut reply_flag = open_request(&client_params(), None);
reply_flag[3] |= irtt_proto::FLAG_REPLY;
for (name, packet) in [
("empty datagram", Vec::new()),
("datagram shorter than the header", vec![0x14, 0xa7, 0x5b]),
("bad magic", bad_magic),
("reserved flag bit", reserved_flag),
("reply flag", reply_flag),
] {
assert_eq!(
core.handle_datagram(peer(), &packet).unwrap(),
None,
"{name} must be discarded"
);
}
assert_eq!(core.session_count(), 1);
assert_eq!(core.session(TOKEN_A), Some(&session));
}