feat(peer)!: cut over to authenticated catalog sharing

Replace address-only trust and pushed peer state with installation identities,
SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned
protocol-8 pulls. The runtime now owns each network generation and all admitted
work through shutdown.

Add exact bundled content identities, reproducible manifest publishing,
capability-confined downloads, streaming BLAKE3 verification, quarantine and
retry, and crash-recoverable download and install transactions. Ship generated
fixture catalogs and fail closed when production manifests are absent.

The Tauri backend exposes durable sharing policy, redacted identity state, and
attempt-keyed transfer snapshots. Frontend consumption follows in the next
commit. Repository-wide test certificates and protocol-7 paths are removed.

BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts;
protocol-7 frames and shared-certificate identities are no longer accepted.

Test Plan:
- `just test` -- passed on the completed stack (708 workspace tests)
- `just clippy` -- passed on the completed stack
- `just build` -- passed with fixture catalogs on the completed stack
- `just catalog-check-production` -- failed closed because the external
  production manifest corpus is absent
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-10 13:59:18 +02:00
parent 36c4785775
commit 60fd7ba0c2
128 changed files with 51759 additions and 10784 deletions
-1
View File
@@ -5,7 +5,6 @@ edition = "2024"
[lib]
doctest = false
test = false
[dependencies]
# local
+1705 -154
View File
@@ -1,56 +1,409 @@
use std::net::SocketAddr;
use std::{
fmt::{self, Write as _},
net::SocketAddr,
str::FromStr,
};
use bytes::Bytes;
use lanspread_db::db::{Game, GameFileDescription};
use serde::{Deserialize, Serialize};
pub use lanspread_db::content_manifest::{CanonicalCatalogPath, ContentId};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::DeserializeOwned};
pub const PROTOCOL_VERSION: u32 = 7;
pub const PROTOCOL_VERSION: u32 = 8;
pub const ALPN_PROTOCOL: &[u8] = b"lanspread/8";
pub const MAX_CONTROL_FRAME_BYTES: usize = 8 * 1024 * 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;
pub use lanspread_db::db::Availability;
const PEER_ID_BYTE_LENGTH: usize = 32;
const PEER_ID_ENCODED_LENGTH: usize = 52;
const BASE32_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct GameSummary {
pub id: String,
pub name: String,
pub size: u64,
pub downloaded: bool,
pub installed: bool,
pub eti_version: Option<String>,
pub manifest_hash: u64,
pub availability: Availability,
/// 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
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Hello {
pub peer_id: String,
pub proto_ver: u32,
pub listen_addr: SocketAddr,
pub library: LibrarySnapshot,
pub features: Vec<String>,
pub call_to_play_events: Vec<CallToPlayEvent>,
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("\")")
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HelloAck {
pub peer_id: String,
pub proto_ver: u32,
pub listen_addr: SocketAddr,
pub library: LibrarySnapshot,
pub features: Vec<String>,
pub call_to_play_events: Vec<CallToPlayEvent>,
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(())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct CallToPlayEvent {
pub id: String,
pub call_id: String,
pub actor_id: String,
pub actor_name: String,
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().chunks_exact(2)) {
*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,
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, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub enum CallToPlayAction {
Create {
game_id: String,
@@ -63,7 +416,6 @@ pub enum CallToPlayAction {
},
Rsvp,
SendMessage {
message_id: String,
text: String,
},
Leave,
@@ -74,78 +426,55 @@ pub enum CallToPlayAction {
},
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum CallToPlayAck {
Applied,
Duplicate,
NeedHandshake,
NeedHistory,
Obsolete,
Rejected { reason: String },
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct CallToPlayAuthorSnapshot {
pub revision: u64,
pub display_name: String,
pub events: Vec<CallToPlayAuthorEvent>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LibrarySnapshot {
pub library_rev: u64,
pub games: Vec<GameSummary>,
#[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, Serialize, Deserialize)]
pub struct LibraryDelta {
pub from_rev: u64,
pub to_rev: u64,
pub added: Vec<GameSummary>,
pub updated: Vec<GameSummary>,
pub removed: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub enum Request {
Ping,
ListGames,
GetGame {
id: String,
},
GetGameFileData(GameFileDescription),
Hello,
LibraryChanged(ChangeHint),
CallToPlayChanged(ChangeHint),
GetGameFileChunk {
game_id: String,
relative_path: String,
content_id: ContentId,
relative_path: CanonicalCatalogPath,
offset: u64,
length: u64,
},
StreamInstall {
game_id: String,
content_id: ContentId,
},
Hello(Hello),
LibraryDelta {
peer_id: String,
delta: LibraryDelta,
},
CallToPlayEvents {
peer_id: String,
events: Vec<CallToPlayEvent>,
},
Goodbye {
peer_id: String,
},
Invalid(Bytes, String),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[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,
ListGames(Vec<Game>),
GetGame {
id: String,
file_descriptions: Vec<GameFileDescription>,
},
HelloAck(HelloAck),
CallToPlayAck(CallToPlayAck),
GameNotFound(String),
InvalidRequest(Bytes, String),
EncodingError(String),
DecodingError(Bytes, String),
InternalPeerError(String),
Pong(PeerRevisions),
HelloSnapshot(PeerStateSnapshot),
Error(ControlErrorCode),
}
const STREAM_INSTALL_CONTROL_FRAME_TAG: u8 = 0;
@@ -154,17 +483,18 @@ 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: String,
archive_name: CanonicalCatalogPath,
solid: bool,
unpacked_size: u64,
},
Directory {
relative_path: String,
relative_path: CanonicalCatalogPath,
},
FileBegin {
relative_path: String,
relative_path: CanonicalCatalogPath,
size: u64,
crc32: u32,
},
@@ -172,10 +502,10 @@ pub enum StreamInstallFrame {
bytes: Bytes,
},
FileEnd {
relative_path: String,
relative_path: CanonicalCatalogPath,
},
ArchiveEnd {
archive_name: String,
archive_name: CanonicalCatalogPath,
},
Complete,
Error {
@@ -183,70 +513,437 @@ pub enum StreamInstallFrame {
},
}
// Add Message trait
#[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;
}
// Implement for Request
impl Message for Request {
fn decode(bytes: Bytes) -> Self {
match serde_json::from_slice(&bytes) {
Ok(t) => t,
Err(e) => {
tracing::error!(?e, "Request decoding error");
Request::Invalid(bytes, e.to_string())
}
}
}
/// 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>;
fn encode(&self) -> Bytes {
match serde_json::to_vec(self) {
Ok(s) => Bytes::from(s),
Err(e) => {
tracing::error!(?e, "Request encoding error");
Bytes::from(format!(r#"{{"error": "encoding error: {e}"}}"#))
/// 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,
},
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::DuplicateGameId => formatter.write_str("library contains a duplicate game ID"),
Self::UnsortedGameIds => {
formatter.write_str("library game IDs are not strictly sorted")
}
}
}
}
// Implement for Response
impl Message for Response {
fn decode(bytes: Bytes) -> Self {
match serde_json::from_slice(&bytes) {
Ok(t) => t,
Err(e) => {
tracing::error!(?e, "Response decoding error");
Response::DecodingError(bytes, e.to_string())
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> {
decode_control_message(&bytes)
}
fn encode(&self) -> Bytes {
match serde_json::to_vec(self) {
Ok(s) => Bytes::from(s),
Err(e) => {
tracing::error!(?e, "Response encoding error");
Bytes::from(format!(r#"{{"error": "encoding error: {e}"}}"#))
fn encode(&self) -> Result<Bytes, ControlCodecError> {
encode_control_message(self)
}
}
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(())
}
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(()),
}
}
}
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,
});
}
Ok(())
}
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 {
if bytes.is_empty() {
return stream_install_decode_error("stream install frame is empty");
}
let tag = bytes[0];
let payload = bytes.slice(1..);
match tag {
STREAM_INSTALL_CONTROL_FRAME_TAG => decode_stream_install_control_frame(&payload),
STREAM_INSTALL_FILE_CHUNK_FRAME_TAG => StreamInstallFrame::FileChunk { bytes: payload },
_ => stream_install_decode_error(format!("unknown stream install frame tag {tag}")),
match Self::decode_checked(bytes) {
Ok(frame) => frame,
Err(error) => {
tracing::error!(?error, "StreamInstallFrame decoding error");
stream_install_decode_error(error.to_string())
}
}
}
@@ -268,19 +965,6 @@ impl Message for StreamInstallFrame {
}
}
fn decode_stream_install_control_frame(payload: &[u8]) -> StreamInstallFrame {
match serde_json::from_slice(payload) {
Ok(StreamInstallFrame::FileChunk { .. }) => {
stream_install_decode_error("stream install control frame cannot contain file bytes")
}
Ok(frame) => frame,
Err(e) => {
tracing::error!(?e, "StreamInstallFrame decoding error");
stream_install_decode_error(format!("stream install frame decoding error: {e}"))
}
}
}
fn tagged_stream_install_frame(tag: u8, payload: &[u8]) -> Bytes {
let mut frame = Vec::with_capacity(1 + payload.len());
frame.push(tag);
@@ -293,3 +977,870 @@ fn stream_install_decode_error(message: impl Into<String>) -> StreamInstallFrame
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_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES, 4 * 1024 * 1024);
}
#[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 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!(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()
);
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_CONTROL_FRAME_BYTES])),
Err(ControlCodecError::Decode(_))
));
assert!(matches!(
Request::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
));
}
}
@@ -1,4 +1,5 @@
use bytes::Bytes;
use lanspread_db::content_manifest::CanonicalCatalogPath;
use lanspread_proto::{Message, StreamInstallFrame};
#[test]
@@ -19,7 +20,8 @@ fn file_chunks_encode_raw_bytes() {
#[test]
fn control_frames_are_tagged_json() {
let frame = StreamInstallFrame::FileBegin {
relative_path: "bin/game.exe".to_string(),
relative_path: CanonicalCatalogPath::new("bin/game.exe")
.expect("test path should be canonical"),
size: 42,
crc32: 0x38B4_88A7,
};