From cf74887bdff508a4092539d0ec376f8799337a77 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Tue, 28 Apr 2026 15:36:40 +0700 Subject: [PATCH] feat(project): Establish initial IRTT workspace and protocol definitions --- .gitignore | 7 + Cargo.lock | 163 ++++++++++++++++++ Cargo.toml | 13 ++ crates/irtt-cli/Cargo.toml | 8 + crates/irtt-cli/src/main.rs | 1 + crates/irtt-client/Cargo.toml | 8 + crates/irtt-client/src/lib.rs | 4 + crates/irtt-proto/Cargo.toml | 12 ++ crates/irtt-proto/src/close.rs | 46 +++++ crates/irtt-proto/src/echo.rs | 204 ++++++++++++++++++++++ crates/irtt-proto/src/error.rs | 35 ++++ crates/irtt-proto/src/flags.rs | 20 +++ crates/irtt-proto/src/hmac.rs | 71 ++++++++ crates/irtt-proto/src/layout.rs | 191 +++++++++++++++++++++ crates/irtt-proto/src/lib.rs | 52 ++++++ crates/irtt-proto/src/open.rs | 143 ++++++++++++++++ crates/irtt-proto/src/params.rs | 235 ++++++++++++++++++++++++++ crates/irtt-proto/src/varint.rs | 115 +++++++++++++ crates/irtt-proto/tests/vectors.rs | 261 +++++++++++++++++++++++++++++ crates/irtt-stats/Cargo.toml | 8 + crates/irtt-stats/src/lib.rs | 4 + 21 files changed, 1601 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/irtt-cli/Cargo.toml create mode 100644 crates/irtt-cli/src/main.rs create mode 100644 crates/irtt-client/Cargo.toml create mode 100644 crates/irtt-client/src/lib.rs create mode 100644 crates/irtt-proto/Cargo.toml create mode 100644 crates/irtt-proto/src/close.rs create mode 100644 crates/irtt-proto/src/echo.rs create mode 100644 crates/irtt-proto/src/error.rs create mode 100644 crates/irtt-proto/src/flags.rs create mode 100644 crates/irtt-proto/src/hmac.rs create mode 100644 crates/irtt-proto/src/layout.rs create mode 100644 crates/irtt-proto/src/lib.rs create mode 100644 crates/irtt-proto/src/open.rs create mode 100644 crates/irtt-proto/src/params.rs create mode 100644 crates/irtt-proto/src/varint.rs create mode 100644 crates/irtt-proto/tests/vectors.rs create mode 100644 crates/irtt-stats/Cargo.toml create mode 100644 crates/irtt-stats/src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46ca93e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Generated by Cargo +# will have compiled files and executables +target/ +*/target/ + +# These are backup files generated by rustfmt +**/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..acf5046 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,163 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "irtt-cli" +version = "0.1.0" + +[[package]] +name = "irtt-client" +version = "0.1.0" + +[[package]] +name = "irtt-proto" +version = "0.1.0" +dependencies = [ + "hmac", + "md-5", + "subtle", + "thiserror", +] + +[[package]] +name = "irtt-stats" +version = "0.1.0" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1c5cfe6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] +members = [ + "crates/irtt-proto", + "crates/irtt-client", + "crates/irtt-stats", + "crates/irtt-cli", +] +resolver = "2" + +[workspace.package] +edition = "2021" +license = "Apache-2.0" +rust-version = "1.75" diff --git a/crates/irtt-cli/Cargo.toml b/crates/irtt-cli/Cargo.toml new file mode 100644 index 0000000..ac154fb --- /dev/null +++ b/crates/irtt-cli/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "irtt-cli" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] diff --git a/crates/irtt-cli/src/main.rs b/crates/irtt-cli/src/main.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/crates/irtt-cli/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/crates/irtt-client/Cargo.toml b/crates/irtt-client/Cargo.toml new file mode 100644 index 0000000..0f1e1ee --- /dev/null +++ b/crates/irtt-client/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "irtt-client" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] diff --git a/crates/irtt-client/src/lib.rs b/crates/irtt-client/src/lib.rs new file mode 100644 index 0000000..34a1196 --- /dev/null +++ b/crates/irtt-client/src/lib.rs @@ -0,0 +1,4 @@ +//! Client crate skeleton. +//! +//! Networking and session runtime are intentionally out of scope for the +//! current milestone. diff --git a/crates/irtt-proto/Cargo.toml b/crates/irtt-proto/Cargo.toml new file mode 100644 index 0000000..84abebb --- /dev/null +++ b/crates/irtt-proto/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "irtt-proto" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +hmac = "0.12" +md-5 = "0.10" +subtle = "2" +thiserror = "1" diff --git a/crates/irtt-proto/src/close.rs b/crates/irtt-proto/src/close.rs new file mode 100644 index 0000000..11b7017 --- /dev/null +++ b/crates/irtt-proto/src/close.rs @@ -0,0 +1,46 @@ +use crate::{ + flags::{FLAG_CLOSE, FLAG_HMAC}, + hmac, write_header, Result, HMAC_SIZE, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CloseRequest { + pub token: u64, +} + +pub fn encode_close_request(request: &CloseRequest, hmac_key: Option<&[u8]>) -> Result> { + let mut flags = FLAG_CLOSE; + if hmac_key.is_some() { + flags |= FLAG_HMAC; + } + + let mut out = Vec::new(); + write_header(&mut out, flags); + if hmac_key.is_some() { + out.extend_from_slice(&[0; HMAC_SIZE]); + } + out.extend_from_slice(&request.token.to_le_bytes()); + if let Some(key) = hmac_key { + hmac::compute_hmac_in_place(key, &mut out, hmac::hmac_offset())?; + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn close_request_sizes() { + let request = CloseRequest { + token: 0x7896_b6ab_8771_5213, + }; + assert_eq!(encode_close_request(&request, None).unwrap().len(), 12); + assert_eq!( + encode_close_request(&request, Some(b"testkey")) + .unwrap() + .len(), + 28 + ); + } +} diff --git a/crates/irtt-proto/src/echo.rs b/crates/irtt-proto/src/echo.rs new file mode 100644 index 0000000..8263d2f --- /dev/null +++ b/crates/irtt-proto/src/echo.rs @@ -0,0 +1,204 @@ +use crate::{ + flags::{has, FLAG_CLOSE, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY}, + hmac, + layout::{echo_packet_len, PacketLayout}, + open::{check_hmac_presence, reject}, + params::Params, + validate_header, write_header, ProtoError, Result, HEADER_SIZE, HMAC_SIZE, RECV_COUNT_SIZE, + RECV_WINDOW_SIZE, SEQ_SIZE, TIMESTAMP_SIZE, TOKEN_SIZE, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EchoRequest { + pub token: u64, + pub sequence: u32, + pub params: Params, + pub payload: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EchoReply { + pub flags: u8, + pub token: u64, + pub sequence: u32, + pub recv_count: Option, + pub recv_window: Option, + pub timestamps: TimestampFields, + pub payload: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TimestampFields { + pub recv_wall: Option, + pub recv_mono: Option, + pub midpoint_wall: Option, + pub midpoint_mono: Option, + pub send_wall: Option, + pub send_mono: Option, +} + +pub fn encode_echo_request(request: &EchoRequest, hmac_key: Option<&[u8]>) -> Result> { + let mut flags = 0; + if hmac_key.is_some() { + flags |= FLAG_HMAC; + } + let layout = PacketLayout::echo(hmac_key.is_some(), &request.params); + let len = echo_packet_len(hmac_key.is_some(), &request.params); + let mut out = Vec::with_capacity(len); + write_header(&mut out, flags); + if hmac_key.is_some() { + out.extend_from_slice(&[0; HMAC_SIZE]); + } + out.extend_from_slice(&request.token.to_le_bytes()); + out.extend_from_slice(&request.sequence.to_le_bytes()); + push_zeroed_layout_tail(layout, &mut out); + out.resize(len, 0); + let payload_offset = layout.header_len(); + let payload_len = out + .len() + .saturating_sub(payload_offset) + .min(request.payload.len()); + out[payload_offset..payload_offset + payload_len] + .copy_from_slice(&request.payload[..payload_len]); + + if let Some(key) = hmac_key { + hmac::compute_hmac_in_place(key, &mut out, hmac::hmac_offset())?; + } + Ok(out) +} + +pub fn decode_echo_reply( + packet: &[u8], + params: &Params, + hmac_key: Option<&[u8]>, +) -> Result { + let flags = validate_header(packet)?; + reject(flags, FLAG_OPEN)?; + crate::open::require(flags, FLAG_REPLY)?; + check_hmac_presence(flags, hmac_key)?; + if let Some(key) = hmac_key { + hmac::verify_hmac(key, packet, hmac::hmac_offset())?; + } + + let layout = PacketLayout::echo(has(flags, FLAG_HMAC), params); + let header_len = layout.header_len(); + if packet.len() < header_len { + return Err(ProtoError::PacketTooShort { + needed: header_len, + actual: packet.len(), + }); + } + + let mut pos = HEADER_SIZE; + if layout.hmac { + pos += HMAC_SIZE; + } + let token = read_u64(packet, &mut pos); + let sequence = read_u32(packet, &mut pos); + let recv_count = layout.recv_count.then(|| read_u32(packet, &mut pos)); + let recv_window = layout.recv_window.then(|| read_u64(packet, &mut pos)); + let timestamps = TimestampFields { + recv_wall: layout.recv_wall.then(|| read_i64(packet, &mut pos)), + recv_mono: layout.recv_mono.then(|| read_i64(packet, &mut pos)), + midpoint_wall: layout.midpoint_wall.then(|| read_i64(packet, &mut pos)), + midpoint_mono: layout.midpoint_mono.then(|| read_i64(packet, &mut pos)), + send_wall: layout.send_wall.then(|| read_i64(packet, &mut pos)), + send_mono: layout.send_mono.then(|| read_i64(packet, &mut pos)), + }; + + Ok(EchoReply { + flags, + token, + sequence, + recv_count, + recv_window, + timestamps, + payload: packet[header_len..].to_vec(), + }) +} + +fn push_zeroed_layout_tail(layout: PacketLayout, out: &mut Vec) { + if layout.recv_count { + out.extend_from_slice(&[0; RECV_COUNT_SIZE]); + } + if layout.recv_window { + out.extend_from_slice(&[0; RECV_WINDOW_SIZE]); + } + for _ in 0..layout.timestamp_count() { + out.extend_from_slice(&[0; TIMESTAMP_SIZE]); + } +} + +fn read_u32(packet: &[u8], pos: &mut usize) -> u32 { + let value = u32::from_le_bytes(packet[*pos..*pos + SEQ_SIZE].try_into().unwrap()); + *pos += SEQ_SIZE; + value +} + +fn read_u64(packet: &[u8], pos: &mut usize) -> u64 { + let value = u64::from_le_bytes(packet[*pos..*pos + TOKEN_SIZE].try_into().unwrap()); + *pos += TOKEN_SIZE; + value +} + +fn read_i64(packet: &[u8], pos: &mut usize) -> i64 { + let value = i64::from_le_bytes(packet[*pos..*pos + TIMESTAMP_SIZE].try_into().unwrap()); + *pos += TIMESTAMP_SIZE; + value +} + +#[allow(dead_code)] +fn _assert_close_flag_imported(_: u8) { + let _ = FLAG_CLOSE; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::params::{Clock, ReceivedStats, StampAt}; + + fn default_params() -> Params { + Params { + received_stats: ReceivedStats::Both, + stamp_at: StampAt::Both, + clock: Clock::Both, + ..Params::default() + } + } + + #[test] + fn echo_request_encodes_default_placeholders() { + let packet = encode_echo_request( + &EchoRequest { + token: 0x7896_b6ab_8771_5213, + sequence: 0, + params: default_params(), + payload: Vec::new(), + }, + None, + ) + .unwrap(); + assert_eq!(packet.len(), 60); + assert_eq!(&packet[..4], &[0x14, 0xa7, 0x5b, 0x00]); + assert_eq!(&packet[4..12], &0x7896_b6ab_8771_5213u64.to_le_bytes()); + assert!(packet[12..].iter().all(|byte| *byte == 0)); + } + + #[test] + fn echo_reply_decodes_default_fields() { + let packet = [ + 0x14, 0xa7, 0x5b, 0x02, 0x13, 0x52, 0x71, 0x87, 0xab, 0xb6, 0x96, 0x78, 0x02, 0x00, + 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xb8, 0x1a, 0x33, 0x0c, 0x86, 0x6d, 0xaa, 0x18, 0xde, 0x26, 0x35, 0x95, 0x00, 0x00, + 0x00, 0x00, 0x80, 0x4d, 0x33, 0x0c, 0x86, 0x6d, 0xaa, 0x18, 0xb2, 0x57, 0x35, 0x95, + 0x00, 0x00, 0x00, 0x00, + ]; + let reply = decode_echo_reply(&packet, &default_params(), None).unwrap(); + assert_eq!(reply.token, 0x7896_b6ab_8771_5213); + assert_eq!(reply.sequence, 2); + assert_eq!(reply.recv_count, Some(3)); + assert_eq!(reply.recv_window, Some(7)); + assert!(reply.timestamps.recv_wall.is_some()); + assert!(reply.timestamps.midpoint_wall.is_none()); + } +} diff --git a/crates/irtt-proto/src/error.rs b/crates/irtt-proto/src/error.rs new file mode 100644 index 0000000..266e72a --- /dev/null +++ b/crates/irtt-proto/src/error.rs @@ -0,0 +1,35 @@ +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ProtoError { + #[error("packet is too short: needed {needed} bytes, got {actual}")] + PacketTooShort { needed: usize, actual: usize }, + #[error("bad magic bytes")] + BadMagic, + #[error("reserved flag bits are set: 0x{0:02x}")] + ReservedFlags(u8), + #[error("required flag 0x{0:02x} is missing")] + MissingFlag(u8), + #[error("unexpected flag 0x{0:02x} is set")] + UnexpectedFlag(u8), + #[error("HMAC flag/key mismatch")] + HmacPresenceMismatch, + #[error("HMAC verification failed")] + BadHmac, + #[error("invalid HMAC field offset")] + InvalidHmacOffset, + #[error("varint is truncated")] + TruncatedVarint, + #[error("varint is too long")] + VarintOverflow, + #[error("string value is not valid UTF-8")] + InvalidUtf8, + #[error("invalid enum value {value} for {name}")] + InvalidEnum { name: &'static str, value: i64 }, + #[error("open reply has zero token without close flag")] + ZeroToken, + #[error("trailing or malformed parameter payload")] + MalformedParams, +} diff --git a/crates/irtt-proto/src/flags.rs b/crates/irtt-proto/src/flags.rs new file mode 100644 index 0000000..20d75b5 --- /dev/null +++ b/crates/irtt-proto/src/flags.rs @@ -0,0 +1,20 @@ +use crate::{error::Result, ProtoError}; + +pub const FLAG_OPEN: u8 = 0x01; +pub const FLAG_REPLY: u8 = 0x02; +pub const FLAG_CLOSE: u8 = 0x04; +pub const FLAG_HMAC: u8 = 0x08; + +pub const RESERVED_FLAGS_MASK: u8 = 0xF0; + +pub fn validate_flags(flags: u8) -> Result<()> { + let reserved = flags & RESERVED_FLAGS_MASK; + if reserved != 0 { + return Err(ProtoError::ReservedFlags(reserved)); + } + Ok(()) +} + +pub fn has(flags: u8, flag: u8) -> bool { + flags & flag != 0 +} diff --git a/crates/irtt-proto/src/hmac.rs b/crates/irtt-proto/src/hmac.rs new file mode 100644 index 0000000..081eccb --- /dev/null +++ b/crates/irtt-proto/src/hmac.rs @@ -0,0 +1,71 @@ +use hmac::{Hmac, Mac}; +use md5::Md5; +use subtle::ConstantTimeEq; + +use crate::{flags::FLAG_HMAC, ProtoError, Result, HEADER_SIZE, HMAC_SIZE}; + +type HmacMd5 = Hmac; + +pub fn compute_hmac(key: &[u8], packet: &[u8], hmac_offset: usize) -> Result<[u8; HMAC_SIZE]> { + if packet.len().saturating_sub(hmac_offset) < HMAC_SIZE { + return Err(ProtoError::InvalidHmacOffset); + } + let mut copy = packet.to_vec(); + copy[hmac_offset..hmac_offset + HMAC_SIZE].fill(0); + let mut mac = HmacMd5::new_from_slice(key).expect("HMAC accepts keys of any size"); + mac.update(©); + let bytes = mac.finalize().into_bytes(); + let mut out = [0u8; HMAC_SIZE]; + out.copy_from_slice(&bytes); + Ok(out) +} + +pub fn compute_hmac_in_place(key: &[u8], packet: &mut [u8], hmac_offset: usize) -> Result<()> { + if packet.len().saturating_sub(hmac_offset) < HMAC_SIZE { + return Err(ProtoError::InvalidHmacOffset); + } + packet[3] |= FLAG_HMAC; + packet[hmac_offset..hmac_offset + HMAC_SIZE].fill(0); + let digest = compute_hmac(key, packet, hmac_offset)?; + packet[hmac_offset..hmac_offset + HMAC_SIZE].copy_from_slice(&digest); + Ok(()) +} + +pub fn verify_hmac(key: &[u8], packet: &[u8], hmac_offset: usize) -> Result<()> { + if packet.len().saturating_sub(hmac_offset) < HMAC_SIZE { + return Err(ProtoError::InvalidHmacOffset); + } + let expected = compute_hmac(key, packet, hmac_offset)?; + let actual = &packet[hmac_offset..hmac_offset + HMAC_SIZE]; + if expected.as_slice().ct_eq(actual).into() { + Ok(()) + } else { + Err(ProtoError::BadHmac) + } +} + +pub(crate) fn hmac_offset() -> usize { + HEADER_SIZE +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{flags::FLAG_OPEN, MAGIC}; + + #[test] + fn compute_and_verify_hmac() { + let mut packet = Vec::new(); + packet.extend_from_slice(&MAGIC); + packet.push(FLAG_OPEN | FLAG_HMAC); + packet.extend_from_slice(&[0; HMAC_SIZE]); + packet.extend_from_slice(&[1, 2, 3, 4]); + + compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap(); + verify_hmac(b"testkey", &packet, hmac_offset()).unwrap(); + assert_eq!( + verify_hmac(b"wrong", &packet, hmac_offset()), + Err(ProtoError::BadHmac) + ); + } +} diff --git a/crates/irtt-proto/src/layout.rs b/crates/irtt-proto/src/layout.rs new file mode 100644 index 0000000..e8460f4 --- /dev/null +++ b/crates/irtt-proto/src/layout.rs @@ -0,0 +1,191 @@ +use crate::{ + params::{Clock, Params, StampAt}, + HEADER_SIZE, HMAC_SIZE, RECV_COUNT_SIZE, RECV_WINDOW_SIZE, SEQ_SIZE, TIMESTAMP_SIZE, + TOKEN_SIZE, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PacketLayout { + pub hmac: bool, + pub token: bool, + pub sequence: bool, + pub recv_count: bool, + pub recv_window: bool, + pub recv_wall: bool, + pub recv_mono: bool, + pub midpoint_wall: bool, + pub midpoint_mono: bool, + pub send_wall: bool, + pub send_mono: bool, +} + +impl PacketLayout { + pub fn open_request(hmac: bool) -> Self { + Self { + hmac, + token: false, + sequence: false, + recv_count: false, + recv_window: false, + recv_wall: false, + recv_mono: false, + midpoint_wall: false, + midpoint_mono: false, + send_wall: false, + send_mono: false, + } + } + + pub fn open_reply(hmac: bool) -> Self { + Self { + token: true, + ..Self::open_request(hmac) + } + } + + pub fn echo(hmac: bool, params: &Params) -> Self { + let clock = params.clock; + Self { + hmac, + token: true, + sequence: true, + recv_count: params.received_stats.has_count(), + recv_window: params.received_stats.has_window(), + recv_wall: matches!(params.stamp_at, StampAt::Receive | StampAt::Both) + && clock.has_wall(), + recv_mono: matches!(params.stamp_at, StampAt::Receive | StampAt::Both) + && clock.has_mono(), + midpoint_wall: matches!(params.stamp_at, StampAt::Midpoint) && clock.has_wall(), + midpoint_mono: matches!(params.stamp_at, StampAt::Midpoint) && clock.has_mono(), + send_wall: matches!(params.stamp_at, StampAt::Send | StampAt::Both) && clock.has_wall(), + send_mono: matches!(params.stamp_at, StampAt::Send | StampAt::Both) && clock.has_mono(), + } + } + + pub fn close_request(hmac: bool) -> Self { + Self { + hmac, + token: true, + sequence: false, + recv_count: false, + recv_window: false, + recv_wall: false, + recv_mono: false, + midpoint_wall: false, + midpoint_mono: false, + send_wall: false, + send_mono: false, + } + } + + pub fn header_len(self) -> usize { + HEADER_SIZE + + if self.hmac { HMAC_SIZE } else { 0 } + + if self.token { TOKEN_SIZE } else { 0 } + + if self.sequence { SEQ_SIZE } else { 0 } + + if self.recv_count { RECV_COUNT_SIZE } else { 0 } + + if self.recv_window { + RECV_WINDOW_SIZE + } else { + 0 + } + + self.timestamp_count() * TIMESTAMP_SIZE + } + + pub fn timestamp_count(self) -> usize { + [ + self.recv_wall, + self.recv_mono, + self.midpoint_wall, + self.midpoint_mono, + self.send_wall, + self.send_mono, + ] + .into_iter() + .filter(|present| *present) + .count() + } +} + +pub fn echo_header_len(hmac: bool, params: &Params) -> usize { + PacketLayout::echo(hmac, params).header_len() +} + +pub fn echo_packet_len(hmac: bool, params: &Params) -> usize { + let header_len = echo_header_len(hmac, params); + let requested = usize::try_from(params.length).unwrap_or(0); + header_len.max(requested) +} + +#[allow(dead_code)] +fn _assert_clock_is_used(_: Clock) {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::params::{ReceivedStats, StampAt}; + + fn params(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> Params { + Params { + received_stats: stats, + stamp_at, + clock, + ..Params::default() + } + } + + #[test] + fn verified_layout_lengths() { + assert_eq!( + echo_header_len( + false, + ¶ms(ReceivedStats::None, StampAt::None, Clock::None) + ), + 16 + ); + assert_eq!( + echo_header_len( + false, + ¶ms(ReceivedStats::Count, StampAt::Send, Clock::Wall) + ), + 28 + ); + assert_eq!( + echo_header_len( + false, + ¶ms(ReceivedStats::Window, StampAt::Receive, Clock::Monotonic) + ), + 32 + ); + assert_eq!( + echo_header_len( + false, + ¶ms(ReceivedStats::Both, StampAt::Midpoint, Clock::Both) + ), + 44 + ); + assert_eq!( + echo_header_len( + false, + ¶ms(ReceivedStats::Both, StampAt::Both, Clock::Both) + ), + 60 + ); + assert_eq!( + echo_header_len( + true, + ¶ms(ReceivedStats::Both, StampAt::Both, Clock::Both) + ), + 76 + ); + } + + #[test] + fn negotiated_length_never_smaller_than_header() { + let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both); + p.length = 20; + assert_eq!(echo_packet_len(false, &p), 60); + p.length = 92; + assert_eq!(echo_packet_len(false, &p), 92); + } +} diff --git a/crates/irtt-proto/src/lib.rs b/crates/irtt-proto/src/lib.rs new file mode 100644 index 0000000..36311a9 --- /dev/null +++ b/crates/irtt-proto/src/lib.rs @@ -0,0 +1,52 @@ +#![forbid(unsafe_code)] + +pub mod close; +pub mod echo; +pub mod error; +pub mod flags; +pub mod hmac; +pub mod layout; +pub mod open; +pub mod params; +pub mod varint; + +pub use close::{encode_close_request, CloseRequest}; +pub use echo::{decode_echo_reply, encode_echo_request, EchoReply, EchoRequest, TimestampFields}; +pub use error::{ProtoError, Result}; +pub use flags::*; +pub use hmac::{compute_hmac, compute_hmac_in_place, verify_hmac}; +pub use layout::{echo_header_len, echo_packet_len, PacketLayout}; +pub use open::{decode_open_reply, encode_open_request, OpenReply, OpenRequest}; +pub use params::{Clock, Params, ReceivedStats, ServerFill, StampAt}; + +pub const MAGIC: [u8; 3] = [0x14, 0xA7, 0x5B]; +pub const PROTOCOL_VERSION: i64 = 1; + +pub const HMAC_SIZE: usize = 16; +pub const TOKEN_SIZE: usize = 8; +pub const SEQ_SIZE: usize = 4; +pub const RECV_COUNT_SIZE: usize = 4; +pub const RECV_WINDOW_SIZE: usize = 8; +pub const TIMESTAMP_SIZE: usize = 8; + +pub(crate) const HEADER_SIZE: usize = 4; + +pub(crate) fn write_header(out: &mut Vec, flags: u8) { + out.extend_from_slice(&MAGIC); + out.push(flags); +} + +pub(crate) fn validate_header(packet: &[u8]) -> Result { + if packet.len() < HEADER_SIZE { + return Err(ProtoError::PacketTooShort { + needed: HEADER_SIZE, + actual: packet.len(), + }); + } + if packet[..3] != MAGIC { + return Err(ProtoError::BadMagic); + } + let flags = packet[3]; + flags::validate_flags(flags)?; + Ok(flags) +} diff --git a/crates/irtt-proto/src/open.rs b/crates/irtt-proto/src/open.rs new file mode 100644 index 0000000..d33ccab --- /dev/null +++ b/crates/irtt-proto/src/open.rs @@ -0,0 +1,143 @@ +use crate::{ + flags::{has, FLAG_CLOSE, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY}, + hmac, + params::Params, + validate_header, write_header, ProtoError, Result, HEADER_SIZE, HMAC_SIZE, TOKEN_SIZE, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenRequest { + pub params: Params, + pub close: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenReply { + pub flags: u8, + pub token: u64, + pub params: Params, +} + +pub fn encode_open_request(request: &OpenRequest, hmac_key: Option<&[u8]>) -> Result> { + let mut flags = FLAG_OPEN; + if request.close { + flags |= FLAG_CLOSE; + } + if hmac_key.is_some() { + flags |= FLAG_HMAC; + } + + let mut out = Vec::new(); + write_header(&mut out, flags); + if hmac_key.is_some() { + out.extend_from_slice(&[0; HMAC_SIZE]); + } + out.extend_from_slice(&request.params.encode()); + + if let Some(key) = hmac_key { + hmac::compute_hmac_in_place(key, &mut out, hmac::hmac_offset())?; + } + Ok(out) +} + +pub fn decode_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> Result { + let flags = validate_header(packet)?; + require(flags, FLAG_OPEN)?; + require(flags, FLAG_REPLY)?; + check_hmac_presence(flags, hmac_key)?; + if let Some(key) = hmac_key { + hmac::verify_hmac(key, packet, hmac::hmac_offset())?; + } + + let mut pos = HEADER_SIZE; + if has(flags, FLAG_HMAC) { + pos += HMAC_SIZE; + } + let needed = pos + TOKEN_SIZE; + if packet.len() < needed { + return Err(ProtoError::PacketTooShort { + needed, + actual: packet.len(), + }); + } + let token = u64::from_le_bytes(packet[pos..pos + TOKEN_SIZE].try_into().unwrap()); + pos += TOKEN_SIZE; + if token == 0 && !has(flags, FLAG_CLOSE) { + return Err(ProtoError::ZeroToken); + } + + Ok(OpenReply { + flags, + token, + params: Params::decode(&packet[pos..])?, + }) +} + +pub(crate) fn require(flags: u8, flag: u8) -> Result<()> { + if has(flags, flag) { + Ok(()) + } else { + Err(ProtoError::MissingFlag(flag)) + } +} + +pub(crate) fn reject(flags: u8, flag: u8) -> Result<()> { + if has(flags, flag) { + Err(ProtoError::UnexpectedFlag(flag)) + } else { + Ok(()) + } +} + +pub(crate) fn check_hmac_presence(flags: u8, hmac_key: Option<&[u8]>) -> Result<()> { + if has(flags, FLAG_HMAC) == hmac_key.is_some() { + Ok(()) + } else { + Err(ProtoError::HmacPresenceMismatch) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::params::{Clock, ReceivedStats, StampAt}; + + fn default_params() -> Params { + Params { + protocol_version: 1, + duration_ns: 3_000_000_000, + interval_ns: 1_000_000_000, + received_stats: ReceivedStats::Both, + stamp_at: StampAt::Both, + clock: Clock::Both, + ..Params::default() + } + } + + #[test] + fn open_request_has_no_token() { + let packet = encode_open_request( + &OpenRequest { + params: default_params(), + close: false, + }, + None, + ) + .unwrap(); + + assert_eq!(packet.len(), 24); + assert_eq!(&packet[..4], &[0x14, 0xa7, 0x5b, 0x01]); + assert_eq!(&packet[4..], &default_params().encode()); + } + + #[test] + fn open_reply_decodes_token() { + let mut packet = vec![0x14, 0xa7, 0x5b, 0x03]; + packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes()); + packet.extend_from_slice(&default_params().encode()); + + let reply = decode_open_reply(&packet, None).unwrap(); + assert_eq!(reply.token, 0x7896_b6ab_8771_5213); + assert_eq!(reply.params, default_params()); + } +} diff --git a/crates/irtt-proto/src/params.rs b/crates/irtt-proto/src/params.rs new file mode 100644 index 0000000..500dcf8 --- /dev/null +++ b/crates/irtt-proto/src/params.rs @@ -0,0 +1,235 @@ +use crate::{varint, ProtoError, Result, PROTOCOL_VERSION}; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Params { + pub protocol_version: i64, + pub duration_ns: i64, + pub interval_ns: i64, + pub length: i64, + pub received_stats: ReceivedStats, + pub stamp_at: StampAt, + pub clock: Clock, + pub dscp: i64, + pub server_fill: Option, +} + +impl Params { + pub fn with_protocol_defaults() -> Self { + Self { + protocol_version: PROTOCOL_VERSION, + ..Self::default() + } + } + + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + push_int(1, self.protocol_version, &mut out); + push_int(2, self.duration_ns, &mut out); + push_int(3, self.interval_ns, &mut out); + push_int(4, self.length, &mut out); + push_int(5, self.received_stats as i64, &mut out); + push_int(6, self.stamp_at as i64, &mut out); + push_int(7, self.clock as i64, &mut out); + push_int(8, self.dscp, &mut out); + if let Some(fill) = &self.server_fill { + varint::encode_uvarint(9, &mut out); + varint::encode_uvarint(fill.value.len() as u64, &mut out); + out.extend_from_slice(fill.value.as_bytes()); + } + out + } + + pub fn decode(input: &[u8]) -> Result { + let mut params = Self::default(); + let mut pos = 0; + while pos < input.len() { + let (tag, used) = varint::decode_uvarint(&input[pos..])?; + pos += used; + match tag { + 1 => params.protocol_version = read_int(input, &mut pos)?, + 2 => params.duration_ns = read_int(input, &mut pos)?, + 3 => params.interval_ns = read_int(input, &mut pos)?, + 4 => params.length = read_int(input, &mut pos)?, + 5 => params.received_stats = ReceivedStats::try_from(read_int(input, &mut pos)?)?, + 6 => params.stamp_at = StampAt::try_from(read_int(input, &mut pos)?)?, + 7 => params.clock = Clock::try_from(read_int(input, &mut pos)?)?, + 8 => params.dscp = read_int(input, &mut pos)?, + 9 => { + let (len, used) = varint::decode_uvarint(&input[pos..])?; + pos += used; + let len = len as usize; + if input.len().saturating_sub(pos) < len { + return Err(ProtoError::MalformedParams); + } + let value = std::str::from_utf8(&input[pos..pos + len]) + .map_err(|_| ProtoError::InvalidUtf8)? + .to_owned(); + pos += len; + params.server_fill = Some(ServerFill { value }); + } + _ => { + let (_, used) = varint::decode_uvarint(&input[pos..])?; + pos += used; + } + } + } + Ok(params) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerFill { + pub value: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(i64)] +pub enum ReceivedStats { + #[default] + None = 0, + Count = 1, + Window = 2, + Both = 3, +} + +impl ReceivedStats { + pub fn has_count(self) -> bool { + matches!(self, Self::Count | Self::Both) + } + + pub fn has_window(self) -> bool { + matches!(self, Self::Window | Self::Both) + } +} + +impl TryFrom for ReceivedStats { + type Error = ProtoError; + + fn try_from(value: i64) -> Result { + match value { + 0 => Ok(Self::None), + 1 => Ok(Self::Count), + 2 => Ok(Self::Window), + 3 => Ok(Self::Both), + _ => Err(ProtoError::InvalidEnum { + name: "ReceivedStats", + value, + }), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(i64)] +pub enum StampAt { + #[default] + None = 0, + Send = 1, + Receive = 2, + Both = 3, + Midpoint = 4, +} + +impl TryFrom for StampAt { + type Error = ProtoError; + + fn try_from(value: i64) -> Result { + match value { + 0 => Ok(Self::None), + 1 => Ok(Self::Send), + 2 => Ok(Self::Receive), + 3 => Ok(Self::Both), + 4 => Ok(Self::Midpoint), + _ => Err(ProtoError::InvalidEnum { + name: "StampAt", + value, + }), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(i64)] +pub enum Clock { + #[default] + None = 0, + Wall = 1, + Monotonic = 2, + Both = 3, +} + +impl Clock { + pub fn has_wall(self) -> bool { + matches!(self, Self::Wall | Self::Both) + } + + pub fn has_mono(self) -> bool { + matches!(self, Self::Monotonic | Self::Both) + } +} + +impl TryFrom for Clock { + type Error = ProtoError; + + fn try_from(value: i64) -> Result { + match value { + 0 => Ok(Self::None), + 1 => Ok(Self::Wall), + 2 => Ok(Self::Monotonic), + 3 => Ok(Self::Both), + _ => Err(ProtoError::InvalidEnum { + name: "Clock", + value, + }), + } + } +} + +fn push_int(tag: u64, value: i64, out: &mut Vec) { + if value == 0 { + return; + } + varint::encode_uvarint(tag, out); + varint::encode_varint(value, out); +} + +fn read_int(input: &[u8], pos: &mut usize) -> Result { + let (value, used) = varint::decode_varint(&input[*pos..])?; + *pos += used; + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn params_round_trip() { + let params = Params { + protocol_version: 1, + duration_ns: 3_000_000_000, + interval_ns: 1_000_000_000, + length: 1472, + received_stats: ReceivedStats::Both, + stamp_at: StampAt::Both, + clock: Clock::Both, + dscp: 184, + server_fill: Some(ServerFill { + value: "pattern:abc".to_owned(), + }), + }; + assert_eq!(Params::decode(¶ms.encode()), Ok(params)); + } + + #[test] + fn unknown_tags_are_ignored() { + let mut encoded = Vec::new(); + varint::encode_uvarint(99, &mut encoded); + varint::encode_varint(123, &mut encoded); + varint::encode_uvarint(1, &mut encoded); + varint::encode_varint(1, &mut encoded); + + let params = Params::decode(&encoded).unwrap(); + assert_eq!(params.protocol_version, 1); + } +} diff --git a/crates/irtt-proto/src/varint.rs b/crates/irtt-proto/src/varint.rs new file mode 100644 index 0000000..64c47d3 --- /dev/null +++ b/crates/irtt-proto/src/varint.rs @@ -0,0 +1,115 @@ +use crate::{ProtoError, Result}; + +pub fn encode_uvarint(mut value: u64, out: &mut Vec) { + while value >= 0x80 { + out.push((value as u8) | 0x80); + value >>= 7; + } + out.push(value as u8); +} + +pub fn decode_uvarint(input: &[u8]) -> Result<(u64, usize)> { + let mut value = 0u64; + for (idx, byte) in input.iter().copied().enumerate() { + if idx == 10 { + return Err(ProtoError::VarintOverflow); + } + let low = u64::from(byte & 0x7f); + let shift = idx * 7; + if shift == 63 && low > 1 { + return Err(ProtoError::VarintOverflow); + } + value |= low << shift; + if byte < 0x80 { + return Ok((value, idx + 1)); + } + } + Err(ProtoError::TruncatedVarint) +} + +pub fn zigzag_encode(value: i64) -> u64 { + ((value << 1) ^ (value >> 63)) as u64 +} + +pub fn zigzag_decode(value: u64) -> i64 { + ((value >> 1) as i64) ^ (-((value & 1) as i64)) +} + +pub fn encode_varint(value: i64, out: &mut Vec) { + encode_uvarint(zigzag_encode(value), out); +} + +pub fn decode_varint(input: &[u8]) -> Result<(i64, usize)> { + let (value, used) = decode_uvarint(input)?; + Ok((zigzag_decode(value), used)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uvarint_round_trips() { + 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()))); + } + } + + #[test] + fn signed_varint_round_trips() { + 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()))); + } + + let mut tag = Vec::new(); + encode_uvarint(9, &mut tag); + assert_eq!(tag, [0x09]); + tag.clear(); + encode_uvarint(24, &mut tag); + assert_eq!(tag, [0x18]); + } +} diff --git a/crates/irtt-proto/tests/vectors.rs b/crates/irtt-proto/tests/vectors.rs new file mode 100644 index 0000000..93bdf5a --- /dev/null +++ b/crates/irtt-proto/tests/vectors.rs @@ -0,0 +1,261 @@ +use irtt_proto::{ + decode_echo_reply, decode_open_reply, encode_close_request, encode_echo_request, + encode_open_request, verify_hmac, Clock, CloseRequest, EchoRequest, OpenRequest, Params, + ReceivedStats, StampAt, +}; + +fn hex(input: &str) -> Vec { + input + .split_whitespace() + .map(|byte| u8::from_str_radix(byte, 16).unwrap()) + .collect() +} + +fn default_params_3s() -> Params { + Params { + protocol_version: 1, + duration_ns: 3_000_000_000, + interval_ns: 1_000_000_000, + received_stats: ReceivedStats::Both, + stamp_at: StampAt::Both, + clock: Clock::Both, + ..Params::default() + } +} + +fn default_params_60s() -> Params { + Params { + duration_ns: 60_000_000_000, + ..default_params_3s() + } +} + +fn hmac_params_2s() -> Params { + Params { + duration_ns: 2_000_000_000, + ..default_params_3s() + } +} + +#[test] +fn vector_1_open_request_no_hmac() { + let expected = hex("14 a7 5b 01 01 02 02 80 f8 82 ad 16 03 80 a8 d6 + b9 07 05 06 06 06 07 06"); + let packet = encode_open_request( + &OpenRequest { + params: default_params_3s(), + close: false, + }, + None, + ) + .unwrap(); + assert_eq!(packet, expected); +} + +#[test] +fn vector_2_open_reply_no_hmac() { + let packet = hex("14 a7 5b 03 13 52 71 87 ab b6 96 78 01 02 02 80 + f8 82 ad 16 03 80 a8 d6 b9 07 05 06 06 06 07 06"); + let reply = decode_open_reply(&packet, None).unwrap(); + assert_eq!(reply.token, 0x7896_b6ab_8771_5213); + assert_eq!(reply.params, default_params_3s()); +} + +#[test] +fn vector_3_echo_request_no_hmac() { + let expected = hex("14 a7 5b 00 13 52 71 87 ab b6 96 78 00 00 00 00 + 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + 00 00 00 00 00 00 00 00 00 00 00 00"); + let packet = encode_echo_request( + &EchoRequest { + token: 0x7896_b6ab_8771_5213, + sequence: 0, + params: default_params_3s(), + payload: Vec::new(), + }, + None, + ) + .unwrap(); + assert_eq!(packet, expected); +} + +#[test] +fn vector_4_echo_reply_no_hmac() { + let packet = hex("14 a7 5b 02 13 52 71 87 ab b6 96 78 02 00 00 00 + 03 00 00 00 07 00 00 00 00 00 00 00 b8 1a 33 0c + 86 6d aa 18 de 26 35 95 00 00 00 00 80 4d 33 0c + 86 6d aa 18 b2 57 35 95 00 00 00 00"); + let reply = decode_echo_reply(&packet, &default_params_3s(), None).unwrap(); + assert_eq!(reply.sequence, 2); + assert_eq!(reply.recv_count, Some(3)); + assert_eq!(reply.recv_window, Some(7)); +} + +#[test] +fn vector_5_close_request_no_hmac() { + let expected = hex("14 a7 5b 04 13 52 71 87 ab b6 96 78"); + let packet = encode_close_request( + &CloseRequest { + token: 0x7896_b6ab_8771_5213, + }, + None, + ) + .unwrap(); + assert_eq!(packet, expected); +} + +#[test] +fn vector_6_hmac_open_request() { + let expected = hex("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"); + let packet = encode_open_request( + &OpenRequest { + params: hmac_params_2s(), + close: false, + }, + Some(b"testkey"), + ) + .unwrap(); + assert_eq!(packet, expected); + verify_hmac(b"testkey", &packet, 4).unwrap(); +} + +#[test] +fn vector_7_hmac_echo_request() { + let expected = hex("14 a7 5b 08 d9 87 4f 82 b3 13 10 31 59 ad 2c 8f + 6b da ef ff 59 ca 3f 5d eb e9 87 43 00 00 00 00 + 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + 00 00 00 00 00 00 00 00 00 00 00 00"); + let packet = encode_echo_request( + &EchoRequest { + token: 0x4387_e9eb_5d3f_ca59, + sequence: 0, + params: default_params_3s(), + payload: Vec::new(), + }, + Some(b"testkey"), + ) + .unwrap(); + assert_eq!(packet, expected); +} + +#[test] +fn vector_8_hmac_close_request() { + let expected = hex("14 a7 5b 0c f5 cd 0f a9 de 9d 7d 66 8d 91 a5 32 + 48 0e 42 e0 59 ca 3f 5d eb e9 87 43"); + let packet = encode_close_request( + &CloseRequest { + token: 0x4387_e9eb_5d3f_ca59, + }, + Some(b"testkey"), + ) + .unwrap(); + assert_eq!(packet, expected); +} + +#[test] +fn vector_9_no_test_open_close_request() { + let expected = hex("14 a7 5b 05 01 02 02 80 e0 ba 84 bf 03 03 80 a8 + d6 b9 07 05 06 06 06 07 06"); + let packet = encode_open_request( + &OpenRequest { + params: default_params_60s(), + close: true, + }, + None, + ) + .unwrap(); + assert_eq!(packet, expected); +} + +#[test] +fn vector_10_no_test_open_close_reply() { + let packet = hex("14 a7 5b 07 00 00 00 00 00 00 00 00 01 02 02 80 + e0 ba 84 bf 03 03 80 a8 d6 b9 07 05 06 06 06 07 + 06"); + let reply = decode_open_reply(&packet, None).unwrap(); + assert_eq!(reply.token, 0); + assert_eq!(reply.params, default_params_60s()); +} + +#[test] +fn vector_11_minimal_echo_packet() { + let expected = hex("14 a7 5b 00 4e 15 61 5c a2 6f 31 a0 00 00 00 00"); + let packet = encode_echo_request( + &EchoRequest { + token: 0xa031_6fa2_5c61_154e, + sequence: 0, + params: Params::default(), + payload: Vec::new(), + }, + None, + ) + .unwrap(); + assert_eq!(packet, expected); +} + +#[test] +fn vector_12_midpoint_reply() { + let packet = hex("14 a7 5b 02 91 a4 1e 7a f0 14 f6 62 00 00 00 00 + 01 00 00 00 01 00 00 00 00 00 00 00 f7 0e 2c 93 + a2 6d aa 18 5a 5f 14 47 03 00 00 00"); + let params = Params { + received_stats: ReceivedStats::Both, + stamp_at: StampAt::Midpoint, + clock: Clock::Both, + ..Params::default() + }; + let reply = decode_echo_reply(&packet, ¶ms, None).unwrap(); + assert_eq!(reply.recv_count, Some(1)); + assert_eq!(reply.recv_window, Some(1)); + assert!(reply.timestamps.midpoint_wall.is_some()); + assert!(reply.timestamps.recv_wall.is_none()); + assert!(reply.timestamps.send_wall.is_none()); +} + +#[test] +fn spec_18_1_hmac_echo_request_fixture() { + let expected = hex("14 a7 5b 08 e7 03 41 e7 d4 08 cf 69 41 f3 f4 78 + 5a 56 0c 4c ea 3e b3 22 a7 c9 6b 88 bb a1 e2 6f + 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + 00 00 00 00 00 00 00 00 00 00 00 00 ff fe fd fc + ff fe fd fc ff fe fd fc ff fe fd fc"); + let params = Params { + length: 92, + received_stats: ReceivedStats::Both, + stamp_at: StampAt::Both, + clock: Clock::Both, + ..Params::default() + }; + let packet = encode_echo_request( + &EchoRequest { + token: 0x886b_c9a7_22b3_3eea, + sequence: 0x6fe2_a1bb, + params, + payload: [0xff, 0xfe, 0xfd, 0xfc].repeat(4), + }, + Some(&[0x3c, 0x68, 0x1d, 0x39, 0x41, 0x1d, 0x72, 0x43]), + ) + .unwrap(); + assert_eq!(packet, expected); +} + +#[test] +fn spec_18_2_hmac_echo_reply_fixture() { + let packet = hex("14 a7 5b 08 d2 98 a3 4a 6a 13 41 02 68 b2 67 a8 + d6 7e 28 25 c3 cb 6f 76 b6 ce 66 e6 06 07 3b 1d + 19 9f bc a3 9b 3b f8 86 e5 39 e9 d7 a1 75 2a ee + 3d f1 5a 52 9a c6 4a 3e 22 62 55 2d 69 3f 29 46 + d4 05 97 58 66 50 2c dd 2f f1 1d 46 ff fe fd fc + ff fe fd fc ff fe fd fc ff fe fd fc"); + verify_hmac( + &[0xda, 0xb3, 0xe9, 0x04, 0xa6, 0x87, 0x92, 0x49], + &packet, + 4, + ) + .unwrap(); +} diff --git a/crates/irtt-stats/Cargo.toml b/crates/irtt-stats/Cargo.toml new file mode 100644 index 0000000..dd5959a --- /dev/null +++ b/crates/irtt-stats/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "irtt-stats" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] diff --git a/crates/irtt-stats/src/lib.rs b/crates/irtt-stats/src/lib.rs new file mode 100644 index 0000000..d5357a6 --- /dev/null +++ b/crates/irtt-stats/src/lib.rs @@ -0,0 +1,4 @@ +//! Statistics crate skeleton. +//! +//! Statistics aggregation is intentionally out of scope for the current +//! milestone.