tests: rework the actually useful tests to live in the integration crate
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 34s
Rust CI / cargo clippy (push) Successful in 35s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 10s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m14s
Rust CI / cargo doc (push) Successful in 23s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Supply chain / cargo-deny advisories (push) Successful in 9s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m11s

This commit is contained in:
Nils Andreas Svee committed 2026-10-03 13:59:38 +07:00
1 parent 74e7532965
commit 48a50d7f3a
101 files changed
+3636 -15159

No files matched your search

+4 -116
View File
@@ -1,16 +1,5 @@
//! 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 and the public
//! constructor still takes only a [`ServerConfig`](crate::ServerConfig).
//!
//! The public [`ServerCore::handle_datagram`](crate::ServerCore::handle_datagram)
//! entry point takes no timestamp: the core samples every instant it reports.
//! The Tokio runtime uses a crate-private entry point that may additionally hand
@@ -21,10 +10,7 @@
//! one that models transport metadata. A [`ClockSample`] remains one paired
//! userspace instant and never carries a kernel reading.
use std::{
fmt,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
/// One instant, read from both clock domains together.
///
@@ -77,19 +63,6 @@ impl ClockSample {
}
}
/// 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.
///
@@ -115,8 +88,8 @@ impl Default for SystemClock {
}
}
impl ClockSource for SystemClock {
fn sample(&mut self) -> ClockSample {
impl SystemClock {
pub(crate) fn sample(&mut self) -> ClockSample {
ClockSample {
wall_ns: wall_ns(),
mono_ns: saturating_ns(self.origin.elapsed()),
@@ -136,7 +109,7 @@ fn wall_ns() -> i64 {
/// does not fit the wire's signed-nanosecond field.
///
/// This is the conversion for an instant the server did **not** read from its
/// own [`ClockSource`] — today, a kernel receive timestamp the transport
/// own clock — today, a kernel receive timestamp the transport
/// observed. It reports unrepresentability rather than saturating, because a
/// clamped instant is indistinguishable from a real one at the boundary and
/// would then be compared against a genuine sample as though it were plausible.
@@ -183,88 +156,3 @@ fn mean_ns(a: i64, b: i64) -> i64 {
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);
}
#[test]
fn an_observed_instant_converts_or_reports_that_it_cannot() {
assert_eq!(
wall_ns_of(UNIX_EPOCH + Duration::from_nanos(1_500)),
Some(1_500)
);
assert_eq!(wall_ns_of(UNIX_EPOCH), Some(0));
// Before the epoch is negative, not a wrap and not a rejection.
assert_eq!(
wall_ns_of(UNIX_EPOCH - Duration::from_nanos(1_500)),
Some(-1_500)
);
// Beyond the field, in either direction, is `None` rather than a
// clamped value that would then read as an ordinary instant.
let beyond = Duration::from_nanos(u64::MAX) + Duration::from_secs(1);
assert_eq!(wall_ns_of(UNIX_EPOCH + beyond), None);
assert_eq!(wall_ns_of(UNIX_EPOCH - beyond), None);
}
}
+5 -17
View File
@@ -11,7 +11,7 @@ use irtt_proto::{
};
use crate::{
clock::{saturating_ns, wall_ns_of, ClockSample, ClockSource, SystemClock},
clock::{saturating_ns, wall_ns_of, ClockSample, SystemClock},
config::ServerConfig,
error::ServerError,
fill::{echo_payload_len, negotiate_server_fill},
@@ -27,8 +27,7 @@ use crate::{
/// 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.
/// reply's timestamps come from — the clock is sampled directly and tokens have a private fault-injection seam.
///
/// The Tokio runtime wraps it directly:
///
@@ -88,7 +87,7 @@ pub struct ServerCore {
config: ServerConfig,
sessions: HashMap<u64, Session>,
tokens: Box<dyn TokenSource>,
clock: Box<dyn ClockSource>,
clock: SystemClock,
}
impl ServerCore {
@@ -99,24 +98,13 @@ impl ServerCore {
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.
// Token fault injection covers random-source failures and allocation bounds.
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,
clock: SystemClock::new(),
}
}
+6 -32
View File
@@ -94,7 +94,7 @@ impl FillMode {
// region as unspecified residual buffer content, which returned
// other traffic's bytes, and a compatible server must not.
Self::None => Vec::new(),
Self::Random => random_payload(len, getrandom::fill),
Self::Random => random_payload(len),
Self::Pattern(pattern) => pattern.iter().copied().cycle().take(len).collect(),
}
}
@@ -132,25 +132,17 @@ fn hex_digit(digit: u8) -> Option<u8> {
}
}
/// `len` random bytes drawn with `draw`, or `len` zeroes if the draw failed.
/// `len` random bytes from the operating system, or zeroes if the draw failed.
///
/// `draw` is a parameter rather than a direct call so the failure path is
/// assertable without a production hook: the caller passes the operating
/// system's random source and one unit test passes a scripted one. An empty
/// region asks the source for nothing at all.
///
/// **A failed draw zero-fills and is not an error.** Payload bytes carry no
/// protocol meaning, so a random source having a bad afternoon must not cost a
/// session its reply, let alone take the server down: the reply is still
/// structurally valid and interoperable. The buffer is rewritten rather than
/// trusted, because a failed draw leaves its contents unspecified.
/// Failure is not fatal: payload variation has no protocol meaning, so a
/// random-source failure must not terminate a listener or drop a session.
///
/// The bytes exist to vary the payload, and nothing here is a security claim.
/// `getrandom` is used because the crate already depends on it; session tokens,
/// which *are* security state, keep their own separate source.
fn random_payload<E>(len: usize, draw: impl FnOnce(&mut [u8]) -> Result<(), E>) -> Vec<u8> {
fn random_payload(len: usize) -> Vec<u8> {
let mut payload = vec![0; len];
if len > 0 && draw(&mut payload).is_err() {
if len > 0 && getrandom::fill(&mut payload).is_err() {
payload.fill(0);
}
payload
@@ -223,21 +215,3 @@ pub(crate) fn echo_payload_len(hmac: bool, params: &Params) -> usize {
let header_len = PacketLayout::echo(hmac, params).header_len();
echo_packet_len(hmac, params).map_or(0, |len| len.saturating_sub(header_len))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_failed_random_draw_yields_zeroes() {
// The real source cannot be made to fail without a production hook, and
// the fallback itself is the policy under test: a failed draw must
// produce a full-length payload of zeroes rather than an error or the
// buffer's unspecified contents.
let scribble = |buffer: &mut [u8]| {
buffer.fill(0xab);
Err(())
};
assert_eq!(random_payload(6, scribble), vec![0; 6]);
}
}
+1 -2
View File
@@ -9,8 +9,7 @@
//! admission, authentication policy, open negotiation, the session table, echo
//! 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. Each reply it produces is an [`OutboundDatagram`]:
//! come from is sampled by the core. Each reply 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. [`ServerSet`]
+1 -197
View File
@@ -403,203 +403,7 @@ pub enum ServerRuntimeError {
#[cfg(test)]
mod tests {
use std::{net::Ipv4Addr, time::Duration};
use irtt_proto::{
decode_echo_reply, decode_open_reply, encode_request, Clock, Params, ReceivedStats,
RequestToEncode, StampAt,
};
use tokio::{net::UdpSocket, sync::oneshot, time::timeout};
use super::*;
#[tokio::test(flavor = "current_thread")]
async fn bind_reports_local_address_and_shutdown_stops_run() {
let requested = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
let mut server = Server::bind(requested, ServerConfig::default())
.await
.unwrap();
let bound = server.local_addr().unwrap();
assert_eq!(bound.ip(), requested.ip());
assert_ne!(bound.port(), 0);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
shutdown_tx.send(()).unwrap();
server
.run(async {
let _ = shutdown_rx.await;
})
.await
.unwrap();
}
/// Construction is where a wildcard listener's promise is made or refused.
///
/// Both outcomes are asserted from the same code, because which one a target
/// gets is exactly the thing under test: a target with a source-selection
/// path configures it and serves; one without refuses the bind rather than
/// starting a listener whose replies may come from an address no client
/// contacted. An explicit bind is unaffected either way.
#[tokio::test(flavor = "current_thread")]
async fn from_socket_settles_wildcard_source_selection_before_serving_anything() {
let explicit = UdpSocket::bind("127.0.0.1:0").await.unwrap();
assert!(Server::from_socket(explicit, ServerConfig::default()).is_ok());
let wildcard = UdpSocket::bind("0.0.0.0:0").await.unwrap();
let wildcard = Server::from_socket(wildcard, ServerConfig::default());
if socket_io::SUPPORTED {
let server = wildcard.expect("a supported target configures source selection");
assert!(server.select_reply_source);
} else {
assert!(matches!(
wildcard,
Err(ServerRuntimeError::WildcardSourceSelectionUnsupported { .. })
));
}
}
#[tokio::test(flavor = "current_thread")]
async fn ipv4_open_echo_close_crosses_a_real_udp_socket() {
exercise_udp_path(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).await;
}
#[tokio::test(flavor = "current_thread")]
async fn ipv6_open_echo_close_when_loopback_is_available() {
let addr = "[::1]:0".parse().unwrap();
let server = match Server::bind(addr, unthrottled()).await {
Ok(server) => server,
Err(ServerRuntimeError::Bind { source, .. })
if matches!(
source.kind(),
io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
) =>
{
return;
}
Err(error) => panic!("unexpected IPv6 loopback bind failure: {error}"),
};
exercise_bound_server(server).await;
}
async fn exercise_udp_path(addr: SocketAddr) {
let server = Server::bind(addr, unthrottled()).await.unwrap();
exercise_bound_server(server).await;
}
async fn exercise_bound_server(mut server: Server) {
let server_addr = server.local_addr().unwrap();
let client_bind = match server_addr {
SocketAddr::V4(_) => "127.0.0.1:0",
SocketAddr::V6(_) => "[::1]:0",
};
let client = UdpSocket::bind(client_bind).await.unwrap();
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let server_task = tokio::spawn(async move {
server
.run(async {
let _ = shutdown_rx.await;
})
.await
});
let requested = Params {
protocol_version: 1,
duration_ns: 1_000_000_000,
interval_ns: 100_000_000,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::default()
};
let open = encode_request(
RequestToEncode::Open {
params: &requested,
no_test: false,
},
None,
)
.unwrap();
client.send_to(&open, server_addr).await.unwrap();
let mut buffer = vec![0; RECEIVE_BUFFER_LEN];
let (len, reply_source) = timeout(Duration::from_secs(1), client.recv_from(&mut buffer))
.await
.expect("OPEN reply timeout")
.unwrap();
assert_eq!(reply_source, server_addr);
let open_reply = decode_open_reply(&buffer[..len], None).unwrap();
assert_ne!(open_reply.token, 0);
let echo = encode_request(
RequestToEncode::Echo {
token: open_reply.token,
sequence: 0,
params: &open_reply.params,
payload: &[],
},
None,
)
.unwrap();
client.send_to(&echo, server_addr).await.unwrap();
let (len, reply_source) = timeout(Duration::from_secs(1), client.recv_from(&mut buffer))
.await
.expect("ECHO reply timeout")
.unwrap();
assert_eq!(reply_source, server_addr);
let echo_reply = decode_echo_reply(&buffer[..len], &open_reply.params, None).unwrap();
assert_eq!(echo_reply.token, open_reply.token);
assert_eq!(echo_reply.sequence, 0);
assert_eq!(echo_reply.recv_count, Some(1));
assert_eq!(echo_reply.recv_window, Some(1));
let close = encode_request(
RequestToEncode::Close {
token: open_reply.token,
},
None,
)
.unwrap();
client.send_to(&close, server_addr).await.unwrap();
client.send_to(&echo, server_addr).await.unwrap();
assert!(
timeout(Duration::from_millis(100), client.recv_from(&mut buffer))
.await
.is_err()
);
shutdown_tx.send(()).unwrap();
server_task.await.unwrap().unwrap();
}
fn unthrottled() -> ServerConfig {
ServerConfig::default().with_min_send_interval(Duration::ZERO)
}
/// Which bound addresses owe their replies a selected source.
///
/// The IPv4-mapped unspecified address is the one that is not obvious, and
/// it is not hypothetical: Linux accepts `[::ffff:0.0.0.0]` as an IPv4
/// wildcard bind and reports it back unchanged, so missing it would leave a
/// working wildcard listener on the plain send path. A mapped address
/// naming a real host address is explicit and must stay there.
#[test]
fn a_bound_address_naming_no_particular_local_address_is_a_wildcard() {
for (addr, wildcard) in [
("0.0.0.0:2112", true),
("[::]:2112", true),
("[::ffff:0.0.0.0]:2112", true),
("127.0.0.1:2112", false),
("192.0.2.10:2112", false),
("[::1]:2112", false),
("[::ffff:127.0.0.1]:2112", false),
] {
assert_eq!(
is_wildcard(addr.parse().unwrap()),
wildcard,
"{addr} classified wrongly"
);
}
}
use super::may_send_unappliable;
/// The rule that decides what an unappliable traffic class means, which no
/// normal interface can reach: it needs a host that refuses the socket
-8
View File
@@ -393,14 +393,6 @@ mod tests {
use super::*;
use crate::{token::TokenSource, ServerCore, ServerError};
#[tokio::test(flavor = "current_thread")]
async fn a_set_serving_nothing_is_refused() {
assert!(matches!(
ServerSet::bind([], ServerConfig::default()).await,
Err(ServerSetError::NoListeners)
));
}
/// A token source that draws one value successfully and then fails every
/// later draw.
///
-186
View File
@@ -256,189 +256,3 @@ fn unsupported() -> io::Error {
"reply source-address selection is unsupported on this target",
)
}
#[cfg(test)]
mod tests {
use std::net::Ipv4Addr;
use super::*;
/// Sends `payload` to a fresh explicit-address listener and returns what the
/// transport layer decided about it, received into a `capacity`-byte buffer.
///
/// The buffer is deliberately tiny: the full-buffer rule is written against
/// the caller's capacity, so it is provable without a 65,536-byte datagram.
async fn receive_one(
capacity: usize,
payload: &[u8],
) -> (io::Result<Option<ReceivedDatagram>>, Vec<u8>) {
let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
let sender = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
sender
.send_to(payload, listener.local_addr().unwrap())
.await
.unwrap();
let mut buffer = vec![0; capacity];
let received = receive(&listener, &mut buffer, false).await;
(received, buffer)
}
#[tokio::test(flavor = "current_thread")]
async fn a_datagram_shorter_than_the_buffer_reaches_the_core_intact() {
let payload = b"irtt";
let (received, buffer) = receive_one(16, payload).await;
let received = received
.unwrap()
.expect("a datagram that cannot have been truncated is accepted");
assert_eq!(received.len, payload.len());
assert_eq!(received.peer.ip(), Ipv4Addr::LOCALHOST);
assert_eq!(received.reply_source, ReplySource::Bound);
assert_eq!(&buffer[..received.len], payload);
}
/// An exactly-full receive is ambiguous, not obviously fine: `recv_from`
/// reports no truncation flag, so this datagram is indistinguishable from a
/// larger one cut down to the same capacity. Both are dropped.
#[tokio::test(flavor = "current_thread")]
async fn a_datagram_filling_the_buffer_is_dropped_as_possibly_truncated() {
let (received, _) = receive_one(4, b"irtt").await;
assert!(received.unwrap().is_none());
}
/// What matters is that an oversized datagram never reaches the core, not
/// how the platform says so: Unix truncates it to the buffer, which the
/// full-buffer rule then drops, while Windows fails the receive outright
/// with `WSAEMSGSIZE`. Asserting the shared guarantee keeps this test true
/// on both.
#[tokio::test(flavor = "current_thread")]
async fn a_datagram_larger_than_the_buffer_never_reaches_the_core() {
let (received, _) = receive_one(4, b"irtt-rs").await;
assert!(!matches!(received, Ok(Some(_))));
}
/// A listener that never asked for kernel timestamps receives normally and
/// reports none — the field is optional in the plainest sense, and its
/// absence is not a defect to be worked around later.
#[tokio::test(flavor = "current_thread")]
async fn a_datagram_with_no_kernel_timestamp_is_served_all_the_same() {
let (received, _) = receive_one(16, b"irtt").await;
let received = received.unwrap().expect("an untimestamped datagram serves");
assert_eq!(received.reply_source, ReplySource::Bound);
assert_eq!(received.kernel_rx_timestamp, None);
}
/// Linux-only coverage of the two receive paths, which is where the
/// timestamp actually comes from.
#[cfg(target_os = "linux")]
mod linux {
use std::net::SocketAddr;
use super::*;
/// Sends `payload` from loopback to `listener` and receives it once.
async fn exchange(
listener: &UdpSocket,
select_reply_source: bool,
) -> (Option<ReceivedDatagram>, Vec<u8>) {
let payload = b"irtt";
let destination =
SocketAddr::from((Ipv4Addr::LOCALHOST, listener.local_addr().unwrap().port()));
let sender = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
sender.send_to(payload, destination).await.unwrap();
let mut buffer = vec![0; 64];
let received = receive(listener, &mut buffer, select_reply_source)
.await
.unwrap();
(received, buffer)
}
/// Reports the timestamp, or says why it is skipping the assertion
/// rather than inventing one. Loopback under a normal kernel supplies
/// it; a container or emulator that does not must not fail the run.
fn timestamp_or_skip(received: &ReceivedDatagram, what: &str) {
match received.kernel_rx_timestamp {
Some(timestamp) => assert!(
timestamp.duration_since(std::time::UNIX_EPOCH).is_ok(),
"a kernel receive timestamp is a wall-clock instant"
),
None => eprintln!(
"skipping {what} kernel timestamp assertion: \
kernel did not provide SCM_TIMESTAMPNS"
),
}
}
/// The explicit-address listener's new `recvmsg` path: it must keep
/// reporting exactly what `recv_from` did, and additionally see the
/// timestamp `recv_from` had no way to retrieve.
#[tokio::test(flavor = "current_thread")]
async fn an_explicit_listener_receives_a_datagram_and_its_kernel_timestamp() {
let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
assert!(
try_configure_kernel_rx_timestamp(&listener),
"Linux loopback accepts SO_TIMESTAMPNS"
);
let (received, buffer) = exchange(&listener, false).await;
let received = received.expect("an explicit listener serves a short datagram");
assert_eq!(received.len, 4);
assert_eq!(&buffer[..received.len], b"irtt");
assert_eq!(received.peer.ip(), Ipv4Addr::LOCALHOST);
assert_eq!(received.reply_source, ReplySource::Bound);
timestamp_or_skip(&received, "explicit");
}
/// The regression this whole change risks: asking for a second control
/// message must not crowd out the first. A wildcard listener with both
/// enabled has to recover its destination *and* its timestamp from one
/// datagram, with no `MSG_CTRUNC` drop and no dependence on the order
/// the kernel returns them in.
#[tokio::test(flavor = "current_thread")]
async fn a_wildcard_listener_recovers_its_destination_and_timestamp_together() {
let listener = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await.unwrap();
configure_destination_metadata(&listener, true).unwrap();
assert!(try_configure_kernel_rx_timestamp(&listener));
let (received, buffer) = exchange(&listener, true).await;
let received = received.expect(
"a wildcard listener asking for two control messages still serves the datagram",
);
assert_eq!(received.len, 4);
assert_eq!(&buffer[..received.len], b"irtt");
assert_eq!(received.peer.ip(), Ipv4Addr::LOCALHOST);
assert_eq!(
received.reply_source,
ReplySource::V4 {
address: Ipv4Addr::LOCALHOST
},
"the required destination survives the added timestamp request"
);
timestamp_or_skip(&received, "wildcard");
}
/// The timestamp is not a precondition for either listener: a wildcard
/// one that never enabled it still recovers the destination its reply
/// depends on and serves the request.
#[tokio::test(flavor = "current_thread")]
async fn a_wildcard_listener_without_timestamps_still_recovers_its_destination() {
let listener = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await.unwrap();
configure_destination_metadata(&listener, true).unwrap();
let (received, _) = exchange(&listener, true).await;
let received = received.expect("no timestamp requested, and none needed");
assert_eq!(
received.reply_source,
ReplySource::V4 {
address: Ipv4Addr::LOCALHOST
}
);
assert_eq!(received.kernel_rx_timestamp, None);
}
}
}
@@ -545,150 +545,3 @@ impl ControlBuffer {
&mut self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Which reply sources need their interface named, which no normal
/// interface can show: it takes a host whose route back to a peer leaves by
/// a different interface than the request arrived on, and a test host has
/// one loopback.
#[test]
fn only_a_link_local_source_pins_the_egress_interface() {
const ARRIVED_ON: u32 = 7;
for (address, expected, why) in [
(
"fe80::1",
ARRIVED_ON,
"a link-local source needs its link named",
),
(
"fe80::7858:baff:fe91:183d",
ARRIVED_ON,
"and so does any other one",
),
("2001:db8::1", 0, "a global source identifies itself"),
("fd00::1", 0, "so does a unique-local one"),
("::1", 0, "and loopback"),
("::ffff:127.0.0.2", 0, "and an IPv4-mapped destination"),
] {
assert_eq!(
egress_interface_index(address.parse().unwrap(), ARRIVED_ON),
expected,
"{address}: {why}"
);
}
}
/// What the two listener kinds do with the metadata they did or did not
/// get, which is the whole distinction this module rests on: a destination
/// is correctness-critical for one of them and irrelevant to the other.
#[test]
fn only_a_listener_that_needs_a_destination_is_stopped_by_a_missing_one() {
let recovered = ReplySource::V4 {
address: Ipv4Addr::LOCALHOST,
};
assert_eq!(
resolved_reply_source(Some(recovered), true),
Some(recovered),
"a wildcard listener replies from the destination it recovered"
);
assert_eq!(
resolved_reply_source(None, true),
None,
"and cannot answer a request whose destination did not arrive"
);
assert_eq!(
resolved_reply_source(None, false),
Some(ReplySource::Bound),
"an explicit listener never needed one"
);
assert_eq!(
resolved_reply_source(Some(recovered), false),
Some(ReplySource::Bound),
"and sends from its bind even if one turned up"
);
}
/// Which truncation is fatal to whom. A cut payload always is; cut control
/// data is fatal only where it may have taken the destination with it.
/// Turning a lost *timestamp* into a lost packet would trade a real client
/// request for optional precision.
#[test]
fn control_truncation_only_condemns_a_datagram_whose_destination_it_may_have_taken() {
for (flags, require_destination, unusable, why) in [
(MsgFlags::empty(), true, false, "a clean wildcard receive"),
(MsgFlags::empty(), false, false, "a clean explicit receive"),
(
MsgFlags::MSG_TRUNC,
false,
true,
"a cut payload is not what the client sent",
),
(
MsgFlags::MSG_TRUNC,
true,
true,
"and no less so for a wildcard listener",
),
(
MsgFlags::MSG_CTRUNC,
true,
true,
"cut control data may have taken the destination",
),
(
MsgFlags::MSG_CTRUNC,
false,
false,
"but for an explicit listener it can only have cost a timestamp",
),
(
MsgFlags::MSG_TRUNC | MsgFlags::MSG_CTRUNC,
false,
true,
"the payload rule still applies",
),
] {
assert_eq!(is_unusable(flags, require_destination), unusable, "{why}");
}
}
/// A representable kernel reading becomes an instant; anything else becomes
/// absent metadata. Note what is *not* rejected here: a reading far from
/// now is structurally fine, and judging it belongs to whoever measures
/// with it.
#[cfg(target_os = "linux")]
#[cfg_attr(target_env = "musl", allow(deprecated))]
#[test]
fn a_kernel_timestamp_converts_only_where_it_describes_an_instant() {
assert_eq!(
system_time_from_timespec(TimeSpec::new(1, 2))
.unwrap()
.duration_since(UNIX_EPOCH)
.unwrap(),
Duration::new(1, 2)
);
assert_eq!(
system_time_from_timespec(TimeSpec::new(0, 0)),
Some(UNIX_EPOCH),
"the epoch itself is representable"
);
assert_eq!(system_time_from_timespec(TimeSpec::new(-1, 0)), None);
assert_eq!(system_time_from_timespec(TimeSpec::new(0, -1)), None);
assert_eq!(
system_time_from_timespec(TimeSpec::new(0, 1_000_000_000)),
None
);
assert_eq!(
system_time_from_timespec(TimeSpec::new(libc::time_t::MIN, libc::c_long::MIN)),
None,
"and the extremes convert rather than panic"
);
let _ = system_time_from_timespec(TimeSpec::new(libc::time_t::MAX, 999_999_999));
}
}
-33
View File
@@ -149,36 +149,3 @@ fn unsupported_traffic_class(_traffic_class: u32, option: &'static str) -> io::R
format!("{option} are unsupported on this target"),
))
}
#[cfg(test)]
mod tests {
use super::*;
/// Which listeners mark with the IPv4 option.
///
/// The mapped forms are the point: such a socket is `AF_INET6` but sends
/// IPv4, and `IPV6_TCLASS` does not reach those packets. Linux marks them
/// through `IP_TOS`; the platforms that would reject that keep the IPv6
/// option, because an unappliable marking stops the send and a dropped
/// reply is worse than an unmarked one.
#[test]
fn a_mapped_listener_marks_with_the_ipv4_option_where_that_option_works() {
let mapped = cfg!(target_os = "linux");
for (addr, is_ipv4) in [
("0.0.0.0:2112", true),
("127.0.0.1:2112", true),
("[::ffff:0.0.0.0]:2112", mapped),
("[::ffff:127.0.0.1]:2112", mapped),
("[::]:2112", false),
("[::1]:2112", false),
("[2001:db8::1]:2112", false),
] {
assert_eq!(
marks_with_ipv4_option(addr.parse().unwrap()),
is_ipv4,
"{addr} would mark with the wrong option"
);
}
}
}
-42
View File
@@ -1,42 +0,0 @@
//! 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));
}
-309
View File
@@ -1,309 +0,0 @@
//! Client-initiated close.
//!
//! A close request that matches a live token *and* arrived from that session's
//! exact endpoint releases the session and is answered with nothing. Every
//! other close — unknown, stale or zero token, foreign endpoint, failed
//! authentication — is the same silence with the session left live, so these
//! tests assert the table as well as the reply.
use std::net::{SocketAddr, SocketAddrV6};
use irtt_proto::{encode_request, RequestToEncode};
use super::support::{
client_params, close_request, close_request_with_trailing, core_for, core_with_tokens,
open_request, open_session, other_peer, peer, ScriptedTokens, KEY, OTHER_KEY,
};
use crate::ServerConfig;
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
const UNKNOWN_TOKEN: u64 = 0x2122_2324_2526_2728;
#[test]
fn a_valid_close_removes_the_session_and_answers_nothing() {
for hmac_key in [None, Some(KEY)] {
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_for(hmac_key, tokens.clone());
let token = open_session(&mut core, peer(), hmac_key);
assert_eq!(core.session_count(), 1);
// Exactly `None`, not an empty or acknowledging packet: version 1 has
// no close reply, so the client learns nothing about the outcome.
assert_eq!(
core.handle_datagram(peer(), &close_request(token, hmac_key))
.unwrap(),
None
);
assert_eq!(core.session_count(), 0);
assert_eq!(core.session(token), None);
assert_eq!(
tokens.remaining(),
0,
"a close must not draw from the token source"
);
}
}
#[test]
fn a_repeated_close_is_a_stale_token_and_changes_nothing() {
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);
let close = close_request(token, hmac_key);
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
assert_eq!(core.session_count(), 0, "nothing may be resurrected");
assert_eq!(core.session(token), None);
}
}
#[test]
fn an_echo_after_a_close_is_a_stale_token() {
// 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);
let params = core.session(token).expect("session must exist").params();
let echo = encode_request(
RequestToEncode::Echo {
token,
sequence: 0,
params,
payload: &[],
},
hmac_key,
)
.unwrap();
core.handle_datagram(peer(), &close_request(token, hmac_key))
.unwrap();
assert_eq!(core.handle_datagram(peer(), &echo).unwrap(), None);
assert_eq!(core.session_count(), 0);
assert_eq!(core.session(token), None);
}
}
#[test]
fn a_close_from_any_other_endpoint_leaves_the_session_live() {
// Possessing the token is not authority to close. The first four pairs
// differ in an established identity component; the last differs only in
// IPv6 zone, which is `irtt-rs` policy rather than observed behavior — see
// `same_endpoint`. It is pinned so the choice cannot drift silently, not
// because evidence settled it.
for (bound, foreign) in [
("198.51.100.7:41234", "198.51.100.7:41235"),
("198.51.100.7:41234", "198.51.100.9:41234"),
("198.51.100.7:41234", "[::ffff:198.51.100.7]:41234"),
("[2001:db8::1]:2112", "[2001:db8::2]:2112"),
("[fe80::1%3]:2112", "[fe80::1%4]:2112"),
] {
let bound = bound.parse().unwrap();
let foreign = foreign.parse().unwrap();
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let token = open_session(&mut core, bound, None);
let session = core.session(token).cloned().expect("session must exist");
let close = close_request(token, None);
assert_eq!(
core.handle_datagram(foreign, &close).unwrap(),
None,
"a close from {foreign} must be silent"
);
assert_eq!(core.session_count(), 1);
assert_eq!(
core.session(token),
Some(&session),
"a close from {foreign} must not remove the session bound to {bound}"
);
// The same request from the session's own endpoint does close it, so
// the request itself was never the reason for the silence.
assert_eq!(core.handle_datagram(bound, &close).unwrap(), None);
assert_eq!(core.session_count(), 0);
}
}
#[test]
fn ipv6_flow_information_is_not_part_of_endpoint_identity() {
// Identity is family, address, port and scope. The flow label is routing
// metadata that may vary within one flow, so letting `SocketAddrV6`
// equality decide would strand a session its own client could never close.
let ip = "2001:db8::1".parse().unwrap();
let unlabeled = SocketAddr::V6(SocketAddrV6::new(ip, 2112, 0, 0));
let labeled = SocketAddr::V6(SocketAddrV6::new(ip, 2112, 0x000f_1234, 0));
assert_ne!(
unlabeled, labeled,
"the two must differ only in flow information"
);
for (bound, closing) in [(unlabeled, labeled), (labeled, unlabeled)] {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let token = open_session(&mut core, bound, None);
assert_eq!(
core.handle_datagram(closing, &close_request(token, None))
.unwrap(),
None
);
assert_eq!(
core.session_count(),
0,
"a close from {closing} must release the session opened from {bound}"
);
}
}
#[test]
fn an_unknown_or_zero_token_closes_nothing() {
// Zero is reserved for no-test replies and is never issued to a session, so
// it can only ever miss the table.
for (name, token) in [("an unknown token", UNKNOWN_TOKEN), ("a zero token", 0)] {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let live = open_session(&mut core, peer(), None);
let session = core.session(live).cloned().expect("session must exist");
assert_eq!(
core.handle_datagram(peer(), &close_request(token, None))
.unwrap(),
None,
"{name} must be answered with silence"
);
assert_eq!(core.session_count(), 1, "{name} must remove nothing");
assert_eq!(core.session(live), Some(&session));
}
}
#[test]
fn a_close_that_fails_authentication_leaves_the_session_live() {
// Authentication runs before the session is looked up, so a close that
// fails it is indistinguishable from one bearing an unknown token — and it
// must not be able to tear down an authenticated session.
let mut core = core_for(Some(KEY), ScriptedTokens::new([TOKEN_A]));
let token = open_session(&mut core, peer(), Some(KEY));
let session = core.session(token).cloned().expect("session must exist");
let mut corrupted = close_request(token, Some(KEY));
corrupted[5] ^= 0x80;
for (name, packet) in [
(
"a close with no MAC field at all",
close_request(token, None),
),
(
"a close signed with a different key",
close_request(token, Some(OTHER_KEY)),
),
("a close with one bit flipped in the MAC", corrupted),
] {
assert_eq!(
core.handle_datagram(peer(), &packet).unwrap(),
None,
"{name} must be answered with silence"
);
assert_eq!(core.session_count(), 1, "{name} must not remove a session");
assert_eq!(core.session(token), Some(&session));
}
}
#[test]
fn a_non_authenticating_server_ignores_an_authenticated_close() {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let token = open_session(&mut core, peer(), None);
let session = core.session(token).cloned().expect("session must exist");
assert_eq!(
core.handle_datagram(peer(), &close_request(token, Some(KEY)))
.unwrap(),
None
);
assert_eq!(core.session_count(), 1);
assert_eq!(core.session(token), Some(&session));
}
#[test]
fn trailing_bytes_after_the_token_do_not_prevent_the_close() {
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);
let close = close_request_with_trailing(token, &[0xde, 0xad, 0xbe, 0xef], hmac_key);
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
assert_eq!(core.session_count(), 0);
}
}
#[test]
fn a_close_removes_only_the_session_it_names() {
// Repeated opens from one endpoint are independent sessions, so a close
// must be resolved by token, never by endpoint.
let mut core = core_with_tokens(
ServerConfig::default(),
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
);
let first = open_session(&mut core, peer(), None);
let second = open_session(&mut core, peer(), None);
let survivor = core.session(second).cloned().expect("session must exist");
assert_eq!(core.session_count(), 2);
assert_eq!(
core.handle_datagram(peer(), &close_request(first, None))
.unwrap(),
None
);
assert_eq!(core.session_count(), 1, "only one session may be released");
assert_eq!(core.session(first), None);
assert_eq!(core.session(second), Some(&survivor));
assert_eq!(
core.handle_datagram(peer(), &close_request(second, None))
.unwrap(),
None
);
assert_eq!(core.session_count(), 0);
}
#[test]
fn a_close_frees_session_capacity_immediately() {
// The session table is the only record of how full the server is, so a
// removal is reclaimed at once with no separate counter to drift.
let tokens = ScriptedTokens::new([TOKEN_A, TOKEN_B]);
let mut core = core_with_tokens(ServerConfig::default().with_max_sessions(1), tokens.clone());
let request = open_request(&client_params(), None);
let first = open_session(&mut core, peer(), None);
assert_eq!(
core.handle_datagram(other_peer(), &request).unwrap(),
None,
"the bound is reached, so the second open is refused"
);
assert_eq!(core.session_count(), 1);
assert_eq!(
tokens.remaining(),
1,
"a refused open must not commit a token"
);
core.handle_datagram(peer(), &close_request(first, None))
.unwrap();
assert_eq!(core.session_count(), 0);
assert_eq!(
tokens.remaining(),
1,
"a close must not draw from the token source"
);
let second = open_session(&mut core, other_peer(), None);
assert_eq!(second, TOKEN_B);
assert_ne!(second, 0);
assert_eq!(core.session_count(), 1);
assert_eq!(core.session(second).unwrap().peer(), other_peer());
}
-818
View File
@@ -1,818 +0,0 @@
//! 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, unthrottled, ScriptedClock, ScriptedTokens, KEY,
OTHER_KEY,
};
use crate::{clock::ClockSample, ServerConfig, TimestampAllowance};
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
const UNKNOWN_TOKEN: u64 = 0x2122_2324_2526_2728;
/// The lifecycle instant an open consumes before any echo is sent.
///
/// The core reads the clock once per authenticated request, to judge idle expiry
/// and to start the new session's deadlines. It is earlier than every echo
/// sample below, as a real monotonic reading would be, and no reply ever carries
/// it: an open reply has no timestamp fields.
fn open_sample() -> ClockSample {
sample(900, 9_000)
}
/// 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 and returns the core, the token and the negotiated params.
///
/// Rate limiting is disabled: these tests drive a whole vector of echoes at once
/// against the production clock, and what they are about is the reply, not the
/// allowance. `rate` covers the limiter.
fn session_with(params: &Params) -> (crate::ServerCore, u64, Params) {
let mut core = core_with_tokens(unthrottled(), 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.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(),
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.bytes().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.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
// 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([open_sample(), 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 consumes exactly its own lifecycle 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.bytes().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_backward_wall_step_never_reports_receive_after_send() {
// A reply's receive instant must not be later than its send instant, and
// the wall clock can be stepped backwards between the two readings by NTP,
// a hypervisor or an administrator. The pair settles on the later reading,
// and only the wall domain moves: the monotonic one was already ordered.
let mut core = core_with_sources(
ServerConfig::default(),
ScriptedTokens::new([TOKEN_A]),
ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(990, 10_300)]),
);
let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
let (token, negotiated) = open_negotiated(&mut core, peer(), &params, None);
let packet = core
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
.unwrap()
.expect("a backward wall step must not stop the echo being answered");
let reply = expect_echo_reply(&packet, &negotiated, None);
assert_eq!(
reply.timestamps,
TimestampFields {
recv_wall: Some(990),
recv_mono: Some(10_000),
send_wall: Some(990),
send_mono: Some(10_300),
..TimestampFields::default()
},
"the receive wall value is held back to the send reading, and only it"
);
assert!(reply.timestamps.recv_wall <= reply.timestamps.send_wall);
assert!(reply.timestamps.recv_mono <= reply.timestamps.send_mono);
// A midpoint drawn from the same stepped pair is ordered for the same
// reason: it is the mean of two values that are no longer inverted.
let mut core = core_with_sources(
ServerConfig::default(),
ScriptedTokens::new([TOKEN_A]),
ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(990, 10_300)]),
);
let params = echo_params(ReceivedStats::None, StampAt::Midpoint, Clock::Wall, 0);
let (token, negotiated) = open_negotiated(&mut core, peer(), &params, None);
let packet = core
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
.unwrap()
.expect("a backward wall step must not stop the echo being answered");
let reply = expect_echo_reply(&packet, &negotiated, None);
assert_eq!(
reply.timestamps,
TimestampFields {
midpoint_wall: Some(990),
..TimestampFields::default()
}
);
}
#[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([open_sample(), 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.bytes().len(),
echo_packet_len(false, &negotiated).unwrap(),
"{clock:?}: the negotiated length, not the compatibility one"
);
assert_eq!(
packet.bytes().len(),
24,
"{clock:?}: one 8-byte midpoint field, not the upstream two"
);
assert_eq!(reply.timestamps, expected);
}
}
#[test]
fn a_reply_payload_is_server_generated_and_never_reflects_the_request() {
// The inbound tail is opaque, and ServerFill describes the *server's* bytes:
// a request's payload is neither echoed, mixed in, nor used to seed
// anything. This session expressed no fill preference, so the region
// carries the server's default fill; `fill` covers the modes themselves.
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.bytes().len(), 64);
assert_eq!(reply.payload.len(), 48);
assert!(
!reply.payload.contains(&0xa5),
"no request byte may appear in the reply payload"
);
assert!(
reply
.payload
.chunks(4)
.all(|chunk| chunk == &b"irtt"[..chunk.len()]),
"the region carries the server's default fill"
);
}
#[test]
fn a_restricted_session_emits_the_restricted_timestamps_and_not_the_requested_ones() {
// Timestamp generation follows the session's *negotiated* placement, which
// is the point of restricting it during negotiation: a client asking for
// both instants from both clocks gets one midpoint field per clock under a
// single-timestamp allowance, and nothing at all under none — with the reply
// laid out and sized from the restriction either way, not from the request.
for (allowance, expected_stamp_at, expected, expected_len) in [
(
TimestampAllowance::Single,
StampAt::Midpoint,
TimestampFields {
midpoint_wall: Some(1_100),
midpoint_mono: Some(10_150),
..TimestampFields::default()
},
32,
),
(
TimestampAllowance::None,
StampAt::None,
TimestampFields::default(),
16,
),
] {
let mut core = core_with_sources(
unthrottled().with_timestamp_allowance(allowance),
ScriptedTokens::new([TOKEN_A]),
ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(1_200, 10_300)]),
);
let requested = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
let (token, negotiated) = open_negotiated(&mut core, peer(), &requested, None);
assert_eq!(negotiated.stamp_at, expected_stamp_at, "{allowance:?}");
assert_eq!(negotiated.clock, Clock::Both, "{allowance:?}");
let packet = core
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
.unwrap()
.unwrap_or_else(|| panic!("{allowance:?}: an admissible echo must be answered"));
let reply = expect_echo_reply(&packet, &negotiated, None);
// Equality over the whole struct, so a leftover receive or send field is
// a failure as much as a missing midpoint one.
assert_eq!(reply.timestamps, expected, "{allowance:?}");
assert_eq!(
packet.bytes().len(),
echo_packet_len(false, &negotiated).unwrap(),
"{allowance:?}: the restricted layout's length"
);
assert_eq!(
packet.bytes().len(),
expected_len,
"{allowance:?}: no empty slot is kept for a restricted field"
);
}
}
-341
View File
@@ -1,341 +0,0 @@
//! Server fill: what an open reply reports, what the session will fill with,
//! and the bytes an echo reply actually carries.
//!
//! A client that expressed no preference keeps its absent or empty descriptor
//! in the negotiated parameters. Payload tests assert the effective fill from
//! reply bytes, without inspecting the session's internal representation.
//!
//! Payload lengths here are derived from `irtt-proto`'s own layout rather than
//! written as offsets, and are deliberately not multiples of the pattern length.
use irtt_proto::{echo_header_len, Clock, Params, ReceivedStats, ServerFill, StampAt};
use super::support::{
client_params, core_for, core_with_tokens, echo_params, echo_request, expect_echo_reply,
expect_no_test_reply, expect_normal_open_reply, no_test_request, open_negotiated, open_request,
other_peer, peer, unthrottled, ScriptedTokens, KEY,
};
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
/// Request payload bytes an echo carries, so a reply reflecting them would be
/// obvious.
const REQUEST_FILLER: u8 = 0xa5;
/// Params requesting `descriptor` as the server fill.
fn requesting(descriptor: Option<&str>) -> Params {
Params {
server_fill: descriptor.map(|value| ServerFill {
value: value.to_owned(),
}),
..client_params()
}
}
/// Opens a session requesting `descriptor` and returns the descriptor the reply
/// carried.
fn negotiated(descriptor: Option<&str>) -> Option<String> {
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(peer(), &open_request(&requesting(descriptor), None))
.unwrap()
.expect("the open must be answered");
let reply = expect_normal_open_reply(&packet, None);
reply.params.server_fill.map(|fill| fill.value)
}
/// The descriptor a no-test open reports for `descriptor`, which creates no
/// session and so retains no fill.
fn no_test_negotiated(descriptor: Option<&str>) -> Option<String> {
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(unthrottled(), tokens.clone());
let packet = core
.handle_datagram(peer(), &no_test_request(&requesting(descriptor), None))
.unwrap()
.expect("the no-test open must be answered");
let reply = expect_no_test_reply(&packet, None);
assert_eq!(core.session_count(), 0, "no-test creates no session");
assert_eq!(tokens.remaining(), 1, "no-test draws no session token");
reply.params.server_fill.map(|fill| fill.value)
}
#[test]
fn an_explicitly_empty_descriptor_is_preserved_and_uses_the_default_fill() {
// A low-level peer can send this even though `ClientConfig` rejects an
// empty fill before it reaches the wire. The clean evidence groups empty
// with absent as "no preference", so it is neither refused nor rewritten:
// the requested wire value comes back exactly, and the server picks its own
// behavior behind it.
let returned = negotiated(Some(""));
assert_eq!(
returned.as_deref(),
Some(""),
"an empty request stays empty"
);
}
#[test]
fn an_unknown_or_malformed_descriptor_is_replaced_by_the_default() {
// Here the server really did change what was asked for, so the reply says
// so and a strict client may refuse the session. The requested string is
// not retained anywhere.
for descriptor in [
"bogus",
"RAND",
"None",
"Pattern:aabb",
"pattern:",
"pattern:f",
"pattern:abc",
"pattern:zz",
"pattern:0g",
"0123456789abcdef0123456789abcdef",
] {
let returned = negotiated(Some(descriptor));
assert_eq!(
returned.as_deref(),
Some(crate::fill::DEFAULT_FILL_DESCRIPTOR),
"{descriptor}"
);
}
}
#[test]
fn a_no_test_open_negotiates_fill_exactly_as_a_normal_open_does() {
// Same negotiation, no session, and therefore no effective mode to retain.
assert_eq!(no_test_negotiated(None), None);
assert_eq!(no_test_negotiated(Some("")).as_deref(), Some(""));
assert_eq!(
no_test_negotiated(Some("pattern:AaBb")).as_deref(),
Some("pattern:AaBb")
);
assert_eq!(no_test_negotiated(Some("none")).as_deref(), Some("none"));
assert_eq!(
no_test_negotiated(Some("pattern:zz")).as_deref(),
Some(crate::fill::DEFAULT_FILL_DESCRIPTOR)
);
}
/// Params for a session requesting `descriptor` whose echo replies have exactly
/// `payload_len` payload bytes.
///
/// The negotiated length is the mandatory field block plus the region wanted,
/// asked of `irtt-proto` rather than written as a constant: the block grows with
/// the negotiated statistics, the timestamps and authentication's 16 bytes, and
/// a test that hard-coded an offset would silently drift from the encoder.
fn payload_params(descriptor: Option<&str>, payload_len: usize, hmac: bool) -> Params {
let mut params = Params {
server_fill: requesting(descriptor).server_fill,
..echo_params(ReceivedStats::None, StampAt::None, Clock::Unspecified, 0)
};
params.length = (echo_header_len(hmac, &params) + payload_len) as i64;
params
}
/// The payload regions of `count` successive echo replies on one session
/// requesting `descriptor`.
///
/// Every request carries [`REQUEST_FILLER`] payload bytes, so a reply that
/// reflected its request would be visible in any of these tests rather than only
/// in the one about it.
fn echo_payloads(
descriptor: Option<&str>,
payload_len: usize,
hmac_key: Option<&[u8]>,
count: u32,
) -> Vec<Vec<u8>> {
let requested = payload_params(descriptor, payload_len, hmac_key.is_some());
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
let (token, negotiated) = open_negotiated(&mut core, peer(), &requested, hmac_key);
// The request fills its own payload region, which is the same size as the
// reply's: the strongest shape for "a reply never reflects its request".
let request_payload = vec![REQUEST_FILLER; payload_len];
(0..count)
.map(|sequence| {
let request = echo_request(token, sequence, &negotiated, &request_payload, hmac_key);
let packet = core
.handle_datagram(peer(), &request)
.unwrap()
.expect("an admissible echo must be answered");
let reply = expect_echo_reply(&packet, &negotiated, hmac_key);
assert_eq!(
packet.bytes().len() as i64,
negotiated.length,
"a reply is exactly the negotiated length"
);
assert_eq!(reply.payload.len(), payload_len, "payload region length");
reply.payload
})
.collect()
}
/// The payload region of one echo reply on a session requesting `descriptor`.
fn echo_payload(descriptor: Option<&str>, payload_len: usize) -> Vec<u8> {
echo_payloads(descriptor, payload_len, None, 1).remove(0)
}
/// `pattern` repeated to `len` bytes, starting at its first byte.
fn repeated(pattern: &[u8], len: usize) -> Vec<u8> {
pattern.iter().copied().cycle().take(len).collect()
}
#[test]
fn no_preference_fills_with_the_default_pattern() {
// An absent descriptor uses default bytes. 7 is not a multiple of 4, so
// the last repeat is partial.
assert_eq!(echo_payload(None, 7), b"irttirt".to_vec());
}
#[test]
fn an_empty_preference_fills_with_the_default_pattern() {
assert_eq!(echo_payload(Some(""), 7), b"irttirt".to_vec());
}
#[test]
fn an_unknown_or_malformed_descriptor_fills_with_the_default_pattern() {
for descriptor in ["bogus", "RAND", "pattern:", "pattern:abc", "pattern:zz"] {
assert_eq!(
echo_payload(Some(descriptor), 7),
b"irttirt".to_vec(),
"{descriptor}"
);
}
}
#[test]
fn no_fill_produces_a_zero_payload_and_never_the_request() {
// `irtt-rs` policy, and a deliberate divergence: the clean evidence records
// the reference server leaving this region as unspecified residual buffer
// content — returning the requester's own payload, or fragments of earlier
// traffic from another client. Zeroes disclose nothing.
let payload = echo_payload(Some("none"), 7);
assert_eq!(payload, vec![0; 7]);
assert!(!payload.contains(&REQUEST_FILLER));
}
#[test]
fn patterns_repeat_from_their_first_byte() {
for (descriptor, expected) in [
("pattern:00", &[0x00][..]),
("pattern:ff00", &[0xff, 0x00][..]),
// The reply follows the bytes the descriptor decodes to, whatever case
// it was written in.
("pattern:AaBb", &[0xaa, 0xbb][..]),
("pattern:69727474", b"irtt"),
] {
assert_eq!(
echo_payload(Some(descriptor), 7),
repeated(expected, 7),
"{descriptor}"
);
}
}
#[test]
fn a_pattern_restarts_for_every_reply() {
// Deliberate `irtt-rs` policy. The clean evidence records the reference
// implementation advancing one phase continuously across replies, sessions
// and listeners, and states that payload phase carries no protocol meaning
// and that a conforming client must not depend on it — so this server keeps
// no fill state at all and every reply starts at byte zero.
let payloads = echo_payloads(Some("pattern:010203"), 5, None, 2);
assert_eq!(payloads[0], vec![0x01, 0x02, 0x03, 0x01, 0x02]);
assert_eq!(
payloads[1], payloads[0],
"the phase resets rather than continuing at 03 01 02 03 01"
);
}
#[test]
fn sessions_fill_independently() {
// Interleaved on one core, so a shared cursor or a global phase would show
// up as one session's bytes drifting into the other's.
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A, TOKEN_B]));
let a_requested = payload_params(Some("pattern:aabb"), 5, false);
let b_requested = payload_params(Some("pattern:112233"), 5, false);
let (a_token, a) = open_negotiated(&mut core, peer(), &a_requested, None);
let (b_token, b) = open_negotiated(&mut core, other_peer(), &b_requested, None);
let mut payload_of = |endpoint, token, sequence, params: &Params| {
let packet = core
.handle_datagram(endpoint, &echo_request(token, sequence, params, &[], None))
.unwrap()
.expect("an admissible echo must be answered");
expect_echo_reply(&packet, params, None).payload
};
assert_eq!(
payload_of(peer(), a_token, 0, &a),
vec![0xaa, 0xbb, 0xaa, 0xbb, 0xaa]
);
assert_eq!(
payload_of(other_peer(), b_token, 0, &b),
vec![0x11, 0x22, 0x33, 0x11, 0x22]
);
assert_eq!(
payload_of(peer(), a_token, 1, &a),
vec![0xaa, 0xbb, 0xaa, 0xbb, 0xaa],
"session A is unaffected by session B's reply"
);
}
#[test]
fn a_random_session_fills_the_whole_region() {
// Deterministic assertions only: the region is the negotiated one and the
// reply decodes and authenticates. What the operating system's random
// source returns is not a property this suite can assert without flipping a
// coin, and `fill`'s unit tests cover propagation and the failure fallback.
for hmac_key in [None, Some(KEY)] {
// `echo_payloads` already asserts the datagram length and the region
// length of each reply, and decoding verifies the MAC. Nothing is
// asserted about the byte values, deliberately: a random payload
// containing any particular byte is chance, not behavior.
assert_eq!(echo_payloads(Some("rand"), 7, hmac_key, 2).len(), 2);
}
}
#[test]
fn authentication_consumes_payload_space_at_the_same_negotiated_length() {
// One negotiated length, two field blocks: authentication's 16 bytes come
// out of the payload region, not out of the datagram. Every remaining
// payload byte is still filled, and the authenticated reply's MAC covers
// those bytes — `expect_echo_reply` verifies it while decoding.
let unauthenticated = payload_params(Some("pattern:aa"), 48, false);
let length = unauthenticated.length;
let authenticated = Params {
length,
..payload_params(Some("pattern:aa"), 0, true)
};
let plain = {
let mut core = core_for(None, ScriptedTokens::new([TOKEN_A]));
let (token, negotiated) = open_negotiated(&mut core, peer(), &unauthenticated, None);
let packet = core
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
.unwrap()
.expect("an admissible echo must be answered");
assert_eq!(packet.bytes().len() as i64, length);
expect_echo_reply(&packet, &negotiated, None).payload
};
let signed = {
let mut core = core_for(Some(KEY), ScriptedTokens::new([TOKEN_A]));
let (token, negotiated) = open_negotiated(&mut core, peer(), &authenticated, Some(KEY));
let packet = core
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], Some(KEY)))
.unwrap()
.expect("an admissible echo must be answered");
assert_eq!(packet.bytes().len() as i64, length);
expect_echo_reply(&packet, &negotiated, Some(KEY)).payload
};
assert_eq!(
signed.len() + irtt_proto::HMAC_SIZE,
plain.len(),
"authentication takes its 16 bytes from the payload region"
);
assert!(plain.iter().chain(&signed).all(|byte| *byte == 0xaa));
}
-160
View File
@@ -1,160 +0,0 @@
use irtt_proto::{verify_packet_hmac, FLAG_HMAC};
use super::support::{
client_params, core_with_tokens, expect_normal_open_reply, open_request,
open_request_with_raw_params, param_int, peer, ScriptedTokens, KEY, OTHER_KEY,
};
use crate::ServerConfig;
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
/// Asserts a datagram is silently discarded, leaving no trace at all: no reply,
/// no session, and no session token drawn.
fn assert_discarded(config: ServerConfig, name: &str, packet: &[u8]) {
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(config, tokens.clone());
let reply = core
.handle_datagram(peer(), packet)
.expect("a rejected datagram is not a server error");
assert!(reply.is_none(), "{name} must be answered with silence");
assert_eq!(core.session_count(), 0, "{name} must create no session");
assert_eq!(
tokens.remaining(),
1,
"{name} must not consume session capacity or a token"
);
}
#[test]
fn an_authenticating_server_discards_everything_that_does_not_authenticate() {
let config = ServerConfig::default().with_hmac_key(KEY);
assert_discarded(
config.clone(),
"open with no MAC field at all",
&open_request(&client_params(), None),
);
assert_discarded(
config.clone(),
"open signed with a different key",
&open_request(&client_params(), Some(OTHER_KEY)),
);
let mut corrupted = open_request(&client_params(), Some(KEY));
corrupted[5] ^= 0x80;
assert_discarded(
config.clone(),
"open with one bit flipped in the MAC",
&corrupted,
);
let mut zeroed = open_request(&client_params(), Some(KEY));
zeroed[4..20].fill(0);
assert_discarded(config.clone(), "open with an all-zero MAC", &zeroed);
let mut truncated = open_request(&client_params(), Some(KEY));
truncated.drain(4..12);
assert_discarded(
config.clone(),
"open with a truncated MAC field",
&truncated,
);
let mut flag_cleared = open_request(&client_params(), Some(KEY));
flag_cleared[3] &= !FLAG_HMAC;
assert_discarded(
config,
"open with a valid MAC but the HMAC flag cleared",
&flag_cleared,
);
}
#[test]
fn a_non_authenticating_server_discards_authenticated_requests() {
assert_discarded(
ServerConfig::default(),
"authenticated open to a server with no key",
&open_request(&client_params(), Some(KEY)),
);
}
#[test]
fn an_authenticating_server_answers_a_valid_open_with_an_authenticated_reply() {
let mut core = core_with_tokens(
ServerConfig::default().with_hmac_key(KEY),
ScriptedTokens::new([TOKEN_A]),
);
let packet = core
.handle_datagram(peer(), &open_request(&client_params(), Some(KEY)))
.unwrap()
.expect("a correctly authenticated open must be answered");
let reply = expect_normal_open_reply(&packet, Some(KEY));
assert_eq!(reply.token, TOKEN_A);
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);
}
#[test]
fn a_non_authenticating_server_answers_an_ordinary_open() {
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 unauthenticated open must be answered");
expect_normal_open_reply(&packet, None);
assert_eq!(packet.bytes()[3] & FLAG_HMAC, 0);
assert_eq!(core.session_count(), 1);
}
#[test]
fn authentication_is_checked_before_parameters_are_decoded() {
// Both of these are dropped, and neither reveals which stage rejected it.
// The pairing is the point: bad parameters under a valid MAC still drop,
// and a bad MAC drops without the parameters ever being parsed.
let config = ServerConfig::default().with_hmac_key(KEY);
let bad_params = param_int(2, 0);
assert_discarded(
config.clone(),
"authenticated open with an explicit zero duration",
&open_request_with_raw_params(&bad_params, Some(KEY)),
);
assert_discarded(
config,
"wrongly keyed open with an explicit zero duration",
&open_request_with_raw_params(&bad_params, Some(OTHER_KEY)),
);
}
#[test]
fn a_failed_authentication_leaves_a_live_session_untouched() {
let mut core = core_with_tokens(
ServerConfig::default().with_hmac_key(KEY),
ScriptedTokens::new([TOKEN_A]),
);
core.handle_datagram(peer(), &open_request(&client_params(), Some(KEY)))
.unwrap()
.expect("the session-creating open must succeed");
let session = core.session(TOKEN_A).cloned().expect("session must exist");
for bad in [
open_request(&client_params(), None),
open_request(&client_params(), Some(OTHER_KEY)),
] {
assert_eq!(core.handle_datagram(peer(), &bad).unwrap(), None);
}
assert_eq!(core.session_count(), 1);
assert_eq!(core.session(TOKEN_A), Some(&session));
}
-668
View File
@@ -1,668 +0,0 @@
//! Consuming a kernel-observed receive timestamp.
//!
//! A Linux listener reports the kernel's arrival time for each datagram, and an
//! echo reply may report it as the wall-clock instant the request was received.
//! These tests pin what that is allowed to change and — mostly — what it is
//! not: only `recv_wall`, only under [`StampAt::Receive`] and [`StampAt::Both`],
//! only on a clock that reports wall time, and never a monotonic value, a
//! midpoint, or any lifecycle, rate or deadline decision.
//!
//! Whether the transport actually captures a timestamp is settled by the socket
//! layer's own tests; nothing here recertifies `SCM_TIMESTAMPNS`. These drive
//! the core with fabricated values, so they run identically on every target.
use std::{
net::SocketAddr,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, TimestampFields};
use super::support::{
close_request, core_with_sources, echo_at, echo_params, echo_request,
expect_closing_echo_reply, expect_echo_reply, expect_normal_open_reply, manual_core,
open_negotiated, open_request, peer, sample, ManualClock, ScriptedClock, ScriptedTokens,
};
use crate::{
clock::ClockSample,
core::{preferred_receive_wall_ns, OutboundDatagram, ServerCore, MAX_KERNEL_RX_LAG},
ServerConfig,
};
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
const MS: i64 = 1_000_000;
const SECOND: i64 = 1_000 * MS;
/// The lifecycle instant the session-creating open consumes.
fn open_sample() -> ClockSample {
sample(900_000, 9_000)
}
/// The core's own receive reading for the echo every matrix test sends.
fn received() -> ClockSample {
sample(1_000_000, 10_000)
}
/// The core's send reading for the same echo.
fn sent() -> ClockSample {
sample(1_200_000, 10_300)
}
/// A kernel arrival reading 100 µs before [`received`], which is the ordinary
/// shape: earlier than the server's own sample and well inside the bound.
fn kernel_rx() -> SystemTime {
at_epoch_ns(900_000)
}
/// The [`SystemTime`] `ns` nanoseconds from the Unix epoch, before it when
/// negative.
fn at_epoch_ns(ns: i64) -> SystemTime {
let magnitude = Duration::from_nanos(ns.unsigned_abs());
if ns.is_negative() {
UNIX_EPOCH - magnitude
} else {
UNIX_EPOCH + magnitude
}
}
/// Sends one echo carrying `kernel_rx_timestamp` through the runtime's entry
/// point and returns the reply's timestamp fields.
fn echo_timestamps(
stamp_at: StampAt,
clock: Clock,
kernel_rx_timestamp: Option<SystemTime>,
) -> TimestampFields {
let mut core = core_with_sources(
ServerConfig::default(),
ScriptedTokens::new([TOKEN_A]),
ScriptedClock::new([open_sample(), received(), sent()]),
);
let params = echo_params(ReceivedStats::None, stamp_at, clock, /* length */ 0);
let (token, negotiated) = open_negotiated(&mut core, peer(), &params, None);
let packet = core
.handle_received_datagram(
peer(),
&echo_request(token, 0, &negotiated, &[], None),
kernel_rx_timestamp,
)
.unwrap()
.unwrap_or_else(|| panic!("{stamp_at:?} on {clock:?} must be answered"));
expect_echo_reply(&packet, &negotiated, None).timestamps
}
#[test]
fn the_preferred_receive_wall_is_the_kernel_reading_only_when_it_is_plausible() {
let userspace = received();
let userspace_wall = userspace.wall_ns;
let bound_ns = i64::try_from(MAX_KERNEL_RX_LAG.as_nanos()).unwrap();
// No observation at all: the server's own reading, which is the whole of
// the pre-existing behavior and what every non-Linux target sees.
assert_eq!(preferred_receive_wall_ns(None, userspace), userspace_wall);
// Earlier, and inside the bound.
assert_eq!(
preferred_receive_wall_ns(Some(kernel_rx()), userspace),
900_000
);
// Equal is accepted: the two clocks may simply have read the same
// nanosecond.
assert_eq!(
preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall)), userspace),
userspace_wall
);
// Later than the sample that observed the same datagram is impossible, so
// it is metadata this server will not measure against. This is the shape a
// backward wall step between the kernel's reading and the core's sample
// produces.
assert_eq!(
preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall + 1)), userspace),
userspace_wall
);
// The lag bound is inclusive.
assert_eq!(
preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall - bound_ns)), userspace),
userspace_wall - bound_ns
);
assert_eq!(
preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall - bound_ns - 1)), userspace),
userspace_wall
);
// A pre-epoch reading is representable as a negative value and is judged
// by exactly the same rule, so against a host clock set anywhere near the
// present it simply lags far too much. Nothing here special-cases the sign.
let present = sample(1_700_000_000 * SECOND, 10_000);
assert_eq!(
preferred_receive_wall_ns(Some(UNIX_EPOCH - Duration::from_nanos(1)), present),
present.wall_ns
);
assert_eq!(
preferred_receive_wall_ns(Some(at_epoch_ns(present.wall_ns - bound_ns)), present),
present.wall_ns - bound_ns,
"and a realistic wall clock accepts a reading at the bound like any other"
);
}
#[test]
fn an_unrepresentable_kernel_reading_falls_back_without_panicking() {
// Far outside the wire's signed-nanosecond field in both directions, and
// judged against a userspace sample at the field's own limits, so nothing
// here can be rescued by a lucky comparison. A conversion that saturated
// instead of reporting failure would turn one of these into an ordinary
// instant.
let beyond = Duration::from_nanos(u64::MAX) + Duration::from_secs(1);
for userspace_wall in [0, i64::MAX, i64::MIN] {
let userspace = sample(userspace_wall, 10_000);
for kernel in [UNIX_EPOCH + beyond, UNIX_EPOCH - beyond] {
assert_eq!(
preferred_receive_wall_ns(Some(kernel), userspace),
userspace_wall,
"an unrepresentable reading is never a measurement endpoint"
);
}
}
}
#[test]
fn only_the_receive_wall_field_can_come_from_the_kernel() {
// The whole StampAt x Clock matrix, each asserted as one struct so that a
// field appearing where it should not is as loud as a wrong instant. Every
// expectation below is what the same session emits with no kernel metadata,
// except `recv_wall` under Receive and Both on a wall-reporting clock.
for (stamp_at, clock, expected) in [
// A: the kernel reading is the reported arrival time.
(
StampAt::Receive,
Clock::Wall,
TimestampFields {
recv_wall: Some(900_000),
..TimestampFields::default()
},
),
// B: and the monotonic half of the same arrival stays the core's own.
// The two fields deliberately name different physical instants.
(
StampAt::Receive,
Clock::Both,
TimestampFields {
recv_wall: Some(900_000),
recv_mono: Some(10_000),
..TimestampFields::default()
},
),
// C: nothing about the departure side moves, in either domain.
(
StampAt::Both,
Clock::Both,
TimestampFields {
recv_wall: Some(900_000),
recv_mono: Some(10_000),
send_wall: Some(1_200_000),
send_mono: Some(10_300),
..TimestampFields::default()
},
),
// D: wall only. The interval between these two now includes the
// kernel-to-userspace part of the server's residency, which is the one
// intended behavior change.
(
StampAt::Both,
Clock::Wall,
TimestampFields {
recv_wall: Some(900_000),
send_wall: Some(1_200_000),
..TimestampFields::default()
},
),
// E: no wall field exists, so there is nothing to improve.
(
StampAt::Both,
Clock::Monotonic,
TimestampFields {
recv_mono: Some(10_000),
send_mono: Some(10_300),
..TimestampFields::default()
},
),
(
StampAt::Receive,
Clock::Monotonic,
TimestampFields {
recv_mono: Some(10_000),
..TimestampFields::default()
},
),
// F: a reply reporting only its departure never learns of an arrival
// observation.
(
StampAt::Send,
Clock::Wall,
TimestampFields {
send_wall: Some(1_200_000),
..TimestampFields::default()
},
),
(
StampAt::Send,
Clock::Both,
TimestampFields {
send_wall: Some(1_200_000),
send_mono: Some(10_300),
..TimestampFields::default()
},
),
// G: no timestamp at all.
(StampAt::None, Clock::Both, TimestampFields::default()),
// H and I: a midpoint is the mean of the core's own paired samples and
// stays exactly that. Substituting only the wall arrival endpoint would
// pull `midpoint_wall` to 1_050_000 — half the kernel-to-userspace
// delay early — improving apparent upstream delay by precisely as much
// as it worsened apparent downstream delay, and leaving the two
// midpoint fields describing different midpoints.
(
StampAt::Midpoint,
Clock::Wall,
TimestampFields {
midpoint_wall: Some(1_100_000),
..TimestampFields::default()
},
),
(
StampAt::Midpoint,
Clock::Both,
TimestampFields {
midpoint_wall: Some(1_100_000),
midpoint_mono: Some(10_150),
..TimestampFields::default()
},
),
(
StampAt::Midpoint,
Clock::Monotonic,
TimestampFields {
midpoint_mono: Some(10_150),
..TimestampFields::default()
},
),
] {
assert_eq!(
echo_timestamps(stamp_at, clock, Some(kernel_rx())),
expected,
"{stamp_at:?} on {clock:?} with a plausible kernel reading"
);
}
}
#[test]
fn a_datagram_with_no_kernel_reading_emits_what_it_always_did() {
// The same matrix without metadata, which is every non-Linux target and
// every Linux listener whose kernel offered nothing. `recv_wall` is the
// core's own 1_000_000 throughout, and the two paths are otherwise the same
// implementation.
for (stamp_at, clock, expected) in [
(
StampAt::Receive,
Clock::Wall,
TimestampFields {
recv_wall: Some(1_000_000),
..TimestampFields::default()
},
),
(
StampAt::Both,
Clock::Both,
TimestampFields {
recv_wall: Some(1_000_000),
recv_mono: Some(10_000),
send_wall: Some(1_200_000),
send_mono: Some(10_300),
..TimestampFields::default()
},
),
(
StampAt::Midpoint,
Clock::Both,
TimestampFields {
midpoint_wall: Some(1_100_000),
midpoint_mono: Some(10_150),
..TimestampFields::default()
},
),
] {
assert_eq!(
echo_timestamps(stamp_at, clock, None),
expected,
"{stamp_at:?} on {clock:?} without kernel metadata"
);
}
}
#[test]
fn an_implausible_kernel_reading_falls_back_to_the_servers_own_sample() {
// Case A of the wall-step analysis: the wall clock stepped *backwards*
// between the kernel's reading and the core's sample, so the kernel value
// is later than a sample that observed the same datagram. Case B: it
// stepped forwards so far that the reading lags beyond the bound. Neither
// is a reason to drop the datagram or to answer differently in any other
// respect.
let userspace_wall = received().wall_ns;
for kernel in [
at_epoch_ns(userspace_wall + 1),
at_epoch_ns(userspace_wall - i64::try_from(MAX_KERNEL_RX_LAG.as_nanos()).unwrap() - 1),
] {
assert_eq!(
echo_timestamps(StampAt::Both, Clock::Both, Some(kernel)),
TimestampFields {
recv_wall: Some(1_000_000),
recv_mono: Some(10_000),
send_wall: Some(1_200_000),
send_mono: Some(10_300),
..TimestampFields::default()
},
);
}
}
#[test]
fn a_backward_wall_step_after_receive_never_reports_receive_after_send() {
// The kernel's reading and the send sample no longer come from one paired
// source, so the ordering rule has to reach the reported arrival value on
// its own: an accepted kernel reading of 900_000, against a send sample the
// wall clock stepped back to 850_000, must still settle on the send
// reading.
let mut core = core_with_sources(
ServerConfig::default(),
ScriptedTokens::new([TOKEN_A]),
ScriptedClock::new([
open_sample(),
sample(1_000_000, 10_000),
sample(850_000, 10_300),
]),
);
let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
let (token, negotiated) = open_negotiated(&mut core, peer(), &params, None);
let packet = core
.handle_received_datagram(
peer(),
&echo_request(token, 0, &negotiated, &[], None),
Some(at_epoch_ns(900_000)),
)
.unwrap()
.expect("a backward wall step must not stop the echo being answered");
let timestamps = expect_echo_reply(&packet, &negotiated, None).timestamps;
assert_eq!(
timestamps,
TimestampFields {
recv_wall: Some(850_000),
recv_mono: Some(10_000),
send_wall: Some(850_000),
send_mono: Some(10_300),
..TimestampFields::default()
},
"the reported arrival wall value is held back to the send reading"
);
assert!(timestamps.recv_wall <= timestamps.send_wall);
// The monotonic pair is ordered by its own source and owes nothing to what
// the wall clock did.
assert_eq!(timestamps.recv_mono, Some(10_000));
assert!(timestamps.recv_mono <= timestamps.send_mono);
}
#[test]
fn the_public_core_path_emits_exactly_the_userspace_receive_wall() {
// A direct `ServerCore` user has no transport metadata and must see the
// behavior it always did, from the same implementation the runtime path
// uses.
let mut core = core_with_sources(
ServerConfig::default(),
ScriptedTokens::new([TOKEN_A]),
ScriptedClock::new([open_sample(), received(), sent()]),
);
let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
let (token, negotiated) = open_negotiated(&mut core, peer(), &params, None);
let packet = core
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
.unwrap()
.expect("the public path must answer an ordinary echo");
assert_eq!(
expect_echo_reply(&packet, &negotiated, None).timestamps,
TimestampFields {
recv_wall: Some(1_000_000),
recv_mono: Some(10_000),
send_wall: Some(1_200_000),
send_mono: Some(10_300),
..TimestampFields::default()
},
);
}
/// One echo's outcome, in the terms a lifecycle test cares about: whether it was
/// answered at all, what it counted, and how much session state the server still
/// holds afterwards.
#[derive(Debug, PartialEq, Eq)]
struct Outcome {
answered: bool,
closing: bool,
recv_count: Option<u32>,
recv_window: Option<u64>,
session_count: usize,
recv_wall: Option<i64>,
}
fn outcome_of(
core: &ServerCore,
reply: Option<OutboundDatagram>,
params: &Params,
closing: bool,
) -> Outcome {
let Some(reply) = reply else {
return Outcome {
answered: false,
closing: false,
recv_count: None,
recv_window: None,
session_count: core.session_count(),
recv_wall: None,
};
};
let decoded = if closing {
expect_closing_echo_reply(&reply, params, None)
} else {
expect_echo_reply(&reply, params, None)
};
Outcome {
answered: true,
closing,
recv_count: decoded.recv_count,
recv_window: decoded.recv_window,
session_count: core.session_count(),
recv_wall: decoded.timestamps.recv_wall,
}
}
/// Sends one echo as it would arrive at `at_ns`, with the kernel having observed
/// it 1 µs earlier when `with_kernel_rx` is set.
fn echo_at_with_kernel_rx(
core: &mut ServerCore,
clock: &ManualClock,
at_ns: i64,
token: u64,
sequence: u32,
params: &Params,
with_kernel_rx: bool,
) -> Option<OutboundDatagram> {
if !with_kernel_rx {
return echo_at(core, clock, at_ns, token, sequence, params, None);
}
clock.set(at_ns);
core.handle_received_datagram(
peer(),
&echo_request(token, sequence, params, &[], None),
Some(at_epoch_ns(at_ns - 1_000)),
)
.expect("a rejected or rate-limited echo is not an internal error")
}
#[test]
fn kernel_metadata_changes_no_lifecycle_rate_or_deadline_decision() {
// Two identical runs over one scripted arrival schedule, one with a
// plausible kernel reading on every echo and one with none. The schedule
// crosses a rate-limit burst, a refill, an idle refresh that only a
// rate-limited request can have produced, and the maximum-duration
// boundary — and the two runs must agree on every one of those outcomes.
//
// The session reports its receive wall, so the one permitted difference is
// visible rather than merely assumed absent.
let params = Params {
interval_ns: 100 * MS,
..echo_params(ReceivedStats::Both, StampAt::Receive, Clock::Wall, 0)
};
let config = ServerConfig::default()
.with_min_send_interval(Duration::from_millis(100))
.with_burst_allowance(2)
.with_idle_timeout(Duration::from_secs(2))
.with_max_test_duration(Duration::from_secs(1));
let run = |with_kernel_rx: bool| {
let (mut core, clock, token, negotiated) = manual_core(
config.clone(),
&params,
ScriptedTokens::new([TOKEN_A]),
None,
);
let mut outcomes = Vec::new();
let mut send = |core: &mut ServerCore, at_ns: i64, sequence: u32, closing: bool| {
let reply = echo_at_with_kernel_rx(
core,
&clock,
at_ns,
token,
sequence,
&negotiated,
with_kernel_rx,
);
outcomes.push(outcome_of(core, reply, &negotiated, closing));
};
// A burst of three against an allowance of two: the third is
// rate-limited, answers nothing and advances no statistic, but does
// refresh the idle deadline.
send(&mut core, 10 * MS, 0, false);
send(&mut core, 10 * MS, 1, false);
send(&mut core, 10 * MS, 2, false);
// Past the refill, and out of order, so the window transition is
// exercised too.
send(&mut core, 200 * MS, 4, false);
send(&mut core, 400 * MS, 3, false);
// The idle timeout is 2 s and the last activity was at 400 ms, so this
// request at 2.3 s finds the session alive only because activity — not
// the open — is what the deadline runs from.
send(&mut core, 2_300 * MS, 5, false);
// The maximum-duration deadline is the first served echo plus one
// second plus the two-second grace: 10 ms + 3 s. This crosses it and
// carries the close, releasing the session.
send(&mut core, 3_100 * MS, 6, true);
// And every later request is an unknown token.
send(&mut core, 3_200 * MS, 7, false);
assert_eq!(core.session_count(), 0);
outcomes
};
let without = run(false);
let with = run(true);
assert_eq!(
without.len(),
8,
"the schedule must actually have run to the end"
);
for (index, (without, with)) in without.iter().zip(&with).enumerate() {
assert_eq!(
(
with.answered,
with.closing,
with.recv_count,
with.recv_window,
with.session_count
),
(
without.answered,
without.closing,
without.recv_count,
without.recv_window,
without.session_count
),
"echo {index}: kernel metadata may not change any same-host decision"
);
}
// The permitted difference, and proof the runs were not simply identical:
// every answered reply reported the kernel's reading rather than the
// server's own, which is 1 µs later.
let reported: Vec<_> = with
.iter()
.filter_map(|outcome| outcome.recv_wall)
.collect();
let userspace: Vec<_> = without
.iter()
.filter_map(|outcome| outcome.recv_wall)
.collect();
assert_eq!(reported.len(), 6, "six of the eight echoes were answered");
assert_eq!(
reported,
userspace
.iter()
.map(|wall| wall - 1_000)
.collect::<Vec<_>>(),
"each answered reply reported the kernel's arrival reading"
);
}
#[test]
fn an_open_or_close_ignores_kernel_metadata_entirely() {
// Neither emits a receive timestamp, and neither may take a different
// lifecycle decision for having been observed. The clock script is what
// pins that: an open consumes exactly its own lifecycle sample whatever the
// transport reported.
let clock = ScriptedClock::new([open_sample(), sample(1_000_000, 10_000)]);
let mut core = core_with_sources(
ServerConfig::default(),
ScriptedTokens::new([TOKEN_A]),
clock.clone(),
);
let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
let packet = core
.handle_received_datagram(peer(), &open_request(&params, None), Some(kernel_rx()))
.unwrap()
.expect("an open must be answered");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(
clock.remaining(),
1,
"an open consumes one lifecycle sample"
);
assert_eq!(core.session_count(), 1);
let closed: SocketAddr = peer();
assert!(core
.handle_received_datagram(closed, &close_request(reply.token, None), Some(kernel_rx()))
.unwrap()
.is_none());
assert_eq!(core.session_count(), 0, "the close released its session");
assert_eq!(
clock.remaining(),
0,
"and a close consumes one of its own, whatever the transport observed"
);
}
-621
View File
@@ -1,621 +0,0 @@
//! Session lifetime: idle expiry and the maximum-duration close.
//!
//! Time is a hand-moved monotonic clock, so every deadline here is exact and no
//! test sleeps, tolerates or races.
//!
//! Two of the behaviors pinned here are deliberate `irtt-rs` policy rather than
//! observed reference behavior, and each says so where it is tested: expiry is
//! immediate at the configured deadline with no reply of any kind, and the
//! deadline covers a session that has never carried an echo request. The clean
//! specification records the reference server's five-second grace, its final
//! lazy-release reply and its never-expiring unused sessions as upstream policy,
//! states that expiry is entirely a server's own business, and recommends
//! against reproducing the resource leak. The one interoperability constraint is
//! negative — a server must never *signal* expiry — and silence satisfies it.
use std::time::Duration;
use irtt_proto::{echo_header_len, Clock, Params, ReceivedStats, ServerFill, StampAt, FLAG_CLOSE};
use super::support::{
close_request, core_with_sources, echo_at, echo_params, echo_request,
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::{OutboundDatagram, ServerConfig};
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
const MS: i64 = 1_000_000;
const SECOND: i64 = 1_000_000_000;
/// Params reporting both statistics and requesting `interval_ns`.
fn params_at(interval_ns: i64) -> Params {
Params {
interval_ns,
..echo_params(
ReceivedStats::Both,
StampAt::None,
Clock::Unspecified,
/* length */ 0,
)
}
}
/// A configuration that expires after `idle` and rate-limits nothing.
fn idle_after(idle: Duration) -> ServerConfig {
ServerConfig::default()
.with_idle_timeout(idle)
.with_min_send_interval(Duration::ZERO)
}
fn count_of(reply: Option<OutboundDatagram>, params: &Params) -> Option<u32> {
let reply = reply?;
expect_echo_reply(reply, params, None).recv_count
}
#[test]
fn a_session_that_never_carried_an_echo_still_expires() {
// `irtt-rs` policy. The reference server starts the idle clock at the first
// echo request, so a session left unused never expires at all; a session is
// created by one unauthenticated datagram and opens are never deduplicated,
// which makes that an unbounded-state hazard rather than a compatibility
// feature. Here the deadline runs from the open.
//
// A one-session table makes the reclamation externally visible: the second
// open can only succeed if the first session was released before capacity
// was judged.
let config = idle_after(Duration::from_secs(2)).with_max_sessions(1);
let clock = ManualClock::at(0);
let mut core = core_with_sources(
config,
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
clock.clone(),
);
let requested = params_at(SECOND);
clock.set(0);
assert!(core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.is_some());
assert_eq!(core.session_count(), 1);
// Still inside the deadline: the table is full and a second open is refused.
clock.set(2 * SECOND - 1);
assert_eq!(
core.handle_datagram(other_peer(), &open_request(&requested, None))
.unwrap(),
None,
"a live session must still occupy the table"
);
assert_eq!(core.session_count(), 1);
// At the deadline the unused session is reclaimed, before capacity is
// judged, so the new open fits.
clock.set(2 * SECOND);
assert!(
core.handle_datagram(other_peer(), &open_request(&requested, None))
.unwrap()
.is_some(),
"an expired session must not deny capacity to a new one"
);
assert_eq!(core.session_count(), 1, "one replaced the other");
// And the reclaimed token names nothing.
assert!(
echo_at(&mut core, &clock, 2 * SECOND, TOKEN_A, 0, &requested, None).is_none(),
"the released token must be unusable"
);
}
#[test]
fn maintenance_reclaims_an_idle_session_without_traffic() {
let clock = ManualClock::at(0);
let mut core = core_with_sources(
idle_after(Duration::from_secs(2)),
ScriptedTokens::new([TOKEN_A]),
clock.clone(),
);
assert!(core
.handle_datagram(peer(), &open_request(&params_at(SECOND), None))
.unwrap()
.is_some());
assert_eq!(core.session_count(), 1);
clock.set(2 * SECOND);
core.maintain();
assert_eq!(core.session_count(), 0);
}
#[test]
fn a_served_echo_moves_the_idle_deadline_along() {
let (mut core, clock, token, negotiated) = manual_core(
idle_after(Duration::from_secs(2)),
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
// Inside the deadline the open set, and past the one it would have had.
assert_eq!(
count_of(
echo_at(&mut core, &clock, SECOND, token, 0, &negotiated, None),
&negotiated
),
Some(1)
);
assert_eq!(
count_of(
echo_at(&mut core, &clock, 2_900 * MS, token, 1, &negotiated, None),
&negotiated
),
Some(2),
"the echo at 1 s moved the deadline from 2 s to 3 s"
);
// Exactly at the deadline the last served echo set: gone, and silently.
assert!(
echo_at(&mut core, &clock, 4_900 * MS, token, 2, &negotiated, None).is_none(),
"the deadline is inclusive"
);
assert_eq!(core.session_count(), 0);
}
#[test]
fn an_expired_session_answers_nothing_at_all() {
// `irtt-rs` policy, and the reason this is its own test. The reference
// server was observed answering the first otherwise-serviceable echo to
// reach an expired session — a final lazy-release reply — and only dropping
// the one after it, with the release boundary a further five seconds past
// the configured timeout. `irtt-rs` reproduces neither: at the deadline the
// session is simply gone, and the client observes silence, which is the only
// thing the specification actually requires of expiry.
let (mut core, clock, token, negotiated) = manual_core(
idle_after(Duration::from_secs(2)),
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
assert_eq!(
count_of(
echo_at(
&mut core,
&clock,
2 * SECOND - 1,
token,
0,
&negotiated,
None
),
&negotiated
),
Some(1),
"one nanosecond short of the deadline is still served"
);
// Two seconds after that echo, to the nanosecond.
assert!(
echo_at(
&mut core,
&clock,
4 * SECOND - 1,
token,
1,
&negotiated,
None
)
.is_none(),
"no final reply: an expired session is simply gone"
);
assert_eq!(core.session_count(), 0);
assert!(
echo_at(&mut core, &clock, 4 * SECOND, token, 2, &negotiated, None).is_none(),
"and stays gone"
);
}
#[test]
fn a_rate_limited_echo_keeps_its_session_alive_without_counting() {
// The one drop class the clean evidence records as refreshing the idle
// deadline. It was established against every other tested class, which does
// not, so `irtt-rs` keeps it.
//
// A 2 s minimum interval against an 8 s idle timeout negotiates 2 s and
// refills every 2 s; a burst of one then makes the echo at 8.5 s — 1.5 s
// after the previous one — rate-limited.
let config = ServerConfig::default()
.with_min_send_interval(Duration::from_secs(2))
.with_idle_timeout(Duration::from_secs(8))
.with_burst_allowance(1);
let (mut core, clock, token, negotiated) = manual_core(
config,
&params_at(2 * SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
assert_eq!(negotiated.interval_ns, 2 * SECOND);
// Served, spending the burst. The idle deadline is now 15 s.
assert_eq!(
count_of(
echo_at(&mut core, &clock, 7 * SECOND, token, 0, &negotiated, None),
&negotiated
),
Some(1)
);
// Rate-limited, 1.5 s later: no reply, and the deadline moves to 16.5 s.
assert!(
echo_at(&mut core, &clock, 8_500 * MS, token, 1, &negotiated, None).is_none(),
"no allowance, so no reply"
);
// Past the deadline the served echo alone would have set, and short of the
// one the rate-limited echo set.
assert_eq!(
count_of(
echo_at(&mut core, &clock, 16 * SECOND, token, 2, &negotiated, None),
&negotiated
),
Some(2),
"the rate-limited echo kept the session alive, and was not counted"
);
}
#[test]
fn a_rejected_echo_does_not_move_the_idle_deadline() {
// Two representative classes; the admission rules themselves are pinned by
// the echo and hmac tests, and are not repeated here.
let config = idle_after(Duration::from_secs(2)).with_max_packet_length(64);
let (mut core, clock, token, negotiated) = manual_core(
config,
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
let oversized = echo_request(
token,
0,
&Params {
length: 65,
..negotiated.clone()
},
&[],
None,
);
assert_eq!(oversized.len(), 65);
clock.set(2 * SECOND - 1);
assert_eq!(core.handle_datagram(peer(), &oversized).unwrap(), None);
assert_eq!(
core.handle_datagram(
other_peer(),
&echo_request(token, 0, &negotiated, &[], None)
)
.unwrap(),
None,
"a foreign endpoint is refused whatever the session's state"
);
assert_eq!(core.session_count(), 1, "neither released it early either");
// The deadline the open set, unmoved.
assert!(
echo_at(&mut core, &clock, 2 * SECOND, token, 0, &negotiated, None).is_none(),
"a rejected echo must not have extended the session"
);
assert_eq!(core.session_count(), 0);
}
#[test]
fn any_authenticated_request_reclaims_expired_sessions_including_a_no_test_open() {
// `irtt-rs` policy. Expiry is a decision about the table, so it runs before
// a request is dispatched and does not depend on what that request is for.
// The reference server was observed releasing an expired session on a burst
// of ordinary opens but not on no-test ones; a client cannot tell, since
// expiry is never signalled, and the distinction is not worth reproducing.
//
// A no-test open still creates nothing of its own, which the token source
// and the session count both check here.
let tokens = ScriptedTokens::new([TOKEN_A]);
let clock = ManualClock::at(0);
let mut core = core_with_sources(
idle_after(Duration::from_secs(2)),
tokens.clone(),
clock.clone(),
);
let requested = params_at(SECOND);
assert!(core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.is_some());
assert_eq!(core.session_count(), 1);
assert_eq!(tokens.remaining(), 0);
clock.set(2 * SECOND);
let packet = core
.handle_datagram(other_peer(), &no_test_request(&requested, None))
.unwrap()
.expect("a no-test open is answered whatever the table holds");
expect_no_test_reply(&packet, None);
assert_eq!(
core.session_count(),
0,
"the expired session was reclaimed, and no-test created none of its own"
);
}
#[test]
fn a_client_close_still_releases_its_session_without_a_reply() {
// Unchanged by this slice, and worth pinning next to the close the *server*
// sends: a client close is answered with nothing at all.
let (mut core, clock, token, negotiated) = manual_core(
idle_after(Duration::from_secs(60)),
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
assert!(echo_at(&mut core, &clock, SECOND, token, 0, &negotiated, None).is_some());
clock.set(2 * SECOND);
assert_eq!(
core.handle_datagram(peer(), &close_request(token, None))
.unwrap(),
None
);
assert_eq!(core.session_count(), 0);
}
/// A configuration whose sessions run for `maximum` and are never idle-expired
/// within a test's timescale.
fn max_duration_of(maximum: Duration) -> ServerConfig {
ServerConfig::default()
.with_max_test_duration(maximum)
.with_idle_timeout(Duration::from_secs(600))
}
#[test]
fn the_maximum_duration_deadline_starts_at_the_first_served_echo() {
// Measured behavior: opening a session well before its first echo does not
// move the deadline, and neither a rejected nor a rate-limited first request
// starts it.
let (mut core, clock, token, negotiated) = manual_core(
max_duration_of(Duration::from_secs(1)),
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
assert_eq!(
negotiated.duration_ns, SECOND,
"the requested duration was reduced to the maximum"
);
// Ten seconds after the open — far past maximum + grace, had the open
// started the clock.
let packet = echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None)
.expect("the first echo must be answered");
let reply = expect_echo_reply(&packet, &negotiated, None);
assert_eq!(reply.recv_count, Some(1));
// The deadline is 13 s: this echo, three seconds after the open but two
// before the origin's own deadline, is ordinary.
let packet = echo_at(&mut core, &clock, 12_999 * MS, token, 1, &negotiated, None)
.expect("an echo inside the deadline must be answered");
assert_eq!(
expect_echo_reply(&packet, &negotiated, None).recv_count,
Some(2)
);
assert_eq!(core.session_count(), 1);
}
#[test]
fn the_first_echo_past_the_maximum_duration_carries_close_and_ends_the_session() {
// The session negotiates a fill and a payload region, so this covers the
// close's payload as well: a server close is an ordinary echo reply with a
// flag added, and it fills exactly as the session's other replies do.
let requested = Params {
server_fill: Some(ServerFill {
value: "pattern:aabb".to_owned(),
}),
length: (echo_header_len(false, &params_at(SECOND)) + 5) as i64,
..params_at(SECOND)
};
let patterned = vec![0xaa, 0xbb, 0xaa, 0xbb, 0xaa];
let (mut core, clock, token, negotiated) = manual_core(
max_duration_of(Duration::from_secs(1)),
&requested,
ScriptedTokens::new([TOKEN_A]),
None,
);
assert!(echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None).is_some());
// One nanosecond short of maximum + 2 s grace, measured from that echo.
let packet = echo_at(
&mut core,
&clock,
13 * SECOND - 1,
token,
1,
&negotiated,
None,
)
.expect("short of the deadline must be answered normally");
let reply = expect_echo_reply(&packet, &negotiated, None);
assert_eq!(reply.flags & FLAG_CLOSE, 0, "not yet");
assert_eq!(reply.payload, patterned, "an ordinary reply's fill");
// At the deadline exactly.
let packet = echo_at(&mut core, &clock, 13 * SECOND, token, 2, &negotiated, None)
.expect("the deadline-crossing echo is answered, with Close");
let reply = expect_closing_echo_reply(&packet, &negotiated, None);
assert_eq!(reply.token, token, "an ordinary reply in every other way");
assert_eq!(reply.sequence, 2, "the triggering sequence, copied through");
assert_eq!(
(reply.recv_count, reply.recv_window),
(Some(3), Some(0x7)),
"the triggering request is included in the statistics it reports"
);
assert_eq!(
reply.payload, patterned,
"the close keeps the session's fill; it is not a special packet"
);
assert_eq!(
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);
assert!(
echo_at(
&mut core,
&clock,
13 * SECOND + 1,
token,
3,
&negotiated,
None
)
.is_none(),
"every later request is an unknown token"
);
}
#[test]
fn a_rate_limited_echo_at_the_deadline_defers_the_close_rather_than_losing_it() {
// The measured dropped-trigger case, and the reason rate allowance is
// judged before the deadline: a deadline-crossing echo with no allowance is
// silently dropped, the session stays usable, and the next echo that *is*
// served carries the close.
//
// A 5 s minimum interval against a 600 s idle timeout negotiates 5 s and
// refills every 5 s, so the echo three seconds after the first is limited.
let config = max_duration_of(Duration::from_secs(1))
.with_min_send_interval(Duration::from_secs(5))
.with_burst_allowance(1);
let (mut core, clock, token, negotiated) = manual_core(
config,
&params_at(5 * SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
assert_eq!(negotiated.interval_ns, 5 * SECOND);
assert_eq!(
count_of(
echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None),
&negotiated
),
Some(1),
"the origin, which also spends the burst"
);
assert!(
echo_at(&mut core, &clock, 13 * SECOND, token, 1, &negotiated, None).is_none(),
"past the deadline but with no allowance: dropped, not closed"
);
assert_eq!(core.session_count(), 1, "and the session is still usable");
// Two seconds later the allowance is back.
let packet = echo_at(&mut core, &clock, 15 * SECOND, token, 2, &negotiated, None)
.expect("the next served echo carries the close");
let reply = expect_closing_echo_reply(&packet, &negotiated, None);
assert_eq!(
reply.recv_count,
Some(2),
"the rate-limited request was never counted"
);
assert_eq!(core.session_count(), 0);
}
#[test]
fn a_rejected_echo_past_the_deadline_neither_closes_nor_releases() {
// The close trigger runs only after ordinary admission and rate allowance
// succeed, so a datagram the server would refuse at any time refuses in the
// ordinary way here too. The specification records the corresponding
// upstream behavior as untested for classes other than rate limiting, so
// this is the deterministic `irtt-rs` reading rather than an observed one.
let (mut core, clock, token, negotiated) = manual_core(
max_duration_of(Duration::from_secs(1)),
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
assert!(echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None).is_some());
clock.set(14 * SECOND);
assert_eq!(
core.handle_datagram(
other_peer(),
&echo_request(token, 1, &negotiated, &[], None)
)
.unwrap(),
None,
"a foreign endpoint gets silence, not a close"
);
assert_eq!(core.session_count(), 1, "and does not release the session");
let packet = echo_at(&mut core, &clock, 14_100 * MS, token, 2, &negotiated, None)
.expect("the owning endpoint still receives the close");
let reply = expect_closing_echo_reply(&packet, &negotiated, None);
assert_eq!(
reply.recv_count,
Some(2),
"the foreign request advanced nothing"
);
assert_eq!(core.session_count(), 0);
}
#[test]
fn an_authenticated_session_closes_through_the_ordinary_reply_path() {
// No special close authentication exists, and none should: the flag rides on
// a reply the normal encoder and key path produced.
let config = max_duration_of(Duration::from_secs(1)).with_hmac_key(KEY);
let (mut core, clock, token, negotiated) = manual_core(
config,
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
Some(KEY),
);
assert!(echo_at(
&mut core,
&clock,
10 * SECOND,
token,
0,
&negotiated,
Some(KEY)
)
.is_some());
let packet = echo_at(
&mut core,
&clock,
13 * SECOND,
token,
1,
&negotiated,
Some(KEY),
)
.expect("the deadline-crossing echo is answered");
// Decoding verifies the MAC, and the flag comparison pins HMAC and Close
// 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.bytes())
.expect("the close-flagged reply is authenticated");
assert_eq!(core.session_count(), 0);
}
+1 -26
View File
@@ -1,29 +1,4 @@
//! Deterministic tests for the server core.
//!
//! The core takes a source endpoint and datagram bytes and returns reply bytes,
//! so these drive it directly; no socket is bound. Requests are built with the
//! 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.
//!
//! 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.
//! Fault paths that cannot reasonably be induced through OS randomness.
mod admission;
mod close;
mod echo;
mod fill;
mod hmac;
mod kernel_rx;
mod lifecycle;
mod negotiation;
mod no_test;
mod open;
mod params;
mod rate;
mod sessions;
mod support;
mod tokens;
mod traffic_class;
-392
View File
@@ -1,392 +0,0 @@
//! Parameter restrictions the server applies to an open request.
//!
//! Every case drives a real open through the core and reads the restriction out
//! of the open reply, because the reply is the contract: a client enforces its
//! own restriction policy against exactly these values, and the server has to
//! honor what it returned.
use std::time::Duration;
use irtt_proto::{Clock, Params, StampAt};
use super::support::{
client_params, core_with_tokens, echo_request, expect_echo_reply, expect_normal_open_reply,
open_request, peer, unthrottled, ScriptedTokens,
};
use crate::{ServerConfig, TimestampAllowance};
const TOKEN_A: u64 = 0x2f45_9a01_c37d_6e88;
/// Negotiates one open against `config` and returns the restricted parameters.
fn negotiated(config: ServerConfig, requested: &Params) -> Params {
let mut core = core_with_tokens(config, ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(peer(), &open_request(requested, None))
.unwrap()
.expect("the open must be answered");
expect_normal_open_reply(&packet, None).params
}
fn requesting(duration_ns: i64, interval_ns: i64) -> Params {
Params {
duration_ns,
interval_ns,
..client_params()
}
}
#[test]
fn a_requested_duration_is_reduced_to_the_configured_maximum() {
let config = || ServerConfig::default().with_max_test_duration(Duration::from_secs(2));
assert_eq!(
negotiated(config(), &requesting(10_000_000_000, 1_000_000_000)).duration_ns,
2_000_000_000,
"a longer duration is reduced to the maximum"
);
assert_eq!(
negotiated(config(), &requesting(1_000_000_000, 1_000_000_000)).duration_ns,
1_000_000_000,
"a duration under the maximum is left alone"
);
// Duration zero is absent from the request, which means continuous. A
// server with a finite maximum is restricting continuous mode to a finite
// test, which is a reduction the client already models.
assert_eq!(
negotiated(config(), &requesting(0, 1_000_000_000)).duration_ns,
2_000_000_000,
"a continuous request takes the maximum"
);
}
#[test]
fn without_a_configured_maximum_a_duration_is_never_restricted() {
let config = || ServerConfig::default();
assert_eq!(
negotiated(config(), &requesting(86_400_000_000_000, 1_000_000_000)).duration_ns,
86_400_000_000_000,
"a day-long test is the operator's business, not negotiation's"
);
assert_eq!(
negotiated(config(), &requesting(0, 1_000_000_000)).duration_ns,
0,
"continuous stays continuous"
);
// A zero maximum is stored as no maximum rather than negotiated: Duration
// zero on the wire means continuous, so a finite maximum of zero could only
// be expressed by turning a client's finite test into an endless one.
let zero = ServerConfig::default().with_max_test_duration(Duration::ZERO);
assert_eq!(zero.max_test_duration(), None);
assert_eq!(
negotiated(zero, &requesting(1_000_000_000, 1_000_000_000)).duration_ns,
1_000_000_000
);
}
#[test]
fn a_requested_interval_is_raised_to_the_configured_minimum() {
let config = || ServerConfig::default().with_min_send_interval(Duration::from_millis(100));
assert_eq!(
negotiated(config(), &requesting(3_000_000_000, 20_000_000)).interval_ns,
100_000_000,
"a faster interval is raised to the minimum"
);
assert_eq!(
negotiated(config(), &requesting(3_000_000_000, 500_000_000)).interval_ns,
500_000_000,
"an interval above the minimum is left alone"
);
// An absent Interval is the wire default zero, not a refusal — an explicit
// zero is rejected during request admission and never reaches negotiation —
// so it is raised like any other value below the floor.
assert_eq!(
negotiated(config(), &requesting(3_000_000_000, 0)).interval_ns,
100_000_000,
"an absent interval takes the minimum"
);
}
#[test]
fn a_negotiated_interval_is_capped_at_a_quarter_of_the_idle_timeout() {
// Without the cap, a client sending at the interval it was handed would
// idle its own session out.
let config = ServerConfig::default().with_idle_timeout(Duration::from_secs(8));
assert_eq!(
negotiated(config, &requesting(3_000_000_000, 5_000_000_000)).interval_ns,
2_000_000_000
);
// A timeout whose quarter is past what the wire can express caps nothing,
// because no representable interval reaches it. The quarter is taken before
// the conversion to nanoseconds for exactly this reason: saturating first
// would put the cap at a quarter of `i64::MAX` and reduce a long interval
// the configured timeout never meant to touch.
let absurd = ServerConfig::default().with_idle_timeout(Duration::MAX);
assert_eq!(
negotiated(absurd, &requesting(3_000_000_000, i64::MAX)).interval_ns,
i64::MAX
);
}
#[test]
fn the_idle_cap_wins_over_a_larger_configured_minimum_interval() {
// The floor is applied first and the cap second, so a minimum above a
// quarter of the idle timeout produces a negotiated interval below that
// minimum. This is the configuration the specification records as an
// upstream hazard: upstream returns 2 s here and still replenishes reply
// allowance every 5 s, so a client obeying the reply is rate-limited.
//
// `irtt-rs` returns the same 2 s — the cap has to win, or the session
// cannot stay alive — and refills at 2 s instead. `rate` proves the
// enforcement half; this pins the value the client is told.
let config = ServerConfig::default()
.with_min_send_interval(Duration::from_secs(5))
.with_idle_timeout(Duration::from_secs(8));
assert_eq!(
negotiated(config, &requesting(3_000_000_000, 10_000_000)).interval_ns,
2_000_000_000
);
}
#[test]
fn a_zero_idle_timeout_caps_nothing() {
// A zero timeout expires a session at the first evaluation, so there is no
// liveness to protect and a quarter of zero must not be read as a cap of
// zero — which would negotiate an interval the client would reject.
let config = ServerConfig::default()
.with_idle_timeout(Duration::ZERO)
.with_min_send_interval(Duration::from_millis(100));
assert_eq!(
negotiated(config, &requesting(3_000_000_000, 3_600_000_000_000)).interval_ns,
3_600_000_000_000
);
}
#[test]
fn the_default_configuration_restricts_neither_capability() {
// Both controls are opt-in, so an existing configuration negotiates exactly
// what it did before they existed.
let config = ServerConfig::default();
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Dual);
assert!(config.dscp_allowed());
assert_eq!(TimestampAllowance::default(), TimestampAllowance::Dual);
let restricted = config
.clone()
.with_timestamp_allowance(TimestampAllowance::Single)
.with_dscp_allowed(false);
assert_eq!(restricted.timestamp_allowance(), TimestampAllowance::Single);
assert!(!restricted.dscp_allowed());
assert_ne!(
restricted, config,
"the policy is part of the configuration"
);
}
#[test]
fn a_requested_stamp_at_is_reduced_to_the_configured_allowance() {
// The whole observed mapping, driven through real opens and read out of the
// reply, because the reply is what a client enforces its own restriction
// policy against. Only one row substitutes rather than removes: a request
// for both instants under a single-timestamp allowance is answered with the
// *midpoint*, which still describes both, rather than with whichever of the
// two the server preferred.
for (requested, allowance, expected) in [
(StampAt::None, TimestampAllowance::Dual, StampAt::None),
(StampAt::None, TimestampAllowance::Single, StampAt::None),
(StampAt::None, TimestampAllowance::None, StampAt::None),
(StampAt::Send, TimestampAllowance::Dual, StampAt::Send),
(StampAt::Send, TimestampAllowance::Single, StampAt::Send),
(StampAt::Send, TimestampAllowance::None, StampAt::None),
(StampAt::Receive, TimestampAllowance::Dual, StampAt::Receive),
(
StampAt::Receive,
TimestampAllowance::Single,
StampAt::Receive,
),
(StampAt::Receive, TimestampAllowance::None, StampAt::None),
(StampAt::Both, TimestampAllowance::Dual, StampAt::Both),
(StampAt::Both, TimestampAllowance::Single, StampAt::Midpoint),
(StampAt::Both, TimestampAllowance::None, StampAt::None),
(
StampAt::Midpoint,
TimestampAllowance::Dual,
StampAt::Midpoint,
),
(
StampAt::Midpoint,
TimestampAllowance::Single,
StampAt::Midpoint,
),
(StampAt::Midpoint, TimestampAllowance::None, StampAt::None),
] {
let params = Params {
stamp_at: requested,
clock: Clock::Both,
..client_params()
};
let negotiated = negotiated(
ServerConfig::default().with_timestamp_allowance(allowance),
&params,
);
assert_eq!(
negotiated.stamp_at, expected,
"{requested:?} under {allowance:?}"
);
assert_eq!(
negotiated.clock,
Clock::Both,
"{requested:?} under {allowance:?}: the allowance restricts placement, not the clock"
);
}
}
#[test]
fn a_timestamp_restriction_never_rewrites_the_requested_clock() {
// The clean evidence describes a timestamp *allowance*, and says nothing
// about restricting clock domains. A server that quietly changed the clock
// too would report a session the client never asked for — and a client
// enforcing strict negotiation would refuse an otherwise serviceable one.
let single = negotiated(
ServerConfig::default().with_timestamp_allowance(TimestampAllowance::Single),
&Params {
stamp_at: StampAt::Both,
clock: Clock::Wall,
..client_params()
},
);
assert_eq!(single.stamp_at, StampAt::Midpoint);
assert_eq!(single.clock, Clock::Wall);
let none = negotiated(
ServerConfig::default().with_timestamp_allowance(TimestampAllowance::None),
&Params {
stamp_at: StampAt::Both,
clock: Clock::Both,
..client_params()
},
);
assert_eq!(none.stamp_at, StampAt::None);
assert_eq!(
none.clock,
Clock::Both,
"the clock tag stays exactly as requested, selecting nothing"
);
}
#[test]
fn an_allowed_dscp_is_negotiated_stored_and_transported_unchanged() {
// The default, and the behavioral compatibility this policy must not
// disturb: the raw byte reaches the reply, the session and the transport.
let requested = Params {
dscp: 0xbb,
..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 open must be answered");
let negotiated = expect_normal_open_reply(&open, None).params;
assert_eq!(negotiated.dscp, 0xbb);
assert_eq!(
core.session(TOKEN_A)
.expect("the session must be live")
.params()
.dscp,
0xbb
);
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()), 0xbb);
}
#[test]
fn a_disallowed_dscp_is_negotiated_to_zero_and_the_session_runs_unmarked() {
// The open is not refused: the server simply will not provide the marking,
// and says so in the value it returns. Nothing downstream needs a second
// record of the policy — the negotiated zero is what the transport reads.
let requested = Params {
dscp: 0xb8,
..client_params()
};
let mut core = core_with_tokens(
unthrottled().with_dscp_allowed(false),
ScriptedTokens::new([TOKEN_A]),
);
let open = core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.expect("a disallowed DSCP must not refuse the open");
let negotiated = expect_normal_open_reply(&open, None).params;
assert_eq!(negotiated.dscp, 0, "restricted to zero");
assert_eq!(
core.session(TOKEN_A)
.expect("the session must be live")
.params()
.dscp,
0,
"and stored as the value the session will be run with"
);
let reply = core
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
.unwrap()
.expect("an unmarked session is an ordinary session");
expect_echo_reply(&reply, &negotiated, None);
assert_eq!(reply.traffic_class(), 0);
}
#[test]
fn a_disallowed_dscp_restricts_a_value_no_socket_could_carry_too() {
// Restriction runs during negotiation, before the transport's
// "out-of-range means unmarked" fallback is relevant at all: these values
// never reach the session, so the reply is honest rather than merely
// harmless.
for dscp in [-1, 256, i64::MAX] {
let requested = Params {
dscp,
..client_params()
};
let mut core = core_with_tokens(
unthrottled().with_dscp_allowed(false),
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, 0, "requested {dscp}");
assert_eq!(
core.session(TOKEN_A)
.expect("the session must be live")
.params()
.dscp,
0,
"requested {dscp}"
);
let reply = core
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
.unwrap()
.unwrap_or_else(|| panic!("the session must serve echoes after restricting {dscp}"));
expect_echo_reply(&reply, &negotiated, None);
assert_eq!(reply.traffic_class(), 0, "requested {dscp}");
}
}
-258
View File
@@ -1,258 +0,0 @@
use irtt_proto::{verify_packet_hmac, Clock, Params, StampAt};
use super::support::{
client_params, core_with_tokens, expect_no_test_reply, no_test_request, open_request,
open_request_with_raw_params, param_int, peer, unthrottled, ScriptedTokens, KEY,
};
use crate::{ServerConfig, TimestampAllowance};
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
#[test]
fn a_no_test_open_replies_with_a_zero_token_and_creates_nothing() {
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
let packet = core
.handle_datagram(peer(), &no_test_request(&client_params(), None))
.unwrap()
.expect("a no-test open must be answered");
let reply = expect_no_test_reply(&packet, None);
assert_eq!(
reply.params,
Params {
protocol_version: 1,
..client_params()
}
);
assert_eq!(core.session_count(), 0, "no-test creates no session");
assert_eq!(
tokens.remaining(),
1,
"no-test must not draw a session token"
);
}
#[test]
fn a_no_test_open_negotiates_exactly_as_a_normal_open_does() {
let requested = client_params();
let mut normal_core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let normal = normal_core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.expect("normal open must be answered");
let mut no_test_core =
core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let no_test = no_test_core
.handle_datagram(peer(), &no_test_request(&requested, None))
.unwrap()
.expect("no-test open must be answered");
assert_eq!(
expect_no_test_reply(&no_test, None).params,
super::support::decode_reply(&normal, None).params
);
}
#[test]
fn a_no_test_open_is_rejected_on_the_same_parameters_a_normal_open_is() {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let request = {
let mut packet = no_test_request(&client_params(), None);
// Replace the parameter payload with an explicit zero duration.
packet.truncate(4);
packet.extend_from_slice(&param_int(2, 0));
packet
};
assert_eq!(core.handle_datagram(peer(), &request).unwrap(), None);
assert_eq!(core.session_count(), 0);
}
#[test]
fn a_no_test_open_selecting_timestamps_without_a_clock_is_silently_refused() {
// No-test exists to tell a client what the session would be, so it must not
// report a session that could not exist. The effective-parameter check runs
// before the no-test path branches off, so both open forms refuse the same
// incoherent timestamp request.
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
let requested = Params {
clock: Clock::Unspecified,
stamp_at: StampAt::Both,
..client_params()
};
assert_eq!(
core.handle_datagram(peer(), &no_test_request(&requested, None))
.expect("an unusable timestamp request is not a server error"),
None
);
assert_eq!(core.session_count(), 0);
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
}
#[test]
fn a_no_test_open_reports_the_reduced_packet_length() {
// No-test answers what the session *would* be, so it has to report the same
// reduced length a session-creating open would enforce — while still
// creating nothing and drawing no token.
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(), &no_test_request(&requested, None))
.unwrap()
.expect("a reducible length must still be answered");
assert_eq!(expect_no_test_reply(&packet, None).params.length, 64);
assert_eq!(core.session_count(), 0);
assert_eq!(tokens.remaining(), 1, "no-test must not draw a token");
}
#[test]
fn a_no_test_open_is_refused_when_its_echo_field_block_exceeds_the_maximum() {
// A maximum below the mandatory echo block describes a session that could
// not exist at all, so no-test must not report one. Both open forms run the
// same effective-parameter check before no-test branches off.
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(
ServerConfig::default().with_max_packet_length(0),
tokens.clone(),
);
assert_eq!(
core.handle_datagram(peer(), &no_test_request(&client_params(), 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 a_no_test_open_is_still_answered_when_no_session_could_be_created() {
// No-test consumes no capacity, so it must keep working exactly where a
// session-creating open would be refused.
let mut refuses_everything = core_with_tokens(
ServerConfig::default().with_max_sessions(0),
ScriptedTokens::new([TOKEN_A]),
);
assert_eq!(
refuses_everything
.handle_datagram(peer(), &open_request(&client_params(), None))
.unwrap(),
None,
"max_sessions 0 refuses a session-creating open"
);
let packet = refuses_everything
.handle_datagram(peer(), &no_test_request(&client_params(), None))
.unwrap()
.expect("no-test must be served at max_sessions 0");
expect_no_test_reply(&packet, None);
let mut full = core_with_tokens(
ServerConfig::default().with_max_sessions(1),
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
);
full.handle_datagram(peer(), &open_request(&client_params(), None))
.unwrap()
.expect("the first open fills the table");
assert_eq!(full.session_count(), 1);
let packet = full
.handle_datagram(peer(), &no_test_request(&client_params(), None))
.unwrap()
.expect("no-test must be served with a full session table");
expect_no_test_reply(&packet, None);
assert_eq!(full.session_count(), 1, "no-test disturbed the table");
}
#[test]
fn an_authenticated_no_test_reply_is_authenticated_and_still_creates_nothing() {
let mut core = core_with_tokens(
ServerConfig::default().with_hmac_key(KEY),
ScriptedTokens::new([TOKEN_A]),
);
let packet = core
.handle_datagram(peer(), &no_test_request(&client_params(), Some(KEY)))
.unwrap()
.expect("an authenticated no-test open must be answered");
expect_no_test_reply(&packet, Some(KEY));
verify_packet_hmac(KEY, packet.bytes()).expect("the no-test reply must carry a valid MAC");
assert_eq!(core.session_count(), 0);
}
#[test]
fn an_empty_no_test_open_is_answered() {
// No interval floor, so the reply shows the wire defaults an empty payload
// decoded to rather than the timing policy negotiation would apply to them.
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
let mut request = open_request_with_raw_params(&[], None);
request[3] |= irtt_proto::FLAG_CLOSE;
let packet = core
.handle_datagram(peer(), &request)
.unwrap()
.expect("an empty no-test open must be answered");
let reply = expect_no_test_reply(&packet, None);
assert_eq!(
reply.params,
Params {
protocol_version: 1,
..Params::default()
}
);
assert_eq!(core.session_count(), 0);
}
#[test]
fn a_no_test_open_reports_the_restricted_capabilities() {
// No-test exists to tell a client what the session would be, so it reports
// exactly what the same open would have negotiated under this policy —
// while still creating nothing and drawing no token. Capability policy is
// not skipped merely because no measurement session follows.
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(
ServerConfig::default()
.with_timestamp_allowance(TimestampAllowance::Single)
.with_dscp_allowed(false),
tokens.clone(),
);
let requested = Params {
stamp_at: StampAt::Both,
clock: Clock::Both,
dscp: 184,
..client_params()
};
let packet = core
.handle_datagram(peer(), &no_test_request(&requested, None))
.unwrap()
.expect("a no-test open must be answered under a restricting policy");
let reply = expect_no_test_reply(&packet, None);
assert_eq!(reply.params.stamp_at, StampAt::Midpoint);
assert_eq!(reply.params.clock, Clock::Both, "the clock is untouched");
assert_eq!(reply.params.dscp, 0);
assert_eq!(reply.token, 0);
assert_eq!(core.session_count(), 0, "no-test creates no session");
assert_eq!(tokens.remaining(), 1, "no-test must not draw a token");
}
-458
View File
@@ -1,458 +0,0 @@
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"
);
}
}
-170
View File
@@ -1,170 +0,0 @@
use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt};
use super::support::{
core_with_tokens, expect_normal_open_reply, open_request, open_request_with_raw_params,
param_int, param_server_fill, peer, unthrottled, ScriptedTokens,
};
use crate::ServerConfig;
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
fn reject_case(name: &str, payload: Vec<u8>) {
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
let reply = core
.handle_datagram(peer(), &open_request_with_raw_params(&payload, None))
.expect("rejected parameters are not a server error");
assert!(reply.is_none(), "{name} must be answered with silence");
assert_eq!(core.session_count(), 0, "{name} must create no session");
assert_eq!(
tokens.remaining(),
1,
"{name} must not consume a session token"
);
}
#[test]
fn malformed_or_out_of_range_parameters_are_dropped_without_a_session() {
// Truncation and overflow.
reject_case("truncated tag varint", vec![0x80]);
reject_case("truncated value varint", vec![2]);
reject_case("truncated multi-byte value", vec![2, 0x80]);
reject_case(
"varint overflow",
vec![
1, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
],
);
// Duration and Interval: present and not positive.
reject_case("explicit duration zero", param_int(2, 0));
reject_case("negative duration", param_int(2, -1));
reject_case("explicit interval zero", param_int(3, 0));
reject_case("negative interval", param_int(3, -5));
// Enum values outside their defined range.
reject_case("received stats above range", param_int(5, 4));
reject_case("negative received stats", param_int(5, -1));
reject_case("stamp at above range", param_int(6, 5));
reject_case("explicit clock zero", param_int(7, 0));
reject_case("clock above range", param_int(7, 4));
// Server fill.
reject_case(
"server fill declaring more than the payload holds",
param_server_fill(10, b"ab"),
);
reject_case(
"server fill longer than 32 bytes",
param_server_fill(33, b"0123456789abcdef0123456789abcdefx"),
);
reject_case(
"server fill that is not utf-8",
param_server_fill(1, &[0xff]),
);
}
#[test]
fn absent_duration_interval_and_clock_are_accepted_as_wire_defaults() {
// The whole reason the decoder reports presence: an omitted tag and one
// explicitly encoded as zero share a value but not a verdict.
//
// The interval floor is disabled so that the accepted wire default is
// visible in the reply. An absent Interval is a value like any other to
// negotiation, and the default floor would raise it; that is interval
// policy, and `negotiation` tests it.
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(
peer(),
&open_request_with_raw_params(&param_int(1, 1), None),
)
.unwrap()
.expect("absent duration, interval and clock are all valid");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(reply.params.duration_ns, 0);
assert_eq!(reply.params.interval_ns, 0);
assert_eq!(reply.params.clock, Clock::Unspecified);
assert_eq!(core.session_count(), 1);
}
#[test]
fn valid_optional_parameter_values_survive_unchanged() {
for (name, requested) in [
(
"clock wall",
Params {
clock: Clock::Wall,
..Params::default()
},
),
(
"clock monotonic",
Params {
clock: Clock::Monotonic,
..Params::default()
},
),
(
"clock both",
Params {
clock: Clock::Both,
..Params::default()
},
),
(
"received stats and stamp at",
Params {
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Midpoint,
clock: Clock::Wall,
..Params::default()
},
),
(
"positive duration and interval",
Params {
duration_ns: 1,
interval_ns: 1,
..Params::default()
},
),
(
// Exactly `MAX_SERVER_FILL_BYTES`, and a valid descriptor: the
// longest one a client can send is also one this server honors, so
// the row stays about the length bound rather than about fill
// policy, which `fill` tests on its own.
"maximum length server fill",
Params {
server_fill: Some(ServerFill {
value: "pattern:0123456789abcdef01234567".to_owned(),
}),
..Params::default()
},
),
] {
// No interval floor: these rows are about which *values* survive
// decoding and negotiation, not about the timing policy applied to
// them.
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.unwrap_or_else(|| panic!("{name} must be answered"));
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(
reply.params,
Params {
protocol_version: 1,
..requested
},
"{name} must survive negotiation unchanged"
);
}
}
-257
View File
@@ -1,257 +0,0 @@
//! Per-session reply allowance.
//!
//! The behavior pinned here is the clean specification's Section 9.3: a session
//! starts with its full burst, spends one unit per echo it is answered,
//! replenishes at one unit per refill interval, and drops an echo arriving with
//! no allowance without a reply and without advancing either reception
//! statistic. That last part is what the counts in the *later* replies prove.
//!
//! The token bucket itself is a black-box inference about the reference server,
//! not a protocol requirement, so nothing here reaches into the limiter's
//! representation: every assertion is about which datagrams came back.
use std::time::Duration;
use irtt_proto::{Clock, Params, ReceivedStats, StampAt};
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::{OutboundDatagram, ServerConfig};
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
const MS: i64 = 1_000_000;
const SECOND: i64 = 1_000_000_000;
/// Params reporting both statistics — the count is how a test tells a served
/// request from a dropped one — and requesting `interval_ns`.
fn params_at(interval_ns: i64) -> Params {
Params {
interval_ns,
..echo_params(
ReceivedStats::Both,
StampAt::None,
Clock::Unspecified,
/* length */ 0,
)
}
}
/// The received count a reply reported, or `None` if there was no reply.
fn count_of(reply: Option<OutboundDatagram>, params: &Params) -> Option<u32> {
let reply = reply?;
expect_echo_reply(reply, params, None).recv_count
}
#[test]
fn a_blast_is_answered_up_to_the_burst_and_then_replenishes() {
// The measured case: a 100 ms minimum interval and a burst of 5 answered
// exactly 5 of a blast of 12, and the next 3 requests after a 600 ms pause
// were all answered, with the count running 1..5 and then 6, 7, 8.
let config = ServerConfig::default()
.with_min_send_interval(Duration::from_millis(100))
.with_burst_allowance(5);
let (mut core, clock, token, negotiated) = manual_core(
config,
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
// The refill cadence is the configured minimum here, not the negotiated
// interval: the ordinary case, where the minimum is the shorter of the two.
assert_eq!(negotiated.interval_ns, SECOND);
for sequence in 0..12 {
let count = count_of(
echo_at(&mut core, &clock, 0, token, sequence, &negotiated, None),
&negotiated,
);
let expected = (sequence < 5).then_some(sequence + 1);
assert_eq!(count, expected, "request {sequence} of the blast");
}
// A pause long enough to refill the whole burst, then three requests spaced
// by one refill interval each. The counts continuing 6, 7, 8 is what proves
// the seven dropped requests advanced nothing.
for (offset, sequence, expected) in [(600, 12, 6), (700, 13, 7), (800, 14, 8)] {
let count = count_of(
echo_at(
&mut core,
&clock,
offset * MS,
token,
sequence,
&negotiated,
None,
),
&negotiated,
);
assert_eq!(count, Some(expected), "request at {offset} ms");
}
}
#[test]
fn a_burst_of_one_answers_one_request_per_interval() {
// The other measured case: with a burst of 1, a blast of 4 produced 1 reply.
let config = ServerConfig::default()
.with_min_send_interval(Duration::from_millis(100))
.with_burst_allowance(1);
let (mut core, clock, token, negotiated) = manual_core(
config,
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
for sequence in 0..4 {
let count = count_of(
echo_at(&mut core, &clock, 0, token, sequence, &negotiated, None),
&negotiated,
);
assert_eq!(count, (sequence == 0).then_some(1), "request {sequence}");
}
// Exactly one interval later the allowance is back, and the count says the
// three dropped requests were never counted.
let count = count_of(
echo_at(&mut core, &clock, 100 * MS, token, 4, &negotiated, None),
&negotiated,
);
assert_eq!(count, Some(2));
}
#[test]
fn the_refill_cadence_is_never_slower_than_the_negotiated_interval() {
// The deliberate `irtt-rs` divergence, and the reason this test exists.
//
// A 5 s minimum interval against an 8 s idle timeout negotiates 2 s, because
// the idle cap is applied after the floor. The clean specification records
// the reference server continuing to replenish every 5 s in that
// configuration, so a fully conforming client sending at the 2 s interval it
// was handed is rate-limited on every request. `irtt-rs` refills at the
// interval it advertised instead.
let config = ServerConfig::default()
.with_min_send_interval(Duration::from_secs(5))
.with_idle_timeout(Duration::from_secs(8))
.with_burst_allowance(1);
let (mut core, clock, token, negotiated) = manual_core(
config,
&params_at(10 * MS),
ScriptedTokens::new([TOKEN_A]),
None,
);
assert_eq!(
negotiated.interval_ns,
2 * SECOND,
"the idle cap wins over the larger configured minimum"
);
assert_eq!(
count_of(
echo_at(&mut core, &clock, 0, token, 0, &negotiated, None),
&negotiated
),
Some(1),
"the first request spends the burst"
);
// One negotiated interval later — and well short of the 5 s the reference
// server would have made this client wait.
assert_eq!(
count_of(
echo_at(&mut core, &clock, 2 * SECOND, token, 1, &negotiated, None),
&negotiated
),
Some(2),
"a client sending at the interval it was given must not be limited"
);
assert_eq!(
count_of(
echo_at(&mut core, &clock, 4 * SECOND, token, 2, &negotiated, None),
&negotiated
),
Some(3),
"and must keep being answered at that cadence"
);
}
#[test]
fn a_zero_burst_refuses_every_echo_and_a_zero_interval_refuses_none() {
// The two zero cases are deliberately not the same. A zero burst is no
// allowance at all; a zero interval is no waiting for allowance.
let (mut core, clock, token, negotiated) = manual_core(
ServerConfig::default().with_burst_allowance(0),
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
for (offset, sequence) in [(0, 0), (SECOND, 1), (60 * SECOND, 2)] {
assert!(
echo_at(
&mut core,
&clock,
offset,
token,
sequence,
&negotiated,
None
)
.is_none(),
"a zero burst answers nothing, however long the wait"
);
}
let (mut core, clock, token, negotiated) = manual_core(
ServerConfig::default().with_min_send_interval(Duration::ZERO),
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
for sequence in 0..12 {
assert_eq!(
count_of(
echo_at(&mut core, &clock, 0, token, sequence, &negotiated, None),
&negotiated
),
Some(sequence + 1),
"a zero interval never withholds allowance, whatever the burst"
);
}
}
#[test]
fn sibling_sessions_hold_their_allowance_independently() {
let config = ServerConfig::default()
.with_min_send_interval(Duration::from_millis(100))
.with_burst_allowance(1);
let clock = ManualClock::at(0);
let mut core = core_with_sources(
config,
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
clock.clone(),
);
let requested = params_at(SECOND);
let (token_a, negotiated) = open_negotiated(&mut core, peer(), &requested, None);
let (token_b, _) = open_negotiated(&mut core, other_peer(), &requested, None);
// The first session spends its whole allowance and is then limited.
assert!(echo_at(&mut core, &clock, 0, token_a, 0, &negotiated, None).is_some());
assert!(echo_at(&mut core, &clock, 0, token_a, 1, &negotiated, None).is_none());
// The second still has its own, from its own endpoint.
clock.set(0);
let packet = core
.handle_datagram(
other_peer(),
&echo_request(token_b, 0, &negotiated, &[], None),
)
.unwrap()
.expect("a sibling session's allowance is its own");
assert_eq!(
expect_echo_reply(&packet, &negotiated, None).recv_count,
Some(1)
);
}
-96
View File
@@ -1,96 +0,0 @@
use super::support::{
client_params, core_with_tokens, expect_normal_open_reply, open_request, other_peer, peer,
ScriptedTokens,
};
use crate::{ServerConfig, DEFAULT_MAX_SESSIONS};
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
const TOKEN_C: u64 = 0x2122_2324_2526_2728;
#[test]
fn the_default_session_bound_is_finite() {
let bound = ServerConfig::default().max_sessions();
assert_eq!(bound, DEFAULT_MAX_SESSIONS);
assert!(bound > 0, "the default bound must admit sessions");
assert!(
bound < usize::MAX,
"there is deliberately no unlimited default"
);
}
#[test]
fn repeated_opens_from_one_endpoint_create_independent_sessions() {
// Opens are never deduplicated by endpoint or by parameters: a client
// retransmission legitimately produces a second, independent session.
let mut core = core_with_tokens(
ServerConfig::default(),
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
);
let request = open_request(&client_params(), None);
let first = core
.handle_datagram(peer(), &request)
.unwrap()
.expect("first open must be answered");
let second = core
.handle_datagram(peer(), &request)
.unwrap()
.expect("second open must be answered");
let first = expect_normal_open_reply(&first, None);
let second = expect_normal_open_reply(&second, None);
assert_ne!(first.token, 0);
assert_ne!(second.token, 0);
assert_ne!(first.token, second.token);
assert_eq!(core.session_count(), 2);
assert_eq!(core.session(first.token).unwrap().peer(), peer());
assert_eq!(core.session(second.token).unwrap().peer(), peer());
}
#[test]
fn a_full_session_table_drops_new_opens_without_evicting_anything() {
let tokens = ScriptedTokens::new([TOKEN_A, TOKEN_B, TOKEN_C]);
let mut core = core_with_tokens(ServerConfig::default().with_max_sessions(2), tokens.clone());
let request = open_request(&client_params(), None);
core.handle_datagram(peer(), &request)
.unwrap()
.expect("first open must be answered");
core.handle_datagram(other_peer(), &request)
.unwrap()
.expect("second open must be answered");
assert_eq!(core.session_count(), 2);
assert_eq!(core.session(TOKEN_A).unwrap().peer(), peer());
assert_eq!(core.session(TOKEN_B).unwrap().peer(), other_peer());
// Refusal is silence: the protocol has no rejection reply, so the client
// simply sees an open timeout.
assert_eq!(core.handle_datagram(peer(), &request).unwrap(), None);
assert_eq!(core.session_count(), 2, "nothing may be evicted");
assert_eq!(core.session(TOKEN_A).unwrap().peer(), peer());
assert_eq!(core.session(TOKEN_B).unwrap().peer(), other_peer());
assert_eq!(core.session(TOKEN_C), None);
assert_eq!(
tokens.remaining(),
1,
"a refused open must not commit a token"
);
}
#[test]
fn a_zero_session_bound_refuses_every_session_creating_open() {
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(ServerConfig::default().with_max_sessions(0), tokens.clone());
assert_eq!(
core.handle_datagram(peer(), &open_request(&client_params(), None))
.unwrap(),
None
);
assert_eq!(core.session_count(), 0);
assert_eq!(tokens.remaining(), 1);
}
+7 -392
View File
@@ -1,55 +1,14 @@
use crate::{token::TokenSource, ServerConfig, ServerCore, ServerError};
use irtt_proto::{
decode_open_reply, encode_request, OpenReply, Params, RequestToEncode, FLAG_HMAC, FLAG_OPEN,
FLAG_REPLY,
};
use std::{
collections::VecDeque,
net::SocketAddr,
sync::{Arc, Mutex},
time::Duration,
};
use irtt_proto::{
decode_echo_reply, decode_open_reply, encode_request, varint, EchoReply, OpenReply,
PacketLayout, Params, RequestToEncode, FLAG_CLOSE, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY,
};
use crate::{
clock::{ClockSample, ClockSource},
core::{OutboundDatagram, ServerCore},
error::ServerError,
token::TokenSource,
ServerConfig,
};
pub(crate) const KEY: &[u8] = b"correctkey";
pub(crate) const OTHER_KEY: &[u8] = b"wrongkey";
/// A default configuration with the minimum send interval disabled.
///
/// One knob, two consequences, and tests about neither of them want both gone:
/// no requested interval is raised during negotiation, and no otherwise
/// admissible echo ever waits for rate allowance. That keeps a test about
/// parameter pass-through, receive statistics or timestamps from silently
/// becoming a test of interval policy — and, since these tests drive the core as
/// fast as the machine will run, from being rate-limited by a floor they never
/// meant to configure.
///
/// Tests that *are* about interval negotiation, rate limiting or lifetime build
/// their configuration explicitly.
pub(crate) fn unthrottled() -> ServerConfig {
ServerConfig::default().with_min_send_interval(Duration::ZERO)
}
pub(crate) fn peer() -> SocketAddr {
"198.51.100.7:41234".parse().unwrap()
}
pub(crate) fn other_peer() -> SocketAddr {
"198.51.100.7:41235".parse().unwrap()
}
/// A token source handing out a fixed script of values, then failing.
///
/// Draining the script is itself a failure rather than a wrap-around, so a test
/// that draws more tokens than it scripted is a loud test bug instead of a
/// silent repeat.
#[derive(Debug, Clone)]
pub(crate) struct ScriptedTokens {
values: Arc<Mutex<VecDeque<Result<u64, ServerError>>>>,
@@ -71,11 +30,6 @@ impl ScriptedTokens {
values: Arc::new(Mutex::new(VecDeque::new())),
}
}
/// How many scripted values are still unused.
pub(crate) fn remaining(&self) -> usize {
self.values.lock().unwrap().len()
}
}
impl TokenSource for ScriptedTokens {
@@ -90,263 +44,14 @@ impl TokenSource for ScriptedTokens {
}
}
/// 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 per authenticated request — when it classifies a datagram as
/// an echo, or after authenticating an open or close — and one more when an
/// admitted echo is about to be answered.
///
/// Tests about lifetime and rate want [`ManualClock`] instead: they care about
/// *when* a datagram arrives, not about how many times the core reads the clock
/// on the way.
#[derive(Debug, Clone)]
pub(crate) struct ScriptedClock {
samples: Arc<Mutex<VecDeque<ClockSample>>>,
pub(crate) fn peer() -> SocketAddr {
"198.51.100.7:41234".parse().unwrap()
}
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 clock standing still at an instant the test moves by hand.
///
/// Lifetime and rate behavior is a question of when a datagram arrives, so these
/// tests set the time, send a packet, and set it again — with no sleeps, no
/// tolerances and nothing riding on how many times the core reads the clock.
/// [`ScriptedClock`] remains for the timestamp tests, where every individual
/// reading is the point.
///
/// Both domains read the same value. Lifetime decisions are monotonic-only by
/// design, and no test here asserts a wall timestamp, so keeping the pair equal
/// makes the readings obviously ordered.
#[derive(Debug, Clone)]
pub(crate) struct ManualClock {
now_ns: Arc<Mutex<i64>>,
}
impl ManualClock {
pub(crate) fn at(now_ns: i64) -> Self {
Self {
now_ns: Arc::new(Mutex::new(now_ns)),
}
}
/// Moves the clock to an exact instant. Deliberately absolute: a test that
/// pins a deadline reads better stating the instant than accumulating
/// offsets.
pub(crate) fn set(&self, now_ns: i64) {
*self.now_ns.lock().unwrap() = now_ns;
}
}
impl ClockSource for ManualClock {
fn sample(&mut self) -> ClockSample {
let now_ns = *self.now_ns.lock().unwrap();
ClockSample {
wall_ns: now_ns,
mono_ns: now_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 chosen, for timestamp, rate and
/// lifetime tests.
pub(crate) fn core_with_sources(
config: ServerConfig,
tokens: ScriptedTokens,
clock: impl ClockSource + 'static,
) -> 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
/// tests loop over both and build the core from the key.
pub(crate) fn core_for(hmac_key: Option<&[u8]>, tokens: ScriptedTokens) -> ServerCore {
let mut config = unthrottled();
if let Some(key) = hmac_key {
config = config.with_hmac_key(key);
}
core_with_tokens(config, tokens)
}
/// A core whose clock the test moves by hand, with one session already open.
///
/// The clock starts at monotonic zero and the session is opened there, so every
/// instant a rate or lifetime test names reads as an offset from the open.
pub(crate) fn manual_core(
config: ServerConfig,
requested: &Params,
tokens: ScriptedTokens,
hmac_key: Option<&[u8]>,
) -> (ServerCore, ManualClock, u64, Params) {
let clock = ManualClock::at(0);
let mut core = core_with_sources(config, tokens, clock.clone());
let (token, negotiated) = open_negotiated(&mut core, peer(), requested, hmac_key);
(core, clock, token, negotiated)
}
/// Sends one echo as it would arrive at monotonic instant `at_ns`.
pub(crate) fn echo_at(
core: &mut ServerCore,
clock: &ManualClock,
at_ns: i64,
token: u64,
sequence: u32,
params: &Params,
hmac_key: Option<&[u8]>,
) -> Option<OutboundDatagram> {
clock.set(at_ns);
core.handle_datagram(
peer(),
&echo_request(token, sequence, params, &[], hmac_key),
)
.expect("a rejected or rate-limited echo is not an internal error")
}
/// Opens one ordinary session from `endpoint` and returns its token.
pub(crate) fn open_session(
core: &mut ServerCore,
endpoint: SocketAddr,
hmac_key: Option<&[u8]>,
) -> u64 {
let packet = core
.handle_datagram(endpoint, &open_request(&client_params(), hmac_key))
.unwrap()
.expect("the session-creating open must be answered");
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: impl AsRef<[u8]>,
params: &Params,
hmac_key: Option<&[u8]>,
) -> EchoReply {
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.
let expected = FLAG_REPLY | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
assert_eq!(reply.flags, expected, "echo reply flags");
reply
}
/// Decodes a server-initiated close: an ordinary echo reply carrying Close.
///
/// The flags are compared exactly, so Open is pinned clear — a reply with it set
/// makes an upstream client abort — and the reply is decoded and its MAC checked
/// 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: impl AsRef<[u8]>,
params: &Params,
hmac_key: Option<&[u8]>,
) -> EchoReply {
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
}
/// Encodes a normal open request carrying `params`.
pub(crate) fn open_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec<u8> {
encode_request(
RequestToEncode::Open {
@@ -358,86 +63,6 @@ pub(crate) fn open_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec<u8>
.unwrap()
}
/// Encodes a no-test open request carrying `params`.
pub(crate) fn no_test_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec<u8> {
encode_request(
RequestToEncode::Open {
params,
no_test: true,
},
hmac_key,
)
.unwrap()
}
/// Encodes a close request for `token`.
pub(crate) fn close_request(token: u64, hmac_key: Option<&[u8]>) -> Vec<u8> {
encode_request(RequestToEncode::Close { token }, hmac_key).unwrap()
}
/// Encodes a close request carrying arbitrary bytes after the token.
///
/// The decoder tolerates them, and this is the shape that proves it. The MAC
/// covers the whole datagram, so it is recomputed over the final form rather
/// than left behind by the encoder's shorter packet.
pub(crate) fn close_request_with_trailing(
token: u64,
trailing: &[u8],
hmac_key: Option<&[u8]>,
) -> Vec<u8> {
let mut packet = close_request(token, hmac_key);
packet.extend_from_slice(trailing);
if let Some(key) = hmac_key {
irtt_proto::compute_hmac_in_place(key, &mut packet, hmac_field_offset()).unwrap();
}
packet
}
/// Builds an open request around a raw parameter payload.
///
/// Used for payloads a compliant encoder cannot produce, such as a truncated
/// varint or an out-of-range enum value.
pub(crate) fn open_request_with_raw_params(payload: &[u8], hmac_key: Option<&[u8]>) -> Vec<u8> {
let mut packet = irtt_proto::MAGIC.to_vec();
packet.push(if hmac_key.is_some() {
FLAG_OPEN | FLAG_HMAC
} else {
FLAG_OPEN
});
if hmac_key.is_some() {
packet.extend_from_slice(&[0; irtt_proto::HMAC_SIZE]);
}
packet.extend_from_slice(payload);
if let Some(key) = hmac_key {
irtt_proto::compute_hmac_in_place(key, &mut packet, hmac_field_offset()).unwrap();
}
packet
}
/// The authentication field sits immediately after the fixed header.
fn hmac_field_offset() -> usize {
PacketLayout::open_request(false).header_len()
}
/// Encodes one integer parameter as `tag` + zigzag value.
pub(crate) fn param_int(tag: u64, value: i64) -> Vec<u8> {
let mut encoded = Vec::new();
varint::encode_uvarint(tag, &mut encoded);
varint::encode_varint(value, &mut encoded);
encoded
}
/// Encodes a `server_fill` parameter with an explicit declared length, so tests
/// can declare a length the payload does not actually carry.
pub(crate) fn param_server_fill(declared_len: u64, value: &[u8]) -> Vec<u8> {
let mut encoded = Vec::new();
varint::encode_uvarint(9, &mut encoded);
varint::encode_uvarint(declared_len, &mut encoded);
encoded.extend_from_slice(value);
encoded
}
/// Params a well-behaved client would request.
pub(crate) fn client_params() -> Params {
Params {
protocol_version: 1,
@@ -452,7 +77,6 @@ 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: impl AsRef<[u8]>, hmac_key: Option<&[u8]>) -> OpenReply {
decode_open_reply(packet.as_ref(), hmac_key).expect("server reply must decode")
}
@@ -468,12 +92,3 @@ pub(crate) fn expect_normal_open_reply(
assert_eq!(reply.params.protocol_version, 1);
reply
}
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 };
assert_eq!(reply.flags, expected, "no-test reply flags");
assert_eq!(reply.token, 0, "a no-test reply carries a zero token");
reply
}
-8
View File
@@ -34,7 +34,6 @@ fn a_colliding_draw_is_discarded_and_never_overwrites_the_live_session() {
core.handle_datagram(peer(), &request)
.unwrap()
.expect("first open must be answered");
let first = core.session(TOKEN_A).cloned().expect("session must exist");
let packet = core
.handle_datagram(peer(), &request)
@@ -43,11 +42,6 @@ fn a_colliding_draw_is_discarded_and_never_overwrites_the_live_session() {
assert_eq!(expect_normal_open_reply(&packet, None).token, TOKEN_B);
assert_eq!(core.session_count(), 2);
assert_eq!(
core.session(TOKEN_A),
Some(&first),
"the colliding token's session must be untouched"
);
}
#[test]
@@ -61,7 +55,6 @@ fn exhausting_the_retry_budget_is_an_internal_error_that_creates_nothing() {
core.handle_datagram(peer(), &request)
.unwrap()
.expect("first open must be answered");
let first = core.session(TOKEN_A).cloned().expect("session must exist");
assert_eq!(
core.handle_datagram(peer(), &request),
@@ -71,7 +64,6 @@ fn exhausting_the_retry_budget_is_an_internal_error_that_creates_nothing() {
);
assert_eq!(core.session_count(), 1, "no session may be half-created");
assert_eq!(core.session(TOKEN_A), Some(&first));
}
#[test]
@@ -1,134 +0,0 @@
//! 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");
}
}