mirror of
https://github.com/Astatin3/unshell.git
synced 2026-06-08 22:38:01 -06:00
Reorganize protocol.
This commit is contained in:
Generated
-322
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+61
-81
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>
|
||||
where
|
||||
/// Trait for framing and unframing packets.
|
||||
pub trait FrameCodec {
|
||||
/// Encodes a packet header and payload into the canonical framed representation.
|
||||
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,
|
||||
>,
|
||||
{
|
||||
// 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.
|
||||
>;
|
||||
|
||||
/// 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: 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());
|
||||
@@ -122,14 +126,9 @@ where
|
||||
frame.extend_from_slice(&payload_len.to_be_bytes());
|
||||
frame.extend_from_slice(&payload_bytes);
|
||||
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,
|
||||
@@ -180,13 +179,25 @@ pub fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError> {
|
||||
.get(payload_start..payload_end)
|
||||
.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,4 +1,4 @@
|
||||
//! Required introspection payloads.
|
||||
//! Required introspection payloads for discovery.
|
||||
|
||||
use alloc::{string::String, vec::Vec};
|
||||
use rkyv::{Archive, Deserialize, Serialize};
|
||||
|
||||
+24
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
mod protocol;
|
||||
mod tree;
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
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 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 { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,13 +76,29 @@ 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 {
|
||||
) -> 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,
|
||||
dst_path: &[String],
|
||||
) -> RouteDecision {
|
||||
let child = child_paths
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -100,6 +116,16 @@ pub fn route_destination(
|
||||
return RouteDecision::Parent;
|
||||
}
|
||||
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)]
|
||||
@@ -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,
|
||||
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user