add treetest protocol simulator and ui

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