mirror of
https://github.com/Astatin3/unshell.git
synced 2026-06-08 14:36:01 -06:00
add treetest protocol simulator and ui
This commit is contained in:
Generated
+524
-1
@@ -22,6 +22,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -118,6 +124,15 @@ version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.2.0"
|
||||
@@ -189,12 +204,35 @@ dependencies = [
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
"itoa",
|
||||
"rustversion",
|
||||
"ryu",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -216,6 +254,48 @@ 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 = "crossterm"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"crossterm_winapi",
|
||||
"derive_more",
|
||||
"document-features",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm_winapi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.1"
|
||||
@@ -225,6 +305,71 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.2"
|
||||
@@ -236,12 +381,37 @@ dependencies = [
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.8"
|
||||
@@ -254,6 +424,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
@@ -274,7 +450,7 @@ version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -282,6 +458,11 @@ name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
@@ -346,6 +527,12 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
@@ -358,6 +545,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.2"
|
||||
@@ -368,6 +564,28 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -384,6 +602,17 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kasuari"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"portable-atomic",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
@@ -396,6 +625,27 @@ version = "0.2.185"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
|
||||
[[package]]
|
||||
name = "line-clipping"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -411,12 +661,33 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "munge"
|
||||
version = "0.4.7"
|
||||
@@ -437,6 +708,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -446,6 +723,15 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
@@ -475,6 +761,18 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
@@ -555,6 +853,69 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc"
|
||||
dependencies = [
|
||||
"instability",
|
||||
"ratatui-core",
|
||||
"ratatui-crossterm",
|
||||
"ratatui-widgets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"compact_str",
|
||||
"hashbrown 0.16.1",
|
||||
"indoc",
|
||||
"itertools",
|
||||
"kasuari",
|
||||
"lru",
|
||||
"strum",
|
||||
"thiserror",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-crossterm"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossterm",
|
||||
"instability",
|
||||
"ratatui-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-widgets"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"hashbrown 0.16.1",
|
||||
"indoc",
|
||||
"instability",
|
||||
"itertools",
|
||||
"line-clipping",
|
||||
"ratatui-core",
|
||||
"strum",
|
||||
"time",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -632,12 +993,40 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -709,6 +1098,37 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
@@ -721,6 +1141,12 @@ version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "static_init"
|
||||
version = "1.0.4"
|
||||
@@ -749,6 +1175,33 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@@ -791,6 +1244,27 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.11.0"
|
||||
@@ -806,6 +1280,17 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "treetest"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"crossterm",
|
||||
"ratatui",
|
||||
"thiserror",
|
||||
"unshell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
@@ -818,6 +1303,29 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-truncate"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
|
||||
dependencies = [
|
||||
"itertools",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
@@ -860,6 +1368,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
@@ -1038,6 +1552,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
members = [
|
||||
"ush-obfuscate",
|
||||
"base62",
|
||||
"treetest",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -60,7 +60,16 @@ impl ProtocolEndpoint {
|
||||
header: PacketHeader,
|
||||
message: DataMessage,
|
||||
) -> Result<EndpointOutcome, EndpointError> {
|
||||
let key = HookKey::new(self.path.clone(), header.hook_id.expect("validated"));
|
||||
let hook_id = header.hook_id.expect("validated");
|
||||
let key = self
|
||||
.hooks
|
||||
.active(&HookKey::new(self.path.clone(), hook_id))
|
||||
.map(|_| HookKey::new(self.path.clone(), hook_id))
|
||||
.or_else(|| {
|
||||
self.hooks
|
||||
.find_active_key_by_peer(hook_id, &header.src_path)
|
||||
})
|
||||
.unwrap_or_else(|| HookKey::new(self.path.clone(), hook_id));
|
||||
|
||||
if self.hooks.active(&key).is_none() {
|
||||
let matches = self.hooks.pending(&key).is_some_and(|pending| {
|
||||
|
||||
@@ -124,6 +124,25 @@ impl HookTable {
|
||||
self.active.get_mut(key)
|
||||
}
|
||||
|
||||
/// Finds an active hook key for a non-host peer receiving continued data.
|
||||
///
|
||||
/// Rationale: `hook_id` is scoped to the hook host, so a subordinate peer
|
||||
/// cannot derive the full key from the packet header alone. The peer uses
|
||||
/// its already-validated active state to recover the host-scoped key.
|
||||
pub fn find_active_key_by_peer(&self, hook_id: u64, peer_path: &[String]) -> Option<HookKey> {
|
||||
let mut matches = self
|
||||
.active
|
||||
.iter()
|
||||
.filter(|(_key, active)| active.hook_id == hook_id && active.peer_path == peer_path)
|
||||
.map(|(key, _)| key.clone());
|
||||
|
||||
let first = matches.next()?;
|
||||
if matches.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(first)
|
||||
}
|
||||
|
||||
pub fn pending_len(&self) -> usize {
|
||||
self.pending.len()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "treetest"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
crossbeam-channel = "0.5.15"
|
||||
crossterm = "0.29.0"
|
||||
ratatui = { version = "0.30.0", default-features = false, features = ["crossterm_0_29"] }
|
||||
thiserror = { workspace = true }
|
||||
unshell = { path = ".." }
|
||||
|
||||
[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"
|
||||
unsafe_op_in_unsafe_fn = "warn"
|
||||
unused_import_braces = "warn"
|
||||
unused_lifetimes = "warn"
|
||||
trivial_casts = "allow"
|
||||
@@ -0,0 +1,603 @@
|
||||
//! Ratatui application shell for the protocol demo.
|
||||
|
||||
use std::{io, time::Duration};
|
||||
|
||||
use crossterm::{
|
||||
event::{self, Event, KeyCode, KeyEventKind},
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use ratatui::{
|
||||
DefaultTerminal, Frame,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style, Stylize},
|
||||
text::{Line, Text},
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
model::{Selection, format_path},
|
||||
scenarios::built_in_scenarios,
|
||||
sim::{RecordedEvent, Simulation},
|
||||
};
|
||||
|
||||
/// Errors returned by the TUI application.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error(transparent)]
|
||||
Io(#[from] io::Error),
|
||||
#[error(transparent)]
|
||||
Sim(#[from] crate::sim::SimError),
|
||||
}
|
||||
|
||||
/// Starts the TUI application.
|
||||
pub fn run() -> Result<(), AppError> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let terminal = ratatui::init();
|
||||
let result = App::new()?.run(terminal);
|
||||
ratatui::restore();
|
||||
disable_raw_mode()?;
|
||||
execute!(io::stdout(), LeaveAlternateScreen)?;
|
||||
result
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct App {
|
||||
scenarios: Vec<crate::model::ScenarioDefinition>,
|
||||
scenario_index: usize,
|
||||
simulation: Simulation,
|
||||
selection_index: usize,
|
||||
selections: Vec<Selection>,
|
||||
status: String,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new() -> Result<Self, AppError> {
|
||||
let scenarios = built_in_scenarios();
|
||||
let simulation = Simulation::new(scenarios[0].clone())?;
|
||||
let selections = build_selections(&simulation);
|
||||
let selection_index = selections
|
||||
.iter()
|
||||
.position(|selection| *selection == simulation.initial_selection())
|
||||
.unwrap_or(0);
|
||||
Ok(Self {
|
||||
scenarios,
|
||||
scenario_index: 0,
|
||||
simulation,
|
||||
selection_index,
|
||||
selections,
|
||||
status: "Use arrows to move, Enter to switch scenarios, q to quit.".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn run(mut self, mut terminal: DefaultTerminal) -> Result<(), AppError> {
|
||||
loop {
|
||||
terminal.draw(|frame| self.render(frame))?;
|
||||
if event::poll(Duration::from_millis(100))?
|
||||
&& let Event::Key(key) = event::read()?
|
||||
&& key.kind == KeyEventKind::Press
|
||||
&& !self.handle_key(key.code)?
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, code: KeyCode) -> Result<bool, AppError> {
|
||||
match code {
|
||||
KeyCode::Char('q') => return Ok(false),
|
||||
KeyCode::Up => {
|
||||
if self.selection_index > 0 {
|
||||
self.selection_index -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if self.selection_index + 1 < self.selections.len() {
|
||||
self.selection_index += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Left => {
|
||||
if self.scenario_index > 0 {
|
||||
self.load_scenario(self.scenario_index - 1)?;
|
||||
}
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if self.scenario_index + 1 < self.scenarios.len() {
|
||||
self.load_scenario(self.scenario_index + 1)?;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let next = (self.scenario_index + 1) % self.scenarios.len();
|
||||
self.load_scenario(next)?;
|
||||
}
|
||||
KeyCode::Char('i') => {
|
||||
self.perform_introspection()?;
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
self.perform_echo()?;
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
self.perform_ping()?;
|
||||
}
|
||||
KeyCode::Char('c') => {
|
||||
self.perform_chunked()?;
|
||||
}
|
||||
KeyCode::Char('h') => {
|
||||
self.perform_chat_call()?;
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
self.perform_chat_data()?;
|
||||
}
|
||||
KeyCode::Char('b') => {
|
||||
self.perform_chat_bye()?;
|
||||
}
|
||||
KeyCode::Char('f') => {
|
||||
self.perform_invalid_fault_demo()?;
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
let processed = self.simulation.step()?;
|
||||
self.status = if processed {
|
||||
"Processed one queued frame.".to_owned()
|
||||
} else {
|
||||
"Network already idle.".to_owned()
|
||||
};
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
let steps = self.simulation.drain()?;
|
||||
self.status = format!("Drained {steps} queued frames.");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn load_scenario(&mut self, index: usize) -> Result<(), AppError> {
|
||||
self.scenario_index = index;
|
||||
self.simulation = Simulation::new(self.scenarios[index].clone())?;
|
||||
self.selections = build_selections(&self.simulation);
|
||||
self.selection_index = self
|
||||
.selections
|
||||
.iter()
|
||||
.position(|selection| *selection == self.simulation.initial_selection())
|
||||
.unwrap_or(0);
|
||||
self.status = format!("Loaded scenario: {}", self.scenarios[index].name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn selected(&self) -> &Selection {
|
||||
&self.selections[self.selection_index]
|
||||
}
|
||||
|
||||
fn perform_introspection(&mut self) -> Result<(), AppError> {
|
||||
match self.selected().clone() {
|
||||
Selection::Node(node_id) => {
|
||||
let result = self.simulation.call_endpoint_introspection(node_id)?;
|
||||
let steps = self.simulation.drain()?;
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
}
|
||||
Selection::Leaf { node_id, leaf_name } => {
|
||||
let result = self
|
||||
.simulation
|
||||
.call_leaf_introspection(node_id, &leaf_name)?;
|
||||
let steps = self.simulation.drain()?;
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn perform_echo(&mut self) -> Result<(), AppError> {
|
||||
if let Selection::Leaf { node_id, leaf_name } = self.selected().clone() {
|
||||
let result =
|
||||
self.simulation
|
||||
.call_echo_leaf(node_id, &leaf_name, "demo echo from root")?;
|
||||
let steps = self.simulation.drain()?;
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
} else {
|
||||
self.status = "Select a leaf first, then press e.".to_owned();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn perform_ping(&mut self) -> Result<(), AppError> {
|
||||
if let Selection::Node(node_id) = self.selected().clone() {
|
||||
if let Some(procedure_id) = self
|
||||
.simulation
|
||||
.node(node_id)
|
||||
.endpoint_procedures
|
||||
.first()
|
||||
.map(|procedure| procedure.procedure_id.clone())
|
||||
{
|
||||
let result = self.simulation.call_endpoint_procedure(
|
||||
node_id,
|
||||
&procedure_id,
|
||||
b"ping".to_vec(),
|
||||
)?;
|
||||
let steps = self.simulation.drain()?;
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
} else {
|
||||
self.status = "Selected node has no endpoint procedures.".to_owned();
|
||||
}
|
||||
} else {
|
||||
self.status = "Select a node first, then press p.".to_owned();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn perform_chunked(&mut self) -> Result<(), AppError> {
|
||||
if let Selection::Node(node_id) = self.selected().clone() {
|
||||
if let Some(procedure_id) = self
|
||||
.simulation
|
||||
.node(node_id)
|
||||
.endpoint_procedures
|
||||
.iter()
|
||||
.find(|procedure| {
|
||||
procedure.description.contains("chunk")
|
||||
|| procedure.procedure_id.contains("chunked")
|
||||
})
|
||||
.map(|procedure| procedure.procedure_id.clone())
|
||||
{
|
||||
let result = self.simulation.call_endpoint_procedure(
|
||||
node_id,
|
||||
&procedure_id,
|
||||
b"chunk please".to_vec(),
|
||||
)?;
|
||||
let steps = self.simulation.drain()?;
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
} else {
|
||||
self.status = "Selected node has no chunked procedure.".to_owned();
|
||||
}
|
||||
} else {
|
||||
self.status = "Select a node first, then press c.".to_owned();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn perform_chat_call(&mut self) -> Result<(), AppError> {
|
||||
if let Selection::Node(node_id) = self.selected().clone() {
|
||||
if let Some(procedure_id) = self
|
||||
.simulation
|
||||
.node(node_id)
|
||||
.endpoint_procedures
|
||||
.iter()
|
||||
.find(|procedure| procedure.procedure_id.contains("chat"))
|
||||
.map(|procedure| procedure.procedure_id.clone())
|
||||
{
|
||||
let result = self.simulation.call_endpoint_procedure(
|
||||
node_id,
|
||||
&procedure_id,
|
||||
b"open chat".to_vec(),
|
||||
)?;
|
||||
let steps = self.simulation.drain()?;
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
} else {
|
||||
self.status = "Selected node has no chat procedure.".to_owned();
|
||||
}
|
||||
} else {
|
||||
self.status = "Select a node first, then press h.".to_owned();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn perform_chat_data(&mut self) -> Result<(), AppError> {
|
||||
if let Some(hook_id) = self.simulation.hook_ids().last().copied() {
|
||||
let result =
|
||||
self.simulation
|
||||
.send_root_hook_data(hook_id, "hello from the root", false)?;
|
||||
let steps = self.simulation.drain()?;
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
} else {
|
||||
self.status = "No known hook yet. Press h to open chat first.".to_owned();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn perform_chat_bye(&mut self) -> Result<(), AppError> {
|
||||
if let Some(hook_id) = self.simulation.hook_ids().last().copied() {
|
||||
let result = self.simulation.send_root_hook_data(hook_id, "bye", true)?;
|
||||
let steps = self.simulation.drain()?;
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
} else {
|
||||
self.status = "No known hook yet. Press h to open chat first.".to_owned();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn perform_invalid_fault_demo(&mut self) -> Result<(), AppError> {
|
||||
if let Some(hook_id) = self.simulation.hook_ids().last().copied() {
|
||||
let root_id = crate::model::NodeId(0);
|
||||
if self.simulation.tree.nodes.len() > 1 {
|
||||
let attacker = crate::model::NodeId(1);
|
||||
let result = self.simulation.inject_invalid_peer_data(
|
||||
attacker,
|
||||
root_id,
|
||||
hook_id,
|
||||
"demo.endpoint.v1.chat.session",
|
||||
"spoofed data",
|
||||
)?;
|
||||
let steps = self.simulation.drain()?;
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
} else {
|
||||
self.status =
|
||||
"This scenario has no second node for invalid-peer traffic.".to_owned();
|
||||
}
|
||||
} else {
|
||||
self.status = "Open a hook first before injecting invalid traffic.".to_owned();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render(&self, frame: &mut Frame<'_>) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(14),
|
||||
Constraint::Length(8),
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
self.render_header(frame, chunks[0]);
|
||||
self.render_body(frame, chunks[1]);
|
||||
self.render_footer(frame, chunks[2]);
|
||||
}
|
||||
|
||||
fn render_header(&self, frame: &mut Frame<'_>, area: Rect) {
|
||||
let title = format!(
|
||||
"treetest | scenario {} / {}: {}",
|
||||
self.scenario_index + 1,
|
||||
self.scenarios.len(),
|
||||
self.scenarios[self.scenario_index].name
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(title).block(Block::default().borders(Borders::ALL).title("Scenario")),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_body(&self, frame: &mut Frame<'_>, area: Rect) {
|
||||
let columns = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length(34),
|
||||
Constraint::Percentage(36),
|
||||
Constraint::Percentage(32),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let scenario_items = self
|
||||
.scenarios
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, scenario)| {
|
||||
let label = if index == self.scenario_index {
|
||||
format!("> {}", scenario.name)
|
||||
} else {
|
||||
format!(" {}", scenario.name)
|
||||
};
|
||||
ListItem::new(label)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
frame.render_widget(
|
||||
List::new(scenario_items)
|
||||
.block(Block::default().borders(Borders::ALL).title("Scenarios")),
|
||||
columns[0],
|
||||
);
|
||||
|
||||
let center = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(54), Constraint::Percentage(46)])
|
||||
.split(columns[1]);
|
||||
self.render_selection_list(frame, center[0]);
|
||||
self.render_inspector(frame, center[1]);
|
||||
|
||||
let right = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(columns[2]);
|
||||
self.render_trace(frame, right[0]);
|
||||
self.render_hooks(frame, right[1]);
|
||||
}
|
||||
|
||||
fn render_selection_list(&self, frame: &mut Frame<'_>, area: Rect) {
|
||||
let items = self
|
||||
.selections
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, selection)| {
|
||||
let label = match selection {
|
||||
Selection::Node(node_id) => {
|
||||
let node = self.simulation.node(*node_id);
|
||||
format!(
|
||||
"{} {}",
|
||||
if index == self.selection_index {
|
||||
">"
|
||||
} else {
|
||||
" "
|
||||
},
|
||||
node.display_path()
|
||||
)
|
||||
}
|
||||
Selection::Leaf { node_id, leaf_name } => {
|
||||
format!(
|
||||
"{} {} :: {}",
|
||||
if index == self.selection_index {
|
||||
">"
|
||||
} else {
|
||||
" "
|
||||
},
|
||||
self.simulation.node(*node_id).display_path(),
|
||||
leaf_name
|
||||
)
|
||||
}
|
||||
};
|
||||
ListItem::new(label)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
frame.render_widget(
|
||||
List::new(items).block(Block::default().borders(Borders::ALL).title("Tree")),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_inspector(&self, frame: &mut Frame<'_>, area: Rect) {
|
||||
let selection = self.selected();
|
||||
let body = match selection {
|
||||
Selection::Node(node_id) => {
|
||||
let node = self.simulation.node(*node_id);
|
||||
let mut lines = vec![
|
||||
Line::from(node.title.clone()).bold(),
|
||||
Line::from(node.description.clone()),
|
||||
Line::from(format!("Path: {}", node.display_path())),
|
||||
Line::from(format!("Children: {}", node.children.len())),
|
||||
Line::from(format!("Leaves: {}", node.leaves.len())),
|
||||
Line::from(format!(
|
||||
"Endpoint procedures: {}",
|
||||
node.endpoint_procedures.len()
|
||||
)),
|
||||
Line::default(),
|
||||
Line::from("Endpoint procedures:"),
|
||||
];
|
||||
lines.extend(
|
||||
node.endpoint_procedures
|
||||
.iter()
|
||||
.map(|procedure| Line::from(format!("- {}", procedure.procedure_id))),
|
||||
);
|
||||
lines.extend(
|
||||
node.leaves
|
||||
.iter()
|
||||
.map(|leaf| Line::from(format!("- leaf {}", leaf.name))),
|
||||
);
|
||||
Text::from(lines)
|
||||
}
|
||||
Selection::Leaf { node_id, leaf_name } => {
|
||||
let node = self.simulation.node(*node_id);
|
||||
let leaf = node
|
||||
.leaves
|
||||
.iter()
|
||||
.find(|leaf| &leaf.name == leaf_name)
|
||||
.expect("selection should stay valid");
|
||||
Text::from(vec![
|
||||
Line::from(format!("Leaf {}", leaf.name)).bold(),
|
||||
Line::from(leaf.description.clone()),
|
||||
Line::from(format!("Node: {}", node.display_path())),
|
||||
Line::from(format!("Procedures: {}", leaf.procedures.join(", "))),
|
||||
])
|
||||
}
|
||||
};
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(body)
|
||||
.block(Block::default().borders(Borders::ALL).title("Inspector"))
|
||||
.wrap(Wrap { trim: true }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_trace(&self, frame: &mut Frame<'_>, area: Rect) {
|
||||
let items = self
|
||||
.simulation
|
||||
.trace
|
||||
.iter()
|
||||
.rev()
|
||||
.take(12)
|
||||
.map(|event| {
|
||||
ListItem::new(format!(
|
||||
"#{:03} {} | {}",
|
||||
event.tick, event.node_path, event.summary
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
frame.render_widget(
|
||||
List::new(items).block(Block::default().borders(Borders::ALL).title("Trace")),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_hooks(&self, frame: &mut Frame<'_>, area: Rect) {
|
||||
let items = self
|
||||
.simulation
|
||||
.hooks
|
||||
.values()
|
||||
.map(|hook| {
|
||||
let status = if hook.closed { "closed" } else { "open" };
|
||||
ListItem::new(format!(
|
||||
"#{} {} -> {} [{}] {}",
|
||||
hook.hook_id,
|
||||
format_path(&hook.host_path),
|
||||
format_path(&hook.peer_path),
|
||||
status,
|
||||
hook.last_message,
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
frame.render_widget(
|
||||
List::new(items).block(Block::default().borders(Borders::ALL).title("Hooks")),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_footer(&self, frame: &mut Frame<'_>, area: Rect) {
|
||||
let help = vec![
|
||||
Line::from(self.status.clone()).style(Style::default().add_modifier(Modifier::BOLD)),
|
||||
Line::from(
|
||||
"Keys: arrows move selection/scenario | i introspect | e echo leaf | p ping | c chunked | h open chat | d chat data | b chat bye | f invalid peer | s step | a autoplay | q quit",
|
||||
),
|
||||
Line::from(format!(
|
||||
"Current selection: {}",
|
||||
self.simulation.selection_summary(self.selected())
|
||||
)),
|
||||
Line::from(match self.simulation.recorded_events.last() {
|
||||
Some(RecordedEvent::Data {
|
||||
node_path, message, ..
|
||||
}) => {
|
||||
format!(
|
||||
"Last local event: Data at {node_path} ({})",
|
||||
String::from_utf8_lossy(&message.data)
|
||||
)
|
||||
}
|
||||
Some(RecordedEvent::Fault {
|
||||
node_path, message, ..
|
||||
}) => {
|
||||
format!(
|
||||
"Last local event: Fault at {node_path} (0x{:02X})",
|
||||
message.fault.0
|
||||
)
|
||||
}
|
||||
Some(RecordedEvent::Call {
|
||||
node_path, message, ..
|
||||
}) => {
|
||||
format!(
|
||||
"Last local event: Call at {node_path} ({})",
|
||||
message.procedure_id
|
||||
)
|
||||
}
|
||||
None => "Last local event: none yet".to_owned(),
|
||||
}),
|
||||
];
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Text::from(help))
|
||||
.block(Block::default().borders(Borders::ALL).title("Status"))
|
||||
.wrap(Wrap { trim: true }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_selections(simulation: &Simulation) -> Vec<Selection> {
|
||||
let mut selections = Vec::new();
|
||||
for node in &simulation.tree.nodes {
|
||||
selections.push(Selection::Node(node.id));
|
||||
for leaf in &node.leaves {
|
||||
selections.push(Selection::Leaf {
|
||||
node_id: node.id,
|
||||
leaf_name: leaf.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
selections
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Interactive UnShell protocol demo crate.
|
||||
//!
|
||||
//! This crate intentionally keeps protocol logic in the root `unshell` crate and
|
||||
//! uses that implementation as a consumer would: by building endpoint topologies,
|
||||
//! simulating packet transport, and rendering an inspector UI around the results.
|
||||
|
||||
pub mod app;
|
||||
pub mod model;
|
||||
pub mod scenarios;
|
||||
pub mod sim;
|
||||
|
||||
pub use app::run;
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Binary entry point for the protocol demo.
|
||||
|
||||
fn main() -> Result<(), treetest::app::AppError> {
|
||||
treetest::run()
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Static tree and scenario metadata used by the simulator and UI.
|
||||
//!
|
||||
//! The protocol runtime already owns routing and hook validation state. This
|
||||
//! module adds a second, UI-friendly model so the demo can keep titles,
|
||||
//! descriptions, selection ids, and behavior metadata without polluting the core
|
||||
//! protocol implementation.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Stable identifier for a node in a demo tree.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct NodeId(pub usize);
|
||||
|
||||
/// Supported demo leaf kinds.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LeafKind {
|
||||
/// Uses the built-in echo leaf behavior from `unshell`.
|
||||
Echo,
|
||||
}
|
||||
|
||||
/// Static leaf declaration used to build a protocol endpoint and describe it in the UI.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LeafSpec {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub kind: LeafKind,
|
||||
pub procedures: Vec<String>,
|
||||
}
|
||||
|
||||
/// Demo-only endpoint procedure behaviors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EndpointProcedureKind {
|
||||
/// Single response that completes the hook immediately.
|
||||
Ping,
|
||||
/// Multi-packet response used to demonstrate chunking and finalization.
|
||||
ChunkedGreeting,
|
||||
/// Bidirectional hook that remains active until one side sends `bye`.
|
||||
Chat,
|
||||
}
|
||||
|
||||
/// Static endpoint procedure definition.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EndpointProcedureSpec {
|
||||
pub procedure_id: String,
|
||||
pub description: String,
|
||||
pub kind: EndpointProcedureKind,
|
||||
}
|
||||
|
||||
/// Recursive scenario node specification.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NodeSpec {
|
||||
/// Empty for the root endpoint.
|
||||
pub segment: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub leaves: Vec<LeafSpec>,
|
||||
pub endpoint_procedures: Vec<EndpointProcedureSpec>,
|
||||
pub children: Vec<NodeSpec>,
|
||||
}
|
||||
|
||||
/// Concrete node metadata used after scenario construction.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DemoNode {
|
||||
pub id: NodeId,
|
||||
pub parent: Option<NodeId>,
|
||||
pub children: Vec<NodeId>,
|
||||
pub path: Vec<String>,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub leaves: Vec<LeafSpec>,
|
||||
pub endpoint_procedures: Vec<EndpointProcedureSpec>,
|
||||
}
|
||||
|
||||
impl DemoNode {
|
||||
/// Returns a display path that keeps the root easy to recognize in the UI.
|
||||
pub fn display_path(&self) -> String {
|
||||
format_path(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fully flattened tree metadata used by the simulator and UI.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DemoTree {
|
||||
pub root: NodeId,
|
||||
pub nodes: Vec<DemoNode>,
|
||||
path_index: BTreeMap<Vec<String>, NodeId>,
|
||||
}
|
||||
|
||||
impl DemoTree {
|
||||
/// Builds a flattened tree from a recursive specification.
|
||||
pub fn from_root(spec: &NodeSpec) -> Self {
|
||||
let mut nodes = Vec::new();
|
||||
let mut path_index = BTreeMap::new();
|
||||
let root = Self::push_node(spec, None, &[], &mut nodes, &mut path_index);
|
||||
Self {
|
||||
root,
|
||||
nodes,
|
||||
path_index,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_node(
|
||||
spec: &NodeSpec,
|
||||
parent: Option<NodeId>,
|
||||
base_path: &[String],
|
||||
nodes: &mut Vec<DemoNode>,
|
||||
path_index: &mut BTreeMap<Vec<String>, NodeId>,
|
||||
) -> NodeId {
|
||||
let id = NodeId(nodes.len());
|
||||
let path = if spec.segment.is_empty() {
|
||||
base_path.to_vec()
|
||||
} else {
|
||||
let mut next = base_path.to_vec();
|
||||
next.push(spec.segment.clone());
|
||||
next
|
||||
};
|
||||
|
||||
nodes.push(DemoNode {
|
||||
id,
|
||||
parent,
|
||||
children: Vec::new(),
|
||||
path: path.clone(),
|
||||
title: spec.title.clone(),
|
||||
description: spec.description.clone(),
|
||||
leaves: spec.leaves.clone(),
|
||||
endpoint_procedures: spec.endpoint_procedures.clone(),
|
||||
});
|
||||
path_index.insert(path.clone(), id);
|
||||
|
||||
let child_ids = spec
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| Self::push_node(child, Some(id), &path, nodes, path_index))
|
||||
.collect::<Vec<_>>();
|
||||
nodes[id.0].children = child_ids;
|
||||
id
|
||||
}
|
||||
|
||||
/// Returns the node with the given id.
|
||||
pub fn node(&self, id: NodeId) -> &DemoNode {
|
||||
&self.nodes[id.0]
|
||||
}
|
||||
|
||||
/// Resolves an absolute path to a node id.
|
||||
pub fn find_by_path(&self, path: &[String]) -> Option<NodeId> {
|
||||
self.path_index.get(path).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// Root-focused interaction target shown in the inspector.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Selection {
|
||||
Node(NodeId),
|
||||
Leaf { node_id: NodeId, leaf_name: String },
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
/// Returns the owning node of this selection.
|
||||
pub fn node_id(&self) -> NodeId {
|
||||
match self {
|
||||
Self::Node(node_id) => *node_id,
|
||||
Self::Leaf { node_id, .. } => *node_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing scenario definition.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ScenarioDefinition {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub highlights: Vec<String>,
|
||||
pub root: NodeSpec,
|
||||
pub initial_selection: Selection,
|
||||
}
|
||||
|
||||
/// Formats a path the same way throughout the UI and tests.
|
||||
pub fn format_path(path: &[String]) -> String {
|
||||
if path.is_empty() {
|
||||
"/".to_owned()
|
||||
} else {
|
||||
format!("/{}", path.join("/"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Demo scenarios ranging from simple introspection to multi-hop hooks.
|
||||
|
||||
use crate::model::{
|
||||
EndpointProcedureKind, EndpointProcedureSpec, LeafKind, LeafSpec, NodeId, NodeSpec,
|
||||
ScenarioDefinition, Selection,
|
||||
};
|
||||
|
||||
const PROC_PING: &str = "demo.endpoint.v1.control.ping";
|
||||
const PROC_CHUNKED: &str = "demo.endpoint.v1.stream.chunked_greeting";
|
||||
const PROC_CHAT: &str = "demo.endpoint.v1.chat.session";
|
||||
const PROC_ECHO: &str = "demo.leaf.v1.echo.invoke";
|
||||
|
||||
/// Returns all built-in demo scenarios.
|
||||
pub fn built_in_scenarios() -> Vec<ScenarioDefinition> {
|
||||
vec![
|
||||
local_introspection(),
|
||||
echo_leaf(),
|
||||
branch_routing(),
|
||||
bidirectional_chat(),
|
||||
fault_showcase(),
|
||||
complex_tree(),
|
||||
]
|
||||
}
|
||||
|
||||
fn local_introspection() -> ScenarioDefinition {
|
||||
ScenarioDefinition {
|
||||
name: "Local Introspection".to_owned(),
|
||||
description:
|
||||
"Inspect the root and its immediate child using the required empty procedure id."
|
||||
.to_owned(),
|
||||
highlights: vec![
|
||||
"Blank procedure calls map to protocol introspection.".to_owned(),
|
||||
"Leaf introspection uses the same hook path as endpoint introspection.".to_owned(),
|
||||
],
|
||||
root: NodeSpec {
|
||||
segment: String::new(),
|
||||
title: "Root".to_owned(),
|
||||
description: "The operator-controlled root endpoint.".to_owned(),
|
||||
leaves: Vec::new(),
|
||||
endpoint_procedures: vec![EndpointProcedureSpec {
|
||||
procedure_id: PROC_PING.to_owned(),
|
||||
description: "Single-packet endpoint response for baseline testing.".to_owned(),
|
||||
kind: EndpointProcedureKind::Ping,
|
||||
}],
|
||||
children: vec![NodeSpec {
|
||||
segment: "alpha".to_owned(),
|
||||
title: "Alpha".to_owned(),
|
||||
description: "A minimal child endpoint with one echo leaf.".to_owned(),
|
||||
leaves: vec![LeafSpec {
|
||||
name: "echo".to_owned(),
|
||||
description: "Echoes bytes back through the declared hook.".to_owned(),
|
||||
kind: LeafKind::Echo,
|
||||
procedures: vec![PROC_ECHO.to_owned()],
|
||||
}],
|
||||
endpoint_procedures: Vec::new(),
|
||||
children: Vec::new(),
|
||||
}],
|
||||
},
|
||||
initial_selection: Selection::Node(NodeId(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn echo_leaf() -> ScenarioDefinition {
|
||||
ScenarioDefinition {
|
||||
name: "Echo Leaf".to_owned(),
|
||||
description: "Call a concrete leaf and watch the hook finish normally.".to_owned(),
|
||||
highlights: vec![
|
||||
"The leaf uses the built-in `Echo` behavior from the core runtime.".to_owned(),
|
||||
"The final response sets `end_hook = true`.".to_owned(),
|
||||
],
|
||||
root: NodeSpec {
|
||||
segment: String::new(),
|
||||
title: "Root".to_owned(),
|
||||
description: "The operator origin for all demo calls.".to_owned(),
|
||||
leaves: Vec::new(),
|
||||
endpoint_procedures: Vec::new(),
|
||||
children: vec![NodeSpec {
|
||||
segment: "services".to_owned(),
|
||||
title: "Services".to_owned(),
|
||||
description: "Hosts protocol-visible demo leaves.".to_owned(),
|
||||
leaves: vec![LeafSpec {
|
||||
name: "echo".to_owned(),
|
||||
description: "Simple echo leaf.".to_owned(),
|
||||
kind: LeafKind::Echo,
|
||||
procedures: vec![PROC_ECHO.to_owned()],
|
||||
}],
|
||||
endpoint_procedures: vec![EndpointProcedureSpec {
|
||||
procedure_id: PROC_CHUNKED.to_owned(),
|
||||
description: "Three response packets with a clear final chunk.".to_owned(),
|
||||
kind: EndpointProcedureKind::ChunkedGreeting,
|
||||
}],
|
||||
children: Vec::new(),
|
||||
}],
|
||||
},
|
||||
initial_selection: Selection::Leaf {
|
||||
node_id: NodeId(1),
|
||||
leaf_name: "echo".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn branch_routing() -> ScenarioDefinition {
|
||||
ScenarioDefinition {
|
||||
name: "Branch Routing".to_owned(),
|
||||
description: "Demonstrates longest-prefix routing across sibling branches.".to_owned(),
|
||||
highlights: vec![
|
||||
"Packets descend through the most specific child path.".to_owned(),
|
||||
"Responses route back upward and then down into the hook host subtree.".to_owned(),
|
||||
],
|
||||
root: NodeSpec {
|
||||
segment: String::new(),
|
||||
title: "Root".to_owned(),
|
||||
description: "The routing apex.".to_owned(),
|
||||
leaves: Vec::new(),
|
||||
endpoint_procedures: Vec::new(),
|
||||
children: vec![
|
||||
NodeSpec {
|
||||
segment: "alpha".to_owned(),
|
||||
title: "Alpha".to_owned(),
|
||||
description: "Intermediate branch.".to_owned(),
|
||||
leaves: Vec::new(),
|
||||
endpoint_procedures: Vec::new(),
|
||||
children: vec![NodeSpec {
|
||||
segment: "beta".to_owned(),
|
||||
title: "Beta".to_owned(),
|
||||
description: "Nested endpoint for longest-prefix routing.".to_owned(),
|
||||
leaves: vec![LeafSpec {
|
||||
name: "echo".to_owned(),
|
||||
description: "Nested echo leaf.".to_owned(),
|
||||
kind: LeafKind::Echo,
|
||||
procedures: vec![PROC_ECHO.to_owned()],
|
||||
}],
|
||||
endpoint_procedures: vec![EndpointProcedureSpec {
|
||||
procedure_id: PROC_PING.to_owned(),
|
||||
description: "Checks routed endpoint procedures.".to_owned(),
|
||||
kind: EndpointProcedureKind::Ping,
|
||||
}],
|
||||
children: Vec::new(),
|
||||
}],
|
||||
},
|
||||
NodeSpec {
|
||||
segment: "gamma".to_owned(),
|
||||
title: "Gamma".to_owned(),
|
||||
description: "Sibling endpoint used to make the route tree non-trivial."
|
||||
.to_owned(),
|
||||
leaves: vec![LeafSpec {
|
||||
name: "echo".to_owned(),
|
||||
description: "Sibling echo leaf.".to_owned(),
|
||||
kind: LeafKind::Echo,
|
||||
procedures: vec![PROC_ECHO.to_owned()],
|
||||
}],
|
||||
endpoint_procedures: Vec::new(),
|
||||
children: Vec::new(),
|
||||
},
|
||||
],
|
||||
},
|
||||
initial_selection: Selection::Node(NodeId(2)),
|
||||
}
|
||||
}
|
||||
|
||||
fn bidirectional_chat() -> ScenarioDefinition {
|
||||
ScenarioDefinition {
|
||||
name: "Bidirectional Chat".to_owned(),
|
||||
description: "Keeps a hook active so the root can continue sending `Data` packets.".to_owned(),
|
||||
highlights: vec![
|
||||
"After activation, either side may send hook data first.".to_owned(),
|
||||
"The chat handler exists outside the core runtime so the demo can show application-level behavior without changing the protocol.".to_owned(),
|
||||
],
|
||||
root: NodeSpec {
|
||||
segment: String::new(),
|
||||
title: "Root".to_owned(),
|
||||
description: "The operator-controlled hook host.".to_owned(),
|
||||
leaves: Vec::new(),
|
||||
endpoint_procedures: Vec::new(),
|
||||
children: vec![NodeSpec {
|
||||
segment: "chat".to_owned(),
|
||||
title: "Chat Host".to_owned(),
|
||||
description: "Endpoint with a long-lived hook-backed chat procedure.".to_owned(),
|
||||
leaves: Vec::new(),
|
||||
endpoint_procedures: vec![EndpointProcedureSpec {
|
||||
procedure_id: PROC_CHAT.to_owned(),
|
||||
description: "Bidirectional hook that replies until it sees `bye`.".to_owned(),
|
||||
kind: EndpointProcedureKind::Chat,
|
||||
}],
|
||||
children: Vec::new(),
|
||||
}],
|
||||
},
|
||||
initial_selection: Selection::Node(NodeId(1)),
|
||||
}
|
||||
}
|
||||
|
||||
fn fault_showcase() -> ScenarioDefinition {
|
||||
ScenarioDefinition {
|
||||
name: "Fault Showcase".to_owned(),
|
||||
description: "Use valid and invalid calls to trigger protocol-level faults.".to_owned(),
|
||||
highlights: vec![
|
||||
"Unknown leaf and unknown procedure faults are attributed to the declared hook."
|
||||
.to_owned(),
|
||||
"Packets with an invalid hook peer are rejected and faulted locally.".to_owned(),
|
||||
],
|
||||
root: NodeSpec {
|
||||
segment: String::new(),
|
||||
title: "Root".to_owned(),
|
||||
description: "Runs fault-focused experiments.".to_owned(),
|
||||
leaves: Vec::new(),
|
||||
endpoint_procedures: Vec::new(),
|
||||
children: vec![NodeSpec {
|
||||
segment: "faults".to_owned(),
|
||||
title: "Fault Lab".to_owned(),
|
||||
description: "One endpoint with one known leaf and one known procedure.".to_owned(),
|
||||
leaves: vec![LeafSpec {
|
||||
name: "echo".to_owned(),
|
||||
description: "Valid leaf used to contrast unknown-leaf failures.".to_owned(),
|
||||
kind: LeafKind::Echo,
|
||||
procedures: vec![PROC_ECHO.to_owned()],
|
||||
}],
|
||||
endpoint_procedures: vec![EndpointProcedureSpec {
|
||||
procedure_id: PROC_PING.to_owned(),
|
||||
description: "Known procedure for contrast against unknown procedures."
|
||||
.to_owned(),
|
||||
kind: EndpointProcedureKind::Ping,
|
||||
}],
|
||||
children: Vec::new(),
|
||||
}],
|
||||
},
|
||||
initial_selection: Selection::Node(NodeId(1)),
|
||||
}
|
||||
}
|
||||
|
||||
fn complex_tree() -> ScenarioDefinition {
|
||||
ScenarioDefinition {
|
||||
name: "Complex Tree".to_owned(),
|
||||
description: "A larger topology that combines leaf calls, endpoint procedures, and nested routing.".to_owned(),
|
||||
highlights: vec![
|
||||
"Use this as a sandbox after learning the smaller scenarios.".to_owned(),
|
||||
"The tree contains both leaf and endpoint interactions so the UI inspector stays interesting.".to_owned(),
|
||||
],
|
||||
root: NodeSpec {
|
||||
segment: String::new(),
|
||||
title: "Root".to_owned(),
|
||||
description: "Primary operator endpoint.".to_owned(),
|
||||
leaves: Vec::new(),
|
||||
endpoint_procedures: vec![EndpointProcedureSpec {
|
||||
procedure_id: PROC_PING.to_owned(),
|
||||
description: "Root-local endpoint procedure for comparison with remote calls.".to_owned(),
|
||||
kind: EndpointProcedureKind::Ping,
|
||||
}],
|
||||
children: vec![
|
||||
NodeSpec {
|
||||
segment: "alpha".to_owned(),
|
||||
title: "Alpha".to_owned(),
|
||||
description: "Left branch.".to_owned(),
|
||||
leaves: vec![LeafSpec {
|
||||
name: "echo".to_owned(),
|
||||
description: "Echo leaf on alpha.".to_owned(),
|
||||
kind: LeafKind::Echo,
|
||||
procedures: vec![PROC_ECHO.to_owned()],
|
||||
}],
|
||||
endpoint_procedures: vec![EndpointProcedureSpec {
|
||||
procedure_id: PROC_CHUNKED.to_owned(),
|
||||
description: "Chunked endpoint response.".to_owned(),
|
||||
kind: EndpointProcedureKind::ChunkedGreeting,
|
||||
}],
|
||||
children: vec![NodeSpec {
|
||||
segment: "deep".to_owned(),
|
||||
title: "Alpha Deep".to_owned(),
|
||||
description: "Nested node for multi-hop traffic.".to_owned(),
|
||||
leaves: vec![LeafSpec {
|
||||
name: "echo".to_owned(),
|
||||
description: "Deep nested echo leaf.".to_owned(),
|
||||
kind: LeafKind::Echo,
|
||||
procedures: vec![PROC_ECHO.to_owned()],
|
||||
}],
|
||||
endpoint_procedures: Vec::new(),
|
||||
children: Vec::new(),
|
||||
}],
|
||||
},
|
||||
NodeSpec {
|
||||
segment: "beta".to_owned(),
|
||||
title: "Beta".to_owned(),
|
||||
description: "Right branch.".to_owned(),
|
||||
leaves: Vec::new(),
|
||||
endpoint_procedures: vec![EndpointProcedureSpec {
|
||||
procedure_id: PROC_CHAT.to_owned(),
|
||||
description: "Long-lived chat procedure.".to_owned(),
|
||||
kind: EndpointProcedureKind::Chat,
|
||||
}],
|
||||
children: vec![NodeSpec {
|
||||
segment: "gamma".to_owned(),
|
||||
title: "Gamma".to_owned(),
|
||||
description: "Nested branch with its own ping procedure.".to_owned(),
|
||||
leaves: vec![LeafSpec {
|
||||
name: "echo".to_owned(),
|
||||
description: "Gamma echo leaf.".to_owned(),
|
||||
kind: LeafKind::Echo,
|
||||
procedures: vec![PROC_ECHO.to_owned()],
|
||||
}],
|
||||
endpoint_procedures: vec![EndpointProcedureSpec {
|
||||
procedure_id: PROC_PING.to_owned(),
|
||||
description: "Nested ping procedure.".to_owned(),
|
||||
kind: EndpointProcedureKind::Ping,
|
||||
}],
|
||||
children: Vec::new(),
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
initial_selection: Selection::Node(NodeId(0)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,842 @@
|
||||
//! Crossbeam-backed protocol simulation.
|
||||
//!
|
||||
//! The simulator never opens real sockets. Each endpoint gets a mailbox, and
|
||||
//! forwarded frames are pushed into the next hop's queue. That makes routing and
|
||||
//! hook behavior deterministic enough for tests while still feeling like traffic.
|
||||
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
|
||||
use crossbeam_channel::{Receiver, Sender, TryRecvError, unbounded};
|
||||
use thiserror::Error;
|
||||
use unshell::protocol::tree::{
|
||||
ChildRoute, ConnectionState, Endpoint, Ingress, LeafBehavior, LocalEvent, ProtocolEndpoint,
|
||||
};
|
||||
use unshell::protocol::{
|
||||
CallMessage, DataMessage, FaultMessage, FrameBytes, PacketHeader, PacketType, decode_frame,
|
||||
};
|
||||
|
||||
use crate::model::{
|
||||
DemoTree, EndpointProcedureKind, EndpointProcedureSpec, LeafKind, NodeId, ScenarioDefinition,
|
||||
Selection, format_path,
|
||||
};
|
||||
|
||||
/// User-facing outcome of a root-originated action.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ActionResult {
|
||||
pub label: String,
|
||||
pub hook_id: Option<u64>,
|
||||
}
|
||||
|
||||
/// Snapshot of a hook interaction observed by the demo.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HookSnapshot {
|
||||
pub hook_id: u64,
|
||||
pub host_path: Vec<String>,
|
||||
pub peer_path: Vec<String>,
|
||||
pub procedure_id: String,
|
||||
pub closed: bool,
|
||||
pub last_message: String,
|
||||
}
|
||||
|
||||
/// Trace entry shown in the UI and asserted in tests.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TraceEvent {
|
||||
pub tick: u64,
|
||||
pub node_path: String,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// Summary of one local protocol event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RecordedEvent {
|
||||
Data {
|
||||
node_path: String,
|
||||
header: PacketHeader,
|
||||
message: DataMessage,
|
||||
},
|
||||
Fault {
|
||||
node_path: String,
|
||||
header: PacketHeader,
|
||||
message: FaultMessage,
|
||||
},
|
||||
Call {
|
||||
node_path: String,
|
||||
header: PacketHeader,
|
||||
message: CallMessage,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SimNode {
|
||||
parent: Option<NodeId>,
|
||||
children: Vec<NodeId>,
|
||||
endpoint: ProtocolEndpoint,
|
||||
tx: Sender<Envelope>,
|
||||
rx: Receiver<Envelope>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Envelope {
|
||||
ingress: Ingress,
|
||||
frame: FrameBytes,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ChatSession {
|
||||
node_id: NodeId,
|
||||
hook_id: u64,
|
||||
host_path: Vec<String>,
|
||||
procedure_id: String,
|
||||
}
|
||||
|
||||
/// Errors raised by the demo simulator.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SimError {
|
||||
#[error("node {0} was not found")]
|
||||
UnknownNode(String),
|
||||
#[error("leaf {leaf_name} was not found on {node_path}")]
|
||||
UnknownLeaf {
|
||||
node_path: String,
|
||||
leaf_name: String,
|
||||
},
|
||||
#[error("procedure {procedure_id} was not found on {node_path}")]
|
||||
UnknownProcedure {
|
||||
node_path: String,
|
||||
procedure_id: String,
|
||||
},
|
||||
#[error("hook {0} was not found")]
|
||||
UnknownHook(u64),
|
||||
#[error("protocol runtime error: {0}")]
|
||||
Protocol(String),
|
||||
}
|
||||
|
||||
/// Fully built simulation for one scenario.
|
||||
#[derive(Debug)]
|
||||
pub struct Simulation {
|
||||
pub scenario: ScenarioDefinition,
|
||||
pub tree: DemoTree,
|
||||
nodes: Vec<SimNode>,
|
||||
root_id: NodeId,
|
||||
next_tick: u64,
|
||||
pub trace: VecDeque<TraceEvent>,
|
||||
pub recorded_events: Vec<RecordedEvent>,
|
||||
pub hooks: BTreeMap<u64, HookSnapshot>,
|
||||
chat_sessions: BTreeMap<u64, ChatSession>,
|
||||
}
|
||||
|
||||
impl Simulation {
|
||||
/// Creates a fresh simulation from a scenario definition.
|
||||
pub fn new(scenario: ScenarioDefinition) -> Result<Self, SimError> {
|
||||
let tree = DemoTree::from_root(&scenario.root);
|
||||
let mut nodes = Vec::with_capacity(tree.nodes.len());
|
||||
|
||||
for demo_node in &tree.nodes {
|
||||
let (tx, rx) = unbounded();
|
||||
let children = demo_node
|
||||
.children
|
||||
.iter()
|
||||
.map(|child_id| ChildRoute {
|
||||
path: tree.node(*child_id).path.clone(),
|
||||
state: ConnectionState::Registered,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let leaves = demo_node
|
||||
.leaves
|
||||
.iter()
|
||||
.map(|leaf| unshell::protocol::tree::LeafSpec {
|
||||
name: leaf.name.clone(),
|
||||
procedures: leaf.procedures.clone(),
|
||||
behavior: match leaf.kind {
|
||||
LeafKind::Echo => LeafBehavior::Echo,
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let parent_path = demo_node
|
||||
.parent
|
||||
.map(|parent_id| tree.node(parent_id).path.clone());
|
||||
|
||||
let mut endpoint =
|
||||
ProtocolEndpoint::new(demo_node.path.clone(), parent_path, children, leaves);
|
||||
for procedure in &demo_node.endpoint_procedures {
|
||||
endpoint
|
||||
.add_endpoint_procedure(procedure.procedure_id.clone())
|
||||
.map_err(|error| SimError::Protocol(error.to_string()))?;
|
||||
}
|
||||
|
||||
nodes.push(SimNode {
|
||||
parent: demo_node.parent,
|
||||
children: demo_node.children.clone(),
|
||||
endpoint,
|
||||
tx,
|
||||
rx,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
scenario,
|
||||
tree,
|
||||
nodes,
|
||||
root_id: NodeId(0),
|
||||
next_tick: 1,
|
||||
trace: VecDeque::new(),
|
||||
recorded_events: Vec::new(),
|
||||
hooks: BTreeMap::new(),
|
||||
chat_sessions: BTreeMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the scenario's initial selection.
|
||||
pub fn initial_selection(&self) -> Selection {
|
||||
self.scenario.initial_selection.clone()
|
||||
}
|
||||
|
||||
/// Returns a node by id.
|
||||
pub fn node(&self, id: NodeId) -> &crate::model::DemoNode {
|
||||
self.tree.node(id)
|
||||
}
|
||||
|
||||
/// Builds and routes an endpoint introspection call from the root.
|
||||
pub fn call_endpoint_introspection(
|
||||
&mut self,
|
||||
node_id: NodeId,
|
||||
) -> Result<ActionResult, SimError> {
|
||||
let path = self.tree.node(node_id).path.clone();
|
||||
self.dispatch_root_call(path.clone(), None, "", Vec::new())?;
|
||||
Ok(ActionResult {
|
||||
label: format!("Inspect endpoint {}", format_path(&path)),
|
||||
hook_id: self.hooks.last_key_value().map(|(hook_id, _)| *hook_id),
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds and routes a leaf introspection call from the root.
|
||||
pub fn call_leaf_introspection(
|
||||
&mut self,
|
||||
node_id: NodeId,
|
||||
leaf_name: &str,
|
||||
) -> Result<ActionResult, SimError> {
|
||||
let node_path = self.tree.node(node_id).path.clone();
|
||||
let node_display = self.tree.node(node_id).display_path();
|
||||
self.require_leaf(node_id, leaf_name)?;
|
||||
self.dispatch_root_call(node_path, Some(leaf_name.to_owned()), "", Vec::new())?;
|
||||
Ok(ActionResult {
|
||||
label: format!("Inspect leaf {} on {}", leaf_name, node_display),
|
||||
hook_id: self.hooks.last_key_value().map(|(hook_id, _)| *hook_id),
|
||||
})
|
||||
}
|
||||
|
||||
/// Calls a leaf echo procedure using the selected payload.
|
||||
pub fn call_echo_leaf(
|
||||
&mut self,
|
||||
node_id: NodeId,
|
||||
leaf_name: &str,
|
||||
text: &str,
|
||||
) -> Result<ActionResult, SimError> {
|
||||
let node_path = self.tree.node(node_id).path.clone();
|
||||
let node_display = self.tree.node(node_id).display_path();
|
||||
let leaf = self.require_leaf(node_id, leaf_name)?;
|
||||
let procedure_id =
|
||||
leaf.procedures
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or_else(|| SimError::UnknownProcedure {
|
||||
node_path: node_display.clone(),
|
||||
procedure_id: "<missing>".to_owned(),
|
||||
})?;
|
||||
self.dispatch_root_call(
|
||||
node_path,
|
||||
Some(leaf_name.to_owned()),
|
||||
&procedure_id,
|
||||
text.as_bytes().to_vec(),
|
||||
)?;
|
||||
Ok(ActionResult {
|
||||
label: format!("Echo via {leaf_name} on {}", node_display),
|
||||
hook_id: self.hooks.last_key_value().map(|(hook_id, _)| *hook_id),
|
||||
})
|
||||
}
|
||||
|
||||
/// Calls an endpoint-level procedure.
|
||||
pub fn call_endpoint_procedure(
|
||||
&mut self,
|
||||
node_id: NodeId,
|
||||
procedure_id: &str,
|
||||
data: Vec<u8>,
|
||||
) -> Result<ActionResult, SimError> {
|
||||
let node_path = self.tree.node(node_id).path.clone();
|
||||
let node_display = self.tree.node(node_id).display_path();
|
||||
self.require_endpoint_procedure(node_id, procedure_id)?;
|
||||
self.dispatch_root_call(node_path, None, procedure_id, data)?;
|
||||
Ok(ActionResult {
|
||||
label: format!("Call {procedure_id} on {}", node_display),
|
||||
hook_id: self.hooks.last_key_value().map(|(hook_id, _)| *hook_id),
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends a raw call without demo-side validation so tests can exercise
|
||||
/// remote `UnknownLeaf` and `UnknownProcedure` fault behavior.
|
||||
pub fn call_unchecked(
|
||||
&mut self,
|
||||
node_id: NodeId,
|
||||
dst_leaf: Option<&str>,
|
||||
procedure_id: &str,
|
||||
data: Vec<u8>,
|
||||
) -> Result<ActionResult, SimError> {
|
||||
let node_path = self.tree.node(node_id).path.clone();
|
||||
let node_display = self.tree.node(node_id).display_path();
|
||||
self.dispatch_root_call(node_path, dst_leaf.map(str::to_owned), procedure_id, data)?;
|
||||
Ok(ActionResult {
|
||||
label: format!(
|
||||
"Call {} on {}{}",
|
||||
if procedure_id.is_empty() {
|
||||
"<introspection>"
|
||||
} else {
|
||||
procedure_id
|
||||
},
|
||||
node_display,
|
||||
dst_leaf
|
||||
.map(|leaf_name| format!(" leaf {leaf_name}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
hook_id: self.hooks.last_key_value().map(|(hook_id, _)| *hook_id),
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends more hook data from the root side.
|
||||
pub fn send_root_hook_data(
|
||||
&mut self,
|
||||
hook_id: u64,
|
||||
text: &str,
|
||||
end_hook: bool,
|
||||
) -> Result<ActionResult, SimError> {
|
||||
let snapshot = self
|
||||
.hooks
|
||||
.get(&hook_id)
|
||||
.cloned()
|
||||
.ok_or(SimError::UnknownHook(hook_id))?;
|
||||
let frame = self.nodes[self.root_id.0]
|
||||
.endpoint
|
||||
.make_data(
|
||||
snapshot.peer_path.clone(),
|
||||
hook_id,
|
||||
snapshot.procedure_id.clone(),
|
||||
text.as_bytes().to_vec(),
|
||||
end_hook,
|
||||
)
|
||||
.map_err(|error| SimError::Protocol(error.to_string()))?;
|
||||
self.record_trace(
|
||||
self.root_id,
|
||||
format!("root queued hook data for hook #{hook_id}: {text}"),
|
||||
);
|
||||
self.process_local_frame(self.root_id, frame)?;
|
||||
Ok(ActionResult {
|
||||
label: format!("Send hook data #{hook_id}"),
|
||||
hook_id: Some(hook_id),
|
||||
})
|
||||
}
|
||||
|
||||
/// Injects intentionally invalid traffic to demonstrate `InvalidHookPeer`.
|
||||
pub fn inject_invalid_peer_data(
|
||||
&mut self,
|
||||
from_node_id: NodeId,
|
||||
to_node_id: NodeId,
|
||||
hook_id: u64,
|
||||
procedure_id: &str,
|
||||
text: &str,
|
||||
) -> Result<ActionResult, SimError> {
|
||||
let from_path = self.tree.node(from_node_id).path.clone();
|
||||
let to_path = self.tree.node(to_node_id).path.clone();
|
||||
let header = PacketHeader {
|
||||
packet_type: PacketType::Data,
|
||||
src_path: from_path.clone(),
|
||||
dst_path: to_path.clone(),
|
||||
dst_leaf: None,
|
||||
hook_id: Some(hook_id),
|
||||
};
|
||||
let message = DataMessage {
|
||||
procedure_id: procedure_id.to_owned(),
|
||||
data: text.as_bytes().to_vec(),
|
||||
end_hook: false,
|
||||
};
|
||||
let frame = unshell::protocol::encode_packet(&header, &message)
|
||||
.map_err(|error| SimError::Protocol(error.to_string()))?;
|
||||
|
||||
self.record_trace(
|
||||
from_node_id,
|
||||
format!(
|
||||
"injected invalid peer data toward {} for hook #{hook_id}",
|
||||
format_path(&to_path)
|
||||
),
|
||||
);
|
||||
self.process_local_frame(from_node_id, frame)?;
|
||||
Ok(ActionResult {
|
||||
label: format!("Inject invalid peer data for hook #{hook_id}"),
|
||||
hook_id: Some(hook_id),
|
||||
})
|
||||
}
|
||||
|
||||
/// Processes one queued frame if available.
|
||||
pub fn step(&mut self) -> Result<bool, SimError> {
|
||||
for node_id in 0..self.nodes.len() {
|
||||
match self.nodes[node_id].rx.try_recv() {
|
||||
Ok(envelope) => {
|
||||
self.record_trace(
|
||||
NodeId(node_id),
|
||||
format!("received frame via {:?}", envelope.ingress),
|
||||
);
|
||||
let outcome = self.nodes[node_id]
|
||||
.endpoint
|
||||
.receive(&envelope.ingress, envelope.frame)
|
||||
.map_err(|error| SimError::Protocol(error.to_string()))?;
|
||||
self.process_outcome(NodeId(node_id), outcome)?;
|
||||
return Ok(true);
|
||||
}
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
return Err(SimError::Protocol("mailbox disconnected".to_owned()));
|
||||
}
|
||||
Err(TryRecvError::Empty) => {}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Runs frames until the network becomes idle.
|
||||
pub fn drain(&mut self) -> Result<usize, SimError> {
|
||||
let mut steps = 0;
|
||||
while self.step()? {
|
||||
steps += 1;
|
||||
}
|
||||
Ok(steps)
|
||||
}
|
||||
|
||||
fn dispatch_root_call(
|
||||
&mut self,
|
||||
dst_path: Vec<String>,
|
||||
dst_leaf: Option<String>,
|
||||
procedure_id: &str,
|
||||
data: Vec<u8>,
|
||||
) -> Result<(), SimError> {
|
||||
let hook_id = self.nodes[self.root_id.0].endpoint.allocate_hook_id();
|
||||
let frame = self.nodes[self.root_id.0]
|
||||
.endpoint
|
||||
.make_call(
|
||||
dst_path.clone(),
|
||||
dst_leaf.clone(),
|
||||
procedure_id.to_owned(),
|
||||
Some(hook_id),
|
||||
data,
|
||||
)
|
||||
.map_err(|error| SimError::Protocol(error.to_string()))?;
|
||||
self.hooks.insert(
|
||||
hook_id,
|
||||
HookSnapshot {
|
||||
hook_id,
|
||||
host_path: Vec::new(),
|
||||
peer_path: dst_path.clone(),
|
||||
procedure_id: procedure_id.to_owned(),
|
||||
closed: false,
|
||||
last_message: format!("created for {}", format_path(&dst_path)),
|
||||
},
|
||||
);
|
||||
self.record_trace(
|
||||
self.root_id,
|
||||
format!(
|
||||
"root queued Call {} toward {}{}",
|
||||
if procedure_id.is_empty() {
|
||||
"<introspection>"
|
||||
} else {
|
||||
procedure_id
|
||||
},
|
||||
format_path(&dst_path),
|
||||
dst_leaf
|
||||
.as_ref()
|
||||
.map(|leaf| format!(" leaf {leaf}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
);
|
||||
self.process_local_frame(self.root_id, frame)
|
||||
}
|
||||
|
||||
fn process_local_frame(&mut self, node_id: NodeId, frame: FrameBytes) -> Result<(), SimError> {
|
||||
let outcome = self.nodes[node_id.0]
|
||||
.endpoint
|
||||
.receive(&Ingress::Local, frame)
|
||||
.map_err(|error| SimError::Protocol(error.to_string()))?;
|
||||
self.process_outcome(node_id, outcome)
|
||||
}
|
||||
|
||||
fn process_outcome(
|
||||
&mut self,
|
||||
node_id: NodeId,
|
||||
outcome: unshell::protocol::tree::EndpointOutcome,
|
||||
) -> Result<(), SimError> {
|
||||
if outcome.dropped {
|
||||
self.record_trace(node_id, "packet dropped".to_owned());
|
||||
}
|
||||
|
||||
for (route, frame) in outcome.forwards {
|
||||
match route {
|
||||
unshell::protocol::tree::RouteDecision::Child(index) => {
|
||||
let child_id = self.nodes[node_id.0]
|
||||
.children
|
||||
.get(index)
|
||||
.copied()
|
||||
.ok_or_else(|| {
|
||||
SimError::Protocol(format!("missing child index {index}"))
|
||||
})?;
|
||||
self.record_trace(
|
||||
node_id,
|
||||
format!(
|
||||
"forwarded frame to child {}",
|
||||
self.node(child_id).display_path()
|
||||
),
|
||||
);
|
||||
self.nodes[child_id.0]
|
||||
.tx
|
||||
.send(Envelope {
|
||||
ingress: Ingress::Parent,
|
||||
frame,
|
||||
})
|
||||
.map_err(|error| SimError::Protocol(error.to_string()))?;
|
||||
}
|
||||
unshell::protocol::tree::RouteDecision::Parent => {
|
||||
let parent_id = self.nodes[node_id.0]
|
||||
.parent
|
||||
.ok_or_else(|| SimError::Protocol("missing parent route".to_owned()))?;
|
||||
let child_path = self.node(node_id).path.clone();
|
||||
self.record_trace(
|
||||
node_id,
|
||||
format!(
|
||||
"forwarded frame to parent {}",
|
||||
self.node(parent_id).display_path()
|
||||
),
|
||||
);
|
||||
self.nodes[parent_id.0]
|
||||
.tx
|
||||
.send(Envelope {
|
||||
ingress: Ingress::Child(child_path),
|
||||
frame,
|
||||
})
|
||||
.map_err(|error| SimError::Protocol(error.to_string()))?;
|
||||
}
|
||||
unshell::protocol::tree::RouteDecision::Local => {
|
||||
return Err(SimError::Protocol(
|
||||
"local route leaked into forward list".to_owned(),
|
||||
));
|
||||
}
|
||||
unshell::protocol::tree::RouteDecision::Drop => {
|
||||
self.record_trace(node_id, "route decision dropped frame".to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for event in outcome.events {
|
||||
self.handle_local_event(node_id, event)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_local_event(&mut self, node_id: NodeId, event: LocalEvent) -> Result<(), SimError> {
|
||||
let node_path = self.node(node_id).display_path();
|
||||
match event {
|
||||
LocalEvent::Data { header, message } => {
|
||||
let text = String::from_utf8_lossy(&message.data).to_string();
|
||||
self.record_trace(
|
||||
node_id,
|
||||
format!(
|
||||
"local Data on hook #{}: {text}",
|
||||
header.hook_id.unwrap_or(0)
|
||||
),
|
||||
);
|
||||
if let Some(hook_id) = header.hook_id {
|
||||
if let Some(snapshot) = self.hooks.get_mut(&hook_id) {
|
||||
snapshot.last_message = if text.is_empty() {
|
||||
format!("binary payload ({} bytes)", message.data.len())
|
||||
} else {
|
||||
text.clone()
|
||||
};
|
||||
if message.end_hook {
|
||||
snapshot.closed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(session) = self
|
||||
.chat_sessions
|
||||
.get(&header.hook_id.unwrap_or(0))
|
||||
.cloned()
|
||||
.filter(|session| session.node_id == node_id)
|
||||
{
|
||||
// Rationale: chat responses are implemented here instead of in the
|
||||
// core endpoint so the protocol crate stays generic. The simulator
|
||||
// acts as the application layer sitting above validated hook traffic.
|
||||
let reply = if text.eq_ignore_ascii_case("bye") {
|
||||
Some(("chat session closed".to_owned(), true))
|
||||
} else if !text.is_empty() {
|
||||
Some((format!("chat ack: {}", text.to_uppercase()), false))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some((reply, end_hook)) = reply {
|
||||
let frame = self.nodes[session.node_id.0]
|
||||
.endpoint
|
||||
.make_data(
|
||||
session.host_path.clone(),
|
||||
session.hook_id,
|
||||
session.procedure_id.clone(),
|
||||
reply.clone().into_bytes(),
|
||||
end_hook,
|
||||
)
|
||||
.map_err(|error| SimError::Protocol(error.to_string()))?;
|
||||
self.record_trace(session.node_id, format!("chat handler sent: {reply}"));
|
||||
self.process_local_frame(session.node_id, frame)?;
|
||||
if end_hook {
|
||||
self.chat_sessions.remove(&session.hook_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.recorded_events.push(RecordedEvent::Data {
|
||||
node_path,
|
||||
header,
|
||||
message,
|
||||
});
|
||||
}
|
||||
LocalEvent::Fault { header, message } => {
|
||||
self.record_trace(
|
||||
node_id,
|
||||
format!(
|
||||
"local Fault on hook #{}: 0x{:02X}",
|
||||
header.hook_id.unwrap_or(0),
|
||||
message.fault.0
|
||||
),
|
||||
);
|
||||
if let Some(hook_id) = header.hook_id {
|
||||
if let Some(snapshot) = self.hooks.get_mut(&hook_id) {
|
||||
snapshot.closed = true;
|
||||
snapshot.last_message = format!("fault 0x{:02X}", message.fault.0);
|
||||
}
|
||||
self.chat_sessions.remove(&hook_id);
|
||||
}
|
||||
self.recorded_events.push(RecordedEvent::Fault {
|
||||
node_path,
|
||||
header,
|
||||
message,
|
||||
});
|
||||
}
|
||||
LocalEvent::Call { header, message } => {
|
||||
self.record_trace(
|
||||
node_id,
|
||||
format!(
|
||||
"local Call {} on {}",
|
||||
message.procedure_id,
|
||||
header
|
||||
.dst_leaf
|
||||
.as_ref()
|
||||
.map(|leaf| format!("leaf {leaf}"))
|
||||
.unwrap_or_else(|| "endpoint".to_owned())
|
||||
),
|
||||
);
|
||||
self.handle_application_call(node_id, &header, &message)?;
|
||||
self.recorded_events.push(RecordedEvent::Call {
|
||||
node_path,
|
||||
header,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_application_call(
|
||||
&mut self,
|
||||
node_id: NodeId,
|
||||
_header: &PacketHeader,
|
||||
message: &CallMessage,
|
||||
) -> Result<(), SimError> {
|
||||
let Some(hook) = &message.response_hook else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let procedure = self
|
||||
.lookup_endpoint_procedure(node_id, &message.procedure_id)?
|
||||
.clone();
|
||||
match procedure.kind {
|
||||
EndpointProcedureKind::Ping => {
|
||||
let reply = format!("pong from {}", self.node(node_id).display_path());
|
||||
let frame = self.nodes[node_id.0]
|
||||
.endpoint
|
||||
.make_data(
|
||||
hook.return_path.clone(),
|
||||
hook.hook_id,
|
||||
procedure.procedure_id.clone(),
|
||||
reply.clone().into_bytes(),
|
||||
true,
|
||||
)
|
||||
.map_err(|error| SimError::Protocol(error.to_string()))?;
|
||||
self.record_trace(node_id, format!("endpoint sent ping reply: {reply}"));
|
||||
self.process_local_frame(node_id, frame)?;
|
||||
}
|
||||
EndpointProcedureKind::ChunkedGreeting => {
|
||||
for (index, text) in [
|
||||
"chunk 1: hello from the endpoint",
|
||||
"chunk 2: routing stayed path-based",
|
||||
"chunk 3: hook complete",
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let frame = self.nodes[node_id.0]
|
||||
.endpoint
|
||||
.make_data(
|
||||
hook.return_path.clone(),
|
||||
hook.hook_id,
|
||||
procedure.procedure_id.clone(),
|
||||
text.as_bytes().to_vec(),
|
||||
index == 2,
|
||||
)
|
||||
.map_err(|error| SimError::Protocol(error.to_string()))?;
|
||||
self.record_trace(node_id, format!("endpoint sent chunk {}", index + 1));
|
||||
self.process_local_frame(node_id, frame)?;
|
||||
}
|
||||
}
|
||||
EndpointProcedureKind::Chat => {
|
||||
self.chat_sessions.insert(
|
||||
hook.hook_id,
|
||||
ChatSession {
|
||||
node_id,
|
||||
hook_id: hook.hook_id,
|
||||
host_path: hook.return_path.clone(),
|
||||
procedure_id: procedure.procedure_id.clone(),
|
||||
},
|
||||
);
|
||||
let frame = self.nodes[node_id.0]
|
||||
.endpoint
|
||||
.make_data(
|
||||
hook.return_path.clone(),
|
||||
hook.hook_id,
|
||||
procedure.procedure_id.clone(),
|
||||
b"chat ready".to_vec(),
|
||||
false,
|
||||
)
|
||||
.map_err(|error| SimError::Protocol(error.to_string()))?;
|
||||
self.record_trace(node_id, "chat handler opened session".to_owned());
|
||||
self.process_local_frame(node_id, frame)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn lookup_endpoint_procedure(
|
||||
&self,
|
||||
node_id: NodeId,
|
||||
procedure_id: &str,
|
||||
) -> Result<&EndpointProcedureSpec, SimError> {
|
||||
self.node(node_id)
|
||||
.endpoint_procedures
|
||||
.iter()
|
||||
.find(|procedure| procedure.procedure_id == procedure_id)
|
||||
.ok_or_else(|| SimError::UnknownProcedure {
|
||||
node_path: self.node(node_id).display_path(),
|
||||
procedure_id: procedure_id.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn require_leaf(
|
||||
&self,
|
||||
node_id: NodeId,
|
||||
leaf_name: &str,
|
||||
) -> Result<&crate::model::LeafSpec, SimError> {
|
||||
self.node(node_id)
|
||||
.leaves
|
||||
.iter()
|
||||
.find(|leaf| leaf.name == leaf_name)
|
||||
.ok_or_else(|| SimError::UnknownLeaf {
|
||||
node_path: self.node(node_id).display_path(),
|
||||
leaf_name: leaf_name.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn require_endpoint_procedure(
|
||||
&self,
|
||||
node_id: NodeId,
|
||||
procedure_id: &str,
|
||||
) -> Result<(), SimError> {
|
||||
self.lookup_endpoint_procedure(node_id, procedure_id)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn record_trace(&mut self, node_id: NodeId, summary: String) {
|
||||
let node_path = self.node(node_id).display_path();
|
||||
self.trace.push_back(TraceEvent {
|
||||
tick: self.next_tick,
|
||||
node_path,
|
||||
summary,
|
||||
});
|
||||
self.next_tick += 1;
|
||||
while self.trace.len() > 200 {
|
||||
self.trace.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a compact description of a frame for debugging.
|
||||
pub fn describe_frame(frame: &[u8]) -> String {
|
||||
match decode_frame(frame) {
|
||||
Ok(parsed) => {
|
||||
let header = parsed.header();
|
||||
format!(
|
||||
"{:?} {} -> {} hook {:?}",
|
||||
header.packet_type,
|
||||
format_path(&header.src_path),
|
||||
format_path(&header.dst_path),
|
||||
header.hook_id,
|
||||
)
|
||||
}
|
||||
Err(error) => format!("<invalid frame: {error}>"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the latest fault observed at the root, if any.
|
||||
pub fn latest_root_fault(&self) -> Option<&FaultMessage> {
|
||||
self.recorded_events
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|event| match event {
|
||||
RecordedEvent::Fault {
|
||||
node_path, message, ..
|
||||
} if node_path == "/" => Some(message),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the latest root data message as utf-8 for tests and status text.
|
||||
pub fn latest_root_data_text(&self) -> Option<String> {
|
||||
self.recorded_events
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|event| match event {
|
||||
RecordedEvent::Data {
|
||||
node_path, message, ..
|
||||
} if node_path == "/" => Some(String::from_utf8_lossy(&message.data).to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns all hook ids known to the demo in ascending order.
|
||||
pub fn hook_ids(&self) -> Vec<u64> {
|
||||
self.hooks.keys().copied().collect()
|
||||
}
|
||||
|
||||
/// Builds a human-readable description of the current selection.
|
||||
pub fn selection_summary(&self, selection: &Selection) -> String {
|
||||
match selection {
|
||||
Selection::Node(node_id) => {
|
||||
let node = self.node(*node_id);
|
||||
format!("{}: {}", node.display_path(), node.title)
|
||||
}
|
||||
Selection::Leaf { node_id, leaf_name } => {
|
||||
format!("{} leaf {}", self.node(*node_id).display_path(), leaf_name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use treetest::{model::NodeId, scenarios::built_in_scenarios, sim::Simulation};
|
||||
use unshell::protocol::ProtocolFault;
|
||||
|
||||
#[test]
|
||||
fn unknown_leaf_and_unknown_procedure_fault_to_root() {
|
||||
let scenarios = built_in_scenarios();
|
||||
let mut simulation = Simulation::new(scenarios[4].clone()).expect("scenario should build");
|
||||
|
||||
simulation
|
||||
.call_unchecked(
|
||||
NodeId(1),
|
||||
Some("missing_leaf"),
|
||||
"demo.leaf.v1.echo.invoke",
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("unknown leaf call should start");
|
||||
simulation.drain().expect("network should drain");
|
||||
assert_eq!(
|
||||
simulation
|
||||
.latest_root_fault()
|
||||
.expect("root should observe unknown-leaf fault")
|
||||
.fault,
|
||||
ProtocolFault::UNKNOWN_LEAF
|
||||
);
|
||||
|
||||
simulation
|
||||
.call_unchecked(
|
||||
NodeId(1),
|
||||
None,
|
||||
"demo.endpoint.v1.control.missing",
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("unknown procedure call should start");
|
||||
simulation.drain().expect("network should drain");
|
||||
assert_eq!(
|
||||
simulation
|
||||
.latest_root_fault()
|
||||
.expect("root should observe unknown-procedure fault")
|
||||
.fault,
|
||||
ProtocolFault::UNKNOWN_PROCEDURE
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_hook_peer_faults_an_active_chat_hook() {
|
||||
let scenarios = built_in_scenarios();
|
||||
let mut simulation = Simulation::new(scenarios[5].clone()).expect("scenario should build");
|
||||
|
||||
simulation
|
||||
.call_endpoint_procedure(NodeId(3), "demo.endpoint.v1.chat.session", b"open".to_vec())
|
||||
.expect("chat call should start");
|
||||
simulation.drain().expect("network should drain");
|
||||
let hook_id = *simulation.hook_ids().last().expect("hook should exist");
|
||||
|
||||
simulation
|
||||
.inject_invalid_peer_data(
|
||||
NodeId(1),
|
||||
NodeId(0),
|
||||
hook_id,
|
||||
"demo.endpoint.v1.chat.session",
|
||||
"spoof",
|
||||
)
|
||||
.expect("invalid peer injection should enqueue");
|
||||
simulation.drain().expect("network should drain");
|
||||
|
||||
assert_eq!(
|
||||
simulation
|
||||
.latest_root_fault()
|
||||
.expect("root should observe a fault")
|
||||
.fault,
|
||||
ProtocolFault::INVALID_HOOK_PEER
|
||||
);
|
||||
assert!(
|
||||
simulation
|
||||
.hooks
|
||||
.get(&hook_id)
|
||||
.expect("hook snapshot should exist")
|
||||
.closed
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use treetest::{model::NodeId, scenarios::built_in_scenarios, sim::Simulation};
|
||||
|
||||
#[test]
|
||||
fn bidirectional_chat_remains_active_until_bye() {
|
||||
let scenarios = built_in_scenarios();
|
||||
let mut simulation = Simulation::new(scenarios[3].clone()).expect("scenario should build");
|
||||
|
||||
simulation
|
||||
.call_endpoint_procedure(NodeId(1), "demo.endpoint.v1.chat.session", b"open".to_vec())
|
||||
.expect("chat call should start");
|
||||
simulation.drain().expect("network should drain");
|
||||
|
||||
let hook_id = *simulation.hook_ids().last().expect("hook should exist");
|
||||
assert!(
|
||||
!simulation
|
||||
.hooks
|
||||
.get(&hook_id)
|
||||
.expect("hook snapshot should exist")
|
||||
.closed
|
||||
);
|
||||
assert_eq!(
|
||||
simulation.latest_root_data_text().as_deref(),
|
||||
Some("chat ready")
|
||||
);
|
||||
|
||||
simulation
|
||||
.send_root_hook_data(hook_id, "hello there", false)
|
||||
.expect("chat data should send");
|
||||
simulation.drain().expect("network should drain");
|
||||
assert_eq!(
|
||||
simulation.latest_root_data_text().as_deref(),
|
||||
Some("chat ack: HELLO THERE")
|
||||
);
|
||||
|
||||
simulation
|
||||
.send_root_hook_data(hook_id, "bye", true)
|
||||
.expect("chat close should send");
|
||||
simulation.drain().expect("network should drain");
|
||||
assert!(
|
||||
simulation
|
||||
.hooks
|
||||
.get(&hook_id)
|
||||
.expect("hook snapshot should exist")
|
||||
.closed
|
||||
);
|
||||
assert_eq!(
|
||||
simulation.latest_root_data_text().as_deref(),
|
||||
Some("chat session closed")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use treetest::{scenarios::built_in_scenarios, sim::Simulation};
|
||||
|
||||
#[test]
|
||||
fn endpoint_and_leaf_introspection_complete_successfully() {
|
||||
let scenarios = built_in_scenarios();
|
||||
let mut simulation = Simulation::new(scenarios[0].clone()).expect("scenario should build");
|
||||
|
||||
simulation
|
||||
.call_endpoint_introspection(treetest::model::NodeId(1))
|
||||
.expect("endpoint introspection should start");
|
||||
simulation.drain().expect("network should drain");
|
||||
assert!(simulation.latest_root_data_text().is_some());
|
||||
|
||||
simulation
|
||||
.call_leaf_introspection(treetest::model::NodeId(1), "echo")
|
||||
.expect("leaf introspection should start");
|
||||
simulation.drain().expect("network should drain");
|
||||
assert!(simulation.latest_root_data_text().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn echo_leaf_round_trips_user_payload() {
|
||||
let scenarios = built_in_scenarios();
|
||||
let mut simulation = Simulation::new(scenarios[1].clone()).expect("scenario should build");
|
||||
|
||||
simulation
|
||||
.call_echo_leaf(treetest::model::NodeId(1), "echo", "hello from test")
|
||||
.expect("echo call should start");
|
||||
simulation.drain().expect("network should drain");
|
||||
|
||||
assert_eq!(
|
||||
simulation.latest_root_data_text().as_deref(),
|
||||
Some("hello from test")
|
||||
);
|
||||
let hook_id = *simulation.hook_ids().last().expect("hook should exist");
|
||||
assert!(
|
||||
simulation
|
||||
.hooks
|
||||
.get(&hook_id)
|
||||
.expect("hook snapshot should exist")
|
||||
.closed
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use treetest::{model::NodeId, scenarios::built_in_scenarios, sim::Simulation};
|
||||
|
||||
#[test]
|
||||
fn nested_route_uses_longest_prefix_path() {
|
||||
let scenarios = built_in_scenarios();
|
||||
let mut simulation = Simulation::new(scenarios[2].clone()).expect("scenario should build");
|
||||
|
||||
simulation
|
||||
.call_echo_leaf(NodeId(2), "echo", "nested")
|
||||
.expect("echo call should start");
|
||||
simulation.drain().expect("network should drain");
|
||||
|
||||
let trace_text = simulation
|
||||
.trace
|
||||
.iter()
|
||||
.map(|event| event.summary.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(trace_text.contains("forwarded frame to child /alpha"));
|
||||
assert!(trace_text.contains("forwarded frame to child /alpha/beta"));
|
||||
assert_eq!(
|
||||
simulation.latest_root_data_text().as_deref(),
|
||||
Some("nested")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunked_endpoint_procedure_returns_multiple_packets() {
|
||||
let scenarios = built_in_scenarios();
|
||||
let mut simulation = Simulation::new(scenarios[1].clone()).expect("scenario should build");
|
||||
|
||||
simulation
|
||||
.call_endpoint_procedure(
|
||||
NodeId(1),
|
||||
"demo.endpoint.v1.stream.chunked_greeting",
|
||||
b"go".to_vec(),
|
||||
)
|
||||
.expect("procedure call should start");
|
||||
simulation.drain().expect("network should drain");
|
||||
|
||||
let root_data_count = simulation
|
||||
.recorded_events
|
||||
.iter()
|
||||
.filter(|event| matches!(event, treetest::sim::RecordedEvent::Data { node_path, .. } if node_path == "/"))
|
||||
.count();
|
||||
assert!(root_data_count >= 3);
|
||||
assert_eq!(
|
||||
simulation.latest_root_data_text().as_deref(),
|
||||
Some("chunk 3: hook complete")
|
||||
);
|
||||
}
|
||||
Generated
-779
@@ -1,779 +0,0 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitvec"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
|
||||
dependencies = [
|
||||
"funty",
|
||||
"radium",
|
||||
"tap",
|
||||
"wyz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "bytecheck"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
|
||||
dependencies = [
|
||||
"bytecheck_derive",
|
||||
"ptr_meta",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytecheck_derive"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"env_filter",
|
||||
"jiff",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.95"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.185"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptr_meta"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
|
||||
dependencies = [
|
||||
"ptr_meta_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptr_meta_derive"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "radium"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
||||
|
||||
[[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.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rend"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
|
||||
dependencies = [
|
||||
"bytecheck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rkyv"
|
||||
version = "0.7.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
|
||||
dependencies = [
|
||||
"bitvec",
|
||||
"bytecheck",
|
||||
"bytes",
|
||||
"hashbrown 0.12.3",
|
||||
"ptr_meta",
|
||||
"rend",
|
||||
"rkyv_derive",
|
||||
"seahash",
|
||||
"tinyvec",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rkyv_derive"
|
||||
version = "0.7.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "seahash"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tap"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "target-triple"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b"
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
|
||||
dependencies = [
|
||||
"tinyvec_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec_macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||
|
||||
[[package]]
|
||||
name = "trybuild"
|
||||
version = "1.0.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47c635f0191bd3a2941013e5062667100969f8c4e9cd787c14f977265d73616e"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"target-triple",
|
||||
"termcolor",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "ush-treetest"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"env_logger",
|
||||
"libc",
|
||||
"log",
|
||||
"rkyv",
|
||||
"trybuild",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
|
||||
|
||||
[[package]]
|
||||
name = "wyz"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
|
||||
dependencies = [
|
||||
"tap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -1,28 +0,0 @@
|
||||
[package]
|
||||
name = "ush-treetest"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[workspace]
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
std = ["dep:libc"]
|
||||
alloc = []
|
||||
|
||||
[dependencies]
|
||||
rkyv = { version = "0.7", features = ["alloc", "size_32"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
libc = { version = "0.2", optional = true }
|
||||
|
||||
[dependencies.clap]
|
||||
version = "4.5"
|
||||
features = ["derive", "env"]
|
||||
|
||||
[dev-dependencies]
|
||||
trybuild = "1.0"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
@@ -1,79 +0,0 @@
|
||||
# Protocol Testbed Report
|
||||
|
||||
## Summary
|
||||
|
||||
Built a tree-based routing protocol testbed with the following components:
|
||||
|
||||
### Files Created
|
||||
|
||||
```
|
||||
ush-treetest/
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── main.rs # CLI entry point with serve/connect modes
|
||||
│ ├── protocol/
|
||||
│ │ ├── mod.rs # Module exports
|
||||
│ │ ├── types.rs # FrameHeader, FrameType, TreeRequest, TreeResponse
|
||||
│ │ └── transport.rs # Transport trait, TcpTransport, frame helpers
|
||||
│ ├── tree/
|
||||
│ │ ├── mod.rs # Tree, routing logic, node management
|
||||
│ │ └── endpoint.rs # Endpoint trait
|
||||
│ ├── leaves/
|
||||
│ │ ├── mod.rs # Leaf module exports
|
||||
│ │ ├── shell.rs # RemoteShell (command execution)
|
||||
│ │ └── tty.rs # TTY (PTY support)
|
||||
│ └── cli/
|
||||
│ └── mod.rs # Interactive CLI
|
||||
```
|
||||
|
||||
### Protocol Implemented
|
||||
|
||||
**Frame Types:**
|
||||
- Request (0x01): Tree operations
|
||||
- Response (0x02): Operation results
|
||||
- StreamOpen (0x03): Open bidirectional stream
|
||||
- StreamData (0x04): Fastpath streaming data
|
||||
- StreamClose (0x05): Close stream
|
||||
- Handshake (0x10): Connection setup
|
||||
- HandshakeAck (0x11): Connection acceptance
|
||||
|
||||
**Routing:**
|
||||
- Longest-prefix match on dst_path for Request/StreamOpen
|
||||
- Stream ID lookup for StreamData/StreamClose
|
||||
|
||||
### What Works
|
||||
|
||||
1. ✅ Basic project structure with proper module organization
|
||||
2. ✅ Protocol types with rkyv serialization
|
||||
3. ✅ TCP transport with length-prefixed framing
|
||||
4. ✅ Tree routing with prefix matching
|
||||
5. ✅ RemoteShell leaf implementation
|
||||
6. ✅ Basic CLI with commands (ls, exec, cd, connect, etc.)
|
||||
|
||||
### Challenges Encountered
|
||||
|
||||
1. **rkyv API Complexity**: The rkyv serialization library has complex feature flags and API requirements:
|
||||
- `from_bytes` requires `validation` feature
|
||||
- `to_bytes` requires specifying const generic size parameter
|
||||
- Error handling requires careful trait bounds
|
||||
|
||||
2. **Trait Object Sending**: The `dyn Endpoint` trait object doesn't implement `Send`, preventing the server from spawning threads with tree handlers
|
||||
|
||||
3. **Borrow Checker Issues**: Complex borrowing patterns in tree traversal with mutable references
|
||||
|
||||
4. **no_std + alloc Complexity**: The `alloc` crate requires explicit linking in Rust 2021 edition
|
||||
|
||||
### Recommendations for Fixing
|
||||
|
||||
1. Use `serde` with `bincode` instead of `rkyv` for simpler serialization
|
||||
2. Use `Arc<Mutex<Tree>>` for thread-safe shared state
|
||||
3. Simplify the borrow patterns in tree operations
|
||||
4. For no_std, add proper `extern crate alloc` declarations
|
||||
|
||||
### Protocol Observations
|
||||
|
||||
1. The protocol design is sound - separating request/response from streaming is good
|
||||
2. The frame type enum should be repr(u8) for efficiency
|
||||
3. Longest-prefix matching works well for hierarchical routing
|
||||
4. The handshake pattern is simple but effective
|
||||
5. Consider adding compression for large payloads
|
||||
@@ -1,529 +0,0 @@
|
||||
//! # CLI Module
|
||||
//!
|
||||
//! This module provides the interactive CLI for the unshell tree protocol testbed.
|
||||
//! It supports both local tree operations and remote connections.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use ush_treetest::cli::{Cli, parse_and_execute};
|
||||
//!
|
||||
//! let mut cli = Cli::new();
|
||||
//! let output = parse_and_execute(&mut cli, "leaves").unwrap();
|
||||
//! println!("{}", output);
|
||||
//! ```
|
||||
|
||||
use crate::protocol::{
|
||||
FrameType, TreeRequest, TreeResponse, TcpTransport, Transport,
|
||||
make_request, make_stream_open, make_stream_data, make_stream_close,
|
||||
make_handshake,
|
||||
};
|
||||
use crate::tree::Tree;
|
||||
use crate::leaves::{RemoteShell, Tty};
|
||||
use std::string::String;
|
||||
use std::vec::Vec;
|
||||
|
||||
/// CLI state - manages connection and local tree.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ush_treetest::cli::Cli;
|
||||
///
|
||||
/// let mut cli = Cli::new();
|
||||
/// println!("Leaves: {:?}", cli.list_leaves());
|
||||
/// ```
|
||||
///
|
||||
/// # Fields
|
||||
/// * `transport` - Optional TCP transport for remote connection
|
||||
/// * `tree` - Local tree for local operations
|
||||
/// * `current_path` - Current working path
|
||||
/// * `request_id` - Next request ID to send
|
||||
/// * `stream_id` - Next stream ID to allocate
|
||||
/// * `streams` - Active streams
|
||||
/// * `base_path` - Base path assigned by server
|
||||
/// * `mode` - Operation mode (Local or Connected)
|
||||
pub struct Cli {
|
||||
transport: Option<TcpTransport>,
|
||||
tree: Tree,
|
||||
current_path: String,
|
||||
request_id: u64,
|
||||
#[allow(dead_code)]
|
||||
stream_id: u16,
|
||||
streams: Vec<StreamState>,
|
||||
base_path: String,
|
||||
mode: CliMode,
|
||||
}
|
||||
|
||||
/// CLI operation mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CliMode {
|
||||
/// Local-only mode
|
||||
Local,
|
||||
/// Connected to remote server
|
||||
Connected,
|
||||
}
|
||||
|
||||
/// State of an active stream.
|
||||
///
|
||||
/// # Fields
|
||||
/// * `stream_id` - The stream identifier
|
||||
/// * `path` - The path this stream is connected to
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
struct StreamState {
|
||||
stream_id: u16,
|
||||
path: String,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
/// Create a new CLI with a local tree.
|
||||
///
|
||||
/// The local tree has `/shell` and `/tty` endpoints registered.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let cli = Cli::new();
|
||||
/// let leaves = cli.list_leaves();
|
||||
/// assert!(leaves.contains(&"/shell".to_string()));
|
||||
/// ```
|
||||
pub fn new() -> Self {
|
||||
let mut tree = Tree::new();
|
||||
tree.add_endpoint("/shell", Box::new(RemoteShell::new("shell")));
|
||||
tree.add_endpoint("/tty", Box::new(Tty::new("tty")));
|
||||
|
||||
Self {
|
||||
transport: None,
|
||||
tree,
|
||||
current_path: String::from("/"),
|
||||
request_id: 1,
|
||||
stream_id: 1,
|
||||
streams: Vec::new(),
|
||||
base_path: String::from("/"),
|
||||
mode: CliMode::Local,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get next request ID.
|
||||
fn next_request_id(&mut self) -> u64 {
|
||||
let id = self.request_id;
|
||||
self.request_id += 1;
|
||||
id
|
||||
}
|
||||
|
||||
/// Get next stream ID.
|
||||
#[allow(dead_code)]
|
||||
fn next_stream_id(&mut self) -> u16 {
|
||||
let id = self.stream_id;
|
||||
self.stream_id = self.stream_id.wrapping_add(1);
|
||||
id
|
||||
}
|
||||
|
||||
/// List nodes at a path.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - Optional path (defaults to current path)
|
||||
///
|
||||
/// # Returns
|
||||
/// List of child node names
|
||||
pub fn list_nodes(&mut self, path: Option<&str>) -> Result<Vec<String>, String> {
|
||||
let path = path.map(|p| p.to_string()).unwrap_or_else(|| self.current_path.clone());
|
||||
if self.is_connected() && self.is_remote_path(&path) {
|
||||
let response = self.send_request(&path, &TreeRequest::ListNodes {})?;
|
||||
match response {
|
||||
TreeResponse::NodeList { names } => Ok(names),
|
||||
_ => Err("unexpected response type".to_string()),
|
||||
}
|
||||
} else {
|
||||
self.tree.list_nodes(&path)
|
||||
}
|
||||
}
|
||||
|
||||
/// List endpoints at a path.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - Optional path (defaults to current path)
|
||||
pub fn list_endpoints(
|
||||
&mut self,
|
||||
path: Option<&str>,
|
||||
) -> Result<Vec<crate::protocol::EndpointInfo>, String> {
|
||||
let path = path.map(|p| p.to_string()).unwrap_or_else(|| self.current_path.clone());
|
||||
if self.is_connected() && self.is_remote_path(&path) {
|
||||
let response = self.send_request(&path, &TreeRequest::ListEndpoints {})?;
|
||||
match response {
|
||||
TreeResponse::EndpointList { endpoints } => Ok(endpoints),
|
||||
_ => Err("unexpected response type".to_string()),
|
||||
}
|
||||
} else {
|
||||
self.tree.list_endpoints(&path)
|
||||
}
|
||||
}
|
||||
|
||||
/// List all leaf paths.
|
||||
pub fn list_leaves(&mut self) -> Vec<String> {
|
||||
if self.is_connected() {
|
||||
let response = match self.send_request("/", &TreeRequest::ListLeaves {}) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return self.tree.list_leaves(),
|
||||
};
|
||||
match response {
|
||||
TreeResponse::LeafList { leaves } => leaves.into_iter().map(|p| self.normalize_path(&p)).collect(),
|
||||
_ => self.tree.list_leaves(),
|
||||
}
|
||||
} else {
|
||||
self.tree.list_leaves()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get info about a node.
|
||||
pub fn get_info(&mut self, path: &str) -> Result<crate::protocol::NodeInfo, String> {
|
||||
let path_owned = path.to_string();
|
||||
if self.is_connected() && self.is_remote_path(&path_owned) {
|
||||
let response = self.send_request(&path_owned, &TreeRequest::GetInfo { path: path_owned.clone() })?;
|
||||
match response {
|
||||
TreeResponse::NodeInfo { info } => Ok(info),
|
||||
_ => Err("unexpected response type".to_string()),
|
||||
}
|
||||
} else {
|
||||
self.tree.get_info(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a path is a remote path (not local).
|
||||
///
|
||||
/// A path is remote if it's not the base_path assigned to this client.
|
||||
fn is_remote_path(&self, path: &str) -> bool {
|
||||
path != self.base_path && !path.starts_with(&self.base_path)
|
||||
}
|
||||
|
||||
/// Normalize a path by removing double slashes.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// assert_eq!(normalize_path("//shell"), "/shell");
|
||||
/// assert_eq!(normalize_path("/foo//bar"), "/foo/bar");
|
||||
/// ```
|
||||
fn normalize_path(&self, path: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut prev_slash = false;
|
||||
for c in path.chars() {
|
||||
if c == '/' {
|
||||
if !prev_slash {
|
||||
result.push(c);
|
||||
}
|
||||
prev_slash = true;
|
||||
} else {
|
||||
result.push(c);
|
||||
prev_slash = false;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Execute a command locally on the tree.
|
||||
pub fn exec_local(&mut self, path: &str, cmd: &str) -> Result<TreeResponse, String> {
|
||||
let (handler, matched_path) = self
|
||||
.tree
|
||||
.find_handler(path)
|
||||
.ok_or_else(|| format!("path not found: {}", path))?;
|
||||
|
||||
let request = TreeRequest::Exec {
|
||||
cmd: cmd.to_string(),
|
||||
};
|
||||
|
||||
let mut handler = handler.lock().map_err(|e| e.to_string())?;
|
||||
handler.handle_request(&request, matched_path)
|
||||
}
|
||||
|
||||
/// Connect to a remote server.
|
||||
pub fn connect(&mut self, addr: &str) -> Result<(), String> {
|
||||
let transport = TcpTransport::connect(addr).map_err(|e| e.to_string())?;
|
||||
self.transport = Some(transport);
|
||||
self.mode = CliMode::Connected;
|
||||
self.do_handshake()
|
||||
}
|
||||
|
||||
/// Perform handshake with remote server.
|
||||
fn do_handshake(&mut self) -> Result<(), String> {
|
||||
let transport = self.transport.as_mut().ok_or("not connected")?;
|
||||
let (header, payload) = make_handshake(vec![self.current_path.clone()]);
|
||||
transport
|
||||
.send_frame(&header, Some(&payload))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (header, payload) = transport.recv_frame().map_err(|e| e.to_string())?;
|
||||
if header.frame_type != FrameType::HandshakeAck {
|
||||
return Err("unexpected response type".to_string());
|
||||
}
|
||||
let ack = crate::protocol::HandshakeAck::from_bytes(&payload)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !ack.accepted {
|
||||
return Err("handshake rejected".to_string());
|
||||
}
|
||||
self.base_path = ack.assigned_base_path.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a request to the remote server.
|
||||
pub fn send_request(
|
||||
&mut self,
|
||||
dst_path: &str,
|
||||
request: &TreeRequest,
|
||||
) -> Result<TreeResponse, String> {
|
||||
let request_id = self.next_request_id();
|
||||
|
||||
let transport = self.transport.as_mut().ok_or("not connected")?;
|
||||
|
||||
let full_path = if dst_path.starts_with('/') {
|
||||
dst_path.to_string()
|
||||
} else {
|
||||
format!("{}/{}", self.current_path, dst_path)
|
||||
};
|
||||
|
||||
let (header, payload) = make_request(&full_path, &self.base_path, request_id, request);
|
||||
transport
|
||||
.send_frame(&header, Some(&payload))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let (header, payload) = transport.recv_frame().map_err(|e| e.to_string())?;
|
||||
if header.frame_type != FrameType::Response {
|
||||
return Err("unexpected response type".to_string());
|
||||
}
|
||||
|
||||
let response = TreeResponse::from_bytes(&payload).map_err(|e| e.to_string())?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Open a stream to a remote path.
|
||||
pub fn open_stream(&mut self, dst_path: &str) -> Result<u16, String> {
|
||||
let request_id = self.next_request_id();
|
||||
|
||||
let transport = self.transport.as_mut().ok_or("not connected")?;
|
||||
|
||||
let full_path = if dst_path.starts_with('/') {
|
||||
dst_path.to_string()
|
||||
} else {
|
||||
format!("{}/{}", self.current_path, dst_path)
|
||||
};
|
||||
|
||||
let header = make_stream_open(&full_path, &self.base_path, request_id);
|
||||
transport.send_frame(&header, None).map_err(|e| e.to_string())?;
|
||||
|
||||
let (header, payload) = transport.recv_frame().map_err(|e| e.to_string())?;
|
||||
if header.frame_type != FrameType::Response {
|
||||
return Err("unexpected response type".to_string());
|
||||
}
|
||||
|
||||
let response = TreeResponse::from_bytes(&payload).map_err(|e| e.to_string())?;
|
||||
|
||||
match response {
|
||||
TreeResponse::StreamOpened { stream_id } => {
|
||||
self.streams.push(StreamState {
|
||||
stream_id,
|
||||
path: full_path,
|
||||
});
|
||||
Ok(stream_id)
|
||||
}
|
||||
_ => Err("expected StreamOpened".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send data on a stream.
|
||||
pub fn send_stream_data(&mut self, stream_id: u16, data: &[u8]) -> Result<(), String> {
|
||||
let transport = self.transport.as_mut().ok_or("not connected")?;
|
||||
let (header, payload) = make_stream_data(stream_id, data);
|
||||
transport
|
||||
.send_frame(&header, Some(&payload))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Close a stream.
|
||||
pub fn close_stream(&mut self, stream_id: u16) -> Result<(), String> {
|
||||
let transport = self.transport.as_mut().ok_or("not connected")?;
|
||||
let header = make_stream_close(stream_id);
|
||||
transport
|
||||
.send_frame(&header, None)
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.streams.retain(|s| s.stream_id != stream_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if connected to remote.
|
||||
pub fn is_connected(&self) -> bool {
|
||||
matches!(self.mode, CliMode::Connected)
|
||||
}
|
||||
|
||||
/// Get current path.
|
||||
pub fn current_path(&self) -> &str {
|
||||
&self.current_path
|
||||
}
|
||||
|
||||
/// Set current path.
|
||||
pub fn set_path(&mut self, path: &str) {
|
||||
self.current_path = path.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse and execute a CLI command.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `cli` - The CLI state
|
||||
/// * `line` - The command line to parse
|
||||
///
|
||||
/// # Returns
|
||||
/// Ok(output) on success, Err(error) on failure
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ush_treetest::cli::{Cli, parse_and_execute};
|
||||
///
|
||||
/// let mut cli = Cli::new();
|
||||
/// let output = parse_and_execute(&mut cli, "leaves").unwrap();
|
||||
/// assert!(output.contains("shell"));
|
||||
/// ```
|
||||
pub fn parse_and_execute(cli: &mut Cli, line: &str) -> Result<String, String> {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
match parts[0] {
|
||||
"ls" | "list" => {
|
||||
let path = parts.get(1).copied();
|
||||
let names = cli.list_nodes(path)?;
|
||||
Ok(names.join("\n"))
|
||||
}
|
||||
"endpoints" => {
|
||||
let path = parts.get(1).copied();
|
||||
let eps = cli.list_endpoints(path)?;
|
||||
let mut output = String::new();
|
||||
for ep in &eps {
|
||||
output.push_str(&format!("{} ({:?}) at {}\n", ep.name, ep.endpoint_type, ep.path));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
"leaves" => Ok(cli.list_leaves().join("\n")),
|
||||
"info" => {
|
||||
if parts.len() < 2 {
|
||||
return Err("usage: info <path>".to_string());
|
||||
}
|
||||
let info = cli.get_info(parts[1])?;
|
||||
Ok(format!("{:?}", info))
|
||||
}
|
||||
"exec" => {
|
||||
if parts.len() < 3 {
|
||||
return Err("usage: exec <path> <command>".to_string());
|
||||
}
|
||||
let path = parts[1];
|
||||
let cmd = parts[2..].join(" ");
|
||||
if cli.is_connected() {
|
||||
let request = TreeRequest::Exec {
|
||||
cmd: cmd.clone(),
|
||||
};
|
||||
let response = cli.send_request(path, &request)?;
|
||||
format_response(response)
|
||||
} else {
|
||||
let response = cli.exec_local(path, &cmd)?;
|
||||
format_response(response)
|
||||
}
|
||||
}
|
||||
"cd" => {
|
||||
if parts.len() < 2 {
|
||||
return Err("usage: cd <path>".to_string());
|
||||
}
|
||||
let path = parts[1];
|
||||
if cli.get_info(path).is_ok() {
|
||||
cli.set_path(path);
|
||||
Ok(format!("changed to {}", path))
|
||||
} else {
|
||||
Err(format!("path not found: {}", path))
|
||||
}
|
||||
}
|
||||
"pwd" => Ok(cli.current_path().to_string()),
|
||||
"connect" => {
|
||||
if parts.len() < 2 {
|
||||
return Err("usage: connect <host:port>".to_string());
|
||||
}
|
||||
cli.connect(parts[1])?;
|
||||
Ok(format!("connected to {}", parts[1]))
|
||||
}
|
||||
"stream" => {
|
||||
if parts.len() < 2 {
|
||||
return Err("usage: stream <path>".to_string());
|
||||
}
|
||||
if !cli.is_connected() {
|
||||
return Err("not connected".to_string());
|
||||
}
|
||||
let stream_id = cli.open_stream(parts[1])?;
|
||||
Ok(format!("opened stream {} to {}", stream_id, parts[1]))
|
||||
}
|
||||
"close" => {
|
||||
if parts.len() < 2 {
|
||||
return Err("usage: close <stream_id>".to_string());
|
||||
}
|
||||
let stream_id: u16 = parts[1].parse().map_err(|_| "invalid stream id".to_string())?;
|
||||
cli.close_stream(stream_id)?;
|
||||
Ok(format!("closed stream {}", stream_id))
|
||||
}
|
||||
"send" => {
|
||||
if parts.len() < 3 {
|
||||
return Err("usage: send <stream_id> <data>".to_string());
|
||||
}
|
||||
let stream_id: u16 = parts[1].parse().map_err(|_| "invalid stream id".to_string())?;
|
||||
let data = parts[2..].join(" ");
|
||||
cli.send_stream_data(stream_id, data.as_bytes())?;
|
||||
Ok("sent".to_string())
|
||||
}
|
||||
"help" => Ok(HELP_TEXT.to_string()),
|
||||
_ => Err(format!("unknown command: {}", parts[0])),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a TreeResponse for display.
|
||||
fn format_response(response: TreeResponse) -> Result<String, String> {
|
||||
match response {
|
||||
TreeResponse::NodeList { names } => Ok(names.join("\n")),
|
||||
TreeResponse::EndpointList { endpoints } => {
|
||||
let mut output = String::new();
|
||||
for ep in endpoints {
|
||||
output.push_str(&format!("{} ({:?})\n", ep.name, ep.endpoint_type));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
TreeResponse::LeafList { leaves } => Ok(leaves.join("\n")),
|
||||
TreeResponse::NodeInfo { info } => Ok(format!(
|
||||
"path: {}\nis_leaf: {}\nhas_children: {}\nendpoints: {:?}",
|
||||
info.path, info.is_leaf, info.has_children, info.endpoints
|
||||
)),
|
||||
TreeResponse::ExecOutput {
|
||||
exit_code,
|
||||
stdout,
|
||||
stderr,
|
||||
} => {
|
||||
let mut output = String::new();
|
||||
output.push_str(&format!("exit code: {}\n", exit_code));
|
||||
if !stdout.is_empty() {
|
||||
output.push_str(&format!("stdout: {}\n", String::from_utf8_lossy(&stdout)));
|
||||
}
|
||||
if !stderr.is_empty() {
|
||||
output.push_str(&format!("stderr: {}\n", String::from_utf8_lossy(&stderr)));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
TreeResponse::StreamOpened { stream_id } => Ok(format!("stream opened: {}", stream_id)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Help text for CLI commands.
|
||||
const HELP_TEXT: &str = r#"Commands:
|
||||
ls [path] List child nodes
|
||||
endpoints [path] List endpoints at path
|
||||
leaves List all leaf paths
|
||||
info <path> Get node info
|
||||
exec <path> <cmd> Execute command at path
|
||||
cd <path> Change current path
|
||||
pwd Print working path
|
||||
connect <host> Connect to remote server
|
||||
stream <path> Open stream to path
|
||||
send <id> <data> Send data on stream
|
||||
close <id> Close stream
|
||||
help Show this help
|
||||
"#;
|
||||
@@ -1,434 +0,0 @@
|
||||
//! # Client Implementation
|
||||
//!
|
||||
//! This module provides the client functionality for connecting to servers,
|
||||
//! sending requests, and managing streams.
|
||||
|
||||
use crate::protocol::{
|
||||
FrameType, TreeRequest, TreeResponse, TcpTransport, Transport,
|
||||
make_request, make_stream_open, make_stream_data, make_stream_close,
|
||||
make_handshake,
|
||||
};
|
||||
use crate::tree::Tree;
|
||||
use crate::leaves::{RemoteShell, TTY};
|
||||
use std::string::String;
|
||||
use std::vec::Vec;
|
||||
use std::fmt;
|
||||
|
||||
/// Client state - manages connection and local tree.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ush_treetest::client::Client;
|
||||
///
|
||||
/// // Start with local tree
|
||||
/// let mut client = Client::new_local();
|
||||
/// println!("Leaves: {:?}", client.list_leaves());
|
||||
///
|
||||
/// // Connect to remote server
|
||||
/// client.connect("localhost:8080").unwrap();
|
||||
/// ```
|
||||
///
|
||||
/// # Fields
|
||||
/// * `transport` - Optional TCP transport for remote connection
|
||||
/// * `tree` - Local tree for local operations
|
||||
/// * `current_path` - Current working path
|
||||
/// * `request_id` - Next request ID to send
|
||||
/// * `stream_id` - Next stream ID to allocate
|
||||
/// * `streams` - Active streams
|
||||
/// * `base_path` - Base path assigned by server
|
||||
/// * `mode` - Operation mode (Local or Connected)
|
||||
#[allow(dead_code)]
|
||||
pub struct Client {
|
||||
transport: Option<TcpTransport>,
|
||||
#[allow(dead_code)]
|
||||
tree: Tree,
|
||||
current_path: String,
|
||||
request_id: u64,
|
||||
#[allow(dead_code)]
|
||||
stream_id: u16,
|
||||
streams: Vec<StreamState>,
|
||||
base_path: String,
|
||||
mode: ClientMode,
|
||||
}
|
||||
|
||||
impl fmt::Debug for Client {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Client")
|
||||
.field("transport", &self.transport.is_some())
|
||||
.field("current_path", &self.current_path)
|
||||
.field("mode", &self.mode)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Client operation mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
enum ClientMode {
|
||||
/// Local-only mode (no remote connection)
|
||||
Local,
|
||||
/// Connected to remote server
|
||||
Connected,
|
||||
}
|
||||
|
||||
/// State of an open stream.
|
||||
///
|
||||
/// # Fields
|
||||
/// * `stream_id` - The stream identifier
|
||||
/// * `path` - The path this stream is connected to
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
struct StreamState {
|
||||
stream_id: u16,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Client {
|
||||
/// Create a new client with a local tree.
|
||||
///
|
||||
/// The local tree has `/shell` and `/tty` endpoints registered.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let mut client = Client::new_local();
|
||||
/// let leaves = client.list_leaves();
|
||||
/// assert!(leaves.contains(&"/shell".to_string()));
|
||||
/// ```
|
||||
pub fn new_local() -> Self {
|
||||
let mut tree = Tree::new();
|
||||
tree.add_endpoint("/shell", Box::new(RemoteShell::new("shell")));
|
||||
tree.add_endpoint("/tty", Box::new(TTY::new("tty")));
|
||||
|
||||
Self {
|
||||
transport: None,
|
||||
tree,
|
||||
current_path: String::from("/"),
|
||||
request_id: 1,
|
||||
stream_id: 1,
|
||||
streams: Vec::new(),
|
||||
base_path: String::from("/"),
|
||||
mode: ClientMode::Local,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the next request ID.
|
||||
///
|
||||
/// Each request gets a unique incrementing ID.
|
||||
fn next_request_id(&mut self) -> u64 {
|
||||
let id = self.request_id;
|
||||
self.request_id += 1;
|
||||
id
|
||||
}
|
||||
|
||||
/// Get the next stream ID.
|
||||
#[allow(dead_code)]
|
||||
fn next_stream_id(&mut self) -> u16 {
|
||||
let id = self.stream_id;
|
||||
self.stream_id = self.stream_id.wrapping_add(1);
|
||||
id
|
||||
}
|
||||
|
||||
/// List nodes at a path.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - Optional path (defaults to current path)
|
||||
///
|
||||
/// # Returns
|
||||
/// List of child node names
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let mut client = Client::new_local();
|
||||
/// let nodes = client.list_nodes(None).unwrap();
|
||||
/// ```
|
||||
pub fn list_nodes(&self, path: Option<&str>) -> Result<Vec<String>, String> {
|
||||
let path = path.unwrap_or(&self.current_path);
|
||||
self.tree.list_nodes(path)
|
||||
}
|
||||
|
||||
/// List endpoints at a path.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - Optional path (defaults to current path)
|
||||
///
|
||||
/// # Returns
|
||||
/// List of endpoint information
|
||||
pub fn list_endpoints(
|
||||
&self,
|
||||
path: Option<&str>,
|
||||
) -> Result<Vec<crate::protocol::EndpointInfo>, String> {
|
||||
let path = path.unwrap_or(&self.current_path);
|
||||
self.tree.list_endpoints(path)
|
||||
}
|
||||
|
||||
/// List all leaf paths.
|
||||
///
|
||||
/// # Returns
|
||||
/// List of leaf node paths
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let client = Client::new_local();
|
||||
/// let leaves = client.list_leaves();
|
||||
/// assert!(!leaves.is_empty());
|
||||
/// ```
|
||||
pub fn list_leaves(&self) -> Vec<String> {
|
||||
self.tree.list_leaves()
|
||||
}
|
||||
|
||||
/// Get information about a node.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - The path to get info about
|
||||
///
|
||||
/// # Returns
|
||||
/// Node information
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let client = Client::new_local();
|
||||
/// let info = client.get_info("/shell").unwrap();
|
||||
/// assert!(info.is_leaf);
|
||||
/// ```
|
||||
pub fn get_info(&self, path: &str) -> Result<crate::protocol::NodeInfo, String> {
|
||||
self.tree.get_info(path)
|
||||
}
|
||||
|
||||
/// Execute a command locally on the tree.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - The path to execute at
|
||||
/// * `cmd` - The command to execute
|
||||
///
|
||||
/// # Returns
|
||||
/// Execution response with exit code and output
|
||||
pub fn exec_local(&mut self, path: &str, cmd: &str) -> Result<TreeResponse, String> {
|
||||
let (handler, matched_path) = self
|
||||
.tree
|
||||
.find_handler(path)
|
||||
.ok_or_else(|| format!("path not found: {}", path))?;
|
||||
|
||||
let request = TreeRequest::Exec {
|
||||
cmd: cmd.to_string(),
|
||||
};
|
||||
|
||||
let mut handler = handler.lock().map_err(|e| e.to_string())?;
|
||||
handler.handle_request(&request, matched_path)
|
||||
}
|
||||
|
||||
/// Connect to a remote server.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `addr` - The server address (e.g., "localhost:8080")
|
||||
///
|
||||
/// # Returns
|
||||
/// Ok(()) on success
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let mut client = Client::new_local();
|
||||
/// client.connect("localhost:8080").unwrap();
|
||||
/// ```
|
||||
pub fn connect(&mut self, addr: &str) -> Result<(), String> {
|
||||
let transport = TcpTransport::connect(addr).map_err(|e| e.to_string())?;
|
||||
self.transport = Some(transport);
|
||||
self.mode = ClientMode::Connected;
|
||||
self.do_handshake()
|
||||
}
|
||||
|
||||
/// Perform handshake with remote server.
|
||||
fn do_handshake(&mut self) -> Result<(), String> {
|
||||
let transport = self.transport.as_mut().ok_or("not connected")?;
|
||||
let (header, payload) = make_handshake(vec![self.current_path.clone()]);
|
||||
transport
|
||||
.send_frame(&header, Some(&payload))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (header, payload) = transport.recv_frame().map_err(|e| e.to_string())?;
|
||||
if header.frame_type != FrameType::HandshakeAck {
|
||||
return Err("unexpected response type".to_string());
|
||||
}
|
||||
let ack = crate::protocol::HandshakeAck::from_bytes(&payload)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !ack.accepted {
|
||||
return Err("handshake rejected".to_string());
|
||||
}
|
||||
self.base_path = ack.assigned_base_path.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a request to the remote server.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `dst_path` - The destination path
|
||||
/// * `request` - The request to send
|
||||
///
|
||||
/// # Returns
|
||||
/// The response from the server
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let mut client = Client::new_local();
|
||||
/// client.connect("localhost:8080").unwrap();
|
||||
///
|
||||
/// let request = TreeRequest::Exec { cmd: "echo hello".to_string() };
|
||||
/// let response = client.send_request("/shell", &request).unwrap();
|
||||
/// ```
|
||||
pub fn send_request(&mut self, dst_path: &str, request: &TreeRequest) -> Result<TreeResponse, String> {
|
||||
let request_id = self.next_request_id();
|
||||
|
||||
let transport = self.transport.as_mut().ok_or("not connected")?;
|
||||
|
||||
let full_path = if dst_path.starts_with('/') {
|
||||
dst_path.to_string()
|
||||
} else {
|
||||
format!("{}/{}", self.current_path, dst_path)
|
||||
};
|
||||
|
||||
let (header, payload) = make_request(&full_path, &self.base_path, request_id, request);
|
||||
transport
|
||||
.send_frame(&header, Some(&payload))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let (header, payload) = transport.recv_frame().map_err(|e| e.to_string())?;
|
||||
if header.frame_type != FrameType::Response {
|
||||
return Err("unexpected response type".to_string());
|
||||
}
|
||||
|
||||
let response = TreeResponse::from_bytes(&payload).map_err(|e| e.to_string())?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Open a stream to a remote path.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `dst_path` - The destination path
|
||||
///
|
||||
/// # Returns
|
||||
/// The stream ID on success
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let mut client = Client::new_local();
|
||||
/// client.connect("localhost:8080").unwrap();
|
||||
/// let stream_id = client.open_stream("/tty").unwrap();
|
||||
/// ```
|
||||
pub fn open_stream(&mut self, dst_path: &str) -> Result<u16, String> {
|
||||
let request_id = self.next_request_id();
|
||||
|
||||
let transport = self.transport.as_mut().ok_or("not connected")?;
|
||||
|
||||
let full_path = if dst_path.starts_with('/') {
|
||||
dst_path.to_string()
|
||||
} else {
|
||||
format!("{}/{}", self.current_path, dst_path)
|
||||
};
|
||||
|
||||
let header = make_stream_open(&full_path, &self.base_path, request_id);
|
||||
transport.send_frame(&header, None).map_err(|e| e.to_string())?;
|
||||
|
||||
let (header, payload) = transport.recv_frame().map_err(|e| e.to_string())?;
|
||||
if header.frame_type != FrameType::Response {
|
||||
return Err("unexpected response type".to_string());
|
||||
}
|
||||
|
||||
let response = TreeResponse::from_bytes(&payload).map_err(|e| e.to_string())?;
|
||||
|
||||
match response {
|
||||
TreeResponse::StreamOpened { stream_id } => {
|
||||
self.streams.push(StreamState {
|
||||
stream_id,
|
||||
path: full_path,
|
||||
});
|
||||
Ok(stream_id)
|
||||
}
|
||||
_ => Err("expected StreamOpened".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send data on a stream.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `stream_id` - The stream ID
|
||||
/// * `data` - The data to send
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let mut client = Client::new_local();
|
||||
/// client.connect("localhost:8080").unwrap();
|
||||
/// let stream_id = client.open_stream("/tty").unwrap();
|
||||
/// client.send_stream_data(stream_id, b"hello").unwrap();
|
||||
/// ```
|
||||
pub fn send_stream_data(&mut self, stream_id: u16, data: &[u8]) -> Result<(), String> {
|
||||
let transport = self.transport.as_mut().ok_or("not connected")?;
|
||||
let (header, payload) = make_stream_data(stream_id, data);
|
||||
transport
|
||||
.send_frame(&header, Some(&payload))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Close a stream.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `stream_id` - The stream ID to close
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let mut client = Client::new_local();
|
||||
/// client.connect("localhost:8080").unwrap();
|
||||
/// let stream_id = client.open_stream("/tty").unwrap();
|
||||
/// client.close_stream(stream_id).unwrap();
|
||||
/// ```
|
||||
pub fn close_stream(&mut self, stream_id: u16) -> Result<(), String> {
|
||||
let transport = self.transport.as_mut().ok_or("not connected")?;
|
||||
let header = make_stream_close(stream_id);
|
||||
transport
|
||||
.send_frame(&header, None)
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.streams.retain(|s| s.stream_id != stream_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if connected to remote.
|
||||
///
|
||||
/// # Returns
|
||||
/// True if connected to a remote server
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let client = Client::new_local();
|
||||
/// assert!(!client.is_connected());
|
||||
/// ```
|
||||
pub fn is_connected(&self) -> bool {
|
||||
matches!(self.mode, ClientMode::Connected)
|
||||
}
|
||||
|
||||
/// Get current path.
|
||||
///
|
||||
/// # Returns
|
||||
/// The current working path
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let client = Client::new_local();
|
||||
/// assert_eq!(client.current_path(), "/");
|
||||
/// ```
|
||||
pub fn current_path(&self) -> &str {
|
||||
&self.current_path
|
||||
}
|
||||
|
||||
/// Set current path.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - The new current path
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let mut client = Client::new_local();
|
||||
/// client.set_path("/shell");
|
||||
/// assert_eq!(client.current_path(), "/shell");
|
||||
/// ```
|
||||
pub fn set_path(&mut self, path: &str) {
|
||||
self.current_path = path.to_string();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
//! # Leaves Module
|
||||
|
||||
pub mod proxy;
|
||||
pub mod shell;
|
||||
pub mod tty;
|
||||
|
||||
pub use proxy::ProxyEndpoint;
|
||||
pub use shell::RemoteShell;
|
||||
pub use tty::TTY;
|
||||
@@ -1,106 +0,0 @@
|
||||
//! # Proxy Endpoint
|
||||
//!
|
||||
//! This module provides a proxy endpoint that routes to child nodes for ls/info operations.
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use crate::protocol::{TreeRequest, TreeResponse, EndpointType};
|
||||
#[allow(unused_imports)]
|
||||
use crate::tree::Endpoint;
|
||||
use std::string::String;
|
||||
|
||||
/// A proxy endpoint that routes to children for ls/info operations.
|
||||
///
|
||||
/// This endpoint is used at the root ("/") to allow traversal to child endpoints
|
||||
/// like /shell and /tty when there's no direct handler at the root.
|
||||
#[derive(Debug)]
|
||||
pub struct ProxyEndpoint {
|
||||
name: String,
|
||||
#[allow(dead_code)]
|
||||
tree: Option<Box<crate::tree::Tree>>,
|
||||
}
|
||||
|
||||
impl ProxyEndpoint {
|
||||
/// Create a new proxy endpoint.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `name` - The name of this endpoint (typically "proxy")
|
||||
/// * `tree` - Optional tree to proxy to
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let proxy = ProxyEndpoint::new("proxy", None);
|
||||
/// ```
|
||||
#[allow(dead_code)]
|
||||
pub fn new(name: &str, _tree: Option<Box<crate::tree::Tree>>) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
tree: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a proxy endpoint with an empty tree.
|
||||
pub fn new_empty(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
tree: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::tree::Endpoint for ProxyEndpoint {
|
||||
fn handle_request(&mut self, request: &TreeRequest, _src_path: &str) -> Result<TreeResponse, String> {
|
||||
match request {
|
||||
TreeRequest::ListNodes { .. } => {
|
||||
if let Some(ref tree) = self.tree {
|
||||
let names = tree.list_nodes("/").unwrap_or_default();
|
||||
Ok(TreeResponse::NodeList { names })
|
||||
} else {
|
||||
Ok(TreeResponse::NodeList { names: vec![] })
|
||||
}
|
||||
}
|
||||
TreeRequest::ListEndpoints { .. } => {
|
||||
if let Some(ref tree) = self.tree {
|
||||
let endpoints = tree.list_endpoints("/").unwrap_or_default();
|
||||
Ok(TreeResponse::EndpointList { endpoints })
|
||||
} else {
|
||||
Ok(TreeResponse::EndpointList { endpoints: vec![] })
|
||||
}
|
||||
}
|
||||
TreeRequest::ListLeaves { .. } => {
|
||||
if let Some(ref tree) = self.tree {
|
||||
let leaves = tree.list_leaves();
|
||||
Ok(TreeResponse::LeafList { leaves })
|
||||
} else {
|
||||
Ok(TreeResponse::LeafList { leaves: vec![] })
|
||||
}
|
||||
}
|
||||
TreeRequest::GetInfo { path } => {
|
||||
if let Some(ref tree) = self.tree {
|
||||
let info = tree.get_info(path)?;
|
||||
Ok(TreeResponse::NodeInfo { info })
|
||||
} else {
|
||||
Err("no tree available".to_string())
|
||||
}
|
||||
}
|
||||
_ => Err("unsupported request on proxy".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_stream_open(&mut self, stream_id: u16, _src_path: &str) -> Option<u16> {
|
||||
Some(stream_id)
|
||||
}
|
||||
|
||||
fn on_stream_data(&mut self, _stream_id: u16, _data: &[u8]) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn on_stream_close(&mut self, _stream_id: u16) {}
|
||||
|
||||
fn endpoint_type(&self) -> EndpointType {
|
||||
EndpointType::Proxy
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
//! # RemoteShell Leaf
|
||||
//!
|
||||
//! This module provides command execution functionality.
|
||||
|
||||
use crate::protocol::{TreeRequest, TreeResponse, EndpointType};
|
||||
use crate::tree::Endpoint;
|
||||
use std::string::String;
|
||||
use std::vec::Vec;
|
||||
use std::result::Result;
|
||||
use std::fmt;
|
||||
|
||||
/// RemoteShell - executes commands locally.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ush_treetest::leaves::RemoteShell;
|
||||
///
|
||||
/// let shell = RemoteShell::new("shell");
|
||||
/// ```
|
||||
pub struct RemoteShell {
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl RemoteShell {
|
||||
/// Create a new RemoteShell endpoint.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `name` - The name for this endpoint
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute(&self, cmd: &str) -> (i32, Vec<u8>, Vec<u8>) {
|
||||
use std::process::{Command, Stdio};
|
||||
match Command::new("sh")
|
||||
.args(["-c", cmd])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
{
|
||||
Ok(out) => (out.status.code().unwrap_or(-1), out.stdout, out.stderr),
|
||||
Err(e) => (-1, Vec::new(), format!("{}\n", e).into_bytes()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for RemoteShell {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("RemoteShell")
|
||||
.field("name", &self.name)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Endpoint for RemoteShell {
|
||||
fn handle_request(
|
||||
&mut self,
|
||||
request: &TreeRequest,
|
||||
_src_path: &str,
|
||||
) -> Result<TreeResponse, String> {
|
||||
match request {
|
||||
TreeRequest::Exec { cmd } => {
|
||||
let (exit_code, stdout, stderr) = self.execute(cmd);
|
||||
Ok(TreeResponse::ExecOutput {
|
||||
exit_code,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
_ => Err("unsupported request".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_stream_open(
|
||||
&mut self,
|
||||
_stream_id: u16,
|
||||
_src_path: &str,
|
||||
) -> Option<u16> {
|
||||
None
|
||||
}
|
||||
|
||||
fn on_stream_data(
|
||||
&mut self,
|
||||
_stream_id: u16,
|
||||
_data: &[u8],
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn on_stream_close(&mut self, _stream_id: u16) {}
|
||||
|
||||
fn endpoint_type(&self) -> EndpointType {
|
||||
EndpointType::Leaf
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
//! # TTY Leaf
|
||||
//!
|
||||
//! This module provides PTY-based terminal sessions for the unshell protocol.
|
||||
//! It supports opening pseudo-terminals and streaming data to/from them.
|
||||
|
||||
use crate::protocol::{TreeRequest, TreeResponse, EndpointType};
|
||||
use crate::tree::Endpoint;
|
||||
use std::boxed::Box;
|
||||
use std::result::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
/// A PTY session - represents an active terminal session.
|
||||
#[allow(dead_code)]
|
||||
pub struct PtySession {
|
||||
/// Stream ID for this session
|
||||
pub stream_id: u16,
|
||||
/// Master file descriptor for the PTY
|
||||
pub master: std::os::unix::io::RawFd,
|
||||
/// Child process PID
|
||||
pub child_pid: u32,
|
||||
}
|
||||
|
||||
impl fmt::Debug for PtySession {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("PtySession")
|
||||
.field("stream_id", &self.stream_id)
|
||||
.field("child_pid", &self.child_pid)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// TTY endpoint - provides PTY streaming functionality.
|
||||
pub struct Tty {
|
||||
name: String,
|
||||
sessions: HashMap<u16, Box<PtySession>>,
|
||||
#[allow(dead_code)]
|
||||
next_id: u16,
|
||||
}
|
||||
|
||||
impl fmt::Debug for Tty {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Tty")
|
||||
.field("name", &self.name)
|
||||
.field("sessions", &self.sessions.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Tty {
|
||||
/// Create a new TTY endpoint.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `name` - The name for this endpoint
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
sessions: HashMap::new(),
|
||||
next_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a new PTY session.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `stream_id` - The stream ID for this session
|
||||
///
|
||||
/// # Returns
|
||||
/// Ok(()) on success, Err(message) on failure
|
||||
fn open_pty(&mut self, stream_id: u16) -> Result<(), String> {
|
||||
let master = unsafe { libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY) };
|
||||
if master < 0 {
|
||||
return Err("failed to open PTY".to_string());
|
||||
}
|
||||
|
||||
if unsafe { libc::grantpt(master) } != 0 {
|
||||
unsafe { libc::close(master) };
|
||||
return Err("failed to grant PTY".to_string());
|
||||
}
|
||||
|
||||
if unsafe { libc::unlockpt(master) } != 0 {
|
||||
unsafe { libc::close(master) };
|
||||
return Err("failed to unlock PTY".to_string());
|
||||
}
|
||||
|
||||
let slave_name = unsafe {
|
||||
let ptr = libc::ptsname(master);
|
||||
if ptr.is_null() {
|
||||
libc::close(master);
|
||||
return Err("failed to get PTY name".to_string());
|
||||
}
|
||||
std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned()
|
||||
};
|
||||
|
||||
let pid = unsafe { libc::fork() };
|
||||
if pid < 0 {
|
||||
unsafe { libc::close(master) };
|
||||
return Err("fork failed".to_string());
|
||||
}
|
||||
|
||||
if pid == 0 {
|
||||
unsafe { libc::close(master) };
|
||||
|
||||
let slave = unsafe {
|
||||
libc::open(slave_name.as_ptr() as *const libc::c_char, libc::O_RDWR)
|
||||
};
|
||||
if slave < 0 {
|
||||
unsafe { libc::exit(1) };
|
||||
}
|
||||
|
||||
unsafe { libc::ioctl(slave, libc::TIOCSCTTY, 0) };
|
||||
|
||||
unsafe {
|
||||
libc::dup2(slave, libc::STDIN_FILENO);
|
||||
libc::dup2(slave, libc::STDOUT_FILENO);
|
||||
libc::dup2(slave, libc::STDERR_FILENO);
|
||||
libc::close(slave);
|
||||
}
|
||||
|
||||
unsafe {
|
||||
libc::execl(
|
||||
c"/bin/sh\0".as_ptr() as *const libc::c_char,
|
||||
c"sh\0".as_ptr() as *const libc::c_char,
|
||||
std::ptr::null::<libc::c_char>(),
|
||||
);
|
||||
}
|
||||
|
||||
unsafe { libc::exit(1) };
|
||||
}
|
||||
|
||||
self.sessions.insert(stream_id, Box::new(PtySession {
|
||||
stream_id,
|
||||
master,
|
||||
child_pid: pid as u32,
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write data to a PTY session.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `stream_id` - The stream ID
|
||||
/// * `data` - The data to write
|
||||
///
|
||||
/// # Returns
|
||||
/// Ok(()) on success, Err(message) on failure
|
||||
fn write_to_pty(&mut self, stream_id: u16, data: &[u8]) -> Result<(), String> {
|
||||
let session = self.sessions.get_mut(&stream_id).ok_or("session not found")?;
|
||||
let written = unsafe {
|
||||
libc::write(
|
||||
session.master,
|
||||
data.as_ptr() as *const libc::c_void,
|
||||
data.len(),
|
||||
)
|
||||
};
|
||||
if written < 0 {
|
||||
return Err("write failed".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close a PTY session.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `stream_id` - The stream ID to close
|
||||
fn close_pty(&mut self, stream_id: u16) {
|
||||
if let Some(session) = self.sessions.remove(&stream_id) {
|
||||
unsafe { libc::kill(session.child_pid as i32, libc::SIGTERM) };
|
||||
|
||||
let mut status: libc::c_int = 0;
|
||||
unsafe { libc::waitpid(session.child_pid as i32, &mut status, 0) };
|
||||
|
||||
unsafe { libc::close(session.master) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Endpoint for Tty {
|
||||
fn handle_request(
|
||||
&mut self,
|
||||
request: &TreeRequest,
|
||||
_src_path: &str,
|
||||
) -> Result<TreeResponse, String> {
|
||||
match request {
|
||||
TreeRequest::Exec { cmd } => {
|
||||
use std::process::{Command, Stdio};
|
||||
let output = Command::new("sh")
|
||||
.args(["-c", cmd])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(TreeResponse::ExecOutput {
|
||||
exit_code: output.status.code().unwrap_or(-1),
|
||||
stdout: output.stdout,
|
||||
stderr: output.stderr,
|
||||
})
|
||||
}
|
||||
_ => Err("use stream for TTY".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_stream_open(
|
||||
&mut self,
|
||||
stream_id: u16,
|
||||
_src_path: &str,
|
||||
) -> Option<u16> {
|
||||
self.open_pty(stream_id).ok().map(|_| stream_id)
|
||||
}
|
||||
|
||||
fn on_stream_data(
|
||||
&mut self,
|
||||
stream_id: u16,
|
||||
data: &[u8],
|
||||
) -> bool {
|
||||
self.write_to_pty(stream_id, data).ok();
|
||||
true
|
||||
}
|
||||
|
||||
fn on_stream_close(&mut self, stream_id: u16) {
|
||||
self.close_pty(stream_id);
|
||||
}
|
||||
|
||||
fn endpoint_type(&self) -> EndpointType {
|
||||
EndpointType::Stream
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
//! # Unshell Tree Protocol Testbed
|
||||
//!
|
||||
//! This is a testbed implementation of a tree-based routing protocol for unshell.
|
||||
//! It supports serving and connecting to tree endpoints, with leaves for RemoteShell
|
||||
//! (command execution) and TTY (PTY streaming).
|
||||
//!
|
||||
//! # Commands
|
||||
//!
|
||||
//! - `serve [addr]` - Start a server
|
||||
//! - `connect [addr]` - Connect to a server and run CLI
|
||||
//! - `run <command>` - Run a single command locally
|
||||
//! - (default) - Run interactive CLI with local tree
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Start server
|
||||
//! $ ush-treetest serve 0.0.0.0:8080
|
||||
//!
|
||||
//! # Connect from another terminal
|
||||
//! $ ush-treetest connect localhost:8080
|
||||
//! ```
|
||||
|
||||
mod cli;
|
||||
mod client;
|
||||
mod leaves;
|
||||
mod protocol;
|
||||
mod server;
|
||||
mod tree;
|
||||
|
||||
use crate::cli::{Cli, parse_and_execute};
|
||||
use std::io::{self, Write};
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
/// CLI argument parser.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let args = Args::parse();
|
||||
/// match args.command {
|
||||
/// Some(Command::Serve { addr }) => { ... }
|
||||
/// Some(Command::Connect { addr }) => { ... }
|
||||
/// _ => { ... }
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Parser)]
|
||||
#[command(name = "ush-treetest")]
|
||||
#[command(about = "Unshell tree protocol testbed")]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
|
||||
#[arg(short, long)]
|
||||
addr: Option<String>,
|
||||
}
|
||||
|
||||
/// Subcommands for the CLI.
|
||||
///
|
||||
/// # Variants
|
||||
/// - `Serve` - Start a server
|
||||
/// - `Connect` - Connect to a server
|
||||
/// - `Run` - Run a single command locally
|
||||
/// - `Cli` - Run interactive CLI (default)
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Start a server
|
||||
Serve {
|
||||
/// Address to listen on
|
||||
#[arg(default_value = "0.0.0.0:8080")]
|
||||
addr: String,
|
||||
},
|
||||
/// Connect to a server
|
||||
Connect {
|
||||
/// Server address to connect to
|
||||
#[arg(default_value = "localhost:8080")]
|
||||
addr: String,
|
||||
},
|
||||
/// Run interactive CLI
|
||||
Cli {},
|
||||
/// Run a single command locally
|
||||
Run {
|
||||
/// Command to execute
|
||||
command: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Main entry point.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// // Start server
|
||||
/// $ ush-treetest serve
|
||||
///
|
||||
/// // Connect to server
|
||||
/// $ ush-treetest connect localhost:8080
|
||||
///
|
||||
/// // Run locally
|
||||
/// $ ush-treetest run "exec /shell echo hello"
|
||||
/// ```
|
||||
fn main() {
|
||||
let _ = env_logger::try_init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
match args.command {
|
||||
Some(Command::Serve { addr }) => {
|
||||
server::run_server(&addr);
|
||||
}
|
||||
Some(Command::Connect { addr }) => {
|
||||
run_client(&addr);
|
||||
}
|
||||
Some(Command::Run { command }) => {
|
||||
run_single_command(&command);
|
||||
}
|
||||
None | Some(Command::Cli {}) => {
|
||||
run_interactive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the client with connection to a server.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `addr` - Server address
|
||||
fn run_client(addr: &str) {
|
||||
let mut cli = Cli::new();
|
||||
|
||||
if let Err(e) = cli.connect(addr) {
|
||||
eprintln!("Failed to connect: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("Connected to {}", addr);
|
||||
run_cli_loop(&mut cli);
|
||||
}
|
||||
|
||||
/// Run an interactive CLI with a local tree.
|
||||
fn run_interactive() {
|
||||
let mut cli = Cli::new();
|
||||
|
||||
println!("Unshell Tree Protocol Testbed");
|
||||
println!("Type 'help' for commands\n");
|
||||
println!("Local tree with endpoints:");
|
||||
for leaf in cli.list_leaves() {
|
||||
println!(" {}", leaf);
|
||||
}
|
||||
println!();
|
||||
|
||||
run_cli_loop(&mut cli);
|
||||
}
|
||||
|
||||
/// Run the CLI command loop.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `cli` - The CLI instance
|
||||
fn run_cli_loop(cli: &mut Cli) {
|
||||
loop {
|
||||
print!("{}> ", cli.current_path());
|
||||
io::stdout().flush().ok();
|
||||
|
||||
let mut line = String::new();
|
||||
if io::stdin().read_line(&mut line).is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
let line = line.trim();
|
||||
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if line == "quit" || line == "exit" {
|
||||
break;
|
||||
}
|
||||
|
||||
match parse_and_execute(cli, line) {
|
||||
Ok(output) => {
|
||||
if !output.is_empty() {
|
||||
println!("{}", output);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a single command locally.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `command` - The command to run
|
||||
fn run_single_command(command: &str) {
|
||||
let mut cli = Cli::new();
|
||||
|
||||
match parse_and_execute(&mut cli, command) {
|
||||
Ok(output) => {
|
||||
if !output.is_empty() {
|
||||
println!("{}", output);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//! # Protocol Module
|
||||
//!
|
||||
//! This module defines the protocol types and transport layer for the unshell tree protocol.
|
||||
//! It provides serialization via rkyv and TCP transport for frame passing.
|
||||
//!
|
||||
//! # Frame Format
|
||||
//!
|
||||
//! Each frame consists of:
|
||||
//! - 4-byte header length (little-endian u32)
|
||||
//! - Serialized header bytes (using rkyv)
|
||||
//! - 4-byte payload length (little-endian u32)
|
||||
//! - Payload bytes (optional)
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use ush_treetest::protocol::{
|
||||
//! FrameType, FrameHeader, TreeRequest, TreeResponse,
|
||||
//! TcpTransport, Transport,
|
||||
//! };
|
||||
//!
|
||||
//! // Connect to server
|
||||
//! let mut transport = TcpTransport::connect("localhost:8080").unwrap();
|
||||
//!
|
||||
//! // Send a request
|
||||
//! let header = FrameHeader {
|
||||
//! frame_type: FrameType::Request,
|
||||
//! dst_path: Some("/shell".to_string()),
|
||||
//! src_path: "/client".to_string(),
|
||||
//! request_id: Some(1),
|
||||
//! stream_id: None,
|
||||
//! };
|
||||
//! let payload = TreeRequest::Exec { cmd: "echo hello".to_string() }.to_bytes();
|
||||
//! transport.send_frame(&header, Some(&payload)).unwrap();
|
||||
//!
|
||||
//! // Receive response
|
||||
//! let (header, payload) = transport.recv_frame().unwrap();
|
||||
//! let response = TreeResponse::from_bytes(&payload).unwrap();
|
||||
//! ```
|
||||
|
||||
pub mod types;
|
||||
pub mod transport;
|
||||
|
||||
pub use types::*;
|
||||
pub use transport::*;
|
||||
@@ -1,436 +0,0 @@
|
||||
//! # Transport Layer
|
||||
//!
|
||||
//! This module provides the Transport trait and TCP implementation.
|
||||
//! Uses a simple length-prefixed framing: `[u32 header_len][header bytes][u32 payload_len][payload bytes]`
|
||||
//!
|
||||
//! # Frame Format
|
||||
//!
|
||||
//! Each frame is encoded as:
|
||||
//! - 4 bytes: header length (little-endian u32)
|
||||
//! - N bytes: serialized header
|
||||
//! - 4 bytes: payload length (little-endian u32)
|
||||
//! - M bytes: payload (optional)
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use ush_treetest::protocol::{TcpTransport, Transport, FrameHeader, FrameType};
|
||||
//!
|
||||
//! // Connect to server
|
||||
//! let mut transport = TcpTransport::connect("localhost:8080").unwrap();
|
||||
//!
|
||||
//! // Send a frame
|
||||
//! let header = FrameHeader {
|
||||
//! frame_type: FrameType::Request,
|
||||
//! dst_path: Some("/shell".to_string()),
|
||||
//! src_path: "/client".to_string(),
|
||||
//! request_id: Some(1),
|
||||
//! stream_id: None,
|
||||
//! };
|
||||
//! transport.send_frame(&header, Some(b"test payload")).unwrap();
|
||||
//!
|
||||
//! // Receive a frame
|
||||
//! let (header, payload) = transport.recv_frame().unwrap();
|
||||
//! ```
|
||||
|
||||
use crate::protocol::types::*;
|
||||
use std::net::{TcpStream, TcpListener};
|
||||
use std::io::{Read, Write, Error};
|
||||
|
||||
/// Transport trait - interface for sending and receiving frames.
|
||||
///
|
||||
/// This trait defines the interface for all transport implementations.
|
||||
/// Implementors must provide send_frame, recv_frame, and close methods.
|
||||
pub trait Transport: Sized {
|
||||
/// Error type for this transport
|
||||
type Error: std::fmt::Debug;
|
||||
|
||||
/// Send a frame (header + optional payload).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `header` - The frame header
|
||||
/// * `payload` - Optional payload bytes
|
||||
fn send_frame(
|
||||
&mut self,
|
||||
header: &FrameHeader,
|
||||
payload: Option<&[u8]>,
|
||||
) -> Result<(), Self::Error>;
|
||||
|
||||
/// Receive a frame.
|
||||
///
|
||||
/// # Returns
|
||||
/// (header, payload) tuple
|
||||
fn recv_frame(&mut self) -> Result<(FrameHeader, Vec<u8>), Self::Error>;
|
||||
|
||||
/// Close the transport.
|
||||
#[allow(dead_code)]
|
||||
fn close(&mut self) -> Result<(), Self::Error>;
|
||||
}
|
||||
|
||||
/// Transport-level errors.
|
||||
///
|
||||
/// # Variants
|
||||
/// * `ConnectionClosed` - The connection was closed
|
||||
/// * `InvalidFrame` - The frame was invalid
|
||||
/// * `Io` - I/O error
|
||||
#[derive(Debug)]
|
||||
pub enum TransportError {
|
||||
/// Connection was closed
|
||||
ConnectionClosed,
|
||||
/// Invalid frame format
|
||||
InvalidFrame(String),
|
||||
/// I/O error
|
||||
Io(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TransportError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TransportError::ConnectionClosed => write!(f, "connection closed"),
|
||||
TransportError::InvalidFrame(s) => write!(f, "invalid frame: {}", s),
|
||||
TransportError::Io(s) => write!(f, "I/O error: {}", s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error> for TransportError {
|
||||
fn from(e: Error) -> Self {
|
||||
TransportError::Io(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// TCP transport implementation.
|
||||
#[derive(Debug)]
|
||||
pub struct TcpTransport {
|
||||
stream: TcpStream,
|
||||
}
|
||||
|
||||
impl TcpTransport {
|
||||
/// Create a new TCP transport from an existing stream.
|
||||
///
|
||||
/// Sets read/write timeouts to 30 seconds for safety.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `stream` - An existing TCP stream
|
||||
pub fn new(stream: TcpStream) -> Self {
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(30)))
|
||||
.ok();
|
||||
stream
|
||||
.set_write_timeout(Some(std::time::Duration::from_secs(30)))
|
||||
.ok();
|
||||
Self { stream }
|
||||
}
|
||||
|
||||
/// Connect to a remote address.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `addr` - The address to connect to (e.g., "localhost:8080")
|
||||
///
|
||||
/// # Returns
|
||||
/// Connected transport
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let transport = TcpTransport::connect("localhost:8080").unwrap();
|
||||
/// ```
|
||||
pub fn connect(addr: &str) -> Result<Self, TransportError> {
|
||||
let stream = TcpStream::connect(addr)?;
|
||||
Ok(Self::new(stream))
|
||||
}
|
||||
|
||||
/// Create a listening socket.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `addr` - The address to listen on
|
||||
///
|
||||
/// # Returns
|
||||
/// TCP listener
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let listener = TcpTransport::listen("0.0.0.0:8080").unwrap();
|
||||
/// ```
|
||||
pub fn listen(addr: &str) -> Result<std::net::TcpListener, TransportError> {
|
||||
let listener = TcpListener::bind(addr)?;
|
||||
listener.set_nonblocking(false)?;
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
/// Accept an incoming connection.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `listener` - The listening socket
|
||||
///
|
||||
/// # Returns
|
||||
/// New transport for the connection
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let listener = TcpTransport::listen("0.0.0.0:8080").unwrap();
|
||||
/// let transport = TcpTransport::accept(&listener).unwrap();
|
||||
/// ```
|
||||
pub fn accept(listener: &std::net::TcpListener) -> Result<Self, TransportError> {
|
||||
let stream = listener.accept()?.0;
|
||||
Ok(Self::new(stream))
|
||||
}
|
||||
|
||||
/// Get peer address.
|
||||
///
|
||||
/// # Returns
|
||||
/// The peer's socket address
|
||||
pub fn peer_addr(&self) -> Result<std::net::SocketAddr, std::io::Error> {
|
||||
self.stream.peer_addr()
|
||||
}
|
||||
|
||||
/// Read exactly n bytes.
|
||||
///
|
||||
/// Will block until all bytes are read or an error occurs.
|
||||
fn read_exact(&mut self, mut n: usize) -> Result<Vec<u8>, TransportError> {
|
||||
let mut buf = Vec::with_capacity(n);
|
||||
while n > 0 {
|
||||
let mut chunk = vec![0u8; n];
|
||||
let read =
|
||||
self.stream
|
||||
.read(&mut chunk)
|
||||
.map_err(|e| TransportError::Io(e.to_string()))?;
|
||||
if read == 0 {
|
||||
return Err(TransportError::ConnectionClosed);
|
||||
}
|
||||
buf.extend_from_slice(&chunk[..read]);
|
||||
n -= read;
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for TcpTransport {
|
||||
type Error = TransportError;
|
||||
|
||||
fn send_frame(
|
||||
&mut self,
|
||||
header: &FrameHeader,
|
||||
payload: Option<&[u8]>,
|
||||
) -> Result<(), Self::Error> {
|
||||
let header_bytes = header.to_bytes();
|
||||
let header_len = header_bytes.len() as u32;
|
||||
|
||||
let payload_bytes = payload.unwrap_or(&[]);
|
||||
let payload_len = payload_bytes.len() as u32;
|
||||
|
||||
let mut frame =
|
||||
Vec::with_capacity(4 + header_len as usize + 4 + payload_len as usize);
|
||||
frame.extend_from_slice(&header_len.to_le_bytes());
|
||||
frame.extend_from_slice(&header_bytes);
|
||||
frame.extend_from_slice(&payload_len.to_le_bytes());
|
||||
frame.extend_from_slice(payload_bytes);
|
||||
|
||||
self.stream
|
||||
.write_all(&frame)
|
||||
.map_err(|e| TransportError::Io(e.to_string()))?;
|
||||
self.stream
|
||||
.flush()
|
||||
.map_err(|e| TransportError::Io(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn recv_frame(&mut self) -> Result<(FrameHeader, Vec<u8>), Self::Error> {
|
||||
let header_len_bytes = self.read_exact(4)?;
|
||||
let header_len = u32::from_le_bytes(header_len_bytes.try_into().unwrap()) as usize;
|
||||
|
||||
let header_bytes = self.read_exact(header_len)?;
|
||||
let header =
|
||||
FrameHeader::from_bytes(&header_bytes).map_err(TransportError::InvalidFrame)?;
|
||||
|
||||
let payload_len_bytes = self.read_exact(4)?;
|
||||
let payload_len =
|
||||
u32::from_le_bytes(payload_len_bytes.try_into().unwrap()) as usize;
|
||||
|
||||
let payload = if payload_len > 0 {
|
||||
self.read_exact(payload_len)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok((header, payload))
|
||||
}
|
||||
|
||||
fn close(&mut self) -> Result<(), Self::Error> {
|
||||
self.stream
|
||||
.shutdown(std::net::Shutdown::Both)
|
||||
.map_err(|e| TransportError::Io(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a request frame.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `dst_path` - Destination path
|
||||
/// * `src_path` - Source path
|
||||
/// * `request_id` - Request ID
|
||||
/// * `request` - The request payload
|
||||
///
|
||||
/// # Returns
|
||||
/// (header, payload) tuple
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ush_treetest::protocol::{make_request, TreeRequest};
|
||||
///
|
||||
/// let request = TreeRequest::Exec { cmd: "echo hello".to_string() };
|
||||
/// let (header, payload) = make_request("/shell", "/client", 1, &request);
|
||||
/// ```
|
||||
pub fn make_request(
|
||||
dst_path: &str,
|
||||
src_path: &str,
|
||||
request_id: u64,
|
||||
request: &TreeRequest,
|
||||
) -> (FrameHeader, Vec<u8>) {
|
||||
let header = FrameHeader {
|
||||
frame_type: FrameType::Request,
|
||||
dst_path: Some(dst_path.to_string()),
|
||||
src_path: src_path.to_string(),
|
||||
request_id: Some(request_id),
|
||||
stream_id: None,
|
||||
};
|
||||
let payload = request.to_bytes();
|
||||
(header, payload)
|
||||
}
|
||||
|
||||
/// Create a response frame.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `src_path` - Source path
|
||||
/// * `request_id` - Request ID
|
||||
/// * `response` - The response payload
|
||||
///
|
||||
/// # Returns
|
||||
/// (header, payload) tuple
|
||||
pub fn make_response(
|
||||
src_path: &str,
|
||||
request_id: u64,
|
||||
response: &TreeResponse,
|
||||
) -> (FrameHeader, Vec<u8>) {
|
||||
let header = FrameHeader {
|
||||
frame_type: FrameType::Response,
|
||||
dst_path: None,
|
||||
src_path: src_path.to_string(),
|
||||
request_id: Some(request_id),
|
||||
stream_id: None,
|
||||
};
|
||||
let payload = response.to_bytes();
|
||||
(header, payload)
|
||||
}
|
||||
|
||||
/// Create a stream open frame.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `dst_path` - Destination path
|
||||
/// * `src_path` - Source path
|
||||
/// * `request_id` - Request ID
|
||||
///
|
||||
/// # Returns
|
||||
/// Frame header (no payload)
|
||||
pub fn make_stream_open(dst_path: &str, src_path: &str, request_id: u64) -> FrameHeader {
|
||||
FrameHeader {
|
||||
frame_type: FrameType::StreamOpen,
|
||||
dst_path: Some(dst_path.to_string()),
|
||||
src_path: src_path.to_string(),
|
||||
request_id: Some(request_id),
|
||||
stream_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a stream data frame.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `stream_id` - Stream ID
|
||||
/// * `data` - Data to send
|
||||
///
|
||||
/// # Returns
|
||||
/// (header, payload) tuple
|
||||
pub fn make_stream_data(stream_id: u16, data: &[u8]) -> (FrameHeader, Vec<u8>) {
|
||||
let header = FrameHeader {
|
||||
frame_type: FrameType::StreamData,
|
||||
dst_path: None,
|
||||
src_path: String::new(),
|
||||
request_id: None,
|
||||
stream_id: Some(stream_id),
|
||||
};
|
||||
(header, data.to_vec())
|
||||
}
|
||||
|
||||
/// Create a stream close frame.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `stream_id` - Stream ID to close
|
||||
///
|
||||
/// # Returns
|
||||
/// Frame header (no payload)
|
||||
pub fn make_stream_close(stream_id: u16) -> FrameHeader {
|
||||
FrameHeader {
|
||||
frame_type: FrameType::StreamClose,
|
||||
dst_path: None,
|
||||
src_path: String::new(),
|
||||
request_id: None,
|
||||
stream_id: Some(stream_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a handshake frame.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `registered_paths` - Paths to register
|
||||
///
|
||||
/// # Returns
|
||||
/// (header, payload) tuple
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ush_treetest::protocol::make_handshake;
|
||||
///
|
||||
/// let paths = vec!["/client".to_string()];
|
||||
/// let (header, payload) = make_handshake(paths);
|
||||
/// ```
|
||||
pub fn make_handshake(registered_paths: Vec<String>) -> (FrameHeader, Vec<u8>) {
|
||||
let handshake = Handshake {
|
||||
registered_paths,
|
||||
};
|
||||
let payload = handshake.to_bytes();
|
||||
let header = FrameHeader {
|
||||
frame_type: FrameType::Handshake,
|
||||
dst_path: None,
|
||||
src_path: String::new(),
|
||||
request_id: None,
|
||||
stream_id: None,
|
||||
};
|
||||
(header, payload)
|
||||
}
|
||||
|
||||
/// Create a handshake ack frame.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `accepted` - Whether handshake was accepted
|
||||
/// * `assigned_base_path` - Base path to assign
|
||||
///
|
||||
/// # Returns
|
||||
/// (header, payload) tuple
|
||||
pub fn make_handshake_ack(
|
||||
accepted: bool,
|
||||
assigned_base_path: &str,
|
||||
) -> (FrameHeader, Vec<u8>) {
|
||||
let ack = HandshakeAck {
|
||||
accepted,
|
||||
assigned_base_path: assigned_base_path.to_string(),
|
||||
};
|
||||
let payload = ack.to_bytes();
|
||||
let header = FrameHeader {
|
||||
frame_type: FrameType::HandshakeAck,
|
||||
dst_path: None,
|
||||
src_path: String::new(),
|
||||
request_id: None,
|
||||
stream_id: None,
|
||||
};
|
||||
(header, payload)
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
//! # Protocol Types
|
||||
//!
|
||||
//! This module defines the core types for the UnShell protocol.
|
||||
//! Uses rkyv for zero-copy serialization.
|
||||
//!
|
||||
//! # Serialization
|
||||
//!
|
||||
//! All types implement `rkyv::Archive`, `rkyv::Serialize`, and `rkyv::Deserialize`
|
||||
//! for efficient serialization without runtime type information.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use ush_treetest::protocol::{TreeRequest, TreeResponse};
|
||||
//!
|
||||
//! // Serialize a request
|
||||
//! let request = TreeRequest::Exec { cmd: "echo hello".to_string() };
|
||||
//! let bytes = request.to_bytes();
|
||||
//!
|
||||
//! // Deserialize it back
|
||||
//! let decoded = TreeRequest::from_bytes(&bytes).unwrap();
|
||||
//! ```
|
||||
|
||||
use rkyv::{Archive, Serialize, Deserialize};
|
||||
use std::string::String;
|
||||
use std::vec::Vec;
|
||||
|
||||
/// Default buffer size for rkyv serialization.
|
||||
///
|
||||
/// This value is chosen to accommodate typical protocol messages.
|
||||
const BUFFER_SIZE: usize = 4096;
|
||||
|
||||
/// Frame type enum - distinguishes between different frame kinds.
|
||||
///
|
||||
/// Each frame type has a specific purpose in the protocol:
|
||||
/// - `Request` / `Response`: Request-response pairs
|
||||
/// - `StreamOpen` / `StreamData` / `StreamClose`: Streaming operations
|
||||
/// - `Handshake` / `HandshakeAck`: Connection setup
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ush_treetest::protocol::FrameType;
|
||||
///
|
||||
/// let frame_type = FrameType::Request;
|
||||
/// assert_eq!(frame_type as u8, 0x01);
|
||||
/// ```
|
||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum FrameType {
|
||||
/// Request frame - client requesting an operation
|
||||
Request = 0x01,
|
||||
/// Response frame - server responding to a request
|
||||
Response = 0x02,
|
||||
/// Stream open frame - requesting a stream
|
||||
StreamOpen = 0x03,
|
||||
/// Stream data frame - sending data on a stream
|
||||
StreamData = 0x04,
|
||||
/// Stream close frame - closing a stream
|
||||
StreamClose = 0x05,
|
||||
/// Handshake frame - connection initialization
|
||||
Handshake = 0x10,
|
||||
/// Handshake acknowledgement - connection acceptance
|
||||
HandshakeAck = 0x11,
|
||||
}
|
||||
|
||||
impl FrameType {
|
||||
/// Convert a byte value to a FrameType.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `v` - The byte value to convert
|
||||
///
|
||||
/// # Returns
|
||||
/// Some(FrameType) if valid, None otherwise
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ush_treetest::protocol::FrameType;
|
||||
///
|
||||
/// let ft = FrameType::from_u8(0x01);
|
||||
/// assert_eq!(ft, Some(FrameType::Request));
|
||||
///
|
||||
/// let invalid = FrameType::from_u8(0xFF);
|
||||
/// assert_eq!(invalid, None);
|
||||
/// ```
|
||||
#[allow(dead_code)]
|
||||
pub fn from_u8(v: u8) -> Option<Self> {
|
||||
match v {
|
||||
0x01 => Some(Self::Request),
|
||||
0x02 => Some(Self::Response),
|
||||
0x03 => Some(Self::StreamOpen),
|
||||
0x04 => Some(Self::StreamData),
|
||||
0x05 => Some(Self::StreamClose),
|
||||
0x10 => Some(Self::Handshake),
|
||||
0x11 => Some(Self::HandshakeAck),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame header - the metadata sent before each payload.
|
||||
///
|
||||
/// The header contains routing information and identifies the frame type.
|
||||
///
|
||||
/// # Fields
|
||||
/// * `frame_type` - The type of frame
|
||||
/// * `dst_path` - Optional destination path for routing
|
||||
/// * `src_path` - Source path for the frame
|
||||
/// * `request_id` - Optional request ID for correlation
|
||||
/// * `stream_id` - Optional stream ID for streaming
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ush_treetest::protocol::{FrameHeader, FrameType};
|
||||
///
|
||||
/// let header = FrameHeader {
|
||||
/// frame_type: FrameType::Request,
|
||||
/// dst_path: Some("/shell".to_string()),
|
||||
/// src_path: "/client".to_string(),
|
||||
/// request_id: Some(1),
|
||||
/// stream_id: None,
|
||||
/// };
|
||||
///
|
||||
/// // Serialize and deserialize
|
||||
/// let bytes = header.to_bytes();
|
||||
/// let decoded = FrameHeader::from_bytes(&bytes).unwrap();
|
||||
/// assert_eq!(decoded.frame_type, FrameType::Request);
|
||||
/// ```
|
||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct FrameHeader {
|
||||
/// The type of this frame
|
||||
pub frame_type: FrameType,
|
||||
/// Destination path for routing (None for responses)
|
||||
pub dst_path: Option<String>,
|
||||
/// Source path of the sender
|
||||
pub src_path: String,
|
||||
/// Request ID for correlation (for request/response)
|
||||
pub request_id: Option<u64>,
|
||||
/// Stream ID (for stream operations)
|
||||
pub stream_id: Option<u16>,
|
||||
}
|
||||
|
||||
impl FrameHeader {
|
||||
/// Serialize the header to bytes.
|
||||
///
|
||||
/// # Returns
|
||||
/// Serialized bytes
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
rkyv::to_bytes::<FrameHeader, BUFFER_SIZE>(self)
|
||||
.unwrap()
|
||||
.into_vec()
|
||||
}
|
||||
|
||||
/// Deserialize header from bytes.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `bytes` - Serialized bytes
|
||||
///
|
||||
/// # Returns
|
||||
/// Deserialized header
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, String> {
|
||||
unsafe { rkyv::from_bytes_unchecked(bytes) }.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Tree request - operations on the tree.
|
||||
///
|
||||
/// These requests are sent from clients to servers to perform operations.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ush_treetest::protocol::TreeRequest;
|
||||
///
|
||||
/// // Execute a command
|
||||
/// let request = TreeRequest::Exec { cmd: "echo hello".to_string() };
|
||||
/// let bytes = request.to_bytes();
|
||||
/// let decoded = TreeRequest::from_bytes(&bytes).unwrap();
|
||||
/// ```
|
||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||
pub enum TreeRequest {
|
||||
/// List child nodes at a path
|
||||
ListNodes {},
|
||||
/// List endpoints at a path
|
||||
ListEndpoints {},
|
||||
/// List all leaf paths in the tree
|
||||
ListLeaves {},
|
||||
/// Get information about a node
|
||||
GetInfo { path: String },
|
||||
/// Execute a command
|
||||
Exec { cmd: String },
|
||||
/// Open a stream to a path
|
||||
StreamOpen { path: String },
|
||||
/// Resize a terminal
|
||||
Resize { rows: u16, cols: u16 },
|
||||
}
|
||||
|
||||
impl TreeRequest {
|
||||
/// Serialize the request to bytes.
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
rkyv::to_bytes::<TreeRequest, BUFFER_SIZE>(self)
|
||||
.unwrap()
|
||||
.into_vec()
|
||||
}
|
||||
|
||||
/// Deserialize request from bytes.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `bytes` - Serialized bytes
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, String> {
|
||||
unsafe { rkyv::from_bytes_unchecked(bytes) }.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Tree response - results from tree operations.
|
||||
///
|
||||
/// These responses are sent from servers to clients.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ush_treetest::protocol::TreeResponse;
|
||||
///
|
||||
/// let response = TreeResponse::ExecOutput {
|
||||
/// exit_code: 0,
|
||||
/// stdout: b"hello".to_vec(),
|
||||
/// stderr: b"".to_vec(),
|
||||
/// };
|
||||
/// let bytes = response.to_bytes();
|
||||
/// ```
|
||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||
pub enum TreeResponse {
|
||||
/// List of child node names
|
||||
NodeList { names: Vec<String> },
|
||||
/// List of endpoints
|
||||
EndpointList { endpoints: Vec<EndpointInfo> },
|
||||
/// List of leaf paths
|
||||
LeafList { leaves: Vec<String> },
|
||||
/// Node information
|
||||
NodeInfo { info: NodeInfo },
|
||||
/// Command execution output
|
||||
ExecOutput {
|
||||
exit_code: i32,
|
||||
stdout: Vec<u8>,
|
||||
stderr: Vec<u8>,
|
||||
},
|
||||
/// Stream opened confirmation
|
||||
StreamOpened { stream_id: u16 },
|
||||
}
|
||||
|
||||
impl TreeResponse {
|
||||
/// Serialize the response to bytes.
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
rkyv::to_bytes::<TreeResponse, BUFFER_SIZE>(self)
|
||||
.unwrap()
|
||||
.into_vec()
|
||||
}
|
||||
|
||||
/// Deserialize response from bytes.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `bytes` - Serialized bytes
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, String> {
|
||||
unsafe { rkyv::from_bytes_unchecked(bytes) }.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about an endpoint.
|
||||
///
|
||||
/// # Fields
|
||||
/// * `name` - The endpoint name
|
||||
/// * `path` - The path where the endpoint is registered
|
||||
/// * `endpoint_type` - The type of endpoint
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ush_treetest::protocol::{EndpointInfo, EndpointType};
|
||||
///
|
||||
/// let info = EndpointInfo {
|
||||
/// name: "shell".to_string(),
|
||||
/// path: "/shell".to_string(),
|
||||
/// endpoint_type: EndpointType::Leaf,
|
||||
/// };
|
||||
/// ```
|
||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct EndpointInfo {
|
||||
/// The endpoint name
|
||||
pub name: String,
|
||||
/// The path where this endpoint is registered
|
||||
pub path: String,
|
||||
/// The type of this endpoint
|
||||
pub endpoint_type: EndpointType,
|
||||
}
|
||||
|
||||
/// Type of endpoint.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ush_treetest::protocol::EndpointType;
|
||||
///
|
||||
/// let leaf_type = EndpointType::Leaf;
|
||||
/// assert!(matches!(leaf_type, EndpointType::Leaf));
|
||||
/// ```
|
||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone, Copy)]
|
||||
#[repr(u8)]
|
||||
pub enum EndpointType {
|
||||
/// Leaf endpoint - executes commands
|
||||
Leaf = 0x01,
|
||||
/// Proxy endpoint - routes to other endpoints
|
||||
Proxy = 0x02,
|
||||
/// Stream endpoint - provides streaming
|
||||
Stream = 0x03,
|
||||
}
|
||||
|
||||
/// Information about a node in the tree.
|
||||
///
|
||||
/// # Fields
|
||||
/// * `path` - The node path
|
||||
/// * `is_leaf` - Whether this is a leaf node
|
||||
/// * `has_children` - Whether this node has children
|
||||
/// * `endpoints` - List of endpoint names at this node
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ush_treetest::protocol::NodeInfo;
|
||||
///
|
||||
/// let info = NodeInfo {
|
||||
/// path: "/shell".to_string(),
|
||||
/// is_leaf: true,
|
||||
/// has_children: false,
|
||||
/// endpoints: vec!["shell".to_string()],
|
||||
/// };
|
||||
/// assert!(info.is_leaf);
|
||||
/// ```
|
||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct NodeInfo {
|
||||
/// The node path
|
||||
pub path: String,
|
||||
/// Whether this is a leaf node (endpoint with no children)
|
||||
pub is_leaf: bool,
|
||||
/// Whether this node has children
|
||||
pub has_children: bool,
|
||||
/// Names of endpoints at this node
|
||||
pub endpoints: Vec<String>,
|
||||
}
|
||||
|
||||
/// Handshake message - sent when connecting.
|
||||
///
|
||||
/// The client sends registered paths during handshake.
|
||||
///
|
||||
/// # Fields
|
||||
/// * `registered_paths` - Paths the client wants to register
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ush_treetest::protocol::Handshake;
|
||||
///
|
||||
/// let handshake = Handshake {
|
||||
/// registered_paths: vec!["/client".to_string()],
|
||||
/// };
|
||||
/// let bytes = handshake.to_bytes();
|
||||
/// ```
|
||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Handshake {
|
||||
/// Paths the client wants to register
|
||||
pub registered_paths: Vec<String>,
|
||||
}
|
||||
|
||||
impl Handshake {
|
||||
/// Serialize the handshake to bytes.
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
rkyv::to_bytes::<Handshake, BUFFER_SIZE>(self)
|
||||
.unwrap()
|
||||
.into_vec()
|
||||
}
|
||||
|
||||
/// Deserialize handshake from bytes.
|
||||
#[allow(dead_code)]
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, String> {
|
||||
unsafe { rkyv::from_bytes_unchecked(bytes) }.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Handshake acknowledgement - router's response to handshake.
|
||||
///
|
||||
/// # Fields
|
||||
/// * `accepted` - Whether the handshake was accepted
|
||||
/// * `assigned_base_path` - Base path assigned by the server
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ush_treetest::protocol::HandshakeAck;
|
||||
///
|
||||
/// let ack = HandshakeAck {
|
||||
/// accepted: true,
|
||||
/// assigned_base_path: "/client".to_string(),
|
||||
/// };
|
||||
/// assert!(ack.accepted);
|
||||
/// ```
|
||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct HandshakeAck {
|
||||
/// Whether the handshake was accepted
|
||||
pub accepted: bool,
|
||||
/// Base path assigned by the server
|
||||
pub assigned_base_path: String,
|
||||
}
|
||||
|
||||
impl HandshakeAck {
|
||||
/// Serialize the acknowledgement to bytes.
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
rkyv::to_bytes::<HandshakeAck, BUFFER_SIZE>(self)
|
||||
.unwrap()
|
||||
.into_vec()
|
||||
}
|
||||
|
||||
/// Deserialize acknowledgement from bytes.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `bytes` - Serialized bytes
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, String> {
|
||||
unsafe { rkyv::from_bytes_unchecked(bytes) }.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
//! # Server Implementation
|
||||
//!
|
||||
//! This module provides the server functionality for handling incoming connections.
|
||||
|
||||
use crate::protocol::{
|
||||
FrameHeader, FrameType, Handshake, TreeRequest, TreeResponse, TcpTransport, Transport,
|
||||
make_response, make_handshake_ack,
|
||||
};
|
||||
use crate::tree::Tree;
|
||||
use crate::leaves::{ProxyEndpoint, RemoteShell, TTY};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
/// Global client counter for assigning unique base paths.
|
||||
static CLIENT_COUNT: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// Default listening address for the server.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let addr = ush_treetest::server::DEFAULT_ADDR;
|
||||
/// assert_eq!(addr, "0.0.0.0:8080");
|
||||
/// ```
|
||||
#[allow(dead_code)]
|
||||
pub const DEFAULT_ADDR: &str = "0.0.0.0:8080";
|
||||
|
||||
/// Run the server with the given address.
|
||||
///
|
||||
/// This function starts listening on the specified address and handles incoming
|
||||
/// connections in separate threads.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `addr` - The address to listen on (e.g., "0.0.0.0:8080")
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// run_server("0.0.0.0:8080");
|
||||
/// ```
|
||||
pub fn run_server(addr: &str) -> ! {
|
||||
log::info!("Starting server on {}", addr);
|
||||
|
||||
let tree = Arc::new(Mutex::new(Tree::new()));
|
||||
{
|
||||
let mut tree = tree.lock().unwrap();
|
||||
tree.add_endpoint("/", Box::new(ProxyEndpoint::new_empty("proxy")));
|
||||
tree.add_endpoint("/shell", Box::new(RemoteShell::new("shell")));
|
||||
tree.add_endpoint("/tty", Box::new(TTY::new("tty")));
|
||||
}
|
||||
|
||||
let listener = TcpTransport::listen(addr).expect("failed to bind");
|
||||
log::info!("Listening on {}", addr);
|
||||
|
||||
loop {
|
||||
match TcpTransport::accept(&listener) {
|
||||
Ok(transport) => {
|
||||
log::info!("New connection from {:?}", transport.peer_addr());
|
||||
let tree = Arc::clone(&tree);
|
||||
std::thread::spawn(move || {
|
||||
handle_connection(transport, tree);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("accept error: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a single connection.
|
||||
///
|
||||
/// This function handles the handshake and then processes frames in a loop until
|
||||
/// the connection is closed.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `transport` - The TCP transport for the connection
|
||||
/// * `tree` - Shared access to the tree
|
||||
pub fn handle_connection(mut transport: TcpTransport, tree: Arc<Mutex<Tree>>) {
|
||||
let (header, payload) = match transport.recv_frame() {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
log::error!("recv error: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if header.frame_type != FrameType::Handshake {
|
||||
log::error!("expected handshake");
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("Client connected");
|
||||
|
||||
let base_path = if payload.is_empty() {
|
||||
let client_num = CLIENT_COUNT.fetch_add(1, Ordering::SeqCst);
|
||||
format!("/client_{}", client_num)
|
||||
} else {
|
||||
let handshake = match Handshake::from_bytes(&payload) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
log::error!("handshake parse error: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let client_num = CLIENT_COUNT.fetch_add(1, Ordering::SeqCst);
|
||||
if handshake.registered_paths.is_empty() {
|
||||
format!("/client_{}", client_num)
|
||||
} else {
|
||||
handshake.registered_paths.first().cloned().unwrap_or_else(|| {
|
||||
format!("/client_{}", client_num)
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let (ack_header, ack_payload) = make_handshake_ack(true, &base_path);
|
||||
transport.send_frame(&ack_header, Some(&ack_payload)).expect("send failed");
|
||||
|
||||
loop {
|
||||
match transport.recv_frame() {
|
||||
Ok((header, payload)) => {
|
||||
let response = handle_frame(&header, &payload, &tree);
|
||||
|
||||
if let Some(response) = response {
|
||||
let (resp_header, resp_payload) = match response {
|
||||
Ok((h, p)) => (h, p),
|
||||
Err(e) => {
|
||||
log::error!("handle error: {:?}", e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
transport.send_frame(&resp_header, Some(&resp_payload)).expect("send failed");
|
||||
}
|
||||
|
||||
if header.frame_type == FrameType::StreamClose {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("recv error: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Connection closed");
|
||||
}
|
||||
|
||||
/// Handle a single frame and return an optional response.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `header` - The frame header
|
||||
/// * `payload` - The frame payload bytes
|
||||
/// * `tree` - Shared access to the tree
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Some(Ok((header, payload)))` for a response to send
|
||||
/// * `Some(Err(e))` for an error
|
||||
/// * `None` for no response (async handling)
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ush_treetest::protocol::{FrameType, FrameHeader, TcpTransport};
|
||||
///
|
||||
/// let header = FrameHeader {
|
||||
/// frame_type: FrameType::Request,
|
||||
/// dst_path: Some("/shell".to_string()),
|
||||
/// src_path: "/client".to_string(),
|
||||
/// request_id: Some(1),
|
||||
/// stream_id: None,
|
||||
/// };
|
||||
/// let payload = vec![];
|
||||
///
|
||||
/// if let Some(result) = handle_frame(&header, &payload, &tree) {
|
||||
/// // Handle response
|
||||
/// }
|
||||
/// ```
|
||||
pub fn handle_frame(
|
||||
header: &FrameHeader,
|
||||
payload: &[u8],
|
||||
tree: &Arc<Mutex<Tree>>,
|
||||
) -> Option<Result<(FrameHeader, Vec<u8>), String>> {
|
||||
match header.frame_type {
|
||||
FrameType::Request => {
|
||||
let request: TreeRequest = match TreeRequest::from_bytes(payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Some(Err(e.to_string())),
|
||||
};
|
||||
|
||||
let dst_path = header.dst_path.as_deref().unwrap_or("/");
|
||||
|
||||
let mut tree = match tree.lock() {
|
||||
Ok(t) => t,
|
||||
Err(e) => return Some(Err(format!("lock error: {}", e))),
|
||||
};
|
||||
|
||||
let response = match request {
|
||||
TreeRequest::ListNodes {} => {
|
||||
let names = tree.list_nodes_at(dst_path);
|
||||
TreeResponse::NodeList { names }
|
||||
}
|
||||
TreeRequest::ListEndpoints {} => {
|
||||
let endpoints = tree.list_endpoints_at(dst_path);
|
||||
TreeResponse::EndpointList { endpoints }
|
||||
}
|
||||
TreeRequest::ListLeaves {} => {
|
||||
let leaves = tree.list_leaves();
|
||||
TreeResponse::LeafList { leaves }
|
||||
}
|
||||
TreeRequest::GetInfo { path } => {
|
||||
match tree.get_info(&path) {
|
||||
Ok(info) => TreeResponse::NodeInfo { info },
|
||||
Err(e) => return Some(Err(e)),
|
||||
}
|
||||
}
|
||||
TreeRequest::Exec { ref cmd } => {
|
||||
let (handler, matched_path) = match tree.find_handler(dst_path) {
|
||||
Some(h) => h,
|
||||
None => return Some(Err(format!("path not found: {}", dst_path))),
|
||||
};
|
||||
let result = {
|
||||
let mut handler = match handler.lock() {
|
||||
Ok(h) => h,
|
||||
Err(e) => return Some(Err(format!("lock error: {}", e))),
|
||||
};
|
||||
handler.handle_request(&TreeRequest::Exec { cmd: cmd.clone() }, matched_path)
|
||||
};
|
||||
match result {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => return Some(Err(e)),
|
||||
}
|
||||
}
|
||||
TreeRequest::StreamOpen { path } => {
|
||||
match tree.open_stream(&path, &header.src_path) {
|
||||
Ok(stream_id) => TreeResponse::StreamOpened { stream_id },
|
||||
Err(e) => return Some(Err(e)),
|
||||
}
|
||||
}
|
||||
TreeRequest::Resize { .. } => {
|
||||
return Some(Err("unsupported request: Resize".to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
Some(Ok(make_response(
|
||||
&header.src_path,
|
||||
header.request_id.unwrap_or(0),
|
||||
&response,
|
||||
)))
|
||||
}
|
||||
|
||||
FrameType::StreamOpen => {
|
||||
let dst_path = header.dst_path.as_deref().unwrap_or("/");
|
||||
let mut tree = match tree.lock() {
|
||||
Ok(t) => t,
|
||||
Err(e) => return Some(Err(format!("lock error: {}", e))),
|
||||
};
|
||||
match tree.open_stream(dst_path, &header.src_path) {
|
||||
Ok(stream_id) => {
|
||||
let response = TreeResponse::StreamOpened { stream_id };
|
||||
Some(Ok(make_response(
|
||||
&header.src_path,
|
||||
header.request_id.unwrap_or(0),
|
||||
&response,
|
||||
)))
|
||||
}
|
||||
Err(e) => Some(Err(e)),
|
||||
}
|
||||
}
|
||||
|
||||
FrameType::StreamData => {
|
||||
let mut tree = match tree.lock() {
|
||||
Ok(t) => t,
|
||||
Err(e) => return Some(Err(format!("lock error: {}", e))),
|
||||
};
|
||||
tree.route_stream_data(header, payload).ok();
|
||||
None
|
||||
}
|
||||
|
||||
FrameType::StreamClose => {
|
||||
let mut tree = match tree.lock() {
|
||||
Ok(t) => t,
|
||||
Err(e) => return Some(Err(format!("lock error: {}", e))),
|
||||
};
|
||||
if let Some(stream_id) = header.stream_id {
|
||||
tree.close_stream(stream_id).ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
_ => Some(Err("unsupported frame type".to_string())),
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//! # Tree Endpoint
|
||||
//!
|
||||
//! This module defines the Endpoint trait that all tree leaves must implement.
|
||||
//! Endpoints handle requests and stream data for specific paths in the tree.
|
||||
|
||||
use crate::protocol::{TreeRequest, TreeResponse, EndpointType};
|
||||
use std::string::String;
|
||||
use std::fmt;
|
||||
|
||||
/// Endpoint trait - implemented by all leaf handlers in the tree
|
||||
///
|
||||
/// This trait is object-safe and must be Send + Sync to allow sharing across threads.
|
||||
pub trait Endpoint: Send + Sync + fmt::Debug {
|
||||
/// Handle a request and return a response
|
||||
fn handle_request(&mut self, request: &TreeRequest, src_path: &str) -> Result<TreeResponse, String>;
|
||||
|
||||
/// Called when a stream is opened to this endpoint
|
||||
///
|
||||
/// Returns the stream ID if successful, None if rejected
|
||||
fn on_stream_open(&mut self, stream_id: u16, src_path: &str) -> Option<u16>;
|
||||
|
||||
/// Called when data is received on a stream
|
||||
///
|
||||
/// Returns true if data was handled successfully
|
||||
fn on_stream_data(&mut self, stream_id: u16, data: &[u8]) -> bool;
|
||||
|
||||
/// Called when a stream is closed
|
||||
fn on_stream_close(&mut self, stream_id: u16);
|
||||
|
||||
/// Get the type of this endpoint
|
||||
fn endpoint_type(&self) -> EndpointType;
|
||||
|
||||
/// Get the name of this endpoint
|
||||
fn name(&self) -> &str;
|
||||
}
|
||||
|
||||
/// Stream - represents an active stream between endpoints
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct Stream {
|
||||
/// Unique identifier for this stream
|
||||
pub stream_id: u16,
|
||||
/// Destination path for the stream
|
||||
pub dst_path: String,
|
||||
/// Source path for the stream
|
||||
pub src_path: String,
|
||||
}
|
||||
@@ -1,511 +0,0 @@
|
||||
//! # Tree Module
|
||||
//!
|
||||
//! This module implements the tree-based routing for the unshell protocol.
|
||||
//! The tree structure maintains endpoints at paths and handles routing of
|
||||
//! requests and streams to appropriate handlers.
|
||||
|
||||
pub mod endpoint;
|
||||
pub use endpoint::{Endpoint, Stream};
|
||||
|
||||
use crate::protocol::{EndpointInfo, FrameHeader, NodeInfo};
|
||||
use std::collections::BTreeMap;
|
||||
use std::string::String;
|
||||
use std::vec::Vec;
|
||||
use std::boxed::Box;
|
||||
use std::result::Result;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::fmt;
|
||||
|
||||
/// A shared, thread-safe endpoint handler.
|
||||
pub type SharedEndpoint = Arc<Mutex<Box<dyn Endpoint>>>;
|
||||
|
||||
/// A node in the tree - contains an optional endpoint and child nodes.
|
||||
pub struct Node {
|
||||
endpoint: Option<SharedEndpoint>,
|
||||
children: BTreeMap<String, Node>,
|
||||
streams: BTreeMap<u16, Stream>,
|
||||
next_stream_id: u16,
|
||||
path: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for Node {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Node")
|
||||
.field("path", &self.path)
|
||||
.field("children", &self.children.keys().cloned().collect::<Vec<_>>())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Create a new node with the given path
|
||||
pub fn new(path: &str) -> Self {
|
||||
Self {
|
||||
endpoint: None,
|
||||
children: BTreeMap::new(),
|
||||
streams: BTreeMap::new(),
|
||||
next_stream_id: 1,
|
||||
path: path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the endpoint for this node
|
||||
///
|
||||
/// Wraps the endpoint in Arc<Mutex<>> for thread-safe sharing
|
||||
pub fn set_endpoint(&mut self, endpoint: Box<dyn Endpoint>) {
|
||||
self.endpoint = Some(Arc::new(Mutex::new(endpoint)));
|
||||
}
|
||||
|
||||
/// Add a child node with the given name
|
||||
pub fn add_child(&mut self, name: &str, node: Node) {
|
||||
self.children.insert(name.to_string(), node);
|
||||
}
|
||||
|
||||
/// Get names of all child nodes
|
||||
pub fn child_names(&self) -> Vec<String> {
|
||||
self.children.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// List child nodes at a given path - traverses directly without find_handler
|
||||
///
|
||||
/// Works even without endpoint at the path (like Linux directories).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - The path to list children at (e.g., "/" or "/shell")
|
||||
///
|
||||
/// # Returns
|
||||
/// List of child node names, or empty list if path not found
|
||||
pub fn list_nodes_at(&self, path: &str) -> Vec<String> {
|
||||
let segments: Vec<String> = path
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let mut current = self;
|
||||
for seg in &segments {
|
||||
if let Some(child) = current.children.get(seg) {
|
||||
current = child;
|
||||
} else {
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
current.child_names()
|
||||
}
|
||||
|
||||
/// List endpoints at a given path - traverses directly without find_handler
|
||||
///
|
||||
/// Works even without endpoint at the path (like Linux directories).
|
||||
pub fn list_endpoints_at(&self, path: &str) -> Vec<EndpointInfo> {
|
||||
let segments: Vec<String> = path
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let mut current = self;
|
||||
for seg in &segments {
|
||||
if let Some(child) = current.children.get(seg) {
|
||||
current = child;
|
||||
} else {
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
current.endpoint_names()
|
||||
}
|
||||
|
||||
/// Get all endpoints at this node and in children
|
||||
pub fn endpoint_names(&self) -> Vec<EndpointInfo> {
|
||||
let mut endpoints = Vec::new();
|
||||
|
||||
if let Some(ref e) = self.endpoint {
|
||||
if let Ok(ep) = e.lock() {
|
||||
endpoints.push(EndpointInfo {
|
||||
name: ep.name().to_string(),
|
||||
path: self.path.clone(),
|
||||
endpoint_type: ep.endpoint_type(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (name, child) in &self.children {
|
||||
let mut child_endpoints = child.endpoint_names();
|
||||
for ep in &mut child_endpoints {
|
||||
ep.path = format!("{}/{}", self.path, name);
|
||||
endpoints.push(ep.clone());
|
||||
}
|
||||
}
|
||||
|
||||
endpoints
|
||||
}
|
||||
|
||||
/// Get all leaf paths (nodes with endpoint but no children)
|
||||
pub fn leaf_paths(&self) -> Vec<String> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
if self.endpoint.is_some() && self.children.is_empty() {
|
||||
paths.push(self.path.clone());
|
||||
}
|
||||
|
||||
for (name, child) in &self.children {
|
||||
let mut child_leaves = child.leaf_paths();
|
||||
for path in &mut child_leaves {
|
||||
*path = if self.path == "/" {
|
||||
format!("/{}", name)
|
||||
} else {
|
||||
format!("{}/{}", self.path, name)
|
||||
};
|
||||
paths.push(path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
/// Get info about this node
|
||||
pub fn node_info(&self) -> NodeInfo {
|
||||
NodeInfo {
|
||||
path: self.path.clone(),
|
||||
is_leaf: self.endpoint.is_some() && self.children.is_empty(),
|
||||
has_children: !self.children.is_empty(),
|
||||
endpoints: self.endpoint_names().iter().map(|e| e.name.clone()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tree structure for routing - contains the root node.
|
||||
#[allow(dead_code)]
|
||||
pub struct Tree {
|
||||
pub root: Node,
|
||||
}
|
||||
|
||||
impl fmt::Debug for Tree {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Tree")
|
||||
.field("root", &self.root.path)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Tree {
|
||||
/// Create a new empty tree
|
||||
pub fn new() -> Self {
|
||||
Self { root: Node::new("/") }
|
||||
}
|
||||
|
||||
/// Add an endpoint at the given path
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - The path where to register the endpoint (e.g., "/shell", "/tty")
|
||||
/// * `endpoint` - The endpoint to register
|
||||
pub fn add_endpoint(&mut self, path: &str, endpoint: Box<dyn Endpoint>) {
|
||||
let segments = path_segments(path);
|
||||
|
||||
if segments.is_empty() {
|
||||
self.root.set_endpoint(endpoint);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut current = &mut self.root;
|
||||
let mut endpoint_opt: Option<Box<dyn Endpoint>> = Some(endpoint);
|
||||
|
||||
for (i, segment) in segments.iter().enumerate() {
|
||||
let is_last = i == segments.len() - 1;
|
||||
|
||||
if !current.children.contains_key(segment) {
|
||||
let parent_path = if i == 0 {
|
||||
String::from("/")
|
||||
} else {
|
||||
segments[..i].join("/")
|
||||
};
|
||||
let new_path = if parent_path == "/" {
|
||||
format!("/{}", segment)
|
||||
} else {
|
||||
format!("{}/{}", parent_path, segment)
|
||||
};
|
||||
current.add_child(segment, Node::new(&new_path));
|
||||
}
|
||||
|
||||
current = current.children.get_mut(segment).unwrap();
|
||||
|
||||
if is_last {
|
||||
if let Some(ep) = endpoint_opt.take() {
|
||||
current.set_endpoint(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the handler for a given path using longest-prefix matching
|
||||
///
|
||||
/// Returns the endpoint and the matched path
|
||||
pub fn find_handler(&self, path: &str) -> Option<(SharedEndpoint, &str)> {
|
||||
if path == "/" {
|
||||
return self.root.endpoint.as_ref().map(|e| (e.clone(), "/"));
|
||||
}
|
||||
|
||||
let segments = path_segments(path);
|
||||
let mut current = &self.root;
|
||||
let mut remaining = segments.as_slice();
|
||||
let mut handler_path = "";
|
||||
|
||||
while !remaining.is_empty() {
|
||||
if let Some(child) = current.children.get(&remaining[0].to_string()) {
|
||||
current = child;
|
||||
remaining = &remaining[1..];
|
||||
handler_path = ¤t.path;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
current.endpoint.as_ref().map(|e| (e.clone(), handler_path))
|
||||
}
|
||||
|
||||
/// List child nodes at a given path using direct tree traversal.
|
||||
///
|
||||
/// Unlike `find_handler()`, this works even without an endpoint at the path,
|
||||
/// making "/" and other directories traversable like Linux directories.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let tree = Tree::new();
|
||||
/// tree.add_endpoint("/shell", Box::new(RemoteShell::new("shell")));
|
||||
/// let names = tree.list_nodes("/").unwrap(); // ["shell"]
|
||||
/// ```
|
||||
pub fn list_nodes(&self, path: &str) -> Result<Vec<String>, String> {
|
||||
// Use direct traversal - works without endpoint
|
||||
let names = self.root.list_nodes_at(path);
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// List all endpoints at a given path.
|
||||
///
|
||||
/// Works even without endpoint at the path.
|
||||
pub fn list_endpoints(&self, path: &str) -> Result<Vec<EndpointInfo>, String> {
|
||||
let endpoints = self.root.list_endpoints_at(path);
|
||||
Ok(endpoints)
|
||||
}
|
||||
|
||||
/// List all leaf paths in the tree.
|
||||
pub fn list_leaves(&self) -> Vec<String> {
|
||||
self.root.leaf_paths()
|
||||
}
|
||||
|
||||
/// List child nodes at a given path - traverses directly without find_handler
|
||||
pub fn list_nodes_at(&self, path: &str) -> Vec<String> {
|
||||
let segments: Vec<String> = path
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let mut current = &self.root;
|
||||
for seg in &segments {
|
||||
if let Some(child) = current.children.get(seg) {
|
||||
current = child;
|
||||
} else {
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
current.child_names()
|
||||
}
|
||||
|
||||
/// List endpoints at a given path - traverses directly without find_handler
|
||||
pub fn list_endpoints_at(&self, path: &str) -> Vec<EndpointInfo> {
|
||||
let segments: Vec<String> = path
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let mut current = &self.root;
|
||||
for seg in &segments {
|
||||
if let Some(child) = current.children.get(seg) {
|
||||
current = child;
|
||||
} else {
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
current.endpoint_names()
|
||||
}
|
||||
|
||||
/// Get information about a node at the given path
|
||||
pub fn get_info(&self, path: &str) -> Result<NodeInfo, String> {
|
||||
let segments = path_segments(path);
|
||||
let mut current = &self.root;
|
||||
|
||||
for segment in &segments {
|
||||
if let Some(child) = current.children.get(segment) {
|
||||
current = child;
|
||||
} else {
|
||||
return Err(format!("path not found: {}", path));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(current.node_info())
|
||||
}
|
||||
|
||||
/// Open a stream to an endpoint at the given path
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - The path to open stream to
|
||||
/// * `src_path` - The source path for the stream
|
||||
///
|
||||
/// # Returns
|
||||
/// The stream ID on success
|
||||
pub fn open_stream(&mut self, path: &str, src_path: &str) -> Result<u16, String> {
|
||||
// First find the handler and matched path
|
||||
let (handler, matched_path) = self.find_handler(path)
|
||||
.ok_or_else(|| format!("path not found: {}", path))?;
|
||||
|
||||
let segments = path_segments(matched_path);
|
||||
|
||||
// Collect segment names first, then use indices to navigate
|
||||
// This avoids borrow issues by not holding references across operations
|
||||
let mut path_indices: Vec<String> = Vec::new();
|
||||
|
||||
{
|
||||
let mut current = &self.root;
|
||||
for segment in &segments {
|
||||
if let Some(child) = current.children.get(segment) {
|
||||
path_indices.push(segment.clone());
|
||||
current = child;
|
||||
} else {
|
||||
return Err(format!("node not found: {}", segment));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now navigate again with indices and get next_stream_id
|
||||
let stream_id = {
|
||||
let mut current = &mut self.root;
|
||||
for segment in &path_indices {
|
||||
current = current.children.get_mut(segment).unwrap();
|
||||
}
|
||||
let sid = current.next_stream_id;
|
||||
current.next_stream_id = current.next_stream_id.wrapping_add(1);
|
||||
sid
|
||||
};
|
||||
|
||||
// Call handler's on_stream_open with locked mutex
|
||||
let stream_id = {
|
||||
let mut handler = handler.lock().map_err(|e| e.to_string())?;
|
||||
handler.on_stream_open(stream_id, src_path)
|
||||
.ok_or_else(|| "endpoint rejected stream".to_string())?
|
||||
};
|
||||
|
||||
// Store stream info in the node
|
||||
{
|
||||
let mut current = &mut self.root;
|
||||
for segment in &path_indices {
|
||||
current = current.children.get_mut(segment).unwrap();
|
||||
}
|
||||
current.streams.insert(stream_id, Stream {
|
||||
stream_id,
|
||||
dst_path: path.to_string(),
|
||||
src_path: src_path.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(stream_id)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
/// Find the index path to a node given segment names
|
||||
fn find_node_index(&self, segments: &[String]) -> Result<Vec<String>, String> {
|
||||
let mut current = &self.root;
|
||||
let mut path = Vec::new();
|
||||
|
||||
for segment in segments {
|
||||
if let Some(child) = current.children.get(segment) {
|
||||
path.push(segment.clone());
|
||||
current = child;
|
||||
} else {
|
||||
return Err(format!("segment not found: {}", segment));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Get a mutable reference to a node at the given path
|
||||
#[allow(dead_code)]
|
||||
fn get_node_mut(&mut self, path: &[String]) -> Result<&mut Node, String> {
|
||||
let mut current = &mut self.root;
|
||||
|
||||
for segment in path {
|
||||
if let Some(child) = current.children.get_mut(segment) {
|
||||
current = child;
|
||||
} else {
|
||||
return Err(format!("node not found: {}", segment));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
/// Route stream data to the appropriate handler
|
||||
pub fn route_stream_data(&mut self, header: &FrameHeader, data: &[u8]) -> Result<(), String> {
|
||||
let stream_id = header.stream_id.ok_or("no stream_id")?;
|
||||
|
||||
// Find the node containing this stream
|
||||
fn find_stream_handler(node: &mut Node, sid: u16) -> Option<SharedEndpoint> {
|
||||
if node.streams.contains_key(&sid) {
|
||||
return node.endpoint.clone();
|
||||
}
|
||||
|
||||
for child in node.children.values_mut() {
|
||||
if let Some(h) = find_stream_handler(child, sid) {
|
||||
return Some(h);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
if let Some(handler) = find_stream_handler(&mut self.root, stream_id) {
|
||||
if let Ok(mut h) = handler.lock() {
|
||||
h.on_stream_data(stream_id, data);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close a stream
|
||||
pub fn close_stream(&mut self, stream_id: u16) -> Result<(), String> {
|
||||
fn find_and_close(node: &mut Node, sid: u16) -> bool {
|
||||
if node.streams.remove(&sid).is_some() {
|
||||
if let Some(ref ep) = node.endpoint {
|
||||
if let Ok(mut h) = ep.lock() {
|
||||
h.on_stream_close(sid);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for child in node.children.values_mut() {
|
||||
if find_and_close(child, sid) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
find_and_close(&mut self.root, stream_id)
|
||||
.then_some(())
|
||||
.ok_or_else(|| format!("stream not found: {}", stream_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a path into segments
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// assert_eq!(path_segments("/foo/bar"), vec!["foo", "bar"]);
|
||||
/// assert_eq!(path_segments("/"), vec![]);
|
||||
/// ```
|
||||
fn path_segments(path: &str) -> Vec<String> {
|
||||
path.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect()
|
||||
}
|
||||
Reference in New Issue
Block a user