refactor(proto): consolidate tests

This commit is contained in:
Nils Andreas Svee committed 2026-10-06 17:02:54 +07:00
1 parent 4c941a7d78
commit 5bc27ef894
13 files changed
+1000 -1802

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
use irtt_proto::{compute_hmac, compute_hmac_in_place, verify_hmac, ProtoError};
fn captured_open() -> Vec<u8> {
// Captured vector 6; provenance and independently checked digest are in
// docs/protocol/test-vectors/README.md. This is an oracle for the primitive.
"14 a7 5b 09 ff 90 16 a7 aa 53 78 16 9e c3 a2 d5 54 dc 30 36
01 02 02 80 d0 ac f3 0e 03 80 a8 d6 b9 07 05 06 06 06 07 06"
.split_whitespace()
.map(|byte| u8::from_str_radix(byte, 16).unwrap())
.collect()
}
#[test]
fn computing_hmac_ignores_existing_digest_and_only_replaces_its_field() {
let expected = captured_open();
let mut packet = expected.clone();
packet[4..20].fill(0xa5);
let before = packet.clone();
assert_eq!(
compute_hmac(b"testkey", &packet, 4).unwrap().as_slice(),
&expected[4..20]
);
assert_eq!(packet, before);
compute_hmac_in_place(b"testkey", &mut packet, 4).unwrap();
assert_eq!(packet, expected);
}
#[test]
fn verification_rejects_wrong_key_and_modified_authenticated_bytes() {
let packet = captured_open();
verify_hmac(b"testkey", &packet, 4).unwrap();
assert_eq!(verify_hmac(b"wrong", &packet, 4), Err(ProtoError::BadHmac));
for offset in [0, 4, 21, packet.len() - 1] {
let mut modified = packet.clone();
modified[offset] ^= 1;
assert_eq!(
verify_hmac(b"testkey", &modified, 4),
Err(ProtoError::BadHmac)
);
}
}
#[test]
fn incomplete_or_out_of_bounds_digest_fields_are_rejected_without_mutation() {
for offset in [5, 20, 21, usize::MAX] {
let mut packet = vec![0xa5; 20];
assert_eq!(
compute_hmac(b"key", &packet, offset),
Err(ProtoError::InvalidHmacOffset)
);
assert_eq!(
verify_hmac(b"key", &packet, offset),
Err(ProtoError::InvalidHmacOffset)
);
assert_eq!(
compute_hmac_in_place(b"key", &mut packet, offset),
Err(ProtoError::InvalidHmacOffset)
);
assert_eq!(packet, [0xa5; 20]);
}
}
+165
View File
@@ -0,0 +1,165 @@
use irtt_proto::params::{Clock, ReceivedStats, StampAt};
use irtt_proto::{
echo_header_len, echo_packet_len, PacketLayout, Params, HMAC_SIZE, RECV_COUNT_SIZE,
RECV_WINDOW_SIZE, SEQ_SIZE, TIMESTAMP_SIZE, TOKEN_SIZE,
};
const HEADER_SIZE: usize = 4;
fn params(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> Params {
Params {
received_stats: stats,
stamp_at,
clock,
..Params::default()
}
}
fn expected_optional_len(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> usize {
let stats_len = match stats {
ReceivedStats::None => 0,
ReceivedStats::Count => RECV_COUNT_SIZE,
ReceivedStats::Window => RECV_WINDOW_SIZE,
ReceivedStats::Both => RECV_COUNT_SIZE + RECV_WINDOW_SIZE,
};
let clock_count = match clock {
Clock::Unspecified => 0,
Clock::Wall | Clock::Monotonic => 1,
Clock::Both => 2,
};
let timestamp_groups = match stamp_at {
StampAt::None => 0,
StampAt::Send | StampAt::Receive | StampAt::Midpoint => 1,
StampAt::Both => 2,
};
stats_len + timestamp_groups * clock_count * TIMESTAMP_SIZE
}
#[test]
fn open_and_close_layout_lengths() {
assert_eq!(PacketLayout::open_request(false).header_len(), 4);
assert_eq!(PacketLayout::open_request(true).header_len(), 20);
assert_eq!(PacketLayout::open_reply(false).header_len(), 12);
assert_eq!(PacketLayout::open_reply(true).header_len(), 28);
assert_eq!(PacketLayout::close_request(false).header_len(), 12);
assert_eq!(PacketLayout::close_request(true).header_len(), 28);
}
#[test]
fn layout_matrix_matches_stats_timestamps_clock_and_hmac_rules() {
for stats in [
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
] {
for stamp_at in [
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
] {
for clock in [
Clock::Unspecified,
Clock::Wall,
Clock::Monotonic,
Clock::Both,
] {
for hmac in [false, true] {
let params = params(stats, stamp_at, clock);
let layout = PacketLayout::echo(hmac, &params);
let expected_len = HEADER_SIZE
+ TOKEN_SIZE
+ SEQ_SIZE
+ if hmac { HMAC_SIZE } else { 0 }
+ expected_optional_len(stats, stamp_at, clock);
assert_eq!(
layout.header_len(),
expected_len,
"unexpected length for stats={stats:?} stamp_at={stamp_at:?} clock={clock:?} hmac={hmac}"
);
assert_eq!(
layout.recv_count,
matches!(stats, ReceivedStats::Count | ReceivedStats::Both)
);
assert_eq!(
layout.recv_window,
matches!(stats, ReceivedStats::Window | ReceivedStats::Both)
);
assert_eq!(
layout.recv_wall,
matches!(stamp_at, StampAt::Receive | StampAt::Both)
&& matches!(clock, Clock::Wall | Clock::Both)
);
assert_eq!(
layout.recv_mono,
matches!(stamp_at, StampAt::Receive | StampAt::Both)
&& matches!(clock, Clock::Monotonic | Clock::Both)
);
assert_eq!(
layout.midpoint_wall,
matches!(stamp_at, StampAt::Midpoint)
&& matches!(clock, Clock::Wall | Clock::Both)
);
assert_eq!(
layout.midpoint_mono,
matches!(stamp_at, StampAt::Midpoint)
&& matches!(clock, Clock::Monotonic | Clock::Both)
);
assert_eq!(
layout.send_wall,
matches!(stamp_at, StampAt::Send | StampAt::Both)
&& matches!(clock, Clock::Wall | Clock::Both)
);
assert_eq!(
layout.send_mono,
matches!(stamp_at, StampAt::Send | StampAt::Both)
&& matches!(clock, Clock::Monotonic | Clock::Both)
);
if hmac {
assert_eq!(
echo_header_len(true, &params) - echo_header_len(false, &params),
HMAC_SIZE
);
}
}
}
}
}
}
#[test]
fn negotiated_length_is_floored_at_the_required_field_block() {
// The 60-byte header of this layout is the floor. A negative negotiated
// length is not an error: it asks for nothing beyond the mandatory
// fields, exactly as zero does.
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
for (length, expected) in [(-4096, 60), (-1, 60), (0, 60), (20, 60), (92, 92)] {
p.length = length;
assert_eq!(
echo_packet_len(false, &p),
Ok(expected),
"unexpected packet length for negotiated length {length}"
);
}
}
/// A positive length wider than `usize` must stay an error rather than
/// becoming a saturated buffer size. Only reachable where `usize` is
/// narrower than `i64`; on a 64-bit target every positive `i64` converts.
#[cfg(target_pointer_width = "32")]
#[test]
fn a_length_wider_than_usize_is_rejected_rather_than_saturated() {
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
p.length = 5_000_000_000;
assert_eq!(
echo_packet_len(false, &p),
Err(irtt_proto::ProtoError::PacketLengthUnrepresentable {
length: 5_000_000_000
})
);
}
+399
View File
@@ -0,0 +1,399 @@
use irtt_proto::{
varint, Clock, ParamPresence, Params, ProtoError, ReceivedStats, ServerFill, StampAt,
};
use proptest::prelude::*;
fn int(tag: u64, value: i64) -> Vec<u8> {
let mut out = Vec::new();
varint::encode_uvarint(tag, &mut out);
varint::encode_varint(value, &mut out);
out
}
fn fill(value: &[u8]) -> Vec<u8> {
let mut out = vec![9];
varint::encode_uvarint(value.len() as u64, &mut out);
out.extend_from_slice(value);
out
}
fn presence(p: ParamPresence) -> [bool; 9] {
[
p.protocol_version,
p.duration_ns,
p.interval_ns,
p.length,
p.received_stats,
p.stamp_at,
p.clock,
p.dscp,
p.server_fill,
]
}
fn assert_error(bytes: &[u8], expected: ProtoError) {
assert_eq!(Params::decode(bytes).unwrap_err(), expected, "{bytes:02x?}");
assert_eq!(
Params::decode_with_presence(bytes).unwrap_err(),
expected,
"{bytes:02x?}"
);
}
#[test]
fn known_encodings_preserve_raw_dscp_and_utf8_byte_lengths() {
for (params, bytes) in [
(
Params {
dscp: 46,
..Params::default()
},
vec![8, 92],
),
(
Params {
dscp: 184,
..Params::default()
},
vec![8, 0xf0, 2],
),
(
Params {
server_fill: Some(ServerFill {
value: "rand".into(),
}),
..Params::default()
},
vec![9, 4, b'r', b'a', b'n', b'd'],
),
(
Params {
server_fill: Some(ServerFill { value: "é".into() }),
..Params::default()
},
vec![9, 2, 0xc3, 0xa9],
),
] {
assert_eq!(params.encode(), bytes);
assert_eq!(Params::decode(&bytes).unwrap(), params);
}
}
#[test]
fn wire_defaults_and_explicit_zero_presence() {
let defaults = Params {
protocol_version: 0,
duration_ns: 0,
interval_ns: 0,
length: 0,
received_stats: ReceivedStats::None,
stamp_at: StampAt::None,
clock: Clock::Unspecified,
dscp: 0,
server_fill: None,
};
let absent = Params::decode_with_presence(&[]).unwrap();
assert_eq!(absent.params, defaults);
assert_eq!(presence(absent.presence), [false; 9]);
assert_eq!(defaults.encode(), Vec::<u8>::new());
assert!(!defaults.clock.has_wall());
assert!(!defaults.clock.has_mono());
// Clock is the sole integer tag for which explicit zero is invalid.
for tag in [1, 2, 3, 4, 5, 6, 8] {
let bytes = int(tag, 0);
let decoded = Params::decode_with_presence(&bytes).unwrap();
assert_eq!(decoded.params, defaults);
let mut expected = [false; 9];
expected[tag as usize - 1] = true;
assert_eq!(presence(decoded.presence), expected, "tag {tag}");
assert_eq!(Params::decode(&bytes).unwrap(), decoded.params);
}
let empty_fill = Params::decode_with_presence(&[9, 0]).unwrap();
assert_eq!(
empty_fill.params.server_fill,
Some(ServerFill {
value: String::new()
})
);
assert_eq!(
presence(empty_fill.presence),
[false, false, false, false, false, false, false, false, true]
);
assert_eq!(empty_fill.params.encode(), [9, 0]);
assert_eq!(
Params {
protocol_version: 1,
..defaults
}
.encode(),
[1, 2]
);
}
#[test]
fn known_tags_map_to_their_fields_and_report_isolated_presence() {
for (tag, value, expected) in [
(
1,
1,
Params {
protocol_version: 1,
..Params::default()
},
),
(
2,
5,
Params {
duration_ns: 5,
..Params::default()
},
),
(
3,
6,
Params {
interval_ns: 6,
..Params::default()
},
),
(
4,
7,
Params {
length: 7,
..Params::default()
},
),
(
8,
184,
Params {
dscp: 184,
..Params::default()
},
),
] {
let decoded = Params::decode_with_presence(&int(tag, value)).unwrap();
assert_eq!(decoded.params, expected);
let mut expected_presence = [false; 9];
expected_presence[tag as usize - 1] = true;
assert_eq!(presence(decoded.presence), expected_presence);
}
for (tag, modes) in [
(
5,
vec![
ReceivedStats::None as i64,
ReceivedStats::Count as i64,
ReceivedStats::Window as i64,
ReceivedStats::Both as i64,
],
),
(
6,
vec![
StampAt::None as i64,
StampAt::Send as i64,
StampAt::Receive as i64,
StampAt::Both as i64,
StampAt::Midpoint as i64,
],
),
(
7,
vec![
Clock::Wall as i64,
Clock::Monotonic as i64,
Clock::Both as i64,
],
),
] {
for (index, mode) in modes.into_iter().enumerate() {
let value = index as i64 + if tag == 7 { 1 } else { 0 };
let decoded = Params::decode_with_presence(&int(tag, value)).unwrap();
let actual = match tag {
5 => decoded.params.received_stats as i64,
6 => decoded.params.stamp_at as i64,
7 => decoded.params.clock as i64,
_ => unreachable!(),
};
assert_eq!(actual, mode);
let mut expected_presence = [false; 9];
expected_presence[tag as usize - 1] = true;
assert_eq!(presence(decoded.presence), expected_presence);
}
}
}
#[test]
fn malformed_values_and_invalid_enums_are_rejected_by_both_decoders() {
for (tag, value, name) in [
(5, 4, "ReceivedStats"),
(6, 5, "StampAt"),
(7, 0, "Clock"),
(7, 4, "Clock"),
(5, -1, "ReceivedStats"),
(6, -1, "StampAt"),
(7, -1, "Clock"),
] {
assert_error(&int(tag, value), ProtoError::InvalidEnum { name, value });
}
for bytes in [&[1, 0x80][..], &[99], &[0x80], &[9, 0x80]] {
assert_error(bytes, ProtoError::TruncatedVarint);
}
}
#[test]
fn server_fill_accepts_valid_bytes_and_enforces_length_and_utf8_boundaries() {
for value in [
"",
"rand",
"0123456789abcdef0123456789abcdef",
"éééééééééééééééé",
] {
let expected = Params {
server_fill: Some(ServerFill {
value: value.into(),
}),
..Params::default()
};
let bytes = fill(value.as_bytes());
assert_eq!(expected.encode(), bytes);
let decoded = Params::decode_with_presence(&bytes).unwrap();
assert_eq!(decoded.params, expected);
assert!(decoded.presence.server_fill);
assert_eq!(Params::decode(&bytes).unwrap(), expected);
}
assert_error(
&fill(b"0123456789abcdef0123456789abcdefx"),
ProtoError::ParameterLengthTooLarge { tag: 9, length: 33 },
);
assert_error(&[9, 4, b'a', b'b'], ProtoError::MalformedParams);
assert_error(&[9, 1, 0xff], ProtoError::InvalidUtf8);
}
#[cfg(target_pointer_width = "32")]
#[test]
fn server_fill_length_too_large_for_usize_is_rejected() {
let mut bytes = vec![9];
let length = u64::from(u32::MAX) + 1;
varint::encode_uvarint(length, &mut bytes);
assert_error(
&bytes,
ProtoError::ParameterLengthTooLarge { tag: 9, length },
);
}
fn string_strategy() -> impl Strategy<Value = String> {
// Eight arbitrary Unicode scalars occupy at most the allowed 32 bytes.
proptest::collection::vec(any::<char>(), 0..=8).prop_map(|chars| chars.into_iter().collect())
}
fn params_strategy() -> impl Strategy<Value = Params> {
(
any::<[i64; 5]>(),
prop::sample::select(vec![
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
]),
prop::sample::select(vec![
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
]),
prop::sample::select(vec![
Clock::Unspecified,
Clock::Wall,
Clock::Monotonic,
Clock::Both,
]),
prop::option::of(string_strategy()),
)
.prop_map(
|(scalar, received_stats, stamp_at, clock, server_fill)| Params {
protocol_version: scalar[0],
duration_ns: scalar[1],
interval_ns: scalar[2],
length: scalar[3],
dscp: scalar[4],
received_stats,
stamp_at,
clock,
server_fill: server_fill.map(|value| ServerFill { value }),
},
)
}
fn all_tags(params: &Params) -> Vec<u8> {
let mut out = Vec::new();
// Deliberately bypass encode's omission: duplicate zero values must be present.
for (tag, value) in [
(1, params.protocol_version),
(2, params.duration_ns),
(3, params.interval_ns),
(4, params.length),
(5, params.received_stats as i64),
(6, params.stamp_at as i64),
(7, params.clock as i64),
(8, params.dscp),
] {
out.extend_from_slice(&int(tag, value));
}
out.extend_from_slice(&fill(params.server_fill.as_ref().unwrap().value.as_bytes()));
out
}
proptest! {
#[test]
fn valid_params_round_trip_with_omission_presence(params in params_strategy()) {
let encoded = params.encode();
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(&decoded.params, &params);
prop_assert_eq!(Params::decode(&encoded).unwrap(), params.clone());
prop_assert_eq!(presence(decoded.presence), [
params.protocol_version != 0, params.duration_ns != 0,
params.interval_ns != 0, params.length != 0,
params.received_stats != ReceivedStats::None, params.stamp_at != StampAt::None,
params.clock != Clock::Unspecified, params.dscp != 0, params.server_fill.is_some(),
]);
}
#[test]
fn repeated_known_tags_are_last_wins(
mut first in params_strategy(), mut second in params_strategy(),
first_clock in 1_i64..=3, second_clock in 1_i64..=3,
first_fill in string_strategy(), second_fill in string_strategy(),
) {
first.clock = Clock::try_from(first_clock).unwrap();
second.clock = Clock::try_from(second_clock).unwrap();
first.server_fill = Some(ServerFill { value: first_fill });
second.server_fill = Some(ServerFill { value: second_fill });
let mut bytes = all_tags(&first);
bytes.extend_from_slice(&all_tags(&second));
let decoded = Params::decode_with_presence(&bytes).unwrap();
prop_assert_eq!(&decoded.params, &second);
prop_assert_eq!(Params::decode(&bytes).unwrap(), second);
prop_assert_eq!(presence(decoded.presence), [true; 9]);
}
#[test]
fn unknown_scalar_tags_do_not_disturb_known_params(
params in params_strategy(), tag in 10_u64..=u16::MAX as u64, value: u64,
) {
let encoded = params.encode();
let expected = Params::decode_with_presence(&encoded).unwrap();
let mut unknown = Vec::new();
varint::encode_uvarint(tag, &mut unknown);
varint::encode_uvarint(value, &mut unknown);
for bytes in [[unknown.as_slice(), &encoded].concat(), [&encoded, unknown.as_slice()].concat()] {
prop_assert_eq!(Params::decode_with_presence(&bytes).unwrap(), expected.clone());
prop_assert_eq!(Params::decode(&bytes).unwrap(), params.clone());
}
}
}
@@ -54,33 +54,41 @@ fn echo_reply(params: &Params, flags: u8) -> EchoReply {
}
#[test]
fn authenticated_reply_encoders_set_hmac_from_key() {
fn reply_encoders_normalize_hmac_flags_from_the_key() {
let params = params();
let open_reply = OpenReply {
flags: FLAG_OPEN | FLAG_REPLY,
token: TOKEN,
params: params.clone(),
};
let packet = encode_open_reply(&open_reply, Some(KEY)).unwrap();
assert_eq!(packet[3], FLAG_OPEN | FLAG_REPLY | FLAG_HMAC);
assert_eq!(
decode_open_reply(&packet, Some(KEY)),
Ok(OpenReply {
flags: FLAG_OPEN | FLAG_REPLY | FLAG_HMAC,
..open_reply
})
);
let echo_reply = echo_reply(&params, FLAG_REPLY);
let packet = encode_echo_reply(&echo_reply, &params, Some(KEY)).unwrap();
assert_eq!(packet[3], FLAG_REPLY | FLAG_HMAC);
assert_eq!(
decode_echo_reply(&packet, &params, Some(KEY)),
Ok(EchoReply {
flags: FLAG_REPLY | FLAG_HMAC,
..echo_reply
})
);
for key in [None, Some(KEY)] {
for supplied_hmac in [0, FLAG_HMAC] {
let auth = if key.is_some() { FLAG_HMAC } else { 0 };
let open = OpenReply {
flags: FLAG_OPEN | FLAG_REPLY | supplied_hmac,
token: TOKEN,
params: params.clone(),
};
let packet = encode_open_reply(&open, key).unwrap();
assert_eq!(packet[3], FLAG_OPEN | FLAG_REPLY | auth);
let offset = if key.is_some() { 20 } else { 4 };
assert_eq!(&packet[offset..offset + 8], &TOKEN.to_le_bytes());
assert_eq!(
decode_open_reply(&packet, key).unwrap(),
OpenReply {
flags: packet[3],
..open
}
);
let echo = echo_reply(&params, FLAG_REPLY | supplied_hmac);
let packet = encode_echo_reply(&echo, &params, key).unwrap();
assert_eq!(packet[3], FLAG_REPLY | auth);
assert_eq!(&packet[offset..offset + 8], &TOKEN.to_le_bytes());
assert_eq!(&packet[offset + 8..offset + 12], &u32::MAX.to_le_bytes());
assert_eq!(
decode_echo_reply(&packet, &params, key).unwrap(),
EchoReply {
flags: packet[3],
..echo
}
);
}
}
}
#[test]
@@ -95,6 +103,12 @@ fn rejected_open_reply_round_trips_and_zero_token_requires_close() {
};
let packet = encode_open_reply(&rejected, None).unwrap();
assert_eq!(decode_open_reply(&packet, None), Ok(rejected.clone()));
let mut zero_without_close = packet.clone();
zero_without_close[3] &= !FLAG_CLOSE;
assert_eq!(
decode_open_reply(&zero_without_close, None),
Err(ProtoError::ZeroToken)
);
assert_eq!(
encode_open_reply(
@@ -119,63 +133,51 @@ fn peer_close_echo_reply_round_trips_without_a_close_reply_codec() {
}
#[test]
fn reply_encoders_supply_their_packet_type_rules() {
fn reply_codecs_enforce_packet_type_and_reserved_flag_rules() {
let params = Params::default();
assert_eq!(
encode_open_reply(
&OpenReply {
flags: FLAG_REPLY,
token: TOKEN,
params: params.clone(),
},
None,
for (flags, expected) in [
(FLAG_REPLY, ProtoError::MissingFlag(FLAG_OPEN)),
(FLAG_OPEN, ProtoError::MissingFlag(FLAG_REPLY)),
(
FLAG_OPEN | FLAG_REPLY | 0x10,
ProtoError::ReservedFlags(0x10),
),
Err(ProtoError::MissingFlag(FLAG_OPEN))
);
assert_eq!(
encode_echo_reply(
&EchoReply {
flags: 0,
..echo_reply(&params, FLAG_REPLY)
},
&params,
None,
),
Err(ProtoError::MissingFlag(FLAG_REPLY))
);
}
/// Every packet the reply encoders produce must be rejected by the inbound
/// request decoder: a server never answers a datagram carrying `FLAG_REPLY`.
#[test]
fn encoded_replies_are_never_admitted_as_inbound_requests() {
let params = Params::default();
for reply_flags in [FLAG_OPEN | FLAG_REPLY, FLAG_OPEN | FLAG_REPLY | FLAG_CLOSE] {
let packet = encode_open_reply(
] {
let open = OpenReply {
flags,
token: TOKEN,
params: params.clone(),
};
assert_eq!(encode_open_reply(&open, None).unwrap_err(), expected);
let mut packet = encode_open_reply(
&OpenReply {
flags: reply_flags,
token: if reply_flags & FLAG_CLOSE == 0 {
TOKEN
} else {
0
},
params: params.clone(),
flags: FLAG_OPEN | FLAG_REPLY,
..open
},
None,
)
.unwrap();
assert_eq!(
decode_request(&packet),
Err(ProtoError::UnexpectedFlag(FLAG_REPLY))
);
packet[3] = flags;
assert_eq!(decode_open_reply(&packet, None).unwrap_err(), expected);
}
for reply_flags in [FLAG_REPLY, FLAG_REPLY | FLAG_CLOSE] {
let packet = encode_echo_reply(&echo_reply(&params, reply_flags), &params, None).unwrap();
for (flags, expected) in [
(0, ProtoError::MissingFlag(FLAG_REPLY)),
(
FLAG_OPEN | FLAG_REPLY,
ProtoError::UnexpectedFlag(FLAG_OPEN),
),
(FLAG_REPLY | 0x10, ProtoError::ReservedFlags(0x10)),
] {
assert_eq!(
decode_request(&packet),
Err(ProtoError::UnexpectedFlag(FLAG_REPLY))
encode_echo_reply(&echo_reply(&params, flags), &params, None).unwrap_err(),
expected
);
let mut packet =
encode_echo_reply(&echo_reply(&params, FLAG_REPLY), &params, None).unwrap();
packet[3] = flags;
assert_eq!(
decode_echo_reply(&packet, &params, None).unwrap_err(),
expected
);
}
}
@@ -207,7 +209,12 @@ fn echo_layout_combinations_round_trip() {
StampAt::Both,
StampAt::Midpoint,
] {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
for clock in [
Clock::Unspecified,
Clock::Wall,
Clock::Monotonic,
Clock::Both,
] {
for key in [None, Some(KEY)] {
let params = Params {
protocol_version: 1,
@@ -265,44 +272,48 @@ fn echo_reply_requires_exact_negotiated_optional_fields() {
#[test]
fn echo_payload_is_copied_zero_filled_and_bounded_in_both_directions() {
let params = Params {
length: 20,
..Params::default()
};
let packet = encode_request(echo_request(&params, &[1, 2]), None).unwrap();
assert_eq!(&packet[16..], &[1, 2, 0, 0]);
assert_eq!(
encode_request(echo_request(&params, &[0; 5]), None),
Err(ProtoError::PayloadTooLarge {
available: 4,
provided: 5,
})
);
let reply = EchoReply {
payload: vec![3, 4],
..echo_reply(&params, FLAG_REPLY)
};
let packet = encode_echo_reply(&reply, &params, None).unwrap();
assert_eq!(&packet[16..], &[3, 4, 0, 0]);
let decoded = decode_echo_reply(&packet, &params, None).unwrap();
assert_eq!(decoded.payload, vec![3, 4, 0, 0]);
assert_eq!(encode_echo_reply(&decoded, &params, None).unwrap(), packet);
assert_eq!(
encode_echo_reply(
&EchoReply {
payload: vec![0; 5],
for stats in [ReceivedStats::None, ReceivedStats::Both] {
for key in [None, Some(KEY)] {
let mut params = Params {
received_stats: stats,
..Params::default()
};
let offset = PacketLayout::echo(key.is_some(), &params).header_len();
params.length = (offset + 4) as i64;
let packet = encode_request(echo_request(&params, &[1, 2]), key).unwrap();
assert_eq!(&packet[offset..], &[1, 2, 0, 0]);
assert_eq!(
encode_request(echo_request(&params, &[0; 5]), key),
Err(ProtoError::PayloadTooLarge {
available: 4,
provided: 5
})
);
let reply = EchoReply {
payload: vec![3, 4],
..echo_reply(&params, FLAG_REPLY)
},
&params,
None,
),
Err(ProtoError::PayloadTooLarge {
available: 4,
provided: 5,
})
);
};
let packet = encode_echo_reply(&reply, &params, key).unwrap();
assert_eq!(&packet[offset..], &[3, 4, 0, 0]);
let decoded = decode_echo_reply(&packet, &params, key).unwrap();
assert_eq!(decoded.payload, vec![3, 4, 0, 0]);
assert_eq!(encode_echo_reply(&decoded, &params, key).unwrap(), packet);
assert_eq!(
encode_echo_reply(
&EchoReply {
payload: vec![0; 5],
..reply
},
&params,
key
),
Err(ProtoError::PayloadTooLarge {
available: 4,
provided: 5
})
);
}
}
}
#[test]
@@ -321,6 +332,14 @@ fn a_negative_negotiated_length_still_encodes_in_both_directions() {
let request = encode_request(echo_request(&params, &[]), None)
.expect("a negative negotiated length must still encode a request");
assert_eq!(request.len(), 16);
assert_eq!(
decode_request(&request).unwrap().kind,
DecodedRequestKind::Echo {
token: TOKEN,
sequence: 17,
tail: &[]
}
);
let reply = encode_echo_reply(&echo_reply(&params, FLAG_REPLY), &params, None)
.expect("a negative negotiated length must still encode a reply");
@@ -505,12 +524,14 @@ fn midpoint_dual_field_reply_authenticates_over_full_packet() {
let reply = decode_echo_reply(&packet, &params, Some(KEY)).unwrap();
assert_eq!(reply.timestamps.midpoint_wall, Some(555));
let mut corrupted = packet;
corrupted[HMAC_OFFSET] ^= 0xFF;
assert_eq!(
decode_echo_reply(&corrupted, &params, Some(KEY)),
Err(ProtoError::BadHmac)
);
for offset in [HMAC_OFFSET, PacketLayout::echo(true, &params).header_len()] {
let mut corrupted = packet.clone();
corrupted[offset] ^= 0xFF;
assert_eq!(
decode_echo_reply(&corrupted, &params, Some(KEY)),
Err(ProtoError::BadHmac)
);
}
}
#[test]
@@ -539,61 +560,92 @@ fn only_the_exact_compat_length_is_accepted_as_an_extension() {
}
#[test]
fn midpoint_both_clock_reply_has_no_additional_extension() {
let params = midpoint_params(Clock::Both, 0);
let reply = echo_reply(&params, FLAG_REPLY);
let packet = encode_echo_reply(&reply, &params, None).unwrap();
let mut too_long = packet.clone();
too_long.extend_from_slice(&[0; 8]);
assert_eq!(
decode_echo_reply(&too_long, &params, None),
Err(ProtoError::PacketLengthMismatch {
expected: packet.len(),
actual: too_long.len(),
})
);
fn other_layouts_have_no_longer_form_exception() {
for (stamp_at, clock) in [
(StampAt::Midpoint, Clock::Both),
(StampAt::Receive, Clock::Wall),
] {
let params = Params {
stamp_at,
clock,
..Params::default()
};
let packet = encode_echo_reply(&echo_reply(&params, FLAG_REPLY), &params, None).unwrap();
let mut too_long = packet.clone();
too_long.extend_from_slice(&[0; 8]);
assert_eq!(
decode_echo_reply(&too_long, &params, None),
Err(ProtoError::PacketLengthMismatch {
expected: packet.len(),
actual: too_long.len()
})
);
}
}
#[test]
fn non_midpoint_stamp_at_has_no_longer_form_exception() {
let params = Params {
received_stats: ReceivedStats::None,
stamp_at: StampAt::Receive,
clock: Clock::Wall,
..Params::default()
};
let reply = echo_reply(&params, FLAG_REPLY);
let packet = encode_echo_reply(&reply, &params, None).unwrap();
let mut too_long = packet.clone();
too_long.extend_from_slice(&[0; 8]);
assert_eq!(
decode_echo_reply(&too_long, &params, None),
Err(ProtoError::PacketLengthMismatch {
expected: packet.len(),
actual: too_long.len(),
})
);
}
/// The midpoint compatibility length is a *reply* concept. An inbound ECHO
/// request of that length is just a longer request, which a receiver accepts
/// without applying any negotiated length rule.
#[test]
fn echo_request_decoding_has_no_compat_length_rule() {
let params = midpoint_params(Clock::Wall, 0);
let packet = encode_request(echo_request(&params, &[]), None).unwrap();
let mut too_long = packet.clone();
too_long.extend_from_slice(&[0; 8]);
let request = decode_request(&too_long).unwrap();
assert_eq!(
request.kind,
DecodedRequestKind::Echo {
token: TOKEN,
sequence: 17,
tail: &too_long[16..],
fn reply_decoders_enforce_hmac_presence_and_authenticate_the_body() {
let params = params();
for key in [None, Some(KEY)] {
let open = encode_open_reply(
&OpenReply {
flags: FLAG_OPEN | FLAG_REPLY,
token: TOKEN,
params: params.clone(),
},
key,
)
.unwrap();
let echo = encode_echo_reply(&echo_reply(&params, FLAG_REPLY), &params, key).unwrap();
let mismatched_key = if key.is_some() { None } else { Some(KEY) };
assert_eq!(
decode_open_reply(&open, mismatched_key),
Err(ProtoError::HmacPresenceMismatch)
);
assert_eq!(
decode_echo_reply(&echo, &params, mismatched_key),
Err(ProtoError::HmacPresenceMismatch)
);
if let Some(key) = key {
let mut bad_open = open;
let mut bad_echo = echo;
*bad_open.last_mut().unwrap() ^= 1;
*bad_echo.last_mut().unwrap() ^= 1;
assert_eq!(
decode_open_reply(&bad_open, Some(key)),
Err(ProtoError::BadHmac)
);
assert_eq!(
decode_echo_reply(&bad_echo, &params, Some(key)),
Err(ProtoError::BadHmac)
);
}
);
}
}
#[test]
fn echo_reply_length_errors_precede_hmac_verification() {
for key in [None, Some(KEY)] {
let mut params = Params::default();
let header = PacketLayout::echo(key.is_some(), &params).header_len();
params.length = (header + 4) as i64;
let packet = encode_echo_reply(&echo_reply(&params, FLAG_REPLY), &params, key).unwrap();
for actual in [header - 1, header, packet.len() - 1, packet.len() + 1] {
let mut malformed = packet.clone();
malformed.resize(actual, 0);
// Truncating or extending the signed packet also invalidates its MAC.
let expected = if actual < header {
ProtoError::PacketTooShort {
needed: header,
actual,
}
} else {
ProtoError::PacketLengthMismatch {
expected: packet.len(),
actual,
}
};
assert_eq!(decode_echo_reply(&malformed, &params, key), Err(expected));
}
}
}
+44 -171
View File
@@ -47,8 +47,6 @@ fn token_and_sequence() -> Vec<u8> {
body
}
// ---------- A. Classification ----------
fn classify(kind: &DecodedRequestKind<'_>) -> &'static str {
match kind {
DecodedRequestKind::Open { no_test: false, .. } => "open",
@@ -71,6 +69,34 @@ fn flag_combinations_classify_requests_with_orthogonal_hmac_presence() {
(FLAG_OPEN | FLAG_CLOSE | FLAG_HMAC, true, "no-test open"),
];
for no_test in [false, true] {
for key in [None, Some(KEY)] {
let params = Params::default();
let packet = encode_request(
RequestToEncode::Open {
params: &params,
no_test,
},
key,
)
.unwrap();
let expected_flags = FLAG_OPEN
| if no_test { FLAG_CLOSE } else { 0 }
| if key.is_some() { FLAG_HMAC } else { 0 };
assert_eq!(packet[3], expected_flags);
assert_eq!(
decode_request(&packet).unwrap().kind,
DecodedRequestKind::Open {
no_test,
params: &[]
}
);
if let Some(key) = key {
verify_packet_hmac(key, &packet).unwrap();
}
}
}
for (flags, hmac_present, expected) in cases {
// Long enough for any kind, so only the flags decide classification.
let packet = raw(flags, &token_and_sequence(), 64);
@@ -82,6 +108,10 @@ fn flag_combinations_classify_requests_with_orthogonal_hmac_presence() {
request.hmac_present, hmac_present,
"flags 0x{flags:02x} HMAC presence"
);
if let DecodedRequestKind::Open { params, .. } = request.kind {
let offset = if hmac_present { 20 } else { 4 };
assert_eq!(params, &packet[offset..]);
}
assert_eq!(
classify(&request.kind),
expected,
@@ -90,8 +120,6 @@ fn flag_combinations_classify_requests_with_orthogonal_hmac_presence() {
}
}
// ---------- B. Invalid header / flags ----------
#[test]
fn structurally_invalid_headers_are_rejected() {
assert_eq!(
@@ -135,8 +163,6 @@ fn any_reply_flagged_datagram_is_rejected_as_a_request() {
}
}
// ---------- C. Structural minimum lengths ----------
#[test]
fn structural_minimum_lengths_are_enforced_per_kind() {
// (flags, minimum accepted length)
@@ -172,8 +198,6 @@ fn structural_minimum_lengths_are_enforced_per_kind() {
}
}
// ---------- D. Field offsets ----------
#[test]
fn token_and_sequence_are_read_from_the_hmac_dependent_offsets() {
for hmac in [false, true] {
@@ -211,69 +235,25 @@ fn token_and_sequence_are_read_from_the_hmac_dependent_offsets() {
}
}
// ---------- E. Open parameter slice ----------
#[test]
fn open_params_are_borrowed_after_the_header_and_decoded_later() {
let encoded = params().encode();
for hmac in [false, true] {
let flags = FLAG_OPEN | if hmac { FLAG_HMAC } else { 0 };
let expected_offset = if hmac { 4 + HMAC_SIZE } else { 4 };
let packet = raw(flags, &encoded, expected_offset + encoded.len());
match decode_request(&packet).unwrap().kind {
DecodedRequestKind::Open { params, .. } => {
assert_eq!(params, &packet[expected_offset..]);
assert_eq!(Params::decode(params).unwrap(), self::params());
// Structural parsing accepts even malformed Params; semantic errors belong
// to Params::decode and are covered in params.rs.
for body in [params().encode(), Vec::new(), vec![1, 0x80]] {
for hmac in [false, true] {
let flags = FLAG_OPEN | if hmac { FLAG_HMAC } else { 0 };
let offset = if hmac { 20 } else { 4 };
let packet = raw(flags, &body, offset + body.len());
match decode_request(&packet).unwrap().kind {
DecodedRequestKind::Open { params, .. } => {
assert_eq!(params, &packet[offset..]);
}
other => panic!("expected an open request, got {other:?}"),
}
other => panic!("expected an open request, got {other:?}"),
}
}
}
#[test]
fn empty_and_malformed_open_params_decode_structurally() {
let empty = header(FLAG_OPEN);
match decode_request(&empty).unwrap().kind {
DecodedRequestKind::Open { params, .. } => assert!(params.is_empty()),
other => panic!("expected an open request, got {other:?}"),
}
// A truncated varint: structurally fine, semantically not. A receiver must
// be able to authenticate before spending effort on this.
let malformed = raw(FLAG_OPEN, &[1, 0x80], 6);
match decode_request(&malformed).unwrap().kind {
DecodedRequestKind::Open { params, .. } => {
assert_eq!(params, &[1, 0x80]);
assert_eq!(Params::decode(params), Err(ProtoError::TruncatedVarint));
}
other => panic!("expected an open request, got {other:?}"),
}
}
// ---------- F. Open precedence ----------
#[test]
fn open_precedence_beats_close_and_an_echo_shaped_body() {
// An OPEN|CLOSE datagram whose body looks exactly like a token and
// sequence number is still a no-test open, and those bytes are parameters.
let packet = raw(FLAG_OPEN | FLAG_CLOSE, &token_and_sequence(), 16);
assert_eq!(
decode_request(&packet).unwrap(),
DecodedRequest {
hmac_present: false,
kind: DecodedRequestKind::Open {
no_test: true,
params: &packet[4..],
},
}
);
}
// ---------- G. Trailing data ----------
#[test]
fn trailing_bytes_are_tolerated_and_no_length_ceiling_applies() {
let mut close = raw(FLAG_CLOSE, &TOKEN.to_le_bytes(), 12);
@@ -303,8 +283,6 @@ fn trailing_bytes_are_tolerated_and_no_length_ceiling_applies() {
}
}
// ---------- H. Zero token ----------
#[test]
fn a_zero_token_is_structurally_valid() {
assert_eq!(
@@ -323,8 +301,6 @@ fn a_zero_token_is_structurally_valid() {
);
}
// ---------- HMAC presence is not authentication ----------
#[test]
fn hmac_presence_is_reported_independently_of_mac_validity() {
// A structurally complete authenticated echo whose MAC is garbage.
@@ -378,106 +354,3 @@ fn packet_verification_requires_a_present_and_complete_hmac_field() {
Err(ProtoError::BadMagic)
);
}
// ---------- Encode -> decode cross-checks ----------
#[test]
fn encoded_requests_decode_back_to_their_sender_side_identity() {
for key in [None, Some(KEY)] {
for no_test in [false, true] {
let packet = encode_request(
RequestToEncode::Open {
params: &params(),
no_test,
},
key,
)
.unwrap();
let request = decode_request(&packet).unwrap();
assert_eq!(request.hmac_present, key.is_some());
match request.kind {
DecodedRequestKind::Open {
no_test: decoded,
params: encoded,
} => {
assert_eq!(decoded, no_test);
assert_eq!(Params::decode(encoded).unwrap(), params());
}
other => panic!("expected an open request, got {other:?}"),
}
if let Some(key) = key {
verify_packet_hmac(key, &packet).unwrap();
}
}
let packet = encode_request(RequestToEncode::Close { token: TOKEN }, key).unwrap();
let request = decode_request(&packet).unwrap();
assert_eq!(request.hmac_present, key.is_some());
assert_eq!(request.kind, DecodedRequestKind::Close { token: TOKEN });
// The ECHO tail is deliberately not compared against the logical
// payload: the sender placed it at a negotiated offset the receiver
// cannot recover without `Params`.
let echo_params = Params {
length: 96,
..params()
};
let packet = encode_request(
RequestToEncode::Echo {
token: TOKEN,
sequence: SEQUENCE,
params: &echo_params,
payload: &[1, 2, 3, 4],
},
key,
)
.unwrap();
let request = decode_request(&packet).unwrap();
assert_eq!(request.hmac_present, key.is_some());
match request.kind {
DecodedRequestKind::Echo {
token, sequence, ..
} => {
assert_eq!(token, TOKEN);
assert_eq!(sequence, SEQUENCE);
}
other => panic!("expected an echo request, got {other:?}"),
}
if let Some(key) = key {
verify_packet_hmac(key, &packet).unwrap();
}
}
}
#[test]
fn echo_encoding_floors_a_negative_negotiated_length_at_the_field_block() {
// A negative negotiated length is accepted during open and echoed back
// unchanged, so a real session can carry one. It must therefore stay
// encodable: it asks for no space beyond the mandatory fields.
let params = Params {
length: -1,
..Params::default()
};
let packet = encode_request(
RequestToEncode::Echo {
token: TOKEN,
sequence: SEQUENCE,
params: &params,
payload: &[],
},
None,
)
.expect("a negative negotiated length must still encode");
// header (4) + token (8) + sequence (4).
assert_eq!(packet.len(), 16);
let request = decode_request(&packet).unwrap();
assert_eq!(
request.kind,
DecodedRequestKind::Echo {
token: TOKEN,
sequence: SEQUENCE,
tail: &[],
}
);
}
+108
View File
@@ -0,0 +1,108 @@
use irtt_proto::{varint::*, ProtoError};
use proptest::prelude::*;
#[test]
fn boundary_values_round_trip() {
for value in [
0,
1,
2,
127,
128,
255,
16_384,
u32::MAX as u64,
i64::MAX as u64,
u64::MAX,
] {
let mut encoded = Vec::new();
encode_uvarint(value, &mut encoded);
assert_eq!(decode_uvarint(&encoded), Ok((value, encoded.len())));
}
for value in [
i64::MIN,
-9_223_372_036,
-2,
-1,
0,
1,
2,
1_000_000_000,
3_000_000_000,
i64::MAX,
] {
let mut encoded = Vec::new();
encode_varint(value, &mut encoded);
assert_eq!(decode_varint(&encoded), Ok((value, encoded.len())));
}
}
#[test]
fn verified_byte_examples() {
let cases: &[(i64, &[u8])] = &[
(1, &[0x02]),
(3_000_000_000, &[0x80, 0xf8, 0x82, 0xad, 0x16]),
(1_000_000_000, &[0x80, 0xa8, 0xd6, 0xb9, 0x07]),
(1472, &[0x80, 0x17]),
(3, &[0x06]),
(184, &[0xf0, 0x02]),
];
for (value, expected) in cases {
let mut encoded = Vec::new();
encode_varint(*value, &mut encoded);
assert_eq!(&encoded, expected);
assert_eq!(decode_varint(expected), Ok((*value, expected.len())));
}
for tag in [9, 24] {
let mut encoded = Vec::new();
encode_uvarint(tag, &mut encoded);
assert_eq!(encoded, [tag as u8]);
}
}
#[test]
fn truncated_and_overflowing_varints_are_rejected() {
// Incomplete input differs from a tenth byte whose bits cannot fit u64,
// or an eleventh byte after ten continuation bytes.
for (bytes, expected) in [
(vec![], ProtoError::TruncatedVarint),
(vec![0x80], ProtoError::TruncatedVarint),
(vec![0x80; 10], ProtoError::TruncatedVarint),
(
[vec![0x80; 9], vec![2]].concat(),
ProtoError::VarintOverflow,
),
(
[vec![0x80; 10], vec![0]].concat(),
ProtoError::VarintOverflow,
),
] {
assert_eq!(decode_uvarint(&bytes), Err(expected), "{bytes:02x?}");
assert_eq!(
decode_varint(&bytes),
decode_uvarint(&bytes).map(|(v, n)| (zigzag_decode(v), n))
);
}
}
proptest! {
#[test]
fn uvarint_round_trips(value: u64) {
let mut encoded = Vec::new();
encode_uvarint(value, &mut encoded);
prop_assert!(!encoded.is_empty());
prop_assert!(encoded.len() <= 10);
prop_assert_eq!(decode_uvarint(&encoded).unwrap(), (value, encoded.len()));
prop_assert_eq!(zigzag_encode(zigzag_decode(value)), value);
}
#[test]
fn signed_varint_round_trips(value: i64) {
let mut encoded = Vec::new();
encode_varint(value, &mut encoded);
prop_assert!(!encoded.is_empty());
prop_assert!(encoded.len() <= 10);
prop_assert_eq!(decode_varint(&encoded).unwrap(), (value, encoded.len()));
prop_assert_eq!(zigzag_decode(zigzag_encode(value)), value);
}
}