`Params::decode` seeded `Params::default()` and overwrote only the tags it saw, so an omitted tag and one explicitly encoded as zero were indistinguishable afterwards. The protocol gives those the same value but not the same meaning, and a server cannot apply its open admission rules without telling them apart: an absent Duration or Interval is accepted as the wire default zero, while one explicitly encoded as zero is invalid. Add `Params::decode_with_presence`, returning the decoded values alongside a `ParamPresence` recording which known tags appeared. `Params::decode` now delegates to it, so there is still exactly one parser and the two cannot diverge. Unknown tags stay ignored and untracked, and repeated known tags keep last-value-wins. Add `Clock::Unspecified = 0` as the default, which is the wire default for an absent Clock tag and what an empty parameter payload decodes to. An explicit Clock tag encoding zero remains an invalid enum value, so `Unspecified` is only ever reached by omission; `encode` omits the tag for it rather than emitting an explicit zero. It selects no clock, so `has_wall`/`has_mono` are false and no timestamp field is laid out. `ClientConfig::clock` is public and therefore now constructible as `Unspecified`, which is a wire default rather than a client mode. Reject it during config validation. The client default stays `Clock::Both`, so normal client-generated params and wire vectors are unchanged.
204 lines
6.1 KiB
Rust
204 lines
6.1 KiB
Rust
use super::*;
|
|
|
|
#[test]
|
|
fn client_config_default() {
|
|
let config = ClientConfig::default();
|
|
assert_eq!(config.duration, Some(Duration::from_secs(3)));
|
|
assert_eq!(config.interval, Duration::from_secs(1));
|
|
assert_eq!(config.length, 0);
|
|
assert_eq!(config.received_stats, ReceivedStats::Both);
|
|
assert_eq!(config.stamp_at, StampAt::Both);
|
|
assert_eq!(config.clock, Clock::Both);
|
|
assert_eq!(config.dscp, 0);
|
|
assert_eq!(config.hmac_key, None);
|
|
assert_eq!(config.server_fill, None);
|
|
assert_eq!(config.open_timeouts, DEFAULT_OPEN_TIMEOUTS);
|
|
assert_eq!(config.run_mode, RunMode::Normal);
|
|
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
|
|
assert_eq!(config.probe_timeout, Duration::from_secs(4));
|
|
assert_eq!(config.max_pending_probes, 4096);
|
|
}
|
|
|
|
#[test]
|
|
fn params_from_config_maps_compatibility_fields() {
|
|
let config = ClientConfig {
|
|
duration: Some(Duration::from_secs(5)),
|
|
interval: Duration::from_millis(250),
|
|
length: 1472,
|
|
received_stats: ReceivedStats::Window,
|
|
stamp_at: StampAt::Midpoint,
|
|
clock: Clock::Wall,
|
|
dscp: 46,
|
|
hmac_key: Some(b"secret".to_vec()),
|
|
server_fill: Some("rand".to_owned()),
|
|
..ClientConfig::default()
|
|
};
|
|
|
|
let params = params_from_config(&config).unwrap();
|
|
assert_eq!(params.protocol_version, PROTOCOL_VERSION);
|
|
assert_eq!(params.duration_ns, 5_000_000_000);
|
|
assert_eq!(params.interval_ns, 250_000_000);
|
|
assert_eq!(params.length, 1472);
|
|
assert_eq!(params.received_stats, ReceivedStats::Window);
|
|
assert_eq!(params.stamp_at, StampAt::Midpoint);
|
|
assert_eq!(params.clock, Clock::Wall);
|
|
assert_eq!(
|
|
params.dscp, 184,
|
|
"config DSCP codepoint 46 must be shifted into raw wire byte 184"
|
|
);
|
|
assert_eq!(
|
|
params.server_fill.as_ref().map(|fill| fill.value.as_str()),
|
|
Some("rand")
|
|
);
|
|
assert_eq!(config.hmac_key.as_deref(), Some(b"secret".as_slice()));
|
|
}
|
|
|
|
#[test]
|
|
fn params_from_config_accepts_boundary_values() {
|
|
for length in [0, 1, 1472, 4096, MAX_UDP_PAYLOAD_LENGTH] {
|
|
let config = ClientConfig {
|
|
length,
|
|
..ClientConfig::default()
|
|
};
|
|
assert_eq!(
|
|
params_from_config(&config).unwrap().length,
|
|
i64::from(length)
|
|
);
|
|
}
|
|
|
|
let config = ClientConfig {
|
|
dscp: 63,
|
|
..ClientConfig::default()
|
|
};
|
|
assert_eq!(
|
|
params_from_config(&config).unwrap().dscp,
|
|
252,
|
|
"boundary codepoint 63 must be shifted into raw wire byte 252"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn params_from_config_encodes_continuous_duration_as_zero() {
|
|
let config = ClientConfig {
|
|
duration: None,
|
|
..ClientConfig::default()
|
|
};
|
|
assert_eq!(params_from_config(&config).unwrap().duration_ns, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn params_from_config_rejects_invalid_values() {
|
|
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
|
let too_large = Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
|
|
let cases = [
|
|
(
|
|
"oversized UDP payload",
|
|
ClientConfig {
|
|
length: MAX_UDP_PAYLOAD_LENGTH + 1,
|
|
..ClientConfig::default()
|
|
},
|
|
"packet length",
|
|
),
|
|
(
|
|
"zero finite duration",
|
|
ClientConfig {
|
|
duration: Some(Duration::ZERO),
|
|
..ClientConfig::default()
|
|
},
|
|
"duration must be greater than zero; use None for continuous mode",
|
|
),
|
|
(
|
|
"zero interval",
|
|
ClientConfig {
|
|
interval: Duration::ZERO,
|
|
..ClientConfig::default()
|
|
},
|
|
"interval must be greater than zero",
|
|
),
|
|
(
|
|
"duration nanosecond overflow",
|
|
ClientConfig {
|
|
duration: Some(too_large),
|
|
..ClientConfig::default()
|
|
},
|
|
"duration is too large to encode as nanoseconds",
|
|
),
|
|
(
|
|
"interval nanosecond overflow",
|
|
ClientConfig {
|
|
interval: too_large,
|
|
..ClientConfig::default()
|
|
},
|
|
"interval is too large to encode as nanoseconds",
|
|
),
|
|
(
|
|
"invalid DSCP codepoint",
|
|
ClientConfig {
|
|
dscp: 64,
|
|
..ClientConfig::default()
|
|
},
|
|
"dscp",
|
|
),
|
|
(
|
|
// The unspecified clock is the absent-tag wire default, not a
|
|
// client mode; a client that needs timestamps must name a clock.
|
|
"unspecified clock",
|
|
ClientConfig {
|
|
clock: Clock::Unspecified,
|
|
..ClientConfig::default()
|
|
},
|
|
"clock must be wall, monotonic, or both",
|
|
),
|
|
(
|
|
"empty server fill",
|
|
ClientConfig {
|
|
server_fill: Some("".to_owned()),
|
|
..ClientConfig::default()
|
|
},
|
|
"server_fill",
|
|
),
|
|
(
|
|
"oversized server fill",
|
|
ClientConfig {
|
|
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
|
|
..ClientConfig::default()
|
|
},
|
|
"server_fill",
|
|
),
|
|
];
|
|
|
|
for (name, config, expected_reason) in cases {
|
|
assert!(
|
|
matches!(
|
|
params_from_config(&config),
|
|
Err(ClientError::InvalidConfig { reason }) if reason.contains(expected_reason)
|
|
),
|
|
"{name} should fail with InvalidConfig containing {expected_reason:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn minimum_open_timeout_under_200ms_is_rejected() {
|
|
let config = ClientConfig {
|
|
open_timeouts: vec![Duration::from_millis(199)],
|
|
..ClientConfig::default()
|
|
};
|
|
assert!(matches!(
|
|
Client::connect(config),
|
|
Err(ClientError::OpenTimeoutTooSmall { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_open_timeouts_is_rejected() {
|
|
let config = ClientConfig {
|
|
open_timeouts: vec![],
|
|
..ClientConfig::default()
|
|
};
|
|
assert!(matches!(
|
|
Client::connect(config),
|
|
Err(ClientError::NoOpenTimeouts)
|
|
));
|
|
}
|