diff --git a/core/Cargo.lock b/core/Cargo.lock new file mode 100644 index 0000000..0581aa1 --- /dev/null +++ b/core/Cargo.lock @@ -0,0 +1,107 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "versevdi-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..a587af8 --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "versevdi-core" +version = "0.1.0" +edition = "2021" +license = "GPL-3.0-only" +publish = false + +[lib] +crate-type = ["staticlib", "rlib"] + +[dependencies] +serde = { version = "=1.0.229", features = ["derive"] } +serde_json = "=1.0.151" + +[[test]] +name = "protocol_fixtures" +path = "tests/protocol_fixtures.rs" + +[workspace] +resolver = "2" diff --git a/core/rust-toolchain.toml b/core/rust-toolchain.toml new file mode 100644 index 0000000..7a16363 --- /dev/null +++ b/core/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "1.97.1" +components = ["clippy", "rustfmt"] +targets = ["aarch64-apple-darwin"] +profile = "minimal" diff --git a/core/src/error.rs b/core/src/error.rs new file mode 100644 index 0000000..d6948fd --- /dev/null +++ b/core/src/error.rs @@ -0,0 +1,67 @@ +use std::fmt; + +/// Stable, provider-free failures returned by the safe core. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CoreError { + InvalidArgument, + AuthorityRejected, + Expired, + QueueFull, + Cancelled, + Truncated, + UnsupportedVersion, + UnknownChannel, + Fragment, + FragmentLimit, + Length, + LengthMismatch, + Magic, + Kind, + Reserved, + Utf8, + Field, + Direction, + Type, + UnsupportedFeature, + ConflictingDuplicate, +} + +impl CoreError { + /// Returns a stable, provider-free machine code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidArgument => "invalid_argument", + Self::AuthorityRejected => "authority_rejected", + Self::Expired => "expired", + Self::QueueFull => "queue_full", + Self::Cancelled => "cancelled", + Self::Truncated => "truncated", + Self::UnsupportedVersion => "unsupported_version", + Self::UnknownChannel => "unknown_channel", + Self::Fragment => "fragment", + Self::FragmentLimit => "fragment_limit", + Self::Length => "length", + Self::LengthMismatch => "length_mismatch", + Self::Magic => "magic", + Self::Kind => "kind", + Self::Reserved => "reserved", + Self::Utf8 => "utf8", + Self::Field => "field", + Self::Direction => "direction", + Self::Type => "type", + Self::UnsupportedFeature => "unsupported_feature", + Self::ConflictingDuplicate => "conflicting_duplicate", + } + } +} + +impl fmt::Display for CoreError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.code()) + } +} + +impl std::error::Error for CoreError {} + +pub type Result = std::result::Result; diff --git a/core/src/input.rs b/core/src/input.rs new file mode 100644 index 0000000..a7804f8 --- /dev/null +++ b/core/src/input.rs @@ -0,0 +1,418 @@ +use crate::error::{CoreError, Result}; + +const INPUT_HEADER: usize = 6; +const FEEDBACK_HEADER: usize = 8; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ControllerState { + pub controller: u8, + pub active_mask: u16, + pub button_flags: u16, + pub left_trigger: u8, + pub right_trigger: u8, + pub left_x: i16, + pub left_y: i16, + pub right_x: i16, + pub right_y: i16, + pub extra_button_flags: u16, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InputEvent { + Keyboard { + pressed: bool, + modifiers: u8, + scancode: u16, + }, + MouseButton { + pressed: bool, + button: u8, + }, + RelativeMouse { + delta_x: i16, + delta_y: i16, + }, + Text(char), + Controller(ControllerState), + AbsoluteMouse { + x: u16, + y: u16, + viewport_width: u16, + viewport_height: u16, + }, + Scroll { + vertical_delta: i16, + horizontal_delta: i16, + }, +} + +fn feature(features: &[&str], wanted: &str) -> Result<()> { + features + .contains(&wanted) + .then_some(()) + .ok_or(CoreError::UnsupportedFeature) +} + +fn state(value: u8) -> Result { + match value { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(CoreError::Field), + } +} + +fn i16_at(bytes: &[u8], offset: usize) -> i16 { + i16::from_be_bytes([bytes[offset], bytes[offset + 1]]) +} + +fn u16_at(bytes: &[u8], offset: usize) -> u16 { + u16::from_be_bytes([bytes[offset], bytes[offset + 1]]) +} + +/// Decodes one bounded VGI1 input envelope. +/// +/// # Errors +/// +/// Returns a stable protocol error for malformed bytes or a missing negotiated feature. +pub fn decode_input(bytes: &[u8], features: &[&str]) -> Result { + if bytes.len() < INPUT_HEADER { + return Err(CoreError::Truncated); + } + if &bytes[..4] != b"VGI1" { + return Err(CoreError::Magic); + } + let body_length = usize::from(bytes[5]); + if bytes.len() != INPUT_HEADER + body_length { + return Err(CoreError::Length); + } + let body = &bytes[INPUT_HEADER..]; + match bytes[4] { + 0x01 if body.len() == 4 => { + let scancode = u16_at(body, 2); + if scancode == 0 { + return Err(CoreError::Field); + } + Ok(InputEvent::Keyboard { + pressed: state(body[0])?, + modifiers: body[1], + scancode, + }) + } + 0x02 if body.len() == 3 => { + if !(1..=5).contains(&body[1]) { + return Err(CoreError::Field); + } + if body[2] != 0 { + return Err(CoreError::Reserved); + } + Ok(InputEvent::MouseButton { + pressed: state(body[0])?, + button: body[1], + }) + } + 0x03 if body.len() == 4 => Ok(InputEvent::RelativeMouse { + delta_x: i16_at(body, 0), + delta_y: i16_at(body, 2), + }), + 0x04 if (1..=4).contains(&body.len()) => { + let text = std::str::from_utf8(body).map_err(|_| CoreError::Utf8)?; + let mut chars = text.chars(); + let value = chars.next().ok_or(CoreError::Utf8)?; + if chars.next().is_some() { + return Err(CoreError::Utf8); + } + Ok(InputEvent::Text(value)) + } + 0x05 if body.len() == 17 => { + if body[0] > 15 { + return Err(CoreError::Field); + } + Ok(InputEvent::Controller(ControllerState { + controller: body[0], + active_mask: u16_at(body, 1), + button_flags: u16_at(body, 3), + left_trigger: body[5], + right_trigger: body[6], + left_x: i16_at(body, 7), + left_y: i16_at(body, 9), + right_x: i16_at(body, 11), + right_y: i16_at(body, 13), + extra_button_flags: u16_at(body, 15), + })) + } + 0x06 if body.len() == 8 => { + feature(features, "input.absolute.v1")?; + let x = u16_at(body, 0); + let y = u16_at(body, 2); + let viewport_width = u16_at(body, 4); + let viewport_height = u16_at(body, 6); + if viewport_width == 0 + || viewport_height == 0 + || x >= viewport_width + || y >= viewport_height + { + return Err(CoreError::Field); + } + Ok(InputEvent::AbsoluteMouse { + x, + y, + viewport_width, + viewport_height, + }) + } + 0x07 if body.len() == 4 => { + feature(features, "input.scroll.v1")?; + Ok(InputEvent::Scroll { + vertical_delta: i16_at(body, 0), + horizontal_delta: i16_at(body, 2), + }) + } + 0x01..=0x07 => Err(CoreError::Length), + _ => Err(CoreError::Kind), + } +} + +fn push_i16(output: &mut Vec, value: i16) { + output.extend_from_slice(&value.to_be_bytes()); +} + +fn push_u16(output: &mut Vec, value: u16) { + output.extend_from_slice(&value.to_be_bytes()); +} + +/// Encodes one bounded VGI1 input envelope. +/// +/// # Errors +/// +/// Returns a stable protocol error for invalid fields or a missing negotiated feature. +pub fn encode_input(event: &InputEvent, features: &[&str]) -> Result> { + let (kind, body) = match event { + InputEvent::Keyboard { + pressed, + modifiers, + scancode, + } => { + if *scancode == 0 { + return Err(CoreError::Field); + } + let mut body = vec![u8::from(*pressed), *modifiers]; + push_u16(&mut body, *scancode); + (0x01, body) + } + InputEvent::MouseButton { pressed, button } => { + if !(1..=5).contains(button) { + return Err(CoreError::Field); + } + (0x02, vec![u8::from(*pressed), *button, 0]) + } + InputEvent::RelativeMouse { delta_x, delta_y } => { + let mut body = Vec::with_capacity(4); + push_i16(&mut body, *delta_x); + push_i16(&mut body, *delta_y); + (0x03, body) + } + InputEvent::Text(value) => { + let mut bytes = [0_u8; 4]; + (0x04, value.encode_utf8(&mut bytes).as_bytes().to_vec()) + } + InputEvent::Controller(controller) => { + if controller.controller > 15 { + return Err(CoreError::Field); + } + let mut body = vec![controller.controller]; + push_u16(&mut body, controller.active_mask); + push_u16(&mut body, controller.button_flags); + body.extend_from_slice(&[controller.left_trigger, controller.right_trigger]); + push_i16(&mut body, controller.left_x); + push_i16(&mut body, controller.left_y); + push_i16(&mut body, controller.right_x); + push_i16(&mut body, controller.right_y); + push_u16(&mut body, controller.extra_button_flags); + (0x05, body) + } + InputEvent::AbsoluteMouse { + x, + y, + viewport_width, + viewport_height, + } => { + feature(features, "input.absolute.v1")?; + if *viewport_width == 0 + || *viewport_height == 0 + || x >= viewport_width + || y >= viewport_height + { + return Err(CoreError::Field); + } + let mut body = Vec::with_capacity(8); + push_u16(&mut body, *x); + push_u16(&mut body, *y); + push_u16(&mut body, *viewport_width); + push_u16(&mut body, *viewport_height); + (0x06, body) + } + InputEvent::Scroll { + vertical_delta, + horizontal_delta, + } => { + feature(features, "input.scroll.v1")?; + let mut body = Vec::with_capacity(4); + push_i16(&mut body, *vertical_delta); + push_i16(&mut body, *horizontal_delta); + (0x07, body) + } + }; + let mut output = Vec::with_capacity(INPUT_HEADER + body.len()); + output.extend_from_slice(b"VGI1"); + output.push(kind); + output.push(u8::try_from(body.len()).map_err(|_| CoreError::Length)?); + output.extend_from_slice(&body); + Ok(output) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FecStatus { + pub frame_index: u32, + pub highest_received_sequence: u16, + pub next_contiguous_sequence: u16, + pub missing_before_highest: u16, + pub total_data_packets: u16, + pub total_parity_packets: u16, + pub received_data_packets: u16, + pub received_parity_packets: u16, + pub fec_percentage: u8, + pub multi_fec_block_index: u8, + pub multi_fec_block_count: u8, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum FeedbackEvent { + IdrRequest, + Fec(FecStatus), + TerminalReceipt, + Termination { + exit_code: u32, + }, + Rumble { + controller: u8, + low_frequency: u16, + high_frequency: u16, + }, + Hdr { + enabled: bool, + }, +} + +fn u32_at(bytes: &[u8], offset: usize) -> u32 { + u32::from_be_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]) +} + +/// Decodes one bounded VGF1 feedback envelope. +/// +/// # Errors +/// +/// Returns a stable protocol error for malformed bytes, direction, type, or size. +pub fn decode_feedback(bytes: &[u8]) -> Result { + if bytes.len() < FEEDBACK_HEADER { + return Err(CoreError::Truncated); + } + if &bytes[..4] != b"VGF1" { + return Err(CoreError::Magic); + } + let direction = bytes[4]; + if direction > 1 { + return Err(CoreError::Direction); + } + let body_length = usize::from(u16_at(bytes, 6)); + if bytes.len() != FEEDBACK_HEADER + body_length { + return Err(CoreError::Length); + } + let body = &bytes[FEEDBACK_HEADER..]; + match (direction, bytes[5]) { + (0, 0x01) if body.is_empty() => Ok(FeedbackEvent::IdrRequest), + (0, 0x02) if body.len() == 21 => Ok(FeedbackEvent::Fec(FecStatus { + frame_index: u32_at(body, 0), + highest_received_sequence: u16_at(body, 4), + next_contiguous_sequence: u16_at(body, 6), + missing_before_highest: u16_at(body, 8), + total_data_packets: u16_at(body, 10), + total_parity_packets: u16_at(body, 12), + received_data_packets: u16_at(body, 14), + received_parity_packets: u16_at(body, 16), + fec_percentage: body[18], + multi_fec_block_index: body[19], + multi_fec_block_count: body[20], + })), + (0, 0x03) if body.is_empty() => Ok(FeedbackEvent::TerminalReceipt), + (1, 0x10) if body.len() == 4 => Ok(FeedbackEvent::Termination { + exit_code: u32_at(body, 0), + }), + (1, 0x11) if body.len() == 5 => Ok(FeedbackEvent::Rumble { + controller: body[0], + low_frequency: u16_at(body, 1), + high_frequency: u16_at(body, 3), + }), + (1, 0x12) if body.len() == 1 && body[0] <= 1 => Ok(FeedbackEvent::Hdr { + enabled: body[0] == 1, + }), + (0, 0x10..=0x12) | (1, 0x01..=0x03) => Err(CoreError::Direction), + (0, 0x01..=0x03) | (1, 0x10..=0x12) => Err(CoreError::Length), + _ => Err(CoreError::Type), + } +} + +/// Encodes one bounded VGF1 feedback envelope. +/// +/// # Errors +/// +/// Returns a stable protocol error if the body size is not representable. +pub fn encode_feedback(event: &FeedbackEvent) -> Result> { + let (direction, kind, body) = match event { + FeedbackEvent::IdrRequest => (0, 0x01, Vec::new()), + FeedbackEvent::Fec(status) => { + let mut body = Vec::with_capacity(21); + body.extend_from_slice(&status.frame_index.to_be_bytes()); + push_u16(&mut body, status.highest_received_sequence); + push_u16(&mut body, status.next_contiguous_sequence); + push_u16(&mut body, status.missing_before_highest); + push_u16(&mut body, status.total_data_packets); + push_u16(&mut body, status.total_parity_packets); + push_u16(&mut body, status.received_data_packets); + push_u16(&mut body, status.received_parity_packets); + body.extend_from_slice(&[ + status.fec_percentage, + status.multi_fec_block_index, + status.multi_fec_block_count, + ]); + (0, 0x02, body) + } + FeedbackEvent::TerminalReceipt => (0, 0x03, Vec::new()), + FeedbackEvent::Termination { exit_code } => (1, 0x10, exit_code.to_be_bytes().to_vec()), + FeedbackEvent::Rumble { + controller, + low_frequency, + high_frequency, + } => { + let mut body = vec![*controller]; + push_u16(&mut body, *low_frequency); + push_u16(&mut body, *high_frequency); + (1, 0x11, body) + } + FeedbackEvent::Hdr { enabled } => (1, 0x12, vec![u8::from(*enabled)]), + }; + let mut output = Vec::with_capacity(FEEDBACK_HEADER + body.len()); + output.extend_from_slice(b"VGF1"); + output.extend_from_slice(&[direction, kind]); + output.extend_from_slice( + &u16::try_from(body.len()) + .map_err(|_| CoreError::Length)? + .to_be_bytes(), + ); + output.extend_from_slice(&body); + Ok(output) +} diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..a478459 --- /dev/null +++ b/core/src/lib.rs @@ -0,0 +1,18 @@ +#![forbid(unsafe_code, unsafe_op_in_unsafe_fn)] + +//! Provider-free wire codecs and bounded session primitives for `VerseVDI` clients. +//! +//! ``` +//! use versevdi_core::media::MediaFragment; +//! +//! let fragment = MediaFragment::new_video(1, 2, 0, 1, vec![1, 2, 3])?; +//! let encoded = fragment.encode()?; +//! assert_eq!(MediaFragment::decode(&encoded)?, fragment); +//! # Ok::<(), versevdi_core::error::CoreError>(()) +//! ``` + +pub mod error; +pub mod input; +pub mod media; +pub mod session; +pub mod wire; diff --git a/core/src/media.rs b/core/src/media.rs new file mode 100644 index 0000000..27215b2 --- /dev/null +++ b/core/src/media.rs @@ -0,0 +1,299 @@ +use std::collections::VecDeque; + +use crate::error::{CoreError, Result}; + +pub const DATAGRAM_HEADER_BYTES: usize = 23; +pub const MAX_DATAGRAM_BYTES: usize = 1_200; +pub const MAX_FRAGMENT_PAYLOAD_BYTES: usize = 1_177; +pub const MAX_FRAGMENT_COUNT: u16 = 891; +pub const MAX_COMPLETE_UNIT_BYTES: usize = 1_048_576; +const MAX_INCOMPLETE_UNITS: usize = 4; +const EXPIRY_MILLISECONDS: u64 = 250; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MediaChannel { + Video, + Audio, +} + +impl MediaChannel { + const fn wire(self) -> u8 { + match self { + Self::Video => 10, + Self::Audio => 11, + } + } + + fn from_wire(value: u8) -> Result { + match value { + 10 => Ok(Self::Video), + 11 => Ok(Self::Audio), + _ => Err(CoreError::UnknownChannel), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MediaFragment { + pub channel: MediaChannel, + pub sequence: u32, + pub timestamp_ms: u64, + pub fragment_index: u16, + pub fragment_count: u16, + pub payload: Vec, +} + +impl MediaFragment { + /// Creates a validated video fragment. + /// + /// # Errors + /// + /// Returns a stable protocol error when fragment or payload bounds are invalid. + pub fn new_video( + sequence: u32, + timestamp_ms: u64, + fragment_index: u16, + fragment_count: u16, + payload: Vec, + ) -> Result { + let fragment = Self { + channel: MediaChannel::Video, + sequence, + timestamp_ms, + fragment_index, + fragment_count, + payload, + }; + fragment.validate()?; + Ok(fragment) + } + + /// Creates a validated audio fragment. + /// + /// # Errors + /// + /// Returns a stable protocol error when fragment or payload bounds are invalid. + pub fn new_audio( + sequence: u32, + timestamp_ms: u64, + fragment_index: u16, + fragment_count: u16, + payload: Vec, + ) -> Result { + let fragment = Self { + channel: MediaChannel::Audio, + sequence, + timestamp_ms, + fragment_index, + fragment_count, + payload, + }; + fragment.validate()?; + Ok(fragment) + } + + fn validate(&self) -> Result<()> { + if self.fragment_count == 0 || self.fragment_index >= self.fragment_count { + return Err(CoreError::Fragment); + } + if self.fragment_count > MAX_FRAGMENT_COUNT { + return Err(CoreError::FragmentLimit); + } + if self.payload.len() > MAX_FRAGMENT_PAYLOAD_BYTES { + return Err(CoreError::LengthMismatch); + } + Ok(()) + } + + /// Decodes one datagram-v2 fragment. + /// + /// # Errors + /// + /// Returns a stable protocol error for malformed or out-of-bound bytes. + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() < DATAGRAM_HEADER_BYTES { + return Err(CoreError::Truncated); + } + if &bytes[..2] != b"VD" { + return Err(CoreError::Magic); + } + if bytes[2] != 2 { + return Err(CoreError::UnsupportedVersion); + } + let channel = MediaChannel::from_wire(bytes[3])?; + if bytes[4] != 0 { + return Err(CoreError::Field); + } + let sequence = u32::from_be_bytes([bytes[5], bytes[6], bytes[7], bytes[8]]); + let timestamp_ms = u64::from_be_bytes([ + bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], bytes[16], + ]); + let fragment_index = u16::from_be_bytes([bytes[17], bytes[18]]); + let fragment_count = u16::from_be_bytes([bytes[19], bytes[20]]); + let payload_length = usize::from(u16::from_be_bytes([bytes[21], bytes[22]])); + if bytes.len() != DATAGRAM_HEADER_BYTES + payload_length || bytes.len() > MAX_DATAGRAM_BYTES + { + return Err(CoreError::LengthMismatch); + } + let fragment = Self { + channel, + sequence, + timestamp_ms, + fragment_index, + fragment_count, + payload: bytes[DATAGRAM_HEADER_BYTES..].to_vec(), + }; + fragment.validate()?; + Ok(fragment) + } + + /// Encodes one datagram-v2 fragment. + /// + /// # Errors + /// + /// Returns a stable protocol error when fragment or payload bounds are invalid. + pub fn encode(&self) -> Result> { + self.validate()?; + let mut output = Vec::with_capacity(DATAGRAM_HEADER_BYTES + self.payload.len()); + output.extend_from_slice(b"VD"); + output.extend_from_slice(&[2, self.channel.wire(), 0]); + output.extend_from_slice(&self.sequence.to_be_bytes()); + output.extend_from_slice(&self.timestamp_ms.to_be_bytes()); + output.extend_from_slice(&self.fragment_index.to_be_bytes()); + output.extend_from_slice(&self.fragment_count.to_be_bytes()); + output.extend_from_slice( + &u16::try_from(self.payload.len()) + .map_err(|_| CoreError::LengthMismatch)? + .to_be_bytes(), + ); + output.extend_from_slice(&self.payload); + Ok(output) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EncodedUnit { + pub channel: MediaChannel, + pub sequence: u32, + pub timestamp_ms: u64, + pub payload: Vec, +} + +#[derive(Debug)] +struct IncompleteUnit { + channel: MediaChannel, + sequence: u32, + timestamp_ms: u64, + fragment_count: u16, + started_at_ms: u64, + total_bytes: usize, + fragments: Vec>>, +} + +#[derive(Debug, Default)] +pub struct Reassembler { + incomplete: VecDeque, + evicted_units: u64, + expired_units: u64, +} + +impl Reassembler { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Adds a fragment and returns a complete encoded unit when all fragments arrive. + /// + /// # Errors + /// + /// Returns a stable protocol error for conflicting fragments or size-bound violations. + pub fn push(&mut self, fragment: MediaFragment, now_ms: u64) -> Result> { + fragment.validate()?; + let before = self.incomplete.len(); + self.incomplete + .retain(|unit| now_ms.saturating_sub(unit.started_at_ms) <= EXPIRY_MILLISECONDS); + self.expired_units += u64::try_from(before - self.incomplete.len()).unwrap_or(u64::MAX); + + let position = self.incomplete.iter().position(|unit| { + unit.channel == fragment.channel && unit.sequence == fragment.sequence + }); + let position = if let Some(position) = position { + position + } else { + if self.incomplete.len() == MAX_INCOMPLETE_UNITS { + self.incomplete.pop_front(); + self.evicted_units += 1; + } + self.incomplete.push_back(IncompleteUnit { + channel: fragment.channel, + sequence: fragment.sequence, + timestamp_ms: fragment.timestamp_ms, + fragment_count: fragment.fragment_count, + started_at_ms: now_ms, + total_bytes: 0, + fragments: vec![None; usize::from(fragment.fragment_count)], + }); + self.incomplete.len() - 1 + }; + + let unit = &mut self.incomplete[position]; + if unit.timestamp_ms != fragment.timestamp_ms + || unit.fragment_count != fragment.fragment_count + { + self.incomplete.remove(position); + return Err(CoreError::ConflictingDuplicate); + } + let index = usize::from(fragment.fragment_index); + if let Some(existing) = &unit.fragments[index] { + if existing == &fragment.payload { + return Ok(None); + } + self.incomplete.remove(position); + return Err(CoreError::ConflictingDuplicate); + } + let total_bytes = unit + .total_bytes + .checked_add(fragment.payload.len()) + .ok_or(CoreError::FragmentLimit)?; + if total_bytes > MAX_COMPLETE_UNIT_BYTES { + self.incomplete.remove(position); + return Err(CoreError::FragmentLimit); + } + unit.total_bytes = total_bytes; + unit.fragments[index] = Some(fragment.payload); + if unit.fragments.iter().any(Option::is_none) { + return Ok(None); + } + + let complete = self + .incomplete + .remove(position) + .ok_or(CoreError::InvalidArgument)?; + let mut payload = Vec::with_capacity(complete.total_bytes); + for bytes in complete.fragments { + payload.extend(bytes.ok_or(CoreError::InvalidArgument)?); + } + Ok(Some(EncodedUnit { + channel: complete.channel, + sequence: complete.sequence, + timestamp_ms: complete.timestamp_ms, + payload, + })) + } + + #[must_use] + pub fn incomplete_units(&self) -> usize { + self.incomplete.len() + } + + #[must_use] + pub const fn evicted_units(&self) -> u64 { + self.evicted_units + } + + #[must_use] + pub const fn expired_units(&self) -> u64 { + self.expired_units + } +} diff --git a/core/src/session.rs b/core/src/session.rs new file mode 100644 index 0000000..89eaa32 --- /dev/null +++ b/core/src/session.rs @@ -0,0 +1,220 @@ +use std::collections::VecDeque; + +use crate::error::{CoreError, Result}; +use crate::media::EncodedUnit; + +pub const INPUT_QUEUE_CAPACITY: usize = 64; +pub const CONTROL_QUEUE_CAPACITY: usize = 64; +const MEDIA_QUEUE_CAPACITY: usize = 4; +const MAX_CONTROL_BYTES: usize = 128 * 1024; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SessionStats { + pub dropped_media_units: u64, +} + +#[derive(Debug, Default)] +pub struct SessionCore { + input: VecDeque>, + control: VecDeque>, + media: VecDeque, + cancelled: bool, + stats: SessionStats, +} + +impl SessionCore { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Enqueues one already-validated VGI1 envelope without blocking. + /// + /// # Errors + /// + /// Returns `invalid_argument`, `queue_full`, or `cancelled`. + pub fn enqueue_input(&mut self, bytes: Vec) -> Result<()> { + if self.cancelled { + return Err(CoreError::Cancelled); + } + if bytes.is_empty() || bytes.len() > 23 { + return Err(CoreError::InvalidArgument); + } + if self.input.len() == INPUT_QUEUE_CAPACITY { + return Err(CoreError::QueueFull); + } + self.input.push_back(bytes); + Ok(()) + } + + /// Enqueues one bounded reliable control body without blocking. + /// + /// # Errors + /// + /// Returns `invalid_argument`, `queue_full`, or `cancelled`. + pub fn enqueue_control(&mut self, bytes: Vec) -> Result<()> { + if self.cancelled { + return Err(CoreError::Cancelled); + } + if bytes.len() > MAX_CONTROL_BYTES { + return Err(CoreError::InvalidArgument); + } + if self.control.len() == CONTROL_QUEUE_CAPACITY { + return Err(CoreError::QueueFull); + } + self.control.push_back(bytes); + Ok(()) + } + + /// Enqueues one bounded complete encoded media unit. + /// + /// # Errors + /// + /// Returns `invalid_argument` for an oversized unit or `cancelled` after cancellation. + pub fn enqueue_media(&mut self, unit: EncodedUnit) -> Result<()> { + if unit.payload.len() > crate::media::MAX_COMPLETE_UNIT_BYTES { + return Err(CoreError::InvalidArgument); + } + if self.cancelled { + self.stats.dropped_media_units += 1; + return Err(CoreError::Cancelled); + } + if self.media.len() == MEDIA_QUEUE_CAPACITY { + let position = self + .media + .iter() + .position(|queued| queued.channel == unit.channel); + if let Some(position) = position { + self.media.remove(position); + } else { + self.stats.dropped_media_units += 1; + return Ok(()); + } + self.stats.dropped_media_units += 1; + } + self.media.push_back(unit); + Ok(()) + } + + pub fn pop_media(&mut self) -> Option { + self.media.pop_front() + } + + pub fn cancel(&mut self) { + self.cancelled = true; + self.input.clear(); + self.control.clear(); + self.media.clear(); + } + + #[must_use] + pub const fn is_cancelled(&self) -> bool { + self.cancelled + } + + #[must_use] + pub const fn stats(&self) -> SessionStats { + self.stats + } +} + +#[cfg(test)] +#[derive(Debug, Default)] +struct InProcessSession { + incoming: VecDeque>, +} + +#[cfg(test)] +impl InProcessSession { + fn push_incoming(&mut self, bytes: Vec) { + self.incoming.push_back(bytes); + } + + fn pop_incoming(&mut self) -> Option> { + self.incoming.pop_front() + } +} + +#[cfg(test)] +mod tests { + use super::{InProcessSession, SessionCore, CONTROL_QUEUE_CAPACITY, INPUT_QUEUE_CAPACITY}; + use crate::error::CoreError; + use crate::media::{EncodedUnit, MediaChannel}; + + #[test] + fn queues_are_bounded_and_cancellation_is_idempotent() { + let mut session = SessionCore::new(); + assert_eq!( + session.enqueue_input(vec![0; 24]), + Err(CoreError::InvalidArgument) + ); + for value in 0..INPUT_QUEUE_CAPACITY { + session + .enqueue_input(vec![u8::try_from(value).expect("capacity fits u8")]) + .expect("within bound"); + } + assert_eq!(session.enqueue_input(vec![0]), Err(CoreError::QueueFull)); + + for value in 0..CONTROL_QUEUE_CAPACITY { + session + .enqueue_control(vec![u8::try_from(value).expect("capacity fits u8")]) + .expect("within bound"); + } + assert_eq!(session.enqueue_control(vec![0]), Err(CoreError::QueueFull)); + let mut oversized_control = SessionCore::new(); + assert_eq!( + oversized_control.enqueue_control(vec![0; 128 * 1024 + 1]), + Err(CoreError::InvalidArgument) + ); + + session + .enqueue_media(EncodedUnit { + channel: MediaChannel::Audio, + sequence: 1, + timestamp_ms: 1, + payload: vec![1], + }) + .expect("bounded media"); + + session.cancel(); + session.cancel(); + assert!(session.is_cancelled()); + assert_eq!(session.enqueue_input(vec![0]), Err(CoreError::Cancelled)); + assert!(session.pop_media().is_none()); + } + + #[test] + fn media_queue_evicts_oldest_same_channel_at_four_units() { + let mut session = SessionCore::new(); + assert_eq!( + session.enqueue_media(EncodedUnit { + channel: MediaChannel::Video, + sequence: 99, + timestamp_ms: 0, + payload: vec![0; 1_048_577], + }), + Err(CoreError::InvalidArgument) + ); + for sequence in 0..5 { + session + .enqueue_media(EncodedUnit { + channel: MediaChannel::Video, + sequence, + timestamp_ms: u64::from(sequence), + payload: vec![u8::try_from(sequence).expect("test sequence fits u8")], + }) + .expect("bounded media"); + } + assert_eq!(session.stats().dropped_media_units, 1); + assert_eq!(session.pop_media().expect("media").sequence, 1); + } + + #[test] + fn private_in_process_session_preserves_byte_order() { + let mut transport = InProcessSession::default(); + transport.push_incoming(vec![1, 2]); + transport.push_incoming(vec![3]); + assert_eq!(transport.pop_incoming(), Some(vec![1, 2])); + assert_eq!(transport.pop_incoming(), Some(vec![3])); + } +} diff --git a/core/src/wire.rs b/core/src/wire.rs new file mode 100644 index 0000000..9e978bd --- /dev/null +++ b/core/src/wire.rs @@ -0,0 +1,507 @@ +use serde::de::DeserializeOwned; +use serde::Deserialize; + +use crate::error::{CoreError, Result}; + +const MAX_MANIFEST_JSON_BYTES: usize = 128 * 1024; +const MAX_CREDENTIAL_JSON_BYTES: usize = 196 * 1024; +const MAX_ADMISSION_JSON_BYTES: usize = 16 * 1024; + +fn decode_strict(bytes: &[u8], maximum: usize) -> Result { + if bytes.is_empty() || bytes.len() > maximum { + return Err(CoreError::InvalidArgument); + } + serde_json::from_slice(bytes).map_err(|_| CoreError::InvalidArgument) +} + +fn bounded(value: &str, minimum: usize, maximum: usize) -> bool { + (minimum..=maximum).contains(&value.len()) +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct Timestamp { + year: u16, + month: u8, + day: u8, + hour: u8, + minute: u8, + second: u8, + nanosecond: u32, +} + +fn timestamp(value: &str, exact_seconds: bool) -> Option { + let bytes = value.as_bytes(); + if bytes.len() < 20 + || bytes.len() > 30 + || bytes[4] != b'-' + || bytes[7] != b'-' + || bytes[10] != b'T' + || bytes[13] != b':' + || bytes[16] != b':' + || *bytes.last()? != b'Z' + { + return None; + } + let digits = |start: usize, end: usize| { + bytes + .get(start..end)? + .iter() + .try_fold(0_u32, |number, byte| { + byte.is_ascii_digit() + .then_some(number * 10 + u32::from(*byte - b'0')) + }) + }; + let year = u16::try_from(digits(0, 4)?).ok()?; + let month = u8::try_from(digits(5, 7)?).ok()?; + let day = u8::try_from(digits(8, 10)?).ok()?; + let hour = u8::try_from(digits(11, 13)?).ok()?; + let minute = u8::try_from(digits(14, 16)?).ok()?; + let second = u8::try_from(digits(17, 19)?).ok()?; + if hour > 23 || minute > 59 || second > 59 { + return None; + } + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let maximum_day = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => return None, + }; + if day == 0 || day > maximum_day { + return None; + } + let nanosecond = if bytes.len() == 20 { + 0 + } else { + if exact_seconds || bytes[19] != b'.' { + return None; + } + let fraction = bytes.get(20..bytes.len() - 1)?; + if fraction.is_empty() + || fraction.len() > 9 + || !fraction.iter().all(u8::is_ascii_digit) + || *fraction.last()? == b'0' + { + return None; + } + let mut value = fraction + .iter() + .fold(0_u32, |number, byte| number * 10 + u32::from(*byte - b'0')); + for _ in fraction.len()..9 { + value *= 10; + } + value + }; + Some(Timestamp { + year, + month, + day, + hour, + minute, + second, + nanosecond, + }) +} + +fn valid_dns_name(value: &str) -> bool { + bounded(value, 1, 253) + && value.split('.').all(|label| { + bounded(label, 1, 63) + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }) +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct CapabilityProfile { + transport: String, + framing: String, + media: String, + audio: String, + source_rate_control: String, + client_decode: Vec, +} + +impl CapabilityProfile { + /// Creates and validates an RC5 capability profile. + /// + /// # Errors + /// + /// Returns `invalid_argument` when a field violates RC5 bounds or registry values. + pub fn new( + transport: &str, + framing: &str, + media: &str, + audio: &str, + source_rate_control: &str, + client_decode: Vec, + ) -> Result { + let profile = Self { + transport: transport.to_owned(), + framing: framing.to_owned(), + media: media.to_owned(), + audio: audio.to_owned(), + source_rate_control: source_rate_control.to_owned(), + client_decode, + }; + profile.validate()?; + Ok(profile) + } + + fn validate(&self) -> Result<()> { + if !bounded(&self.transport, 1, 64) + || !matches!(self.framing.as_str(), "datagram-v1" | "datagram-v2") + || !bounded(&self.media, 1, 64) + || !bounded(&self.audio, 1, 64) + || !bounded(&self.source_rate_control, 1, 64) + || !(1..=2).contains(&self.client_decode.len()) + || self + .client_decode + .iter() + .any(|value| !matches!(value.as_str(), "h264-opus" | "hevc-opus")) + || self.client_decode.len() + != self + .client_decode + .iter() + .collect::>() + .len() + { + return Err(CoreError::InvalidArgument); + } + Ok(()) + } + + fn is_subset_of(&self, offered: &Self) -> bool { + self.transport == offered.transport + && self.framing == offered.framing + && self.media == offered.media + && self.audio == offered.audio + && self.source_rate_control == offered.source_rate_control + && self + .client_decode + .iter() + .all(|codec| offered.client_decode.contains(codec)) + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestGateway { + id: String, + addresses: Vec, + public_identity: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestTunnel { + versions: Vec, + features: Vec, +} + +#[allow(clippy::struct_field_names)] +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestBounds { + minimum_kbps: u64, + target_kbps: u64, + maximum_kbps: u64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DisplayMode { + resolution_width: u16, + resolution_height: u16, + fps: u16, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestProfile { + id: String, + bounds: ManifestBounds, + display_mode: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct GrantReference { + opaque_value: String, + expires_at: String, + audience: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConnectionManifest { + version: String, + purpose: String, + session_id: String, + reconnect_sequence: u64, + gateway: ManifestGateway, + tunnel: ManifestTunnel, + profile: ManifestProfile, + grant: GrantReference, + correlation_id: String, +} + +impl ConnectionManifest { + /// Strictly decodes and validates an RC5 connection manifest. + /// + /// # Errors + /// + /// Returns `invalid_argument` for malformed, duplicate, trailing, unknown, or invalid data. + pub fn decode(bytes: &[u8]) -> Result { + let manifest: Self = decode_strict(bytes, MAX_MANIFEST_JSON_BYTES)?; + manifest.validate()?; + Ok(manifest) + } + + fn validate(&self) -> Result<()> { + let bounds = &self.profile.bounds; + let valid_display = self.profile.display_mode.as_ref().is_none_or(|mode| { + (320..=16_384).contains(&mode.resolution_width) + && (200..=8_640).contains(&mode.resolution_height) + && (1..=240).contains(&mode.fps) + }); + if self.version != "1" + || !matches!(self.purpose.as_str(), "launch" | "reconnect") + || !bounded(&self.session_id, 1, 128) + || !bounded(&self.gateway.id, 1, 128) + || !(1..=4).contains(&self.gateway.addresses.len()) + || self + .gateway + .addresses + .iter() + .any(|address| !bounded(address, 1, 256)) + || !valid_dns_name(&self.gateway.public_identity) + || !(1..=4).contains(&self.tunnel.versions.len()) + || self + .tunnel + .versions + .iter() + .any(|version| !bounded(version, 1, 64)) + || self.tunnel.features.len() > 32 + || self + .tunnel + .features + .iter() + .any(|feature| !bounded(feature, 1, 64)) + || !bounded(&self.profile.id, 1, 128) + || !(1..=100_000_000).contains(&bounds.minimum_kbps) + || !(1..=100_000_000).contains(&bounds.target_kbps) + || !(1..=100_000_000).contains(&bounds.maximum_kbps) + || bounds.minimum_kbps > bounds.target_kbps + || bounds.target_kbps > bounds.maximum_kbps + || !valid_display + || !bounded(&self.grant.opaque_value, 43, 256) + || timestamp(&self.grant.expires_at, false).is_none() + || !bounded(&self.grant.audience, 1, 128) + || !bounded(&self.correlation_id, 1, 128) + { + return Err(CoreError::InvalidArgument); + } + Ok(()) + } + + /// Checks expiry and the supported tunnel binding at a supplied UTC instant. + /// + /// # Errors + /// + /// Returns `invalid_argument` for unsupported input or `expired` for an expired grant. + pub fn validate_at(&self, now_utc: &str) -> Result<()> { + let now = timestamp(now_utc, false).ok_or(CoreError::InvalidArgument)?; + if timestamp(&self.grant.expires_at, false).ok_or(CoreError::InvalidArgument)? <= now { + return Err(CoreError::Expired); + } + if !self + .tunnel + .versions + .iter() + .any(|value| value == "verse-gateway-v1/1") + || !self + .tunnel + .features + .iter() + .any(|value| value == "control.v1") + { + return Err(CoreError::InvalidArgument); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NativeTunnelCredential { + client_device_id: String, + device_key_id: String, + certificate_chain_pem: String, + trust_bundle_pem: String, + expires_at: String, +} + +impl NativeTunnelCredential { + /// Strictly decodes and validates an RC5 native tunnel credential. + /// + /// # Errors + /// + /// Returns `invalid_argument` for malformed, unknown, or out-of-bound data. + pub fn decode(bytes: &[u8]) -> Result { + let credential: Self = decode_strict(bytes, MAX_CREDENTIAL_JSON_BYTES)?; + if !bounded(&credential.client_device_id, 1, 128) + || !bounded(&credential.device_key_id, 1, 128) + || !bounded(&credential.certificate_chain_pem, 1, 65_536) + || !bounded(&credential.trust_bundle_pem, 1, 65_536) + || timestamp(&credential.expires_at, false).is_none() + { + return Err(CoreError::InvalidArgument); + } + Ok(credential) + } + + /// Checks credential expiry at a supplied UTC instant. + /// + /// # Errors + /// + /// Returns `invalid_argument` for an invalid instant or `expired` after expiry. + pub fn validate_at(&self, now_utc: &str) -> Result<()> { + let now = timestamp(now_utc, false).ok_or(CoreError::InvalidArgument)?; + if timestamp(&self.expires_at, false).ok_or(CoreError::InvalidArgument)? <= now { + return Err(CoreError::Expired); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TunnelAdmissionRequest { + version: String, + session_id: String, + gateway_id: String, + audience: String, + grant: String, + reconnect_sequence: u64, + client_nonce: String, + device_signature: String, + capabilities: CapabilityProfile, +} + +impl TunnelAdmissionRequest { + /// Strictly decodes and validates an RC5 tunnel admission request. + /// + /// # Errors + /// + /// Returns `invalid_argument` for malformed, unknown, or out-of-bound data. + pub fn decode(bytes: &[u8]) -> Result { + let request: Self = decode_strict(bytes, MAX_ADMISSION_JSON_BYTES)?; + if request.version != "1" + || !bounded(&request.session_id, 1, 128) + || !bounded(&request.gateway_id, 1, 128) + || !bounded(&request.audience, 1, 256) + || !bounded(&request.grant, 43, 256) + || !bounded(&request.client_nonce, 16, 128) + || request.device_signature.len() != 86 + { + return Err(CoreError::InvalidArgument); + } + request.capabilities.validate()?; + Ok(request) + } + + #[must_use] + pub fn admission_transcript(&self) -> Vec { + let reconnect_sequence = self.reconnect_sequence.to_string(); + let decode_count = self.capabilities.client_decode.len().to_string(); + let mut fields = vec![ + self.session_id.as_str(), + self.gateway_id.as_str(), + self.audience.as_str(), + self.grant.as_str(), + reconnect_sequence.as_str(), + self.client_nonce.as_str(), + self.capabilities.transport.as_str(), + self.capabilities.framing.as_str(), + self.capabilities.media.as_str(), + self.capabilities.audio.as_str(), + self.capabilities.source_rate_control.as_str(), + decode_count.as_str(), + ]; + fields.extend(self.capabilities.client_decode.iter().map(String::as_str)); + let mut transcript = String::from("versevdi/tunnel-admission/v1"); + for field in fields { + transcript.push_str(&field.len().to_string()); + transcript.push(':'); + transcript.push_str(field); + } + transcript.into_bytes() + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClientSessionAuthority { + version: String, + session_id: String, + gateway_id: String, + audience: String, + reconnect_sequence: u64, + expires_at: String, + capabilities: CapabilityProfile, +} + +impl ClientSessionAuthority { + /// Strictly decodes and validates a provider-free RC5 client authority. + /// + /// # Errors + /// + /// Returns `invalid_argument` for malformed, unknown, provider-shaped, or invalid data. + pub fn decode(bytes: &[u8]) -> Result { + let authority: Self = decode_strict(bytes, MAX_ADMISSION_JSON_BYTES)?; + if authority.version != "1" + || !bounded(&authority.session_id, 1, 128) + || !bounded(&authority.gateway_id, 1, 128) + || !bounded(&authority.audience, 1, 256) + || timestamp(&authority.expires_at, true).is_none() + { + return Err(CoreError::InvalidArgument); + } + authority.capabilities.validate()?; + Ok(authority) + } + + /// Checks session, gateway, audience, reconnect, expiry, and capability bindings. + /// + /// # Errors + /// + /// Returns `invalid_argument` for an invalid clock value or `authority_rejected` on mismatch. + pub fn validate_binding( + &self, + manifest: &ConnectionManifest, + offered: &CapabilityProfile, + now_utc: &str, + ) -> Result<()> { + let now = timestamp(now_utc, false).ok_or(CoreError::InvalidArgument)?; + let expires = timestamp(&self.expires_at, true).ok_or(CoreError::AuthorityRejected)?; + let grant_expires = + timestamp(&manifest.grant.expires_at, false).ok_or(CoreError::AuthorityRejected)?; + if self.session_id != manifest.session_id + || self.gateway_id != manifest.gateway.id + || self.audience != manifest.grant.audience + || self.reconnect_sequence != manifest.reconnect_sequence + || expires <= now + || expires > grant_expires + || !self.capabilities.is_subset_of(offered) + { + return Err(CoreError::AuthorityRejected); + } + Ok(()) + } +} diff --git a/core/tests/fixtures/PROVENANCE.md b/core/tests/fixtures/PROVENANCE.md new file mode 100644 index 0000000..e25e76e --- /dev/null +++ b/core/tests/fixtures/PROVENANCE.md @@ -0,0 +1,13 @@ +# Protocol fixture provenance + +- Repository: `git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol` +- Commit: `4693102b3ccbb81aeb1144c1a3b0884ee682bfa3` +- Tag: `v1.0.0-phase3d-macos-rc.5` +- Schema SHA-256: `b2353c12269304289b4e872f27cc370ae61b958dea90d9fb7b6ab8afd7d37248` +- Fixture corpus SHA-256: `6d2ce3a855b2fa45733a5f7b5b4c2e68448cceed5dfbca535ec81fe8cf230b30` + +Copied byte-for-byte from `fixtures/conformance/tunnel-v1.tsv`, +`fixtures/conformance/datagram-v2.tsv`, +`fixtures/conformance/gateway-input-feedback-v1.tsv`, and +`fixtures/manifest.json`. RC4 is superseded and is not an authority for these +fixtures. diff --git a/core/tests/fixtures/datagram-v2.tsv b/core/tests/fixtures/datagram-v2.tsv new file mode 100644 index 0000000..0d437ca --- /dev/null +++ b/core/tests/fixtures/datagram-v2.tsv @@ -0,0 +1,10 @@ +id version kind input expected +v2-valid-video-single 2 datagram hex=5644020a00000000010000000000000002000000010003010203 valid +v2-valid-video-last-fragment 2 datagram hex=5644020a00000000010000000000000002037a037b0000 valid +v2-invalid-short 2 datagram hex=564402 invalid:truncated +v2-invalid-version 2 datagram hex=5644030a00000000010000000000000002000000010000 invalid:unsupported_version +v2-invalid-channel 2 datagram hex=5644020d00000000010000000000000002000000010000 invalid:unknown_channel +v2-invalid-fragment-zero 2 datagram hex=5644020a00000000010000000000000002000000000000 invalid:fragment +v2-invalid-fragment-index 2 datagram hex=5644020a00000000010000000000000002000100010000 invalid:fragment +v2-invalid-fragment-count-limit 2 datagram hex=5644020a000000000100000000000000020000037c0000 invalid:fragment_limit +v2-invalid-length 2 datagram hex=5644020a00000000010000000000000002000000010001 invalid:length_mismatch diff --git a/core/tests/fixtures/gateway-input-feedback-v1.tsv b/core/tests/fixtures/gateway-input-feedback-v1.tsv new file mode 100644 index 0000000..686f5ac --- /dev/null +++ b/core/tests/fixtures/gateway-input-feedback-v1.tsv @@ -0,0 +1,34 @@ +id version kind input expected +valid-keyboard-press 1 gateway_input hex=5647493101040102001e valid +valid-keyboard-release 1 gateway_input hex=5647493101040000001e valid +valid-mouse-button 1 gateway_input hex=564749310203010100 valid +valid-mouse-release 1 gateway_input hex=564749310203000100 valid +valid-relative-mouse 1 gateway_input hex=564749310304fffe0003 valid +valid-utf8-scalar 1 gateway_input hex=564749310403e29883 valid +valid-controller 1 gateway_input hex=5647493105110200030004ffff00010002000300040005 valid +valid-controller-release 1 gateway_input hex=5647493105110200000000000000000000000000000000 valid +valid-absolute-mouse 1 gateway_input hex=56474931060804d202370a0005a0 valid +valid-scroll 1 gateway_input hex=564749310704ff880078 valid +valid-idr 1 gateway_feedback hex=5647463100010000 valid +valid-fec 1 gateway_feedback hex=56474631000200150000002a000500030002000a000200080002140001 valid +valid-terminal-receipt 1 gateway_feedback hex=5647463100030000 valid +valid-termination 1 gateway_feedback hex=564746310110000400000001 valid +valid-rumble 1 gateway_feedback hex=56474631011100050112345678 valid +valid-hdr 1 gateway_feedback hex=564746310112000101 valid +invalid-input-magic 1 gateway_input hex=494e503101040102001e invalid:magic +invalid-input-kind 1 gateway_input hex=564749317f00 invalid:kind +invalid-input-reserved 1 gateway_input hex=564749310203010101 invalid:reserved +invalid-input-utf8 1 gateway_input hex=564749310402c328 invalid:utf8 +invalid-input-length 1 gateway_input hex=564749310104010200 invalid:length +invalid-absolute-zero-viewport 1 gateway_input hex=56474931060800000000000005a0 invalid:field +invalid-absolute-x-out-of-range 1 gateway_input hex=5647493106080a0000000a0005a0 invalid:field +invalid-absolute-y-out-of-range 1 gateway_input hex=564749310608000005a00a0005a0 invalid:field +invalid-absolute-length 1 gateway_input hex=56474931060700000000010001 invalid:length +invalid-scroll-length 1 gateway_input hex=5647493107020000 invalid:length +invalid-feedback-direction 1 gateway_feedback hex=5647463101020000 invalid:direction +invalid-terminal-receipt-direction 1 gateway_feedback hex=5647463101030000 invalid:direction +invalid-terminal-receipt-body 1 gateway_feedback hex=5647463100030001ff invalid:length +invalid-terminal-receipt-truncated 1 gateway_feedback hex=56474631000300 invalid:truncated +invalid-terminal-receipt-length 1 gateway_feedback hex=5647463100030001 invalid:length +invalid-feedback-type 1 gateway_feedback hex=5647463100040000 invalid:type +invalid-feedback-length 1 gateway_feedback hex=5647463101100003000000 invalid:length diff --git a/core/tests/fixtures/manifest.json b/core/tests/fixtures/manifest.json new file mode 100644 index 0000000..56e430e --- /dev/null +++ b/core/tests/fixtures/manifest.json @@ -0,0 +1,15 @@ +{ + "algorithm": "sha256(path\\0bytes\\0 sorted by path)", + "files": [ + "fixtures/conformance/control-v1.tsv", + "fixtures/conformance/datagram-v1.tsv", + "fixtures/conformance/datagram-v2.tsv", + "fixtures/conformance/device-proof-v1.tsv", + "fixtures/conformance/events-v1.tsv", + "fixtures/conformance/gateway-clipboard-audit-v1.tsv", + "fixtures/conformance/gateway-clipboard-v1.tsv", + "fixtures/conformance/gateway-input-feedback-v1.tsv", + "fixtures/conformance/tunnel-v1.tsv" + ], + "corpus_sha256": "6d2ce3a855b2fa45733a5f7b5b4c2e68448cceed5dfbca535ec81fe8cf230b30" +} diff --git a/core/tests/fixtures/tunnel-v1.tsv b/core/tests/fixtures/tunnel-v1.tsv new file mode 100644 index 0000000..0b1bf56 --- /dev/null +++ b/core/tests/fixtures/tunnel-v1.tsv @@ -0,0 +1,9 @@ +id version kind input expected +tunnel-current 2 tunnel offered=2;feature=control.v2 valid +tunnel-n-minus-1 1 tunnel offered=1;feature=control.v1 valid +tunnel-n-minus-2 0 tunnel offered=0;feature=control.v1 valid +tunnel-display-request 2 tunnel offered=2;feature=display.request.v1 valid +tunnel-absolute-input 2 tunnel offered=2;feature=input.absolute.v1 valid +tunnel-scroll-input 2 tunnel offered=2;feature=input.scroll.v1 valid +tunnel-unsupported 2 tunnel offered=3;feature=control.v2 invalid:unsupported_version +tunnel-no-control 2 tunnel offered=2;feature=media.video invalid:unsupported_feature diff --git a/core/tests/protocol_fixtures.rs b/core/tests/protocol_fixtures.rs new file mode 100644 index 0000000..ec1f3df --- /dev/null +++ b/core/tests/protocol_fixtures.rs @@ -0,0 +1,373 @@ +use std::path::PathBuf; +use std::process::Command; + +use versevdi_core::input::{decode_feedback, decode_input, encode_feedback, encode_input}; +use versevdi_core::media::{MediaFragment, Reassembler}; +use versevdi_core::wire::{ + CapabilityProfile, ClientSessionAuthority, ConnectionManifest, NativeTunnelCredential, + TunnelAdmissionRequest, +}; + +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) +} + +#[test] +fn copied_protocol_rc5_fixtures_have_immutable_hashes() { + let cases = [ + ( + "tunnel-v1.tsv", + "31a884800031c17844b9a8789702cf0d3639838ccfeb819c2bc3f0a5462ac5df", + ), + ( + "datagram-v2.tsv", + "65b9f6f018af624033562a78aae0b685df331c5e857d5f3d5d415aefa9d5b97b", + ), + ( + "gateway-input-feedback-v1.tsv", + "91b4dc1eb756476637ea7deebcb297256cb56d8e9c3d635088511af6b8914bda", + ), + ( + "manifest.json", + "76c33b33864d85f7d4a3761798eab52ffadbbe5fc313cf4e6e03369cfdc8df9a", + ), + ]; + + for (name, expected) in cases { + let output = Command::new("shasum") + .args(["-a", "256"]) + .arg(fixture(name)) + .output() + .expect("shasum must be installed for fixture verification"); + assert!(output.status.success(), "shasum failed for {name}"); + let actual = String::from_utf8(output.stdout).expect("shasum output is UTF-8"); + assert_eq!(&actual[..64], expected, "fixture drifted: {name}"); + } +} + +fn valid_manifest() -> &'static [u8] { + br#"{ + "version":"1","purpose":"launch","session_id":"session","reconnect_sequence":0, + "gateway":{"id":"gateway","addresses":["gateway.test:443"],"public_identity":"gateway.test"}, + "tunnel":{"versions":["verse-gateway-v1/1"],"features":["control.v1","input.absolute.v1","input.scroll.v1"]}, + "profile":{"id":"standard","bounds":{"minimum_kbps":1000,"target_kbps":5000,"maximum_kbps":10000},"display_mode":{"resolution_width":1920,"resolution_height":1080,"fps":60}}, + "grant":{"opaque_value":"ggggggggggggggggggggggggggggggggggggggggggg","expires_at":"2099-01-01T00:00:00Z","audience":"audience"}, + "correlation_id":"correlation" + }"# +} + +fn capabilities() -> CapabilityProfile { + CapabilityProfile::new( + "quic-tls13", + "datagram-v2", + "encoded", + "encoded", + "server", + vec!["h264-opus".to_owned(), "hevc-opus".to_owned()], + ) + .expect("literal capability profile is valid") +} + +#[test] +fn strict_rc5_dtos_reject_duplicate_trailing_unknown_and_provider_fields() { + assert!(ConnectionManifest::decode(valid_manifest()).is_ok()); + assert!( + ConnectionManifest::decode(br#"{"version":"1","version":"1","purpose":"launch"}"#).is_err() + ); + + let trailing = [valid_manifest(), b" {}"].concat(); + assert!(ConnectionManifest::decode(&trailing).is_err()); + + let unknown = String::from_utf8(valid_manifest().to_vec()) + .expect("fixture is UTF-8") + .replacen( + "\"correlation_id\"", + "\"unknown\":true,\"correlation_id\"", + 1, + ); + assert!(ConnectionManifest::decode(unknown.as_bytes()).is_err()); + + let provider = String::from_utf8(valid_manifest().to_vec()) + .expect("fixture is UTF-8") + .replacen( + "\"addresses\"", + "\"providerIdentity\":\"hidden\",\"addresses\"", + 1, + ); + assert!(ConnectionManifest::decode(provider.as_bytes()).is_err()); + + let authority = br#"{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}"#; + let authority_trailing = [authority.as_slice(), b" {}"].concat(); + assert!(ClientSessionAuthority::decode(&authority_trailing).is_err()); + let authority_duplicate = String::from_utf8(authority.to_vec()) + .expect("fixture is UTF-8") + .replacen( + "\"session_id\":", + "\"session_id\":\"duplicate\",\"session_id\":", + 1, + ); + assert!(ClientSessionAuthority::decode(authority_duplicate.as_bytes()).is_err()); + + let admission_provider = format!( + r#"{{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","grant":"{}","reconnect_sequence":0,"client_nonce":"{}","device_signature":"{}","provider_identity":"forbidden","capabilities":{{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}}}"#, + "g".repeat(43), + "n".repeat(16), + "A".repeat(86), + ); + assert!(TunnelAdmissionRequest::decode(admission_provider.as_bytes()).is_err()); +} + +#[test] +fn rc5_manifest_credential_and_authority_enforce_bounds_and_bindings() { + let manifest = ConnectionManifest::decode(valid_manifest()).expect("valid manifest"); + manifest + .validate_at("2026-08-12T00:00:00Z") + .expect("unexpired manifest"); + + let credential = NativeTunnelCredential::decode( + br#"{"client_device_id":"device","device_key_id":"key","certificate_chain_pem":"certificate","trust_bundle_pem":"root","expires_at":"2099-01-01T00:00:00Z"}"#, + ) + .expect("valid credential"); + credential + .validate_at("2026-08-12T00:00:00Z") + .expect("unexpired credential"); + assert!(NativeTunnelCredential::decode( + br#"{"client_device_id":"device","device_key_id":"key","certificate_chain_pem":"certificate","trust_bundle_pem":"root","client_private_key_pem":"forbidden","expires_at":"2099-01-01T00:00:00Z"}"#, + ) + .is_err()); + + let authority = ClientSessionAuthority::decode( + br#"{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}"#, + ) + .expect("valid client-safe authority"); + authority + .validate_binding(&manifest, &capabilities(), "2026-08-12T00:00:00Z") + .expect("authority is bound and is a capability subset"); + + let provider_authority = br#"{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]},"provider_profile":"apollo"}"#; + assert!(ClientSessionAuthority::decode(provider_authority).is_err()); +} + +#[test] +fn expiry_mismatch_and_capability_escalation_are_rejected() { + let manifest = ConnectionManifest::decode(valid_manifest()).expect("valid manifest"); + assert!(manifest.validate_at("2100-01-01T00:00:00Z").is_err()); + let credential = NativeTunnelCredential::decode( + br#"{"client_device_id":"device","device_key_id":"key","certificate_chain_pem":"certificate","trust_bundle_pem":"root","expires_at":"2099-01-01T00:00:00Z"}"#, + ) + .expect("valid credential"); + assert!(credential.validate_at("2099-01-01T00:00:00Z").is_err()); + + let mismatched = ClientSessionAuthority::decode( + br#"{"version":"1","session_id":"other","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}"#, + ) + .expect("shape is valid"); + assert!(mismatched + .validate_binding(&manifest, &capabilities(), "2026-08-12T00:00:00Z") + .is_err()); + + let escalated = ClientSessionAuthority::decode( + br#"{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus","hevc-opus"]}}"#, + ) + .expect("shape is valid"); + let h264_only = CapabilityProfile::new( + "quic-tls13", + "datagram-v2", + "encoded", + "encoded", + "server", + vec!["h264-opus".to_owned()], + ) + .expect("literal capability profile"); + assert!(escalated + .validate_binding(&manifest, &h264_only, "2026-08-12T00:00:00Z") + .is_err()); +} + +#[test] +fn admission_transcript_matches_rc5_literal() { + let request = TunnelAdmissionRequest::decode( + format!( + r#"{{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","grant":"{}","reconnect_sequence":0,"client_nonce":"{}","device_signature":"{}","capabilities":{{"transport":"quic-tls13","framing":"datagram-v1","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}}}"#, + "g".repeat(43), + "n".repeat(16), + "A".repeat(86), + ) + .as_bytes(), + ) + .expect("valid admission request"); + + assert_eq!( + request.admission_transcript(), + format!( + "versevdi/tunnel-admission/v17:session7:gateway8:audience43:{}1:016:{}10:quic-tls1311:datagram-v17:encoded7:encoded6:server1:19:h264-opus", + "g".repeat(43), + "n".repeat(16), + ) + .into_bytes() + ); +} + +fn decode_hex(value: &str) -> Vec { + value + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let text = std::str::from_utf8(pair).expect("fixture hex is ASCII"); + u8::from_str_radix(text, 16).expect("fixture hex is valid") + }) + .collect() +} + +fn fixture_rows(name: &str) -> impl Iterator> { + let data = match name { + "datagram-v2.tsv" => include_str!("fixtures/datagram-v2.tsv"), + "gateway-input-feedback-v1.tsv" => { + include_str!("fixtures/gateway-input-feedback-v1.tsv") + } + _ => unreachable!("known fixture"), + }; + data.lines() + .skip(1) + .map(|line| line.split('\t').collect::>()) +} + +#[test] +fn datagram_v2_codec_matches_all_rc5_conformance_rows() { + for row in fixture_rows("datagram-v2.tsv") { + let bytes = decode_hex(row[3].strip_prefix("hex=").expect("hex fixture")); + match row[4] { + "valid" => { + let fragment = MediaFragment::decode(&bytes).expect(row[0]); + assert_eq!(fragment.encode().expect(row[0]), bytes, "{}", row[0]); + } + error => assert_eq!( + MediaFragment::decode(&bytes).expect_err(row[0]).code(), + error.strip_prefix("invalid:").expect("invalid fixture"), + "{}", + row[0] + ), + } + } +} + +#[test] +fn vgi1_and_vgf1_codecs_match_all_rc5_conformance_rows() { + let features = ["input.absolute.v1", "input.scroll.v1"]; + for row in fixture_rows("gateway-input-feedback-v1.tsv") { + let bytes = decode_hex(row[3].strip_prefix("hex=").expect("hex fixture")); + let result = match row[2] { + "gateway_input" => { + decode_input(&bytes, &features).and_then(|event| encode_input(&event, &features)) + } + "gateway_feedback" => decode_feedback(&bytes).and_then(|event| encode_feedback(&event)), + _ => unreachable!("known fixture kind"), + }; + match row[4] { + "valid" => assert_eq!(result.expect(row[0]), bytes, "{}", row[0]), + error => assert_eq!( + result.expect_err(row[0]).code(), + error.strip_prefix("invalid:").expect("invalid fixture"), + "{}", + row[0] + ), + } + } +} + +#[test] +fn datagram_v2_reassembly_is_bounded_reordered_and_duplicate_safe() { + let first = MediaFragment::new_video(7, 42, 0, 2, b"hello".to_vec()).expect("fragment"); + let second = MediaFragment::new_video(7, 42, 1, 2, b" world".to_vec()).expect("fragment"); + let mut reassembler = Reassembler::new(); + + assert!(reassembler + .push(second.clone(), 10) + .expect("second") + .is_none()); + assert!(reassembler.push(second, 11).expect("duplicate").is_none()); + let unit = reassembler + .push(first, 12) + .expect("first") + .expect("complete unit"); + assert_eq!(unit.payload, b"hello world"); + assert_eq!(reassembler.incomplete_units(), 0); + + for sequence in 0..5 { + let fragment = MediaFragment::new_video(sequence, 1, 0, 2, vec![0]).expect("fragment"); + assert!(reassembler.push(fragment, 20 + u64::from(sequence)).is_ok()); + } + assert_eq!(reassembler.incomplete_units(), 4); + assert_eq!(reassembler.evicted_units(), 1); +} + +#[test] +fn datagram_v2_boundary_loop_covers_payload_fragment_count_and_audio() { + for payload_length in [0, 1, 1_177] { + let fragment = MediaFragment::new_audio( + u32::try_from(payload_length).expect("small"), + 1, + 0, + 1, + vec![0; payload_length], + ) + .expect("boundary is valid"); + assert_eq!( + MediaFragment::decode(&fragment.encode().expect("encode")).expect("decode"), + fragment + ); + } + assert!(MediaFragment::new_audio(1, 1, 0, 1, vec![0; 1_178]).is_err()); + assert!(MediaFragment::new_audio(1, 1, 0, 0, Vec::new()).is_err()); + assert!(MediaFragment::new_audio(1, 1, 0, 892, Vec::new()).is_err()); +} + +#[test] +fn reassembly_discards_conflicts_and_expires_after_250_ms() { + let mut reassembler = Reassembler::new(); + let first = MediaFragment::new_video(1, 1, 0, 2, vec![1]).expect("fragment"); + reassembler.push(first, 0).expect("first"); + let conflict = MediaFragment::new_video(1, 1, 0, 2, vec![2]).expect("fragment"); + assert!(reassembler.push(conflict, 1).is_err()); + assert_eq!(reassembler.incomplete_units(), 0); + + let expiring = MediaFragment::new_video(2, 1, 0, 2, vec![1]).expect("fragment"); + reassembler.push(expiring, 10).expect("first"); + let next = MediaFragment::new_video(3, 1, 0, 2, vec![1]).expect("fragment"); + reassembler.push(next, 261).expect("expiry sweep"); + assert_eq!(reassembler.expired_units(), 1); +} + +#[test] +fn reassembly_rejects_a_complete_unit_above_one_mebibyte() { + let mut reassembler = Reassembler::new(); + for index in 0..891_u16 { + let fragment = MediaFragment::new_video(9, 1, index, 891, vec![0; 1_177]) + .expect("individual fragment is bounded"); + let result = reassembler.push(fragment, 0); + if index < 890 { + assert!(result.expect("within aggregate bound").is_none()); + } else { + assert!(result.is_err()); + } + } + assert_eq!(reassembler.incomplete_units(), 0); +} + +#[test] +fn input_features_and_feedback_booleans_fail_closed() { + assert_eq!( + decode_input(&decode_hex("56474931060804d202370a0005a0"), &[]) + .expect_err("absolute feature is required") + .code(), + "unsupported_feature" + ); + assert_eq!( + decode_feedback(&decode_hex("564746310112000102")) + .expect_err("HDR is boolean") + .code(), + "length" + ); +}