From 2b633ce01972d7a1c70f46d9687bca4ce92225f1 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:19:42 -0600 Subject: [PATCH] add treetest protocol simulator and ui --- Cargo.lock | 525 ++++++++++++++- Cargo.toml | 1 + src/protocol/tree/endpoint/hooks.rs | 11 +- src/protocol/tree/hook.rs | 19 + treetest/Cargo.toml | 26 + treetest/src/app.rs | 603 ++++++++++++++++++ treetest/src/lib.rs | 12 + treetest/src/main.rs | 5 + treetest/src/model.rs | 184 ++++++ treetest/src/scenarios.rs | 310 +++++++++ treetest/src/sim.rs | 842 +++++++++++++++++++++++++ treetest/tests/faults.rs | 80 +++ treetest/tests/hooks.rs | 50 ++ treetest/tests/protocol_basics.rs | 43 ++ treetest/tests/routing.rs | 51 ++ ush-treetest/Cargo.lock | 779 ----------------------- ush-treetest/Cargo.toml | 28 - ush-treetest/PROTOCOL.md | 79 --- ush-treetest/src/cli/mod.rs | 529 ---------------- ush-treetest/src/client.rs | 434 ------------- ush-treetest/src/leaves/mod.rs | 9 - ush-treetest/src/leaves/proxy.rs | 106 ---- ush-treetest/src/leaves/shell.rs | 101 --- ush-treetest/src/leaves/tty.rs | 231 ------- ush-treetest/src/main.rs | 207 ------ ush-treetest/src/protocol/mod.rs | 45 -- ush-treetest/src/protocol/transport.rs | 436 ------------- ush-treetest/src/protocol/types.rs | 420 ------------ ush-treetest/src/server.rs | 290 --------- ush-treetest/src/tree/endpoint.rs | 47 -- ush-treetest/src/tree/mod.rs | 511 --------------- 31 files changed, 2760 insertions(+), 4254 deletions(-) create mode 100644 treetest/Cargo.toml create mode 100644 treetest/src/app.rs create mode 100644 treetest/src/lib.rs create mode 100644 treetest/src/main.rs create mode 100644 treetest/src/model.rs create mode 100644 treetest/src/scenarios.rs create mode 100644 treetest/src/sim.rs create mode 100644 treetest/tests/faults.rs create mode 100644 treetest/tests/hooks.rs create mode 100644 treetest/tests/protocol_basics.rs create mode 100644 treetest/tests/routing.rs delete mode 100644 ush-treetest/Cargo.lock delete mode 100644 ush-treetest/Cargo.toml delete mode 100644 ush-treetest/PROTOCOL.md delete mode 100644 ush-treetest/src/cli/mod.rs delete mode 100644 ush-treetest/src/client.rs delete mode 100644 ush-treetest/src/leaves/mod.rs delete mode 100644 ush-treetest/src/leaves/proxy.rs delete mode 100644 ush-treetest/src/leaves/shell.rs delete mode 100644 ush-treetest/src/leaves/tty.rs delete mode 100644 ush-treetest/src/main.rs delete mode 100644 ush-treetest/src/protocol/mod.rs delete mode 100644 ush-treetest/src/protocol/transport.rs delete mode 100644 ush-treetest/src/protocol/types.rs delete mode 100644 ush-treetest/src/server.rs delete mode 100644 ush-treetest/src/tree/endpoint.rs delete mode 100644 ush-treetest/src/tree/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 9f0cf0a..ec532b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 156d994..63cccc6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "ush-obfuscate", "base62", + "treetest", ] resolver = "2" diff --git a/src/protocol/tree/endpoint/hooks.rs b/src/protocol/tree/endpoint/hooks.rs index ce683d5..23e3adc 100644 --- a/src/protocol/tree/endpoint/hooks.rs +++ b/src/protocol/tree/endpoint/hooks.rs @@ -60,7 +60,16 @@ impl ProtocolEndpoint { header: PacketHeader, message: DataMessage, ) -> Result { - 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| { diff --git a/src/protocol/tree/hook.rs b/src/protocol/tree/hook.rs index aa88e50..aa469fc 100644 --- a/src/protocol/tree/hook.rs +++ b/src/protocol/tree/hook.rs @@ -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 { + 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() } diff --git a/treetest/Cargo.toml b/treetest/Cargo.toml new file mode 100644 index 0000000..3338a9d --- /dev/null +++ b/treetest/Cargo.toml @@ -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" diff --git a/treetest/src/app.rs b/treetest/src/app.rs new file mode 100644 index 0000000..93b6e0b --- /dev/null +++ b/treetest/src/app.rs @@ -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, + scenario_index: usize, + simulation: Simulation, + selection_index: usize, + selections: Vec, + status: String, +} + +impl App { + fn new() -> Result { + 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 { + 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::>(); + 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::>(); + 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::>(); + 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::>(); + 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 { + 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 +} diff --git a/treetest/src/lib.rs b/treetest/src/lib.rs new file mode 100644 index 0000000..fee4fa5 --- /dev/null +++ b/treetest/src/lib.rs @@ -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; diff --git a/treetest/src/main.rs b/treetest/src/main.rs new file mode 100644 index 0000000..dc88b99 --- /dev/null +++ b/treetest/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for the protocol demo. + +fn main() -> Result<(), treetest::app::AppError> { + treetest::run() +} diff --git a/treetest/src/model.rs b/treetest/src/model.rs new file mode 100644 index 0000000..c53948c --- /dev/null +++ b/treetest/src/model.rs @@ -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, +} + +/// 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, + pub endpoint_procedures: Vec, + pub children: Vec, +} + +/// Concrete node metadata used after scenario construction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DemoNode { + pub id: NodeId, + pub parent: Option, + pub children: Vec, + pub path: Vec, + pub title: String, + pub description: String, + pub leaves: Vec, + pub endpoint_procedures: Vec, +} + +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, + path_index: BTreeMap, 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, + base_path: &[String], + nodes: &mut Vec, + path_index: &mut BTreeMap, 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::>(); + 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 { + 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, + 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("/")) + } +} diff --git a/treetest/src/scenarios.rs b/treetest/src/scenarios.rs new file mode 100644 index 0000000..bbfdd9d --- /dev/null +++ b/treetest/src/scenarios.rs @@ -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 { + 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)), + } +} diff --git a/treetest/src/sim.rs b/treetest/src/sim.rs new file mode 100644 index 0000000..27bed7a --- /dev/null +++ b/treetest/src/sim.rs @@ -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, +} + +/// 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, + pub peer_path: Vec, + 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, + children: Vec, + endpoint: ProtocolEndpoint, + tx: Sender, + rx: Receiver, +} + +#[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, + 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, + root_id: NodeId, + next_tick: u64, + pub trace: VecDeque, + pub recorded_events: Vec, + pub hooks: BTreeMap, + chat_sessions: BTreeMap, +} + +impl Simulation { + /// Creates a fresh simulation from a scenario definition. + pub fn new(scenario: ScenarioDefinition) -> Result { + 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::>(); + 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::>(); + 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 { + 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 { + 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 { + 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: "".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, + ) -> Result { + 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, + ) -> Result { + 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() { + "" + } 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 { + 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 { + 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 { + 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 { + let mut steps = 0; + while self.step()? { + steps += 1; + } + Ok(steps) + } + + fn dispatch_root_call( + &mut self, + dst_path: Vec, + dst_leaf: Option, + procedure_id: &str, + data: Vec, + ) -> 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() { + "" + } 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!(""), + } + } + + /// 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 { + 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 { + 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) + } + } + } +} diff --git a/treetest/tests/faults.rs b/treetest/tests/faults.rs new file mode 100644 index 0000000..70f9165 --- /dev/null +++ b/treetest/tests/faults.rs @@ -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 + ); +} diff --git a/treetest/tests/hooks.rs b/treetest/tests/hooks.rs new file mode 100644 index 0000000..59ce95b --- /dev/null +++ b/treetest/tests/hooks.rs @@ -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") + ); +} diff --git a/treetest/tests/protocol_basics.rs b/treetest/tests/protocol_basics.rs new file mode 100644 index 0000000..e2124c2 --- /dev/null +++ b/treetest/tests/protocol_basics.rs @@ -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 + ); +} diff --git a/treetest/tests/routing.rs b/treetest/tests/routing.rs new file mode 100644 index 0000000..9baf90c --- /dev/null +++ b/treetest/tests/routing.rs @@ -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::>() + .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") + ); +} diff --git a/ush-treetest/Cargo.lock b/ush-treetest/Cargo.lock deleted file mode 100644 index 4d1e49a..0000000 --- a/ush-treetest/Cargo.lock +++ /dev/null @@ -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" diff --git a/ush-treetest/Cargo.toml b/ush-treetest/Cargo.toml deleted file mode 100644 index aab9dc6..0000000 --- a/ush-treetest/Cargo.toml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/ush-treetest/PROTOCOL.md b/ush-treetest/PROTOCOL.md deleted file mode 100644 index 2be94f9..0000000 --- a/ush-treetest/PROTOCOL.md +++ /dev/null @@ -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>` 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 diff --git a/ush-treetest/src/cli/mod.rs b/ush-treetest/src/cli/mod.rs deleted file mode 100644 index e284248..0000000 --- a/ush-treetest/src/cli/mod.rs +++ /dev/null @@ -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, - tree: Tree, - current_path: String, - request_id: u64, - #[allow(dead_code)] - stream_id: u16, - streams: Vec, - 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, 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, 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 ".to_string()); - } - let info = cli.get_info(parts[1])?; - Ok(format!("{:?}", info)) - } - "exec" => { - if parts.len() < 3 { - return Err("usage: exec ".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 ".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 ".to_string()); - } - cli.connect(parts[1])?; - Ok(format!("connected to {}", parts[1])) - } - "stream" => { - if parts.len() < 2 { - return Err("usage: stream ".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 ".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 ".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 { - 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 Get node info - exec Execute command at path - cd Change current path - pwd Print working path - connect Connect to remote server - stream Open stream to path - send Send data on stream - close Close stream - help Show this help -"#; diff --git a/ush-treetest/src/client.rs b/ush-treetest/src/client.rs deleted file mode 100644 index b425292..0000000 --- a/ush-treetest/src/client.rs +++ /dev/null @@ -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, - #[allow(dead_code)] - tree: Tree, - current_path: String, - request_id: u64, - #[allow(dead_code)] - stream_id: u16, - streams: Vec, - 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, 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, 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 { - 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 { - 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 { - 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 { - 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 { - 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(); - } -} \ No newline at end of file diff --git a/ush-treetest/src/leaves/mod.rs b/ush-treetest/src/leaves/mod.rs deleted file mode 100644 index c3fd49b..0000000 --- a/ush-treetest/src/leaves/mod.rs +++ /dev/null @@ -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; \ No newline at end of file diff --git a/ush-treetest/src/leaves/proxy.rs b/ush-treetest/src/leaves/proxy.rs deleted file mode 100644 index 77b6c3d..0000000 --- a/ush-treetest/src/leaves/proxy.rs +++ /dev/null @@ -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>, -} - -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>) -> 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 { - 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 { - 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 - } -} \ No newline at end of file diff --git a/ush-treetest/src/leaves/shell.rs b/ush-treetest/src/leaves/shell.rs deleted file mode 100644 index f307770..0000000 --- a/ush-treetest/src/leaves/shell.rs +++ /dev/null @@ -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, Vec) { - 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 { - 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 { - 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 - } -} \ No newline at end of file diff --git a/ush-treetest/src/leaves/tty.rs b/ush-treetest/src/leaves/tty.rs deleted file mode 100644 index 01bb66d..0000000 --- a/ush-treetest/src/leaves/tty.rs +++ /dev/null @@ -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>, - #[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::(), -); - } - - 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 { - 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 { - 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 - } -} \ No newline at end of file diff --git a/ush-treetest/src/main.rs b/ush-treetest/src/main.rs deleted file mode 100644 index 74ded1b..0000000 --- a/ush-treetest/src/main.rs +++ /dev/null @@ -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 ` - 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, - - #[arg(short, long)] - addr: Option, -} - -/// 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); - } - } -} \ No newline at end of file diff --git a/ush-treetest/src/protocol/mod.rs b/ush-treetest/src/protocol/mod.rs deleted file mode 100644 index aecc19d..0000000 --- a/ush-treetest/src/protocol/mod.rs +++ /dev/null @@ -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::*; \ No newline at end of file diff --git a/ush-treetest/src/protocol/transport.rs b/ush-treetest/src/protocol/transport.rs deleted file mode 100644 index 2f61442..0000000 --- a/ush-treetest/src/protocol/transport.rs +++ /dev/null @@ -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), 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 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 { - 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 { - 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 { - let stream = listener.accept()?.0; - Ok(Self::new(stream)) - } - - /// Get peer address. - /// - /// # Returns - /// The peer's socket address - pub fn peer_addr(&self) -> Result { - 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, 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), 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) { - 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) { - 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) { - 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) -> (FrameHeader, Vec) { - 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) { - 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) -} \ No newline at end of file diff --git a/ush-treetest/src/protocol/types.rs b/ush-treetest/src/protocol/types.rs deleted file mode 100644 index cab676e..0000000 --- a/ush-treetest/src/protocol/types.rs +++ /dev/null @@ -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 { - 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, - /// Source path of the sender - pub src_path: String, - /// Request ID for correlation (for request/response) - pub request_id: Option, - /// Stream ID (for stream operations) - pub stream_id: Option, -} - -impl FrameHeader { - /// Serialize the header to bytes. - /// - /// # Returns - /// Serialized bytes - pub fn to_bytes(&self) -> Vec { - rkyv::to_bytes::(self) - .unwrap() - .into_vec() - } - - /// Deserialize header from bytes. - /// - /// # Arguments - /// * `bytes` - Serialized bytes - /// - /// # Returns - /// Deserialized header - pub fn from_bytes(bytes: &[u8]) -> Result { - 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 { - rkyv::to_bytes::(self) - .unwrap() - .into_vec() - } - - /// Deserialize request from bytes. - /// - /// # Arguments - /// * `bytes` - Serialized bytes - pub fn from_bytes(bytes: &[u8]) -> Result { - 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 }, - /// List of endpoints - EndpointList { endpoints: Vec }, - /// List of leaf paths - LeafList { leaves: Vec }, - /// Node information - NodeInfo { info: NodeInfo }, - /// Command execution output - ExecOutput { - exit_code: i32, - stdout: Vec, - stderr: Vec, - }, - /// Stream opened confirmation - StreamOpened { stream_id: u16 }, -} - -impl TreeResponse { - /// Serialize the response to bytes. - pub fn to_bytes(&self) -> Vec { - rkyv::to_bytes::(self) - .unwrap() - .into_vec() - } - - /// Deserialize response from bytes. - /// - /// # Arguments - /// * `bytes` - Serialized bytes - pub fn from_bytes(bytes: &[u8]) -> Result { - 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, -} - -/// 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, -} - -impl Handshake { - /// Serialize the handshake to bytes. - pub fn to_bytes(&self) -> Vec { - rkyv::to_bytes::(self) - .unwrap() - .into_vec() - } - - /// Deserialize handshake from bytes. - #[allow(dead_code)] - pub fn from_bytes(bytes: &[u8]) -> Result { - 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 { - rkyv::to_bytes::(self) - .unwrap() - .into_vec() - } - - /// Deserialize acknowledgement from bytes. - /// - /// # Arguments - /// * `bytes` - Serialized bytes - pub fn from_bytes(bytes: &[u8]) -> Result { - unsafe { rkyv::from_bytes_unchecked(bytes) }.map_err(|e| e.to_string()) - } -} \ No newline at end of file diff --git a/ush-treetest/src/server.rs b/ush-treetest/src/server.rs deleted file mode 100644 index bb000b1..0000000 --- a/ush-treetest/src/server.rs +++ /dev/null @@ -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>) { - 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>, -) -> Option), 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())), - } -} \ No newline at end of file diff --git a/ush-treetest/src/tree/endpoint.rs b/ush-treetest/src/tree/endpoint.rs deleted file mode 100644 index 29bcc1b..0000000 --- a/ush-treetest/src/tree/endpoint.rs +++ /dev/null @@ -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; - - /// 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; - - /// 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, -} \ No newline at end of file diff --git a/ush-treetest/src/tree/mod.rs b/ush-treetest/src/tree/mod.rs deleted file mode 100644 index 31a3afd..0000000 --- a/ush-treetest/src/tree/mod.rs +++ /dev/null @@ -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>>; - -/// A node in the tree - contains an optional endpoint and child nodes. -pub struct Node { - endpoint: Option, - children: BTreeMap, - streams: BTreeMap, - 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::>()) - .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> for thread-safe sharing - pub fn set_endpoint(&mut self, endpoint: Box) { - 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 { - 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 { - let segments: Vec = 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 { - let segments: Vec = 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 { - 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 { - 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) { - 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> = 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, 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, String> { - let endpoints = self.root.list_endpoints_at(path); - Ok(endpoints) - } - - /// List all leaf paths in the tree. - pub fn list_leaves(&self) -> Vec { - 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 { - let segments: Vec = 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 { - let segments: Vec = 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 { - 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 { - // 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 = 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, 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 { - 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 { - path.split('/') - .filter(|s| !s.is_empty()) - .map(String::from) - .collect() -} \ No newline at end of file