diff --git a/Cargo.lock b/Cargo.lock index b47b8d7..931f536 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", -] diff --git a/Cargo.toml b/Cargo.toml index 93116d5..0c115d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" +static_init = "1.0.4" +unshell = { path = "." } +# ush-obfuscate = { path = "./ush-obfuscate" } +# base62 = { path = "./base62" } -# Utilities -static_init = "1.0.4" # safe static initialisation - -# Internal workspace crates (other crates depend on these) -unshell = { path = "." } -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" diff --git a/search_results.html b/search_results.html new file mode 100644 index 0000000..40e2918 --- /dev/null +++ b/search_results.html @@ -0,0 +1,37 @@ +Google SearchGoogle Search \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 354954c..470c12c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/logger/mod.rs b/src/logger/mod.rs index e8080f6..43cc909 100644 --- a/src/logger/mod.rs +++ b/src/logger/mod.rs @@ -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; } } diff --git a/src/protocol/codec.rs b/src/protocol/codec.rs index 3088eeb..644ec55 100644 --- a/src/protocol/codec.rs +++ b/src/protocol/codec.rs @@ -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,154 +42,162 @@ 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 { deserialize_archived_bytes::(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 { deserialize_archived_bytes::(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 { deserialize_archived_bytes::(self.payload_bytes) } } -/// 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. +/// Trait for framing and unframing packets. +pub trait FrameCodec { + /// Encodes a packet header and payload into the canonical framed representation. + fn encode_packet

(header: &PacketHeader, payload: &P) -> Result + where + P: for<'a> Serialize< + 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, FrameError>; +} + +/// Default implementation of the `FrameCodec` using `rkyv`. +pub struct RkyvCodec; + +impl FrameCodec for RkyvCodec { + fn encode_packet

(header: &PacketHeader, payload: &P) -> Result + where + P: for<'a> Serialize< + rkyv::api::high::HighSerializer< + AlignedVec, + rkyv::ser::allocator::ArenaHandle<'a>, + Error, + >, + >, + { + // 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::(header).map_err(FrameError::Serialize)?; + let payload_bytes = to_bytes::(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 mut frame = Vec::with_capacity(8 + header_bytes.len() + payload_bytes.len()); + frame.extend_from_slice(&header_len.to_be_bytes()); + frame.extend_from_slice(&header_bytes); + frame.extend_from_slice(&payload_len.to_be_bytes()); + frame.extend_from_slice(&payload_bytes); + Ok(frame.into_boxed_slice()) + } + + fn decode_frame(bytes: &[u8]) -> Result, FrameError> { + if bytes.len() < 8 { + return Err(FrameError::Truncated); + } + + let header_len = u32::from_be_bytes( + bytes + .get(0..4) + .ok_or(FrameError::Truncated)? + .try_into() + .expect("slice width checked"), + ) as usize; + let header_start = 4usize; + let header_end = header_start + header_len; + if header_end + 4 > bytes.len() { + return Err(FrameError::Truncated); + } + + let payload_len = u32::from_be_bytes( + bytes + .get(header_end..header_end + 4) + .ok_or(FrameError::Truncated)? + .try_into() + .expect("slice width checked"), + ) as usize; + let payload_start = header_end + 4; + let payload_end = payload_start + payload_len; + if payload_end != bytes.len() { + return Err(FrameError::Truncated); + } + + // WARNING: the wire format puts a 4-byte length prefix before each archived section. + // That means the section start is not guaranteed to satisfy rkyv's aligned-access + // requirements. The header is copied into one temporary `AlignedVec` here because + // routing cannot proceed safely without a validated header. + let aligned_header = align_section( + bytes + .get(header_start..header_end) + .ok_or(FrameError::Truncated)?, + ); + let archived_header = access::(&aligned_header) + .map_err(FrameError::InvalidHeader)?; + let header = deserialize::(archived_header) + .map_err(FrameError::InvalidHeader)?; + + Ok(ParsedFrame { + header, + payload_bytes: bytes + .get(payload_start..payload_end) + .ok_or(FrameError::Truncated)?, + }) + } +} + +/// Encodes a packet header and payload using the default codec. pub fn encode_packet

(header: &PacketHeader, payload: &P) -> Result where P: for<'a> Serialize< rkyv::api::high::HighSerializer, 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. - let header_bytes = to_bytes::(header).map_err(FrameError::Serialize)?; - let payload_bytes = to_bytes::(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 mut frame = Vec::with_capacity(8 + header_bytes.len() + payload_bytes.len()); - frame.extend_from_slice(&header_len.to_be_bytes()); - frame.extend_from_slice(&header_bytes); - frame.extend_from_slice(&payload_len.to_be_bytes()); - frame.extend_from_slice(&payload_bytes); - Ok(frame.into_boxed_slice()) + RkyvCodec::encode_packet(header, payload) } -/// Decodes a framed packet into a borrowed parsed view. -/// -/// # Errors -/// -/// Returns [`FrameError`] when the frame is truncated or the header archive is invalid. +/// Decodes a framed packet using the default codec. pub fn decode_frame(bytes: &[u8]) -> Result, FrameError> { - if bytes.len() < 8 { - return Err(FrameError::Truncated); - } - - let header_len = u32::from_be_bytes( - bytes - .get(0..4) - .ok_or(FrameError::Truncated)? - .try_into() - .expect("slice width checked"), - ) as usize; - let header_start = 4usize; - let header_end = header_start + header_len; - if header_end + 4 > bytes.len() { - return Err(FrameError::Truncated); - } - - let payload_len = u32::from_be_bytes( - bytes - .get(header_end..header_end + 4) - .ok_or(FrameError::Truncated)? - .try_into() - .expect("slice width checked"), - ) as usize; - let payload_start = header_end + 4; - let payload_end = payload_start + payload_len; - if payload_end != bytes.len() { - return Err(FrameError::Truncated); - } - - // WARNING: the wire format puts a 4-byte length prefix before each archived section. - // That means the section start is not guaranteed to satisfy rkyv's aligned-access - // requirements. The header is copied into one temporary `AlignedVec` here because - // routing cannot proceed safely without a validated header. - let aligned_header = align_section( - bytes - .get(header_start..header_end) - .ok_or(FrameError::Truncated)?, - ); - let archived_header = access::(&aligned_header) - .map_err(FrameError::InvalidHeader)?; - let header = - deserialize::(archived_header).map_err(FrameError::InvalidHeader)?; - - Ok(ParsedFrame { - header, - payload_bytes: bytes - .get(payload_start..payload_end) - .ok_or(FrameError::Truncated)?, - }) + 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(bytes: &[u8]) -> Result 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); - } -} diff --git a/src/protocol/introspection.rs b/src/protocol/introspection.rs index e2b34e1..1f046e1 100644 --- a/src/protocol/introspection.rs +++ b/src/protocol/introspection.rs @@ -1,4 +1,4 @@ -//! Required introspection payloads. +//! Required introspection payloads for discovery. use alloc::{string::String, vec::Vec}; use rkyv::{Archive, Deserialize, Serialize}; diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 5cbc603..f2d8229 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -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

(header: &PacketHeader, payload: &P) -> Result +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, FrameError> { + codec::decode_frame(bytes) +} diff --git a/src/protocol/tests/mod.rs b/src/protocol/tests/mod.rs new file mode 100644 index 0000000..e90d000 --- /dev/null +++ b/src/protocol/tests/mod.rs @@ -0,0 +1,2 @@ +mod protocol; +mod tree; diff --git a/src/protocol/tests/protocol.rs b/src/protocol/tests/protocol.rs new file mode 100644 index 0000000..0664c33 --- /dev/null +++ b/src/protocol/tests/protocol.rs @@ -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 { + 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) + )); +} diff --git a/src/protocol/tests/tree.rs b/src/protocol/tests/tree.rs new file mode 100644 index 0000000..d97b07c --- /dev/null +++ b/src/protocol/tests/tree.rs @@ -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 { + 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::::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, + >(&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:?}"), + } +} diff --git a/src/protocol/traits.rs b/src/protocol/traits.rs new file mode 100644 index 0000000..629f9bb --- /dev/null +++ b/src/protocol/traits.rs @@ -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 PacketFraming for T where T: FrameCodec + ?Sized {} + +/// Route resolution contract for endpoint path delivery. +pub trait RouteResolution: RouteProvider {} + +impl 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) -> Option<()>; + fn remove_pending(&mut self, key: &HookKey) -> Option; + fn remove_active(&mut self, key: &HookKey) -> Option; + 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) -> Option<()> { + HookTable::activate_pending(self, key, peer_path) + } + + fn remove_pending(&mut self, key: &HookKey) -> Option { + HookTable::remove_pending(self, key) + } + + fn remove_active(&mut self, key: &HookKey) -> Option { + 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; +} + +impl PacketProcessor for T +where + T: Endpoint + ?Sized, +{ + fn path(&self) -> &[String] { + Endpoint::path(self) + } + + fn receive( + &mut self, + ingress: &Ingress, + frame: FrameBytes, + ) -> Result { + Endpoint::receive(self, ingress, frame) + } +} diff --git a/src/tree/endpoint.rs b/src/protocol/tree/endpoint.rs similarity index 53% rename from src/tree/endpoint.rs rename to src/protocol/tree/endpoint.rs index 0e721f2..215a3c6 100644 --- a/src/tree/endpoint.rs +++ b/src/protocol/tree/endpoint.rs @@ -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::{ - 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}, +use crate::protocol::{ + CallMessage, DataMessage, EndpointIntrospection, FaultMessage, FrameBytes, FrameError, + HookTarget, LeafIntrospection, LeafIntrospectionSummary, PacketHeader, PacketType, + 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, - /// Local connection state. pub state: ConnectionState, } impl ChildRoute { - /// Creates a registered child route. pub fn registered(path: Vec) -> 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, - /// 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), - /// 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, - /// 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 for EndpointError { fn from(value: FrameError) -> Self { @@ -134,15 +113,25 @@ impl From for EndpointError { } } -impl From for EndpointError { - fn from(value: crate::protocol::ValidationError) -> Self { +impl From 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; +} + +/// Default endpoint implementation. #[derive(Debug, Default)] -pub struct Endpoint { +pub struct ProtocolEndpoint { path: Vec, parent_path: Option>, children: Vec, @@ -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, parent_path: Option>, @@ -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, @@ -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, @@ -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, @@ -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 { - 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 { - 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 { - 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 { - 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::(&LeafIntrospection { leaf_name: leaf_name.clone(), procedures: leaf.procedures.clone(), }) - .expect("leaf introspection should serialize") + .expect("serialize") .to_vec() } else { to_bytes::(&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 { - 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 { + 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, @@ -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> = 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}; +impl Endpoint for ProtocolEndpoint { + fn path(&self) -> &[String] { + &self.path + } - fn echo_leaf() -> LeafSpec { - LeafSpec { - name: String::from("echo"), - procedures: vec![String::from("org.product.v1.echo.roundtrip")], - behavior: LeafBehavior::Echo, + fn receive( + &mut self, + ingress: &Ingress, + frame: FrameBytes, + ) -> Result { + 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() + }), + 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() + }), + } + } } } - - #[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(), - }), - }; - - 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 { .. }) - )); - } } diff --git a/src/tree/hook.rs b/src/protocol/tree/hook.rs similarity index 77% rename from src/tree/hook.rs rename to src/protocol/tree/hook.rs index ae4599a..ccc4b34 100644 --- a/src/tree/hook.rs +++ b/src/protocol/tree/hook.rs @@ -12,7 +12,6 @@ pub struct HookKey { } impl HookKey { - /// Creates a new hook key. pub fn new(return_path: Vec, 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, - /// Hook host path. pub return_path: Vec, - /// Hook identifier. pub hook_id: u64, - /// Procedure anchored to the call. pub procedure_id: String, - /// Destination leaf from the call. pub dst_leaf: Option, } /// 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, - /// Hook identifier. pub hook_id: u64, - /// Expected direct peer for hook traffic. pub peer_path: Vec, - /// Procedure bound to the hook. pub procedure_id: String, - /// Original destination leaf. pub dst_leaf: Option, - /// 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) -> 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 { self.pending.remove(key) } - /// Removes active state. pub fn remove_active(&mut self, key: &HookKey) -> Option { 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() } diff --git a/src/tree/mod.rs b/src/protocol/tree/mod.rs similarity index 62% rename from src/tree/mod.rs rename to src/protocol/tree/mod.rs index fc593ca..a436512 100644 --- a/src/tree/mod.rs +++ b/src/protocol/tree/mod.rs @@ -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, +}; diff --git a/src/tree/routing.rs b/src/protocol/tree/routing.rs similarity index 68% rename from src/tree/routing.rs rename to src/protocol/tree/routing.rs index c54c8ba..3c27d8c 100644 --- a/src/tree/routing.rs +++ b/src/protocol/tree/routing.rs @@ -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,30 +76,56 @@ 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. +/// 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], + 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], + has_parent: bool, + dst_path: &[String], + ) -> RouteDecision { + let child = child_paths + .iter() + .enumerate() + .filter(|(_, child_path)| is_prefix(child_path, dst_path)) + .max_by_key(|(_, child_path)| child_path.len()) + .map(|(index, _)| index); + + if let Some(index) = child { + return RouteDecision::Child(index); + } + if local_path == dst_path { + return RouteDecision::Local; + } + if has_parent && !is_prefix(local_path, dst_path) { + return RouteDecision::Parent; + } + RouteDecision::Drop + } +} + pub fn route_destination( local_path: &[String], child_paths: &[Vec], has_parent: bool, dst_path: &[String], ) -> RouteDecision { - let child = child_paths - .iter() - .enumerate() - .filter(|(_, child_path)| is_prefix(child_path, dst_path)) - .max_by_key(|(_, child_path)| child_path.len()) - .map(|(index, _)| index); - - if let Some(index) = child { - return RouteDecision::Child(index); - } - if local_path == dst_path { - return RouteDecision::Local; - } - if has_parent && !is_prefix(local_path, dst_path) { - return RouteDecision::Parent; - } - RouteDecision::Drop + DefaultRouteProvider.route_destination(local_path, child_paths, has_parent, dst_path) } #[cfg(test)] @@ -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::::new(), &children, false, diff --git a/src/protocol/types.rs b/src/protocol/types.rs index 82cbe33..9bcc937 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -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}; diff --git a/src/protocol/validation.rs b/src/protocol/validation.rs index ebae7c3..9551b76 100644 --- a/src/protocol/validation.rs +++ b/src/protocol/validation.rs @@ -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()); - } -} diff --git a/src/transport/channel.rs b/src/transport/channel.rs deleted file mode 100644 index 5693778..0000000 --- a/src/transport/channel.rs +++ /dev/null @@ -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, - receiver: Receiver, -} - -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 { - 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); - } -} diff --git a/src/transport/mod.rs b/src/transport/mod.rs deleted file mode 100644 index cc159b4..0000000 --- a/src/transport/mod.rs +++ /dev/null @@ -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 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; -} diff --git a/src/transport/tcp.rs b/src/transport/tcp.rs deleted file mode 100644 index 151a3c6..0000000 --- a/src/transport/tcp.rs +++ /dev/null @@ -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(addr: A) -> Result { - 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 { - 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 { - 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 - ); - } -}