diff --git a/Cargo.lock b/Cargo.lock index 4e14a81..41eb91f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,7 +109,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -118,6 +127,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -516,10 +531,16 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -735,6 +756,7 @@ version = "0.5.0" dependencies = [ "hmac", "md-5", + "proptest", "subtle", "thiserror 2.0.20", ] @@ -1161,7 +1183,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.7", ] [[package]] @@ -1204,6 +1226,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1213,6 +1244,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.1", + "num-traits", + "rand 0.9.5", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.47" @@ -1240,7 +1296,27 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1249,6 +1325,24 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "ratatui" version = "0.30.2" @@ -1416,6 +1510,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -1606,6 +1712,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "termina" version = "0.3.3" @@ -1780,6 +1899,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1842,6 +1967,15 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2016,3 +2150,23 @@ name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/crates/irtt-proto/Cargo.toml b/crates/irtt-proto/Cargo.toml index 4fbf426..143b1cc 100644 --- a/crates/irtt-proto/Cargo.toml +++ b/crates/irtt-proto/Cargo.toml @@ -16,3 +16,6 @@ hmac = "0.13" md-5 = "0.11" subtle = "2.6.1" thiserror = "2.0.20" + +[dev-dependencies] +proptest = "1.11.0" diff --git a/crates/irtt-proto/src/params.rs b/crates/irtt-proto/src/params.rs index 6ead82d..ce74859 100644 --- a/crates/irtt-proto/src/params.rs +++ b/crates/irtt-proto/src/params.rs @@ -764,4 +764,340 @@ mod tests { let encoded = [99]; assert_eq!(Params::decode(&encoded), Err(ProtoError::TruncatedVarint)); } + + mod properties { + use super::*; + use proptest::prelude::*; + + fn received_stats_strategy() -> impl Strategy { + prop_oneof![ + Just(ReceivedStats::None), + Just(ReceivedStats::Count), + Just(ReceivedStats::Window), + Just(ReceivedStats::Both), + ] + } + + fn stamp_at_strategy() -> impl Strategy { + prop_oneof![ + Just(StampAt::None), + Just(StampAt::Send), + Just(StampAt::Receive), + Just(StampAt::Both), + Just(StampAt::Midpoint), + ] + } + + /// A `Clock` valid for direct construction, including `Unspecified` + /// (wire omission). Never produces an explicit zero on the wire; see + /// `explicit_clock_strategy` for that. + fn clock_strategy() -> impl Strategy { + prop_oneof![ + Just(Clock::Unspecified), + Just(Clock::Wall), + Just(Clock::Monotonic), + Just(Clock::Both), + ] + } + + /// A `Clock` value valid to encode *explicitly* on the wire (tag 7). + /// Excludes `Unspecified`/zero, which is only ever reached by omission. + fn explicit_clock_value_strategy() -> impl Strategy { + 1_i64..=3 + } + + fn server_fill_strategy() -> impl Strategy> { + prop_oneof![ + Just(None), + proptest::collection::vec(proptest::char::any(), 0..=8).prop_map(|chars| Some( + ServerFill { + value: chars.into_iter().collect(), + } + )), + ] + } + + fn params_strategy() -> impl Strategy { + ( + any::(), + any::(), + any::(), + any::(), + any::(), + received_stats_strategy(), + stamp_at_strategy(), + clock_strategy(), + server_fill_strategy(), + ) + .prop_map( + |( + protocol_version, + duration_ns, + interval_ns, + length, + dscp, + received_stats, + stamp_at, + clock, + server_fill, + )| Params { + protocol_version, + duration_ns, + interval_ns, + length, + received_stats, + stamp_at, + clock, + dscp, + server_fill, + }, + ) + } + + fn encode_tagged_int(tag: u64, value: i64, out: &mut Vec) { + varint::encode_uvarint(tag, out); + varint::encode_varint(value, out); + } + + fn encode_tagged_server_fill(value: &str, out: &mut Vec) { + varint::encode_uvarint(9, out); + varint::encode_uvarint(value.len() as u64, out); + out.extend_from_slice(value.as_bytes()); + } + + proptest! { + #[test] + fn valid_params_round_trip(params in params_strategy()) { + let encoded = params.encode(); + let decoded = Params::decode(&encoded).unwrap(); + prop_assert_eq!(decoded, params); + } + + #[test] + fn encoded_param_presence_matches_omission_rules(params in params_strategy()) { + let encoded = params.encode(); + let decoded = Params::decode_with_presence(&encoded).unwrap(); + prop_assert_eq!(&decoded.params, ¶ms); + + prop_assert_eq!(decoded.presence.protocol_version, params.protocol_version != 0); + prop_assert_eq!(decoded.presence.duration_ns, params.duration_ns != 0); + prop_assert_eq!(decoded.presence.interval_ns, params.interval_ns != 0); + prop_assert_eq!(decoded.presence.length, params.length != 0); + prop_assert_eq!(decoded.presence.dscp, params.dscp != 0); + prop_assert_eq!( + decoded.presence.received_stats, + params.received_stats != ReceivedStats::None + ); + prop_assert_eq!(decoded.presence.stamp_at, params.stamp_at != StampAt::None); + prop_assert_eq!(decoded.presence.clock, params.clock != Clock::Unspecified); + prop_assert_eq!(decoded.presence.server_fill, params.server_fill.is_some()); + } + + #[test] + fn repeated_protocol_version_is_last_wins(first: i64, second: i64) { + let mut encoded = Vec::new(); + encode_tagged_int(1, first, &mut encoded); + encode_tagged_int(1, second, &mut encoded); + let decoded = Params::decode_with_presence(&encoded).unwrap(); + prop_assert_eq!(decoded.params.protocol_version, second); + prop_assert!(decoded.presence.protocol_version); + } + + #[test] + fn repeated_duration_ns_is_last_wins(first: i64, second: i64) { + let mut encoded = Vec::new(); + encode_tagged_int(2, first, &mut encoded); + encode_tagged_int(2, second, &mut encoded); + let decoded = Params::decode_with_presence(&encoded).unwrap(); + prop_assert_eq!(decoded.params.duration_ns, second); + prop_assert!(decoded.presence.duration_ns); + } + + #[test] + fn repeated_interval_ns_is_last_wins(first: i64, second: i64) { + let mut encoded = Vec::new(); + encode_tagged_int(3, first, &mut encoded); + encode_tagged_int(3, second, &mut encoded); + let decoded = Params::decode_with_presence(&encoded).unwrap(); + prop_assert_eq!(decoded.params.interval_ns, second); + prop_assert!(decoded.presence.interval_ns); + } + + #[test] + fn repeated_length_is_last_wins(first: i64, second: i64) { + let mut encoded = Vec::new(); + encode_tagged_int(4, first, &mut encoded); + encode_tagged_int(4, second, &mut encoded); + let decoded = Params::decode_with_presence(&encoded).unwrap(); + prop_assert_eq!(decoded.params.length, second); + prop_assert!(decoded.presence.length); + } + + #[test] + fn repeated_received_stats_is_last_wins(first in 0_i64..=3, second in 0_i64..=3) { + let mut encoded = Vec::new(); + encode_tagged_int(5, first, &mut encoded); + encode_tagged_int(5, second, &mut encoded); + let decoded = Params::decode_with_presence(&encoded).unwrap(); + prop_assert_eq!(decoded.params.received_stats, ReceivedStats::try_from(second).unwrap()); + prop_assert!(decoded.presence.received_stats); + } + + #[test] + fn repeated_stamp_at_is_last_wins(first in 0_i64..=4, second in 0_i64..=4) { + let mut encoded = Vec::new(); + encode_tagged_int(6, first, &mut encoded); + encode_tagged_int(6, second, &mut encoded); + let decoded = Params::decode_with_presence(&encoded).unwrap(); + prop_assert_eq!(decoded.params.stamp_at, StampAt::try_from(second).unwrap()); + prop_assert!(decoded.presence.stamp_at); + } + + #[test] + fn repeated_clock_is_last_wins( + first in explicit_clock_value_strategy(), + second in explicit_clock_value_strategy(), + ) { + let mut encoded = Vec::new(); + encode_tagged_int(7, first, &mut encoded); + encode_tagged_int(7, second, &mut encoded); + let decoded = Params::decode_with_presence(&encoded).unwrap(); + prop_assert_eq!(decoded.params.clock, Clock::try_from(second).unwrap()); + prop_assert!(decoded.presence.clock); + } + + #[test] + fn repeated_dscp_is_last_wins(first: i64, second: i64) { + let mut encoded = Vec::new(); + encode_tagged_int(8, first, &mut encoded); + encode_tagged_int(8, second, &mut encoded); + let decoded = Params::decode_with_presence(&encoded).unwrap(); + prop_assert_eq!(decoded.params.dscp, second); + prop_assert!(decoded.presence.dscp); + } + + #[test] + fn repeated_server_fill_is_last_wins( + first in proptest::collection::vec(proptest::char::any(), 0..=8) + .prop_map(|chars| chars.into_iter().collect::()), + second in proptest::collection::vec(proptest::char::any(), 0..=8) + .prop_map(|chars| chars.into_iter().collect::()), + ) { + let mut encoded = Vec::new(); + encode_tagged_server_fill(&first, &mut encoded); + encode_tagged_server_fill(&second, &mut encoded); + let decoded = Params::decode_with_presence(&encoded).unwrap(); + prop_assert_eq!( + decoded.params.server_fill, + Some(ServerFill { value: second }) + ); + prop_assert!(decoded.presence.server_fill); + } + + #[test] + fn unknown_scalar_tag_prefix_does_not_disturb_known_params( + params in params_strategy(), + unknown_tag in 10_u64..=u16::MAX as u64, + unknown_value: u64, + ) { + let mut prefixed = Vec::new(); + varint::encode_uvarint(unknown_tag, &mut prefixed); + varint::encode_uvarint(unknown_value, &mut prefixed); + prefixed.extend_from_slice(¶ms.encode()); + + let decoded = Params::decode(&prefixed).unwrap(); + prop_assert_eq!(decoded, params.clone()); + + let decoded_with_presence = Params::decode_with_presence(&prefixed).unwrap(); + let expected_presence = Params::decode_with_presence(¶ms.encode()).unwrap().presence; + prop_assert_eq!(decoded_with_presence.presence, expected_presence); + } + + #[test] + fn unknown_scalar_tag_suffix_does_not_disturb_known_params( + params in params_strategy(), + unknown_tag in 10_u64..=u16::MAX as u64, + unknown_value: u64, + ) { + let mut suffixed = params.encode(); + varint::encode_uvarint(unknown_tag, &mut suffixed); + varint::encode_uvarint(unknown_value, &mut suffixed); + + let decoded = Params::decode(&suffixed).unwrap(); + prop_assert_eq!(decoded, params.clone()); + + let decoded_with_presence = Params::decode_with_presence(&suffixed).unwrap(); + let expected_presence = Params::decode_with_presence(¶ms.encode()).unwrap().presence; + prop_assert_eq!(decoded_with_presence.presence, expected_presence); + } + } + + /// Omitted-vs-explicit-zero presence, for the fields where explicit + /// zero is valid on the wire. `Clock` is deliberately excluded: an + /// explicit zero there is rejected outright, not merely "present with + /// the default value" (see `explicit_clock_zero_is_rejected`). + #[test] + fn omitted_vs_explicit_zero_presence_for_zero_valid_fields() { + let scalar_tags = [1_u64, 2, 3, 4, 8]; + for tag in scalar_tags { + let omitted = Params::decode_with_presence(&[]).unwrap(); + let mut explicit_zero = Vec::new(); + encode_tagged_int(tag, 0, &mut explicit_zero); + let explicit = Params::decode_with_presence(&explicit_zero).unwrap(); + + assert_eq!(omitted.params, explicit.params); + assert_eq!(explicit.params, Params::default()); + + let (omitted_presence, explicit_presence) = match tag { + 1 => ( + omitted.presence.protocol_version, + explicit.presence.protocol_version, + ), + 2 => (omitted.presence.duration_ns, explicit.presence.duration_ns), + 3 => (omitted.presence.interval_ns, explicit.presence.interval_ns), + 4 => (omitted.presence.length, explicit.presence.length), + 8 => (omitted.presence.dscp, explicit.presence.dscp), + _ => unreachable!(), + }; + assert!(!omitted_presence); + assert!(explicit_presence); + } + + let enum_tags = [5_u64, 6]; + for tag in enum_tags { + let omitted = Params::decode_with_presence(&[]).unwrap(); + let mut explicit_zero = Vec::new(); + encode_tagged_int(tag, 0, &mut explicit_zero); + let explicit = Params::decode_with_presence(&explicit_zero).unwrap(); + + assert_eq!(omitted.params, explicit.params); + + let (omitted_presence, explicit_presence) = match tag { + 5 => ( + omitted.presence.received_stats, + explicit.presence.received_stats, + ), + 6 => (omitted.presence.stamp_at, explicit.presence.stamp_at), + _ => unreachable!(), + }; + assert!(!omitted_presence); + assert!(explicit_presence); + } + + // `Clock` deliberately breaks the pattern above: explicit zero is + // rejected rather than accepted-but-present. + let omitted = Params::decode_with_presence(&[]).unwrap(); + assert_eq!(omitted.params.clock, Clock::Unspecified); + assert!(!omitted.presence.clock); + let mut explicit_clock_zero = Vec::new(); + encode_tagged_int(7, 0, &mut explicit_clock_zero); + assert_eq!( + Params::decode_with_presence(&explicit_clock_zero), + Err(ProtoError::InvalidEnum { + name: "Clock", + value: 0, + }) + ); + } + } } diff --git a/crates/irtt-proto/src/varint.rs b/crates/irtt-proto/src/varint.rs index 64c47d3..4f27ef3 100644 --- a/crates/irtt-proto/src/varint.rs +++ b/crates/irtt-proto/src/varint.rs @@ -112,4 +112,43 @@ mod tests { encode_uvarint(24, &mut tag); assert_eq!(tag, [0x18]); } + + mod properties { + use super::*; + use proptest::prelude::*; + + 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); + let (decoded, used) = decode_uvarint(&encoded).unwrap(); + prop_assert_eq!(decoded, value); + prop_assert_eq!(used, encoded.len()); + } + + #[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); + let (decoded, used) = decode_varint(&encoded).unwrap(); + prop_assert_eq!(decoded, value); + prop_assert_eq!(used, encoded.len()); + } + + #[test] + fn zigzag_round_trips_from_signed(value: i64) { + prop_assert_eq!(zigzag_decode(zigzag_encode(value)), value); + } + + #[test] + fn zigzag_round_trips_from_unsigned(value: u64) { + prop_assert_eq!(zigzag_encode(zigzag_decode(value)), value); + } + } + } }