Reorganize protocol.

This commit is contained in:
Michael Mikovsky
2026-04-24 13:37:30 -06:00
parent dcf0fe230b
commit 49901b6370
21 changed files with 861 additions and 1438 deletions
+112 -132
View File
@@ -1,10 +1,13 @@
//! Framed packet encoding and decoding.
//!
//! This module provides the `FrameCodec` trait, which abstracts the conversion
//! between owned packet structures and the canonical length-prefixed wire format.
use alloc::{boxed::Box, vec::Vec};
use core::fmt;
use rkyv::{Serialize, access, deserialize, rancor::Error, to_bytes, util::AlignedVec};
use crate::protocol::types::{
use super::types::{
ArchivedCallMessage, ArchivedDataMessage, ArchivedFaultMessage, ArchivedPacketHeader,
};
use crate::protocol::{CallMessage, DataMessage, FaultMessage, PacketHeader, PacketType};
@@ -39,154 +42,162 @@ impl fmt::Display for FrameError {
}
}
#[cfg(feature = "std")]
impl std::error::Error for FrameError {}
impl core::error::Error for FrameError {}
/// Borrowed view over a framed packet.
/// A view into a framed packet, providing access to archived sections.
pub struct ParsedFrame<'a> {
header: PacketHeader,
payload_bytes: &'a [u8],
}
impl<'a> ParsedFrame<'a> {
/// Returns the decoded header.
pub fn header(&self) -> &PacketHeader {
&self.header
}
/// Returns the packet type.
pub fn packet_type(&self) -> PacketType {
self.header.packet_type
}
/// Returns the raw payload byte section.
pub fn payload_bytes(&self) -> &'a [u8] {
self.payload_bytes
}
/// Returns an owned header copy.
pub fn deserialize_header(&self) -> PacketHeader {
self.header.clone()
}
/// Decodes the payload as a call.
///
/// # Errors
///
/// Returns [`FrameError`] when the payload bytes are not a valid archived call.
pub fn deserialize_call(&self) -> Result<CallMessage, FrameError> {
deserialize_archived_bytes::<ArchivedCallMessage, CallMessage>(self.payload_bytes)
}
/// Decodes the payload as data.
///
/// # Errors
///
/// Returns [`FrameError`] when the payload bytes are not a valid archived data packet.
pub fn deserialize_data(&self) -> Result<DataMessage, FrameError> {
deserialize_archived_bytes::<ArchivedDataMessage, DataMessage>(self.payload_bytes)
}
/// Decodes the payload as a fault.
///
/// # Errors
///
/// Returns [`FrameError`] when the payload bytes are not a valid archived fault.
pub fn deserialize_fault(&self) -> Result<FaultMessage, FrameError> {
deserialize_archived_bytes::<ArchivedFaultMessage, FaultMessage>(self.payload_bytes)
}
}
/// Encodes a packet header and payload into the canonical framed representation.
///
/// # Errors
///
/// Returns [`FrameError`] when serialization fails or a framed section exceeds the wire limit.
/// Trait for framing and unframing packets.
pub trait FrameCodec {
/// Encodes a packet header and payload into the canonical framed representation.
fn encode_packet<P>(header: &PacketHeader, payload: &P) -> Result<FrameBytes, FrameError>
where
P: for<'a> Serialize<
rkyv::api::high::HighSerializer<
AlignedVec,
rkyv::ser::allocator::ArenaHandle<'a>,
Error,
>,
>;
/// Decodes a framed packet into a borrowed parsed view.
fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError>;
}
/// Default implementation of the `FrameCodec` using `rkyv`.
pub struct RkyvCodec;
impl FrameCodec for RkyvCodec {
fn encode_packet<P>(header: &PacketHeader, payload: &P) -> Result<FrameBytes, FrameError>
where
P: for<'a> Serialize<
rkyv::api::high::HighSerializer<
AlignedVec,
rkyv::ser::allocator::ArenaHandle<'a>,
Error,
>,
>,
{
// WARNING: framed packets move as one contiguous buffer across the core boundary.
// Keeping ownership here avoids hidden copies later in routing code.
let header_bytes = to_bytes::<Error>(header).map_err(FrameError::Serialize)?;
let payload_bytes = to_bytes::<Error>(payload).map_err(FrameError::Serialize)?;
let header_len =
u32::try_from(header_bytes.len()).map_err(|_| FrameError::LengthOverflow)?;
let payload_len =
u32::try_from(payload_bytes.len()).map_err(|_| FrameError::LengthOverflow)?;
let mut frame = Vec::with_capacity(8 + header_bytes.len() + payload_bytes.len());
frame.extend_from_slice(&header_len.to_be_bytes());
frame.extend_from_slice(&header_bytes);
frame.extend_from_slice(&payload_len.to_be_bytes());
frame.extend_from_slice(&payload_bytes);
Ok(frame.into_boxed_slice())
}
fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError> {
if bytes.len() < 8 {
return Err(FrameError::Truncated);
}
let header_len = u32::from_be_bytes(
bytes
.get(0..4)
.ok_or(FrameError::Truncated)?
.try_into()
.expect("slice width checked"),
) as usize;
let header_start = 4usize;
let header_end = header_start + header_len;
if header_end + 4 > bytes.len() {
return Err(FrameError::Truncated);
}
let payload_len = u32::from_be_bytes(
bytes
.get(header_end..header_end + 4)
.ok_or(FrameError::Truncated)?
.try_into()
.expect("slice width checked"),
) as usize;
let payload_start = header_end + 4;
let payload_end = payload_start + payload_len;
if payload_end != bytes.len() {
return Err(FrameError::Truncated);
}
// WARNING: the wire format puts a 4-byte length prefix before each archived section.
// That means the section start is not guaranteed to satisfy rkyv's aligned-access
// requirements. The header is copied into one temporary `AlignedVec` here because
// routing cannot proceed safely without a validated header.
let aligned_header = align_section(
bytes
.get(header_start..header_end)
.ok_or(FrameError::Truncated)?,
);
let archived_header = access::<ArchivedPacketHeader, Error>(&aligned_header)
.map_err(FrameError::InvalidHeader)?;
let header = deserialize::<PacketHeader, Error>(archived_header)
.map_err(FrameError::InvalidHeader)?;
Ok(ParsedFrame {
header,
payload_bytes: bytes
.get(payload_start..payload_end)
.ok_or(FrameError::Truncated)?,
})
}
}
/// Encodes a packet header and payload using the default codec.
pub fn encode_packet<P>(header: &PacketHeader, payload: &P) -> Result<FrameBytes, FrameError>
where
P: for<'a> Serialize<
rkyv::api::high::HighSerializer<AlignedVec, rkyv::ser::allocator::ArenaHandle<'a>, Error>,
>,
{
// WARNING: the simulated and TCP transports both move complete framed packets.
// One owned contiguous buffer at this boundary is therefore intentional and avoids
// scattering later hidden copies through routing code.
let header_bytes = to_bytes::<Error>(header).map_err(FrameError::Serialize)?;
let payload_bytes = to_bytes::<Error>(payload).map_err(FrameError::Serialize)?;
let header_len = u32::try_from(header_bytes.len()).map_err(|_| FrameError::LengthOverflow)?;
let payload_len = u32::try_from(payload_bytes.len()).map_err(|_| FrameError::LengthOverflow)?;
let mut frame = Vec::with_capacity(8 + header_bytes.len() + payload_bytes.len());
frame.extend_from_slice(&header_len.to_be_bytes());
frame.extend_from_slice(&header_bytes);
frame.extend_from_slice(&payload_len.to_be_bytes());
frame.extend_from_slice(&payload_bytes);
Ok(frame.into_boxed_slice())
RkyvCodec::encode_packet(header, payload)
}
/// Decodes a framed packet into a borrowed parsed view.
///
/// # Errors
///
/// Returns [`FrameError`] when the frame is truncated or the header archive is invalid.
/// Decodes a framed packet using the default codec.
pub fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError> {
if bytes.len() < 8 {
return Err(FrameError::Truncated);
}
let header_len = u32::from_be_bytes(
bytes
.get(0..4)
.ok_or(FrameError::Truncated)?
.try_into()
.expect("slice width checked"),
) as usize;
let header_start = 4usize;
let header_end = header_start + header_len;
if header_end + 4 > bytes.len() {
return Err(FrameError::Truncated);
}
let payload_len = u32::from_be_bytes(
bytes
.get(header_end..header_end + 4)
.ok_or(FrameError::Truncated)?
.try_into()
.expect("slice width checked"),
) as usize;
let payload_start = header_end + 4;
let payload_end = payload_start + payload_len;
if payload_end != bytes.len() {
return Err(FrameError::Truncated);
}
// WARNING: the wire format puts a 4-byte length prefix before each archived section.
// That means the section start is not guaranteed to satisfy rkyv's aligned-access
// requirements. The header is copied into one temporary `AlignedVec` here because
// routing cannot proceed safely without a validated header.
let aligned_header = align_section(
bytes
.get(header_start..header_end)
.ok_or(FrameError::Truncated)?,
);
let archived_header = access::<ArchivedPacketHeader, Error>(&aligned_header)
.map_err(FrameError::InvalidHeader)?;
let header =
deserialize::<PacketHeader, Error>(archived_header).map_err(FrameError::InvalidHeader)?;
Ok(ParsedFrame {
header,
payload_bytes: bytes
.get(payload_start..payload_end)
.ok_or(FrameError::Truncated)?,
})
RkyvCodec::decode_frame(bytes)
}
/// Deserializes a standalone archived byte section.
///
/// # Errors
///
/// Returns [`FrameError`] when the archived bytes are invalid for the requested type.
pub fn deserialize_archived_bytes<A, T>(bytes: &[u8]) -> Result<T, FrameError>
where
A: rkyv::Portable
@@ -204,34 +215,3 @@ fn align_section(bytes: &[u8]) -> AlignedVec {
aligned.extend_from_slice(bytes);
aligned
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{HookTarget, PacketType};
use alloc::{string::String, vec};
#[test]
fn framing_roundtrip_preserves_call() {
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: Vec::new(),
dst_path: vec![String::from("child")],
dst_leaf: Some(String::from("echo")),
hook_id: None,
};
let call = CallMessage {
procedure_id: String::from("org.product.v1.echo.roundtrip"),
data: b"ping".to_vec(),
response_hook: Some(HookTarget {
hook_id: 1,
return_path: Vec::new(),
}),
};
let frame = encode_packet(&header, &call).expect("frame should encode");
let parsed = decode_frame(&frame).expect("frame should decode");
assert_eq!(parsed.deserialize_header(), header);
assert_eq!(parsed.deserialize_call().expect("call should decode"), call);
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! Required introspection payloads.
//! Required introspection payloads for discovery.
use alloc::{string::String, vec::Vec};
use rkyv::{Archive, Deserialize, Serialize};
+24 -1
View File
@@ -4,14 +4,37 @@
pub mod codec;
pub mod introspection;
pub mod traits;
pub mod tree;
mod types;
pub mod validation;
#[cfg(test)]
mod tests;
pub use codec::{
FrameBytes, FrameError, ParsedFrame, decode_frame, deserialize_archived_bytes, encode_packet,
FrameBytes, FrameCodec, FrameError, ParsedFrame, RkyvCodec, deserialize_archived_bytes,
};
pub use introspection::{EndpointIntrospection, LeafIntrospection, LeafIntrospectionSummary};
pub use traits::{HookStore, LeafMetadata, PacketFraming, PacketProcessor, RouteResolution};
pub use types::{
CallMessage, DataMessage, FaultMessage, HookTarget, PacketHeader, PacketType, ProtocolFault,
};
pub use validation::{ValidationError, validate_call, validate_header, validate_procedure_id};
pub fn encode_packet<P>(header: &PacketHeader, payload: &P) -> Result<FrameBytes, FrameError>
where
P: for<'a> rkyv::Serialize<
rkyv::api::high::HighSerializer<
rkyv::util::AlignedVec,
rkyv::ser::allocator::ArenaHandle<'a>,
rkyv::rancor::Error,
>,
>,
{
codec::encode_packet(header, payload)
}
pub fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError> {
codec::decode_frame(bytes)
}
+2
View File
@@ -0,0 +1,2 @@
mod protocol;
mod tree;
+118
View File
@@ -0,0 +1,118 @@
use alloc::{borrow::ToOwned, string::String, vec, vec::Vec};
use crate::protocol::{
CallMessage, FaultMessage, FrameError, HookTarget, PacketHeader, PacketType, ProtocolFault,
ValidationError, decode_frame, encode_packet, validate_call, validate_header,
validate_procedure_id,
};
fn path(parts: &[&str]) -> Vec<String> {
parts.iter().map(|part| (*part).to_owned()).collect()
}
#[test]
fn packet_framing_roundtrip_preserves_header_and_payload() {
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: path(&["root", "caller"]),
dst_path: path(&["root", "callee"]),
dst_leaf: Some("echo".to_owned()),
hook_id: None,
};
let call = CallMessage {
procedure_id: "unshell.echo.v1.alpha.invoke".to_owned(),
data: vec![1, 2, 3, 4],
response_hook: Some(HookTarget {
hook_id: 7,
return_path: path(&["root", "caller"]),
}),
};
let frame = encode_packet(&header, &call).expect("frame should encode");
let parsed = decode_frame(&frame).expect("frame should decode");
assert_eq!(parsed.header(), &header);
assert_eq!(parsed.packet_type(), PacketType::Call);
assert_eq!(
parsed.deserialize_call().expect("call should deserialize"),
call
);
}
#[test]
fn header_and_call_validation_reject_invalid_combinations() {
let invalid_header = PacketHeader {
packet_type: PacketType::Data,
src_path: path(&["peer"]),
dst_path: path(&["host"]),
dst_leaf: Some("echo".to_owned()),
hook_id: None,
};
assert_eq!(
validate_header(&invalid_header),
Err(ValidationError::HeaderInvariant(
"Data and Fault packets must not carry dst_leaf"
))
);
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: path(&["caller"]),
dst_path: path(&["callee"]),
dst_leaf: Some("echo".to_owned()),
hook_id: None,
};
let invalid_call = CallMessage {
procedure_id: "unshell.echo.v1.alpha.invoke".to_owned(),
data: Vec::new(),
response_hook: Some(HookTarget {
hook_id: 5,
return_path: path(&["elsewhere"]),
}),
};
assert_eq!(
validate_call(&header, &invalid_call),
Err(ValidationError::CallInvariant(
"response_hook.return_path must equal header.src_path"
))
);
}
#[test]
fn procedure_validation_accepts_introspection_and_rejects_bad_shapes() {
assert_eq!(validate_procedure_id(""), Ok(()));
assert_eq!(
validate_procedure_id("unshell.echo.v01.alpha.invoke"),
Err(ValidationError::ProcedureId(
"version segment must be v followed by a positive decimal integer"
))
);
assert_eq!(
validate_procedure_id("too.short.v1"),
Err(ValidationError::ProcedureId(
"must contain exactly 5 segments"
))
);
}
#[test]
fn truncated_frames_are_rejected() {
let header = PacketHeader {
packet_type: PacketType::Fault,
src_path: path(&["src"]),
dst_path: path(&["dst"]),
dst_leaf: None,
hook_id: Some(9),
};
let message = FaultMessage {
fault: ProtocolFault::InternalError,
};
let frame = encode_packet(&header, &message).expect("frame should encode");
let truncated = &frame[..frame.len() - 1];
assert!(matches!(
decode_frame(truncated),
Err(FrameError::Truncated)
));
}
+155
View File
@@ -0,0 +1,155 @@
use alloc::{borrow::ToOwned, string::String, vec, vec::Vec};
use crate::protocol::tree::{
DefaultRouteProvider, Endpoint, Ingress, LeafBehavior, LeafNode, LeafSpec, LocalEvent,
ProtocolEndpoint, RouteDecision, RouteProvider, TreeNode,
};
use crate::protocol::{
DataMessage, EndpointIntrospection, FaultMessage, PacketHeader, PacketType, ProtocolFault,
decode_frame, deserialize_archived_bytes, encode_packet,
};
fn path(parts: &[&str]) -> Vec<String> {
parts.iter().map(|part| (*part).to_owned()).collect()
}
#[test]
fn tree_node_paths_flatten_explicitly() {
let tree = TreeNode::Root {
children: vec![TreeNode::Endpoint {
segment: "branch".to_owned(),
leaves: vec![LeafNode {
name: "echo".to_owned(),
procedures: vec!["unshell.echo.v1.alpha.invoke".to_owned()],
}],
children: vec![TreeNode::Endpoint {
segment: "leaf".to_owned(),
leaves: Vec::new(),
children: Vec::new(),
}],
}],
};
assert_eq!(
tree.paths(),
vec![
Vec::<String>::new(),
path(&["branch"]),
path(&["branch", "leaf"])
]
);
}
#[test]
fn longest_prefix_routing_prefers_most_specific_child() {
let provider = DefaultRouteProvider;
let child_paths = vec![path(&["a"]), path(&["a", "b"]), path(&["x"])];
assert_eq!(
provider.route_destination(&Vec::new(), &child_paths, true, &path(&["a", "b", "c"])),
RouteDecision::Child(1)
);
assert_eq!(
provider.route_destination(&path(&["a"]), &child_paths, true, &path(&["z"])),
RouteDecision::Parent
);
}
#[test]
fn protocol_endpoint_introspection_returns_leaf_summary() {
let mut endpoint = ProtocolEndpoint::new(
path(&["root"]),
Some(Vec::new()),
Vec::new(),
vec![LeafSpec {
name: "echo".to_owned(),
procedures: vec!["unshell.echo.v1.alpha.invoke".to_owned()],
behavior: LeafBehavior::Echo,
}],
);
let hook_id = endpoint.allocate_hook_id();
let frame = endpoint
.make_call(path(&["root"]), None, "", Some(hook_id), Vec::new())
.expect("introspection call should encode");
let outcome = endpoint
.receive(&Ingress::Local, frame)
.expect("endpoint should handle introspection");
assert!(outcome.events.is_empty());
assert_eq!(outcome.forwards.len(), 1);
assert_eq!(outcome.forwards[0].0, RouteDecision::Parent);
let parsed = decode_frame(&outcome.forwards[0].1).expect("response should decode");
let response = parsed
.deserialize_data()
.expect("response data should deserialize");
let introspection = deserialize_archived_bytes::<
rkyv::Archived<EndpointIntrospection>,
EndpointIntrospection,
>(&response.data)
.expect("introspection payload should deserialize");
assert!(response.end_hook);
assert_eq!(introspection.leaves.len(), 1);
assert_eq!(introspection.leaves[0].leaf_name, "echo");
assert_eq!(
introspection.leaves[0].procedures,
vec!["unshell.echo.v1.alpha.invoke".to_owned()]
);
}
#[test]
fn invalid_hook_peer_emits_local_fault_event() {
let mut endpoint = ProtocolEndpoint::new(path(&["client"]), None, Vec::new(), Vec::new());
let hook_id = endpoint.allocate_hook_id();
endpoint
.make_call(
path(&["server"]),
None,
"unshell.echo.v1.alpha.invoke",
Some(hook_id),
vec![1, 2, 3],
)
.expect("call should establish an active hook");
let frame = encode_packet(
&PacketHeader {
packet_type: PacketType::Data,
src_path: path(&["client"]),
dst_path: path(&["client"]),
dst_leaf: None,
hook_id: Some(hook_id),
},
&DataMessage {
procedure_id: "unshell.echo.v1.alpha.invoke".to_owned(),
data: vec![9],
end_hook: false,
},
)
.expect("data frame should encode");
let outcome = endpoint
.receive(&Ingress::Local, frame)
.expect("invalid peer should be handled");
assert!(outcome.forwards.is_empty());
assert_eq!(outcome.events.len(), 1);
assert!(!outcome.dropped);
match &outcome.events[0] {
LocalEvent::Fault { header, message } => {
assert_eq!(header.packet_type, PacketType::Fault);
assert_eq!(header.hook_id, Some(hook_id));
assert_eq!(
message,
&FaultMessage {
fault: ProtocolFault::InvalidHookPeer,
}
);
}
other => panic!("expected fault event, got {other:?}"),
}
}
+142
View File
@@ -0,0 +1,142 @@
//! Protocol implementation traits exposed by the core crate.
//!
//! These traits collect the core contracts needed to plug framing, routing,
//! hook storage, leaf metadata, and packet processing into an implementation.
use alloc::{string::String, vec::Vec};
use super::{
FrameBytes, FrameCodec, LeafIntrospection, LeafIntrospectionSummary,
tree::{
ActiveHook, Endpoint, EndpointError, EndpointOutcome, HookKey, HookTable, Ingress,
LeafNode, LeafSpec, PendingHook, RouteProvider,
},
};
/// Packet framing contract for the canonical wire format.
pub trait PacketFraming: FrameCodec {}
impl<T> PacketFraming for T where T: FrameCodec + ?Sized {}
/// Route resolution contract for endpoint path delivery.
pub trait RouteResolution: RouteProvider {}
impl<T> RouteResolution for T where T: RouteProvider + ?Sized {}
/// Hook storage contract for pending and active protocol flows.
pub trait HookStore {
fn allocate_hook_id(&self, return_path: &[String]) -> u64;
fn insert_pending(&mut self, pending: PendingHook);
fn insert_active(&mut self, active: ActiveHook);
fn activate_pending(&mut self, key: &HookKey, peer_path: Vec<String>) -> Option<()>;
fn remove_pending(&mut self, key: &HookKey) -> Option<PendingHook>;
fn remove_active(&mut self, key: &HookKey) -> Option<ActiveHook>;
fn pending(&self, key: &HookKey) -> Option<&PendingHook>;
fn active(&self, key: &HookKey) -> Option<&ActiveHook>;
fn active_mut(&mut self, key: &HookKey) -> Option<&mut ActiveHook>;
}
impl HookStore for HookTable {
fn allocate_hook_id(&self, return_path: &[String]) -> u64 {
HookTable::allocate_hook_id(self, return_path)
}
fn insert_pending(&mut self, pending: PendingHook) {
HookTable::insert_pending(self, pending);
}
fn insert_active(&mut self, active: ActiveHook) {
HookTable::insert_active(self, active);
}
fn activate_pending(&mut self, key: &HookKey, peer_path: Vec<String>) -> Option<()> {
HookTable::activate_pending(self, key, peer_path)
}
fn remove_pending(&mut self, key: &HookKey) -> Option<PendingHook> {
HookTable::remove_pending(self, key)
}
fn remove_active(&mut self, key: &HookKey) -> Option<ActiveHook> {
HookTable::remove_active(self, key)
}
fn pending(&self, key: &HookKey) -> Option<&PendingHook> {
HookTable::pending(self, key)
}
fn active(&self, key: &HookKey) -> Option<&ActiveHook> {
HookTable::active(self, key)
}
fn active_mut(&mut self, key: &HookKey) -> Option<&mut ActiveHook> {
HookTable::active_mut(self, key)
}
}
/// Leaf metadata contract used for protocol discovery payloads.
pub trait LeafMetadata {
fn leaf_name(&self) -> &str;
fn procedures(&self) -> &[String];
fn summary(&self) -> LeafIntrospectionSummary {
LeafIntrospectionSummary {
leaf_name: self.leaf_name().into(),
procedures: self.procedures().to_vec(),
}
}
fn introspection(&self) -> LeafIntrospection {
LeafIntrospection {
leaf_name: self.leaf_name().into(),
procedures: self.procedures().to_vec(),
}
}
}
impl LeafMetadata for LeafSpec {
fn leaf_name(&self) -> &str {
&self.name
}
fn procedures(&self) -> &[String] {
&self.procedures
}
}
impl LeafMetadata for LeafNode {
fn leaf_name(&self) -> &str {
&self.name
}
fn procedures(&self) -> &[String] {
&self.procedures
}
}
/// Packet processor and local runtime contract for framed protocol traffic.
pub trait PacketProcessor {
fn path(&self) -> &[String];
fn receive(
&mut self,
ingress: &Ingress,
frame: FrameBytes,
) -> Result<EndpointOutcome, EndpointError>;
}
impl<T> PacketProcessor for T
where
T: Endpoint + ?Sized,
{
fn path(&self) -> &[String] {
Endpoint::path(self)
}
fn receive(
&mut self,
ingress: &Ingress,
frame: FrameBytes,
) -> Result<EndpointOutcome, EndpointError> {
Endpoint::receive(self, ingress, frame)
}
}
+590
View File
@@ -0,0 +1,590 @@
//! Endpoint runtime and traits.
use alloc::{
collections::{BTreeMap, BTreeSet},
string::String,
vec,
vec::Vec,
};
use core::fmt;
use rkyv::{rancor::Error as RkyvError, to_bytes};
use crate::protocol::{
CallMessage, DataMessage, EndpointIntrospection, FaultMessage, FrameBytes, FrameError,
HookTarget, LeafIntrospection, LeafIntrospectionSummary, PacketHeader, PacketType,
ProtocolFault, ValidationError, decode_frame, encode_packet,
introspection::INTROSPECTION_PROCEDURE_ID, validate_call, validate_header,
validate_procedure_id,
};
use super::{ActiveHook, HookKey, HookTable, PendingHook, RouteDecision, route_destination};
/// Local connection state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionState {
Unregistered,
Registered,
}
/// Registered child route.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChildRoute {
pub path: Vec<String>,
pub state: ConnectionState,
}
impl ChildRoute {
pub fn registered(path: Vec<String>) -> Self {
Self {
path,
state: ConnectionState::Registered,
}
}
}
/// Leaf behavior for test runtime.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LeafBehavior {
Echo,
}
/// Static leaf description.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LeafSpec {
pub name: String,
pub procedures: Vec<String>,
pub behavior: LeafBehavior,
}
/// Arrival side.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ingress {
Parent,
Child(Vec<String>),
Local,
}
/// Local events.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LocalEvent {
Call {
header: PacketHeader,
message: CallMessage,
},
Data {
header: PacketHeader,
message: DataMessage,
},
Fault {
header: PacketHeader,
message: FaultMessage,
},
}
/// Processing outcome.
#[derive(Debug, Default)]
pub struct EndpointOutcome {
pub forwards: Vec<(RouteDecision, FrameBytes)>,
pub events: Vec<LocalEvent>,
pub dropped: bool,
}
/// Processing error.
#[derive(Debug)]
pub enum EndpointError {
Frame(FrameError),
Validation(ValidationError),
}
impl fmt::Display for EndpointError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Frame(error) => write!(f, "{error}"),
Self::Validation(error) => write!(f, "{error}"),
}
}
}
impl core::error::Error for EndpointError {}
impl From<FrameError> for EndpointError {
fn from(value: FrameError) -> Self {
Self::Frame(value)
}
}
impl From<ValidationError> for EndpointError {
fn from(value: ValidationError) -> Self {
Self::Validation(value)
}
}
/// Core trait for a protocol endpoint.
pub trait Endpoint {
fn path(&self) -> &[String];
fn receive(
&mut self,
ingress: &Ingress,
frame: FrameBytes,
) -> Result<EndpointOutcome, EndpointError>;
}
/// Default endpoint implementation.
#[derive(Debug, Default)]
pub struct ProtocolEndpoint {
path: Vec<String>,
parent_path: Option<Vec<String>>,
children: Vec<ChildRoute>,
leaves: BTreeMap<String, LeafSpec>,
endpoint_procedures: BTreeSet<String>,
hooks: HookTable,
}
impl ProtocolEndpoint {
pub fn new(
path: Vec<String>,
parent_path: Option<Vec<String>>,
children: Vec<ChildRoute>,
leaves: Vec<LeafSpec>,
) -> Self {
Self {
path,
parent_path,
children,
leaves: leaves
.into_iter()
.map(|leaf| (leaf.name.clone(), leaf))
.collect(),
endpoint_procedures: BTreeSet::new(),
hooks: HookTable::default(),
}
}
pub fn add_endpoint_procedure(
&mut self,
procedure_id: impl Into<String>,
) -> Result<(), EndpointError> {
let procedure_id = procedure_id.into();
validate_procedure_id(&procedure_id)?;
self.endpoint_procedures.insert(procedure_id);
Ok(())
}
pub fn allocate_hook_id(&self) -> u64 {
self.hooks.allocate_hook_id(&self.path)
}
pub fn make_call(
&mut self,
dst_path: Vec<String>,
dst_leaf: Option<String>,
procedure_id: impl Into<String>,
response_hook_id: Option<u64>,
data: Vec<u8>,
) -> Result<FrameBytes, EndpointError> {
let procedure_id = procedure_id.into();
validate_procedure_id(&procedure_id)?;
let response_hook = response_hook_id.map(|hook_id| HookTarget {
hook_id,
return_path: self.path.clone(),
});
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: self.path.clone(),
dst_path: dst_path.clone(),
dst_leaf: dst_leaf.clone(),
hook_id: None,
};
let call = CallMessage {
procedure_id: procedure_id.clone(),
data,
response_hook,
};
validate_header(&header)?;
validate_call(&header, &call)?;
if let Some(hook) = &call.response_hook {
self.hooks.insert_active(ActiveHook {
return_path: hook.return_path.clone(),
hook_id: hook.hook_id,
peer_path: dst_path,
procedure_id,
dst_leaf,
peer_finished: false,
});
}
Ok(encode_packet(&header, &call)?)
}
pub fn make_data(
&self,
dst_path: Vec<String>,
hook_id: u64,
procedure_id: impl Into<String>,
data: Vec<u8>,
end_hook: bool,
) -> Result<FrameBytes, EndpointError> {
let procedure_id = procedure_id.into();
validate_procedure_id(&procedure_id)?;
let header = PacketHeader {
packet_type: PacketType::Data,
src_path: self.path.clone(),
dst_path,
dst_leaf: None,
hook_id: Some(hook_id),
};
let message = DataMessage {
procedure_id,
data,
end_hook,
};
validate_header(&header)?;
Ok(encode_packet(&header, &message)?)
}
fn handle_local_call(
&mut self,
header: PacketHeader,
message: CallMessage,
) -> Result<EndpointOutcome, EndpointError> {
let key = message
.response_hook
.as_ref()
.map(|hook| HookKey::new(hook.return_path.clone(), hook.hook_id));
if let Some(hook) = &message.response_hook {
self.hooks.insert_pending(PendingHook {
caller_src_path: header.src_path.clone(),
return_path: hook.return_path.clone(),
hook_id: hook.hook_id,
procedure_id: message.procedure_id.clone(),
dst_leaf: header.dst_leaf.clone(),
});
}
if message.procedure_id == INTROSPECTION_PROCEDURE_ID {
return self.handle_introspection(&header, key);
}
let supported = match &header.dst_leaf {
Some(leaf_name) => self
.leaves
.get(leaf_name)
.map(|leaf| leaf.procedures.iter().any(|p| p == &message.procedure_id))
.unwrap_or(false),
None => self.endpoint_procedures.contains(&message.procedure_id),
};
if !supported {
let fault = if header
.dst_leaf
.as_ref()
.is_some_and(|name| !self.leaves.contains_key(name))
{
ProtocolFault::UnknownLeaf
} else {
ProtocolFault::UnknownProcedure
};
return self.emit_fault_if_possible(key, fault);
}
if let Some(key) = &key {
self.hooks.activate_pending(key, header.src_path.clone());
}
match header
.dst_leaf
.as_ref()
.and_then(|name| self.leaves.get(name))
{
Some(leaf) if leaf.behavior == LeafBehavior::Echo && key.is_some() => {
let hook = message.response_hook.expect("synchronized");
let response = DataMessage {
procedure_id: message.procedure_id.clone(),
data: message.data,
end_hook: true,
};
let response_header = PacketHeader {
packet_type: PacketType::Data,
src_path: self.path.clone(),
dst_path: hook.return_path.clone(),
dst_leaf: None,
hook_id: Some(hook.hook_id),
};
let frame = encode_packet(&response_header, &response)?;
self.hooks
.remove_active(&HookKey::new(hook.return_path, hook.hook_id));
Ok(EndpointOutcome {
forwards: vec![(RouteDecision::Parent, frame)],
..EndpointOutcome::default()
})
}
_ => Ok(EndpointOutcome {
events: vec![LocalEvent::Call { header, message }],
..EndpointOutcome::default()
}),
}
}
fn handle_introspection(
&mut self,
header: &PacketHeader,
key: Option<HookKey>,
) -> Result<EndpointOutcome, EndpointError> {
let Some(key) = key else {
return Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
});
};
self.hooks.activate_pending(&key, header.src_path.clone());
let payload = if let Some(leaf_name) = &header.dst_leaf {
let Some(leaf) = self.leaves.get(leaf_name) else {
return self.emit_fault_if_possible(Some(key), ProtocolFault::UnknownLeaf);
};
to_bytes::<RkyvError>(&LeafIntrospection {
leaf_name: leaf_name.clone(),
procedures: leaf.procedures.clone(),
})
.expect("serialize")
.to_vec()
} else {
to_bytes::<RkyvError>(&EndpointIntrospection {
leaves: self
.leaves
.values()
.map(|leaf| LeafIntrospectionSummary {
leaf_name: leaf.name.clone(),
procedures: leaf.procedures.clone(),
})
.collect(),
})
.expect("serialize")
.to_vec()
};
let response_header = PacketHeader {
packet_type: PacketType::Data,
src_path: self.path.clone(),
dst_path: key.return_path.clone(),
dst_leaf: None,
hook_id: Some(key.hook_id),
};
let response = DataMessage {
procedure_id: String::new(),
data: payload,
end_hook: true,
};
let frame = encode_packet(&response_header, &response)?;
self.hooks.remove_active(&key);
Ok(EndpointOutcome {
forwards: vec![(RouteDecision::Parent, frame)],
..EndpointOutcome::default()
})
}
fn handle_local_data(
&mut self,
header: PacketHeader,
message: DataMessage,
) -> Result<EndpointOutcome, EndpointError> {
let key = HookKey::new(self.path.clone(), header.hook_id.expect("validated"));
if self.hooks.active(&key).is_none() {
let matches = self.hooks.pending(&key).is_some_and(|p| {
p.caller_src_path == header.src_path && p.procedure_id == message.procedure_id
});
if matches {
self.hooks.activate_pending(&key, header.src_path.clone());
}
}
let Some(active) = self.hooks.active(&key).cloned() else {
return Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
});
};
if active.peer_path != header.src_path || active.procedure_id != message.procedure_id {
self.hooks.remove_active(&key);
self.hooks.remove_pending(&key);
return Ok(EndpointOutcome {
events: vec![LocalEvent::Fault {
header: PacketHeader {
packet_type: PacketType::Fault,
src_path: header.src_path,
dst_path: self.path.clone(),
dst_leaf: None,
hook_id: Some(key.hook_id),
},
message: FaultMessage {
fault: ProtocolFault::InvalidHookPeer,
},
}],
..EndpointOutcome::default()
});
}
if message.end_hook {
self.hooks.remove_active(&key);
}
Ok(EndpointOutcome {
events: vec![LocalEvent::Data { header, message }],
..EndpointOutcome::default()
})
}
fn handle_local_fault(
&mut self,
header: PacketHeader,
message: FaultMessage,
) -> Result<EndpointOutcome, EndpointError> {
let key = HookKey::new(self.path.clone(), header.hook_id.expect("validated"));
let matches = self
.hooks
.active(&key)
.is_some_and(|a| a.peer_path == header.src_path)
|| self
.hooks
.pending(&key)
.is_some_and(|p| p.caller_src_path == header.src_path);
if !matches {
return Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
});
}
self.hooks.remove_active(&key);
self.hooks.remove_pending(&key);
Ok(EndpointOutcome {
events: vec![LocalEvent::Fault { header, message }],
..EndpointOutcome::default()
})
}
fn emit_fault_if_possible(
&mut self,
key: Option<HookKey>,
fault: ProtocolFault,
) -> Result<EndpointOutcome, EndpointError> {
let Some(key) = key else {
return Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
});
};
self.hooks.remove_pending(&key);
self.hooks.remove_active(&key);
let header = PacketHeader {
packet_type: PacketType::Fault,
src_path: self.path.clone(),
dst_path: key.return_path.clone(),
dst_leaf: None,
hook_id: Some(key.hook_id),
};
let frame = encode_packet(&header, &FaultMessage { fault })?;
Ok(EndpointOutcome {
forwards: vec![(RouteDecision::Parent, frame)],
..EndpointOutcome::default()
})
}
fn decide_route(&self, dst_path: &[String]) -> RouteDecision {
let child_paths: Vec<Vec<String>> = self
.children
.iter()
.filter(|c| c.state == ConnectionState::Registered)
.map(|c| c.path.clone())
.collect();
route_destination(
&self.path,
&child_paths,
self.parent_path.is_some(),
dst_path,
)
}
fn valid_source_for_ingress(&self, ingress: &Ingress, src_path: &[String]) -> bool {
match ingress {
Ingress::Parent => self
.parent_path
.as_ref()
.map_or(self.path.is_empty(), |p| p == src_path),
Ingress::Child(path) => path == src_path,
Ingress::Local => src_path == self.path,
}
}
}
impl Endpoint for ProtocolEndpoint {
fn path(&self) -> &[String] {
&self.path
}
fn receive(
&mut self,
ingress: &Ingress,
frame: FrameBytes,
) -> Result<EndpointOutcome, EndpointError> {
let parsed = decode_frame(&frame)?;
let header = parsed.deserialize_header();
validate_header(&header)?;
if !self.valid_source_for_ingress(ingress, &header.src_path) {
return Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
});
}
match header.packet_type {
PacketType::Call => {
let message = parsed.deserialize_call()?;
if !matches!(ingress, Ingress::Parent | Ingress::Local) {
return Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
});
}
validate_call(&header, &message)?;
match self.decide_route(&header.dst_path) {
RouteDecision::Child(idx) => Ok(EndpointOutcome {
forwards: vec![(RouteDecision::Child(idx), frame)],
..EndpointOutcome::default()
}),
RouteDecision::Parent => Ok(EndpointOutcome {
forwards: vec![(RouteDecision::Parent, frame)],
..EndpointOutcome::default()
}),
RouteDecision::Drop => Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
}),
RouteDecision::Local => self.handle_local_call(header, message),
}
}
PacketType::Data => {
let message = parsed.deserialize_data()?;
match self.decide_route(&header.dst_path) {
RouteDecision::Local => self.handle_local_data(header, message),
_ => Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
}),
}
}
PacketType::Fault => {
let message = parsed.deserialize_fault()?;
match self.decide_route(&header.dst_path) {
RouteDecision::Local => self.handle_local_fault(header, message),
_ => Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
}),
}
}
}
}
}
+118
View File
@@ -0,0 +1,118 @@
//! Hook state for pending and active protocol flows.
use alloc::{collections::BTreeMap, string::String, vec::Vec};
/// Hook table key scoped to the hook host path.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct HookKey {
/// Path of the endpoint hosting the hook.
pub return_path: Vec<String>,
/// Hook identifier scoped to `return_path`.
pub hook_id: u64,
}
impl HookKey {
pub fn new(return_path: Vec<String>, hook_id: u64) -> Self {
Self {
return_path,
hook_id,
}
}
}
/// Pending hook context created by a received call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingHook {
pub caller_src_path: Vec<String>,
pub return_path: Vec<String>,
pub hook_id: u64,
pub procedure_id: String,
pub dst_leaf: Option<String>,
}
/// Active hook context used for ordinary data traffic.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveHook {
pub return_path: Vec<String>,
pub hook_id: u64,
pub peer_path: Vec<String>,
pub procedure_id: String,
pub dst_leaf: Option<String>,
pub peer_finished: bool,
}
/// Durable hook state tables.
#[derive(Debug, Default)]
pub struct HookTable {
pending: BTreeMap<HookKey, PendingHook>,
active: BTreeMap<HookKey, ActiveHook>,
}
impl HookTable {
pub fn allocate_hook_id(&self, return_path: &[String]) -> u64 {
let mut hook_id = 0u64;
loop {
let key = HookKey::new(return_path.to_vec(), hook_id);
if !self.pending.contains_key(&key) && !self.active.contains_key(&key) {
return hook_id;
}
hook_id = hook_id.saturating_add(1);
}
}
pub fn insert_pending(&mut self, pending: PendingHook) {
// WARNING: hook tables intentionally own their path and procedure strings.
// Hook state must outlive any individual frame buffer.
let key = HookKey::new(pending.return_path.clone(), pending.hook_id);
self.pending.insert(key, pending);
}
pub fn insert_active(&mut self, active: ActiveHook) {
let key = HookKey::new(active.return_path.clone(), active.hook_id);
self.active.insert(key, active);
}
pub fn activate_pending(&mut self, key: &HookKey, peer_path: Vec<String>) -> Option<()> {
let pending = self.pending.remove(key)?;
self.active.insert(
key.clone(),
ActiveHook {
return_path: pending.return_path,
hook_id: pending.hook_id,
peer_path,
procedure_id: pending.procedure_id,
dst_leaf: pending.dst_leaf,
peer_finished: false,
},
);
Some(())
}
pub fn remove_pending(&mut self, key: &HookKey) -> Option<PendingHook> {
self.pending.remove(key)
}
pub fn remove_active(&mut self, key: &HookKey) -> Option<ActiveHook> {
self.active.remove(key)
}
pub fn pending(&self, key: &HookKey) -> Option<&PendingHook> {
self.pending.get(key)
}
pub fn active(&self, key: &HookKey) -> Option<&ActiveHook> {
self.active.get(key)
}
pub fn active_mut(&mut self, key: &HookKey) -> Option<&mut ActiveHook> {
self.active.get_mut(key)
}
pub fn pending_len(&self) -> usize {
self.pending.len()
}
pub fn active_len(&self) -> usize {
self.active.len()
}
}
+15
View File
@@ -0,0 +1,15 @@
//! Explicit tree declaration, routing, and a small endpoint runtime.
mod endpoint;
mod hook;
mod routing;
pub use endpoint::{
ChildRoute, ConnectionState, Endpoint, EndpointError, EndpointOutcome, Ingress, LeafBehavior,
LeafSpec, LocalEvent, ProtocolEndpoint,
};
pub use hook::{ActiveHook, HookKey, HookTable, PendingHook};
pub use routing::{
DefaultRouteProvider, LeafNode, RouteDecision, RouteProvider, TreeNode, is_prefix,
route_destination,
};
+177
View File
@@ -0,0 +1,177 @@
//! Path routing helpers and explicit enum tree declarations.
use alloc::{string::String, vec::Vec};
/// Explicit test tree declaration used for configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TreeNode {
/// The tree root.
Root { children: Vec<Self> },
/// A concrete endpoint in the tree.
Endpoint {
segment: String,
leaves: Vec<LeafNode>,
children: Vec<Self>,
},
}
/// Leaf declaration used inside the explicit tree enum.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LeafNode {
/// Local leaf name.
pub name: String,
/// Supported procedures.
pub procedures: Vec<String>,
}
impl TreeNode {
/// Flattens the tree into absolute endpoint paths.
pub fn paths(&self) -> Vec<Vec<String>> {
let mut output = Vec::new();
self.collect_paths(&[], &mut output);
output
}
fn collect_paths(&self, prefix: &[String], output: &mut Vec<Vec<String>>) {
match self {
Self::Root { children } => {
output.push(Vec::new());
for child in children {
child.collect_paths(&[], output);
}
}
Self::Endpoint {
segment, children, ..
} => {
let mut next = prefix.to_vec();
next.push(segment.clone());
output.push(next.clone());
for child in children {
child.collect_paths(&next, output);
}
}
}
}
}
/// Longest-prefix route decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteDecision {
/// Forward to the child at the given index.
Child(usize),
/// Deliver locally.
Local,
/// Forward upward toward the parent.
Parent,
/// Silently drop.
Drop,
}
/// Returns `true` if `prefix` is a path prefix of `path`.
pub fn is_prefix(prefix: &[String], path: &[String]) -> bool {
prefix.len() <= path.len()
&& prefix
.iter()
.zip(path.iter())
.all(|(left, right)| left == right)
}
/// Trait for resolving a destination path to a routing decision.
pub trait RouteProvider {
/// Computes the routing decision for a destination path.
fn route_destination(
&self,
local_path: &[String],
child_paths: &[Vec<String>],
has_parent: bool,
dst_path: &[String],
) -> RouteDecision;
}
/// Default routing implementation using the protocol's longest-prefix rule.
pub struct DefaultRouteProvider;
impl RouteProvider for DefaultRouteProvider {
fn route_destination(
&self,
local_path: &[String],
child_paths: &[Vec<String>],
has_parent: bool,
dst_path: &[String],
) -> RouteDecision {
let child = child_paths
.iter()
.enumerate()
.filter(|(_, child_path)| is_prefix(child_path, dst_path))
.max_by_key(|(_, child_path)| child_path.len())
.map(|(index, _)| index);
if let Some(index) = child {
return RouteDecision::Child(index);
}
if local_path == dst_path {
return RouteDecision::Local;
}
if has_parent && !is_prefix(local_path, dst_path) {
return RouteDecision::Parent;
}
RouteDecision::Drop
}
}
pub fn route_destination(
local_path: &[String],
child_paths: &[Vec<String>],
has_parent: bool,
dst_path: &[String],
) -> RouteDecision {
DefaultRouteProvider.route_destination(local_path, child_paths, has_parent, dst_path)
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::{string::String, vec};
#[test]
fn longest_prefix_wins() {
let provider = DefaultRouteProvider;
let children = vec![
vec![String::from("a")],
vec![String::from("a"), String::from("b")],
];
assert_eq!(
provider.route_destination(
&Vec::<String>::new(),
&children,
false,
&[String::from("a"), String::from("b"), String::from("c")]
),
RouteDecision::Child(1)
);
}
#[test]
fn tree_enum_flattens_paths() {
let tree = TreeNode::Root {
children: vec![TreeNode::Endpoint {
segment: String::from("a"),
leaves: Vec::new(),
children: vec![TreeNode::Endpoint {
segment: String::from("b"),
leaves: Vec::new(),
children: Vec::new(),
}],
}],
};
assert_eq!(
tree.paths(),
vec![
Vec::<String>::new(),
vec![String::from("a")],
vec![String::from("a"), String::from("b")],
]
);
}
}
+4 -1
View File
@@ -1,4 +1,7 @@
//! Archived protocol message types.
//! Canonical UnShell protocol message types.
//!
//! These types define the wire format and are designed for zero-copy
//! access via `rkyv`.
use alloc::{string::String, vec::Vec};
use rkyv::{Archive, Deserialize, Serialize};
+2 -63
View File
@@ -1,10 +1,9 @@
//! Stateless protocol validation.
use core::fmt;
use crate::protocol::{
CallMessage, PacketHeader, PacketType, introspection::INTROSPECTION_PROCEDURE_ID,
};
use core::fmt;
/// Validation failures for protocol structures.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -27,14 +26,9 @@ impl fmt::Display for ValidationError {
}
}
#[cfg(feature = "std")]
impl std::error::Error for ValidationError {}
impl core::error::Error for ValidationError {}
/// Validates packet header invariants from the protocol.
///
/// # Errors
///
/// Returns [`ValidationError`] when the header shape does not match the packet type.
pub fn validate_header(header: &PacketHeader) -> Result<(), ValidationError> {
match header.packet_type {
PacketType::Call => {
@@ -57,15 +51,10 @@ pub fn validate_header(header: &PacketHeader) -> Result<(), ValidationError> {
}
}
}
Ok(())
}
/// Validates the canonical dotted `procedure_id` shape.
///
/// # Errors
///
/// Returns [`ValidationError`] when the procedure id does not match the required format.
pub fn validate_procedure_id(procedure_id: &str) -> Result<(), ValidationError> {
if procedure_id == INTROSPECTION_PROCEDURE_ID {
return Ok(());
@@ -114,10 +103,6 @@ pub fn validate_procedure_id(procedure_id: &str) -> Result<(), ValidationError>
}
/// Validates call-specific invariants that depend on both header and payload.
///
/// # Errors
///
/// Returns [`ValidationError`] when the call payload conflicts with the header.
pub fn validate_call(header: &PacketHeader, call: &CallMessage) -> Result<(), ValidationError> {
validate_procedure_id(&call.procedure_id)?;
@@ -141,49 +126,3 @@ pub fn validate_call(header: &PacketHeader, call: &CallMessage) -> Result<(), Va
fn is_portable_procedure_char(ch: char) -> bool {
ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_'
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{HookTarget, PacketType};
use alloc::{string::String, vec};
#[test]
fn rejects_invalid_data_header() {
let header = PacketHeader {
packet_type: PacketType::Data,
src_path: Vec::new(),
dst_path: Vec::new(),
dst_leaf: Some(String::from("leaf")),
hook_id: None,
};
assert!(validate_header(&header).is_err());
}
#[test]
fn validates_procedure_id_shape() {
assert!(validate_procedure_id("org.product.v1.demo.echo").is_ok());
assert!(validate_procedure_id("org.product.v01.demo.echo").is_err());
assert!(validate_procedure_id("Org.product.v1.demo.echo").is_err());
}
#[test]
fn validates_response_hook_return_path() {
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: vec![String::from("src")],
dst_path: vec![String::from("dst")],
dst_leaf: None,
hook_id: None,
};
let call = CallMessage {
procedure_id: String::from("org.product.v1.demo.echo"),
data: Vec::new(),
response_hook: Some(HookTarget {
hook_id: 1,
return_path: vec![String::from("other")],
}),
};
assert!(validate_call(&header, &call).is_err());
}
}