2026-04-24 12:32:24 -06:00
|
|
|
//! Stateless protocol validation.
|
|
|
|
|
|
|
|
|
|
use crate::protocol::{
|
|
|
|
|
CallMessage, PacketHeader, PacketType, introspection::INTROSPECTION_PROCEDURE_ID,
|
|
|
|
|
};
|
2026-04-24 13:37:30 -06:00
|
|
|
use core::fmt;
|
2026-04-24 12:32:24 -06:00
|
|
|
|
|
|
|
|
/// Validation failures for protocol structures.
|
2026-04-26 11:18:49 -06:00
|
|
|
///
|
|
|
|
|
/// These errors exist so callers can reject malformed outbound packets early, before they are
|
|
|
|
|
/// encoded or sent across the tree.
|
|
|
|
|
///
|
|
|
|
|
/// # Example
|
|
|
|
|
/// ```rust
|
|
|
|
|
/// use unshell::protocol::{PacketHeader, PacketType, ValidationError, validate_header};
|
|
|
|
|
/// let invalid = PacketHeader {
|
|
|
|
|
/// packet_type: PacketType::Data,
|
|
|
|
|
/// src_path: vec!["peer".into()],
|
|
|
|
|
/// dst_path: vec!["host".into()],
|
|
|
|
|
/// dst_leaf: Some("service".into()),
|
|
|
|
|
/// hook_id: None,
|
|
|
|
|
/// };
|
|
|
|
|
/// assert!(matches!(validate_header(&invalid), Err(ValidationError::HeaderInvariant(_))));
|
|
|
|
|
/// ```
|
2026-04-24 12:32:24 -06:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub enum ValidationError {
|
2026-04-26 01:53:37 -06:00
|
|
|
/// One header field combination is invalid for the chosen packet type.
|
2026-04-24 12:32:24 -06:00
|
|
|
HeaderInvariant(&'static str),
|
2026-04-26 01:53:37 -06:00
|
|
|
/// The procedure identifier violates the protocol's minimal reserved-id rules.
|
2026-04-24 12:32:24 -06:00
|
|
|
ProcedureId(&'static str),
|
2026-04-26 01:53:37 -06:00
|
|
|
/// The call payload contradicts the surrounding packet header.
|
2026-04-24 12:32:24 -06:00
|
|
|
CallInvariant(&'static str),
|
2026-04-26 01:53:37 -06:00
|
|
|
/// A hook lifecycle transition would break protocol state invariants.
|
2026-04-25 17:42:39 -06:00
|
|
|
HookInvariant(&'static str),
|
2026-04-26 16:13:28 -06:00
|
|
|
/// One endpoint-topology update would break local tree invariants.
|
|
|
|
|
TopologyInvariant(&'static str),
|
2026-04-26 01:53:37 -06:00
|
|
|
/// A hook id collided with existing endpoint-local state.
|
2026-04-24 14:10:03 -06:00
|
|
|
InvalidHookId,
|
2026-04-24 12:32:24 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl fmt::Display for ValidationError {
|
|
|
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
|
|
|
match self {
|
|
|
|
|
Self::HeaderInvariant(message) => write!(f, "invalid header: {message}"),
|
|
|
|
|
Self::ProcedureId(message) => write!(f, "invalid procedure id: {message}"),
|
|
|
|
|
Self::CallInvariant(message) => write!(f, "invalid call: {message}"),
|
2026-04-25 17:42:39 -06:00
|
|
|
Self::HookInvariant(message) => write!(f, "invalid hook state: {message}"),
|
2026-04-26 16:13:28 -06:00
|
|
|
Self::TopologyInvariant(message) => write!(f, "invalid topology: {message}"),
|
2026-04-25 12:37:54 -06:00
|
|
|
Self::InvalidHookId => f.write_str("invalid hook identifier"),
|
2026-04-24 12:32:24 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 13:37:30 -06:00
|
|
|
impl core::error::Error for ValidationError {}
|
2026-04-24 12:32:24 -06:00
|
|
|
|
2026-04-26 01:53:37 -06:00
|
|
|
/// Validates stateless packet-header invariants.
|
|
|
|
|
///
|
|
|
|
|
/// This checks wire-shape rules only. It does not verify route existence, leaf existence,
|
|
|
|
|
/// hook ownership, or whether the destination actually supports the requested procedure.
|
2026-04-26 11:18:49 -06:00
|
|
|
///
|
|
|
|
|
/// # Example
|
|
|
|
|
/// ```rust
|
|
|
|
|
/// use unshell::protocol::{PacketHeader, PacketType, validate_header};
|
|
|
|
|
/// let header = PacketHeader {
|
|
|
|
|
/// packet_type: PacketType::Call,
|
|
|
|
|
/// src_path: vec!["root".into()],
|
|
|
|
|
/// dst_path: vec!["worker".into()],
|
|
|
|
|
/// dst_leaf: Some("service".into()),
|
|
|
|
|
/// hook_id: None,
|
|
|
|
|
/// };
|
|
|
|
|
/// validate_header(&header)?;
|
|
|
|
|
/// # Ok::<(), unshell::protocol::ValidationError>(())
|
|
|
|
|
/// ```
|
2026-04-24 12:32:24 -06:00
|
|
|
pub fn validate_header(header: &PacketHeader) -> Result<(), ValidationError> {
|
|
|
|
|
match header.packet_type {
|
|
|
|
|
PacketType::Call => {
|
|
|
|
|
if header.hook_id.is_some() {
|
|
|
|
|
return Err(ValidationError::HeaderInvariant(
|
|
|
|
|
"Call packets must not carry hook_id",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
PacketType::Data | PacketType::Fault => {
|
|
|
|
|
if header.dst_leaf.is_some() {
|
|
|
|
|
return Err(ValidationError::HeaderInvariant(
|
|
|
|
|
"Data and Fault packets must not carry dst_leaf",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if header.hook_id.is_none() {
|
|
|
|
|
return Err(ValidationError::HeaderInvariant(
|
|
|
|
|
"Data and Fault packets must carry hook_id",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 11:27:29 -06:00
|
|
|
/// Validates the protocol-level `procedure_id` invariant.
|
2026-04-26 01:53:37 -06:00
|
|
|
///
|
|
|
|
|
/// This is intentionally permissive. The protocol reserves only the empty string for
|
|
|
|
|
/// introspection; every other non-empty identifier is treated as opaque application data.
|
2026-04-26 11:18:49 -06:00
|
|
|
///
|
|
|
|
|
/// # Example
|
|
|
|
|
/// ```rust
|
|
|
|
|
/// use unshell::protocol::{INTROSPECTION_PROCEDURE_ID, validate_procedure_id};
|
|
|
|
|
/// validate_procedure_id(INTROSPECTION_PROCEDURE_ID)?;
|
|
|
|
|
/// validate_procedure_id("example.service.v1.invoke")?;
|
|
|
|
|
/// # Ok::<(), unshell::protocol::ValidationError>(())
|
|
|
|
|
/// ```
|
2026-04-24 12:32:24 -06:00
|
|
|
pub fn validate_procedure_id(procedure_id: &str) -> Result<(), ValidationError> {
|
|
|
|
|
if procedure_id == INTROSPECTION_PROCEDURE_ID {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
2026-04-24 14:10:03 -06:00
|
|
|
if procedure_id.is_empty() {
|
2026-04-24 12:32:24 -06:00
|
|
|
return Err(ValidationError::ProcedureId(
|
2026-04-24 14:10:03 -06:00
|
|
|
"procedure identifier cannot be empty except for introspection",
|
2026-04-24 12:32:24 -06:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validates call-specific invariants that depend on both header and payload.
|
2026-04-26 01:53:37 -06:00
|
|
|
///
|
|
|
|
|
/// This complements [`validate_header`]. It does not verify destination reachability or leaf
|
|
|
|
|
/// support, only consistency between the opening `Call` header and payload.
|
2026-04-26 11:18:49 -06:00
|
|
|
///
|
|
|
|
|
/// # Example
|
|
|
|
|
/// ```rust
|
|
|
|
|
/// use unshell::protocol::{CallMessage, HookTarget, PacketHeader, PacketType, validate_call};
|
|
|
|
|
/// let header = PacketHeader {
|
|
|
|
|
/// packet_type: PacketType::Call,
|
|
|
|
|
/// src_path: vec!["root".into()],
|
|
|
|
|
/// dst_path: vec!["worker".into()],
|
|
|
|
|
/// dst_leaf: Some("service".into()),
|
|
|
|
|
/// hook_id: None,
|
|
|
|
|
/// };
|
|
|
|
|
/// let call = CallMessage {
|
|
|
|
|
/// procedure_id: "example.service.v1.invoke".into(),
|
|
|
|
|
/// data: vec![],
|
|
|
|
|
/// response_hook: Some(HookTarget {
|
|
|
|
|
/// hook_id: 7,
|
|
|
|
|
/// return_path: vec!["root".into()],
|
|
|
|
|
/// }),
|
|
|
|
|
/// };
|
|
|
|
|
/// validate_call(&header, &call)?;
|
|
|
|
|
/// # Ok::<(), unshell::protocol::ValidationError>(())
|
|
|
|
|
/// ```
|
2026-04-24 12:32:24 -06:00
|
|
|
pub fn validate_call(header: &PacketHeader, call: &CallMessage) -> Result<(), ValidationError> {
|
|
|
|
|
validate_procedure_id(&call.procedure_id)?;
|
|
|
|
|
|
|
|
|
|
if let Some(hook) = &call.response_hook
|
|
|
|
|
&& hook.return_path != header.src_path
|
|
|
|
|
{
|
|
|
|
|
return Err(ValidationError::CallInvariant(
|
|
|
|
|
"response_hook.return_path must equal header.src_path",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if call.procedure_id == INTROSPECTION_PROCEDURE_ID && call.response_hook.is_none() {
|
2026-04-26 01:53:37 -06:00
|
|
|
// Introspection is defined as a request/response exchange, never a fire-and-forget call.
|
2026-04-24 12:32:24 -06:00
|
|
|
return Err(ValidationError::CallInvariant(
|
|
|
|
|
"introspection requires a response hook",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|