From 2d5f04a024f054eea65a8bf1c8a2b0eddfe065d6 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:57:56 -0600 Subject: [PATCH] Reorganize protocol test modules --- src/protocol/mod.rs | 6 +- .../tests/endpoint/downward_routing.rs | 94 ++++ src/protocol/tests/endpoint/hook_lifecycle.rs | 48 ++ src/protocol/tests/endpoint/local_delivery.rs | 70 +++ .../tests/endpoint/malformed_or_forged.rs | 112 ++++ src/protocol/tests/endpoint/mod.rs | 5 + src/protocol/tests/endpoint/upward_routing.rs | 143 +++++ src/protocol/tests/integration/mod.rs | 2 + src/protocol/tests/integration/oneshot.rs | 149 ++++++ .../tests/{oneshot => integration}/streams.rs | 17 +- .../{leaves.rs => leaves/caller.rs} | 221 +------- src/protocol/tests/merkle_sync/leaves/mod.rs | 7 + .../tests/merkle_sync/leaves/respondent.rs | 147 ++++++ .../tests/merkle_sync/leaves/transport.rs | 79 +++ src/protocol/tests/mod.rs | 5 + src/protocol/tests/oneshot/mod.rs | 491 ------------------ src/protocol/tests/oneshot/support.rs | 205 -------- src/protocol/tests/support/assertions.rs | 23 + src/protocol/tests/support/endpoints.rs | 42 ++ src/protocol/tests/support/mod.rs | 4 + src/protocol/tests/support/packets.rs | 23 + src/protocol/tests/support/transport.rs | 63 +++ unshell-leaves/leaf-pty/src/tests/session.rs | 282 ---------- .../leaf-pty/src/tests/session/concurrency.rs | 47 ++ .../leaf-pty/src/tests/session/failure.rs | 41 ++ .../leaf-pty/src/tests/session/filtering.rs | 44 ++ .../src/tests/session/input_output.rs | 33 ++ .../leaf-pty/src/tests/session/lifecycle.rs | 145 ++++++ .../leaf-pty/src/tests/session/mod.rs | 5 + unshell-leaves/leaf-pty/src/tests/support.rs | 137 ----- .../leaf-pty/src/tests/support/assertions.rs | 47 ++ .../leaf-pty/src/tests/support/drains.rs | 23 + .../leaf-pty/src/tests/support/endpoints.rs | 40 ++ .../leaf-pty/src/tests/support/mod.rs | 9 + .../leaf-pty/src/tests/support/packets.rs | 46 ++ 35 files changed, 1519 insertions(+), 1336 deletions(-) create mode 100644 src/protocol/tests/endpoint/downward_routing.rs create mode 100644 src/protocol/tests/endpoint/hook_lifecycle.rs create mode 100644 src/protocol/tests/endpoint/local_delivery.rs create mode 100644 src/protocol/tests/endpoint/malformed_or_forged.rs create mode 100644 src/protocol/tests/endpoint/mod.rs create mode 100644 src/protocol/tests/endpoint/upward_routing.rs create mode 100644 src/protocol/tests/integration/mod.rs create mode 100644 src/protocol/tests/integration/oneshot.rs rename src/protocol/tests/{oneshot => integration}/streams.rs (96%) rename src/protocol/tests/merkle_sync/{leaves.rs => leaves/caller.rs} (53%) create mode 100644 src/protocol/tests/merkle_sync/leaves/mod.rs create mode 100644 src/protocol/tests/merkle_sync/leaves/respondent.rs create mode 100644 src/protocol/tests/merkle_sync/leaves/transport.rs create mode 100644 src/protocol/tests/mod.rs delete mode 100644 src/protocol/tests/oneshot/mod.rs delete mode 100644 src/protocol/tests/oneshot/support.rs create mode 100644 src/protocol/tests/support/assertions.rs create mode 100644 src/protocol/tests/support/endpoints.rs create mode 100644 src/protocol/tests/support/mod.rs create mode 100644 src/protocol/tests/support/packets.rs create mode 100644 src/protocol/tests/support/transport.rs delete mode 100644 unshell-leaves/leaf-pty/src/tests/session.rs create mode 100644 unshell-leaves/leaf-pty/src/tests/session/concurrency.rs create mode 100644 unshell-leaves/leaf-pty/src/tests/session/failure.rs create mode 100644 unshell-leaves/leaf-pty/src/tests/session/filtering.rs create mode 100644 unshell-leaves/leaf-pty/src/tests/session/input_output.rs create mode 100644 unshell-leaves/leaf-pty/src/tests/session/lifecycle.rs create mode 100644 unshell-leaves/leaf-pty/src/tests/session/mod.rs delete mode 100644 unshell-leaves/leaf-pty/src/tests/support.rs create mode 100644 unshell-leaves/leaf-pty/src/tests/support/assertions.rs create mode 100644 unshell-leaves/leaf-pty/src/tests/support/drains.rs create mode 100644 unshell-leaves/leaf-pty/src/tests/support/endpoints.rs create mode 100644 unshell-leaves/leaf-pty/src/tests/support/mod.rs create mode 100644 unshell-leaves/leaf-pty/src/tests/support/packets.rs diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 3c030a8..edc92c7 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -32,8 +32,4 @@ pub type PacketQueue = VecDeque; type RouteMap = Vec<(EndpointName, PacketQueue)>; #[cfg(test)] -mod tests { - mod merkle_sync; - mod oneshot; - mod packet; -} +mod tests; diff --git a/src/protocol/tests/endpoint/downward_routing.rs b/src/protocol/tests/endpoint/downward_routing.rs new file mode 100644 index 0000000..aaabb16 --- /dev/null +++ b/src/protocol/tests/endpoint/downward_routing.rs @@ -0,0 +1,94 @@ +use alloc::vec; + +use crate::protocol::{Endpoint, EndpointError, RouteDirection}; + +use super::super::support::{ + assertions::{assert_hook_present, assert_hook_removed}, + endpoints::{ENDPOINT_A, ENDPOINT_B, ENDPOINT_C, endpoint_at, single_outbound_packet}, + packets::{echo_packet, echo_packet_with_end}, +}; + +#[test] +fn inbound_downward_packet_routes_to_immediate_child() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let hook_id = endpoint.get_hook_id(); + endpoint.add_connection(ENDPOINT_A, true); + endpoint.add_connection(ENDPOINT_C, false); + + endpoint + .add_inbound_from( + ENDPOINT_A, + echo_packet(vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C], hook_id), + ) + .unwrap(); + + let packet = single_outbound_packet(&endpoint, ENDPOINT_C); + assert!(!packet.end_hook); + assert_eq!(packet.path, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]); + assert_hook_present(&endpoint, hook_id); + assert_eq!(endpoint.hook_peer(hook_id), Some(ENDPOINT_C)); + assert!(!Endpoint::route_contains(ENDPOINT_A, &endpoint.outbound)); +} + +#[test] +fn outbound_downward_packet_routes_to_immediate_child() { + let mut endpoint = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]); + let hook_id = endpoint.get_hook_id(); + endpoint.accept_hook(hook_id, ENDPOINT_B); + endpoint.add_connection(ENDPOINT_B, false); + + endpoint + .add_outbound(echo_packet_with_end( + vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C], + hook_id, + true, + )) + .unwrap(); + + let packet = single_outbound_packet(&endpoint, ENDPOINT_B); + assert!(packet.end_hook); + assert_eq!(packet.path, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]); + assert_hook_removed(&endpoint, hook_id); + assert!(!Endpoint::route_contains(ENDPOINT_C, &endpoint.outbound)); +} + +#[test] +fn downward_outbound_without_hook_is_allowed() { + let mut endpoint = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]); + endpoint.add_connection(ENDPOINT_B, false); + + let new_hook = endpoint.get_hook_id(); + + endpoint + .add_outbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], new_hook)) + .unwrap(); + + assert_eq!( + Endpoint::route_get(ENDPOINT_B, &endpoint.outbound) + .unwrap() + .len(), + 1 + ); + assert_hook_present(&endpoint, new_hook); + assert_eq!(endpoint.hook_peer(new_hook), Some(ENDPOINT_B)); +} + +#[test] +fn downward_route_without_connection_is_rejected() { + let mut endpoint = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]); + let hook_id = endpoint.get_hook_id(); + + let error = endpoint + .add_outbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id)) + .unwrap_err(); + + assert!(matches!( + error, + EndpointError::MissingConnection { + next_hop: ENDPOINT_B, + direction: RouteDirection::Downward, + } + )); + assert_hook_removed(&endpoint, hook_id); + assert!(Endpoint::routes_is_empty(&endpoint.outbound)); +} diff --git a/src/protocol/tests/endpoint/hook_lifecycle.rs b/src/protocol/tests/endpoint/hook_lifecycle.rs new file mode 100644 index 0000000..ff4dad3 --- /dev/null +++ b/src/protocol/tests/endpoint/hook_lifecycle.rs @@ -0,0 +1,48 @@ +use alloc::vec; + +use crate::protocol::{Endpoint, EndpointError, RouteDirection}; + +use super::super::support::{ + assertions::{assert_hook_present, assert_hook_removed}, + endpoints::{ENDPOINT_A, ENDPOINT_B, endpoint_at, single_outbound_packet}, + packets::echo_packet_with_end, +}; + +#[test] +fn end_hook_removes_hook_after_packet_is_queued() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let hook_id = endpoint.get_hook_id(); + endpoint.accept_hook(hook_id, ENDPOINT_A); + endpoint.add_connection(ENDPOINT_A, true); + + endpoint + .add_outbound(echo_packet_with_end(vec![ENDPOINT_A], hook_id, true)) + .unwrap(); + + assert_hook_removed(&endpoint, hook_id); + assert_eq!( + single_outbound_packet(&endpoint, ENDPOINT_A).hook_id, + hook_id + ); +} + +#[test] +fn failed_end_hook_route_keeps_hook_state() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let hook_id = endpoint.get_hook_id(); + endpoint.accept_hook(hook_id, ENDPOINT_A); + + let error = endpoint + .add_outbound(echo_packet_with_end(vec![ENDPOINT_A], hook_id, true)) + .unwrap_err(); + + assert!(matches!( + error, + EndpointError::MissingConnection { + next_hop: ENDPOINT_A, + direction: RouteDirection::Upward, + } + )); + assert_hook_present(&endpoint, hook_id); + assert!(Endpoint::routes_is_empty(&endpoint.outbound)); +} diff --git a/src/protocol/tests/endpoint/local_delivery.rs b/src/protocol/tests/endpoint/local_delivery.rs new file mode 100644 index 0000000..d685065 --- /dev/null +++ b/src/protocol/tests/endpoint/local_delivery.rs @@ -0,0 +1,70 @@ +use alloc::vec; + +use crate::protocol::{Endpoint, EndpointError}; + +use super::super::support::{ + assertions::{assert_hook_present, assert_hook_removed}, + endpoints::{ENDPOINT_A, ENDPOINT_B, endpoint_at, single_inbound_packet}, + packets::echo_packet, +}; + +#[test] +fn inbound_downward_packet_for_local_endpoint_opens_hook() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let hook_id = endpoint.get_hook_id(); + endpoint.add_connection(ENDPOINT_A, true); + + endpoint + .add_inbound_from( + ENDPOINT_A, + echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id), + ) + .unwrap(); + + let packet = single_inbound_packet(&endpoint, ENDPOINT_B); + assert!(!packet.end_hook); + assert_eq!(packet.path, vec![ENDPOINT_A, ENDPOINT_B]); + assert_hook_present(&endpoint, hook_id); + assert_eq!(endpoint.hook_peer(hook_id), Some(ENDPOINT_A)); + assert!(Endpoint::routes_is_empty(&endpoint.outbound)); +} + +#[test] +fn outbound_packet_for_local_endpoint_is_delivered_locally() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let hook_id = endpoint.get_hook_id(); + + endpoint + .add_outbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id)) + .unwrap(); + + let packet = single_inbound_packet(&endpoint, ENDPOINT_B); + assert!(!packet.end_hook); + assert_eq!(packet.data, "ABC123".as_bytes()); + assert_hook_removed(&endpoint, hook_id); + assert!(Endpoint::routes_is_empty(&endpoint.outbound)); +} + +#[test] +fn inbound_without_absolute_path_is_rejected() { + let mut endpoint = Endpoint::new(ENDPOINT_A); + + let error = endpoint + .add_inbound(echo_packet(vec![ENDPOINT_A], 1)) + .unwrap_err(); + + assert!(matches!(error, EndpointError::EndpointPathUnset)); + assert!(Endpoint::routes_is_empty(&endpoint.inbound)); +} + +#[test] +fn outbound_without_absolute_path_is_rejected() { + let mut endpoint = Endpoint::new(ENDPOINT_A); + + let error = endpoint + .add_outbound(echo_packet(vec![ENDPOINT_A], 1)) + .unwrap_err(); + + assert!(matches!(error, EndpointError::EndpointPathUnset)); + assert!(Endpoint::routes_is_empty(&endpoint.outbound)); +} diff --git a/src/protocol/tests/endpoint/malformed_or_forged.rs b/src/protocol/tests/endpoint/malformed_or_forged.rs new file mode 100644 index 0000000..ea6ca44 --- /dev/null +++ b/src/protocol/tests/endpoint/malformed_or_forged.rs @@ -0,0 +1,112 @@ +use alloc::vec; + +use crate::protocol::{Endpoint, EndpointError, Leaf}; + +use super::super::support::{ + assertions::assert_hook_present, + endpoints::{ENDPOINT_A, ENDPOINT_B, ENDPOINT_C, endpoint_at, single_inbound_packet}, + packets::{echo_packet, echo_packet_with_end}, + transport::CommsLeaf, +}; + +#[test] +fn forged_sideways_packet_is_rejected_as_incorrect_path() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let hook_id = endpoint.get_hook_id(); + endpoint.accept_hook(hook_id, ENDPOINT_A); + endpoint.add_connection(ENDPOINT_A, true); + + let error = endpoint + .add_inbound_from( + ENDPOINT_A, + echo_packet(vec![ENDPOINT_A, ENDPOINT_C], hook_id), + ) + .unwrap_err(); + + assert!(matches!(error, EndpointError::DestinationOutsideLocalTree)); + assert_hook_present(&endpoint, hook_id); + assert!(Endpoint::routes_is_empty(&endpoint.inbound)); + assert!(Endpoint::routes_is_empty(&endpoint.outbound)); +} + +#[test] +fn malformed_frame_is_dropped_by_comms_leaf() { + let (tx_to_endpoint, rx_for_endpoint) = crossbeam_channel::unbounded(); + let (tx_unused, _rx_unused) = crossbeam_channel::unbounded(); + let mut endpoint = Endpoint::new(ENDPOINT_B); + let mut comms = CommsLeaf { + tx: tx_unused, + rx: rx_for_endpoint, + remote_id: ENDPOINT_A, + is_authority: true, + started: false, + }; + endpoint.path = vec![ENDPOINT_A, ENDPOINT_B]; + + tx_to_endpoint.send(vec![0, 1, 2, 3]).unwrap(); + comms.update(&mut endpoint); + + assert!(Endpoint::routes_is_empty(&endpoint.inbound)); + assert!(Endpoint::routes_is_empty(&endpoint.outbound)); +} + +#[test] +fn malformed_frame_does_not_block_following_valid_packet() { + let (tx_to_endpoint, rx_for_endpoint) = crossbeam_channel::unbounded(); + let (tx_unused, _rx_unused) = crossbeam_channel::unbounded(); + let hook_id = 42; + let mut endpoint = Endpoint::new(ENDPOINT_B); + let mut comms = CommsLeaf { + tx: tx_unused, + rx: rx_for_endpoint, + remote_id: ENDPOINT_A, + is_authority: true, + started: false, + }; + endpoint.path = vec![ENDPOINT_A, ENDPOINT_B]; + + tx_to_endpoint.send(vec![0, 1, 2, 3]).unwrap(); + tx_to_endpoint + .send( + echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id) + .serialize() + .unwrap(), + ) + .unwrap(); + comms.update(&mut endpoint); + + let packet = single_inbound_packet(&endpoint, ENDPOINT_B); + assert!(!packet.end_hook); + assert_eq!(packet.hook_id, hook_id); + assert_hook_present(&endpoint, hook_id); +} + +#[test] +fn forged_frame_without_required_hook_is_dropped_by_comms_leaf() { + let (tx_to_endpoint, rx_for_endpoint) = crossbeam_channel::unbounded(); + let (tx_unused, _rx_unused) = crossbeam_channel::unbounded(); + let mut endpoint = Endpoint::new(ENDPOINT_B); + let mut comms = CommsLeaf { + tx: tx_unused, + rx: rx_for_endpoint, + remote_id: ENDPOINT_C, + is_authority: false, + started: false, + }; + endpoint.path = vec![ENDPOINT_A, ENDPOINT_B]; + endpoint.accept_hook(7, ENDPOINT_C); + endpoint.add_connection(ENDPOINT_A, true); + + tx_to_endpoint + .send( + echo_packet_with_end(vec![ENDPOINT_A], 12, true) + .serialize() + .unwrap(), + ) + .unwrap(); + comms.update(&mut endpoint); + + assert_hook_present(&endpoint, 7); + assert!(Endpoint::routes_is_empty(&endpoint.inbound)); + assert!(Endpoint::routes_is_empty(&endpoint.outbound)); +} diff --git a/src/protocol/tests/endpoint/mod.rs b/src/protocol/tests/endpoint/mod.rs new file mode 100644 index 0000000..f43c2a8 --- /dev/null +++ b/src/protocol/tests/endpoint/mod.rs @@ -0,0 +1,5 @@ +mod downward_routing; +mod hook_lifecycle; +mod local_delivery; +mod malformed_or_forged; +mod upward_routing; diff --git a/src/protocol/tests/endpoint/upward_routing.rs b/src/protocol/tests/endpoint/upward_routing.rs new file mode 100644 index 0000000..8da8f22 --- /dev/null +++ b/src/protocol/tests/endpoint/upward_routing.rs @@ -0,0 +1,143 @@ +use alloc::vec; + +use crate::protocol::{Endpoint, EndpointError, RouteDirection}; + +use super::super::support::{ + assertions::{assert_hook_present, assert_hook_removed}, + endpoints::{ENDPOINT_A, ENDPOINT_B, ENDPOINT_C, endpoint_at, single_outbound_packet}, + packets::echo_packet_with_end, +}; + +#[test] +fn inbound_upward_packet_with_hook_routes_to_parent() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let hook_id = endpoint.get_hook_id(); + endpoint.accept_hook(hook_id, ENDPOINT_C); + endpoint.add_connection(ENDPOINT_A, true); + endpoint.add_connection(ENDPOINT_C, false); + + endpoint + .add_inbound_from( + ENDPOINT_C, + echo_packet_with_end(vec![ENDPOINT_A], hook_id, true), + ) + .unwrap(); + + let packet = single_outbound_packet(&endpoint, ENDPOINT_A); + assert!(packet.end_hook); + assert_eq!(packet.hook_id, hook_id); + assert_hook_removed(&endpoint, hook_id); + assert!(!Endpoint::route_contains(ENDPOINT_C, &endpoint.outbound)); +} + +#[test] +fn inbound_upward_packet_without_hook_is_rejected() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let hook_id = endpoint.get_hook_id(); + endpoint.add_connection(ENDPOINT_A, true); + endpoint.add_connection(ENDPOINT_C, false); + + let error = endpoint + .add_inbound_from( + ENDPOINT_C, + echo_packet_with_end(vec![ENDPOINT_A], hook_id, true), + ) + .unwrap_err(); + + assert!(matches!( + error, + EndpointError::UnknownHook { hook_id: observed_hook_id } if observed_hook_id == hook_id + )); + assert!(Endpoint::routes_is_empty(&endpoint.inbound)); + assert!(Endpoint::routes_is_empty(&endpoint.outbound)); +} + +#[test] +fn forged_upward_packet_with_unknown_hook_is_rejected() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + endpoint.accept_hook(7, ENDPOINT_C); + endpoint.add_connection(ENDPOINT_A, true); + endpoint.add_connection(ENDPOINT_C, false); + + let error = endpoint + .add_inbound_from(ENDPOINT_C, echo_packet_with_end(vec![ENDPOINT_A], 99, true)) + .unwrap_err(); + + assert!(matches!(error, EndpointError::UnknownHook { hook_id: 99 })); + assert_hook_present(&endpoint, 7); + assert!(Endpoint::routes_is_empty(&endpoint.outbound)); +} + +#[test] +fn upward_outbound_without_hook_is_rejected() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + endpoint.accept_hook(7, ENDPOINT_A); + endpoint.add_connection(ENDPOINT_A, true); + + let new_hook = endpoint.get_hook_id(); + + let error = endpoint + .add_outbound(echo_packet_with_end(vec![ENDPOINT_A], new_hook, true)) + .unwrap_err(); + + assert!(matches!( + error, + EndpointError::UnknownHook { hook_id: observed_hook_id } if observed_hook_id == new_hook + )); + assert_hook_present(&endpoint, 7); + assert!(Endpoint::routes_is_empty(&endpoint.outbound)); +} + +#[test] +fn deeper_upward_route_uses_parent_as_next_hop() { + let mut endpoint = endpoint_at(ENDPOINT_C, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]); + let new_hook = endpoint.get_hook_id(); + + endpoint.accept_hook(new_hook, ENDPOINT_B); + endpoint.add_connection(ENDPOINT_B, true); + + endpoint + .add_outbound(echo_packet_with_end(vec![ENDPOINT_A], new_hook, true)) + .unwrap(); + + assert!(Endpoint::route_contains(ENDPOINT_B, &endpoint.outbound)); + assert!(!Endpoint::route_contains(ENDPOINT_A, &endpoint.outbound)); + assert_hook_removed(&endpoint, new_hook); +} + +#[test] +fn upward_route_without_connection_is_rejected_even_with_hook() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let hook_id = endpoint.get_hook_id(); + endpoint.accept_hook(hook_id, ENDPOINT_A); + + let error = endpoint + .add_outbound(echo_packet_with_end(vec![ENDPOINT_A], hook_id, true)) + .unwrap_err(); + + assert!(matches!( + error, + EndpointError::MissingConnection { + next_hop: ENDPOINT_A, + direction: RouteDirection::Upward, + } + )); + assert_hook_present(&endpoint, hook_id); + assert!(Endpoint::routes_is_empty(&endpoint.outbound)); +} + +#[test] +fn trusted_upward_packet_without_peer_metadata_checks_hook_existence_only() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let hook_id = endpoint.get_hook_id(); + endpoint.accept_hook(hook_id, ENDPOINT_A); + endpoint.add_connection(ENDPOINT_A, true); + + endpoint + .add_inbound(echo_packet_with_end(vec![ENDPOINT_A], hook_id, true)) + .unwrap(); + + let packet = single_outbound_packet(&endpoint, ENDPOINT_A); + assert_eq!(packet.hook_id, hook_id); + assert_hook_removed(&endpoint, hook_id); +} diff --git a/src/protocol/tests/integration/mod.rs b/src/protocol/tests/integration/mod.rs new file mode 100644 index 0000000..c72bf5f --- /dev/null +++ b/src/protocol/tests/integration/mod.rs @@ -0,0 +1,2 @@ +mod oneshot; +mod streams; diff --git a/src/protocol/tests/integration/oneshot.rs b/src/protocol/tests/integration/oneshot.rs new file mode 100644 index 0000000..e356aa9 --- /dev/null +++ b/src/protocol/tests/integration/oneshot.rs @@ -0,0 +1,149 @@ +use alloc::{vec, vec::Vec}; + +use crate::protocol::{Endpoint, Leaf}; + +#[cfg(feature = "interface")] +use crate::protocol::LeafMeta; + +use super::super::support::{ + endpoints::{ENDPOINT_A, ENDPOINT_B}, + packets::{echo_packet, echo_packet_with_end}, + transport::CommsLeaf, +}; + +const LEAF_CONTROLLER: u32 = 100; +const LEAF_RESPONDER: u32 = 102; + +struct ControllerLeaf { + has_run: bool, +} + +struct ResponderLeaf; + +impl Leaf for ControllerLeaf { + fn get_id(&self) -> u32 { + LEAF_CONTROLLER + } + + #[cfg(feature = "interface")] + fn get_meta(&self) -> LeafMeta { + LeafMeta { + name: "Controller Leaf", + identifier: "dev.unshell.test.controller_leaf", + version: "v0", + authors: alloc::vec!["ASTATIN3"], + } + } + + fn update(&mut self, endpoint: &mut Endpoint) { + if !self.has_run { + // The controller starts exactly one request so the end-to-end test can + // assert deterministic routing without accumulating retries. + let hook_id = endpoint.get_hook_id(); + let packet = echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id); + let _ = endpoint.add_outbound(packet); + self.has_run = true; + } + } +} + +impl Leaf for ResponderLeaf { + fn get_id(&self) -> u32 { + LEAF_RESPONDER + } + + #[cfg(feature = "interface")] + fn get_meta(&self) -> LeafMeta { + LeafMeta { + name: "Responder Leaf", + identifier: "dev.unshell.test.responder_leaf", + version: "v0", + authors: alloc::vec!["ASTATIN3"], + } + } + + fn update(&mut self, endpoint: &mut Endpoint) { + let local_id = endpoint.path.last().cloned().unwrap_or(0); + let mut packets = Vec::new(); + + endpoint.take_inbound_clear(local_id, |packet| { + let mut response = echo_packet_with_end(vec![ENDPOINT_A], packet.hook_id, true); + response.hook_id = packet.hook_id; + response.data = packet.data.clone(); + packets.push(response); + }); + + for packet in packets { + let _ = endpoint.add_outbound(packet); + } + } +} + +#[test] +fn request_response_round_trip_over_mock_transport() { + let (tx_a, rx_a) = crossbeam_channel::unbounded(); + let (tx_b, rx_b) = crossbeam_channel::unbounded(); + + let mut endpoint_a = Endpoint::new(ENDPOINT_A); + let mut controller_a = ControllerLeaf { has_run: false }; + let mut comms_a = CommsLeaf { + tx: tx_b, + rx: rx_a, + remote_id: ENDPOINT_B, + is_authority: false, + started: false, + }; + endpoint_a.path = vec![ENDPOINT_A]; + + let mut endpoint_b = Endpoint::new(ENDPOINT_B); + let mut responder_b = ResponderLeaf; + let mut comms_b = CommsLeaf { + tx: tx_a, + rx: rx_b, + remote_id: ENDPOINT_A, + is_authority: true, + started: false, + }; + endpoint_b.path = vec![ENDPOINT_A, ENDPOINT_B]; + + // Connections are registered routing state. The comms leaves also insert them + // during updates, but the first application packet should not depend on leaf order. + endpoint_a.add_connection(ENDPOINT_B, false); + endpoint_b.add_connection(ENDPOINT_A, true); + + // Cycle 1: A sends request to B. + controller_a.update(&mut endpoint_a); + comms_a.update(&mut endpoint_a); + responder_b.update(&mut endpoint_b); + comms_b.update(&mut endpoint_b); + + // Cycle 2: B receives request and sends response to A. + responder_b.update(&mut endpoint_b); + comms_b.update(&mut endpoint_b); + controller_a.update(&mut endpoint_a); + comms_a.update(&mut endpoint_a); + + // Cycle 3: A's transport leaf needs one more update to pull the response bytes + // from the channel and put the packet into the inbound queue. + controller_a.update(&mut endpoint_a); + comms_a.update(&mut endpoint_a); + + assert!( + Endpoint::route_contains(ENDPOINT_A, &endpoint_a.inbound), + "Endpoint A should have received response" + ); + assert_eq!( + Endpoint::route_get(ENDPOINT_A, &endpoint_a.inbound) + .unwrap() + .len(), + 1, + "Endpoint A should have exactly one packet" + ); + let response = &Endpoint::route_get(ENDPOINT_A, &endpoint_a.inbound) + .unwrap() + .front() + .unwrap(); + assert!(response.end_hook); + assert_eq!(response.data, "ABC123".as_bytes()); + assert_eq!(endpoint_b.hook_count(), 0); +} diff --git a/src/protocol/tests/oneshot/streams.rs b/src/protocol/tests/integration/streams.rs similarity index 96% rename from src/protocol/tests/oneshot/streams.rs rename to src/protocol/tests/integration/streams.rs index 11f4940..84d12c1 100644 --- a/src/protocol/tests/oneshot/streams.rs +++ b/src/protocol/tests/integration/streams.rs @@ -5,10 +5,15 @@ use crate::protocol::LeafMeta; use alloc::{format, vec, vec::Vec}; -use super::support::{CommsLeaf, ENDPOINT_A, ENDPOINT_B, assert_hook_present, assert_hook_removed}; +use super::super::support::{ + assertions::{assert_hook_present, assert_hook_removed}, + endpoints::{ENDPOINT_A, ENDPOINT_B}, + transport::CommsLeaf, +}; const LEAF_STREAM_CALLER: u32 = 200; const LEAF_STREAM_RESPONDENT: u32 = 201; +const LEAF_COMMS: u32 = 101; /// Builds the initial downwards packet that opens the stream on the respondent. /// @@ -104,7 +109,7 @@ impl Leaf for StreamCallerLeaf { name: "Stream Caller Leaf", identifier: "dev.unshell.test.stream_caller_leaf", version: "v0", - authors: vec!["ASTATIN3"], + authors: alloc::vec!["ASTATIN3"], } } @@ -127,10 +132,10 @@ impl Leaf for StreamRespondentLeaf { #[cfg(feature = "interface")] fn get_meta(&self) -> LeafMeta { LeafMeta { - name: "Stream Respondant Leaf", + name: "Stream Respondent Leaf", identifier: "dev.unshell.test.stream_respondent_leaf", version: "v0", - authors: vec!["ASTATIN3"], + authors: alloc::vec!["ASTATIN3"], } } @@ -243,9 +248,9 @@ fn stream_endpoints(total_packets: usize) -> StreamHarness { /// Asserts the requested two-endpoint, four-leaf topology. fn assert_four_leaf_topology(harness: &StreamHarness) { assert_eq!(harness.caller_a.get_id(), LEAF_STREAM_CALLER); - assert_eq!(harness.comms_a.get_id(), 101); + assert_eq!(harness.comms_a.get_id(), LEAF_COMMS); assert_eq!(harness.respondent_b.get_id(), LEAF_STREAM_RESPONDENT); - assert_eq!(harness.comms_b.get_id(), 101); + assert_eq!(harness.comms_b.get_id(), LEAF_COMMS); } /// Drives the initial request until it is queued locally on endpoint B. diff --git a/src/protocol/tests/merkle_sync/leaves.rs b/src/protocol/tests/merkle_sync/leaves/caller.rs similarity index 53% rename from src/protocol/tests/merkle_sync/leaves.rs rename to src/protocol/tests/merkle_sync/leaves/caller.rs index caf9a73..751d59b 100644 --- a/src/protocol/tests/merkle_sync/leaves.rs +++ b/src/protocol/tests/merkle_sync/leaves/caller.rs @@ -1,43 +1,25 @@ -use alloc::{collections::VecDeque, rc::Rc, vec, vec::Vec}; +use alloc::{collections::VecDeque, rc::Rc, vec::Vec}; use core::cell::RefCell; -use crossbeam_channel::{Receiver, Sender}; - use crate::protocol::{Endpoint, Leaf, Packet}; #[cfg(feature = "interface")] use crate::protocol::LeafMeta; -use super::{ +use super::super::{ codec::{decode_block_chunk, decode_child_summary, decode_u32}, constants::{ - ENDPOINT_CALLER, ENDPOINT_RESPONDENT, LEAF_MERKLE_CALLER, LEAF_MERKLE_RESPONDENT, - LEAF_MOCK_CONNECTION, PROC_BLOCK_CHUNK, PROC_CHILD_HASH_ENTRY, PROC_GET_BLOCK_STREAM, - PROC_GET_CHILD_HASHES, PROC_GET_ROOT_HASH, PROC_ROOT_HASH, ROOT_NODE, + ENDPOINT_CALLER, LEAF_MERKLE_CALLER, PROC_BLOCK_CHUNK, PROC_CHILD_HASH_ENTRY, + PROC_GET_BLOCK_STREAM, PROC_GET_CHILD_HASHES, PROC_GET_ROOT_HASH, PROC_ROOT_HASH, + ROOT_NODE, }, - rpc::{ - block_chunk_frame, block_stream_request, child_hash_frame, child_hashes_request, - root_hash_frame, root_hash_request, - }, - state::{CallerPhase, CallerReport, RespondentReport, ResponseStream}, - tree::{BlockChunk, ChildKind, MerkleStore}, + rpc::{block_stream_request, child_hashes_request, root_hash_request}, + state::{CallerPhase, CallerReport}, + tree::{ChildKind, MerkleStore}, }; -/// Leaf that simulates a serialized transport connection with crossbeam channels. -/// -/// This is intentionally tiny and reusable. Both endpoints in the Merkle test have -/// exactly one of these leaves, giving the requested four-leaf topology: caller, -/// respondent, and two mock connections. -pub(super) struct MockConnectionLeaf { - pub(super) tx: Sender>, - pub(super) rx: Receiver>, - pub(super) remote_id: u32, - pub(super) is_authority: bool, - pub(super) started: bool, -} - /// Caller leaf that drives the Merkle synchronization algorithm. -pub(super) struct MerkleCallerLeaf { +pub(crate) struct MerkleCallerLeaf { local: MerkleStore, phase: CallerPhase, pending_nodes: VecDeque, @@ -45,34 +27,9 @@ pub(super) struct MerkleCallerLeaf { report: Rc>, } -/// Respondent leaf that serves Merkle hash and block streams. -pub(super) struct MerkleRespondentLeaf { - remote: MerkleStore, - active_stream: Option, - report: Rc>, -} - -impl MockConnectionLeaf { - /// Creates one side of a mock connection. - pub(super) fn new( - tx: Sender>, - rx: Receiver>, - remote_id: u32, - is_authority: bool, - ) -> Self { - Self { - tx, - rx, - remote_id, - is_authority, - started: false, - } - } -} - impl MerkleCallerLeaf { /// Creates a caller with a local store and externally visible report. - pub(super) fn new(local: MerkleStore, report: Rc>) -> Self { + pub(crate) fn new(local: MerkleStore, report: Rc>) -> Self { Self { local, phase: CallerPhase::NeedRoot, @@ -83,55 +40,6 @@ impl MerkleCallerLeaf { } } -impl MerkleRespondentLeaf { - /// Creates a respondent backed by the authoritative remote store. - pub(super) fn new(remote: MerkleStore, report: Rc>) -> Self { - Self { - remote, - active_stream: None, - report, - } - } -} - -impl Leaf for MockConnectionLeaf { - fn get_id(&self) -> u32 { - LEAF_MOCK_CONNECTION - } - - #[cfg(feature = "interface")] - fn get_meta(&self) -> LeafMeta { - LeafMeta { - name: "Merke Connection Leaf", - identifier: "dev.unshell.test.merkle.connection", - version: "v0", - authors: vec!["ASTATIN3"], - } - } - - fn update(&mut self, endpoint: &mut Endpoint) { - if !self.started { - endpoint.add_connection(self.remote_id, self.is_authority); - self.started = true; - } - - while !self.rx.is_empty() { - let data = self.rx.recv().unwrap(); - - // Mock transports move untrusted bytes. Malformed frames are dropped so - // the sync state machine is tested only after packet parsing succeeds. - if let Ok(packet) = Packet::deserialize(&data) { - let _ = endpoint.add_inbound_from(self.remote_id, packet); - } - } - - endpoint.take_outbound_clear(self.remote_id, |packet| { - let data = packet.serialize().unwrap(); - let _ = self.tx.send(data); - }); - } -} - impl Leaf for MerkleCallerLeaf { fn get_id(&self) -> u32 { LEAF_MERKLE_CALLER @@ -143,7 +51,7 @@ impl Leaf for MerkleCallerLeaf { name: "Merke Caller Leaf", identifier: "dev.unshell.test.merkle.caller", version: "v0", - authors: vec!["ASTATIN3"], + authors: alloc::vec!["ASTATIN3"], } } @@ -153,27 +61,6 @@ impl Leaf for MerkleCallerLeaf { } } -impl Leaf for MerkleRespondentLeaf { - fn get_id(&self) -> u32 { - LEAF_MERKLE_RESPONDENT - } - - #[cfg(feature = "interface")] - fn get_meta(&self) -> LeafMeta { - LeafMeta { - name: "Merke Respondent Leaf", - identifier: "dev.unshell.test.merkle.respondent", - version: "v0", - authors: vec!["ASTATIN3"], - } - } - - fn update(&mut self, endpoint: &mut Endpoint) { - self.open_stream_from_request(endpoint); - self.send_one_response_frame(endpoint); - } -} - impl MerkleCallerLeaf { /// Consumes all response packets currently delivered to endpoint A. fn receive_responses(&mut self, endpoint: &mut Endpoint) { @@ -346,89 +233,3 @@ impl MerkleCallerLeaf { report.final_root_hash = Some(self.local.root_hash()); } } - -impl MerkleRespondentLeaf { - /// Opens one response stream from the first pending local request. - fn open_stream_from_request(&mut self, endpoint: &mut Endpoint) { - if self.active_stream.is_some() { - return; - } - - let mut request = None; - endpoint.take_inbound_clear(ENDPOINT_RESPONDENT, |packet| { - if request.is_none() { - request = Some((packet.hook_id, packet.procedure_id, packet.data.clone())); - } - }); - - let Some((hook_id, procedure_id, data)) = request else { - return; - }; - - let frames = self.frames_for_request(procedure_id, &data); - - self.report.borrow_mut().requests_seen.push(procedure_id); - if !frames.is_empty() { - self.report.borrow_mut().streams_started += 1; - self.active_stream = Some(ResponseStream::new(hook_id, frames)); - } - } - - /// Builds response frames for one request procedure. - fn frames_for_request(&self, procedure_id: u32, data: &[u8]) -> Vec { - match procedure_id { - PROC_GET_ROOT_HASH => vec![root_hash_frame(self.remote.root_hash())], - PROC_GET_CHILD_HASHES => { - let node_id = decode_u32(data).expect("child hash request node id"); - self.remote - .child_summaries(node_id) - .into_iter() - .map(child_hash_frame) - .collect() - } - PROC_GET_BLOCK_STREAM => { - let block_id = decode_u32(data).expect("block stream request block id"); - let chunks = self.remote.block_chunks(block_id); - let total = chunks.len() as u32; - chunks - .into_iter() - .enumerate() - .map(|(index, data)| { - block_chunk_frame(BlockChunk { - block_id, - index: index as u32, - total, - data, - }) - }) - .collect() - } - _ => Vec::new(), - } - } - - /// Sends at most one response frame per update loop. - fn send_one_response_frame(&mut self, endpoint: &mut Endpoint) { - let Some(stream) = self.active_stream.as_mut() else { - return; - }; - - if stream.is_empty() { - self.active_stream = None; - return; - } - - let packet = stream.next_packet().expect("active stream frame"); - if endpoint.add_outbound(packet).is_err() { - return; - } - - self.report.borrow_mut().frames_sent += 1; - stream.advance(); - - if stream.is_complete() { - self.report.borrow_mut().streams_completed += 1; - self.active_stream = None; - } - } -} diff --git a/src/protocol/tests/merkle_sync/leaves/mod.rs b/src/protocol/tests/merkle_sync/leaves/mod.rs new file mode 100644 index 0000000..2e1ced9 --- /dev/null +++ b/src/protocol/tests/merkle_sync/leaves/mod.rs @@ -0,0 +1,7 @@ +mod caller; +mod respondent; +mod transport; + +pub(crate) use caller::MerkleCallerLeaf; +pub(crate) use respondent::MerkleRespondentLeaf; +pub(crate) use transport::MockConnectionLeaf; diff --git a/src/protocol/tests/merkle_sync/leaves/respondent.rs b/src/protocol/tests/merkle_sync/leaves/respondent.rs new file mode 100644 index 0000000..1998054 --- /dev/null +++ b/src/protocol/tests/merkle_sync/leaves/respondent.rs @@ -0,0 +1,147 @@ +use alloc::{rc::Rc, vec::Vec}; +use core::cell::RefCell; + +use crate::protocol::{Endpoint, Leaf}; + +#[cfg(feature = "interface")] +use crate::protocol::LeafMeta; + +use super::super::{ + codec::decode_u32, + constants::{ + ENDPOINT_RESPONDENT, LEAF_MERKLE_RESPONDENT, PROC_GET_BLOCK_STREAM, PROC_GET_CHILD_HASHES, + PROC_GET_ROOT_HASH, + }, + rpc::{block_chunk_frame, child_hash_frame, root_hash_frame}, + state::{RespondentReport, ResponseStream}, + tree::{BlockChunk, MerkleStore}, +}; + +/// Respondent leaf that serves Merkle hash and block streams. +pub(crate) struct MerkleRespondentLeaf { + remote: MerkleStore, + active_stream: Option, + report: Rc>, +} + +impl MerkleRespondentLeaf { + /// Creates a respondent backed by the authoritative remote store. + pub(crate) fn new(remote: MerkleStore, report: Rc>) -> Self { + Self { + remote, + active_stream: None, + report, + } + } +} + +impl Leaf for MerkleRespondentLeaf { + fn get_id(&self) -> u32 { + LEAF_MERKLE_RESPONDENT + } + + #[cfg(feature = "interface")] + fn get_meta(&self) -> LeafMeta { + LeafMeta { + name: "Merke Respondent Leaf", + identifier: "dev.unshell.test.merkle.respondent", + version: "v0", + authors: alloc::vec!["ASTATIN3"], + } + } + + fn update(&mut self, endpoint: &mut Endpoint) { + self.open_stream_from_request(endpoint); + self.send_one_response_frame(endpoint); + } +} + +impl MerkleRespondentLeaf { + /// Opens one response stream from the first pending local request. + fn open_stream_from_request(&mut self, endpoint: &mut Endpoint) { + if self.active_stream.is_some() { + return; + } + + let mut request = None; + endpoint.take_inbound_clear(ENDPOINT_RESPONDENT, |packet| { + if request.is_none() { + request = Some((packet.hook_id, packet.procedure_id, packet.data.clone())); + } + }); + + let Some((hook_id, procedure_id, data)) = request else { + return; + }; + + let frames = self.frames_for_request(procedure_id, &data); + + self.report.borrow_mut().requests_seen.push(procedure_id); + if !frames.is_empty() { + self.report.borrow_mut().streams_started += 1; + self.active_stream = Some(ResponseStream::new(hook_id, frames)); + } + } + + /// Builds response frames for one request procedure. + fn frames_for_request( + &self, + procedure_id: u32, + data: &[u8], + ) -> Vec { + match procedure_id { + PROC_GET_ROOT_HASH => alloc::vec![root_hash_frame(self.remote.root_hash())], + PROC_GET_CHILD_HASHES => { + let node_id = decode_u32(data).expect("child hash request node id"); + self.remote + .child_summaries(node_id) + .into_iter() + .map(child_hash_frame) + .collect() + } + PROC_GET_BLOCK_STREAM => { + let block_id = decode_u32(data).expect("block stream request block id"); + let chunks = self.remote.block_chunks(block_id); + let total = chunks.len() as u32; + chunks + .into_iter() + .enumerate() + .map(|(index, data)| { + block_chunk_frame(BlockChunk { + block_id, + index: index as u32, + total, + data, + }) + }) + .collect() + } + _ => Vec::new(), + } + } + + /// Sends at most one response frame per update loop. + fn send_one_response_frame(&mut self, endpoint: &mut Endpoint) { + let Some(stream) = self.active_stream.as_mut() else { + return; + }; + + if stream.is_empty() { + self.active_stream = None; + return; + } + + let packet = stream.next_packet().expect("active stream frame"); + if endpoint.add_outbound(packet).is_err() { + return; + } + + self.report.borrow_mut().frames_sent += 1; + stream.advance(); + + if stream.is_complete() { + self.report.borrow_mut().streams_completed += 1; + self.active_stream = None; + } + } +} diff --git a/src/protocol/tests/merkle_sync/leaves/transport.rs b/src/protocol/tests/merkle_sync/leaves/transport.rs new file mode 100644 index 0000000..05fbae4 --- /dev/null +++ b/src/protocol/tests/merkle_sync/leaves/transport.rs @@ -0,0 +1,79 @@ +use alloc::vec::Vec; + +use crossbeam_channel::{Receiver, Sender}; + +use crate::protocol::{Endpoint, Leaf, Packet}; + +#[cfg(feature = "interface")] +use crate::protocol::LeafMeta; + +use super::super::constants::LEAF_MOCK_CONNECTION; + +/// Leaf that simulates a serialized transport connection with crossbeam channels. +/// +/// This is intentionally tiny and reusable. Both endpoints in the Merkle test have +/// exactly one of these leaves, giving the requested four-leaf topology: caller, +/// respondent, and two mock connections. +pub(crate) struct MockConnectionLeaf { + pub(crate) tx: Sender>, + pub(crate) rx: Receiver>, + pub(crate) remote_id: u32, + pub(crate) is_authority: bool, + pub(crate) started: bool, +} + +impl MockConnectionLeaf { + /// Creates one side of a mock connection. + pub(crate) fn new( + tx: Sender>, + rx: Receiver>, + remote_id: u32, + is_authority: bool, + ) -> Self { + Self { + tx, + rx, + remote_id, + is_authority, + started: false, + } + } +} + +impl Leaf for MockConnectionLeaf { + fn get_id(&self) -> u32 { + LEAF_MOCK_CONNECTION + } + + #[cfg(feature = "interface")] + fn get_meta(&self) -> LeafMeta { + LeafMeta { + name: "Merke Connection Leaf", + identifier: "dev.unshell.test.merkle.connection", + version: "v0", + authors: alloc::vec!["ASTATIN3"], + } + } + + fn update(&mut self, endpoint: &mut Endpoint) { + if !self.started { + endpoint.add_connection(self.remote_id, self.is_authority); + self.started = true; + } + + while !self.rx.is_empty() { + let data = self.rx.recv().unwrap(); + + // Mock transports move untrusted bytes. Malformed frames are dropped so + // the sync state machine is tested only after packet parsing succeeds. + if let Ok(packet) = Packet::deserialize(&data) { + let _ = endpoint.add_inbound_from(self.remote_id, packet); + } + } + + endpoint.take_outbound_clear(self.remote_id, |packet| { + let data = packet.serialize().unwrap(); + let _ = self.tx.send(data); + }); + } +} diff --git a/src/protocol/tests/mod.rs b/src/protocol/tests/mod.rs new file mode 100644 index 0000000..f60b960 --- /dev/null +++ b/src/protocol/tests/mod.rs @@ -0,0 +1,5 @@ +mod endpoint; +mod integration; +mod merkle_sync; +mod packet; +mod support; diff --git a/src/protocol/tests/oneshot/mod.rs b/src/protocol/tests/oneshot/mod.rs deleted file mode 100644 index e319bcb..0000000 --- a/src/protocol/tests/oneshot/mod.rs +++ /dev/null @@ -1,491 +0,0 @@ -mod streams; -mod support; - -use crate::protocol::{Endpoint, EndpointError, Leaf, RouteDirection}; - -use alloc::vec; - -use support::{ - CommsLeaf, ControllerLeaf, ENDPOINT_A, ENDPOINT_B, ENDPOINT_C, ResponderLeaf, - assert_hook_present, assert_hook_removed, echo_packet, echo_packet_with_end, endpoint_at, - single_inbound_packet, single_outbound_packet, -}; - -#[test] -fn test_oneshot() { - let (tx_a, rx_a) = crossbeam_channel::unbounded(); - let (tx_b, rx_b) = crossbeam_channel::unbounded(); - - let mut endpoint_a = Endpoint::new(ENDPOINT_A); - let mut controller_a = ControllerLeaf { has_run: false }; - let mut comms_a = CommsLeaf { - tx: tx_b, - rx: rx_a, - remote_id: ENDPOINT_B, - is_authority: false, - started: false, - }; - endpoint_a.path = vec![ENDPOINT_A]; - - let mut endpoint_b = Endpoint::new(ENDPOINT_B); - let mut responder_b = ResponderLeaf; - let mut comms_b = CommsLeaf { - tx: tx_a, - rx: rx_b, - remote_id: ENDPOINT_A, - is_authority: true, - started: false, - }; - endpoint_b.path = vec![ENDPOINT_A, ENDPOINT_B]; - - // Connections are registered routing state. The comms leaves also insert them - // during updates, but the first application packet should not depend on leaf order. - endpoint_a.add_connection(ENDPOINT_B, false); - endpoint_b.add_connection(ENDPOINT_A, true); - - // Cycle 1: A sends request to B - controller_a.update(&mut endpoint_a); - comms_a.update(&mut endpoint_a); - responder_b.update(&mut endpoint_b); - comms_b.update(&mut endpoint_b); - - // Cycle 2: B receives request and sends response to A - responder_b.update(&mut endpoint_b); - comms_b.update(&mut endpoint_b); - controller_a.update(&mut endpoint_a); - comms_a.update(&mut endpoint_a); - - // Cycle 3: A's CommsLeaf needs one more update to pull the packet from the channel - // and put it into the inbound queue. - controller_a.update(&mut endpoint_a); - comms_a.update(&mut endpoint_a); - - // Assertions on state - assert!( - Endpoint::route_contains(ENDPOINT_A, &endpoint_a.inbound), - "Endpoint A should have received response" - ); - assert_eq!( - Endpoint::route_get(ENDPOINT_A, &endpoint_a.inbound) - .unwrap() - .len(), - 1, - "Endpoint A should have exactly one packet" - ); - let response = &Endpoint::route_get(ENDPOINT_A, &endpoint_a.inbound) - .unwrap() - .front() - .unwrap(); - assert!(response.end_hook); - assert_eq!(response.data, "ABC123".as_bytes()); - assert!( - endpoint_b.hook_count() == 0, - "responder hook should be cleaned after the upward response" - ); - // assert_eq!(response.hook_id, HOOK_ECHO); -} - -#[test] -fn inbound_downward_packet_for_local_endpoint_opens_hook() { - let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); - let hook_id = endpoint.get_hook_id(); - endpoint.add_connection(ENDPOINT_A, true); - - endpoint - .add_inbound_from( - ENDPOINT_A, - echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id), - ) - .unwrap(); - - let packet = single_inbound_packet(&endpoint, ENDPOINT_B); - assert!(!packet.end_hook); - assert_eq!(packet.path, vec![ENDPOINT_A, ENDPOINT_B]); - assert_hook_present(&endpoint, hook_id); - assert_eq!(endpoint.hook_peer(hook_id), Some(ENDPOINT_A)); - assert!(Endpoint::routes_is_empty(&endpoint.outbound)); -} - -#[test] -fn outbound_packet_for_local_endpoint_is_delivered_locally() { - let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); - let hook_id = endpoint.get_hook_id(); - - endpoint - .add_outbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id)) - .unwrap(); - - let packet = single_inbound_packet(&endpoint, ENDPOINT_B); - assert!(!packet.end_hook); - assert_eq!(packet.data, "ABC123".as_bytes()); - assert_hook_removed(&endpoint, hook_id); - assert!(Endpoint::routes_is_empty(&endpoint.outbound)); -} - -#[test] -fn inbound_downward_packet_routes_to_immediate_child() { - let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); - let hook_id = endpoint.get_hook_id(); - endpoint.add_connection(ENDPOINT_A, true); - endpoint.add_connection(ENDPOINT_C, false); - - endpoint - .add_inbound_from( - ENDPOINT_A, - echo_packet(vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C], hook_id), - ) - .unwrap(); - - let packet = single_outbound_packet(&endpoint, ENDPOINT_C); - assert!(!packet.end_hook); - assert_eq!(packet.path, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]); - assert_hook_present(&endpoint, hook_id); - assert_eq!(endpoint.hook_peer(hook_id), Some(ENDPOINT_C)); - assert!(!Endpoint::route_contains(ENDPOINT_A, &endpoint.outbound)); -} - -#[test] -fn outbound_downward_packet_routes_to_immediate_child() { - let mut endpoint = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]); - let hook_id = endpoint.get_hook_id(); - endpoint.accept_hook(hook_id, ENDPOINT_B); - endpoint.add_connection(ENDPOINT_B, false); - - endpoint - .add_outbound(echo_packet_with_end( - vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C], - hook_id, - true, - )) - .unwrap(); - - let packet = single_outbound_packet(&endpoint, ENDPOINT_B); - assert!(packet.end_hook); - assert_eq!(packet.path, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]); - assert_hook_removed(&endpoint, hook_id); - assert!(!Endpoint::route_contains(ENDPOINT_C, &endpoint.outbound)); -} - -#[test] -fn inbound_upward_packet_with_hook_routes_to_parent() { - let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); - let hook_id = endpoint.get_hook_id(); - endpoint.accept_hook(hook_id, ENDPOINT_C); - endpoint.add_connection(ENDPOINT_A, true); - endpoint.add_connection(ENDPOINT_C, false); - - endpoint - .add_inbound_from( - ENDPOINT_C, - echo_packet_with_end(vec![ENDPOINT_A], hook_id, true), - ) - .unwrap(); - - let packet = single_outbound_packet(&endpoint, ENDPOINT_A); - assert!(packet.end_hook); - assert_eq!(packet.hook_id, hook_id); - assert_hook_removed(&endpoint, hook_id); - assert!(!Endpoint::route_contains(ENDPOINT_C, &endpoint.outbound)); -} - -#[test] -fn inbound_upward_packet_without_hook_is_rejected() { - let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); - let hook_id = endpoint.get_hook_id(); - endpoint.add_connection(ENDPOINT_A, true); - endpoint.add_connection(ENDPOINT_C, false); - - let error = endpoint - .add_inbound_from( - ENDPOINT_C, - echo_packet_with_end(vec![ENDPOINT_A], hook_id, true), - ) - .unwrap_err(); - - assert!(matches!( - error, - EndpointError::UnknownHook { hook_id: observed_hook_id } if observed_hook_id == hook_id - )); - assert!(Endpoint::routes_is_empty(&endpoint.inbound)); - assert!(Endpoint::routes_is_empty(&endpoint.outbound)); -} - -#[test] -fn forged_upward_packet_with_unknown_hook_is_rejected() { - let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); - endpoint.accept_hook(7, ENDPOINT_C); - endpoint.add_connection(ENDPOINT_A, true); - endpoint.add_connection(ENDPOINT_C, false); - - let error = endpoint - .add_inbound_from(ENDPOINT_C, echo_packet_with_end(vec![ENDPOINT_A], 99, true)) - .unwrap_err(); - - assert!(matches!(error, EndpointError::UnknownHook { hook_id: 99 })); - assert_hook_present(&endpoint, 7); - assert!(Endpoint::routes_is_empty(&endpoint.outbound)); -} - -#[test] -fn forged_sideways_packet_is_rejected_as_incorrect_path() { - let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); - let hook_id = endpoint.get_hook_id(); - endpoint.accept_hook(hook_id, ENDPOINT_A); - endpoint.add_connection(ENDPOINT_A, true); - - let error = endpoint - .add_inbound_from( - ENDPOINT_A, - echo_packet(vec![ENDPOINT_A, ENDPOINT_C], hook_id), - ) - .unwrap_err(); - - assert!(matches!(error, EndpointError::DestinationOutsideLocalTree)); - assert_hook_present(&endpoint, hook_id); - assert!(Endpoint::routes_is_empty(&endpoint.inbound)); - assert!(Endpoint::routes_is_empty(&endpoint.outbound)); -} - -#[test] -fn malformed_frame_is_dropped_by_comms_leaf() { - let (tx_to_endpoint, rx_for_endpoint) = crossbeam_channel::unbounded(); - let (tx_unused, _rx_unused) = crossbeam_channel::unbounded(); - let mut endpoint = Endpoint::new(ENDPOINT_B); - let mut comms = CommsLeaf { - tx: tx_unused, - rx: rx_for_endpoint, - remote_id: ENDPOINT_A, - is_authority: true, - started: false, - }; - endpoint.path = vec![ENDPOINT_A, ENDPOINT_B]; - - tx_to_endpoint.send(vec![0, 1, 2, 3]).unwrap(); - comms.update(&mut endpoint); - - assert!(Endpoint::routes_is_empty(&endpoint.inbound)); - assert!(Endpoint::routes_is_empty(&endpoint.outbound)); -} - -#[test] -fn malformed_frame_does_not_block_following_valid_packet() { - let (tx_to_endpoint, rx_for_endpoint) = crossbeam_channel::unbounded(); - let (tx_unused, _rx_unused) = crossbeam_channel::unbounded(); - let hook_id = 42; - let mut endpoint = Endpoint::new(ENDPOINT_B); - let mut comms = CommsLeaf { - tx: tx_unused, - rx: rx_for_endpoint, - remote_id: ENDPOINT_A, - is_authority: true, - started: false, - }; - endpoint.path = vec![ENDPOINT_A, ENDPOINT_B]; - - tx_to_endpoint.send(vec![0, 1, 2, 3]).unwrap(); - tx_to_endpoint - .send( - echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id) - .serialize() - .unwrap(), - ) - .unwrap(); - comms.update(&mut endpoint); - - let packet = single_inbound_packet(&endpoint, ENDPOINT_B); - assert!(!packet.end_hook); - assert_eq!(packet.hook_id, hook_id); - assert_hook_present(&endpoint, hook_id); -} - -#[test] -fn forged_frame_without_required_hook_is_dropped_by_comms_leaf() { - let (tx_to_endpoint, rx_for_endpoint) = crossbeam_channel::unbounded(); - let (tx_unused, _rx_unused) = crossbeam_channel::unbounded(); - let mut endpoint = Endpoint::new(ENDPOINT_B); - let mut comms = CommsLeaf { - tx: tx_unused, - rx: rx_for_endpoint, - remote_id: ENDPOINT_C, - is_authority: false, - started: false, - }; - endpoint.path = vec![ENDPOINT_A, ENDPOINT_B]; - endpoint.accept_hook(7, ENDPOINT_C); - endpoint.add_connection(ENDPOINT_A, true); - - tx_to_endpoint - .send( - echo_packet_with_end(vec![ENDPOINT_A], 12, true) - .serialize() - .unwrap(), - ) - .unwrap(); - comms.update(&mut endpoint); - - assert_hook_present(&endpoint, 7); - assert!(Endpoint::routes_is_empty(&endpoint.inbound)); - assert!(Endpoint::routes_is_empty(&endpoint.outbound)); -} - -#[test] -fn upward_outbound_without_hook_is_rejected() { - let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); - endpoint.accept_hook(7, ENDPOINT_A); - endpoint.add_connection(ENDPOINT_A, true); - - let new_hook = endpoint.get_hook_id(); - - let error = endpoint - .add_outbound(echo_packet_with_end(vec![ENDPOINT_A], new_hook, true)) - .unwrap_err(); - - assert!(matches!( - error, - EndpointError::UnknownHook { hook_id: observed_hook_id } if observed_hook_id == new_hook - )); - assert_hook_present(&endpoint, 7); - assert!(Endpoint::routes_is_empty(&endpoint.outbound)); -} - -#[test] -fn downward_outbound_without_hook_is_allowed() { - let mut endpoint = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]); - endpoint.add_connection(ENDPOINT_B, false); - - let new_hook = endpoint.get_hook_id(); - - endpoint - .add_outbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], new_hook)) - .unwrap(); - - assert_eq!( - Endpoint::route_get(ENDPOINT_B, &endpoint.outbound) - .unwrap() - .len(), - 1 - ); - assert_hook_present(&endpoint, new_hook); - assert_eq!(endpoint.hook_peer(new_hook), Some(ENDPOINT_B)); -} - -#[test] -fn deeper_upward_route_uses_parent_as_next_hop() { - let mut endpoint = endpoint_at(ENDPOINT_C, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]); - let new_hook = endpoint.get_hook_id(); - - endpoint.accept_hook(new_hook, ENDPOINT_B); - endpoint.add_connection(ENDPOINT_B, true); - - endpoint - .add_outbound(echo_packet_with_end(vec![ENDPOINT_A], new_hook, true)) - .unwrap(); - - assert!(Endpoint::route_contains(ENDPOINT_B, &endpoint.outbound)); - assert!(!Endpoint::route_contains(ENDPOINT_A, &endpoint.outbound)); - assert_hook_removed(&endpoint, new_hook); -} - -#[test] -fn downward_route_without_connection_is_rejected() { - let mut endpoint = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]); - let hook_id = endpoint.get_hook_id(); - - let error = endpoint - .add_outbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id)) - .unwrap_err(); - - assert!(matches!( - error, - EndpointError::MissingConnection { - next_hop: ENDPOINT_B, - direction: RouteDirection::Downward, - } - )); - assert_hook_removed(&endpoint, hook_id); - assert!(Endpoint::routes_is_empty(&endpoint.outbound)); -} - -#[test] -fn upward_route_without_connection_is_rejected_even_with_hook() { - let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); - let hook_id = endpoint.get_hook_id(); - endpoint.accept_hook(hook_id, ENDPOINT_A); - - let error = endpoint - .add_outbound(echo_packet_with_end(vec![ENDPOINT_A], hook_id, true)) - .unwrap_err(); - - assert!(matches!( - error, - EndpointError::MissingConnection { - next_hop: ENDPOINT_A, - direction: RouteDirection::Upward, - } - )); - assert_hook_present(&endpoint, hook_id); - assert!(Endpoint::routes_is_empty(&endpoint.outbound)); -} - -#[test] -fn end_hook_removes_hook_after_packet_is_queued() { - let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); - let hook_id = endpoint.get_hook_id(); - endpoint.accept_hook(hook_id, ENDPOINT_A); - endpoint.add_connection(ENDPOINT_A, true); - - endpoint - .add_outbound(echo_packet_with_end(vec![ENDPOINT_A], hook_id, true)) - .unwrap(); - - assert_hook_removed(&endpoint, hook_id); - assert_eq!( - single_outbound_packet(&endpoint, ENDPOINT_A).hook_id, - hook_id - ); -} - -#[test] -fn failed_end_hook_route_keeps_hook_state() { - let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); - let hook_id = endpoint.get_hook_id(); - endpoint.accept_hook(hook_id, ENDPOINT_A); - - let error = endpoint - .add_outbound(echo_packet_with_end(vec![ENDPOINT_A], hook_id, true)) - .unwrap_err(); - - assert!(matches!( - error, - EndpointError::MissingConnection { - next_hop: ENDPOINT_A, - direction: RouteDirection::Upward, - } - )); - assert_hook_present(&endpoint, hook_id); - assert!(Endpoint::routes_is_empty(&endpoint.outbound)); -} - -#[test] -fn inbound_without_absolute_path_is_rejected() { - let mut endpoint = Endpoint::new(ENDPOINT_A); - - let error = endpoint - .add_inbound(echo_packet(vec![ENDPOINT_A], 1)) - .unwrap_err(); - - assert!(matches!(error, EndpointError::EndpointPathUnset)); - assert!(Endpoint::routes_is_empty(&endpoint.inbound)); -} - -#[test] -fn outbound_without_absolute_path_is_rejected() { - let mut endpoint = Endpoint::new(ENDPOINT_A); - - let error = endpoint - .add_outbound(echo_packet(vec![ENDPOINT_A], 1)) - .unwrap_err(); - - assert!(matches!(error, EndpointError::EndpointPathUnset)); - assert!(Endpoint::routes_is_empty(&endpoint.outbound)); -} diff --git a/src/protocol/tests/oneshot/support.rs b/src/protocol/tests/oneshot/support.rs deleted file mode 100644 index c1af87a..0000000 --- a/src/protocol/tests/oneshot/support.rs +++ /dev/null @@ -1,205 +0,0 @@ -use crate::protocol::{Endpoint, Leaf, Packet}; - -#[cfg(feature = "interface")] -use crate::protocol::LeafMeta; - -use alloc::{vec, vec::Vec}; -use crossbeam_channel::{Receiver, Sender}; - -pub(super) const ENDPOINT_A: u32 = 0; -pub(super) const ENDPOINT_B: u32 = 1; -pub(super) const ENDPOINT_C: u32 = 2; - -const LEAF_CONTROLLER: u32 = 100; -const LEAF_COMMS: u32 = 101; -const LEAF_RESPONDER: u32 = 102; - -/// Builds a test packet whose route is the only field varied by routing tests. -/// -/// Keeping the payload stable makes each assertion about endpoint behavior rather -/// than packet construction, which is important because forged and malformed cases -/// should fail before any leaf-level procedure handling would matter. -pub(super) fn echo_packet(path: Vec, hook_id: u16) -> Packet { - echo_packet_with_end(path, hook_id, false) -} - -/// Builds a test packet with an explicit hook-lifetime marker. -pub(super) fn echo_packet_with_end(path: Vec, hook_id: u16, end_hook: bool) -> Packet { - Packet { - hook_id, - end_hook, - path, - procedure_id: 1, - data: "ABC123".as_bytes().to_vec(), - } -} - -/// Creates a bare endpoint at a known absolute path. -/// -/// Most routing tests do not need leaves; they only need the endpoint's local path, -/// connection table, and hook table. This helper keeps that setup explicit without -/// hiding the routing state that each test is validating. -pub(super) fn endpoint_at(id: u32, path: Vec) -> Endpoint { - let mut endpoint = Endpoint::new(id); - endpoint.path = path; - endpoint -} - -/// Returns the only outbound packet queued for `next_hop`. -/// -/// Routing bugs often show up as packets being sent to the final destination rather -/// than the immediate neighbor. Tests use this helper to assert both that exactly one -/// packet exists and that it was queued for the expected adjacent endpoint. -pub(super) fn single_outbound_packet(endpoint: &Endpoint, next_hop: u32) -> &Packet { - let queue = Endpoint::route_get(next_hop, &endpoint.outbound) - .unwrap_or_else(|| panic!("expected one outbound queue for {next_hop}")); - assert_eq!(queue.len(), 1, "expected exactly one outbound packet"); - queue.front().unwrap() -} - -/// Returns the only inbound packet delivered to `local_id`. -/// -/// Local delivery is intentionally separate from transit forwarding, so the tests -/// assert against the local inbound queue instead of only checking that routing did -/// not produce an error. -pub(super) fn single_inbound_packet(endpoint: &Endpoint, local_id: u32) -> &Packet { - let queue = Endpoint::route_get(local_id, &endpoint.inbound) - .unwrap_or_else(|| panic!("expected one inbound queue for {local_id}")); - assert_eq!(queue.len(), 1, "expected exactly one inbound packet"); - queue.front().unwrap() -} - -/// Asserts that local hook state still contains `hook_id`. -/// -/// Tests use this instead of open-coded map checks so every lifecycle assertion -/// explains the intended routing invariant when it fails. -pub(super) fn assert_hook_present(endpoint: &Endpoint, hook_id: u16) { - assert!( - endpoint.has_hook(hook_id), - "expected hook {hook_id} to remain registered" - ); -} - -/// Asserts that local hook state no longer contains `hook_id`. -/// -/// Upward `end_hook` packets are the only cases that should remove hook state; -/// downward and local packets with the same flag must leave hooks alone. -pub(super) fn assert_hook_removed(endpoint: &Endpoint, hook_id: u16) { - assert!( - !endpoint.has_hook(hook_id), - "expected hook {hook_id} to be cleaned up" - ); -} - -pub(super) struct ControllerLeaf { - pub(super) has_run: bool, -} - -pub(super) struct CommsLeaf { - pub(super) tx: Sender>, - pub(super) rx: Receiver>, - - pub(super) remote_id: u32, - pub(super) is_authority: bool, - pub(super) started: bool, -} - -pub(super) struct ResponderLeaf; - -impl Leaf for ControllerLeaf { - fn get_id(&self) -> u32 { - LEAF_CONTROLLER - } - - #[cfg(feature = "interface")] - fn get_meta(&self) -> LeafMeta { - LeafMeta { - name: "Controller Leaf", - identifier: "dev.unshell.test.controller_leaf", - version: "v0", - authors: vec!["ASTATIN3"], - } - } - - fn update(&mut self, endpoint: &mut Endpoint) { - if !self.has_run { - // The controller starts exactly one request so the end-to-end test can - // assert deterministic routing without accumulating retries. - let hook_id = endpoint.get_hook_id(); - let packet = echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id); - let _ = endpoint.add_outbound(packet); - self.has_run = true; - } - } -} - -impl Leaf for CommsLeaf { - fn get_id(&self) -> u32 { - LEAF_COMMS - } - - #[cfg(feature = "interface")] - fn get_meta(&self) -> LeafMeta { - LeafMeta { - name: "Comms Leaf", - identifier: "dev.unshell.test.comms_leaf", - version: "v0", - authors: vec!["ASTATIN3"], - } - } - - fn update(&mut self, endpoint: &mut Endpoint) { - if !self.started { - endpoint.add_connection(self.remote_id, self.is_authority); - self.started = true; - } - - while !self.rx.is_empty() { - let data = self.rx.recv().unwrap(); - - // Transport bytes are untrusted. Dropping malformed frames here keeps - // the oneshot harness faithful to a router boundary: invalid wire data - // must not panic or poison later valid packets on the same connection. - if let Ok(packet) = Packet::deserialize(&data) { - let _ = endpoint.add_inbound_from(self.remote_id, packet); - } - } - - endpoint.take_outbound_clear(self.remote_id, |packet| { - let data = packet.serialize().unwrap(); - let _ = self.tx.send(data); - }); - } -} - -impl Leaf for ResponderLeaf { - fn get_id(&self) -> u32 { - LEAF_RESPONDER - } - - #[cfg(feature = "interface")] - fn get_meta(&self) -> LeafMeta { - LeafMeta { - name: "Responder Leaf", - identifier: "dev.unshell.test.responder_leaf", - version: "v0", - authors: vec!["ASTATIN3"], - } - } - - fn update(&mut self, endpoint: &mut Endpoint) { - let local_id = endpoint.path.last().cloned().unwrap_or(0); - let mut packets = Vec::new(); - - endpoint.take_inbound_clear(local_id, |packet| { - let mut response = echo_packet_with_end(vec![ENDPOINT_A], packet.hook_id, true); - response.hook_id = packet.hook_id; - response.data = packet.data.clone(); - packets.push(response); - }); - - for packet in packets { - let _ = endpoint.add_outbound(packet); - } - } -} diff --git a/src/protocol/tests/support/assertions.rs b/src/protocol/tests/support/assertions.rs new file mode 100644 index 0000000..f17aca8 --- /dev/null +++ b/src/protocol/tests/support/assertions.rs @@ -0,0 +1,23 @@ +use crate::protocol::Endpoint; + +/// Asserts that local hook state still contains `hook_id`. +/// +/// Tests use this instead of open-coded map checks so every lifecycle assertion +/// explains the intended routing invariant when it fails. +pub(crate) fn assert_hook_present(endpoint: &Endpoint, hook_id: u16) { + assert!( + endpoint.has_hook(hook_id), + "expected hook {hook_id} to remain registered" + ); +} + +/// Asserts that local hook state no longer contains `hook_id`. +/// +/// Upward `end_hook` packets are the only cases that should remove hook state; +/// downward and local packets with the same flag must leave hooks alone. +pub(crate) fn assert_hook_removed(endpoint: &Endpoint, hook_id: u16) { + assert!( + !endpoint.has_hook(hook_id), + "expected hook {hook_id} to be cleaned up" + ); +} diff --git a/src/protocol/tests/support/endpoints.rs b/src/protocol/tests/support/endpoints.rs new file mode 100644 index 0000000..ae45ede --- /dev/null +++ b/src/protocol/tests/support/endpoints.rs @@ -0,0 +1,42 @@ +use alloc::vec::Vec; + +use crate::protocol::{Endpoint, Packet}; + +pub(crate) const ENDPOINT_A: u32 = 0; +pub(crate) const ENDPOINT_B: u32 = 1; +pub(crate) const ENDPOINT_C: u32 = 2; + +/// Creates a bare endpoint at a known absolute path. +/// +/// Most routing tests do not need leaves; they only need the endpoint's local path, +/// connection table, and hook table. This helper keeps that setup explicit without +/// hiding the routing state that each test is validating. +pub(crate) fn endpoint_at(id: u32, path: Vec) -> Endpoint { + let mut endpoint = Endpoint::new(id); + endpoint.path = path; + endpoint +} + +/// Returns the only outbound packet queued for `next_hop`. +/// +/// Routing bugs often show up as packets being sent to the final destination rather +/// than the immediate neighbor. Tests use this helper to assert both that exactly one +/// packet exists and that it was queued for the expected adjacent endpoint. +pub(crate) fn single_outbound_packet(endpoint: &Endpoint, next_hop: u32) -> &Packet { + let queue = Endpoint::route_get(next_hop, &endpoint.outbound) + .unwrap_or_else(|| panic!("expected one outbound queue for {next_hop}")); + assert_eq!(queue.len(), 1, "expected exactly one outbound packet"); + queue.front().unwrap() +} + +/// Returns the only inbound packet delivered to `local_id`. +/// +/// Local delivery is intentionally separate from transit forwarding, so the tests +/// assert against the local inbound queue instead of only checking that routing did +/// not produce an error. +pub(crate) fn single_inbound_packet(endpoint: &Endpoint, local_id: u32) -> &Packet { + let queue = Endpoint::route_get(local_id, &endpoint.inbound) + .unwrap_or_else(|| panic!("expected one inbound queue for {local_id}")); + assert_eq!(queue.len(), 1, "expected exactly one inbound packet"); + queue.front().unwrap() +} diff --git a/src/protocol/tests/support/mod.rs b/src/protocol/tests/support/mod.rs new file mode 100644 index 0000000..c1eafbf --- /dev/null +++ b/src/protocol/tests/support/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod assertions; +pub(crate) mod endpoints; +pub(crate) mod packets; +pub(crate) mod transport; diff --git a/src/protocol/tests/support/packets.rs b/src/protocol/tests/support/packets.rs new file mode 100644 index 0000000..900ae3d --- /dev/null +++ b/src/protocol/tests/support/packets.rs @@ -0,0 +1,23 @@ +use alloc::vec::Vec; + +use crate::protocol::Packet; + +/// Builds a test packet whose route is the only field varied by routing tests. +/// +/// Keeping the payload stable makes each assertion about endpoint behavior rather +/// than packet construction, which is important because forged and malformed cases +/// should fail before any leaf-level procedure handling would matter. +pub(crate) fn echo_packet(path: Vec, hook_id: u16) -> Packet { + echo_packet_with_end(path, hook_id, false) +} + +/// Builds a test packet with an explicit hook-lifetime marker. +pub(crate) fn echo_packet_with_end(path: Vec, hook_id: u16, end_hook: bool) -> Packet { + Packet { + hook_id, + end_hook, + path, + procedure_id: 1, + data: "ABC123".as_bytes().to_vec(), + } +} diff --git a/src/protocol/tests/support/transport.rs b/src/protocol/tests/support/transport.rs new file mode 100644 index 0000000..cb4f008 --- /dev/null +++ b/src/protocol/tests/support/transport.rs @@ -0,0 +1,63 @@ +use alloc::vec::Vec; + +use crossbeam_channel::{Receiver, Sender}; + +use crate::protocol::{Endpoint, Leaf, Packet}; + +#[cfg(feature = "interface")] +use crate::protocol::LeafMeta; + +const LEAF_COMMS: u32 = 101; + +/// Mock transport leaf that serializes outbound packets through a channel pair. +/// +/// This is intentionally shared by protocol integration tests: it is the boundary +/// where structured packets become untrusted bytes and malformed frames get dropped +/// before reaching endpoint routing. +pub(crate) struct CommsLeaf { + pub(crate) tx: Sender>, + pub(crate) rx: Receiver>, + + pub(crate) remote_id: u32, + pub(crate) is_authority: bool, + pub(crate) started: bool, +} + +impl Leaf for CommsLeaf { + fn get_id(&self) -> u32 { + LEAF_COMMS + } + + #[cfg(feature = "interface")] + fn get_meta(&self) -> LeafMeta { + LeafMeta { + name: "Comms Leaf", + identifier: "dev.unshell.test.comms_leaf", + version: "v0", + authors: alloc::vec!["ASTATIN3"], + } + } + + fn update(&mut self, endpoint: &mut Endpoint) { + if !self.started { + endpoint.add_connection(self.remote_id, self.is_authority); + self.started = true; + } + + while !self.rx.is_empty() { + let data = self.rx.recv().unwrap(); + + // Transport bytes are untrusted. Dropping malformed frames here keeps + // integration harnesses faithful to a router boundary: invalid wire data + // must not panic or poison later valid packets on the same connection. + if let Ok(packet) = Packet::deserialize(&data) { + let _ = endpoint.add_inbound_from(self.remote_id, packet); + } + } + + endpoint.take_outbound_clear(self.remote_id, |packet| { + let data = packet.serialize().unwrap(); + let _ = self.tx.send(data); + }); + } +} diff --git a/unshell-leaves/leaf-pty/src/tests/session.rs b/unshell-leaves/leaf-pty/src/tests/session.rs deleted file mode 100644 index a8a30be..0000000 --- a/unshell-leaves/leaf-pty/src/tests/session.rs +++ /dev/null @@ -1,282 +0,0 @@ -use alloc::{vec, vec::Vec}; - -use unshell::protocol::{Leaf, Packet}; - -use crate::{ - FakePtyLeaf, FakePtyState, OP_ABORT, OP_ERROR, OP_EXIT, OP_INPUT, OP_OUTPUT, OP_STDIN_EOF, - OP_TERMINATE, pty_open_packet, -}; - -use super::support::{ - ENDPOINT_A, ENDPOINT_B, PROC_OTHER, assert_frame, assert_hook_present, assert_hook_removed, - assert_opened, drain_parent_pty_packets, endpoint_at, has_frame, open_pty_session, - pty_endpoints, send_downward_frame, transfer_packets, -}; - -#[test] -fn open_pty_paves_hook_and_creates_session() { - let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); - let mut leaf = FakePtyLeaf::new(FakePtyState::new()); - - let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); - let packets = drain_parent_pty_packets(&mut endpoint_a); - - assert_eq!(leaf.active_session_count(), 1); - assert_eq!(leaf.state().active_count, 1); - assert_eq!(leaf.state().total_opened, 1); - assert_hook_present(&endpoint_a, hook_id); - assert_hook_present(&endpoint_b, hook_id); - assert_eq!(packets.len(), 1); - assert_opened(&packets[0], hook_id); -} - -#[test] -fn input_and_output_share_one_hook() { - let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); - let mut leaf = FakePtyLeaf::new(FakePtyState::new()); - let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); - drain_parent_pty_packets(&mut endpoint_a); - - send_downward_frame( - &mut endpoint_a, - &mut endpoint_b, - hook_id, - OP_INPUT, - b"hello", - false, - ); - leaf.update(&mut endpoint_b); - transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); - let packets = drain_parent_pty_packets(&mut endpoint_a); - - assert_eq!(packets.len(), 1); - assert_frame(&packets[0], hook_id, OP_OUTPUT, false, b"hello"); - assert_hook_present(&endpoint_a, hook_id); - assert_hook_present(&endpoint_b, hook_id); -} - -#[test] -fn stdin_eof_keeps_hook_until_exit() { - let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); - let mut leaf = FakePtyLeaf::new(FakePtyState::new()); - let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); - drain_parent_pty_packets(&mut endpoint_a); - - send_downward_frame( - &mut endpoint_a, - &mut endpoint_b, - hook_id, - OP_STDIN_EOF, - &[], - false, - ); - leaf.update(&mut endpoint_b); - transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); - - assert_eq!(leaf.state().last_stdin_eof_hook, Some(hook_id)); - assert!(drain_parent_pty_packets(&mut endpoint_a).is_empty()); - assert_hook_present(&endpoint_a, hook_id); - assert_hook_present(&endpoint_b, hook_id); - - send_downward_frame( - &mut endpoint_a, - &mut endpoint_b, - hook_id, - OP_TERMINATE, - &[], - false, - ); - leaf.update(&mut endpoint_b); - transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); - let packets = drain_parent_pty_packets(&mut endpoint_a); - - assert_eq!(packets.len(), 1); - assert_frame(&packets[0], hook_id, OP_EXIT, true, &[0]); - assert_eq!(leaf.active_session_count(), 0); - assert_hook_removed(&endpoint_a, hook_id); - assert_hook_removed(&endpoint_b, hook_id); -} - -#[test] -fn exit_end_hook_cleans_route_and_session() { - let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); - let mut leaf = FakePtyLeaf::new(FakePtyState::new()); - let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); - drain_parent_pty_packets(&mut endpoint_a); - - send_downward_frame( - &mut endpoint_a, - &mut endpoint_b, - hook_id, - OP_TERMINATE, - &[], - false, - ); - leaf.update(&mut endpoint_b); - transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); - let packets = drain_parent_pty_packets(&mut endpoint_a); - - assert_eq!(packets.len(), 1); - assert_frame(&packets[0], hook_id, OP_EXIT, true, &[0]); - assert_eq!(leaf.active_session_count(), 0); - assert_hook_removed(&endpoint_a, hook_id); - assert_hook_removed(&endpoint_b, hook_id); -} - -#[test] -fn failed_final_exit_route_closes_session_without_retry() { - let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); - let mut leaf = FakePtyLeaf::new(FakePtyState::new()); - let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); - drain_parent_pty_packets(&mut endpoint_a); - - send_downward_frame( - &mut endpoint_a, - &mut endpoint_b, - hook_id, - OP_TERMINATE, - &[], - false, - ); - endpoint_b.remove_connection(ENDPOINT_A, true); - leaf.update(&mut endpoint_b); - - assert_eq!(leaf.active_session_count(), 0); - assert_eq!(leaf.pending_packet_count(), 0); - assert_hook_removed(&endpoint_b, hook_id); - - endpoint_b.add_connection(ENDPOINT_A, true); - leaf.update(&mut endpoint_b); - transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); - let packets = drain_parent_pty_packets(&mut endpoint_a); - - assert!(packets.is_empty()); - assert_eq!(leaf.active_session_count(), 0); - assert_hook_present(&endpoint_a, hook_id); - assert_hook_removed(&endpoint_b, hook_id); -} - -#[test] -fn abort_downward_end_hook_closes_without_ack() { - let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); - let mut leaf = FakePtyLeaf::new(FakePtyState::new()); - let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); - drain_parent_pty_packets(&mut endpoint_a); - - send_downward_frame( - &mut endpoint_a, - &mut endpoint_b, - hook_id, - OP_ABORT, - &[], - true, - ); - leaf.update(&mut endpoint_b); - transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); - - assert_eq!(leaf.active_session_count(), 0); - assert!(drain_parent_pty_packets(&mut endpoint_a).is_empty()); - assert_hook_removed(&endpoint_a, hook_id); - assert_hook_removed(&endpoint_b, hook_id); -} - -#[test] -fn unknown_session_input_returns_error_end_hook() { - let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); - let mut leaf = FakePtyLeaf::new(FakePtyState::new()); - let hook_id = endpoint_a.get_hook_id(); - - send_downward_frame( - &mut endpoint_a, - &mut endpoint_b, - hook_id, - OP_INPUT, - b"orphan", - false, - ); - leaf.update(&mut endpoint_b); - transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); - let packets = drain_parent_pty_packets(&mut endpoint_a); - - assert_eq!(packets.len(), 1); - assert_frame(&packets[0], hook_id, OP_ERROR, true, b"unknown-session"); - assert_eq!(leaf.active_session_count(), 0); - assert_hook_removed(&endpoint_a, hook_id); - assert_hook_removed(&endpoint_b, hook_id); -} - -#[test] -fn two_pty_sessions_interleave_without_crossing_hooks() { - let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); - let mut leaf = FakePtyLeaf::new(FakePtyState::new()); - - let first_hook = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); - let second_hook = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); - drain_parent_pty_packets(&mut endpoint_a); - - send_downward_frame( - &mut endpoint_a, - &mut endpoint_b, - second_hook, - OP_INPUT, - b"second", - false, - ); - send_downward_frame( - &mut endpoint_a, - &mut endpoint_b, - first_hook, - OP_INPUT, - b"first", - false, - ); - leaf.update(&mut endpoint_b); - transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); - let packets = drain_parent_pty_packets(&mut endpoint_a); - - assert_eq!(leaf.active_session_count(), 2); - assert_eq!(packets.len(), 2); - assert!(has_frame(&packets, first_hook, OP_OUTPUT, b"first")); - assert!(has_frame(&packets, second_hook, OP_OUTPUT, b"second")); - assert_hook_present(&endpoint_a, first_hook); - assert_hook_present(&endpoint_a, second_hook); - assert_hook_present(&endpoint_b, first_hook); - assert_hook_present(&endpoint_b, second_hook); -} - -#[test] -fn pty_leaf_does_not_consume_other_leaf_packets() { - let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); - let mut leaf = FakePtyLeaf::new(FakePtyState::new()); - endpoint.add_connection(ENDPOINT_A, true); - - endpoint - .add_inbound_from(ENDPOINT_A, pty_open_packet(vec![ENDPOINT_A, ENDPOINT_B], 7)) - .unwrap(); - endpoint - .add_inbound_from( - ENDPOINT_A, - Packet { - hook_id: 8, - end_hook: false, - path: vec![ENDPOINT_A, ENDPOINT_B], - procedure_id: PROC_OTHER, - data: b"leave-me".to_vec(), - }, - ) - .unwrap(); - - leaf.update(&mut endpoint); - - let mut other_packets = Vec::new(); - endpoint.take_inbound_matching( - ENDPOINT_B, - |packet| packet.procedure_id == PROC_OTHER, - |packet| other_packets.push(packet), - ); - - assert_eq!(leaf.active_session_count(), 1); - assert_eq!(other_packets.len(), 1); - assert_eq!(other_packets[0].procedure_id, PROC_OTHER); - assert_eq!(other_packets[0].data, b"leave-me".to_vec()); -} diff --git a/unshell-leaves/leaf-pty/src/tests/session/concurrency.rs b/unshell-leaves/leaf-pty/src/tests/session/concurrency.rs new file mode 100644 index 0000000..a10b692 --- /dev/null +++ b/unshell-leaves/leaf-pty/src/tests/session/concurrency.rs @@ -0,0 +1,47 @@ +use unshell::protocol::Leaf; + +use crate::{FakePtyLeaf, FakePtyState, OP_INPUT, OP_OUTPUT}; + +use super::super::support::{ + ENDPOINT_A, ENDPOINT_B, assert_hook_present, drain_parent_pty_packets, has_frame, + open_pty_session, pty_endpoints, send_downward_frame, transfer_packets, +}; + +#[test] +fn two_pty_sessions_interleave_without_crossing_hooks() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + + let first_hook = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); + let second_hook = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); + drain_parent_pty_packets(&mut endpoint_a); + + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + second_hook, + OP_INPUT, + b"second", + false, + ); + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + first_hook, + OP_INPUT, + b"first", + false, + ); + leaf.update(&mut endpoint_b); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + let packets = drain_parent_pty_packets(&mut endpoint_a); + + assert_eq!(leaf.active_session_count(), 2); + assert_eq!(packets.len(), 2); + assert!(has_frame(&packets, first_hook, OP_OUTPUT, b"first")); + assert!(has_frame(&packets, second_hook, OP_OUTPUT, b"second")); + assert_hook_present(&endpoint_a, first_hook); + assert_hook_present(&endpoint_a, second_hook); + assert_hook_present(&endpoint_b, first_hook); + assert_hook_present(&endpoint_b, second_hook); +} diff --git a/unshell-leaves/leaf-pty/src/tests/session/failure.rs b/unshell-leaves/leaf-pty/src/tests/session/failure.rs new file mode 100644 index 0000000..c01078b --- /dev/null +++ b/unshell-leaves/leaf-pty/src/tests/session/failure.rs @@ -0,0 +1,41 @@ +use unshell::protocol::Leaf; + +use crate::{FakePtyLeaf, FakePtyState, OP_TERMINATE}; + +use super::super::support::{ + ENDPOINT_A, ENDPOINT_B, assert_hook_present, assert_hook_removed, drain_parent_pty_packets, + open_pty_session, pty_endpoints, send_downward_frame, transfer_packets, +}; + +#[test] +fn failed_final_exit_route_closes_session_without_retry() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); + drain_parent_pty_packets(&mut endpoint_a); + + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + hook_id, + OP_TERMINATE, + &[], + false, + ); + endpoint_b.remove_connection(ENDPOINT_A, true); + leaf.update(&mut endpoint_b); + + assert_eq!(leaf.active_session_count(), 0); + assert_eq!(leaf.pending_packet_count(), 0); + assert_hook_removed(&endpoint_b, hook_id); + + endpoint_b.add_connection(ENDPOINT_A, true); + leaf.update(&mut endpoint_b); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + let packets = drain_parent_pty_packets(&mut endpoint_a); + + assert!(packets.is_empty()); + assert_eq!(leaf.active_session_count(), 0); + assert_hook_present(&endpoint_a, hook_id); + assert_hook_removed(&endpoint_b, hook_id); +} diff --git a/unshell-leaves/leaf-pty/src/tests/session/filtering.rs b/unshell-leaves/leaf-pty/src/tests/session/filtering.rs new file mode 100644 index 0000000..becb18b --- /dev/null +++ b/unshell-leaves/leaf-pty/src/tests/session/filtering.rs @@ -0,0 +1,44 @@ +use alloc::{vec, vec::Vec}; + +use unshell::protocol::{Leaf, Packet}; + +use crate::{FakePtyLeaf, FakePtyState, pty_open_packet}; + +use super::super::support::{ENDPOINT_A, ENDPOINT_B, PROC_OTHER, endpoint_at}; + +#[test] +fn pty_leaf_does_not_consume_other_leaf_packets() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + endpoint.add_connection(ENDPOINT_A, true); + + endpoint + .add_inbound_from(ENDPOINT_A, pty_open_packet(vec![ENDPOINT_A, ENDPOINT_B], 7)) + .unwrap(); + endpoint + .add_inbound_from( + ENDPOINT_A, + Packet { + hook_id: 8, + end_hook: false, + path: vec![ENDPOINT_A, ENDPOINT_B], + procedure_id: PROC_OTHER, + data: b"leave-me".to_vec(), + }, + ) + .unwrap(); + + leaf.update(&mut endpoint); + + let mut other_packets = Vec::new(); + endpoint.take_inbound_matching( + ENDPOINT_B, + |packet| packet.procedure_id == PROC_OTHER, + |packet| other_packets.push(packet), + ); + + assert_eq!(leaf.active_session_count(), 1); + assert_eq!(other_packets.len(), 1); + assert_eq!(other_packets[0].procedure_id, PROC_OTHER); + assert_eq!(other_packets[0].data, b"leave-me".to_vec()); +} diff --git a/unshell-leaves/leaf-pty/src/tests/session/input_output.rs b/unshell-leaves/leaf-pty/src/tests/session/input_output.rs new file mode 100644 index 0000000..ae585d4 --- /dev/null +++ b/unshell-leaves/leaf-pty/src/tests/session/input_output.rs @@ -0,0 +1,33 @@ +use unshell::protocol::Leaf; + +use crate::{FakePtyLeaf, FakePtyState, OP_INPUT, OP_OUTPUT}; + +use super::super::support::{ + ENDPOINT_A, ENDPOINT_B, assert_frame, assert_hook_present, drain_parent_pty_packets, + open_pty_session, pty_endpoints, send_downward_frame, transfer_packets, +}; + +#[test] +fn input_and_output_share_one_hook() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); + drain_parent_pty_packets(&mut endpoint_a); + + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + hook_id, + OP_INPUT, + b"hello", + false, + ); + leaf.update(&mut endpoint_b); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + let packets = drain_parent_pty_packets(&mut endpoint_a); + + assert_eq!(packets.len(), 1); + assert_frame(&packets[0], hook_id, OP_OUTPUT, false, b"hello"); + assert_hook_present(&endpoint_a, hook_id); + assert_hook_present(&endpoint_b, hook_id); +} diff --git a/unshell-leaves/leaf-pty/src/tests/session/lifecycle.rs b/unshell-leaves/leaf-pty/src/tests/session/lifecycle.rs new file mode 100644 index 0000000..deedeb3 --- /dev/null +++ b/unshell-leaves/leaf-pty/src/tests/session/lifecycle.rs @@ -0,0 +1,145 @@ +use unshell::protocol::Leaf; + +use crate::{ + FakePtyLeaf, FakePtyState, OP_ABORT, OP_ERROR, OP_EXIT, OP_INPUT, OP_STDIN_EOF, OP_TERMINATE, +}; + +use super::super::support::{ + ENDPOINT_A, ENDPOINT_B, assert_frame, assert_hook_present, assert_hook_removed, assert_opened, + drain_parent_pty_packets, open_pty_session, pty_endpoints, send_downward_frame, + transfer_packets, +}; + +#[test] +fn open_pty_paves_hook_and_creates_session() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + + let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); + let packets = drain_parent_pty_packets(&mut endpoint_a); + + assert_eq!(leaf.active_session_count(), 1); + assert_eq!(leaf.state().active_count, 1); + assert_eq!(leaf.state().total_opened, 1); + assert_hook_present(&endpoint_a, hook_id); + assert_hook_present(&endpoint_b, hook_id); + assert_eq!(packets.len(), 1); + assert_opened(&packets[0], hook_id); +} + +#[test] +fn stdin_eof_keeps_hook_until_exit() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); + drain_parent_pty_packets(&mut endpoint_a); + + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + hook_id, + OP_STDIN_EOF, + &[], + false, + ); + leaf.update(&mut endpoint_b); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + + assert_eq!(leaf.state().last_stdin_eof_hook, Some(hook_id)); + assert!(drain_parent_pty_packets(&mut endpoint_a).is_empty()); + assert_hook_present(&endpoint_a, hook_id); + assert_hook_present(&endpoint_b, hook_id); + + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + hook_id, + OP_TERMINATE, + &[], + false, + ); + leaf.update(&mut endpoint_b); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + let packets = drain_parent_pty_packets(&mut endpoint_a); + + assert_eq!(packets.len(), 1); + assert_frame(&packets[0], hook_id, OP_EXIT, true, &[0]); + assert_eq!(leaf.active_session_count(), 0); + assert_hook_removed(&endpoint_a, hook_id); + assert_hook_removed(&endpoint_b, hook_id); +} + +#[test] +fn exit_end_hook_cleans_route_and_session() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); + drain_parent_pty_packets(&mut endpoint_a); + + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + hook_id, + OP_TERMINATE, + &[], + false, + ); + leaf.update(&mut endpoint_b); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + let packets = drain_parent_pty_packets(&mut endpoint_a); + + assert_eq!(packets.len(), 1); + assert_frame(&packets[0], hook_id, OP_EXIT, true, &[0]); + assert_eq!(leaf.active_session_count(), 0); + assert_hook_removed(&endpoint_a, hook_id); + assert_hook_removed(&endpoint_b, hook_id); +} + +#[test] +fn abort_downward_end_hook_closes_without_ack() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); + drain_parent_pty_packets(&mut endpoint_a); + + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + hook_id, + OP_ABORT, + &[], + true, + ); + leaf.update(&mut endpoint_b); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + + assert_eq!(leaf.active_session_count(), 0); + assert!(drain_parent_pty_packets(&mut endpoint_a).is_empty()); + assert_hook_removed(&endpoint_a, hook_id); + assert_hook_removed(&endpoint_b, hook_id); +} + +#[test] +fn unknown_session_input_returns_error_end_hook() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + let hook_id = endpoint_a.get_hook_id(); + + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + hook_id, + OP_INPUT, + b"orphan", + false, + ); + leaf.update(&mut endpoint_b); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + let packets = drain_parent_pty_packets(&mut endpoint_a); + + assert_eq!(packets.len(), 1); + assert_frame(&packets[0], hook_id, OP_ERROR, true, b"unknown-session"); + assert_eq!(leaf.active_session_count(), 0); + assert_hook_removed(&endpoint_a, hook_id); + assert_hook_removed(&endpoint_b, hook_id); +} diff --git a/unshell-leaves/leaf-pty/src/tests/session/mod.rs b/unshell-leaves/leaf-pty/src/tests/session/mod.rs new file mode 100644 index 0000000..c35a33b --- /dev/null +++ b/unshell-leaves/leaf-pty/src/tests/session/mod.rs @@ -0,0 +1,5 @@ +mod concurrency; +mod failure; +mod filtering; +mod input_output; +mod lifecycle; diff --git a/unshell-leaves/leaf-pty/src/tests/support.rs b/unshell-leaves/leaf-pty/src/tests/support.rs deleted file mode 100644 index 5c1d179..0000000 --- a/unshell-leaves/leaf-pty/src/tests/support.rs +++ /dev/null @@ -1,137 +0,0 @@ -use alloc::{vec, vec::Vec}; - -use unshell::protocol::{Endpoint, Leaf, Packet}; - -use crate::{ - FakePtyLeaf, OP_OPENED, PROC_PTY, frame_opcode, frame_payload, pty_open_packet, pty_packet, -}; - -pub(super) const ENDPOINT_A: u32 = 0; -pub(super) const ENDPOINT_B: u32 = 1; -pub(super) const PROC_OTHER: u32 = 31; - -/// Creates a bare endpoint at a known absolute path. -pub(super) fn endpoint_at(id: u32, path: Vec) -> Endpoint { - let mut endpoint = Endpoint::new(id); - endpoint.path = path; - endpoint -} - -/// Creates the parent/child endpoint pair used by PTY session tests. -pub(super) fn pty_endpoints() -> (Endpoint, Endpoint) { - let mut endpoint_a = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]); - let mut endpoint_b = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); - - endpoint_a.add_connection(ENDPOINT_B, false); - endpoint_b.add_connection(ENDPOINT_A, true); - - (endpoint_a, endpoint_b) -} - -/// Transfers every queued packet for `next_hop` into `receiver` as `remote_id` traffic. -pub(super) fn transfer_packets( - sender: &mut Endpoint, - receiver: &mut Endpoint, - next_hop: u32, - remote_id: u32, -) { - let mut packets = Vec::new(); - sender.take_outbound_clear(next_hop, |packet| packets.push(packet.clone())); - - for packet in packets { - receiver.add_inbound_from(remote_id, packet).unwrap(); - } -} - -/// Sends one downward PTY frame from endpoint A to endpoint B. -pub(super) fn send_downward_frame( - endpoint_a: &mut Endpoint, - endpoint_b: &mut Endpoint, - hook_id: u16, - opcode: u8, - payload: &[u8], - end_hook: bool, -) { - endpoint_a - .add_outbound(pty_packet( - vec![ENDPOINT_A, ENDPOINT_B], - hook_id, - end_hook, - opcode, - payload, - )) - .unwrap(); - transfer_packets(endpoint_a, endpoint_b, ENDPOINT_B, ENDPOINT_A); -} - -/// Opens a fake PTY session and delivers the `Opened` response to endpoint A. -pub(super) fn open_pty_session( - endpoint_a: &mut Endpoint, - endpoint_b: &mut Endpoint, - leaf: &mut FakePtyLeaf, -) -> u16 { - let hook_id = endpoint_a.get_hook_id(); - endpoint_a - .add_outbound(pty_open_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id)) - .unwrap(); - - transfer_packets(endpoint_a, endpoint_b, ENDPOINT_B, ENDPOINT_A); - leaf.update(endpoint_b); - transfer_packets(endpoint_b, endpoint_a, ENDPOINT_A, ENDPOINT_B); - - hook_id -} - -/// Drains packets for `procedure_id` delivered to endpoint A. -pub(super) fn drain_parent_packets(endpoint: &mut Endpoint, procedure_id: u32) -> Vec { - let mut packets = Vec::new(); - endpoint.take_inbound_matching( - ENDPOINT_A, - |packet| packet.procedure_id == procedure_id, - |packet| packets.push(packet), - ); - packets -} - -/// Drains PTY packets delivered to endpoint A. -pub(super) fn drain_parent_pty_packets(endpoint: &mut Endpoint) -> Vec { - drain_parent_packets(endpoint, PROC_PTY) -} - -/// Asserts that local hook state still contains `hook_id`. -pub(super) fn assert_hook_present(endpoint: &Endpoint, hook_id: u16) { - assert!(endpoint.has_hook(hook_id)); -} - -/// Asserts that local hook state no longer contains `hook_id`. -pub(super) fn assert_hook_removed(endpoint: &Endpoint, hook_id: u16) { - assert!(!endpoint.has_hook(hook_id)); -} - -/// Asserts that `packet` carries the expected PTY frame. -pub(super) fn assert_frame( - packet: &Packet, - hook_id: u16, - opcode: u8, - end_hook: bool, - payload: &[u8], -) { - assert_eq!(packet.hook_id, hook_id); - assert_eq!(packet.end_hook, end_hook); - assert_eq!(frame_opcode(packet), Some(opcode)); - assert_eq!(frame_payload(packet), payload); -} - -/// Returns true when `packets` contains the requested frame. -pub(super) fn has_frame(packets: &[Packet], hook_id: u16, opcode: u8, payload: &[u8]) -> bool { - packets.iter().any(|packet| { - packet.hook_id == hook_id - && frame_opcode(packet) == Some(opcode) - && frame_payload(packet) == payload - }) -} - -/// Asserts that a packet is the fake PTY open acknowledgement. -pub(super) fn assert_opened(packet: &Packet, hook_id: u16) { - assert_frame(packet, hook_id, OP_OPENED, false, &[]); -} diff --git a/unshell-leaves/leaf-pty/src/tests/support/assertions.rs b/unshell-leaves/leaf-pty/src/tests/support/assertions.rs new file mode 100644 index 0000000..87481b7 --- /dev/null +++ b/unshell-leaves/leaf-pty/src/tests/support/assertions.rs @@ -0,0 +1,47 @@ +use unshell::protocol::{Endpoint, Packet}; + +use crate::{OP_OPENED, frame_opcode, frame_payload}; + +/// Asserts that local hook state still contains `hook_id`. +pub(crate) fn assert_hook_present(endpoint: &Endpoint, hook_id: u16) { + assert!( + endpoint.has_hook(hook_id), + "expected hook {hook_id} to remain registered" + ); +} + +/// Asserts that local hook state no longer contains `hook_id`. +pub(crate) fn assert_hook_removed(endpoint: &Endpoint, hook_id: u16) { + assert!( + !endpoint.has_hook(hook_id), + "expected hook {hook_id} to be cleaned up" + ); +} + +/// Asserts that `packet` carries the expected PTY frame. +pub(crate) fn assert_frame( + packet: &Packet, + hook_id: u16, + opcode: u8, + end_hook: bool, + payload: &[u8], +) { + assert_eq!(packet.hook_id, hook_id); + assert_eq!(packet.end_hook, end_hook); + assert_eq!(frame_opcode(packet), Some(opcode)); + assert_eq!(frame_payload(packet), payload); +} + +/// Returns true when `packets` contains the requested frame. +pub(crate) fn has_frame(packets: &[Packet], hook_id: u16, opcode: u8, payload: &[u8]) -> bool { + packets.iter().any(|packet| { + packet.hook_id == hook_id + && frame_opcode(packet) == Some(opcode) + && frame_payload(packet) == payload + }) +} + +/// Asserts that a packet is the fake PTY open acknowledgement. +pub(crate) fn assert_opened(packet: &Packet, hook_id: u16) { + assert_frame(packet, hook_id, OP_OPENED, false, &[]); +} diff --git a/unshell-leaves/leaf-pty/src/tests/support/drains.rs b/unshell-leaves/leaf-pty/src/tests/support/drains.rs new file mode 100644 index 0000000..344747c --- /dev/null +++ b/unshell-leaves/leaf-pty/src/tests/support/drains.rs @@ -0,0 +1,23 @@ +use alloc::vec::Vec; + +use unshell::protocol::{Endpoint, Packet}; + +use crate::PROC_PTY; + +use super::ENDPOINT_A; + +/// Drains packets for `procedure_id` delivered to endpoint A. +pub(crate) fn drain_parent_packets(endpoint: &mut Endpoint, procedure_id: u32) -> Vec { + let mut packets = Vec::new(); + endpoint.take_inbound_matching( + ENDPOINT_A, + |packet| packet.procedure_id == procedure_id, + |packet| packets.push(packet), + ); + packets +} + +/// Drains PTY packets delivered to endpoint A. +pub(crate) fn drain_parent_pty_packets(endpoint: &mut Endpoint) -> Vec { + drain_parent_packets(endpoint, PROC_PTY) +} diff --git a/unshell-leaves/leaf-pty/src/tests/support/endpoints.rs b/unshell-leaves/leaf-pty/src/tests/support/endpoints.rs new file mode 100644 index 0000000..a8d6413 --- /dev/null +++ b/unshell-leaves/leaf-pty/src/tests/support/endpoints.rs @@ -0,0 +1,40 @@ +use alloc::{vec, vec::Vec}; + +use unshell::protocol::{Endpoint, Packet}; + +pub(crate) const ENDPOINT_A: u32 = 0; +pub(crate) const ENDPOINT_B: u32 = 1; +pub(crate) const PROC_OTHER: u32 = 31; + +/// Creates a bare endpoint at a known absolute path. +pub(crate) fn endpoint_at(id: u32, path: Vec) -> Endpoint { + let mut endpoint = Endpoint::new(id); + endpoint.path = path; + endpoint +} + +/// Creates the parent/child endpoint pair used by PTY session tests. +pub(crate) fn pty_endpoints() -> (Endpoint, Endpoint) { + let mut endpoint_a = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]); + let mut endpoint_b = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + + endpoint_a.add_connection(ENDPOINT_B, false); + endpoint_b.add_connection(ENDPOINT_A, true); + + (endpoint_a, endpoint_b) +} + +/// Transfers every queued packet for `next_hop` into `receiver` as `remote_id` traffic. +pub(crate) fn transfer_packets( + sender: &mut Endpoint, + receiver: &mut Endpoint, + next_hop: u32, + remote_id: u32, +) { + let mut packets = Vec::::new(); + sender.take_outbound_clear(next_hop, |packet| packets.push(packet.clone())); + + for packet in packets { + receiver.add_inbound_from(remote_id, packet).unwrap(); + } +} diff --git a/unshell-leaves/leaf-pty/src/tests/support/mod.rs b/unshell-leaves/leaf-pty/src/tests/support/mod.rs new file mode 100644 index 0000000..96d60ae --- /dev/null +++ b/unshell-leaves/leaf-pty/src/tests/support/mod.rs @@ -0,0 +1,9 @@ +mod assertions; +mod drains; +mod endpoints; +mod packets; + +pub(crate) use assertions::*; +pub(crate) use drains::*; +pub(crate) use endpoints::*; +pub(crate) use packets::*; diff --git a/unshell-leaves/leaf-pty/src/tests/support/packets.rs b/unshell-leaves/leaf-pty/src/tests/support/packets.rs new file mode 100644 index 0000000..44f50e9 --- /dev/null +++ b/unshell-leaves/leaf-pty/src/tests/support/packets.rs @@ -0,0 +1,46 @@ +use alloc::vec; + +use unshell::protocol::{Endpoint, Leaf}; + +use crate::{FakePtyLeaf, pty_open_packet, pty_packet}; + +use super::{ENDPOINT_A, ENDPOINT_B, transfer_packets}; + +/// Sends one downward PTY frame from endpoint A to endpoint B. +pub(crate) fn send_downward_frame( + endpoint_a: &mut Endpoint, + endpoint_b: &mut Endpoint, + hook_id: u16, + opcode: u8, + payload: &[u8], + end_hook: bool, +) { + endpoint_a + .add_outbound(pty_packet( + vec![ENDPOINT_A, ENDPOINT_B], + hook_id, + end_hook, + opcode, + payload, + )) + .unwrap(); + transfer_packets(endpoint_a, endpoint_b, ENDPOINT_B, ENDPOINT_A); +} + +/// Opens a fake PTY session and delivers the `Opened` response to endpoint A. +pub(crate) fn open_pty_session( + endpoint_a: &mut Endpoint, + endpoint_b: &mut Endpoint, + leaf: &mut FakePtyLeaf, +) -> u16 { + let hook_id = endpoint_a.get_hook_id(); + endpoint_a + .add_outbound(pty_open_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id)) + .unwrap(); + + transfer_packets(endpoint_a, endpoint_b, ENDPOINT_B, ENDPOINT_A); + leaf.update(endpoint_b); + transfer_packets(endpoint_b, endpoint_a, ENDPOINT_A, ENDPOINT_B); + + hook_id +}