Reorganize protocol.

This commit is contained in:
Michael Mikovsky
2026-04-24 13:37:30 -06:00
parent dcf0fe230b
commit 49901b6370
21 changed files with 861 additions and 1438 deletions
Generated
-322
View File
@@ -2,26 +2,6 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -37,16 +17,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base62"
version = "0.1.0"
dependencies = [
"aes",
"cbc",
"regex",
"sha2",
]
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -59,33 +29,6 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
dependencies = [
"hybrid-array",
]
[[package]]
name = "bumpalo"
version = "3.19.1"
@@ -121,15 +64,6 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.2.54"
@@ -171,66 +105,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -243,28 +123,6 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -277,27 +135,6 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-literal"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1"
[[package]]
name = "hybrid-array"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1b229d73f5803b562cc26e4da0396c8610a4ee209f4fac8fa4f8d709166dc45"
dependencies = [
"typenum",
]
[[package]]
name = "iana-time-zone"
version = "0.1.64"
@@ -332,16 +169,6 @@ dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding 0.3.3",
"generic-array",
]
[[package]]
name = "js-sys"
version = "0.3.85"
@@ -437,15 +264,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -484,12 +302,6 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rancor"
version = "0.1.1"
@@ -499,35 +311,6 @@ dependencies = [
"ptr_meta",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -537,35 +320,6 @@ dependencies = [
"bitflags 2.11.1",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
[[package]]
name = "rend"
version = "0.5.3"
@@ -617,17 +371,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -731,12 +474,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-ident"
version = "1.0.22"
@@ -748,27 +485,9 @@ name = "unshell"
version = "0.1.0"
dependencies = [
"chrono",
"crossbeam-channel",
"rkyv",
"static_init",
"thiserror",
"ush-obfuscate",
]
[[package]]
name = "ush-obfuscate"
version = "0.1.0"
dependencies = [
"base62",
"block-padding 0.4.2",
"getrandom",
"hex",
"hex-literal",
"proc-macro2",
"quote",
"rand",
"static_init",
"syn 2.0.114",
]
[[package]]
@@ -781,21 +500,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.108"
@@ -921,29 +625,3 @@ checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
[[package]]
name = "zerocopy"
version = "0.8.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
+20 -138
View File
@@ -1,30 +1,13 @@
cargo-features = ["trim-paths", "panic-immediate-abort"]
# =============================================================================
# UnShell Workspace
# =============================================================================
#
# Crate layout:
#
# unshell — core library: protocol types, transport trait, tree routing
# ush-router — the router/relay binary (runs on operator's VPS)
# ush-payload — the implant binary (runs on the target)
# ush-cli — the operator REPL binary (runs on the operator's machine)
# ush-obfuscate — proc-macro crate: compile-time string/code obfuscation
# base62 — base62 encoding (used for node IDs)
#
# Build profiles:
# dev — fast compile, debug info
# release — optimized
# minimize — size-optimized, for the payload binary
[workspace]
members = []
members = [
# "ush-router",
# "ush-payload",
# "ush-cli",
# "ush-obfuscate",
# "base62", "no-alloc-network-test",
]
resolver = "2"
# ---------------------------------------------------------------------------
# Shared package metadata
# ---------------------------------------------------------------------------
[workspace.package]
version = "0.1.0"
edition = "2024"
@@ -33,146 +16,45 @@ license = "MIT"
repository = "https://github.com/Astatin3/unshell"
include = ["LICENSE", "**/*.rs", "Cargo.toml"]
# ---------------------------------------------------------------------------
# Shared dependencies — all crates in the workspace can reference these
# with `dep.workspace = true` to get consistent versions.
# ---------------------------------------------------------------------------
[workspace.dependencies]
# Serialisation
rkyv = "0.8.16" # zero-copy deserialisation framework
rkyv = "0.8.16"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
# Concurrency
crossbeam-channel = "0.5.15" # multi-producer multi-consumer channels
# Error handling
thiserror = "2.0.18" # derive(Error) macro
# Logging / time
thiserror = "2.0.18"
chrono = "0.4.44"
# Utilities
static_init = "1.0.4" # safe static initialisation
# Internal workspace crates (other crates depend on these)
static_init = "1.0.4"
unshell = { path = "." }
ush-obfuscate = { path = "./ush-obfuscate" }
base62 = { path = "./base62" }
# ush-obfuscate = { path = "./ush-obfuscate" }
# base62 = { path = "./base62" }
# ---------------------------------------------------------------------------
# The unshell core library
# ---------------------------------------------------------------------------
[package]
name = "unshell"
version.workspace = true
edition.workspace = true
description = "UnShell core library: protocol types, transport, and tree routing"
# The library must be no_std compatible so the payload can use it without
# a full standard library. It does, however, link `alloc` (heap allocation).
#
# Binaries (ush-router, ush-cli) link std and use the library's full API.
# The payload binary also links std for now but the library itself is no_std.
description = "Pure no_std implementation of the UnShell Protocol"
[features]
default = ["std", "sim"]
# Enable std-backed modules such as simulated transports and richer runtime helpers.
std = []
# Enable the structured logger (uses chrono for timestamps)
log = ["std"]
default = []
log = []
log_debug = ["log", "dep:chrono"]
# Enable TCP transport (requires std). All std binaries enable this.
# The payload binary can also enable it; only omit it for bare-metal embedded targets.
tcp = ["std"]
# Enable the crossbeam-channel simulated transport.
sim = ["std"]
# Obfuscation support (compile-time string obfuscation via proc-macro)
obfuscate_aes = ["ush-obfuscate/obfuscate_aes"]
obfuscate_ref = ["ush-obfuscate/obfuscate_ref"]
# obfuscate_aes = ["ush-obfuscate/obfuscate_aes"]
# obfuscate_ref = ["ush-obfuscate/obfuscate_ref"]
[dependencies]
rkyv = { workspace = true }
crossbeam-channel = { workspace = true }
thiserror = { workspace = true }
thiserror = { workspace = true, optional = true }
chrono = { workspace = true, optional = true }
ush-obfuscate = { workspace = true }
# ush-obfuscate = { workspace = true }
static_init = { workspace = true }
# ---------------------------------------------------------------------------
# Build profiles
# ---------------------------------------------------------------------------
[profile.release]
opt-level = 2
# Even in debug builds, optimise all dependencies so test runs aren't sluggish.
[profile.dev.package."*"]
opt-level = 2
# Payload profile: strip everything possible, optimise for size.
# Use with: cargo build --profile minimize -p ush-payload
[profile.minimize]
inherits = "release"
strip = true # strip debug symbols and non-essential sections
opt-level = "z" # optimise for binary size
lto = true # link-time optimisation (cross-crate dead code elim)
codegen-units = 1 # single codegen unit for maximum LTO
panic = "immediate-abort"
debug = false
trim-paths = "all" # strip file paths from panic messages
# ---------------------------------------------------------------------------
# Lints — applied to the entire workspace
# ---------------------------------------------------------------------------
[lints]
workspace = true
[workspace.lints.rust]
[lints.rust]
elided_lifetimes_in_paths = "warn"
future_incompatible = { level = "warn", priority = -1 }
nonstandard_style = { level = "warn", priority = -1 }
rust_2018_idioms = { level = "warn", priority = -1 }
rust_2021_prelude_collisions = "warn"
semicolon_in_expressions_from_macros = "warn"
trivial_numeric_casts = "warn"
unsafe_op_in_unsafe_fn = "warn"
unused_extern_crates = "warn"
unused_import_braces = "warn"
unused_lifetimes = "warn"
trivial_casts = "allow"
unused_qualifications = "allow"
[workspace.lints.rustdoc]
all = "warn"
missing_crate_level_docs = "warn"
[workspace.lints.clippy]
# --- Correctness ---
get_unwrap = "warn"
unwrap_used = "warn"
indexing_slicing = "warn"
# --- Style ---
cloned_instead_of_copied = "warn"
explicit_into_iter_loop = "warn"
explicit_iter_loop = "warn"
manual_string_new = "warn"
needless_borrow = "warn"
needless_pass_by_value = "warn"
str_to_string = "warn"
uninlined_format_args = "warn"
use_self = "warn"
# --- Documentation ---
missing_errors_doc = "warn"
missing_safety_doc = "warn"
undocumented_unsafe_blocks = "warn"
# --- Complexity ---
too_many_lines = "warn"
# --- Allowed (intentional style choices) ---
manual_range_contains = "allow"
map_unwrap_or = "allow"
File diff suppressed because one or more lines are too long
+10 -34
View File
@@ -1,44 +1,20 @@
//! UnShell core protocol crate.
//! # UnShell Core
//!
//! The crate now models the draft protocol in `PROTOCOL.md` directly:
//! This crate implements the UnShell protocol as a pure, `no_std` library.
//! It provides a trait-based architecture for routed endpoint communication
//! using an explicit tree topology.
//!
//! - [`protocol`] provides the canonical wire types, framing helpers, validation,
//! and introspection payloads.
//! - [`tree`] provides an explicit enum-based tree declaration, longest-prefix
//! routing helpers, and a small endpoint runtime for tests.
//! - [`transport`] provides framed transport implementations for simulated
//! channel-based links and TCP links.
//! - [`logger`] remains available for lightweight logging.
//! ## Architecture
//!
//! ```rust
//! use unshell::protocol::{CallMessage, HookTarget, PacketHeader, PacketType, encode_packet};
//! - [`protocol`] - Wire types, framing, stateless validation, routing/runtime, and implementation traits.
//!
//! let header = PacketHeader {
//! packet_type: PacketType::Call,
//! src_path: Vec::new(),
//! dst_path: vec!["child".into()],
//! dst_leaf: Some("echo".into()),
//! hook_id: None,
//! };
//! let call = CallMessage {
//! procedure_id: "org.product.v1.echo.roundtrip".into(),
//! data: b"ping".to_vec(),
//! response_hook: Some(HookTarget {
//! hook_id: 1,
//! return_path: Vec::new(),
//! }),
//! };
//!
//! let frame = encode_packet(&header, &call).expect("call should encode");
//! assert!(!frame.is_empty());
//! ```
//! The library requires `alloc` for path and payload management.
#![no_std]
#![cfg_attr(not(feature = "std"), no_std)]
extern crate alloc;
pub mod logger;
pub mod protocol;
pub mod transport;
pub mod tree;
pub use ush_obfuscate as obfuscate;
// pub use ush_obfuscate as obfuscate;
+38 -65
View File
@@ -1,14 +1,21 @@
//! # Logger Module
//!
//! A lightweight, no_std-compatible logging system.
//! A lightweight global logging system for core-only environments.
//!
//! ## Usage
//!
//! ```rust
//! use unshell::{info, warn, error};
//! use unshell::logger::Logger;
//! use unshell::logger::{Logger, Record};
//!
//! struct Sink;
//! impl Logger for Sink {
//! fn log(&self, _record: &Record<'_>) {}
//! }
//!
//! static LOGGER: Sink = Sink;
//! unshell::logger::set_logger(&LOGGER);
//!
//! // Uses the default (no-op) logger until one is installed.
//! info!("Starting up");
//! warn!("Something is off");
//! error!("Critical failure");
@@ -18,19 +25,25 @@
//!
//! Call [`set_logger`] with any type that implements [`Logger`]:
//!
//! ```rust,no_run
//! ```rust
//! use unshell::logger::{Logger, LogLevel, Record, set_logger};
//!
//! struct StdoutLogger;
//! impl Logger for StdoutLogger {
//! struct MemoryLogger {
//! min_level: LogLevel,
//! }
//!
//! impl Logger for MemoryLogger {
//! fn log(&self, record: &Record<'_>) {
//! // In a no_std environment you would use the `unix-print` crate
//! // or write to a pre-opened file descriptor.
//! let _ = record; // placeholder
//! if record.level < self.min_level {
//! return;
//! }
//! let _ = record;
//! }
//! }
//!
//! static MY_LOGGER: StdoutLogger = StdoutLogger;
//! static MY_LOGGER: MemoryLogger = MemoryLogger {
//! min_level: LogLevel::Info,
//! };
//! set_logger(&MY_LOGGER);
//! ```
//!
@@ -41,7 +54,7 @@
//! because:
//!
//! 1. The payload is single-threaded.
//! 2. The router and CLI set the logger before spawning node threads.
//! 2. Integrators install the logger before concurrent execution begins.
//!
//! If you need to change the logger after threads start, synchronise access
//! with a `Mutex` or an atomic pointer in your logger implementation.
@@ -107,8 +120,8 @@ pub struct Record<'a> {
/// A sink for log records.
///
/// Implement this to direct log output wherever you want (stdout, a file,
/// a TCP connection, a memory buffer for tests).
/// Implement this to direct log output wherever you want, such as a device
/// sink, a ring buffer, or a test collector.
pub trait Logger: Sync {
/// Receive and process a log record.
fn log(&self, record: &Record<'_>);
@@ -129,7 +142,7 @@ impl Logger for NullLogger {
/// Written once at startup via [`set_logger`], then only read.
/// # Safety
/// This is `static mut` to avoid a dependency on synchronisation primitives
/// in a no_std context. It is safe as long as `set_logger` is called before
/// in a core-only context. It is safe as long as `set_logger` is called before
/// any threads are spawned (see module-level docs).
static mut GLOBAL_LOGGER: &dyn Logger = &NullLogger;
@@ -189,73 +202,33 @@ pub fn log(level: LogLevel, message: &str, file: Option<&'static str>, line: Opt
}
// ---------------------------------------------------------------------------
// A minimal stdout logger for use in std binaries (router, CLI)
// A minimal compatibility logger
// ---------------------------------------------------------------------------
/// A simple logger that prints to stderr.
/// A simple filter-only logger.
///
/// Suitable for the router and operator CLI binaries.
/// Do not use in the payload binary (which may not have stderr available).
///
/// # Example
///
/// ```rust,no_run
/// use unshell::logger::{StderrLogger, set_logger};
///
/// static LOGGER: StderrLogger = StderrLogger::new(unshell::logger::LogLevel::Info);
/// set_logger(&LOGGER);
/// ```
pub struct StderrLogger {
/// Minimum level to log. Records below this level are discarded.
/// This provides a small compatibility surface for installations that want a
/// concrete logger type without defining their own sink yet.
pub struct CompatibilityLogger {
/// Minimum level to accept. Records below this level are discarded.
min_level: LogLevel,
}
impl StderrLogger {
/// Create a new `StderrLogger` that logs records at `min_level` and above.
///
/// # Example
///
/// ```rust
/// use unshell::logger::{StderrLogger, LogLevel};
/// let logger = StderrLogger::new(LogLevel::Info);
/// ```
impl CompatibilityLogger {
/// Create a new `CompatibilityLogger` that accepts records at `min_level`
/// and above.
#[must_use]
pub const fn new(min_level: LogLevel) -> Self {
Self { min_level }
}
}
impl Logger for StderrLogger {
impl Logger for CompatibilityLogger {
fn log(&self, record: &Record<'_>) {
if record.level < self.min_level {
return;
}
// eprintln! and String require std (available only with the `tcp` feature).
// In no_std builds this method is a no-op. The payload uses a different
// logger (or the null logger) in no_std contexts.
#[cfg(feature = "tcp")]
{
use alloc::string::String;
let location = match (record.file, record.line) {
(Some(f), Some(l)) => {
let mut s = String::from(f);
s.push(':');
s.push_str(&format!("{l}"));
s
}
_ => String::new(),
};
if location.is_empty() {
eprintln!("[{}] {}", record.level.as_str(), record.message);
} else {
eprintln!(
"[{}] {} - {}",
record.level.as_str(),
record.message,
location
);
}
}
let _ = record;
}
}
+57 -77
View File
@@ -1,10 +1,13 @@
//! Framed packet encoding and decoding.
//!
//! This module provides the `FrameCodec` trait, which abstracts the conversion
//! between owned packet structures and the canonical length-prefixed wire format.
use alloc::{boxed::Box, vec::Vec};
use core::fmt;
use rkyv::{Serialize, access, deserialize, rancor::Error, to_bytes, util::AlignedVec};
use crate::protocol::types::{
use super::types::{
ArchivedCallMessage, ArchivedDataMessage, ArchivedFaultMessage, ArchivedPacketHeader,
};
use crate::protocol::{CallMessage, DataMessage, FaultMessage, PacketHeader, PacketType};
@@ -39,82 +42,83 @@ impl fmt::Display for FrameError {
}
}
#[cfg(feature = "std")]
impl std::error::Error for FrameError {}
impl core::error::Error for FrameError {}
/// Borrowed view over a framed packet.
/// A view into a framed packet, providing access to archived sections.
pub struct ParsedFrame<'a> {
header: PacketHeader,
payload_bytes: &'a [u8],
}
impl<'a> ParsedFrame<'a> {
/// Returns the decoded header.
pub fn header(&self) -> &PacketHeader {
&self.header
}
/// Returns the packet type.
pub fn packet_type(&self) -> PacketType {
self.header.packet_type
}
/// Returns the raw payload byte section.
pub fn payload_bytes(&self) -> &'a [u8] {
self.payload_bytes
}
/// Returns an owned header copy.
pub fn deserialize_header(&self) -> PacketHeader {
self.header.clone()
}
/// Decodes the payload as a call.
///
/// # Errors
///
/// Returns [`FrameError`] when the payload bytes are not a valid archived call.
pub fn deserialize_call(&self) -> Result<CallMessage, FrameError> {
deserialize_archived_bytes::<ArchivedCallMessage, CallMessage>(self.payload_bytes)
}
/// Decodes the payload as data.
///
/// # Errors
///
/// Returns [`FrameError`] when the payload bytes are not a valid archived data packet.
pub fn deserialize_data(&self) -> Result<DataMessage, FrameError> {
deserialize_archived_bytes::<ArchivedDataMessage, DataMessage>(self.payload_bytes)
}
/// Decodes the payload as a fault.
///
/// # Errors
///
/// Returns [`FrameError`] when the payload bytes are not a valid archived fault.
pub fn deserialize_fault(&self) -> Result<FaultMessage, FrameError> {
deserialize_archived_bytes::<ArchivedFaultMessage, FaultMessage>(self.payload_bytes)
}
}
/// Trait for framing and unframing packets.
pub trait FrameCodec {
/// Encodes a packet header and payload into the canonical framed representation.
///
/// # Errors
///
/// Returns [`FrameError`] when serialization fails or a framed section exceeds the wire limit.
pub fn encode_packet<P>(header: &PacketHeader, payload: &P) -> Result<FrameBytes, FrameError>
fn encode_packet<P>(header: &PacketHeader, payload: &P) -> Result<FrameBytes, FrameError>
where
P: for<'a> Serialize<
rkyv::api::high::HighSerializer<AlignedVec, rkyv::ser::allocator::ArenaHandle<'a>, Error>,
rkyv::api::high::HighSerializer<
AlignedVec,
rkyv::ser::allocator::ArenaHandle<'a>,
Error,
>,
>;
/// Decodes a framed packet into a borrowed parsed view.
fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError>;
}
/// Default implementation of the `FrameCodec` using `rkyv`.
pub struct RkyvCodec;
impl FrameCodec for RkyvCodec {
fn encode_packet<P>(header: &PacketHeader, payload: &P) -> Result<FrameBytes, FrameError>
where
P: for<'a> Serialize<
rkyv::api::high::HighSerializer<
AlignedVec,
rkyv::ser::allocator::ArenaHandle<'a>,
Error,
>,
>,
{
// WARNING: the simulated and TCP transports both move complete framed packets.
// One owned contiguous buffer at this boundary is therefore intentional and avoids
// scattering later hidden copies through routing code.
// WARNING: framed packets move as one contiguous buffer across the core boundary.
// Keeping ownership here avoids hidden copies later in routing code.
let header_bytes = to_bytes::<Error>(header).map_err(FrameError::Serialize)?;
let payload_bytes = to_bytes::<Error>(payload).map_err(FrameError::Serialize)?;
let header_len = u32::try_from(header_bytes.len()).map_err(|_| FrameError::LengthOverflow)?;
let payload_len = u32::try_from(payload_bytes.len()).map_err(|_| FrameError::LengthOverflow)?;
let header_len =
u32::try_from(header_bytes.len()).map_err(|_| FrameError::LengthOverflow)?;
let payload_len =
u32::try_from(payload_bytes.len()).map_err(|_| FrameError::LengthOverflow)?;
let mut frame = Vec::with_capacity(8 + header_bytes.len() + payload_bytes.len());
frame.extend_from_slice(&header_len.to_be_bytes());
@@ -124,12 +128,7 @@ where
Ok(frame.into_boxed_slice())
}
/// Decodes a framed packet into a borrowed parsed view.
///
/// # Errors
///
/// Returns [`FrameError`] when the frame is truncated or the header archive is invalid.
pub fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError> {
fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError> {
if bytes.len() < 8 {
return Err(FrameError::Truncated);
}
@@ -171,8 +170,8 @@ pub fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError> {
);
let archived_header = access::<ArchivedPacketHeader, Error>(&aligned_header)
.map_err(FrameError::InvalidHeader)?;
let header =
deserialize::<PacketHeader, Error>(archived_header).map_err(FrameError::InvalidHeader)?;
let header = deserialize::<PacketHeader, Error>(archived_header)
.map_err(FrameError::InvalidHeader)?;
Ok(ParsedFrame {
header,
@@ -181,12 +180,24 @@ pub fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError> {
.ok_or(FrameError::Truncated)?,
})
}
}
/// Encodes a packet header and payload using the default codec.
pub fn encode_packet<P>(header: &PacketHeader, payload: &P) -> Result<FrameBytes, FrameError>
where
P: for<'a> Serialize<
rkyv::api::high::HighSerializer<AlignedVec, rkyv::ser::allocator::ArenaHandle<'a>, Error>,
>,
{
RkyvCodec::encode_packet(header, payload)
}
/// Decodes a framed packet using the default codec.
pub fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError> {
RkyvCodec::decode_frame(bytes)
}
/// Deserializes a standalone archived byte section.
///
/// # Errors
///
/// Returns [`FrameError`] when the archived bytes are invalid for the requested type.
pub fn deserialize_archived_bytes<A, T>(bytes: &[u8]) -> Result<T, FrameError>
where
A: rkyv::Portable
@@ -204,34 +215,3 @@ fn align_section(bytes: &[u8]) -> AlignedVec {
aligned.extend_from_slice(bytes);
aligned
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{HookTarget, PacketType};
use alloc::{string::String, vec};
#[test]
fn framing_roundtrip_preserves_call() {
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: Vec::new(),
dst_path: vec![String::from("child")],
dst_leaf: Some(String::from("echo")),
hook_id: None,
};
let call = CallMessage {
procedure_id: String::from("org.product.v1.echo.roundtrip"),
data: b"ping".to_vec(),
response_hook: Some(HookTarget {
hook_id: 1,
return_path: Vec::new(),
}),
};
let frame = encode_packet(&header, &call).expect("frame should encode");
let parsed = decode_frame(&frame).expect("frame should decode");
assert_eq!(parsed.deserialize_header(), header);
assert_eq!(parsed.deserialize_call().expect("call should decode"), call);
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! Required introspection payloads.
//! Required introspection payloads for discovery.
use alloc::{string::String, vec::Vec};
use rkyv::{Archive, Deserialize, Serialize};
+24 -1
View File
@@ -4,14 +4,37 @@
pub mod codec;
pub mod introspection;
pub mod traits;
pub mod tree;
mod types;
pub mod validation;
#[cfg(test)]
mod tests;
pub use codec::{
FrameBytes, FrameError, ParsedFrame, decode_frame, deserialize_archived_bytes, encode_packet,
FrameBytes, FrameCodec, FrameError, ParsedFrame, RkyvCodec, deserialize_archived_bytes,
};
pub use introspection::{EndpointIntrospection, LeafIntrospection, LeafIntrospectionSummary};
pub use traits::{HookStore, LeafMetadata, PacketFraming, PacketProcessor, RouteResolution};
pub use types::{
CallMessage, DataMessage, FaultMessage, HookTarget, PacketHeader, PacketType, ProtocolFault,
};
pub use validation::{ValidationError, validate_call, validate_header, validate_procedure_id};
pub fn encode_packet<P>(header: &PacketHeader, payload: &P) -> Result<FrameBytes, FrameError>
where
P: for<'a> rkyv::Serialize<
rkyv::api::high::HighSerializer<
rkyv::util::AlignedVec,
rkyv::ser::allocator::ArenaHandle<'a>,
rkyv::rancor::Error,
>,
>,
{
codec::encode_packet(header, payload)
}
pub fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError> {
codec::decode_frame(bytes)
}
+2
View File
@@ -0,0 +1,2 @@
mod protocol;
mod tree;
+118
View File
@@ -0,0 +1,118 @@
use alloc::{borrow::ToOwned, string::String, vec, vec::Vec};
use crate::protocol::{
CallMessage, FaultMessage, FrameError, HookTarget, PacketHeader, PacketType, ProtocolFault,
ValidationError, decode_frame, encode_packet, validate_call, validate_header,
validate_procedure_id,
};
fn path(parts: &[&str]) -> Vec<String> {
parts.iter().map(|part| (*part).to_owned()).collect()
}
#[test]
fn packet_framing_roundtrip_preserves_header_and_payload() {
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: path(&["root", "caller"]),
dst_path: path(&["root", "callee"]),
dst_leaf: Some("echo".to_owned()),
hook_id: None,
};
let call = CallMessage {
procedure_id: "unshell.echo.v1.alpha.invoke".to_owned(),
data: vec![1, 2, 3, 4],
response_hook: Some(HookTarget {
hook_id: 7,
return_path: path(&["root", "caller"]),
}),
};
let frame = encode_packet(&header, &call).expect("frame should encode");
let parsed = decode_frame(&frame).expect("frame should decode");
assert_eq!(parsed.header(), &header);
assert_eq!(parsed.packet_type(), PacketType::Call);
assert_eq!(
parsed.deserialize_call().expect("call should deserialize"),
call
);
}
#[test]
fn header_and_call_validation_reject_invalid_combinations() {
let invalid_header = PacketHeader {
packet_type: PacketType::Data,
src_path: path(&["peer"]),
dst_path: path(&["host"]),
dst_leaf: Some("echo".to_owned()),
hook_id: None,
};
assert_eq!(
validate_header(&invalid_header),
Err(ValidationError::HeaderInvariant(
"Data and Fault packets must not carry dst_leaf"
))
);
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: path(&["caller"]),
dst_path: path(&["callee"]),
dst_leaf: Some("echo".to_owned()),
hook_id: None,
};
let invalid_call = CallMessage {
procedure_id: "unshell.echo.v1.alpha.invoke".to_owned(),
data: Vec::new(),
response_hook: Some(HookTarget {
hook_id: 5,
return_path: path(&["elsewhere"]),
}),
};
assert_eq!(
validate_call(&header, &invalid_call),
Err(ValidationError::CallInvariant(
"response_hook.return_path must equal header.src_path"
))
);
}
#[test]
fn procedure_validation_accepts_introspection_and_rejects_bad_shapes() {
assert_eq!(validate_procedure_id(""), Ok(()));
assert_eq!(
validate_procedure_id("unshell.echo.v01.alpha.invoke"),
Err(ValidationError::ProcedureId(
"version segment must be v followed by a positive decimal integer"
))
);
assert_eq!(
validate_procedure_id("too.short.v1"),
Err(ValidationError::ProcedureId(
"must contain exactly 5 segments"
))
);
}
#[test]
fn truncated_frames_are_rejected() {
let header = PacketHeader {
packet_type: PacketType::Fault,
src_path: path(&["src"]),
dst_path: path(&["dst"]),
dst_leaf: None,
hook_id: Some(9),
};
let message = FaultMessage {
fault: ProtocolFault::InternalError,
};
let frame = encode_packet(&header, &message).expect("frame should encode");
let truncated = &frame[..frame.len() - 1];
assert!(matches!(
decode_frame(truncated),
Err(FrameError::Truncated)
));
}
+155
View File
@@ -0,0 +1,155 @@
use alloc::{borrow::ToOwned, string::String, vec, vec::Vec};
use crate::protocol::tree::{
DefaultRouteProvider, Endpoint, Ingress, LeafBehavior, LeafNode, LeafSpec, LocalEvent,
ProtocolEndpoint, RouteDecision, RouteProvider, TreeNode,
};
use crate::protocol::{
DataMessage, EndpointIntrospection, FaultMessage, PacketHeader, PacketType, ProtocolFault,
decode_frame, deserialize_archived_bytes, encode_packet,
};
fn path(parts: &[&str]) -> Vec<String> {
parts.iter().map(|part| (*part).to_owned()).collect()
}
#[test]
fn tree_node_paths_flatten_explicitly() {
let tree = TreeNode::Root {
children: vec![TreeNode::Endpoint {
segment: "branch".to_owned(),
leaves: vec![LeafNode {
name: "echo".to_owned(),
procedures: vec!["unshell.echo.v1.alpha.invoke".to_owned()],
}],
children: vec![TreeNode::Endpoint {
segment: "leaf".to_owned(),
leaves: Vec::new(),
children: Vec::new(),
}],
}],
};
assert_eq!(
tree.paths(),
vec![
Vec::<String>::new(),
path(&["branch"]),
path(&["branch", "leaf"])
]
);
}
#[test]
fn longest_prefix_routing_prefers_most_specific_child() {
let provider = DefaultRouteProvider;
let child_paths = vec![path(&["a"]), path(&["a", "b"]), path(&["x"])];
assert_eq!(
provider.route_destination(&Vec::new(), &child_paths, true, &path(&["a", "b", "c"])),
RouteDecision::Child(1)
);
assert_eq!(
provider.route_destination(&path(&["a"]), &child_paths, true, &path(&["z"])),
RouteDecision::Parent
);
}
#[test]
fn protocol_endpoint_introspection_returns_leaf_summary() {
let mut endpoint = ProtocolEndpoint::new(
path(&["root"]),
Some(Vec::new()),
Vec::new(),
vec![LeafSpec {
name: "echo".to_owned(),
procedures: vec!["unshell.echo.v1.alpha.invoke".to_owned()],
behavior: LeafBehavior::Echo,
}],
);
let hook_id = endpoint.allocate_hook_id();
let frame = endpoint
.make_call(path(&["root"]), None, "", Some(hook_id), Vec::new())
.expect("introspection call should encode");
let outcome = endpoint
.receive(&Ingress::Local, frame)
.expect("endpoint should handle introspection");
assert!(outcome.events.is_empty());
assert_eq!(outcome.forwards.len(), 1);
assert_eq!(outcome.forwards[0].0, RouteDecision::Parent);
let parsed = decode_frame(&outcome.forwards[0].1).expect("response should decode");
let response = parsed
.deserialize_data()
.expect("response data should deserialize");
let introspection = deserialize_archived_bytes::<
rkyv::Archived<EndpointIntrospection>,
EndpointIntrospection,
>(&response.data)
.expect("introspection payload should deserialize");
assert!(response.end_hook);
assert_eq!(introspection.leaves.len(), 1);
assert_eq!(introspection.leaves[0].leaf_name, "echo");
assert_eq!(
introspection.leaves[0].procedures,
vec!["unshell.echo.v1.alpha.invoke".to_owned()]
);
}
#[test]
fn invalid_hook_peer_emits_local_fault_event() {
let mut endpoint = ProtocolEndpoint::new(path(&["client"]), None, Vec::new(), Vec::new());
let hook_id = endpoint.allocate_hook_id();
endpoint
.make_call(
path(&["server"]),
None,
"unshell.echo.v1.alpha.invoke",
Some(hook_id),
vec![1, 2, 3],
)
.expect("call should establish an active hook");
let frame = encode_packet(
&PacketHeader {
packet_type: PacketType::Data,
src_path: path(&["client"]),
dst_path: path(&["client"]),
dst_leaf: None,
hook_id: Some(hook_id),
},
&DataMessage {
procedure_id: "unshell.echo.v1.alpha.invoke".to_owned(),
data: vec![9],
end_hook: false,
},
)
.expect("data frame should encode");
let outcome = endpoint
.receive(&Ingress::Local, frame)
.expect("invalid peer should be handled");
assert!(outcome.forwards.is_empty());
assert_eq!(outcome.events.len(), 1);
assert!(!outcome.dropped);
match &outcome.events[0] {
LocalEvent::Fault { header, message } => {
assert_eq!(header.packet_type, PacketType::Fault);
assert_eq!(header.hook_id, Some(hook_id));
assert_eq!(
message,
&FaultMessage {
fault: ProtocolFault::InvalidHookPeer,
}
);
}
other => panic!("expected fault event, got {other:?}"),
}
}
+142
View File
@@ -0,0 +1,142 @@
//! Protocol implementation traits exposed by the core crate.
//!
//! These traits collect the core contracts needed to plug framing, routing,
//! hook storage, leaf metadata, and packet processing into an implementation.
use alloc::{string::String, vec::Vec};
use super::{
FrameBytes, FrameCodec, LeafIntrospection, LeafIntrospectionSummary,
tree::{
ActiveHook, Endpoint, EndpointError, EndpointOutcome, HookKey, HookTable, Ingress,
LeafNode, LeafSpec, PendingHook, RouteProvider,
},
};
/// Packet framing contract for the canonical wire format.
pub trait PacketFraming: FrameCodec {}
impl<T> PacketFraming for T where T: FrameCodec + ?Sized {}
/// Route resolution contract for endpoint path delivery.
pub trait RouteResolution: RouteProvider {}
impl<T> RouteResolution for T where T: RouteProvider + ?Sized {}
/// Hook storage contract for pending and active protocol flows.
pub trait HookStore {
fn allocate_hook_id(&self, return_path: &[String]) -> u64;
fn insert_pending(&mut self, pending: PendingHook);
fn insert_active(&mut self, active: ActiveHook);
fn activate_pending(&mut self, key: &HookKey, peer_path: Vec<String>) -> Option<()>;
fn remove_pending(&mut self, key: &HookKey) -> Option<PendingHook>;
fn remove_active(&mut self, key: &HookKey) -> Option<ActiveHook>;
fn pending(&self, key: &HookKey) -> Option<&PendingHook>;
fn active(&self, key: &HookKey) -> Option<&ActiveHook>;
fn active_mut(&mut self, key: &HookKey) -> Option<&mut ActiveHook>;
}
impl HookStore for HookTable {
fn allocate_hook_id(&self, return_path: &[String]) -> u64 {
HookTable::allocate_hook_id(self, return_path)
}
fn insert_pending(&mut self, pending: PendingHook) {
HookTable::insert_pending(self, pending);
}
fn insert_active(&mut self, active: ActiveHook) {
HookTable::insert_active(self, active);
}
fn activate_pending(&mut self, key: &HookKey, peer_path: Vec<String>) -> Option<()> {
HookTable::activate_pending(self, key, peer_path)
}
fn remove_pending(&mut self, key: &HookKey) -> Option<PendingHook> {
HookTable::remove_pending(self, key)
}
fn remove_active(&mut self, key: &HookKey) -> Option<ActiveHook> {
HookTable::remove_active(self, key)
}
fn pending(&self, key: &HookKey) -> Option<&PendingHook> {
HookTable::pending(self, key)
}
fn active(&self, key: &HookKey) -> Option<&ActiveHook> {
HookTable::active(self, key)
}
fn active_mut(&mut self, key: &HookKey) -> Option<&mut ActiveHook> {
HookTable::active_mut(self, key)
}
}
/// Leaf metadata contract used for protocol discovery payloads.
pub trait LeafMetadata {
fn leaf_name(&self) -> &str;
fn procedures(&self) -> &[String];
fn summary(&self) -> LeafIntrospectionSummary {
LeafIntrospectionSummary {
leaf_name: self.leaf_name().into(),
procedures: self.procedures().to_vec(),
}
}
fn introspection(&self) -> LeafIntrospection {
LeafIntrospection {
leaf_name: self.leaf_name().into(),
procedures: self.procedures().to_vec(),
}
}
}
impl LeafMetadata for LeafSpec {
fn leaf_name(&self) -> &str {
&self.name
}
fn procedures(&self) -> &[String] {
&self.procedures
}
}
impl LeafMetadata for LeafNode {
fn leaf_name(&self) -> &str {
&self.name
}
fn procedures(&self) -> &[String] {
&self.procedures
}
}
/// Packet processor and local runtime contract for framed protocol traffic.
pub trait PacketProcessor {
fn path(&self) -> &[String];
fn receive(
&mut self,
ingress: &Ingress,
frame: FrameBytes,
) -> Result<EndpointOutcome, EndpointError>;
}
impl<T> PacketProcessor for T
where
T: Endpoint + ?Sized,
{
fn path(&self) -> &[String] {
Endpoint::path(self)
}
fn receive(
&mut self,
ingress: &Ingress,
frame: FrameBytes,
) -> Result<EndpointOutcome, EndpointError> {
Endpoint::receive(self, ingress, frame)
}
}
@@ -1,4 +1,4 @@
//! Minimal endpoint runtime for protocol tests.
//! Endpoint runtime and traits.
use alloc::{
collections::{BTreeMap, BTreeSet},
@@ -9,36 +9,31 @@ use alloc::{
use core::fmt;
use rkyv::{rancor::Error as RkyvError, to_bytes};
use crate::{
protocol::{
use crate::protocol::{
CallMessage, DataMessage, EndpointIntrospection, FaultMessage, FrameBytes, FrameError,
HookTarget, LeafIntrospection, LeafIntrospectionSummary, PacketHeader, PacketType,
ProtocolFault, decode_frame, encode_packet, introspection::INTROSPECTION_PROCEDURE_ID,
validate_call, validate_header, validate_procedure_id,
},
tree::{ActiveHook, HookKey, HookTable, PendingHook, RouteDecision, route_destination},
ProtocolFault, ValidationError, decode_frame, encode_packet,
introspection::INTROSPECTION_PROCEDURE_ID, validate_call, validate_header,
validate_procedure_id,
};
/// Local connection state defined by the protocol.
use super::{ActiveHook, HookKey, HookTable, PendingHook, RouteDecision, route_destination};
/// Local connection state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionState {
/// Connected but not routable.
Unregistered,
/// Admitted into local routing.
Registered,
}
/// Registered child route.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChildRoute {
/// Child endpoint path.
pub path: Vec<String>,
/// Local connection state.
pub state: ConnectionState,
}
impl ChildRoute {
/// Creates a registered child route.
pub fn registered(path: Vec<String>) -> Self {
Self {
path,
@@ -47,73 +42,58 @@ impl ChildRoute {
}
}
/// Basic leaf behavior used by the test protocol runtime.
/// Leaf behavior for test runtime.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LeafBehavior {
/// Echoes the call data back in one `Data` packet.
Echo,
}
/// Static leaf description.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LeafSpec {
/// Local leaf name.
pub name: String,
/// Supported procedures.
pub procedures: Vec<String>,
/// Test behavior.
pub behavior: LeafBehavior,
}
/// How a packet arrived at the endpoint.
/// Arrival side.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ingress {
/// From the direct parent.
Parent,
/// From a direct child path.
Child(Vec<String>),
/// Originated locally.
Local,
}
/// Locally delivered events produced by protocol processing.
/// Local events.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LocalEvent {
/// A supported local call with no response hook.
Call {
header: PacketHeader,
message: CallMessage,
},
/// Locally delivered data.
Data {
header: PacketHeader,
message: DataMessage,
},
/// Locally delivered or synthesized fault.
Fault {
header: PacketHeader,
message: FaultMessage,
},
}
/// Output from processing one frame.
/// Processing outcome.
#[derive(Debug, Default)]
pub struct EndpointOutcome {
/// Frames to forward. The frame bytes are moved, not cloned.
pub forwards: Vec<(RouteDecision, FrameBytes)>,
/// Events delivered locally.
pub events: Vec<LocalEvent>,
/// Whether the packet was silently dropped.
pub dropped: bool,
}
/// Endpoint processing failure.
/// Processing error.
#[derive(Debug)]
pub enum EndpointError {
/// Frame parsing failed.
Frame(FrameError),
/// Validation failed.
Validation(crate::protocol::ValidationError),
Validation(ValidationError),
}
impl fmt::Display for EndpointError {
@@ -125,8 +105,7 @@ impl fmt::Display for EndpointError {
}
}
#[cfg(feature = "std")]
impl std::error::Error for EndpointError {}
impl core::error::Error for EndpointError {}
impl From<FrameError> for EndpointError {
fn from(value: FrameError) -> Self {
@@ -134,15 +113,25 @@ impl From<FrameError> for EndpointError {
}
}
impl From<crate::protocol::ValidationError> for EndpointError {
fn from(value: crate::protocol::ValidationError) -> Self {
impl From<ValidationError> for EndpointError {
fn from(value: ValidationError) -> Self {
Self::Validation(value)
}
}
/// Local endpoint model suitable for tests and later integration work.
/// Core trait for a protocol endpoint.
pub trait Endpoint {
fn path(&self) -> &[String];
fn receive(
&mut self,
ingress: &Ingress,
frame: FrameBytes,
) -> Result<EndpointOutcome, EndpointError>;
}
/// Default endpoint implementation.
#[derive(Debug, Default)]
pub struct Endpoint {
pub struct ProtocolEndpoint {
path: Vec<String>,
parent_path: Option<Vec<String>>,
children: Vec<ChildRoute>,
@@ -151,8 +140,7 @@ pub struct Endpoint {
hooks: HookTable,
}
impl Endpoint {
/// Creates an endpoint with explicit path, parent, children, and leaves.
impl ProtocolEndpoint {
pub fn new(
path: Vec<String>,
parent_path: Option<Vec<String>>,
@@ -172,21 +160,6 @@ impl Endpoint {
}
}
/// Returns the local endpoint path.
pub fn path(&self) -> &[String] {
&self.path
}
/// Returns the hook table for assertions.
pub fn hooks(&self) -> &HookTable {
&self.hooks
}
/// Registers an endpoint-level procedure.
///
/// # Errors
///
/// Returns [`EndpointError`] when the procedure id is invalid.
pub fn add_endpoint_procedure(
&mut self,
procedure_id: impl Into<String>,
@@ -197,16 +170,10 @@ impl Endpoint {
Ok(())
}
/// Allocates a new local hook id.
pub fn allocate_hook_id(&self) -> u64 {
self.hooks.allocate_hook_id(&self.path)
}
/// Creates an outbound `Call` frame and registers host-side hook state when needed.
///
/// # Errors
///
/// Returns [`EndpointError`] when validation or framing fails.
pub fn make_call(
&mut self,
dst_path: Vec<String>,
@@ -250,11 +217,6 @@ impl Endpoint {
Ok(encode_packet(&header, &call)?)
}
/// Creates an outbound `Data` frame.
///
/// # Errors
///
/// Returns [`EndpointError`] when validation or framing fails.
pub fn make_data(
&self,
dst_path: Vec<String>,
@@ -281,150 +243,6 @@ impl Endpoint {
Ok(encode_packet(&header, &message)?)
}
/// Processes one framed packet.
///
/// # Errors
///
/// Returns [`EndpointError`] when frame decoding or validation fails.
pub fn receive(
&mut self,
ingress: &Ingress,
frame: FrameBytes,
) -> Result<EndpointOutcome, EndpointError> {
enum OwnedPayload {
Call(PacketHeader, CallMessage),
Data(PacketHeader, DataMessage),
Fault(PacketHeader, FaultMessage),
}
let owned = {
let parsed = decode_frame(&frame)?;
let header = parsed.deserialize_header();
validate_header(&header)?;
match header.packet_type {
PacketType::Call => OwnedPayload::Call(header, parsed.deserialize_call()?),
PacketType::Data => OwnedPayload::Data(header, parsed.deserialize_data()?),
PacketType::Fault => OwnedPayload::Fault(header, parsed.deserialize_fault()?),
}
};
let src_path = match &owned {
OwnedPayload::Call(header, _) => &header.src_path,
OwnedPayload::Data(header, _) => &header.src_path,
OwnedPayload::Fault(header, _) => &header.src_path,
};
if !self.valid_source_for_ingress(ingress, src_path) {
return Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
});
}
match owned {
OwnedPayload::Call(header, message) => {
self.receive_call(ingress, frame, header, message)
}
OwnedPayload::Data(header, message) => self.receive_data(header, message),
OwnedPayload::Fault(header, message) => self.receive_fault(header, message),
}
}
fn receive_call(
&mut self,
ingress: &Ingress,
frame: FrameBytes,
header: PacketHeader,
message: CallMessage,
) -> Result<EndpointOutcome, EndpointError> {
if !matches!(ingress, Ingress::Parent | Ingress::Local) {
return Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
});
}
validate_call(&header, &message)?;
match self.decide_route(&header.dst_path) {
RouteDecision::Child(index) => Ok(EndpointOutcome {
forwards: vec![(RouteDecision::Child(index), frame)],
..EndpointOutcome::default()
}),
RouteDecision::Parent => Ok(EndpointOutcome {
forwards: vec![(RouteDecision::Parent, frame)],
..EndpointOutcome::default()
}),
RouteDecision::Drop => Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
}),
RouteDecision::Local => self.handle_local_call(header, message),
}
}
fn receive_data(
&mut self,
header: PacketHeader,
message: DataMessage,
) -> Result<EndpointOutcome, EndpointError> {
match self.decide_route(&header.dst_path) {
RouteDecision::Child(_) | RouteDecision::Parent => Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
}),
RouteDecision::Drop => Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
}),
RouteDecision::Local => self.handle_local_data(header, message),
}
}
fn receive_fault(
&mut self,
header: PacketHeader,
message: FaultMessage,
) -> Result<EndpointOutcome, EndpointError> {
match self.decide_route(&header.dst_path) {
RouteDecision::Child(_) | RouteDecision::Parent => Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
}),
RouteDecision::Drop => Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
}),
RouteDecision::Local => {
let key = HookKey::new(
self.path.clone(),
header.hook_id.expect("validated hook id"),
);
let matches_active = self
.hooks
.active(&key)
.map(|active| active.peer_path == header.src_path)
.unwrap_or(false);
let matches_pending = self
.hooks
.pending(&key)
.map(|pending| pending.caller_src_path == header.src_path)
.unwrap_or(false);
if !(matches_active || matches_pending) {
return Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
});
}
self.hooks.remove_active(&key);
self.hooks.remove_pending(&key);
Ok(EndpointOutcome {
events: vec![LocalEvent::Fault { header, message }],
..EndpointOutcome::default()
})
}
}
}
fn handle_local_call(
&mut self,
header: PacketHeader,
@@ -453,11 +271,7 @@ impl Endpoint {
Some(leaf_name) => self
.leaves
.get(leaf_name)
.map(|leaf| {
leaf.procedures
.iter()
.any(|candidate| candidate == &message.procedure_id)
})
.map(|leaf| leaf.procedures.iter().any(|p| p == &message.procedure_id))
.unwrap_or(false),
None => self.endpoint_procedures.contains(&message.procedure_id),
};
@@ -466,7 +280,7 @@ impl Endpoint {
let fault = if header
.dst_leaf
.as_ref()
.is_some_and(|leaf_name| !self.leaves.contains_key(leaf_name))
.is_some_and(|name| !self.leaves.contains_key(name))
{
ProtocolFault::UnknownLeaf
} else {
@@ -482,15 +296,10 @@ impl Endpoint {
match header
.dst_leaf
.as_ref()
.and_then(|leaf_name| self.leaves.get(leaf_name))
.and_then(|name| self.leaves.get(name))
{
Some(LeafSpec {
behavior: LeafBehavior::Echo,
..
}) if key.is_some() => {
let hook = message
.response_hook
.expect("key and hook are synchronized");
Some(leaf) if leaf.behavior == LeafBehavior::Echo && key.is_some() => {
let hook = message.response_hook.expect("synchronized");
let response = DataMessage {
procedure_id: message.procedure_id.clone(),
data: message.data,
@@ -535,13 +344,11 @@ impl Endpoint {
let Some(leaf) = self.leaves.get(leaf_name) else {
return self.emit_fault_if_possible(Some(key), ProtocolFault::UnknownLeaf);
};
// WARNING: introspection nests one archived payload inside `DataMessage.data`.
// This inner allocation is required because the protocol defines `data` as opaque bytes.
to_bytes::<RkyvError>(&LeafIntrospection {
leaf_name: leaf_name.clone(),
procedures: leaf.procedures.clone(),
})
.expect("leaf introspection should serialize")
.expect("serialize")
.to_vec()
} else {
to_bytes::<RkyvError>(&EndpointIntrospection {
@@ -554,7 +361,7 @@ impl Endpoint {
})
.collect(),
})
.expect("endpoint introspection should serialize")
.expect("serialize")
.to_vec()
};
@@ -583,21 +390,13 @@ impl Endpoint {
header: PacketHeader,
message: DataMessage,
) -> Result<EndpointOutcome, EndpointError> {
let key = HookKey::new(
self.path.clone(),
header.hook_id.expect("validated hook id"),
);
let key = HookKey::new(self.path.clone(), header.hook_id.expect("validated"));
if self.hooks.active(&key).is_none() {
let pending_matches = self
.hooks
.pending(&key)
.map(|pending| {
pending.caller_src_path == header.src_path
&& pending.procedure_id == message.procedure_id
})
.unwrap_or(false);
if pending_matches {
let matches = self.hooks.pending(&key).is_some_and(|p| {
p.caller_src_path == header.src_path && p.procedure_id == message.procedure_id
});
if matches {
self.hooks.activate_pending(&key, header.src_path.clone());
}
}
@@ -632,13 +431,40 @@ impl Endpoint {
if message.end_hook {
self.hooks.remove_active(&key);
}
Ok(EndpointOutcome {
events: vec![LocalEvent::Data { header, message }],
..EndpointOutcome::default()
})
}
fn handle_local_fault(
&mut self,
header: PacketHeader,
message: FaultMessage,
) -> Result<EndpointOutcome, EndpointError> {
let key = HookKey::new(self.path.clone(), header.hook_id.expect("validated"));
let matches = self
.hooks
.active(&key)
.is_some_and(|a| a.peer_path == header.src_path)
|| self
.hooks
.pending(&key)
.is_some_and(|p| p.caller_src_path == header.src_path);
if !matches {
return Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
});
}
self.hooks.remove_active(&key);
self.hooks.remove_pending(&key);
Ok(EndpointOutcome {
events: vec![LocalEvent::Fault { header, message }],
..EndpointOutcome::default()
})
}
fn emit_fault_if_possible(
&mut self,
key: Option<HookKey>,
@@ -659,8 +485,7 @@ impl Endpoint {
dst_leaf: None,
hook_id: Some(key.hook_id),
};
let message = FaultMessage { fault };
let frame = encode_packet(&header, &message)?;
let frame = encode_packet(&header, &FaultMessage { fault })?;
Ok(EndpointOutcome {
forwards: vec![(RouteDecision::Parent, frame)],
..EndpointOutcome::default()
@@ -671,8 +496,8 @@ impl Endpoint {
let child_paths: Vec<Vec<String>> = self
.children
.iter()
.filter(|child| child.state == ConnectionState::Registered)
.map(|child| child.path.clone())
.filter(|c| c.state == ConnectionState::Registered)
.map(|c| c.path.clone())
.collect();
route_destination(
&self.path,
@@ -687,107 +512,79 @@ impl Endpoint {
Ingress::Parent => self
.parent_path
.as_ref()
.map_or(self.path.is_empty(), |path| path == src_path),
.map_or(self.path.is_empty(), |p| p == src_path),
Ingress::Child(path) => path == src_path,
Ingress::Local => src_path == self.path,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::introspection::ArchivedEndpointIntrospection;
use crate::protocol::{HookTarget, deserialize_archived_bytes};
fn echo_leaf() -> LeafSpec {
LeafSpec {
name: String::from("echo"),
procedures: vec![String::from("org.product.v1.echo.roundtrip")],
behavior: LeafBehavior::Echo,
}
impl Endpoint for ProtocolEndpoint {
fn path(&self) -> &[String] {
&self.path
}
#[test]
fn introspection_returns_payload_and_clears_hook() {
let mut child = Endpoint::new(
vec![String::from("child")],
Some(Vec::new()),
Vec::new(),
vec![echo_leaf()],
);
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: Vec::new(),
dst_path: vec![String::from("child")],
dst_leaf: None,
hook_id: None,
};
let call = CallMessage {
procedure_id: String::new(),
data: Vec::new(),
response_hook: Some(HookTarget {
hook_id: 1,
return_path: Vec::new(),
fn receive(
&mut self,
ingress: &Ingress,
frame: FrameBytes,
) -> Result<EndpointOutcome, EndpointError> {
let parsed = decode_frame(&frame)?;
let header = parsed.deserialize_header();
validate_header(&header)?;
if !self.valid_source_for_ingress(ingress, &header.src_path) {
return Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
});
}
match header.packet_type {
PacketType::Call => {
let message = parsed.deserialize_call()?;
if !matches!(ingress, Ingress::Parent | Ingress::Local) {
return Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
});
}
validate_call(&header, &message)?;
match self.decide_route(&header.dst_path) {
RouteDecision::Child(idx) => Ok(EndpointOutcome {
forwards: vec![(RouteDecision::Child(idx), frame)],
..EndpointOutcome::default()
}),
};
let outcome = child
.receive(
&Ingress::Parent,
encode_packet(&header, &call).expect("frame"),
)
.expect("receive should succeed");
let (_, frame) = outcome
.forwards
.first()
.expect("forwarded frame should exist");
let parsed = decode_frame(frame).expect("data frame");
let data = parsed.deserialize_data().expect("data payload");
let payload = deserialize_archived_bytes::<
ArchivedEndpointIntrospection,
EndpointIntrospection,
>(&data.data)
.expect("introspection payload");
assert_eq!(payload.leaves.len(), 1);
assert_eq!(child.hooks().active_len(), 0);
}
#[test]
fn invalid_peer_generates_local_fault_event() {
let mut root = Endpoint::new(Vec::new(), None, Vec::new(), Vec::new());
let _call = root
.make_call(
vec![String::from("child")],
None,
String::from("org.product.v1.echo.roundtrip"),
Some(7),
Vec::new(),
)
.expect("call should encode");
let frame = root
.make_data(
Vec::new(),
7,
String::from("org.product.v1.echo.roundtrip"),
b"bad".to_vec(),
false,
)
.expect("data should encode");
let parsed = decode_frame(&frame).expect("frame should decode");
let mut header = parsed.deserialize_header();
header.src_path = vec![String::from("other")];
let bad_frame = encode_packet(
&header,
&parsed.deserialize_data().expect("data should decode"),
)
.expect("bad frame should encode");
let outcome = root
.receive(&Ingress::Child(vec![String::from("other")]), bad_frame)
.expect("receive should work");
assert!(matches!(
outcome.events.first(),
Some(LocalEvent::Fault { .. })
));
RouteDecision::Parent => Ok(EndpointOutcome {
forwards: vec![(RouteDecision::Parent, frame)],
..EndpointOutcome::default()
}),
RouteDecision::Drop => Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
}),
RouteDecision::Local => self.handle_local_call(header, message),
}
}
PacketType::Data => {
let message = parsed.deserialize_data()?;
match self.decide_route(&header.dst_path) {
RouteDecision::Local => self.handle_local_data(header, message),
_ => Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
}),
}
}
PacketType::Fault => {
let message = parsed.deserialize_fault()?;
match self.decide_route(&header.dst_path) {
RouteDecision::Local => self.handle_local_fault(header, message),
_ => Ok(EndpointOutcome {
dropped: true,
..EndpointOutcome::default()
}),
}
}
}
}
}
+1 -25
View File
@@ -12,7 +12,6 @@ pub struct HookKey {
}
impl HookKey {
/// Creates a new hook key.
pub fn new(return_path: Vec<String>, hook_id: u64) -> Self {
Self {
return_path,
@@ -24,32 +23,21 @@ impl HookKey {
/// Pending hook context created by a received call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingHook {
/// Original caller path.
pub caller_src_path: Vec<String>,
/// Hook host path.
pub return_path: Vec<String>,
/// Hook identifier.
pub hook_id: u64,
/// Procedure anchored to the call.
pub procedure_id: String,
/// Destination leaf from the call.
pub dst_leaf: Option<String>,
}
/// Active hook context used for ordinary data traffic.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveHook {
/// Path of the endpoint hosting the hook.
pub return_path: Vec<String>,
/// Hook identifier.
pub hook_id: u64,
/// Expected direct peer for hook traffic.
pub peer_path: Vec<String>,
/// Procedure bound to the hook.
pub procedure_id: String,
/// Original destination leaf.
pub dst_leaf: Option<String>,
/// Whether the peer has indicated completion.
pub peer_finished: bool,
}
@@ -61,7 +49,6 @@ pub struct HookTable {
}
impl HookTable {
/// Allocates the lowest inactive hook id for a return path.
pub fn allocate_hook_id(&self, return_path: &[String]) -> u64 {
let mut hook_id = 0u64;
loop {
@@ -73,22 +60,18 @@ impl HookTable {
}
}
/// Inserts pending hook state.
pub fn insert_pending(&mut self, pending: PendingHook) {
// WARNING: hook tables intentionally own their path and procedure strings.
// Hook state must outlive any individual frame buffer, so borrowing framed
// transport memory here would be unsound.
// Hook state must outlive any individual frame buffer.
let key = HookKey::new(pending.return_path.clone(), pending.hook_id);
self.pending.insert(key, pending);
}
/// Inserts active hook state.
pub fn insert_active(&mut self, active: ActiveHook) {
let key = HookKey::new(active.return_path.clone(), active.hook_id);
self.active.insert(key, active);
}
/// Promotes pending hook state to active state.
pub fn activate_pending(&mut self, key: &HookKey, peer_path: Vec<String>) -> Option<()> {
let pending = self.pending.remove(key)?;
self.active.insert(
@@ -105,37 +88,30 @@ impl HookTable {
Some(())
}
/// Removes pending state.
pub fn remove_pending(&mut self, key: &HookKey) -> Option<PendingHook> {
self.pending.remove(key)
}
/// Removes active state.
pub fn remove_active(&mut self, key: &HookKey) -> Option<ActiveHook> {
self.active.remove(key)
}
/// Returns pending state.
pub fn pending(&self, key: &HookKey) -> Option<&PendingHook> {
self.pending.get(key)
}
/// Returns active state.
pub fn active(&self, key: &HookKey) -> Option<&ActiveHook> {
self.active.get(key)
}
/// Returns mutable active state.
pub fn active_mut(&mut self, key: &HookKey) -> Option<&mut ActiveHook> {
self.active.get_mut(key)
}
/// Returns the number of pending hooks.
pub fn pending_len(&self) -> usize {
self.pending.len()
}
/// Returns the number of active hooks.
pub fn active_len(&self) -> usize {
self.active.len()
}
+5 -2
View File
@@ -6,7 +6,10 @@ mod routing;
pub use endpoint::{
ChildRoute, ConnectionState, Endpoint, EndpointError, EndpointOutcome, Ingress, LeafBehavior,
LeafSpec, LocalEvent,
LeafSpec, LocalEvent, ProtocolEndpoint,
};
pub use hook::{ActiveHook, HookKey, HookTable, PendingHook};
pub use routing::{LeafNode, RouteDecision, TreeNode, is_prefix, route_destination};
pub use routing::{
DefaultRouteProvider, LeafNode, RouteDecision, RouteProvider, TreeNode, is_prefix,
route_destination,
};
@@ -2,7 +2,7 @@
use alloc::{string::String, vec::Vec};
/// Explicit test tree declaration.
/// Explicit test tree declaration used for configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TreeNode {
/// The tree root.
@@ -67,7 +67,7 @@ pub enum RouteDecision {
Drop,
}
/// Returns `true` if `prefix` is a prefix of `path`.
/// Returns `true` if `prefix` is a path prefix of `path`.
pub fn is_prefix(prefix: &[String], path: &[String]) -> bool {
prefix.len() <= path.len()
&& prefix
@@ -76,8 +76,24 @@ pub fn is_prefix(prefix: &[String], path: &[String]) -> bool {
.all(|(left, right)| left == right)
}
/// Routes a destination path using the protocol's longest-prefix rule.
pub fn route_destination(
/// Trait for resolving a destination path to a routing decision.
pub trait RouteProvider {
/// Computes the routing decision for a destination path.
fn route_destination(
&self,
local_path: &[String],
child_paths: &[Vec<String>],
has_parent: bool,
dst_path: &[String],
) -> RouteDecision;
}
/// Default routing implementation using the protocol's longest-prefix rule.
pub struct DefaultRouteProvider;
impl RouteProvider for DefaultRouteProvider {
fn route_destination(
&self,
local_path: &[String],
child_paths: &[Vec<String>],
has_parent: bool,
@@ -101,6 +117,16 @@ pub fn route_destination(
}
RouteDecision::Drop
}
}
pub fn route_destination(
local_path: &[String],
child_paths: &[Vec<String>],
has_parent: bool,
dst_path: &[String],
) -> RouteDecision {
DefaultRouteProvider.route_destination(local_path, child_paths, has_parent, dst_path)
}
#[cfg(test)]
mod tests {
@@ -109,12 +135,13 @@ mod tests {
#[test]
fn longest_prefix_wins() {
let provider = DefaultRouteProvider;
let children = vec![
vec![String::from("a")],
vec![String::from("a"), String::from("b")],
];
assert_eq!(
route_destination(
provider.route_destination(
&Vec::<String>::new(),
&children,
false,
+4 -1
View File
@@ -1,4 +1,7 @@
//! Archived protocol message types.
//! Canonical UnShell protocol message types.
//!
//! These types define the wire format and are designed for zero-copy
//! access via `rkyv`.
use alloc::{string::String, vec::Vec};
use rkyv::{Archive, Deserialize, Serialize};
+2 -63
View File
@@ -1,10 +1,9 @@
//! Stateless protocol validation.
use core::fmt;
use crate::protocol::{
CallMessage, PacketHeader, PacketType, introspection::INTROSPECTION_PROCEDURE_ID,
};
use core::fmt;
/// Validation failures for protocol structures.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -27,14 +26,9 @@ impl fmt::Display for ValidationError {
}
}
#[cfg(feature = "std")]
impl std::error::Error for ValidationError {}
impl core::error::Error for ValidationError {}
/// Validates packet header invariants from the protocol.
///
/// # Errors
///
/// Returns [`ValidationError`] when the header shape does not match the packet type.
pub fn validate_header(header: &PacketHeader) -> Result<(), ValidationError> {
match header.packet_type {
PacketType::Call => {
@@ -57,15 +51,10 @@ pub fn validate_header(header: &PacketHeader) -> Result<(), ValidationError> {
}
}
}
Ok(())
}
/// Validates the canonical dotted `procedure_id` shape.
///
/// # Errors
///
/// Returns [`ValidationError`] when the procedure id does not match the required format.
pub fn validate_procedure_id(procedure_id: &str) -> Result<(), ValidationError> {
if procedure_id == INTROSPECTION_PROCEDURE_ID {
return Ok(());
@@ -114,10 +103,6 @@ pub fn validate_procedure_id(procedure_id: &str) -> Result<(), ValidationError>
}
/// Validates call-specific invariants that depend on both header and payload.
///
/// # Errors
///
/// Returns [`ValidationError`] when the call payload conflicts with the header.
pub fn validate_call(header: &PacketHeader, call: &CallMessage) -> Result<(), ValidationError> {
validate_procedure_id(&call.procedure_id)?;
@@ -141,49 +126,3 @@ pub fn validate_call(header: &PacketHeader, call: &CallMessage) -> Result<(), Va
fn is_portable_procedure_char(ch: char) -> bool {
ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_'
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{HookTarget, PacketType};
use alloc::{string::String, vec};
#[test]
fn rejects_invalid_data_header() {
let header = PacketHeader {
packet_type: PacketType::Data,
src_path: Vec::new(),
dst_path: Vec::new(),
dst_leaf: Some(String::from("leaf")),
hook_id: None,
};
assert!(validate_header(&header).is_err());
}
#[test]
fn validates_procedure_id_shape() {
assert!(validate_procedure_id("org.product.v1.demo.echo").is_ok());
assert!(validate_procedure_id("org.product.v01.demo.echo").is_err());
assert!(validate_procedure_id("Org.product.v1.demo.echo").is_err());
}
#[test]
fn validates_response_hook_return_path() {
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: vec![String::from("src")],
dst_path: vec![String::from("dst")],
dst_leaf: None,
hook_id: None,
};
let call = CallMessage {
procedure_id: String::from("org.product.v1.demo.echo"),
data: Vec::new(),
response_hook: Some(HookTarget {
hook_id: 1,
return_path: vec![String::from("other")],
}),
};
assert!(validate_call(&header, &call).is_err());
}
}
-77
View File
@@ -1,77 +0,0 @@
//! Simulated transport built on `crossbeam-channel`.
use crossbeam_channel::{Receiver, Sender, unbounded};
use crate::{
protocol::FrameBytes,
transport::{Transport, TransportError},
};
/// One endpoint of a simulated duplex transport.
#[derive(Debug, Clone)]
pub struct ChannelTransport {
sender: Sender<FrameBytes>,
receiver: Receiver<FrameBytes>,
}
impl ChannelTransport {
/// Builds a connected pair of transports.
pub fn pair() -> (Self, Self) {
let (ab_tx, ab_rx) = unbounded();
let (ba_tx, ba_rx) = unbounded();
(
Self {
sender: ab_tx,
receiver: ba_rx,
},
Self {
sender: ba_tx,
receiver: ab_rx,
},
)
}
}
impl Transport for ChannelTransport {
fn send_frame(&mut self, frame: FrameBytes) -> Result<(), TransportError> {
self.sender
.send(frame)
.map_err(|_| TransportError::ChannelClosed)
}
fn recv_frame(&mut self) -> Result<FrameBytes, TransportError> {
self.receiver
.recv()
.map_err(|_| TransportError::ChannelClosed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{DataMessage, PacketHeader, PacketType, decode_frame, encode_packet};
use alloc::{string::String, vec};
#[test]
fn channel_roundtrip_moves_framed_bytes() {
let (mut left, mut right) = ChannelTransport::pair();
let header = PacketHeader {
packet_type: PacketType::Data,
src_path: vec![String::from("a")],
dst_path: vec![String::from("b")],
dst_leaf: None,
hook_id: Some(7),
};
let data = DataMessage {
procedure_id: String::from("org.product.v1.echo.roundtrip"),
data: b"payload".to_vec(),
end_hook: true,
};
let frame = encode_packet(&header, &data).expect("frame should encode");
left.send_frame(frame).expect("send should succeed");
let received = right.recv_frame().expect("recv should succeed");
let parsed = decode_frame(&received).expect("received frame should decode");
assert_eq!(parsed.deserialize_data().expect("data should decode"), data);
}
}
-79
View File
@@ -1,79 +0,0 @@
//! Framed transport implementations.
//!
//! Transports move complete framed packets represented by [`crate::protocol::FrameBytes`].
//! Packet parsing and validation live above this layer.
use crate::protocol::FrameBytes;
#[cfg(feature = "sim")]
pub mod channel;
#[cfg(feature = "tcp")]
pub mod tcp;
/// Maximum allowed size for a serialized header section.
pub const MAX_HEADER_BYTES: usize = 64 * 1024;
/// Maximum allowed size for a serialized payload section.
pub const MAX_PAYLOAD_BYTES: usize = 64 * 1024 * 1024;
/// Transport-layer failure.
#[derive(Debug)]
pub enum TransportError {
/// The peer disconnected cleanly.
Disconnected,
/// The announced header length exceeded the limit.
HeaderTooLarge(usize, usize),
/// The announced payload length exceeded the limit.
PayloadTooLarge(usize, usize),
/// Underlying I/O failure.
#[cfg(feature = "tcp")]
Io(std::io::Error),
/// Channel send or receive failure.
#[cfg(feature = "sim")]
ChannelClosed,
}
impl core::fmt::Display for TransportError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Disconnected => f.write_str("transport disconnected"),
Self::HeaderTooLarge(got, max) => {
write!(f, "header too large: {got} bytes (limit {max})")
}
Self::PayloadTooLarge(got, max) => {
write!(f, "payload too large: {got} bytes (limit {max})")
}
#[cfg(feature = "tcp")]
Self::Io(error) => write!(f, "transport I/O error: {error}"),
#[cfg(feature = "sim")]
Self::ChannelClosed => f.write_str("channel transport closed"),
}
}
}
#[cfg(feature = "std")]
impl std::error::Error for TransportError {}
#[cfg(feature = "tcp")]
impl From<std::io::Error> for TransportError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
/// Duplex framed transport.
pub trait Transport: Send {
/// Sends one complete framed packet.
///
/// # Errors
///
/// Returns [`TransportError`] when the underlying transport cannot deliver the frame.
fn send_frame(&mut self, frame: FrameBytes) -> Result<(), TransportError>;
/// Receives one complete framed packet.
///
/// # Errors
///
/// Returns [`TransportError`] when the transport disconnects or a frame cannot be read.
fn recv_frame(&mut self) -> Result<FrameBytes, TransportError>;
}
-132
View File
@@ -1,132 +0,0 @@
//! TCP framed transport.
use alloc::vec::Vec;
use std::{
io::{ErrorKind, Read, Write},
net::{TcpStream, ToSocketAddrs},
};
use crate::{
protocol::FrameBytes,
transport::{MAX_HEADER_BYTES, MAX_PAYLOAD_BYTES, Transport, TransportError},
};
/// Framed TCP transport.
pub struct TcpTransport {
stream: TcpStream,
}
impl TcpTransport {
/// Connects to a remote address.
///
/// # Errors
///
/// Returns [`TransportError`] when the TCP connection cannot be established.
pub fn connect<A: ToSocketAddrs>(addr: A) -> Result<Self, TransportError> {
Ok(Self {
stream: TcpStream::connect(addr)?,
})
}
/// Wraps an existing TCP stream.
pub fn from_stream(stream: TcpStream) -> Self {
Self { stream }
}
}
impl Transport for TcpTransport {
fn send_frame(&mut self, frame: FrameBytes) -> Result<(), TransportError> {
self.stream.write_all(&frame).map_err(map_io_error)
}
fn recv_frame(&mut self) -> Result<FrameBytes, TransportError> {
let header_len = read_u32(&mut self.stream)?;
if header_len > MAX_HEADER_BYTES {
return Err(TransportError::HeaderTooLarge(header_len, MAX_HEADER_BYTES));
}
let mut header = vec![0u8; header_len];
read_exact(&mut self.stream, &mut header)?;
let payload_len = read_u32(&mut self.stream)?;
if payload_len > MAX_PAYLOAD_BYTES {
return Err(TransportError::PayloadTooLarge(
payload_len,
MAX_PAYLOAD_BYTES,
));
}
let mut payload = vec![0u8; payload_len];
read_exact(&mut self.stream, &mut payload)?;
let mut frame = Vec::with_capacity(8 + header_len + payload_len);
frame.extend_from_slice(&(header_len as u32).to_be_bytes());
frame.extend_from_slice(&header);
frame.extend_from_slice(&(payload_len as u32).to_be_bytes());
frame.extend_from_slice(&payload);
Ok(frame.into_boxed_slice())
}
}
fn read_u32(stream: &mut TcpStream) -> Result<usize, TransportError> {
let mut bytes = [0u8; 4];
read_exact(stream, &mut bytes)?;
Ok(u32::from_be_bytes(bytes) as usize)
}
fn read_exact(stream: &mut TcpStream, buffer: &mut [u8]) -> Result<(), TransportError> {
stream.read_exact(buffer).map_err(map_io_error)
}
fn map_io_error(error: std::io::Error) -> TransportError {
match error.kind() {
ErrorKind::UnexpectedEof | ErrorKind::BrokenPipe | ErrorKind::ConnectionReset => {
TransportError::Disconnected
}
_ => TransportError::Io(error),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{DataMessage, PacketHeader, PacketType, decode_frame, encode_packet};
use alloc::{string::String, vec};
use std::{net::TcpListener, thread};
#[test]
fn tcp_roundtrip_preserves_frame() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind should succeed");
let addr = listener.local_addr().expect("local address should exist");
let header = PacketHeader {
packet_type: PacketType::Data,
src_path: vec![String::from("a")],
dst_path: vec![String::from("b")],
dst_leaf: None,
hook_id: Some(9),
};
let payload = DataMessage {
procedure_id: String::from("org.product.v1.echo.roundtrip"),
data: b"payload".to_vec(),
end_hook: true,
};
let frame = encode_packet(&header, &payload).expect("frame should encode");
let sender = thread::spawn(move || {
let mut transport = TcpTransport::connect(addr).expect("connect should succeed");
transport.send_frame(frame).expect("send should succeed");
});
let (stream, _) = listener.accept().expect("accept should succeed");
let mut transport = TcpTransport::from_stream(stream);
let received = transport.recv_frame().expect("recv should succeed");
let parsed = decode_frame(&received).expect("frame should decode");
sender.join().expect("sender should not panic");
assert_eq!(
parsed.deserialize_data().expect("data should decode"),
payload
);
}
}