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
+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:?}"),
}
}