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

+19 -13
View File
@@ -333,9 +333,9 @@ effective parameters must be safe for the server to run.
- The transition is pure and is committed only after the reply has encoded, so
an internal encoding failure cannot leave a session claiming to have answered
a request it never did.
- Clock sampling is a private injected seam, like token generation, not a
runtime abstraction: the receive instant is taken as soon as a datagram is
classified as an echo and the send instant just before the reply is built, so
- Clock sampling uses the system clock directly, not a runtime abstraction: the
receive instant is taken as soon as a datagram is classified as an echo and
the send instant just before the reply is built, so
they bracket the server's own handling. The monotonic origin belongs to the
clock source and is stable for its life.
- A reply's receive instant is held back to its send instant where the wall
@@ -601,16 +601,22 @@ half-created session.
maintain shadow encoders for well-formed wire data.
- Hand-build packets only where the point is a payload a compliant encoder
cannot produce — truncated varints, out-of-range enums, corrupted MACs.
- Token generation and clock sampling are the only nondeterministic parts; tests
inject a scripted source for each, so identity, collisions, allocation failure
and timestamp values are all assertable. Keep both seams private, and keep
timestamp, rate and lifetime tests free of sleeps, tolerances and real
wall-clock assertions.
- Two clock fakes, for two different questions. A scripted clock returns a fixed
list of samples and is for tests that assert individual timestamp *readings*; a
hand-moved clock stands still until the test moves it, and is for rate and
lifetime tests, which care when a datagram arrived and not how many times the
core read the clock on the way. Prefer the latter for anything about deadlines.
- Prefer the public `ServerCore` packet interface and real runtime sockets for
durable behavior. Use production encoders and decoders, actual OS-generated
tokens, and observable replies/session capacity rather than private state.
- Observe nonzero rate refill and lifetime deadlines with real time when an
oversleep cannot invalidate the assertion. Bound observation loops and report
useful failures. Do not restore fake clocks, random-source injection, race
gates, synthetic ancillary classifiers or private observability merely to
reproduce the deleted test structure. Focused fault injection remains allowed
under the root policy only when no reasonable production-interface alternative
can protect an important invariant.
- Random fill's OS-source failure cannot be forced through the packet interface.
Its zero-fill fallback remains an explicitly unexercised error path; ordinary
random-fill wire tests must not claim to cover it or add an injection seam
merely for coverage. Existing token-allocation fault tests protect bounded
allocation and transactional failure, which cannot be driven reliably with
actual random draws.
- Assert rate and lifetime behavior from replies: which datagrams came back, the
count and window they reported, and `session_count`. The token bucket is a
black-box inference about the reference server, not a protocol requirement, so
+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");
}
}
@@ -0,0 +1,377 @@
//! Durable server contracts observed through encoded requests and public replies.
use std::{
net::SocketAddr,
thread,
time::{Duration, Instant},
};
use irtt_proto::{
decode_echo_reply, decode_open_reply, encode_request, Clock, EchoReply, OpenReply, Params,
ReceivedStats, RequestToEncode, StampAt, FLAG_CLOSE,
};
use irtt_server::{ServerConfig, ServerCore, TimestampAllowance};
fn peer() -> SocketAddr {
"127.0.0.1:40000".parse().unwrap()
}
fn open_packet(params: &Params, no_test: bool, key: Option<&[u8]>) -> Vec<u8> {
encode_request(RequestToEncode::Open { params, no_test }, key).unwrap()
}
fn open(server: &mut ServerCore, params: &Params) -> OpenReply {
let reply = server
.handle_datagram(peer(), &open_packet(params, false, None))
.unwrap()
.unwrap();
decode_open_reply(reply.bytes(), None).unwrap()
}
fn echo(server: &mut ServerCore, opened: &OpenReply, sequence: u32) -> Option<EchoReply> {
let request = encode_request(
RequestToEncode::Echo {
token: opened.token,
sequence,
params: &opened.params,
payload: &[],
},
None,
)
.unwrap();
server
.handle_datagram(peer(), &request)
.unwrap()
.map(|reply| decode_echo_reply(reply.bytes(), &opened.params, None).unwrap())
}
fn unthrottled() -> ServerConfig {
ServerConfig::default().with_min_send_interval(Duration::ZERO)
}
#[test]
fn no_test_authenticates_negotiates_and_uses_no_session_capacity() {
let key = b"no-test-contract";
let mut server = ServerCore::new(
ServerConfig::default()
.with_hmac_key(key)
.with_max_sessions(0)
.with_max_packet_length(96)
.with_min_send_interval(Duration::from_millis(50))
.with_timestamp_allowance(TimestampAllowance::Single)
.with_dscp_allowed(false),
);
let params = Params {
protocol_version: 99,
length: 128,
dscp: 185,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::default()
};
let request = open_packet(&params, true, Some(key));
assert!(server
.handle_datagram(peer(), &open_packet(&params, true, None))
.unwrap()
.is_none());
let mut bad_mac = request.clone();
bad_mac[4] ^= 1;
assert!(server.handle_datagram(peer(), &bad_mac).unwrap().is_none());
let reply = server.handle_datagram(peer(), &request).unwrap().unwrap();
assert_eq!(reply.traffic_class(), 0);
let negotiated = decode_open_reply(reply.bytes(), Some(key)).unwrap();
assert_eq!(negotiated.token, 0);
assert_eq!(negotiated.params.protocol_version, 1);
assert_ne!(negotiated.flags & FLAG_CLOSE, 0);
assert_eq!(negotiated.params.length, 96);
assert_eq!(negotiated.params.interval_ns, 50_000_000);
assert_eq!(negotiated.params.stamp_at, StampAt::Midpoint);
assert_eq!(negotiated.params.clock, Clock::Both);
assert_eq!(negotiated.params.dscp, 0);
assert_eq!(server.session_count(), 0);
assert!(server
.handle_datagram(peer(), &open_packet(&params, false, Some(key)))
.unwrap()
.is_none());
}
#[test]
fn no_test_refuses_unexecutable_layout_but_validates_after_restriction() {
let params = Params {
stamp_at: StampAt::Both,
..Params::with_protocol_defaults()
};
let mut server = ServerCore::new(unthrottled());
assert!(server
.handle_datagram(peer(), &open_packet(&params, true, None))
.unwrap()
.is_none());
let mut restricted =
ServerCore::new(unthrottled().with_timestamp_allowance(TimestampAllowance::None));
let reply = restricted
.handle_datagram(peer(), &open_packet(&params, true, None))
.unwrap()
.unwrap();
assert_eq!(
decode_open_reply(reply.bytes(), None)
.unwrap()
.params
.stamp_at,
StampAt::None
);
let rich = Params {
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::with_protocol_defaults()
};
let mut too_small = ServerCore::new(unthrottled().with_max_packet_length(32));
assert!(too_small
.handle_datagram(peer(), &open_packet(&rich, true, None))
.unwrap()
.is_none());
assert_eq!(too_small.session_count(), 0);
let invalid = Params {
interval_ns: -1,
..Params::with_protocol_defaults()
};
assert!(server
.handle_datagram(peer(), &open_packet(&invalid, true, None))
.unwrap()
.is_none());
assert_eq!(server.session_count(), 0);
}
#[test]
fn receive_statistics_follow_verified_gap_duplicate_reordering_and_wrap_vectors() {
// SERVER_BEHAVIORAL_VECTORS.md Sections 1.2, 1.5, 1.6, 1.8 and 1.10.
let vectors: &[&[(u32, u64)]] = &[
&[(0, 1), (1, 3), (3, 13), (4, 27)],
&[(0, 1), (1, 3), (1, 3), (2, 7)],
&[(0, 1), (1, 3), (2, 7), (1, 1), (3, 5)],
&[(0, 1), (63, 0x8000_0000_0000_0001)],
&[(0, 1), (64, 1)],
&[
(u32::MAX - 2, 1),
(u32::MAX - 1, 3),
(u32::MAX, 7),
(0, 15),
(1, 31),
],
];
for vector in vectors {
let mut server = ServerCore::new(unthrottled());
let opened = open(
&mut server,
&Params {
received_stats: ReceivedStats::Both,
..Params::with_protocol_defaults()
},
);
for (index, &(sequence, window)) in vector.iter().enumerate() {
let reply = echo(&mut server, &opened, sequence).unwrap();
assert_eq!(reply.recv_count, Some(index as u32 + 1));
assert_eq!(
reply.recv_window,
Some(window),
"sequence {sequence} in {vector:?}"
);
}
}
}
#[test]
fn a_spent_nonzero_burst_refills_without_counting_dropped_echoes() {
let refill = Duration::from_millis(100);
let mut server = ServerCore::new(
ServerConfig::default()
.with_min_send_interval(refill)
.with_burst_allowance(1)
.with_idle_timeout(Duration::MAX),
);
let opened = open(
&mut server,
&Params {
received_stats: ReceivedStats::Both,
..Params::with_protocol_defaults()
},
);
let mut count = echo(&mut server, &opened, 0).unwrap().recv_count.unwrap();
// Observe actual refusal rather than assuming two calls fit a timing window.
let deadline = Instant::now() + Duration::from_secs(2);
while let Some(reply) = echo(&mut server, &opened, 0) {
count = reply.recv_count.unwrap();
assert!(
Instant::now() < deadline,
"could not observe a spent burst within two seconds"
);
}
thread::sleep(refill); // Oversleep only replenishes more; idle expiry is disabled in practice.
let reply = echo(&mut server, &opened, 1).unwrap();
assert_eq!(reply.recv_count, Some(count + 1));
assert_eq!(reply.recv_window, Some(3));
}
#[test]
fn a_nonzero_idle_deadline_reclaims_an_unused_session_before_another_open() {
let idle = Duration::from_millis(10);
let mut server = ServerCore::new(unthrottled().with_max_sessions(1).with_idle_timeout(idle));
let params = Params::with_protocol_defaults();
let old = open(&mut server, &params);
thread::sleep(idle); // Any oversleep still requires silent expiry and capacity recovery.
assert!(echo(&mut server, &old, 0).is_none());
assert_eq!(server.session_count(), 0);
open(&mut server, &params);
assert_eq!(server.session_count(), 1);
}
#[test]
fn maximum_duration_starts_at_the_first_served_echo_and_closes_with_its_reply() {
let maximum = Duration::from_millis(1);
let mut server = ServerCore::new(
unthrottled()
.with_idle_timeout(Duration::MAX)
.with_max_test_duration(maximum),
);
let opened = open(
&mut server,
&Params {
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
dscp: 185,
..Params::with_protocol_defaults()
},
);
assert_eq!(opened.params.duration_ns, 1_000_000);
let elapsed_deadline = maximum + Duration::from_secs(2);
thread::sleep(elapsed_deadline);
let first = echo(&mut server, &opened, 0).unwrap();
assert_eq!(
first.flags & FLAG_CLOSE,
0,
"OPEN must not start the maximum-duration clock"
);
thread::sleep(elapsed_deadline); // Oversleep still requires the next served echo to close.
let request = encode_request(
RequestToEncode::Echo {
token: opened.token,
sequence: 1,
params: &opened.params,
payload: &[],
},
None,
)
.unwrap();
let reply = server.handle_datagram(peer(), &request).unwrap().unwrap();
assert_eq!(reply.traffic_class(), 185);
let closed = decode_echo_reply(reply.bytes(), &opened.params, None).unwrap();
assert_ne!(closed.flags & FLAG_CLOSE, 0);
assert_eq!(closed.sequence, 1);
assert_eq!(closed.recv_count, Some(2));
assert_eq!(closed.recv_window, Some(3));
assert!(closed.timestamps.recv_wall.is_some());
assert!(closed.timestamps.send_mono.is_some());
assert_eq!(server.session_count(), 0);
assert!(echo(&mut server, &opened, 2).is_none());
}
#[test]
fn negotiated_timestamp_modes_emit_only_the_selected_fields_in_order() {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
for placement in [
StampAt::None,
StampAt::Receive,
StampAt::Send,
StampAt::Both,
StampAt::Midpoint,
] {
let mut server = ServerCore::new(unthrottled());
let opened = open(
&mut server,
&Params {
stamp_at: placement,
clock,
..Params::with_protocol_defaults()
},
);
let t = echo(&mut server, &opened, 0).unwrap().timestamps;
let wall = clock != Clock::Monotonic;
let mono = clock != Clock::Wall;
let recv = matches!(placement, StampAt::Receive | StampAt::Both);
let send = matches!(placement, StampAt::Send | StampAt::Both);
let midpoint = placement == StampAt::Midpoint;
assert_eq!(t.recv_wall.is_some(), wall && recv);
assert_eq!(t.recv_mono.is_some(), mono && recv);
assert_eq!(t.send_wall.is_some(), wall && send);
assert_eq!(t.send_mono.is_some(), mono && send);
assert_eq!(t.midpoint_wall.is_some(), wall && midpoint);
assert_eq!(t.midpoint_mono.is_some(), mono && midpoint);
if let (Some(recv), Some(send)) = (t.recv_wall, t.send_wall) {
assert!(recv <= send);
}
if let (Some(recv), Some(send)) = (t.recv_mono, t.send_mono) {
assert!(0 <= recv && recv <= send);
let next = echo(&mut server, &opened, 1).unwrap().timestamps;
assert!(
next.recv_mono.unwrap() >= send,
"monotonic origin must remain stable"
);
}
}
}
}
#[test]
fn duration_close_waits_for_rate_allowance_after_the_deadline() {
let refill = Duration::from_secs(4);
let maximum = Duration::from_millis(1);
for _ in 0..3 {
let mut server = ServerCore::new(
ServerConfig::default()
.with_min_send_interval(refill)
.with_burst_allowance(1)
.with_idle_timeout(Duration::MAX)
.with_max_test_duration(maximum),
);
let started = Instant::now();
let opened = open(
&mut server,
&Params {
received_stats: ReceivedStats::Both,
..Params::with_protocol_defaults()
},
);
let first = echo(&mut server, &opened, 0).unwrap();
assert_eq!(first.flags & FLAG_CLOSE, 0);
thread::sleep(maximum + Duration::from_secs(2));
let refused = echo(&mut server, &opened, 63);
// Include open/setup and the request itself in the elapsed bound. If the
// scheduler carried us past refill, the reply may legitimately close.
if started.elapsed() >= refill {
continue;
}
assert!(
refused.is_none(),
"exhausted allowance must suppress the deadline-crossing reply"
);
assert_eq!(
server.session_count(),
1,
"rate refusal must not release the session"
);
thread::sleep(refill);
let closed = echo(&mut server, &opened, 1).unwrap();
assert_ne!(closed.flags & FLAG_CLOSE, 0);
assert_eq!(closed.sequence, 1);
assert_eq!(closed.recv_count, Some(2));
assert_eq!(closed.recv_window, Some(3));
assert_eq!(server.session_count(), 0);
return;
}
panic!("scheduler crossed the refill boundary in all three fresh sessions");
}
#[test]
fn default_server_resource_limits_remain_bounded() {
let config = ServerConfig::default();
// Upper resource budgets, not an exact default or a comparison against
// the same constants used by the implementation. Smaller bounds are fine.
assert!((1..=1024).contains(&config.max_sessions()));
assert!((1..=65_507).contains(&config.max_packet_length()));
}
+283
View File
@@ -0,0 +1,283 @@
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 irtt_server::{Server, ServerConfig, ServerRuntimeError};
use std::{io, net::SocketAddr};
#[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();
}
#[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; 65_536];
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)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "freebsd")))]
#[tokio::test(flavor = "current_thread")]
async fn wildcard_construction_is_refused_without_reply_source_selection() {
let addr = "0.0.0.0:0".parse().unwrap();
assert!(matches!(
Server::bind(addr, unthrottled()).await,
Err(ServerRuntimeError::WildcardSourceSelectionUnsupported { .. })
));
}
// A current-thread runtime cannot receive while this test holds its thread.
// Queue real packets before delaying the server: no synthetic ancillary data or
// transport hooks. Observed queue timing determines whether kernel wall time
// is plausible under the one-second bound, including scheduler overshoot. Monotonic and
// midpoint timestamps must always remain at the later userspace observation.
#[cfg(target_os = "linux")]
#[tokio::test(flavor = "current_thread")]
async fn queued_echoes_select_kernel_receive_wall_only_when_plausible() {
use std::time::{SystemTime, UNIX_EPOCH};
let ns = || {
i64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos(),
)
.unwrap()
};
for (placement, delay) in [
(StampAt::Both, Duration::from_millis(100)),
(StampAt::Both, Duration::from_millis(1100)),
(StampAt::Midpoint, Duration::from_millis(100)),
] {
let mut server = Server::bind("127.0.0.1:0".parse().unwrap(), unthrottled())
.await
.unwrap();
let addr = server.local_addr().unwrap();
let client = UdpSocket::bind("127.0.0.1:0").await.unwrap();
client.connect(addr).await.unwrap();
let (stop_tx, stop_rx) = oneshot::channel();
let run = tokio::spawn(async move {
server
.run(async {
let _ = stop_rx.await;
})
.await
});
let params = Params {
stamp_at: placement,
clock: Clock::Both,
received_stats: ReceivedStats::Both,
..Params::with_protocol_defaults()
};
let open = encode_request(
RequestToEncode::Open {
params: &params,
no_test: false,
},
None,
)
.unwrap();
client.send(&open).await.unwrap();
let mut buffer = [0; 2048];
let len = timeout(Duration::from_secs(2), client.recv(&mut buffer))
.await
.unwrap()
.unwrap();
let opened = decode_open_reply(&buffer[..len], None).unwrap();
let echo = encode_request(
RequestToEncode::Echo {
token: opened.token,
sequence: 0,
params: &opened.params,
payload: &[],
},
None,
)
.unwrap();
client.writable().await.unwrap();
let before_send = ns();
client.try_send(&echo).unwrap(); // No await from submission through resume marker.
let queued_by = ns();
std::thread::sleep(delay);
let resumed_at = ns();
let len = timeout(Duration::from_secs(2), client.recv(&mut buffer))
.await
.unwrap()
.unwrap();
let reply = decode_echo_reply(&buffer[..len], &opened.params, None).unwrap();
assert_eq!(reply.recv_count, Some(1));
assert_eq!(reply.recv_window, Some(1));
let timestamps = reply.timestamps;
if placement == StampAt::Both {
let receive = timestamps.recv_wall.unwrap();
let send = timestamps.send_wall.unwrap();
let kernel_endpoint = (before_send..=queued_by).contains(&receive);
let userspace_endpoint = (resumed_at..=send).contains(&receive);
// Arrival lies in [before_send, queued_by], and core observation
// lies in [resumed_at, send]. Only demand one selection where these
// observed bounds put the whole lag interval on one side of 1 s.
// Scheduler overshoot can legitimately change the expected path.
if send - before_send <= 1_000_000_000 {
assert!(
kernel_endpoint,
"plausible arrival must select kernel wall time"
);
} else if resumed_at - queued_by > 1_000_000_000 {
assert!(
userspace_endpoint,
"stale arrival must fall back to userspace"
);
} else {
assert!(
kernel_endpoint || userspace_endpoint,
"boundary observation must identify a real endpoint"
);
}
if kernel_endpoint {
let wall_elapsed = send - receive;
let mono_elapsed = timestamps.send_mono.unwrap() - timestamps.recv_mono.unwrap();
assert!(
wall_elapsed - mono_elapsed >= 50_000_000,
"queue delay must not move the monotonic endpoint"
);
}
} else {
assert!(
timestamps.midpoint_wall.unwrap() >= resumed_at,
"midpoint must use userspace time"
);
}
stop_tx.send(()).unwrap();
run.await.unwrap().unwrap();
}
}
+8
View File
@@ -465,3 +465,11 @@ where
.await
.expect("the test exceeded its bounded runtime");
}
#[tokio::test(flavor = "current_thread")]
async fn a_set_serving_nothing_is_refused() {
assert!(matches!(
ServerSet::bind([], ServerConfig::default()).await,
Err(ServerSetError::NoListeners)
));
}
@@ -0,0 +1,264 @@
use std::{
net::{SocketAddr, SocketAddrV6},
time::Duration,
};
use irtt_proto::{decode_echo_reply, decode_open_reply, encode_request, Params, RequestToEncode};
use irtt_server::{ServerConfig, ServerCore};
// Admission, authentication, endpoint ownership and capacity are checked through
// the public packet interface, with actual OS-generated session tokens.
#[test]
fn authenticated_sessions_are_bounded_and_owned_by_the_opening_endpoint() {
let key = b"session-behavior";
let peer: SocketAddr = "127.0.0.1:40000".parse().unwrap();
let other: SocketAddr = "127.0.0.1:40001".parse().unwrap();
let mut server = ServerCore::new(
ServerConfig::default()
.with_hmac_key(key)
.with_max_sessions(1)
.with_min_send_interval(Duration::ZERO),
);
let params = Params {
received_stats: irtt_proto::ReceivedStats::Both,
..Params::with_protocol_defaults()
};
let open = encode_request(
RequestToEncode::Open {
params: &params,
no_test: false,
},
Some(key),
)
.unwrap();
let mut unkeyed = ServerCore::new(ServerConfig::default());
assert!(unkeyed.handle_datagram(peer, &open).unwrap().is_none());
assert_eq!(unkeyed.session_count(), 0);
let unauthenticated = encode_request(
RequestToEncode::Open {
params: &params,
no_test: false,
},
None,
)
.unwrap();
for packet in [
&[][..],
&[0; 16][..],
&open[..open.len() - 1],
&unauthenticated,
] {
assert!(server.handle_datagram(peer, packet).unwrap().is_none());
assert_eq!(server.session_count(), 0);
}
let reply = server.handle_datagram(peer, &open).unwrap().unwrap();
let opened = decode_open_reply(reply.bytes(), Some(key)).unwrap();
assert_ne!(opened.token, 0);
assert!(server.handle_datagram(other, &open).unwrap().is_none());
assert_eq!(server.session_count(), 1);
let echo = encode_request(
RequestToEncode::Echo {
token: opened.token,
sequence: 0,
params: &opened.params,
payload: &[],
},
Some(key),
)
.unwrap();
let close = encode_request(
RequestToEncode::Close {
token: opened.token,
},
Some(key),
)
.unwrap();
assert!(server.handle_datagram(other, &echo).unwrap().is_none());
assert!(server.handle_datagram(other, &close).unwrap().is_none());
let mut corrupted = echo.clone();
corrupted[4] ^= 1;
assert!(server.handle_datagram(peer, &corrupted).unwrap().is_none());
let reply = server.handle_datagram(peer, &echo).unwrap().unwrap();
let echoed = decode_echo_reply(reply.bytes(), &opened.params, Some(key)).unwrap();
assert_eq!(echoed.sequence, 0);
assert_eq!(echoed.recv_count, Some(1));
assert!(server.handle_datagram(peer, &close).unwrap().is_none());
assert_eq!(server.session_count(), 0);
assert!(server.handle_datagram(peer, &echo).unwrap().is_none());
assert!(server.handle_datagram(other, &open).unwrap().is_some());
}
// Zero allowance and zero idle lifetime expose the boundary behavior directly,
// without scripting clock reads or inspecting session/rate representations.
#[test]
fn zero_burst_and_zero_idle_timeout_silently_refuse_echoes() {
let peer: SocketAddr = "127.0.0.1:40000".parse().unwrap();
for config in [
ServerConfig::default().with_burst_allowance(0),
ServerConfig::default().with_idle_timeout(Duration::ZERO),
] {
let mut server = ServerCore::new(config);
let params = Params::with_protocol_defaults();
let open = encode_request(
RequestToEncode::Open {
params: &params,
no_test: false,
},
None,
)
.unwrap();
let reply = server.handle_datagram(peer, &open).unwrap().unwrap();
let opened = decode_open_reply(reply.bytes(), None).unwrap();
let echo = encode_request(
RequestToEncode::Echo {
token: opened.token,
sequence: 0,
params: &opened.params,
payload: &[],
},
None,
)
.unwrap();
assert!(server.handle_datagram(peer, &echo).unwrap().is_none());
}
}
#[test]
fn ipv6_flow_labels_share_a_session_but_zones_do_not() {
let endpoint = |flow, scope| {
SocketAddr::V6(SocketAddrV6::new(
"fe80::1234".parse().unwrap(),
40000,
flow,
scope,
))
};
let mut server =
ServerCore::new(ServerConfig::default().with_min_send_interval(Duration::ZERO));
let params = Params {
received_stats: irtt_proto::ReceivedStats::Both,
..Params::with_protocol_defaults()
};
let packet = encode_request(
RequestToEncode::Open {
params: &params,
no_test: false,
},
None,
)
.unwrap();
let reply = server
.handle_datagram(endpoint(1, 2), &packet)
.unwrap()
.unwrap();
let opened = decode_open_reply(reply.bytes(), None).unwrap();
let echo = encode_request(
RequestToEncode::Echo {
token: opened.token,
sequence: 0,
params: &opened.params,
payload: &[],
},
None,
)
.unwrap();
let close = encode_request(
RequestToEncode::Close {
token: opened.token,
},
None,
)
.unwrap();
assert!(server
.handle_datagram(endpoint(1, 3), &echo)
.unwrap()
.is_none());
assert!(server
.handle_datagram(endpoint(1, 3), &close)
.unwrap()
.is_none());
assert_eq!(server.session_count(), 1);
let reply = server
.handle_datagram(endpoint(99, 2), &echo)
.unwrap()
.unwrap();
assert_eq!(
decode_echo_reply(reply.bytes(), &opened.params, None)
.unwrap()
.recv_count,
Some(1)
);
assert!(server
.handle_datagram(endpoint(99, 2), &close)
.unwrap()
.is_none());
assert_eq!(server.session_count(), 0);
}
#[test]
fn oversized_authenticated_echo_does_not_advance_session_state() {
let key = b"oversized-echo-test";
let peer = "127.0.0.1:40000".parse().unwrap();
let mut server = ServerCore::new(
ServerConfig::default()
.with_hmac_key(key)
.with_max_packet_length(96)
.with_burst_allowance(1)
.with_min_send_interval(Duration::from_secs(60))
.with_max_test_duration(Duration::from_millis(1))
.with_idle_timeout(Duration::MAX),
);
let params = Params {
length: 96,
received_stats: irtt_proto::ReceivedStats::Both,
..Params::with_protocol_defaults()
};
let open = encode_request(
RequestToEncode::Open {
params: &params,
no_test: false,
},
Some(key),
)
.unwrap();
let reply = server.handle_datagram(peer, &open).unwrap().unwrap();
let opened = decode_open_reply(reply.bytes(), Some(key)).unwrap();
let oversized_params = Params {
length: 128,
..opened.params.clone()
};
let oversized = encode_request(
RequestToEncode::Echo {
token: opened.token,
sequence: 63,
params: &oversized_params,
payload: &[],
},
Some(key),
)
.unwrap();
assert!(oversized.len() > 96);
assert!(server.handle_datagram(peer, &oversized).unwrap().is_none());
// If the oversized request started maximum duration, the first valid
// echo would now close. Oversleep preserves this distinction.
std::thread::sleep(Duration::from_millis(2001));
let valid = encode_request(
RequestToEncode::Echo {
token: opened.token,
sequence: 0,
params: &opened.params,
payload: &[],
},
Some(key),
)
.unwrap();
let reply = server
.handle_datagram(peer, &valid)
.unwrap()
.expect("oversized request must not consume allowance");
let reply = decode_echo_reply(reply.bytes(), &opened.params, Some(key)).unwrap();
assert_eq!(reply.flags & irtt_proto::FLAG_CLOSE, 0);
assert_eq!(reply.recv_count, Some(1));
assert_eq!(reply.recv_window, Some(1));
assert_eq!(server.session_count(), 1);
}
+133
View File
@@ -0,0 +1,133 @@
//! Observe the emitted IP byte, independently of protocol negotiation.
#![cfg(target_os = "linux")]
use std::{io::IoSliceMut, net::UdpSocket as StdSocket, os::fd::AsRawFd, time::Duration};
use irtt_proto::{
decode_echo_reply, decode_open_reply, encode_request, OpenReply, Params, RequestToEncode,
FLAG_CLOSE,
};
use irtt_server::{Server, ServerConfig};
use nix::sys::socket::{recvmsg, ControlMessageOwned, MsgFlags};
use tokio::{net::UdpSocket, sync::oneshot, time::timeout};
async fn exchange(client: &UdpSocket, request: &[u8], expected: u8) -> Vec<u8> {
client.send(request).await.unwrap();
loop {
client.readable().await.unwrap();
let packet = client.try_io(tokio::io::Interest::READABLE, || {
let mut bytes = [0; 2048];
let mut control = nix::cmsg_space!(u8);
let mut iov = [IoSliceMut::new(&mut bytes)];
let msg = recvmsg::<()>(
client.as_raw_fd(),
&mut iov,
Some(&mut control),
MsgFlags::MSG_DONTWAIT,
)?;
let size = msg.bytes;
let class = msg.cmsgs().unwrap().find_map(|message| match message {
ControlMessageOwned::Ipv4Tos(class) => Some(class),
_ => None,
});
assert_eq!(class, Some(expected));
Ok(bytes[..size].to_vec())
});
match packet {
Ok(packet) => return packet,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
Err(error) => panic!("receive failed: {error}"),
}
}
}
fn echo(opened: &OpenReply, sequence: u32) -> Vec<u8> {
encode_request(
RequestToEncode::Echo {
token: opened.token,
sequence,
params: &opened.params,
payload: &[],
},
None,
)
.unwrap()
}
#[tokio::test(flavor = "current_thread")]
async fn every_open_echo_and_server_close_applies_its_own_raw_traffic_class() {
timeout(Duration::from_secs(8), async {
let maximum = Duration::from_millis(1);
let mut server = Server::bind(
"127.0.0.1:0".parse().unwrap(),
ServerConfig::default()
.with_min_send_interval(Duration::ZERO)
.with_idle_timeout(Duration::MAX)
.with_max_test_duration(maximum),
)
.await
.unwrap();
let addr = server.local_addr().unwrap();
let (shutdown, stopped) = oneshot::channel();
let task = tokio::spawn(async move {
server
.run(async {
let _ = stopped.await;
})
.await
});
let socket = StdSocket::bind("127.0.0.1:0").unwrap();
socket2::SockRef::from(&socket)
.set_recv_tos_v4(true)
.unwrap();
socket.set_nonblocking(true).unwrap();
let client = UdpSocket::from_std(socket).unwrap();
client.connect(addr).await.unwrap();
let mut sessions = Vec::new();
// Each new OPEN follows a prior ECHO. The first raw byte also includes
// ECN bits: neither a codepoint shift nor masking would preserve it.
for (requested, class) in [(185, 185), (32, 32), (0, 0), (256, 0), (-1, 0)] {
let params = Params {
dscp: requested,
..Params::with_protocol_defaults()
};
let request = encode_request(
RequestToEncode::Open {
params: &params,
no_test: false,
},
None,
)
.unwrap();
let packet = exchange(&client, &request, 0).await;
let opened = decode_open_reply(&packet, None).unwrap();
assert_eq!(opened.params.dscp, requested);
let packet = exchange(&client, &echo(&opened, 0), class).await;
assert_eq!(
decode_echo_reply(&packet, &opened.params, None)
.unwrap()
.flags
& FLAG_CLOSE,
0
);
sessions.push((opened, class));
}
// Every session has now served its first echo. Waiting past all duration
// deadlines is safe under oversleep; idle expiry cannot intervene.
tokio::time::sleep(maximum + Duration::from_secs(2)).await;
for (opened, class) in sessions {
let packet = exchange(&client, &echo(&opened, 1), class).await;
assert_ne!(
decode_echo_reply(&packet, &opened.params, None)
.unwrap()
.flags
& FLAG_CLOSE,
0
);
}
shutdown.send(()).unwrap();
task.await.unwrap().unwrap();
})
.await
.expect("marked runtime exchanges did not complete");
}