Files
irtt-rs/crates/irtt-server/src/tests/open.rs
T

459 lines
17 KiB
Rust

use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt, TimestampFields};
use super::support::{
client_params, core_with_tokens, echo_request, expect_echo_reply, expect_normal_open_reply,
open_request, open_request_with_raw_params, param_int, peer, unthrottled, ScriptedTokens, KEY,
};
use crate::{ServerConfig, TimestampAllowance, DEFAULT_MAX_PACKET_LENGTH};
const TOKEN_A: u64 = 0x7896_b6ab_8771_5213;
#[test]
fn an_ordinary_open_is_answered_and_creates_one_session() {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(peer(), &open_request(&client_params(), None))
.unwrap()
.expect("an ordinary open must be answered");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(reply.token, TOKEN_A);
assert_eq!(core.session_count(), 1);
let session = core.session(TOKEN_A).expect("the session must be live");
assert_eq!(session.params(), &reply.params);
}
#[test]
fn a_session_records_the_exact_source_endpoint() {
// Address family, address, port and — where present — the IPv6 scope all
// form part of session identity, so the endpoint is stored verbatim rather
// than normalized to an address.
for endpoint in [
"198.51.100.7:41234",
"198.51.100.7:41235",
"[2001:db8::1]:2112",
"[fe80::1%3]:2112",
] {
let endpoint = endpoint.parse().unwrap();
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
core.handle_datagram(endpoint, &open_request(&client_params(), None))
.unwrap()
.expect("open must be answered");
assert_eq!(core.session(TOKEN_A).unwrap().peer(), endpoint);
}
}
#[test]
fn any_requested_protocol_version_is_accepted_and_answered_with_one() {
// Version mismatch is detected client-side only: the server rewrites the
// version and creates a normal session whatever was asked for.
for requested in [Some(1), Some(0), Some(2), Some(-1), None] {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let params = Params {
protocol_version: requested.unwrap_or(0),
..client_params()
};
let request = match requested {
// An explicit zero cannot be encoded by `Params::encode`, which
// omits zero-valued integers, so encode that tag by hand.
Some(0) => open_request_with_raw_params(&param_int(1, 0), None),
Some(_) => open_request(&params, None),
None => open_request(
&Params {
protocol_version: 0,
..params
},
None,
),
};
let packet = core
.handle_datagram(peer(), &request)
.unwrap()
.unwrap_or_else(|| panic!("protocol version {requested:?} must be answered"));
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(reply.params.protocol_version, 1);
assert_eq!(core.session_count(), 1);
}
}
#[test]
fn an_empty_parameter_payload_is_answered_with_only_a_version() {
// The interval floor is disabled here so that the reply carries nothing but
// the version: with one configured, an absent Interval negotiates up to it
// and is encoded, which is interval policy rather than what this test is
// about.
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(peer(), &open_request_with_raw_params(&[], None))
.unwrap()
.expect("an empty parameter payload is a valid open");
// 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.bytes().len(), 14);
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(
reply.params,
Params {
protocol_version: 1,
..Params::default()
}
);
// An unspecified clock is not itself a defect. Only pairing it with a
// selected `stamp_at` is, so a session requesting no timestamps at all is
// fully valid with no Clock tag.
assert_eq!(reply.params.stamp_at, StampAt::None);
assert_eq!(reply.params.clock, Clock::Unspecified);
assert_eq!(core.session_count(), 1);
}
#[test]
fn unknown_parameter_tags_are_ignored_and_not_reflected_in_the_reply() {
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
let mut payload = param_int(1, 1);
payload.extend_from_slice(&param_int(42, 7));
payload.extend_from_slice(&param_int(200, 9));
let packet = core
.handle_datagram(peer(), &open_request_with_raw_params(&payload, None))
.unwrap()
.expect("unknown tags must not prevent an open");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(
reply.params,
Params {
protocol_version: 1,
..Params::default()
}
);
// The reply is the same 14 bytes an empty payload produces: no unknown tag
// was copied through.
assert_eq!(packet.bytes().len(), 14);
assert_eq!(core.session_count(), 1);
}
#[test]
fn selecting_timestamps_without_a_clock_is_silently_refused() {
// Timestamps from no clock at all describe a session the server could not
// run: the negotiated layout carries no timestamp field to fill. The open
// is discarded rather than repaired — synthesizing a clock would answer
// with a session the client never asked for, and dropping `stamp_at` would
// silently discard the measurement it did ask for.
//
// Only an *absent* Clock tag reaches this: an explicit zero is already out
// of range for the decoder, and a conforming client always sends a clock
// when it selects timestamps.
//
// A server whose timestamp allowance is none accepts the very same request,
// because then no timestamp is negotiated to be unproducible; see
// `a_timestamp_disallowing_server_accepts_a_request_that_omitted_its_clock`.
for stamp_at in [
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
] {
let requested = Params {
clock: Clock::Unspecified,
stamp_at,
..client_params()
};
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
let reply = core
.handle_datagram(peer(), &open_request(&requested, None))
.expect("an unusable timestamp request is not a server error");
assert!(
reply.is_none(),
"{stamp_at:?} without a clock must be silent"
);
assert_eq!(
core.session_count(),
0,
"{stamp_at:?} without a clock must create no session"
);
assert_eq!(
tokens.remaining(),
1,
"{stamp_at:?} without a clock must not consume a token"
);
}
}
#[test]
fn a_timestamp_disallowing_server_accepts_a_request_that_omitted_its_clock() {
// The same request the test above refuses, against a server that provides no
// timestamps at all. The restriction runs before the effective-session
// check, so `stamp_at` is already none by the time executability is judged:
// the absent clock then selects nothing that was going to be reported, and
// the session is one the server can run exactly as negotiated.
//
// This is what the ordering exists for. Validating the *request* instead
// would refuse an open that this policy makes perfectly serviceable.
let requested = Params {
clock: Clock::Unspecified,
stamp_at: StampAt::Both,
..client_params()
};
let mut core = core_with_tokens(
unthrottled().with_timestamp_allowance(TimestampAllowance::None),
ScriptedTokens::new([TOKEN_A]),
);
let packet = core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.expect("an omitted clock is safe once no timestamps are negotiated");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(reply.params.stamp_at, StampAt::None);
assert_eq!(
reply.params.clock,
Clock::Unspecified,
"the absent clock tag stays absent"
);
assert_eq!(core.session_count(), 1);
let echo = core
.handle_datagram(
peer(),
&echo_request(reply.token, 0, &reply.params, &[], None),
)
.unwrap()
.expect("the session must serve echoes");
assert_eq!(
expect_echo_reply(&echo, &reply.params, None).timestamps,
TimestampFields::default(),
"no timestamps were negotiated, so the reply carries none"
);
}
#[test]
fn current_non_length_parameters_survive_negotiation_unchanged() {
// Statistics, timestamps and DSCP are what the capability restrictions
// change when they are configured; this is the default configuration, which
// restricts none of them. Version, an oversized packet
// length, the duration maximum and the interval floor and cap are already
// restricted and have their own tests; the length here is deliberately
// under the default maximum, and the configuration below leaves duration
// and interval unrestricted, so this test says nothing about either policy.
//
// The server fill below is a *valid* descriptor, which is what survives
// unchanged. Fill negotiation has its own restriction rule and its own
// tests in `fill`.
//
// This is about what *negotiation* rewrites, which is a separate question
// from whether the core will acknowledge the result: an open whose
// effective parameters are incoherent is still discarded, as
// `selecting_timestamps_without_a_clock_is_silently_refused` shows.
let unrestricted = Params {
protocol_version: 2,
duration_ns: 3_600_000_000_000,
interval_ns: 1,
length: 1472,
received_stats: ReceivedStats::Window,
stamp_at: StampAt::Midpoint,
clock: Clock::Monotonic,
dscp: 184,
server_fill: Some(ServerFill {
value: "pattern:abcd".to_owned(),
}),
};
// No duration maximum is configured by default, and the interval floor is
// disabled, so a one-nanosecond interval is left where it was asked for.
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(peer(), &open_request(&unrestricted, None))
.unwrap()
.expect("open must be answered");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(
reply.params,
Params {
protocol_version: 1,
..unrestricted
}
);
}
#[test]
fn a_positive_length_above_the_configured_maximum_is_reduced_to_it() {
// The reduced value is what the client is told and what the session holds,
// because it is what the server will actually emit. Both have to agree: an
// upstream client treats a reply shorter than the negotiated length as
// fatal, so an honest reduction at open is the only serviceable answer.
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(
ServerConfig::default().with_max_packet_length(64),
tokens.clone(),
);
let requested = Params {
length: 1000,
..client_params()
};
let packet = core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.expect("a reducible length must still be answered");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(reply.params.length, 64);
assert_eq!(core.session_count(), 1);
assert_eq!(tokens.remaining(), 0, "one token was consumed");
let session = core.session(TOKEN_A).expect("the session must be live");
assert_eq!(
session.params().length,
64,
"the session must enforce the reduced length"
);
}
#[test]
fn the_default_maximum_reduces_an_absurd_requested_length() {
// The largest length the wire can carry is representable as a `usize` on a
// 64-bit target, so packet sizing would happily have turned it into an
// allocation; the configured maximum is what stops it. Restriction happens
// during negotiation, before any sizing, so this holds on a 32-bit target
// too — the request never reaches a conversion that could fail.
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let requested = Params {
length: i64::MAX,
..client_params()
};
let packet = core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.expect("an absurd length is reduced, not refused");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(reply.params.length, DEFAULT_MAX_PACKET_LENGTH as i64);
let session = core.session(TOKEN_A).expect("the session must be live");
assert_eq!(session.params().length, DEFAULT_MAX_PACKET_LENGTH as i64);
}
#[test]
fn an_open_whose_echo_field_block_exceeds_the_maximum_is_silently_refused() {
// Length itself satisfies the policy — zero asks for nothing beyond the
// mandatory fields — but the received-statistics fields alone make the
// smallest compliant echo reply larger than 20 bytes. Capping the Length
// parameter is therefore not enough on its own: this session could never be
// answered, so it is never acknowledged.
let requested = Params {
protocol_version: 1,
length: 0,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::None,
clock: Clock::Unspecified,
..Params::default()
};
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(
ServerConfig::default().with_max_packet_length(20),
tokens.clone(),
);
assert_eq!(
core.handle_datagram(peer(), &open_request(&requested, None))
.expect("an unservable packet size is not a server error"),
None
);
assert_eq!(core.session_count(), 0);
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
}
#[test]
fn authentication_counts_toward_the_executable_echo_size() {
// An authentication field adds 16 bytes to the mandatory echo block, so a
// maximum between the two floors admits the same minimal session on an
// unauthenticated server and refuses it on an authenticated one. The sizes
// themselves come from `irtt_proto`; what this pins is that the server asks
// about its own authentication mode rather than assuming the smaller floor.
let minimal = Params {
protocol_version: 1,
..Params::default()
};
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut authenticated = core_with_tokens(
ServerConfig::default()
.with_hmac_key(KEY)
.with_max_packet_length(31),
tokens.clone(),
);
assert_eq!(
authenticated
.handle_datagram(peer(), &open_request(&minimal, Some(KEY)))
.expect("an unservable packet size is not a server error"),
None
);
assert_eq!(authenticated.session_count(), 0);
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
let mut unauthenticated = core_with_tokens(
ServerConfig::default().with_max_packet_length(31),
ScriptedTokens::new([TOKEN_A]),
);
let packet = unauthenticated
.handle_datagram(peer(), &open_request(&minimal, None))
.unwrap()
.expect("the same session fits without an authentication field");
expect_normal_open_reply(&packet, None);
assert_eq!(unauthenticated.session_count(), 1);
}
#[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, 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*
// lengths and touches nothing else. It does not need clamping here to be
// executable: echo packet sizing in `irtt-proto` floors it at the required
// field block, so the session this creates can be run as negotiated.
for (length, dscp) in [(-1, -1), (0, 0), (1472, 184), (-4096, 512)] {
let requested = Params {
protocol_version: 1,
length,
dscp,
..Params::default()
};
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.unwrap_or_else(|| panic!("length {length} dscp {dscp} must be answered"));
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(reply.params.length, length);
assert_eq!(reply.params.dscp, dscp);
assert_eq!(core.session_count(), 1);
let session = core.session(TOKEN_A).expect("the session must be live");
assert_eq!(
session.params(),
&reply.params,
"the session must hold the negotiated values verbatim"
);
}
}