Files
lanspread/crates/lanspread-proto/src/lib.rs
T
ddidderr 0fd368b6fa fix(proto): apply catalog component rules to wire game IDs
Follow-up to EXP2-SEC-04 (wire game IDs). The wire validator rejected
separators, control characters and the `.`/`..` pseudo-components and
otherwise relied on the catalog lookup that follows every request. That
lookup is real, but it leaves a class of IDs on the wire that the catalog
itself would never publish: Windows device names (`CON`, `nul.txt`,
`com1`), trailing dots or spaces, and the Windows-reserved characters
`< > : " | ? *`. Rejecting them at the protocol boundary means a
filesystem-backed handler can never see one, whichever consumer is added
next.

`validate_game_id` now also calls
`lanspread_db::content_manifest::validate_portable_component`, the same
function the catalog uses to admit game IDs and that `path_validation.rs`
already reuses. Reusing it rather than copying the device-name table
(the parallel branch grew three private copies) guarantees the wire rule
can neither over-match nor drift: an ID the catalog accepts always
encodes. The existing `InvalidPathComponent` error variant is reused so
callers and logs are unchanged.

Tests add the newly rejected forms and a positive list of catalog-valid
IDs that must keep encoding: embedded dots and spaces (`game..v1
(final)`), `console.txt`, `com10` and a non-ASCII name. The fixture
catalogs and the peer test suite, which encode many request IDs, pass
unchanged.

Test plan:
- `cargo test -p lanspread-proto`: 24 passed.
- `cargo test -p lanspread-peer -p lanspread-peer-cli`: 491 + 15 + 21
  passed.
- `cargo clippy -p lanspread-proto --all-targets -- -D warnings`: clean.

Claude-Session: https://claude.ai/code/session_01QRkCv4a4GqkajyamxmbSuA
2026-09-12 11:17:12 +02:00

2104 lines
69 KiB
Rust

use std::{
fmt::{self, Write as _},
marker::PhantomData,
net::SocketAddr,
str::FromStr,
};
use bytes::Bytes;
use lanspread_db::content_manifest::validate_portable_component;
pub use lanspread_db::content_manifest::{CanonicalCatalogPath, ContentId};
use serde::{
Deserialize,
Deserializer,
Serialize,
Serializer,
de::{DeserializeOwned, IgnoredAny, SeqAccess, Visitor},
};
pub const PROTOCOL_VERSION: u32 = 8;
pub const ALPN_PROTOCOL: &[u8] = b"lanspread/8";
/// Upper bound for one control-plane frame in either direction. Responses
/// (peer-state snapshots) can legitimately approach this size.
pub const MAX_CONTROL_FRAME_BYTES: usize = 8 * 1024 * 1024;
/// Upper bound for one inbound `Request` frame. Every request variant is a
/// few kilobytes at most, so public responders decode requests with this much
/// smaller allowance instead of reserving a snapshot-sized buffer per stream.
pub const MAX_REQUEST_FRAME_BYTES: usize = 64 * 1024;
pub const MAX_STREAM_INSTALL_FRAME_BYTES: usize = 8 * 1024 * 1024;
pub const MAX_LIBRARY_GAMES: usize = 4_096;
pub const MAX_GAME_ID_BYTES: usize = 255;
pub const MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR: usize = 4_096;
pub const MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS: usize = 24;
pub const MAX_CALL_TO_PLAY_MESSAGE_CHARS: usize = 500;
pub const MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES: usize = 4 * 1024 * 1024;
const PEER_ID_BYTE_LENGTH: usize = 32;
const PEER_ID_ENCODED_LENGTH: usize = 52;
const BASE32_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
/// Stable responder identity encoded as canonical lowercase unpadded RFC 4648 base32.
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PeerId([u8; PEER_ID_BYTE_LENGTH]);
impl PeerId {
/// Constructs an identity from its 32-byte value.
#[must_use]
pub const fn from_bytes(bytes: [u8; PEER_ID_BYTE_LENGTH]) -> Self {
Self(bytes)
}
/// Returns the identity's 32-byte value.
#[must_use]
pub const fn as_bytes(&self) -> &[u8; PEER_ID_BYTE_LENGTH] {
&self.0
}
}
impl fmt::Debug for PeerId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("PeerId(\"")?;
fmt::Display::fmt(self, formatter)?;
formatter.write_str("\")")
}
}
impl fmt::Display for PeerId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for byte in encode_base32(&self.0) {
formatter.write_char(char::from(byte))?;
}
Ok(())
}
}
impl FromStr for PeerId {
type Err = ParsePeerIdError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
decode_base32(value).map(Self).ok_or(ParsePeerIdError)
}
}
impl Serialize for PeerId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for PeerId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
String::deserialize(deserializer)?
.parse()
.map_err(serde::de::Error::custom)
}
}
/// Error returned when a peer ID is not in canonical string form.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ParsePeerIdError;
impl fmt::Display for ParsePeerIdError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(
"peer ID must be 52 characters of canonical lowercase unpadded RFC 4648 base32",
)
}
}
impl std::error::Error for ParsePeerIdError {}
/// One authenticated peer identity and its current transport address.
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PeerEndpoint {
pub peer_id: PeerId,
pub addr: SocketAddr,
}
impl PeerEndpoint {
#[must_use]
pub const fn new(peer_id: PeerId, addr: SocketAddr) -> Self {
Self { peer_id, addr }
}
}
fn encode_base32(input: &[u8; PEER_ID_BYTE_LENGTH]) -> [u8; PEER_ID_ENCODED_LENGTH] {
let mut output = [0_u8; PEER_ID_ENCODED_LENGTH];
let mut written = 0_usize;
let mut accumulator = 0_u16;
let mut bits = 0_u8;
for byte in input {
accumulator = (accumulator << 8) | u16::from(*byte);
bits += 8;
while bits >= 5 {
bits -= 5;
let index = usize::from((accumulator >> bits) & 0x1f);
output[written] = BASE32_ALPHABET[index];
written += 1;
accumulator &= (1_u16 << bits) - 1;
}
}
if bits != 0 {
let index = usize::from((accumulator << (5 - bits)) & 0x1f);
output[written] = BASE32_ALPHABET[index];
written += 1;
}
debug_assert_eq!(written, output.len());
output
}
fn decode_base32(value: &str) -> Option<[u8; PEER_ID_BYTE_LENGTH]> {
if value.len() != PEER_ID_ENCODED_LENGTH {
return None;
}
let mut output = [0_u8; PEER_ID_BYTE_LENGTH];
let mut written = 0_usize;
let mut accumulator = 0_u16;
let mut bits = 0_u8;
for byte in value.bytes() {
let digit = match byte {
b'a'..=b'z' => byte - b'a',
b'2'..=b'7' => byte - b'2' + 26,
_ => return None,
};
accumulator = (accumulator << 5) | u16::from(digit);
bits += 5;
if bits >= 8 {
bits -= 8;
if written == output.len() {
return None;
}
output[written] = u8::try_from(accumulator >> bits).ok()?;
written += 1;
accumulator &= (1_u16 << bits) - 1;
}
}
(written == output.len() && bits == 4 && accumulator == 0).then_some(output)
}
const NONCE_BYTE_LENGTH: usize = 16;
const NONCE_ENCODED_LENGTH: usize = NONCE_BYTE_LENGTH * 2;
macro_rules! fixed_hex_id {
($name:ident, $error:ident, $label:literal) => {
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct $name([u8; NONCE_BYTE_LENGTH]);
impl $name {
#[must_use]
pub const fn from_bytes(bytes: [u8; NONCE_BYTE_LENGTH]) -> Self {
Self(bytes)
}
#[must_use]
pub const fn as_bytes(&self) -> &[u8; NONCE_BYTE_LENGTH] {
&self.0
}
}
impl fmt::Debug for $name {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(concat!(stringify!($name), "(\""))?;
fmt::Display::fmt(self, formatter)?;
formatter.write_str("\")")
}
}
impl fmt::Display for $name {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for byte in self.0 {
write!(formatter, "{byte:02x}")?;
}
Ok(())
}
}
impl FromStr for $name {
type Err = $error;
fn from_str(value: &str) -> Result<Self, Self::Err> {
decode_fixed_lower_hex(value).map(Self).ok_or($error)
}
}
impl Serialize for $name {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
String::deserialize(deserializer)?
.parse()
.map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct $error;
impl fmt::Display for $error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(concat!(
$label,
" must contain exactly 32 lowercase hexadecimal characters"
))
}
}
impl std::error::Error for $error {}
};
}
fixed_hex_id!(
RuntimeSessionId,
ParseRuntimeSessionIdError,
"runtime session ID"
);
fixed_hex_id!(CallNonce, ParseCallNonceError, "call nonce");
fixed_hex_id!(EventNonce, ParseEventNonceError, "event nonce");
fn decode_fixed_lower_hex(value: &str) -> Option<[u8; NONCE_BYTE_LENGTH]> {
if value.len() != NONCE_ENCODED_LENGTH {
return None;
}
let mut output = [0_u8; NONCE_BYTE_LENGTH];
for (decoded, encoded) in output.iter_mut().zip(value.as_bytes().as_chunks::<2>().0) {
*decoded =
(decode_lower_hex_nibble(encoded[0])? << 4) | decode_lower_hex_nibble(encoded[1])?;
}
Some(output)
}
const fn decode_lower_hex_nibble(value: u8) -> Option<u8> {
match value {
b'0'..=b'9' => Some(value - b'0'),
b'a'..=b'f' => Some(value - b'a' + 10),
_ => None,
}
}
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct CallId {
pub creator: PeerId,
pub random_nonce: CallNonce,
}
impl CallId {
#[must_use]
pub const fn new(creator: PeerId, random_nonce: CallNonce) -> Self {
Self {
creator,
random_nonce,
}
}
}
impl fmt::Debug for CallId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("CallId(\"")?;
fmt::Display::fmt(self, formatter)?;
formatter.write_str("\")")
}
}
impl fmt::Display for CallId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}.{}", self.creator, self.random_nonce)
}
}
impl FromStr for CallId {
type Err = ParseCallIdError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let (creator, random_nonce) = value.split_once('.').ok_or(ParseCallIdError)?;
Ok(Self {
creator: creator.parse().map_err(|_| ParseCallIdError)?,
random_nonce: random_nonce.parse().map_err(|_| ParseCallIdError)?,
})
}
}
impl Serialize for CallId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for CallId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
String::deserialize(deserializer)?
.parse()
.map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ParseCallIdError;
impl fmt::Display for ParseCallIdError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(
"call ID must be a canonical PeerId, '.', and 32 lowercase hexadecimal characters",
)
}
}
impl std::error::Error for ParseCallIdError {}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PeerRevisions {
pub runtime_session_id: RuntimeSessionId,
pub library_revision: u64,
pub call_to_play_revision: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct GameAvailability {
pub game_id: String,
pub content_id: ContentId,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct LibrarySnapshot {
pub revision: u64,
#[serde(deserialize_with = "deserialize_library_games")]
pub games: Vec<GameAvailability>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ChangeHint {
pub claimed_peer_id: PeerId,
pub runtime_session_id: RuntimeSessionId,
pub revision: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct CallToPlayAuthorEvent {
pub id: EventNonce,
pub call_id: CallId,
pub at: i64,
pub action: CallToPlayAction,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub enum CallToPlayAction {
Create {
game_id: String,
max_players: u16,
scheduled_for: Option<i64>,
deadline: i64,
},
Respond {
ready_at: Option<i64>,
},
Rsvp,
SendMessage {
text: String,
},
Leave,
Cancel,
Start,
AddTime {
deadline: i64,
},
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct CallToPlayAuthorSnapshot {
pub revision: u64,
pub display_name: String,
#[serde(deserialize_with = "deserialize_call_to_play_events")]
pub events: Vec<CallToPlayAuthorEvent>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PeerStateSnapshot {
pub runtime_session_id: RuntimeSessionId,
pub library: LibrarySnapshot,
pub call_to_play: CallToPlayAuthorSnapshot,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub enum Request {
Ping,
Hello,
LibraryChanged(ChangeHint),
CallToPlayChanged(ChangeHint),
GetGameFileChunk {
game_id: String,
content_id: ContentId,
relative_path: CanonicalCatalogPath,
offset: u64,
length: u64,
},
StreamInstall {
game_id: String,
content_id: ContentId,
},
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum ControlErrorCode {
InvalidRequest,
Internal,
Unavailable,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub enum Response {
Pong(PeerRevisions),
HelloSnapshot(PeerStateSnapshot),
Error(ControlErrorCode),
}
const STREAM_INSTALL_CONTROL_FRAME_TAG: u8 = 0;
const STREAM_INSTALL_FILE_CHUNK_FRAME_TAG: u8 = 1;
const STREAM_INSTALL_ENCODE_ERROR_FRAME: &[u8] =
b"\0{\"Error\":{\"message\":\"stream install frame encoding error\"}}";
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub enum StreamInstallFrame {
ArchiveBegin {
archive_name: CanonicalCatalogPath,
solid: bool,
unpacked_size: u64,
},
Directory {
relative_path: CanonicalCatalogPath,
},
FileBegin {
relative_path: CanonicalCatalogPath,
size: u64,
crc32: u32,
},
FileChunk {
bytes: Bytes,
},
FileEnd {
relative_path: CanonicalCatalogPath,
},
ArchiveEnd {
archive_name: CanonicalCatalogPath,
},
Complete,
Error {
message: String,
},
}
#[derive(Debug)]
pub enum StreamInstallFrameDecodeError {
Empty,
FrameTooLarge { actual: usize, maximum: usize },
UnknownTag(u8),
InvalidControl(serde_json::Error),
FileChunkInControl,
}
impl fmt::Display for StreamInstallFrameDecodeError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("stream install frame is empty"),
Self::FrameTooLarge { actual, maximum } => write!(
formatter,
"stream install frame is {actual} bytes; maximum is {maximum}"
),
Self::UnknownTag(tag) => write!(formatter, "unknown stream install frame tag {tag}"),
Self::InvalidControl(error) => {
write!(formatter, "invalid stream install control frame: {error}")
}
Self::FileChunkInControl => {
formatter.write_str("stream install control frame cannot contain file bytes")
}
}
}
}
impl std::error::Error for StreamInstallFrameDecodeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::InvalidControl(error) => Some(error),
Self::Empty
| Self::FrameTooLarge { .. }
| Self::UnknownTag(_)
| Self::FileChunkInControl => None,
}
}
}
impl StreamInstallFrame {
/// Strictly decodes one bounded streamed-install frame.
///
/// An explicit sender [`StreamInstallFrame::Error`] remains a successfully
/// decoded frame. Malformed framing, JSON, or canonical paths return an
/// error so a receiver can classify them as integrity failures.
///
/// # Errors
///
/// Returns an error for an empty or oversized frame, an unknown tag,
/// malformed typed control JSON, or file bytes encoded under the control
/// tag.
pub fn decode_checked(mut bytes: Bytes) -> Result<Self, StreamInstallFrameDecodeError> {
if bytes.len() > MAX_STREAM_INSTALL_FRAME_BYTES {
return Err(StreamInstallFrameDecodeError::FrameTooLarge {
actual: bytes.len(),
maximum: MAX_STREAM_INSTALL_FRAME_BYTES,
});
}
if bytes.is_empty() {
return Err(StreamInstallFrameDecodeError::Empty);
}
let tag = bytes.split_to(1)[0];
let payload = bytes;
match tag {
STREAM_INSTALL_CONTROL_FRAME_TAG => {
let frame = serde_json::from_slice(&payload)
.map_err(StreamInstallFrameDecodeError::InvalidControl)?;
if matches!(frame, Self::FileChunk { .. }) {
return Err(StreamInstallFrameDecodeError::FileChunkInControl);
}
Ok(frame)
}
STREAM_INSTALL_FILE_CHUNK_FRAME_TAG => Ok(Self::FileChunk { bytes: payload }),
_ => Err(StreamInstallFrameDecodeError::UnknownTag(tag)),
}
}
}
/// Non-fallible framed codec retained for streamed-install data frames.
pub trait Message {
fn decode(bytes: Bytes) -> Self;
fn encode(&self) -> Bytes;
}
/// Strict, bounded JSON codec for one protocol control frame.
pub trait ControlMessage: Sized {
/// Decodes one bounded, strict control frame.
///
/// # Errors
///
/// Returns an error when the frame exceeds its bound, is not the exact
/// current wire shape, or fails request-level semantic validation.
fn decode(bytes: Bytes) -> Result<Self, ControlCodecError>;
/// Encodes one bounded, semantically valid control frame.
///
/// # Errors
///
/// Returns an error when the value fails semantic validation, cannot be
/// serialized, or its encoded frame exceeds the control-frame bound.
fn encode(&self) -> Result<Bytes, ControlCodecError>;
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ControlValidationError {
EmptyField {
field: &'static str,
},
FieldTooLong {
field: &'static str,
maximum: usize,
},
EncodedTooLarge {
field: &'static str,
actual: usize,
maximum: usize,
},
TooManyItems {
field: &'static str,
maximum: usize,
},
InvalidPathComponent {
field: &'static str,
},
DuplicateGameId,
UnsortedGameIds,
}
impl fmt::Display for ControlValidationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyField { field } => write!(formatter, "{field} cannot be blank"),
Self::FieldTooLong { field, maximum } => {
write!(formatter, "{field} exceeds its {maximum}-unit limit")
}
Self::EncodedTooLarge {
field,
actual,
maximum,
} => write!(
formatter,
"encoded {field} is {actual} bytes; maximum is {maximum}"
),
Self::TooManyItems { field, maximum } => {
write!(formatter, "{field} exceeds its {maximum}-item limit")
}
Self::InvalidPathComponent { field } => {
write!(formatter, "{field} is not a single plain path component")
}
Self::DuplicateGameId => formatter.write_str("library contains a duplicate game ID"),
Self::UnsortedGameIds => {
formatter.write_str("library game IDs are not strictly sorted")
}
}
}
}
impl std::error::Error for ControlValidationError {}
#[derive(Debug)]
pub enum ControlCodecError {
FrameTooLarge { actual: usize, maximum: usize },
Invalid(ControlValidationError),
Encode(serde_json::Error),
Decode(serde_json::Error),
}
impl fmt::Display for ControlCodecError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::FrameTooLarge { actual, maximum } => {
write!(
formatter,
"control frame is {actual} bytes; maximum is {maximum}"
)
}
Self::Invalid(error) => write!(formatter, "invalid control message: {error}"),
Self::Encode(error) => write!(formatter, "failed to encode control message: {error}"),
Self::Decode(error) => write!(formatter, "failed to decode control message: {error}"),
}
}
}
impl std::error::Error for ControlCodecError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Invalid(error) => Some(error),
Self::Encode(error) | Self::Decode(error) => Some(error),
Self::FrameTooLarge { .. } => None,
}
}
}
trait ValidateControlMessage {
fn validate_control(&self) -> Result<(), ControlValidationError>;
}
impl ControlMessage for Request {
fn decode(bytes: Bytes) -> Result<Self, ControlCodecError> {
check_request_frame_length(bytes.len())?;
decode_control_message(&bytes)
}
fn encode(&self) -> Result<Bytes, ControlCodecError> {
let bytes = encode_control_message(self)?;
check_request_frame_length(bytes.len())?;
Ok(bytes)
}
}
impl ControlMessage for Response {
fn decode(bytes: Bytes) -> Result<Self, ControlCodecError> {
decode_control_message_unvalidated(&bytes)
}
fn encode(&self) -> Result<Bytes, ControlCodecError> {
encode_control_message(self)
}
}
fn decode_control_message<T>(bytes: &[u8]) -> Result<T, ControlCodecError>
where
T: DeserializeOwned + ValidateControlMessage,
{
let message = decode_control_message_unvalidated::<T>(bytes)?;
message
.validate_control()
.map_err(ControlCodecError::Invalid)?;
Ok(message)
}
fn decode_control_message_unvalidated<T>(bytes: &[u8]) -> Result<T, ControlCodecError>
where
T: DeserializeOwned,
{
check_control_frame_length(bytes.len())?;
serde_json::from_slice::<T>(bytes).map_err(ControlCodecError::Decode)
}
fn encode_control_message<T>(message: &T) -> Result<Bytes, ControlCodecError>
where
T: Serialize + ValidateControlMessage,
{
message
.validate_control()
.map_err(ControlCodecError::Invalid)?;
let bytes = serde_json::to_vec(message).map_err(ControlCodecError::Encode)?;
check_control_frame_length(bytes.len())?;
Ok(Bytes::from(bytes))
}
const fn check_control_frame_length(length: usize) -> Result<(), ControlCodecError> {
if length > MAX_CONTROL_FRAME_BYTES {
return Err(ControlCodecError::FrameTooLarge {
actual: length,
maximum: MAX_CONTROL_FRAME_BYTES,
});
}
Ok(())
}
const fn check_request_frame_length(length: usize) -> Result<(), ControlCodecError> {
if length > MAX_REQUEST_FRAME_BYTES {
return Err(ControlCodecError::FrameTooLarge {
actual: length,
maximum: MAX_REQUEST_FRAME_BYTES,
});
}
Ok(())
}
impl ValidateControlMessage for Request {
fn validate_control(&self) -> Result<(), ControlValidationError> {
match self {
Self::Ping | Self::Hello | Self::LibraryChanged(_) | Self::CallToPlayChanged(_) => {
Ok(())
}
Self::GetGameFileChunk { game_id, .. } | Self::StreamInstall { game_id, .. } => {
validate_game_id(game_id)
}
}
}
}
impl ValidateControlMessage for Response {
fn validate_control(&self) -> Result<(), ControlValidationError> {
match self {
Self::Pong(_) | Self::Error(_) => Ok(()),
Self::HelloSnapshot(snapshot) => snapshot.validate_control(),
}
}
}
impl ValidateControlMessage for PeerStateSnapshot {
fn validate_control(&self) -> Result<(), ControlValidationError> {
self.library.validate_control()?;
self.call_to_play.validate_control()
}
}
impl ValidateControlMessage for LibrarySnapshot {
fn validate_control(&self) -> Result<(), ControlValidationError> {
self.validate()
}
}
impl LibrarySnapshot {
/// Validates the bounded, canonical library domain independently of the
/// other domains in a peer-state snapshot.
///
/// # Errors
///
/// Returns the first resource-bound or canonical-ordering violation.
pub fn validate(&self) -> Result<(), ControlValidationError> {
if self.games.len() > MAX_LIBRARY_GAMES {
return Err(ControlValidationError::TooManyItems {
field: "library games",
maximum: MAX_LIBRARY_GAMES,
});
}
for game in &self.games {
validate_game_id(&game.game_id)?;
}
for games in self.games.windows(2) {
match games[0].game_id.cmp(&games[1].game_id) {
std::cmp::Ordering::Less => {}
std::cmp::Ordering::Equal => {
return Err(ControlValidationError::DuplicateGameId);
}
std::cmp::Ordering::Greater => {
return Err(ControlValidationError::UnsortedGameIds);
}
}
}
Ok(())
}
}
impl ValidateControlMessage for CallToPlayAuthorSnapshot {
fn validate_control(&self) -> Result<(), ControlValidationError> {
self.validate()
}
}
impl CallToPlayAuthorSnapshot {
/// Validates the bounded Call-to-Play domain independently of the other
/// domains in a peer-state snapshot.
///
/// # Errors
///
/// Returns the first display-name, event-count, or event resource-bound
/// violation.
pub fn validate(&self) -> Result<(), ControlValidationError> {
validate_bounded_chars(
&self.display_name,
MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS,
"Call to Play display name",
)?;
if self.events.len() > MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR {
return Err(ControlValidationError::TooManyItems {
field: "Call to Play author events",
maximum: MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR,
});
}
for event in &self.events {
event.validate()?;
}
let actual = serde_json::to_vec(self)
.map_err(|_| ControlValidationError::EncodedTooLarge {
field: "Call to Play author snapshot",
actual: usize::MAX,
maximum: MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES,
})?
.len();
if actual > MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES {
return Err(ControlValidationError::EncodedTooLarge {
field: "Call to Play author snapshot",
actual,
maximum: MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES,
});
}
Ok(())
}
}
impl ValidateControlMessage for CallToPlayAuthorEvent {
fn validate_control(&self) -> Result<(), ControlValidationError> {
self.validate()
}
}
impl CallToPlayAuthorEvent {
/// Validates the wire-level resource bounds of one author event.
///
/// # Errors
///
/// Returns an error when a game ID or message violates its wire bound.
pub fn validate(&self) -> Result<(), ControlValidationError> {
match &self.action {
CallToPlayAction::Create { game_id, .. } => validate_game_id(game_id),
CallToPlayAction::SendMessage { text } => {
validate_bounded_chars(text, MAX_CALL_TO_PLAY_MESSAGE_CHARS, "Call to Play message")
}
CallToPlayAction::Respond { .. }
| CallToPlayAction::Rsvp
| CallToPlayAction::Leave
| CallToPlayAction::Cancel
| CallToPlayAction::Start
| CallToPlayAction::AddTime { .. } => Ok(()),
}
}
}
/// Game IDs name one catalog directory. The wire boundary therefore admits
/// only a single plain path component: no separators, no control characters,
/// and neither of the `.`/`..` pseudo-components. On top of that it applies
/// the catalog's own portable component rules (no trailing dot or space, no
/// Windows-reserved characters, no Windows device name stem) by calling the
/// `lanspread-db` validator, so anything the catalog can publish is accepted
/// and anything it would refuse never reaches a filesystem-backed handler.
/// Every consumer still resolves the ID against the local catalog before
/// touching the filesystem; this is defence in depth, not the only gate.
fn validate_game_id(game_id: &str) -> Result<(), ControlValidationError> {
if game_id.trim().is_empty() {
return Err(ControlValidationError::EmptyField { field: "game ID" });
}
if game_id.len() > MAX_GAME_ID_BYTES {
return Err(ControlValidationError::FieldTooLong {
field: "game ID",
maximum: MAX_GAME_ID_BYTES,
});
}
if matches!(game_id, "." | "..")
|| game_id
.chars()
.any(|character| character.is_control() || matches!(character, '/' | '\\'))
|| validate_portable_component(game_id).is_err()
{
return Err(ControlValidationError::InvalidPathComponent { field: "game ID" });
}
Ok(())
}
fn deserialize_library_games<'de, D>(deserializer: D) -> Result<Vec<GameAvailability>, D::Error>
where
D: Deserializer<'de>,
{
deserialize_bounded_vec(deserializer, MAX_LIBRARY_GAMES, "library games")
}
fn deserialize_call_to_play_events<'de, D>(
deserializer: D,
) -> Result<Vec<CallToPlayAuthorEvent>, D::Error>
where
D: Deserializer<'de>,
{
deserialize_bounded_vec(
deserializer,
MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR,
"Call to Play author events",
)
}
/// Deserializes a wire sequence without ever materialising more than
/// `maximum + 1` elements.
///
/// The semantic validators (`LibrarySnapshot::validate`,
/// `CallToPlayAuthorSnapshot::validate`) report `TooManyItems` when a
/// collection exceeds its limit, and `Response::decode` deliberately leaves
/// that judgement to the caller so one invalid domain does not discard the
/// other. Truncating to exactly one element past the limit keeps both
/// behaviours intact while bounding the memory an oversize frame can claim
/// during parsing: the remaining elements are consumed as `IgnoredAny` and
/// never allocated.
fn deserialize_bounded_vec<'de, D, T>(
deserializer: D,
maximum: usize,
field: &'static str,
) -> Result<Vec<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
struct BoundedVecVisitor<T> {
maximum: usize,
field: &'static str,
marker: PhantomData<T>,
}
impl<'de, T> Visitor<'de> for BoundedVecVisitor<T>
where
T: Deserialize<'de>,
{
type Value = Vec<T>;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "a bounded sequence of {}", self.field)
}
fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let mut values = Vec::new();
while let Some(value) = sequence.next_element()? {
values.push(value);
if values.len() > self.maximum {
while sequence.next_element::<IgnoredAny>()?.is_some() {}
break;
}
}
Ok(values)
}
}
deserializer.deserialize_seq(BoundedVecVisitor {
maximum,
field,
marker: PhantomData,
})
}
fn validate_bounded_chars(
value: &str,
maximum: usize,
field: &'static str,
) -> Result<(), ControlValidationError> {
if value.trim().is_empty() {
return Err(ControlValidationError::EmptyField { field });
}
if value.chars().count() > maximum {
return Err(ControlValidationError::FieldTooLong { field, maximum });
}
Ok(())
}
impl Message for StreamInstallFrame {
fn decode(bytes: Bytes) -> Self {
match Self::decode_checked(bytes) {
Ok(frame) => frame,
Err(error) => {
tracing::error!(?error, "StreamInstallFrame decoding error");
stream_install_decode_error(error.to_string())
}
}
}
fn encode(&self) -> Bytes {
match self {
StreamInstallFrame::FileChunk { bytes } => {
tagged_stream_install_frame(STREAM_INSTALL_FILE_CHUNK_FRAME_TAG, bytes)
}
_ => match serde_json::to_vec(self) {
Ok(payload) => {
tagged_stream_install_frame(STREAM_INSTALL_CONTROL_FRAME_TAG, &payload)
}
Err(e) => {
tracing::error!(?e, "StreamInstallFrame encoding error");
Bytes::from_static(STREAM_INSTALL_ENCODE_ERROR_FRAME)
}
},
}
}
}
fn tagged_stream_install_frame(tag: u8, payload: &[u8]) -> Bytes {
let mut frame = Vec::with_capacity(1 + payload.len());
frame.push(tag);
frame.extend_from_slice(payload);
Bytes::from(frame)
}
fn stream_install_decode_error(message: impl Into<String>) -> StreamInstallFrame {
StreamInstallFrame::Error {
message: message.into(),
}
}
#[cfg(test)]
mod tests {
use std::net::SocketAddr;
use serde_json::{Value, json};
use super::*;
fn path(value: &str) -> CanonicalCatalogPath {
CanonicalCatalogPath::new(value).expect("test path should be canonical")
}
fn peer(seed: u8) -> PeerId {
PeerId::from_bytes([seed; 32])
}
fn session(seed: u8) -> RuntimeSessionId {
RuntimeSessionId::from_bytes([seed; 16])
}
fn call_nonce(seed: u8) -> CallNonce {
CallNonce::from_bytes([seed; 16])
}
fn event_nonce(seed: u8) -> EventNonce {
EventNonce::from_bytes([seed; 16])
}
fn content(seed: u8) -> ContentId {
ContentId::from_bytes([seed; 32])
}
fn event(action: CallToPlayAction) -> CallToPlayAuthorEvent {
CallToPlayAuthorEvent {
id: event_nonce(4),
call_id: CallId::new(peer(5), call_nonce(6)),
at: 1_700_000_000_000,
action,
}
}
fn state_snapshot(events: Vec<CallToPlayAuthorEvent>) -> PeerStateSnapshot {
PeerStateSnapshot {
runtime_session_id: session(1),
library: LibrarySnapshot {
revision: 2,
games: vec![
GameAvailability {
game_id: "alpha".to_owned(),
content_id: content(2),
},
GameAvailability {
game_id: "bravo".to_owned(),
content_id: content(3),
},
],
},
call_to_play: CallToPlayAuthorSnapshot {
revision: 3,
display_name: "Alice".to_owned(),
events,
},
}
}
fn encode_text(message: &impl ControlMessage) -> String {
String::from_utf8(
message
.encode()
.expect("test control message should encode")
.to_vec(),
)
.expect("control JSON should be UTF-8")
}
#[test]
fn peer_id_uses_canonical_lowercase_unpadded_base32() {
let zero = PeerId::from_bytes([0_u8; 32]);
assert_eq!(zero.to_string(), "a".repeat(52));
let ones = PeerId::from_bytes([u8::MAX; 32]);
assert_eq!(ones.to_string(), format!("{}q", "7".repeat(51)));
assert_eq!(
ones.to_string()
.parse::<PeerId>()
.expect("canonical peer ID should parse"),
ones
);
assert_eq!(ones.as_bytes(), &[u8::MAX; 32]);
}
#[test]
fn peer_id_round_trips_every_byte() {
let mut bytes = [0_u8; 32];
for (value, byte) in (0_u8..32).zip(&mut bytes) {
*byte = value;
}
let peer_id = PeerId::from_bytes(bytes);
let encoded = peer_id.to_string();
assert_eq!(encoded.len(), 52);
assert_eq!(
encoded
.parse::<PeerId>()
.expect("encoded peer ID should parse"),
peer_id
);
}
#[test]
fn peer_id_rejects_every_noncanonical_shape() {
let canonical = PeerId::from_bytes([0_u8; 32]).to_string();
let mut nonzero_trailing_bits = canonical.clone();
nonzero_trailing_bits.replace_range(51..52, "b");
for invalid in [
canonical[..51].to_owned(),
format!("{canonical}a"),
canonical.to_ascii_uppercase(),
format!("{canonical}="),
canonical.replacen('a', "0", 1),
nonzero_trailing_bits,
format!(" {canonical}"),
] {
assert!(
invalid.parse::<PeerId>().is_err(),
"accepted noncanonical peer ID {invalid:?}"
);
}
}
#[test]
fn peer_id_serde_is_exactly_one_canonical_string() {
let peer_id = PeerId::from_bytes([0x5a; 32]);
let encoded = peer_id.to_string();
let json = serde_json::to_string(&peer_id).expect("peer ID should serialize");
assert_eq!(json, format!(r#""{encoded}""#));
assert_eq!(
serde_json::from_str::<PeerId>(&json).expect("peer ID should deserialize"),
peer_id
);
assert!(serde_json::from_str::<PeerId>("42").is_err());
assert!(
serde_json::from_str::<PeerId>(
r#""AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA""#,
)
.is_err()
);
}
#[test]
fn peer_endpoint_carries_strict_identity_and_address() {
let peer_id = PeerId::from_bytes([0x3c; 32]);
let addr = SocketAddr::from(([127, 0, 0, 1], 42_424));
let endpoint = PeerEndpoint::new(peer_id, addr);
let endpoint_json = serde_json::to_value(endpoint).expect("peer endpoint should serialize");
let encoded_peer_id = peer_id.to_string();
assert_eq!(endpoint.peer_id, peer_id);
assert_eq!(endpoint.addr, addr);
assert_eq!(
endpoint_json.get("peer_id").and_then(Value::as_str),
Some(encoded_peer_id.as_str())
);
assert_eq!(
endpoint_json.get("addr").and_then(Value::as_str),
Some("127.0.0.1:42424")
);
assert!(
serde_json::from_value::<PeerEndpoint>(json!({
"peer_id": encoded_peer_id,
"addr": "127.0.0.1:42424",
"extra": true,
}))
.is_err()
);
}
#[test]
fn protocol_version_and_alpn_are_one_v8_cutover() {
assert_eq!(PROTOCOL_VERSION, 8);
assert_eq!(ALPN_PROTOCOL, b"lanspread/8");
assert_eq!(MAX_CONTROL_FRAME_BYTES, 8 * 1024 * 1024);
assert_eq!(MAX_REQUEST_FRAME_BYTES, 64 * 1024);
assert_eq!(MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES, 4 * 1024 * 1024);
}
#[test]
fn maximal_requests_fit_the_request_frame_bound() {
use lanspread_db::content_manifest::{MAX_CATALOG_COMPONENT_BYTES, MAX_CATALOG_PATH_BYTES};
let directory = "d".repeat(MAX_CATALOG_COMPONENT_BYTES);
let relative_path = format!(
"{directory}/{directory}/{directory}/{}",
"f".repeat(MAX_CATALOG_PATH_BYTES - 3 * (MAX_CATALOG_COMPONENT_BYTES + 1))
);
assert_eq!(relative_path.len(), MAX_CATALOG_PATH_BYTES);
let request = Request::GetGameFileChunk {
game_id: "g".repeat(MAX_GAME_ID_BYTES),
content_id: content(0xff),
relative_path: CanonicalCatalogPath::new(relative_path)
.expect("maximal catalog path should be canonical"),
offset: u64::MAX,
length: u64::MAX,
};
let encoded = request.encode().expect("maximal request must encode");
assert!(encoded.len() < MAX_REQUEST_FRAME_BYTES / 8);
Request::decode(encoded).expect("maximal request must decode");
}
#[test]
fn fixed_hex_ids_are_canonical_and_distinct() {
let runtime = session(0xab);
let call = call_nonce(0xcd);
let event = event_nonce(0xef);
assert_eq!(runtime.to_string(), "ab".repeat(16));
assert_eq!(call.to_string(), "cd".repeat(16));
assert_eq!(event.to_string(), "ef".repeat(16));
assert_eq!(runtime.as_bytes(), &[0xab; 16]);
assert_eq!(call.as_bytes(), &[0xcd; 16]);
assert_eq!(event.as_bytes(), &[0xef; 16]);
assert_eq!(runtime.to_string().parse::<RuntimeSessionId>(), Ok(runtime));
assert_eq!(call.to_string().parse::<CallNonce>(), Ok(call));
assert_eq!(event.to_string().parse::<EventNonce>(), Ok(event));
for invalid in [
"a".repeat(31),
"a".repeat(33),
"AB".repeat(16),
format!("{}g", "a".repeat(31)),
] {
assert!(invalid.parse::<RuntimeSessionId>().is_err());
assert!(invalid.parse::<CallNonce>().is_err());
assert!(invalid.parse::<EventNonce>().is_err());
}
}
#[test]
fn call_id_is_one_canonical_string() {
let call_id = CallId::new(peer(0), call_nonce(0xab));
let expected = format!("{}.{}", "a".repeat(52), "ab".repeat(16));
assert_eq!(call_id.to_string(), expected);
assert_eq!(
serde_json::to_string(&call_id).expect("serialize"),
format!(r#""{expected}""#)
);
assert_eq!(expected.parse::<CallId>(), Ok(call_id));
for invalid in [
"a".repeat(52),
format!("{}.{}", "A".repeat(52), "ab".repeat(16)),
format!("{}.{}", "a".repeat(52), "AB".repeat(16)),
format!("{}.{}.x", "a".repeat(52), "ab".repeat(16)),
] {
assert!(invalid.parse::<CallId>().is_err(), "accepted {invalid:?}");
}
assert!(
serde_json::from_value::<CallId>(json!({
"creator": peer(0).to_string(),
"random_nonce": call_nonce(0xab).to_string(),
}))
.is_err()
);
}
#[test]
fn owned_json_values_deserialize_typed_ids() {
let runtime_session_id = session(0xab);
let call_nonce = call_nonce(0xcd);
let event_nonce = event_nonce(0xef);
let call_id = CallId::new(peer(0), call_nonce);
assert_eq!(
serde_json::from_value::<RuntimeSessionId>(json!(runtime_session_id.to_string()))
.expect("owned runtime-session JSON value should deserialize"),
runtime_session_id
);
assert_eq!(
serde_json::from_value::<CallNonce>(json!(call_nonce.to_string()))
.expect("owned call-nonce JSON value should deserialize"),
call_nonce
);
assert_eq!(
serde_json::from_value::<EventNonce>(json!(event_nonce.to_string()))
.expect("owned event-nonce JSON value should deserialize"),
event_nonce
);
assert_eq!(
serde_json::from_value::<CallId>(json!(call_id.to_string()))
.expect("owned call-ID JSON value should deserialize"),
call_id
);
}
#[test]
fn request_and_response_json_are_golden() {
let peer_id = peer(0).to_string();
let runtime = session(1).to_string();
let content_id = content(2).to_string();
assert_eq!(encode_text(&Request::Ping), r#""Ping""#);
assert_eq!(encode_text(&Request::Hello), r#""Hello""#);
assert_eq!(
encode_text(&Request::LibraryChanged(ChangeHint {
claimed_peer_id: peer(0),
runtime_session_id: session(1),
revision: 7,
})),
format!(
r#"{{"LibraryChanged":{{"claimed_peer_id":"{peer_id}","runtime_session_id":"{runtime}","revision":7}}}}"#
),
);
assert_eq!(
encode_text(&Request::CallToPlayChanged(ChangeHint {
claimed_peer_id: peer(0),
runtime_session_id: session(1),
revision: 8,
})),
format!(
r#"{{"CallToPlayChanged":{{"claimed_peer_id":"{peer_id}","runtime_session_id":"{runtime}","revision":8}}}}"#
),
);
assert_eq!(
encode_text(&Request::GetGameFileChunk {
game_id: "game".to_owned(),
content_id: content(2),
relative_path: path("bin/game.exe"),
offset: 7,
length: 9,
}),
format!(
r#"{{"GetGameFileChunk":{{"game_id":"game","content_id":"{content_id}","relative_path":"bin/game.exe","offset":7,"length":9}}}}"#
),
);
assert_eq!(
encode_text(&Request::StreamInstall {
game_id: "game".to_owned(),
content_id: content(2),
}),
format!(r#"{{"StreamInstall":{{"game_id":"game","content_id":"{content_id}"}}}}"#),
);
assert_eq!(
encode_text(&Response::Pong(PeerRevisions {
runtime_session_id: session(1),
library_revision: 2,
call_to_play_revision: 3,
})),
format!(
r#"{{"Pong":{{"runtime_session_id":"{runtime}","library_revision":2,"call_to_play_revision":3}}}}"#
),
);
assert_eq!(
encode_text(&Response::Error(ControlErrorCode::Unavailable)),
r#"{"Error":"Unavailable"}"#,
);
let empty = PeerStateSnapshot {
runtime_session_id: session(1),
library: LibrarySnapshot {
revision: 2,
games: Vec::new(),
},
call_to_play: CallToPlayAuthorSnapshot {
revision: 3,
display_name: "Alice".to_owned(),
events: Vec::new(),
},
};
assert_eq!(
encode_text(&Response::HelloSnapshot(empty)),
format!(
r#"{{"HelloSnapshot":{{"runtime_session_id":"{runtime}","library":{{"revision":2,"games":[]}},"call_to_play":{{"revision":3,"display_name":"Alice","events":[]}}}}}}"#
),
);
}
#[test]
fn call_to_play_actions_have_golden_json() {
let cases = [
(
CallToPlayAction::Create {
game_id: "game".to_owned(),
max_players: 4,
scheduled_for: None,
deadline: 42,
},
r#"{"Create":{"game_id":"game","max_players":4,"scheduled_for":null,"deadline":42}}"#,
),
(
CallToPlayAction::Respond { ready_at: Some(12) },
r#"{"Respond":{"ready_at":12}}"#,
),
(CallToPlayAction::Rsvp, r#""Rsvp""#),
(
CallToPlayAction::SendMessage {
text: "hello".to_owned(),
},
r#"{"SendMessage":{"text":"hello"}}"#,
),
(CallToPlayAction::Leave, r#""Leave""#),
(CallToPlayAction::Cancel, r#""Cancel""#),
(CallToPlayAction::Start, r#""Start""#),
(
CallToPlayAction::AddTime { deadline: 99 },
r#"{"AddTime":{"deadline":99}}"#,
),
];
for (action, expected) in cases {
assert_eq!(
serde_json::to_string(&action).expect("serialize action"),
expected
);
}
}
#[test]
fn all_control_variants_round_trip() {
let hint = ChangeHint {
claimed_peer_id: peer(7),
runtime_session_id: session(8),
revision: 9,
};
let requests = [
Request::Ping,
Request::Hello,
Request::LibraryChanged(hint),
Request::CallToPlayChanged(hint),
Request::GetGameFileChunk {
game_id: "game".to_owned(),
content_id: content(9),
relative_path: path("data/file.bin"),
offset: 10,
length: 11,
},
Request::StreamInstall {
game_id: "game".to_owned(),
content_id: content(9),
},
];
for request in requests {
let encoded = request.encode().expect("encode request");
assert_eq!(Request::decode(encoded).expect("decode request"), request);
}
let responses = [
Response::Pong(PeerRevisions {
runtime_session_id: session(1),
library_revision: 2,
call_to_play_revision: 3,
}),
Response::HelloSnapshot(state_snapshot(vec![event(CallToPlayAction::Rsvp)])),
Response::Error(ControlErrorCode::InvalidRequest),
Response::Error(ControlErrorCode::Internal),
Response::Error(ControlErrorCode::Unavailable),
];
for response in responses {
let encoded = response.encode().expect("encode response");
assert_eq!(
Response::decode(encoded).expect("decode response"),
response
);
}
}
#[test]
fn strict_serde_rejects_unknown_missing_duplicate_and_identity_fields() {
let content_id = content(1);
for invalid in [
format!(
r#"{{"StreamInstall":{{"game_id":"game","content_id":"{content_id}","extra":true}}}}"#
),
r#"{"StreamInstall":{"game_id":"game"}}"#.to_owned(),
format!(
r#"{{"StreamInstall":{{"game_id":"game","game_id":"other","content_id":"{content_id}"}}}}"#
),
r#"{"Hello":{"peer_id":"legacy","proto_ver":7}}"#.to_owned(),
] {
assert!(Request::decode(Bytes::from(invalid)).is_err());
}
let mut value = serde_json::to_value(Response::HelloSnapshot(state_snapshot(vec![event(
CallToPlayAction::Rsvp,
)])))
.expect("serialize snapshot");
value["HelloSnapshot"]["call_to_play"]["events"][0]["actor_id"] = json!(peer(8));
value["HelloSnapshot"]["call_to_play"]["events"][0]["actor_name"] = json!("Mallory");
assert!(Response::decode(Bytes::from(serde_json::to_vec(&value).expect("json"))).is_err());
}
#[test]
fn oversize_wire_collections_are_truncated_to_one_past_their_limit() {
// Build the JSON by hand: the encoder refuses to produce these frames.
let event_json = serde_json::to_string(&event(CallToPlayAction::Rsvp)).expect("event json");
let events =
std::iter::repeat_n(event_json.as_str(), MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR * 4)
.collect::<Vec<_>>()
.join(",");
let snapshot = format!(r#"{{"revision":1,"display_name":"Alice","events":[{events}]}}"#);
let decoded: CallToPlayAuthorSnapshot =
serde_json::from_str(&snapshot).expect("oversize sequence still parses");
assert_eq!(decoded.events.len(), MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR + 1);
assert!(matches!(
decoded.validate(),
Err(ControlValidationError::TooManyItems {
field: "Call to Play author events",
..
})
));
let content_id = content(1);
let games = (0..MAX_LIBRARY_GAMES * 4)
.map(|index| format!(r#"{{"game_id":"game-{index:05}","content_id":"{content_id}"}}"#))
.collect::<Vec<_>>()
.join(",");
let library = format!(r#"{{"revision":1,"games":[{games}]}}"#);
let decoded: LibrarySnapshot =
serde_json::from_str(&library).expect("oversize sequence still parses");
assert_eq!(decoded.games.len(), MAX_LIBRARY_GAMES + 1);
assert!(matches!(
decoded.validate(),
Err(ControlValidationError::TooManyItems {
field: "library games",
..
})
));
// A sequence exactly at the limit is untouched.
let games = (0..MAX_LIBRARY_GAMES)
.map(|index| format!(r#"{{"game_id":"game-{index:05}","content_id":"{content_id}"}}"#))
.collect::<Vec<_>>()
.join(",");
let decoded: LibrarySnapshot =
serde_json::from_str(&format!(r#"{{"revision":1,"games":[{games}]}}"#))
.expect("sequence at the limit parses");
assert_eq!(decoded.games.len(), MAX_LIBRARY_GAMES);
decoded.validate().expect("sequence at the limit is valid");
}
#[test]
fn response_decode_isolates_domain_validation() {
let invalid_call_to_play = state_snapshot(vec![
event(CallToPlayAction::Rsvp);
MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR + 1
]);
let decoded = Response::decode(Bytes::from(
serde_json::to_vec(&Response::HelloSnapshot(invalid_call_to_play.clone()))
.expect("structural response should serialize"),
))
.expect("semantic domain validation must not reject the response frame");
let Response::HelloSnapshot(decoded) = decoded else {
panic!("decoded the wrong response variant");
};
decoded.library.validate().expect("library is valid");
assert_eq!(
decoded.call_to_play.events.len(),
MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR + 1
);
assert!(decoded.call_to_play.validate().is_err());
assert!(
Response::HelloSnapshot(invalid_call_to_play)
.encode()
.is_err()
);
let mut invalid_library = state_snapshot(Vec::new());
invalid_library.library.games.swap(0, 1);
let decoded = Response::decode(Bytes::from(
serde_json::to_vec(&Response::HelloSnapshot(invalid_library.clone()))
.expect("structural response should serialize"),
))
.expect("semantic domain validation must not reject the response frame");
let Response::HelloSnapshot(decoded) = decoded else {
panic!("decoded the wrong response variant");
};
assert!(decoded.library.validate().is_err());
decoded
.call_to_play
.validate()
.expect("Call to Play slice is valid");
assert!(Response::HelloSnapshot(invalid_library).encode().is_err());
}
#[test]
fn canonical_wire_scalars_are_enforced_during_decode() {
let peer_id = peer(1).to_string();
let runtime = session(0xab).to_string();
for invalid in [
format!(
r#"{{"LibraryChanged":{{"claimed_peer_id":"{}","runtime_session_id":"{runtime}","revision":1}}}}"#,
peer_id.to_ascii_uppercase()
),
format!(
r#"{{"LibraryChanged":{{"claimed_peer_id":"{peer_id}","runtime_session_id":"{}","revision":1}}}}"#,
runtime.to_ascii_uppercase()
),
format!(
r#"{{"GetGameFileChunk":{{"game_id":"game","content_id":"{}","relative_path":"file","offset":0,"length":1}}}}"#,
content(0xab).to_string().to_ascii_uppercase()
),
format!(
r#"{{"GetGameFileChunk":{{"game_id":"game","content_id":"{}","relative_path":"../file","offset":0,"length":1}}}}"#,
content(3)
),
] {
assert!(Request::decode(Bytes::from(invalid)).is_err());
}
}
#[test]
fn established_resource_boundaries_are_exact() {
for game_id in [
"g".repeat(MAX_GAME_ID_BYTES),
"é".repeat(MAX_GAME_ID_BYTES / 2),
] {
Request::StreamInstall {
game_id,
content_id: content(1),
}
.encode()
.expect("bounded game ID should encode");
}
assert!(
Request::StreamInstall {
game_id: "g".repeat(MAX_GAME_ID_BYTES + 1),
content_id: content(1),
}
.encode()
.is_err()
);
for game_id in [
".",
"..",
"../game",
"game/../other",
"dir/game",
"dir\\game",
"game\0",
"game\n",
"game.",
"game ",
"CON",
"nul.txt",
"com1",
"C:game",
"game?",
"game*",
] {
assert!(
matches!(
Request::StreamInstall {
game_id: game_id.to_owned(),
content_id: content(1),
}
.encode(),
Err(ControlCodecError::Invalid(
ControlValidationError::InvalidPathComponent { field: "game ID" }
))
),
"accepted game ID {game_id:?}"
);
}
for game_id in ["game..v1 (final)", "console.txt", "com10", "Jörg"] {
Request::StreamInstall {
game_id: game_id.to_owned(),
content_id: content(1),
}
.encode()
.expect("catalog-valid game ID should encode");
}
let mut snapshot = state_snapshot(Vec::new());
snapshot.call_to_play.display_name = "é".repeat(MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS);
Response::HelloSnapshot(snapshot.clone())
.encode()
.expect("bounded name");
snapshot.call_to_play.display_name = "x".repeat(MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS + 1);
assert!(Response::HelloSnapshot(snapshot).encode().is_err());
let bounded_message = "x".repeat(MAX_CALL_TO_PLAY_MESSAGE_CHARS);
Response::HelloSnapshot(state_snapshot(vec![event(CallToPlayAction::SendMessage {
text: bounded_message,
})]))
.encode()
.expect("bounded message");
assert!(
Response::HelloSnapshot(state_snapshot(vec![event(CallToPlayAction::SendMessage {
text: "x".repeat(MAX_CALL_TO_PLAY_MESSAGE_CHARS + 1),
},)]))
.encode()
.is_err()
);
let bounded_events =
vec![event(CallToPlayAction::Rsvp); MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR];
Response::HelloSnapshot(state_snapshot(bounded_events.clone()))
.encode()
.expect("bounded events");
let mut oversized_events = bounded_events;
oversized_events.push(event(CallToPlayAction::Rsvp));
assert!(
Response::HelloSnapshot(state_snapshot(oversized_events))
.encode()
.is_err()
);
}
#[test]
fn library_is_bounded_unique_and_strictly_sorted() {
let games = (0..MAX_LIBRARY_GAMES)
.map(|index| GameAvailability {
game_id: format!("game-{index:04}"),
content_id: content(1),
})
.collect::<Vec<_>>();
let mut snapshot = state_snapshot(Vec::new());
snapshot.library.games = games.clone();
Response::HelloSnapshot(snapshot)
.encode()
.expect("bounded sorted library");
let mut oversized = games.clone();
oversized.push(GameAvailability {
game_id: "zzzz".to_owned(),
content_id: content(1),
});
let mut snapshot = state_snapshot(Vec::new());
snapshot.library.games = oversized;
assert!(matches!(
Response::HelloSnapshot(snapshot).encode(),
Err(ControlCodecError::Invalid(
ControlValidationError::TooManyItems { .. }
))
));
for games in [
vec![
GameAvailability {
game_id: "same".to_owned(),
content_id: content(1),
},
GameAvailability {
game_id: "same".to_owned(),
content_id: content(2),
},
],
vec![
GameAvailability {
game_id: "z".to_owned(),
content_id: content(1),
},
GameAvailability {
game_id: "a".to_owned(),
content_id: content(2),
},
],
] {
let mut snapshot = state_snapshot(Vec::new());
snapshot.library.games = games;
assert!(Response::HelloSnapshot(snapshot).encode().is_err());
}
}
#[test]
fn control_frame_limit_and_single_document_are_enforced() {
assert!(matches!(
Request::decode(Bytes::from(vec![b' '; MAX_REQUEST_FRAME_BYTES])),
Err(ControlCodecError::Decode(_))
));
assert!(matches!(
Request::decode(Bytes::from(vec![b' '; MAX_REQUEST_FRAME_BYTES + 1])),
Err(ControlCodecError::FrameTooLarge { actual, maximum })
if actual == MAX_REQUEST_FRAME_BYTES + 1 && maximum == MAX_REQUEST_FRAME_BYTES
));
assert!(matches!(
Response::decode(Bytes::from(vec![b' '; MAX_CONTROL_FRAME_BYTES])),
Err(ControlCodecError::Decode(_))
));
assert!(matches!(
Response::decode(Bytes::from(vec![b' '; MAX_CONTROL_FRAME_BYTES + 1])),
Err(ControlCodecError::FrameTooLarge { actual, maximum })
if actual == MAX_CONTROL_FRAME_BYTES + 1 && maximum == MAX_CONTROL_FRAME_BYTES
));
assert!(matches!(
Request::decode(Bytes::from_static(br#""Ping""Hello""#)),
Err(ControlCodecError::Decode(_))
));
let escaped = format!("x{}", "\0".repeat(MAX_CALL_TO_PLAY_MESSAGE_CHARS - 1));
let events = vec![
event(CallToPlayAction::SendMessage { text: escaped });
MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR
];
assert!(matches!(
Response::HelloSnapshot(state_snapshot(events)).encode(),
Err(ControlCodecError::Invalid(
ControlValidationError::EncodedTooLarge { .. }
))
));
}
#[test]
fn maximal_author_budget_still_fits_with_a_bounded_library() {
fn author_with_message_chars(char_count: usize) -> CallToPlayAuthorSnapshot {
let text = "😀".repeat(char_count);
let events = (0..MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR)
.map(|index| {
let mut event = event(CallToPlayAction::SendMessage { text: text.clone() });
event.id = EventNonce::from_bytes((index as u128).to_be_bytes());
event
})
.collect();
state_snapshot(events).call_to_play
}
let mut accepted_chars = 0;
let mut rejected_chars = MAX_CALL_TO_PLAY_MESSAGE_CHARS + 1;
while accepted_chars + 1 < rejected_chars {
let candidate = usize::midpoint(accepted_chars, rejected_chars);
if author_with_message_chars(candidate).validate().is_ok() {
accepted_chars = candidate;
} else {
rejected_chars = candidate;
}
}
assert!(accepted_chars > 0);
assert!(rejected_chars <= MAX_CALL_TO_PLAY_MESSAGE_CHARS);
let accepted_author = author_with_message_chars(accepted_chars);
accepted_author.validate().expect("author at byte budget");
let rejected_author = author_with_message_chars(rejected_chars);
assert!(matches!(
rejected_author.validate(),
Err(ControlValidationError::EncodedTooLarge { .. })
));
let games = (0..MAX_LIBRARY_GAMES)
.map(|index| GameAvailability {
game_id: format!("{index:04}-{}", "g".repeat(MAX_GAME_ID_BYTES - 5)),
content_id: content(1),
})
.collect();
let response = Response::HelloSnapshot(PeerStateSnapshot {
runtime_session_id: session(1),
library: LibrarySnapshot { revision: 1, games },
call_to_play: accepted_author,
});
let encoded = response
.encode()
.expect("a maximally accepted author plus bounded library must fit");
assert!(encoded.len() <= MAX_CONTROL_FRAME_BYTES);
let isolated = PeerStateSnapshot {
runtime_session_id: session(1),
library: LibrarySnapshot {
revision: 1,
games: Vec::new(),
},
call_to_play: rejected_author,
};
let decoded = Response::decode(Bytes::from(
serde_json::to_vec(&Response::HelloSnapshot(isolated))
.expect("structural response should serialize"),
))
.expect("invalid author remains domain-isolated during response decode");
let Response::HelloSnapshot(decoded) = decoded else {
panic!("wrong response variant");
};
decoded.library.validate().expect("library remains valid");
assert!(matches!(
decoded.call_to_play.validate(),
Err(ControlValidationError::EncodedTooLarge { .. })
));
}
#[test]
fn removed_v7_control_surface_has_no_decode_fallback() {
for variant in [
"ListGames",
"GetGame",
"GetGameFileData",
"LibraryDelta",
"CallToPlayEvents",
"Goodbye",
"Invalid",
] {
let old = format!(r#"{{"{variant}":{{}}}}"#);
assert!(
Request::decode(Bytes::from(old)).is_err(),
"accepted v7 {variant}"
);
}
for variant in [
"ListGames",
"GetGame",
"HelloAck",
"CallToPlayAck",
"GameNotFound",
"InvalidRequest",
"EncodingError",
"DecodingError",
"InternalPeerError",
] {
let old = format!(r#"{{"{variant}":{{}}}}"#);
assert!(
Response::decode(Bytes::from(old)).is_err(),
"accepted v7 {variant}"
);
}
assert!(Response::decode(Bytes::from_static(br#""Pong""#)).is_err());
assert!(Request::decode(Bytes::from_static(
br#"{"GetGameFileChunk":{"game_id":"game","relative_path":"file","offset":0,"length":1}}"#,
)).is_err());
assert!(
Request::decode(Bytes::from_static(
br#"{"StreamInstall":{"game_id":"game"}}"#,
))
.is_err()
);
// Ping is intentionally a unit value in both schemas; version-bound ALPN
// rejects a protocol-7 connection before any control JSON is decoded.
assert_eq!(
Request::decode(Bytes::from_static(br#""Ping""#)).expect("v8 Ping should decode"),
Request::Ping
);
}
#[test]
fn stream_install_paths_are_canonical_but_codec_semantics_stay_in_band() {
let frame = StreamInstallFrame::FileBegin {
relative_path: path("bin/game.exe"),
size: 42,
crc32: 7,
};
assert_eq!(StreamInstallFrame::decode(frame.encode()), frame);
match StreamInstallFrame::decode(Bytes::new()) {
StreamInstallFrame::Error { message } => assert!(message.contains("empty")),
other => panic!("expected error frame, got {other:?}"),
}
let invalid = Bytes::from_static(b"\0{\"Directory\":{\"relative_path\":\"../escape\"}}");
assert!(matches!(
StreamInstallFrame::decode(invalid),
StreamInstallFrame::Error { .. }
));
}
#[test]
fn checked_stream_decode_distinguishes_wire_errors_from_sender_errors() {
let explicit = StreamInstallFrame::Error {
message: "provider unavailable".to_owned(),
};
assert_eq!(
StreamInstallFrame::decode_checked(explicit.encode()).expect("explicit Error is data"),
explicit
);
assert!(matches!(
StreamInstallFrame::decode_checked(Bytes::new()),
Err(StreamInstallFrameDecodeError::Empty)
));
assert!(matches!(
StreamInstallFrame::decode_checked(Bytes::from_static(b"\x7f{}")),
Err(StreamInstallFrameDecodeError::UnknownTag(0x7f))
));
assert!(matches!(
StreamInstallFrame::decode_checked(Bytes::from_static(
b"\0{\"Directory\":{\"relative_path\":\"../escape\"}}"
)),
Err(StreamInstallFrameDecodeError::InvalidControl(_))
));
let file_chunk_under_control = tagged_stream_install_frame(
STREAM_INSTALL_CONTROL_FRAME_TAG,
&serde_json::to_vec(&StreamInstallFrame::FileChunk {
bytes: Bytes::from_static(b"bytes"),
})
.expect("serialize test frame"),
);
assert!(matches!(
StreamInstallFrame::decode_checked(file_chunk_under_control),
Err(StreamInstallFrameDecodeError::FileChunkInControl)
));
assert!(matches!(
StreamInstallFrame::decode_checked(Bytes::from(vec![
STREAM_INSTALL_FILE_CHUNK_FRAME_TAG;
MAX_STREAM_INSTALL_FRAME_BYTES + 1
])),
Err(StreamInstallFrameDecodeError::FrameTooLarge { actual, maximum })
if actual == MAX_STREAM_INSTALL_FRAME_BYTES + 1
&& maximum == MAX_STREAM_INSTALL_FRAME_BYTES
));
}
}