Files
unshell/src/protocol/validation.rs
T
2026-04-26 01:53:37 -06:00

106 lines
3.9 KiB
Rust

//! Stateless protocol validation.
use crate::protocol::{
CallMessage, PacketHeader, PacketType, introspection::INTROSPECTION_PROCEDURE_ID,
};
use core::fmt;
/// Validation failures for protocol structures.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
/// One header field combination is invalid for the chosen packet type.
HeaderInvariant(&'static str),
/// The procedure identifier violates the protocol's minimal reserved-id rules.
ProcedureId(&'static str),
/// The call payload contradicts the surrounding packet header.
CallInvariant(&'static str),
/// A hook lifecycle transition would break protocol state invariants.
HookInvariant(&'static str),
/// A hook id collided with existing endpoint-local state.
InvalidHookId,
}
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}"),
Self::HookInvariant(message) => write!(f, "invalid hook state: {message}"),
Self::InvalidHookId => f.write_str("invalid hook identifier"),
}
}
}
impl core::error::Error for ValidationError {}
/// 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.
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(())
}
/// Validates the protocol-level `procedure_id` invariant.
///
/// This is intentionally permissive. The protocol reserves only the empty string for
/// introspection; every other non-empty identifier is treated as opaque application data.
pub fn validate_procedure_id(procedure_id: &str) -> Result<(), ValidationError> {
if procedure_id == INTROSPECTION_PROCEDURE_ID {
return Ok(());
}
if procedure_id.is_empty() {
return Err(ValidationError::ProcedureId(
"procedure identifier cannot be empty except for introspection",
));
}
Ok(())
}
/// Validates call-specific invariants that depend on both header and payload.
///
/// This complements [`validate_header`]. It does not verify destination reachability or leaf
/// support, only consistency between the opening `Call` header and payload.
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() {
// Introspection is defined as a request/response exchange, never a fire-and-forget call.
return Err(ValidationError::CallInvariant(
"introspection requires a response hook",
));
}
Ok(())
}