From 13f3ca984b009a83ab76beca8f37921401553444 Mon Sep 17 00:00:00 2001 From: temrjan Date: Thu, 9 Jul 2026 11:02:50 +0500 Subject: [PATCH 1/7] build(deps): add ratatui/crossterm/serde/zeroize for the approver client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T1a foundation. ratatui 0.30 (crossterm backend, immediate-mode, no async runtime) + crossterm 0.29 pinned to ratatui's re-export; serde/serde_json for the JSON-lines wire types; zeroize for PIN hygiene; insta (dev) for TUI render snapshots. cargo deny check is green on the new tree (advisories/bans/licenses/ sources ok) — the WTFPL/Unicode-DFS-2016 crates are transitive to the unused termwiz/termina backends and are not in the crossterm build graph. --- Cargo.lock | 1734 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 17 +- 2 files changed, 1749 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0762a4c..b102a00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,1740 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[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.118", +] + +[[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.118", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[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.118", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "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.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + +[[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.118", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "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 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.0", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[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.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustok-console" version = "0.0.1" +dependencies = [ + "crossterm", + "insta", + "ratatui", + "serde", + "serde_json", + "zeroize", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[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.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.0", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "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 = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[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 = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 2fdbd89..b7a4a48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,22 @@ license = "MIT" description = "Rustok Console — terminal approval screen for the Rustok self-custody wallet (the human face)" repository = "https://github.com/rustok-org/console" -# Dependencies (ratatui, crossterm) land with the C-PR-1 implementation; the -# bootstrap crate stays dependency-free so CI/deny are meaningful from day one. [dependencies] +# TUI: immediate-mode render + its own event loop (crossterm::event::poll), no async +# runtime — per AGENTS.md. crossterm is pinned to the version ratatui 0.30 re-exports +# so the event API and the backend agree. +ratatui = "0.30" +crossterm = "0.29" +# Wire protocol (JSON Lines) — serde types mirror docs/APPROVER-PROTOCOL.md. +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# PIN hygiene: the PIN and its serialized auth request live in Zeroizing buffers. +zeroize = "1" + +[dev-dependencies] +# Snapshot assertions for TUI render tests (TestBackend), where a snapshot reads +# clearer than field-by-field checks. +insta = "1" [lints.rust] unsafe_code = "forbid" From 4cac50295472453b882ca69452848d4bb0f2e8a9 Mon Sep 17 00:00:00 2001 From: temrjan Date: Thu, 9 Jul 2026 11:09:19 +0500 Subject: [PATCH 2/7] feat(protocol): wire types + codec mirroring APPROVER-PROTOCOL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure protocol layer (no I/O): Request encode + parse_hello/auth/list/get, and the domain types (Summary, Card, DecodedCall, Kind, Risk). Faithful to the canon: numeric wire strings (amount_wei decimal, decoded amount 0x-hex, raw_data) are kept as String and rendered verbatim (AGENTS.md #1) — a decoded amount is bignum-safe text, never a truncating integer parse; decoded_call is Option (null for bare/unknown); unknown fields ignored (§6 additive). auth request deferred to the transport layer (PIN must serialize into a Zeroizing buffer, not through the general Serialize path). console is now lib+bin so the layer is unit-tested in isolation. 18 tests; red proven by mutating the enum rename_all (kind/risk wire mapping). --- src/lib.rs | 7 + src/protocol.rs | 555 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 562 insertions(+) create mode 100644 src/lib.rs create mode 100644 src/protocol.rs diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..442260f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +//! Rustok Console — library surface. +//! +//! The binary (`src/main.rs`) is a thin shell over this crate so the protocol, +//! transport, and render layers are unit-testable in isolation. The wire contract +//! is canonical in `docs/APPROVER-PROTOCOL.md`. + +pub mod protocol; diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..7dbe7f6 --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,555 @@ +//! Wire protocol types + codec — a faithful mirror of `docs/APPROVER-PROTOCOL.md`. +//! +//! This layer is **pure**: `encode_request` and the `parse_*` functions do no I/O, +//! so they are unit-tested directly (the socket worker thread is a separate layer). +//! +//! Numeric wire strings (`amount_wei`, a decoded `amount`, `tx_hash`) are kept as +//! `String` — the console renders the core's values **verbatim** (`AGENTS.md` #1) +//! and never re-derives meaning. A display may re-base a value for reading, but the +//! type carries the ground truth exactly as received; a decoded `amount` in +//! particular is a `0x`-hex string, not an integer, so a truncating parse cannot +//! silently mis-state an unlimited approval. +//! +//! Unknown fields are ignored (not `deny_unknown_fields`): additive fields are +//! allowed within a major version (protocol §6). + +use serde::{Deserialize, Serialize}; + +/// Wire protocol major version this client speaks. +pub const PROTO_VERSION: u32 = 1; + +// ─────────────────────────── Requests (client → server) ─────────────────────────── + +/// A request line the client sends. Serializes to one JSON object, e.g. +/// `{"op":"list"}`. **`auth` is intentionally absent here** — it carries the PIN, +/// whose serialized form must live in a `Zeroizing` buffer, so it is built in the +/// transport layer rather than through this general `Serialize` path (which would +/// leave an un-zeroized `String` copy of the PIN). +#[derive(Debug, Serialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum Request<'a> { + /// Version handshake; must be the first line on a connection. + Hello { + /// Major protocol version ([`PROTO_VERSION`]). + proto: u32, + /// Informational client id (the server does not validate it). + client: &'a str, + }, + /// Ask for the pending/executing queue summaries. + List, + /// Ask for the full clear-signing card of one item. + Get { + /// The item's preview-uuid, as received in a summary. + id: &'a str, + }, +} + +/// Serialize a request to a single JSON line (no trailing `\n`; the transport adds +/// it). +/// +/// # Errors +/// [`ProtocolError::Encode`] if serialization fails — not expected for these +/// shapes, but the seam is kept rather than panicking in a library path. +pub fn encode_request(req: &Request<'_>) -> Result { + serde_json::to_string(req).map_err(|e| ProtocolError::Encode(e.to_string())) +} + +// ─────────────────────────── Domain types (card / summary) ─────────────────────────── + +/// A pending item's kind, from a `list` summary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Kind { + /// A bare native transfer (no calldata). + Send, + /// A contract call (has calldata). + Call, +} + +/// The txguard risk level — two-valued, mirroring the core `RiskLevel` enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Risk { + /// No txguard warning. + Safe, + /// txguard flagged a warning. + Warning, +} + +/// One `list` summary line. `to` is EIP-55 checksummed and `amount_wei` is a +/// decimal string (both top-level, via the core's `Display`). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct Summary { + /// Preview-uuid (hyphenated lowercase). + pub id: String, + /// Send vs call. + pub kind: Kind, + /// EVM chain id. + pub chain_id: u64, + /// Recipient / target, EIP-55 checksummed `0x`-hex. + pub to: String, + /// Native value, **decimal** wei string. + pub amount_wei: String, + /// txguard risk level. + pub risk: Risk, + /// Whether approving needs a per-request PIN. + pub high_risk: bool, + /// Absolute expiry, unix seconds. + pub not_after_unix: u64, +} + +/// The core's decode of a recognised drain-vector call. **Every field is optional** +/// — an absent field is `null`, not a misleading zero. Addresses here are +/// serde-encoded **lowercase** `0x`-hex (unlike the top-level checksummed `to`), +/// and `amount`/`deadline` are `0x`-hex strings (unlike the decimal `amount_wei`). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct DecodedCall { + /// `approve` | `transfer` | `transfer_from` | `set_approval_for_all` | + /// `permit` | `permit2_approve` | `increase_allowance`. + pub method: String, + /// Authorized spender (`approve`/`permit`/`permit2_approve`). + pub spender: Option, + /// Operator (`set_approval_for_all`). + pub operator: Option, + /// Source (`transfer_from`) / owner (`permit`). + pub from: Option, + /// Recipient (`transfer`/`transfer_from`). + pub to: Option, + /// Approved token (`permit2_approve`; the tx `to` is the Permit2 contract). + pub token: Option, + /// Raw token amount, `0x`-hex string (kept as text — bignum-safe, verbatim). + pub amount: Option, + /// `permit` deadline / Permit2 expiration (unix), `0x`-hex string. + pub deadline: Option, + /// `set_approval_for_all`: `true` = grant, `false` = revoke. + pub approved: Option, + /// `amount == U256::MAX` — an infinite (unlimited) approval. + pub is_unlimited: Option, +} + +/// The full clear-signing card for one item (`get`). `decoded_call` may be `null` +/// (a bare transfer or an unrecognised selector) — render from `to` / `amount_wei` +/// / `raw_data` in that case, do not assume an object. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct Card { + /// Preview-uuid. + pub id: String, + /// EVM chain id. + pub chain_id: u64, + /// Recipient / target, EIP-55 checksummed. + pub to: String, + /// Native value, decimal wei string. + pub amount_wei: String, + /// The core's decode, or `null`. + pub decoded_call: Option, + /// Whether approving needs a per-request PIN. + pub high_risk: bool, + /// Closed set: `unlimited_approval` and/or `txguard_warning`. + pub high_risk_reasons: Vec, + /// Exact call input as `0x`-lowercase-hex; `"0x"` if empty. + pub raw_data: String, + /// Absolute expiry, unix seconds. + pub not_after_unix: u64, +} + +// ─────────────────────────── Responses (server → client) ─────────────────────────── + +/// Outcome of the `hello` handshake. +#[derive(Debug, PartialEq, Eq)] +pub enum HelloOutcome { + /// Handshake accepted; carries the informational server id. + Ok { + /// e.g. `"core-server/0.1.0"` — informational, never a compat gate. + server: String, + }, + /// Major version mismatch — fatal; the client must upgrade. Carries the + /// versions the server supports. + Unsupported { + /// Protocol majors the server accepts. + supported: Vec, + }, +} + +/// Outcome of `auth`. +#[derive(Debug, PartialEq, Eq)] +pub enum AuthOutcome { + /// Session authorized. + Ok, + /// Wrong PIN; `attempts_left == 0` means the lockout is now armed. + BadPin { + /// Attempts before the lockout trips. + attempts_left: u32, + }, + /// Lockout active; retry after this many seconds. + Locked { + /// Seconds until the channel accepts a PIN again. + retry_after_s: u64, + }, + /// The wallet has no PIN record (created before the PIN era). + PinNotSet, + /// Transient Argon2 backend failure — never an accept. + PinUnavailable, +} + +/// Outcome of `get`. +#[derive(Debug, PartialEq, Eq)] +pub enum GetOutcome { + /// The card. + Card(Box), + /// The id is not a live item (resolved, expired+swept, or never known). + UnknownId, +} + +/// A parse or encode failure in the protocol layer. +#[derive(Debug, PartialEq, Eq)] +pub enum ProtocolError { + /// The line was not valid JSON, or lacked a field the shape requires. + Malformed(String), + /// A request could not be serialized (not expected for our shapes). + Encode(String), + /// A server error code this response path does not model. + Unexpected(String), +} + +impl std::fmt::Display for ProtocolError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Malformed(m) => write!(f, "malformed response: {m}"), + Self::Encode(m) => write!(f, "request encode failed: {m}"), + Self::Unexpected(code) => write!(f, "unexpected server response: {code}"), + } + } +} + +impl std::error::Error for ProtocolError {} + +fn parse_line(line: &str) -> Result +where + T: for<'de> Deserialize<'de>, +{ + serde_json::from_str(line).map_err(|e| ProtocolError::Malformed(e.to_string())) +} + +/// Parse a response to `hello`. +/// +/// # Errors +/// [`ProtocolError::Malformed`] on non-JSON / wrong shape; [`ProtocolError::Unexpected`] +/// on an error code other than `unsupported_proto`. +pub fn parse_hello(line: &str) -> Result { + #[derive(Deserialize)] + struct Raw { + ok: bool, + server: Option, + error: Option, + supported: Option>, + } + let raw: Raw = parse_line(line)?; + if raw.ok { + Ok(HelloOutcome::Ok { + server: raw.server.unwrap_or_default(), + }) + } else if raw.error.as_deref() == Some("unsupported_proto") { + Ok(HelloOutcome::Unsupported { + supported: raw.supported.unwrap_or_default(), + }) + } else { + Err(ProtocolError::Unexpected( + raw.error + .unwrap_or_else(|| "hello without ok or error".to_owned()), + )) + } +} + +/// Parse a response to `auth`. +/// +/// # Errors +/// [`ProtocolError::Malformed`] on non-JSON / wrong shape; [`ProtocolError::Unexpected`] +/// on an unmodeled error code. +pub fn parse_auth(line: &str) -> Result { + #[derive(Deserialize)] + struct Raw { + ok: bool, + error: Option, + attempts_left: Option, + retry_after_s: Option, + } + let raw: Raw = parse_line(line)?; + if raw.ok { + return Ok(AuthOutcome::Ok); + } + match raw.error.as_deref() { + Some("bad_pin") => Ok(AuthOutcome::BadPin { + attempts_left: raw.attempts_left.unwrap_or(0), + }), + Some("locked") => Ok(AuthOutcome::Locked { + retry_after_s: raw.retry_after_s.unwrap_or(0), + }), + Some("pin_not_set") => Ok(AuthOutcome::PinNotSet), + Some("pin_unavailable") => Ok(AuthOutcome::PinUnavailable), + other => Err(ProtocolError::Unexpected( + other.unwrap_or("auth without ok or error").to_owned(), + )), + } +} + +/// Parse a response to `list` into the queue summaries. +/// +/// # Errors +/// [`ProtocolError::Malformed`] on non-JSON / wrong shape; [`ProtocolError::Unexpected`] +/// on an error response. +pub fn parse_list(line: &str) -> Result, ProtocolError> { + #[derive(Deserialize)] + struct Raw { + ok: bool, + pending: Option>, + error: Option, + } + let raw: Raw = parse_line(line)?; + if raw.ok { + Ok(raw.pending.unwrap_or_default()) + } else { + Err(ProtocolError::Unexpected( + raw.error + .unwrap_or_else(|| "list without ok or error".to_owned()), + )) + } +} + +/// Parse a response to `get`. +/// +/// # Errors +/// [`ProtocolError::Malformed`] on non-JSON / wrong shape / `ok` without a card; +/// [`ProtocolError::Unexpected`] on an error code other than `unknown_id`. +pub fn parse_get(line: &str) -> Result { + #[derive(Deserialize)] + struct Raw { + ok: bool, + card: Option, + error: Option, + } + let raw: Raw = parse_line(line)?; + if raw.ok { + raw.card + .map(|c| GetOutcome::Card(Box::new(c))) + .ok_or_else(|| ProtocolError::Malformed("ok get without a card".to_owned())) + } else if raw.error.as_deref() == Some("unknown_id") { + Ok(GetOutcome::UnknownId) + } else { + Err(ProtocolError::Unexpected( + raw.error + .unwrap_or_else(|| "get without ok or error".to_owned()), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── requests ── + + #[test] + fn encode_hello_is_a_single_tagged_line() { + let line = encode_request(&Request::Hello { + proto: PROTO_VERSION, + client: "rustok-console/0.0.1", + }) + .unwrap(); + assert_eq!( + line, + r#"{"op":"hello","proto":1,"client":"rustok-console/0.0.1"}"# + ); + } + + #[test] + fn encode_list_is_just_the_op() { + assert_eq!(encode_request(&Request::List).unwrap(), r#"{"op":"list"}"#); + } + + #[test] + fn encode_get_carries_the_id() { + let line = encode_request(&Request::Get { id: "abc-123" }).unwrap(); + assert_eq!(line, r#"{"op":"get","id":"abc-123"}"#); + } + + // ── hello ── + + #[test] + fn parse_hello_ok_keeps_the_server_id() { + let r = parse_hello(r#"{"ok":true,"proto":1,"server":"core-server/0.1.0"}"#).unwrap(); + assert_eq!( + r, + HelloOutcome::Ok { + server: "core-server/0.1.0".to_owned() + } + ); + } + + #[test] + fn parse_hello_unsupported_carries_supported_versions() { + let r = parse_hello(r#"{"ok":false,"error":"unsupported_proto","supported":[1]}"#).unwrap(); + assert_eq!(r, HelloOutcome::Unsupported { supported: vec![1] }); + } + + #[test] + fn parse_hello_rejects_a_bogus_error() { + assert!(matches!( + parse_hello(r#"{"ok":false,"error":"weird"}"#), + Err(ProtocolError::Unexpected(_)) + )); + } + + // ── auth ── + + #[test] + fn parse_auth_ok() { + assert_eq!(parse_auth(r#"{"ok":true}"#).unwrap(), AuthOutcome::Ok); + } + + #[test] + fn parse_auth_bad_pin_carries_attempts_left_including_zero() { + assert_eq!( + parse_auth(r#"{"ok":false,"error":"bad_pin","attempts_left":2}"#).unwrap(), + AuthOutcome::BadPin { attempts_left: 2 } + ); + // attempts_left:0 is the arming response — must round-trip as 0, not drop. + assert_eq!( + parse_auth(r#"{"ok":false,"error":"bad_pin","attempts_left":0}"#).unwrap(), + AuthOutcome::BadPin { attempts_left: 0 } + ); + } + + #[test] + fn parse_auth_locked_and_pin_states() { + assert_eq!( + parse_auth(r#"{"ok":false,"error":"locked","retry_after_s":287}"#).unwrap(), + AuthOutcome::Locked { retry_after_s: 287 } + ); + assert_eq!( + parse_auth(r#"{"ok":false,"error":"pin_not_set"}"#).unwrap(), + AuthOutcome::PinNotSet + ); + assert_eq!( + parse_auth(r#"{"ok":false,"error":"pin_unavailable"}"#).unwrap(), + AuthOutcome::PinUnavailable + ); + } + + // ── list ── + + #[test] + fn parse_list_empty_queue() { + assert_eq!(parse_list(r#"{"ok":true,"pending":[]}"#).unwrap(), vec![]); + } + + #[test] + fn parse_list_one_summary_all_fields() { + let summaries = parse_list( + r#"{"ok":true,"pending":[ + {"id":"a1","kind":"call","chain_id":1,"to":"0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + "amount_wei":"100000000000000000","risk":"warning","high_risk":true, + "not_after_unix":1783100000}]}"#, + ) + .unwrap(); + assert_eq!(summaries.len(), 1); + let s = &summaries[0]; + assert_eq!(s.kind, Kind::Call); + assert_eq!(s.risk, Risk::Warning); + assert_eq!(s.amount_wei, "100000000000000000"); // decimal, verbatim + assert!(s.high_risk); + } + + #[test] + fn parse_list_ignores_unknown_additive_fields() { + // §6: additive fields within a major version are ignored, not rejected. + let summaries = parse_list( + r#"{"ok":true,"pending":[ + {"id":"a1","kind":"send","chain_id":1,"to":"0xabc","amount_wei":"0", + "risk":"safe","high_risk":false,"not_after_unix":1,"future_field":42}], + "server_note":"ignored"}"#, + ) + .unwrap(); + assert_eq!(summaries[0].kind, Kind::Send); + } + + // ── get ── + + #[test] + fn parse_get_card_with_decoded_call() { + let out = parse_get( + r#"{"ok":true,"card":{"id":"a1","chain_id":1, + "to":"0x742d35Cc6634C0532925a3b844Bc454e4438f44e","amount_wei":"0", + "decoded_call":{"method":"approve", + "spender":"0x742d35cc6634c0532925a3b844bc454e4438f44e", + "amount":"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "is_unlimited":true}, + "high_risk":true,"high_risk_reasons":["unlimited_approval"], + "raw_data":"0x095ea7b3","not_after_unix":1783100000}}"#, + ) + .unwrap(); + let GetOutcome::Card(card) = out else { + panic!("expected a card"); + }; + let decoded = card.decoded_call.expect("decoded_call present"); + assert_eq!(decoded.method, "approve"); + assert_eq!(decoded.is_unlimited, Some(true)); + // amount is a 0x-hex STRING (bignum-safe), not an integer. + assert_eq!( + decoded.amount.as_deref(), + Some("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + ); + // absent sub-fields are None, not a misleading zero. + assert_eq!(decoded.operator, None); + assert_eq!(card.raw_data, "0x095ea7b3"); + } + + #[test] + fn parse_get_card_with_null_decoded_call() { + // A bare transfer: decoded_call is null, raw_data is "0x". + let out = parse_get( + r#"{"ok":true,"card":{"id":"a1","chain_id":1,"to":"0xabc","amount_wei":"1000", + "decoded_call":null,"high_risk":false,"high_risk_reasons":[], + "raw_data":"0x","not_after_unix":1}}"#, + ) + .unwrap(); + let GetOutcome::Card(card) = out else { + panic!("expected a card"); + }; + assert_eq!(card.decoded_call, None); + assert_eq!(card.raw_data, "0x"); + } + + #[test] + fn parse_get_unknown_id() { + assert_eq!( + parse_get(r#"{"ok":false,"error":"unknown_id"}"#).unwrap(), + GetOutcome::UnknownId + ); + } + + #[test] + fn parse_get_ok_without_card_is_malformed() { + assert!(matches!( + parse_get(r#"{"ok":true}"#), + Err(ProtocolError::Malformed(_)) + )); + } + + // ── codec robustness ── + + #[test] + fn parse_rejects_non_json() { + assert!(matches!( + parse_hello("not json at all"), + Err(ProtocolError::Malformed(_)) + )); + } + + #[test] + fn parse_rejects_a_truncated_line() { + assert!(matches!( + parse_get(r#"{"ok":true,"card":{"id":"#), + Err(ProtocolError::Malformed(_)) + )); + } +} From 0a9a289e742302eedcbccb8f6baa60e07c4ab697 Mon Sep 17 00:00:00 2001 From: temrjan Date: Thu, 9 Jul 2026 11:23:15 +0500 Subject: [PATCH 3/7] feat(transport): socket worker thread + request/response + errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client's only owner of the UNIX stream: a dedicated worker thread holds a blocking std UnixStream (no async runtime — AGENTS.md) and does request→response; the MVU layer talks to it over two mpsc channels and never blocks on the slow approve path. hello handshake on connect; one request in flight (protocol §1). Every failure surfaces as Reply::Fatal without a panic — refused connect (NotConnected), mid-session EOF / read-side close (ConnectionLost), garbled or oversize reply line (Protocol), fatal hello mismatch (UnsupportedProto). PIN never travels as a plain String: auth carries a pre-serialized Zeroizing line. 8 fake-socket tests, deterministic (recv-block, no sleep); the read-side EOF guard is isolated from a write-side broken pipe (mutation-proven). --- src/lib.rs | 1 + src/transport.rs | 483 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100644 src/transport.rs diff --git a/src/lib.rs b/src/lib.rs index 442260f..8faad22 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,3 +5,4 @@ //! is canonical in `docs/APPROVER-PROTOCOL.md`. pub mod protocol; +pub mod transport; diff --git a/src/transport.rs b/src/transport.rs new file mode 100644 index 0000000..94aca04 --- /dev/null +++ b/src/transport.rs @@ -0,0 +1,483 @@ +//! Socket worker thread — the client's only owner of the UNIX stream. +//! +//! `AGENTS.md` forbids an async *runtime*, not threads. The approver's `approve` +//! is slow (Argon2 verify + sign + broadcast — seconds), so a single-threaded +//! blocking read in the MVU loop would freeze the UI (countdown, Ctrl-C) for the +//! whole broadcast window. Instead a dedicated worker owns a blocking +//! `std::os::unix::net::UnixStream` and does request→response; the MVU loop talks +//! to it over two `mpsc` channels and never blocks on the socket. +//! +//! One request is in flight per connection (protocol §1): the worker sends a line +//! and reads exactly one reply before taking the next [`Request`]. Ordering the +//! *intents* (one in flight, latest-wins polling) is the MVU layer's job; the +//! transport just serializes the exchange. +//! +//! Every failure surfaces as [`Reply::Fatal`] — a refused connect, a mid-session +//! EOF, or a malformed reply line all end the worker cleanly instead of panicking. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::thread::JoinHandle; + +use zeroize::Zeroizing; + +use crate::protocol::{ + self, AuthOutcome, GetOutcome, HelloOutcome, PROTO_VERSION, Summary, encode_request, + parse_auth, parse_get, parse_hello, parse_list, +}; + +/// Informational client id sent in `hello` (the server does not validate it). +const CLIENT_ID: &str = concat!("rustok-console/", env!("CARGO_PKG_VERSION")); +/// A response line longer than this is refused — the transport mirror of the +/// server's own 64 KiB cap, so a runaway peer cannot exhaust client memory. +const MAX_LINE_BYTES: u64 = 64 * 1024; + +/// A request the MVU layer asks the worker to send. `auth` carries its already +/// serialized line inside a [`Zeroizing`] buffer — the PIN never travels as a +/// plain `String` (built in the MVU layer, zeroized on drop here after the write). +pub enum Request { + /// Pre-serialized `auth` line (PIN inside), zeroized after sending. + Auth(Zeroizing), + /// Ask for the queue summaries. + List, + /// Ask for one item's card by id. + Get(String), +} + +/// A message from the worker to the MVU layer. +#[derive(Debug, PartialEq, Eq)] +pub enum Reply { + /// Handshake accepted (sent once, right after connect). + Hello { + /// Informational server id. + server: String, + }, + /// Result of an `auth`. + Auth(AuthOutcome), + /// Result of a `list`. + List(Vec), + /// Result of a `get`. + Get(GetOutcome), + /// The connection is finished and unusable — the worker has exited. + Fatal(TransportError), +} + +/// A transport- or protocol-level failure. Each ends the worker; the MVU layer +/// renders the matching message and exits with the right code. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransportError { + /// The socket could not be opened — the wallet is not running / not reachable. + NotConnected, + /// The connection dropped mid-session (EOF, broken pipe). + ConnectionLost, + /// A reply line was not valid protocol (malformed / unexpected code). + Protocol(String), + /// Fatal `hello` version mismatch — the client must upgrade. Carries the + /// versions the server supports. + UnsupportedProto(Vec), +} + +impl std::fmt::Display for TransportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotConnected => { + write!(f, "wallet not running? could not open the approver socket") + } + Self::ConnectionLost => write!(f, "connection lost"), + Self::Protocol(m) => write!(f, "protocol error: {m}"), + Self::UnsupportedProto(v) => { + write!( + f, + "server speaks an unsupported protocol; it supports {v:?} — upgrade the console" + ) + } + } + } +} + +/// The MVU layer's handle to the worker: send [`Request`]s, receive [`Reply`]s. +/// Dropping it closes the request channel, which ends the worker. +pub struct Transport { + req_tx: Sender, + reply_rx: Receiver, + worker: Option>, +} + +impl Transport { + /// Connect to the approver socket at `path` and start the worker. The worker + /// performs the `hello` handshake immediately; its first [`Reply`] is either + /// [`Reply::Hello`] or a [`Reply::Fatal`]. Non-blocking: returns at once. + #[must_use] + pub fn connect(path: impl AsRef) -> Self { + let path = path.as_ref().to_path_buf(); + let (req_tx, req_rx) = channel::(); + let (reply_tx, reply_rx) = channel::(); + let worker = std::thread::spawn(move || worker_loop(&path, &req_rx, &reply_tx)); + Self { + req_tx, + reply_rx, + worker: Some(worker), + } + } + + /// Queue a request for the worker to send. Returns `false` if the worker has + /// exited (connection finished) — the caller then reads the final [`Reply`]. + pub fn send(&self, req: Request) -> bool { + self.req_tx.send(req).is_ok() + } + + /// Non-blocking poll for a reply. `None` means "nothing yet". + #[must_use] + pub fn try_recv(&self) -> Option { + self.reply_rx.try_recv().ok() + } + + /// Blocking receive of the next reply (used in tests and the connect handshake). + #[must_use] + pub fn recv(&self) -> Option { + self.reply_rx.recv().ok() + } +} + +impl Drop for Transport { + fn drop(&mut self) { + // Closing the request channel ends the worker's `recv` loop; join so the + // thread (and its socket) is gone before we return. + drop(std::mem::replace(&mut self.req_tx, channel().0)); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +/// The worker body: connect, handshake, then serve one request at a time until +/// the request channel closes or the connection fails. +fn worker_loop(path: &Path, req_rx: &Receiver, reply_tx: &Sender) { + let Ok(stream) = UnixStream::connect(path) else { + let _ = reply_tx.send(Reply::Fatal(TransportError::NotConnected)); + return; + }; + let Ok(write_half) = stream.try_clone() else { + let _ = reply_tx.send(Reply::Fatal(TransportError::NotConnected)); + return; + }; + let mut writer = write_half; + let mut reader = BufReader::new(stream); + + // Handshake first. A fatal outcome ends the worker. + match handshake(&mut writer, &mut reader) { + Ok(server) => { + if reply_tx.send(Reply::Hello { server }).is_err() { + return; // MVU gone already + } + } + Err(fatal) => { + let _ = reply_tx.send(Reply::Fatal(fatal)); + return; + } + } + + // Serve requests until the MVU layer drops the sender or the link breaks. + while let Ok(req) = req_rx.recv() { + let reply = match serve_one(&mut writer, &mut reader, &req) { + Ok(reply) => reply, + Err(fatal) => { + let _ = reply_tx.send(Reply::Fatal(fatal)); + return; // connection is unusable — stop + } + }; + if reply_tx.send(reply).is_err() { + return; + } + } +} + +/// Send `hello` and interpret the reply. Returns the server id on success. +fn handshake( + writer: &mut UnixStream, + reader: &mut BufReader, +) -> Result { + let line = encode_request(&protocol::Request::Hello { + proto: PROTO_VERSION, + client: CLIENT_ID, + }) + .map_err(|e| TransportError::Protocol(e.to_string()))?; + let resp = exchange(writer, reader, &line)?; + match parse_hello(&resp).map_err(|e| TransportError::Protocol(e.to_string()))? { + HelloOutcome::Ok { server } => Ok(server), + HelloOutcome::Unsupported { supported } => Err(TransportError::UnsupportedProto(supported)), + } +} + +/// Send one request and parse its reply. A broken connection or a malformed / +/// unexpected reply line is an `Err` (fatal — the exchange is out of sync and the +/// worker stops); expected error codes are already `Ok` variants (e.g. `get`'s +/// `unknown_id` → [`GetOutcome::UnknownId`]). +fn serve_one( + writer: &mut UnixStream, + reader: &mut BufReader, + req: &Request, +) -> Result { + let resp = match req { + Request::Auth(line) => exchange(writer, reader, line)?, + Request::List => { + let line = encode_request(&protocol::Request::List) + .map_err(|e| TransportError::Protocol(e.to_string()))?; + exchange(writer, reader, &line)? + } + Request::Get(id) => { + let line = encode_request(&protocol::Request::Get { id }) + .map_err(|e| TransportError::Protocol(e.to_string()))?; + exchange(writer, reader, &line)? + } + }; + let parsed = match req { + Request::Auth(_) => parse_auth(&resp).map(Reply::Auth), + Request::List => parse_list(&resp).map(Reply::List), + Request::Get(_) => parse_get(&resp).map(Reply::Get), + }; + parsed.map_err(|e| TransportError::Protocol(e.to_string())) +} + +/// Write one request line (adding the `\n`) and read exactly one reply line. An +/// EOF or I/O error is a lost connection; an over-long reply is a protocol error. +fn exchange( + writer: &mut UnixStream, + reader: &mut BufReader, + line: &str, +) -> Result { + writer + .write_all(line.as_bytes()) + .and_then(|()| writer.write_all(b"\n")) + .and_then(|()| writer.flush()) + .map_err(|_| TransportError::ConnectionLost)?; + + let mut resp = String::new(); + // Reborrow so `take` (which consumes its reader) does not move the `&mut` + // param; the cap mirrors the server's own oversize guard. + let read = (&mut *reader) + .take(MAX_LINE_BYTES + 1) + .read_line(&mut resp) + .map_err(|_| TransportError::ConnectionLost)?; + if read == 0 { + return Err(TransportError::ConnectionLost); // peer closed + } + if resp.len() as u64 > MAX_LINE_BYTES { + return Err(TransportError::Protocol("oversize reply line".to_owned())); + } + Ok(resp) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::net::UnixListener; + use std::path::PathBuf; + + /// A scripted fake approver: binds a temp socket, accepts one connection, and + /// for each request line it reads, writes back the next scripted reply. When + /// the script runs out it either closes (default) or keeps the connection open. + struct FakeServer { + path: PathBuf, + handle: Option>, + } + + impl FakeServer { + /// Each script step reads one request line, then acts: `Some(line)` writes + /// that reply; `None` reads the request but sends nothing (then the script + /// ends and the connection drops) — this isolates the worker's read-side + /// EOF path from a write-side broken pipe. + fn start(tag: &str, replies: Vec>) -> Self { + let path = std::env::temp_dir().join(format!( + "rustok_console_t1a_{}_{tag}.sock", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path).expect("bind fake socket"); + let handle = std::thread::spawn(move || { + let (stream, _) = listener.accept().expect("accept"); + let mut writer = stream.try_clone().expect("clone"); + let mut reader = BufReader::new(stream); + for reply in replies { + let mut line = String::new(); + // Block until the worker sends a request line (deterministic — + // no sleep). EOF means the worker went away first. + if reader.read_line(&mut line).unwrap_or(0) == 0 { + return; + } + if let Some(reply) = reply { + let _ = writer.write_all(reply.as_bytes()); + let _ = writer.write_all(b"\n"); + let _ = writer.flush(); + } + } + // Script exhausted: drop the connection so the next request sees EOF. + }); + Self { + path, + handle: Some(handle), + } + } + } + + impl Drop for FakeServer { + fn drop(&mut self) { + if let Some(h) = self.handle.take() { + let _ = h.join(); + } + let _ = std::fs::remove_file(&self.path); + } + } + + #[test] + fn connect_refused_when_no_socket() { + let path = std::env::temp_dir().join(format!( + "rustok_console_t1a_{}_nosock.sock", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let t = Transport::connect(&path); + assert_eq!( + t.recv(), + Some(Reply::Fatal(TransportError::NotConnected)), + "a missing socket is a clean NotConnected, not a panic" + ); + } + + #[test] + fn handshake_then_list_then_get() { + let server = FakeServer::start( + "happy", + vec![ + Some(r#"{"ok":true,"proto":1,"server":"core-server/0.1.0"}"#), + Some( + r#"{"ok":true,"pending":[{"id":"a1","kind":"send","chain_id":1,"to":"0xabc","amount_wei":"1000","risk":"safe","high_risk":false,"not_after_unix":1}]}"#, + ), + Some( + r#"{"ok":true,"card":{"id":"a1","chain_id":1,"to":"0xabc","amount_wei":"1000","decoded_call":null,"high_risk":false,"high_risk_reasons":[],"raw_data":"0x","not_after_unix":1}}"#, + ), + ], + ); + let t = Transport::connect(&server.path); + assert_eq!( + t.recv(), + Some(Reply::Hello { + server: "core-server/0.1.0".to_owned() + }) + ); + + assert!(t.send(Request::List)); + let Some(Reply::List(summaries)) = t.recv() else { + panic!("expected a list reply"); + }; + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].id, "a1"); + + assert!(t.send(Request::Get("a1".to_owned()))); + let Some(Reply::Get(GetOutcome::Card(card))) = t.recv() else { + panic!("expected a card reply"); + }; + assert_eq!(card.raw_data, "0x"); + } + + #[test] + fn auth_line_is_sent_and_result_parsed() { + let server = FakeServer::start( + "auth", + vec![ + Some(r#"{"ok":true,"proto":1,"server":"core-server/0.1.0"}"#), + Some(r#"{"ok":false,"error":"bad_pin","attempts_left":2}"#), + ], + ); + let t = Transport::connect(&server.path); + assert!(matches!(t.recv(), Some(Reply::Hello { .. }))); + + // The MVU layer builds this Zeroizing line (PIN inside); the transport + // just sends it. + let auth = Zeroizing::new(r#"{"op":"auth","pin":"000000"}"#.to_owned()); + assert!(t.send(Request::Auth(auth))); + assert_eq!( + t.recv(), + Some(Reply::Auth(AuthOutcome::BadPin { attempts_left: 2 })) + ); + } + + #[test] + fn unsupported_proto_at_handshake_is_fatal() { + let server = FakeServer::start( + "badproto", + vec![Some( + r#"{"ok":false,"error":"unsupported_proto","supported":[2]}"#, + )], + ); + let t = Transport::connect(&server.path); + assert_eq!( + t.recv(), + Some(Reply::Fatal(TransportError::UnsupportedProto(vec![2]))) + ); + } + + #[test] + fn mid_session_eof_is_connection_lost() { + // Server answers hello, then closes (script exhausted) → the next request + // hits a broken write / EOF. + let server = FakeServer::start( + "eof", + vec![Some( + r#"{"ok":true,"proto":1,"server":"core-server/0.1.0"}"#, + )], + ); + let t = Transport::connect(&server.path); + assert!(matches!(t.recv(), Some(Reply::Hello { .. }))); + assert!(t.send(Request::List)); + assert_eq!(t.recv(), Some(Reply::Fatal(TransportError::ConnectionLost))); + } + + #[test] + fn server_reads_request_then_closes_without_replying_is_connection_lost() { + // The write succeeds (the server reads the line) but no reply comes and the + // connection drops → the read-side EOF guard, not a broken pipe, is what + // yields ConnectionLost. Isolates the `read == 0` path. + let server = FakeServer::start( + "read_then_close", + vec![ + Some(r#"{"ok":true,"proto":1,"server":"core-server/0.1.0"}"#), + None, // reads the `list` request, sends nothing, then drops + ], + ); + let t = Transport::connect(&server.path); + assert!(matches!(t.recv(), Some(Reply::Hello { .. }))); + assert!(t.send(Request::List)); + assert_eq!(t.recv(), Some(Reply::Fatal(TransportError::ConnectionLost))); + } + + #[test] + fn a_garbled_reply_line_is_a_protocol_error_not_a_panic() { + let server = FakeServer::start( + "garbled", + vec![ + Some(r#"{"ok":true,"proto":1,"server":"core-server/0.1.0"}"#), + Some("this is not json"), + ], + ); + let t = Transport::connect(&server.path); + assert!(matches!(t.recv(), Some(Reply::Hello { .. }))); + assert!(t.send(Request::List)); + assert!(matches!( + t.recv(), + Some(Reply::Fatal(TransportError::Protocol(_))) + )); + } + + #[test] + fn garbled_handshake_is_a_protocol_error() { + let server = FakeServer::start("badhello", vec![Some("not even json")]); + let t = Transport::connect(&server.path); + assert!(matches!( + t.recv(), + Some(Reply::Fatal(TransportError::Protocol(_))) + )); + } +} From 57e358b730fcfade080683923b031f9741e74d27 Mon Sep 17 00:00:00 2001 From: temrjan Date: Thu, 9 Jul 2026 11:52:26 +0500 Subject: [PATCH 4/7] feat(app): MVU model + one-in-flight intents + PIN hygiene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure update logic over messages (no render, no I/O): Connecting → Authing → Watching → Fatal. One request in flight (protocol §1) — the periodic list poll is suppressed while busy (never queued), and a user action taken meanwhile is parked in a single latest-wins slot so it is not lost. Watch behaviour: the selection follows an item's id across a refresh (not its index); a vanished selection on open shows a transient note, not a dead card; open/back toggles the card. PIN hygiene: a dedicated Pin type wraps Zeroizing (zeroed on drop), redacts its digits in Debug (so a derived Model Debug cannot leak the PIN — note Zeroizing itself derives Debug and Serialize, hence the wrapper), accepts digits only (keeping the hand-built auth JSON injection-free), and builds the auth line by hand into a Zeroizing buffer rather than via serde (which would allocate an un-zeroized String copy). Request stays non-Debug so the PIN cannot surface even in a test failure. 11 tests; red proven by mutating the in-flight guard and the Pin Debug redaction. --- src/app.rs | 624 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 625 insertions(+) create mode 100644 src/app.rs diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..63620f0 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,624 @@ +//! The Model and its update logic (MVU) — pure over messages, no rendering and no +//! I/O. Keys are mapped to [`Msg`] in the event loop and transport replies arrive +//! as [`Msg::Reply`]; `update` folds them into the [`Model`] and returns at most one +//! [`transport::Request`] to send. This keeps the decision logic unit-testable +//! without a terminal or a socket. +//! +//! **One request in flight** (protocol §1): while a request is outstanding the +//! periodic `list` poll is suppressed (we do not pile up stale polls), and a user +//! action taken meanwhile is parked in a single latest-wins slot so it is not lost. + +use zeroize::Zeroizing; + +use crate::protocol::{AuthOutcome, Card, GetOutcome, Summary}; +use crate::transport::{self, Reply, TransportError}; + +/// The approval PIN as it is typed. Zeroized on drop (via [`Zeroizing`]) and +/// **redacted in `Debug`** so it never lands in a log line, a panic message, or a +/// derived `Debug` of the `Model`. Only ASCII digits are accepted, which also keeps +/// the hand-built auth JSON injection-free. +#[derive(Default, Clone)] +pub struct Pin(Zeroizing); + +impl std::fmt::Debug for Pin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never the digits — length only, so a Debug of the Model cannot leak it. + write!(f, "Pin(<{} digits>)", self.0.len()) + } +} + +impl Pin { + /// Append a digit; non-digits are ignored (keeps the auth JSON injection-free). + pub fn push(&mut self, c: char) { + if c.is_ascii_digit() { + self.0.push(c); + } + } + + /// Remove the last digit. + pub fn pop(&mut self) { + self.0.pop(); + } + + /// Number of digits entered. + #[must_use] + pub fn len(&self) -> usize { + self.0.len() + } + + /// Whether no digits have been entered. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Clear the PIN, zeroizing the current buffer (the old `Zeroizing` is dropped). + pub fn clear(&mut self) { + self.0 = Zeroizing::new(String::new()); + } + + /// Build the `auth` request line into a `Zeroizing` buffer. Assembled by hand + /// rather than via `serde_json::to_string`, which would allocate an + /// **un-zeroized** `String` copy of the PIN. Digits-only (see [`Self::push`]) + /// so no JSON escaping is needed. + #[must_use] + pub fn auth_line(&self) -> Zeroizing { + let mut line = Zeroizing::new(String::with_capacity(21 + self.0.len())); + line.push_str(r#"{"op":"auth","pin":""#); + line.push_str(&self.0); + line.push_str(r#""}"#); + line + } +} + +/// Where the session is. +#[derive(Debug)] +pub enum Phase { + /// Waiting for the `hello` handshake. + Connecting, + /// Handshake done; entering the PIN. + Authing { + /// The digits entered so far. + pin: Pin, + /// The last auth failure to show, if any. + error: Option, + }, + /// Authenticated; watching the queue. + Watching { + /// Current queue summaries. + items: Vec, + /// Selected row (clamped into `items`). + selected: usize, + /// The opened card, if one is being viewed. + card: Option>, + /// A transient note (e.g. the selected item vanished). + note: Option, + }, + /// The connection is finished — render the reason and exit. + Fatal(TransportError), +} + +/// A human-facing auth failure shown on the PIN screen. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthError { + /// Wrong PIN; attempts before lockout. + BadPin(u32), + /// Locked out; seconds to wait. + Locked(u64), + /// The wallet has no PIN set. + NotSet, + /// Transient verifier failure. + Unavailable, +} + +/// A message the update loop folds into the model. +#[derive(Debug)] +pub enum Msg { + /// Periodic timer — drives `list` polling in the watch phase. + Tick, + /// A reply from the transport worker. + Reply(Reply), + /// A digit was typed on the PIN screen. + PinDigit(char), + /// Backspace on the PIN screen. + PinBackspace, + /// Submit the PIN. + PinSubmit, + /// Move the queue selection up. + MoveUp, + /// Move the queue selection down. + MoveDown, + /// Open the selected item's card. + Open, + /// Close the open card. + Back, + /// Quit. + Quit, +} + +/// The application model. +#[derive(Debug)] +pub struct Model { + phase: Phase, + in_flight: bool, + pending: Option, + quit: bool, +} + +/// A parked user intent (no `List` — the poll is suppressed, not queued). +#[derive(Debug)] +enum PendingIntent { + Get(String), + Auth, +} + +impl Default for Model { + fn default() -> Self { + Self { + phase: Phase::Connecting, + in_flight: false, + pending: None, + quit: false, + } + } +} + +impl Model { + /// A fresh model in the `Connecting` phase. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Read-only view of the current phase (for the renderer). + #[must_use] + pub fn phase(&self) -> &Phase { + &self.phase + } + + /// Whether the app has been asked to quit. + #[must_use] + pub fn should_quit(&self) -> bool { + self.quit + } + + /// Fold one message into the model, returning at most one request to send. + pub fn update(&mut self, msg: Msg) -> Option { + match msg { + Msg::Quit => { + self.quit = true; + None + } + Msg::Tick => self.on_tick(), + Msg::Reply(reply) => self.on_reply(reply), + Msg::PinDigit(c) => { + if let Phase::Authing { pin, .. } = &mut self.phase { + pin.push(c); + } + None + } + Msg::PinBackspace => { + if let Phase::Authing { pin, .. } = &mut self.phase { + pin.pop(); + } + None + } + Msg::PinSubmit => self.on_pin_submit(), + Msg::MoveUp => { + if let Phase::Watching { selected, .. } = &mut self.phase { + *selected = selected.saturating_sub(1); + } + None + } + Msg::MoveDown => { + if let Phase::Watching { + selected, items, .. + } = &mut self.phase + { + *selected = (*selected + 1).min(items.len().saturating_sub(1)); + } + None + } + Msg::Open => self.on_open(), + Msg::Back => { + if let Phase::Watching { card, .. } = &mut self.phase { + *card = None; + } + None + } + } + } + + /// Periodic tick: poll `list` in the watch phase — but only when nothing is in + /// flight (the poll is suppressed, never queued, so stale polls do not pile up). + fn on_tick(&mut self) -> Option { + if matches!(self.phase, Phase::Watching { .. }) && !self.in_flight { + self.in_flight = true; + Some(transport::Request::List) + } else { + None + } + } + + fn on_pin_submit(&mut self) -> Option { + let Phase::Authing { pin, .. } = &self.phase else { + return None; + }; + if pin.is_empty() { + return None; + } + let line = pin.auth_line(); + self.dispatch_user(PendingIntent::Auth, || transport::Request::Auth(line)) + } + + fn on_open(&mut self) -> Option { + let Phase::Watching { + items, + selected, + card, + .. + } = &self.phase + else { + return None; + }; + if card.is_some() { + return None; // already viewing one + } + let Some(id) = items.get(*selected).map(|s| s.id.clone()) else { + return None; // empty queue + }; + self.dispatch_user(PendingIntent::Get(id.clone()), || { + transport::Request::Get(id) + }) + } + + /// Send a user intent now if idle, else park it (latest-wins) so it is not lost. + fn dispatch_user( + &mut self, + park: PendingIntent, + make: impl FnOnce() -> transport::Request, + ) -> Option { + if self.in_flight { + self.pending = Some(park); + None + } else { + self.in_flight = true; + Some(make()) + } + } + + fn on_reply(&mut self, reply: Reply) -> Option { + self.in_flight = false; + match reply { + Reply::Hello { .. } => { + self.phase = Phase::Authing { + pin: Pin::default(), + error: None, + }; + } + Reply::Auth(outcome) => self.apply_auth(outcome), + Reply::List(items) => self.apply_list(items), + Reply::Get(outcome) => self.apply_get(outcome), + Reply::Fatal(err) => { + self.phase = Phase::Fatal(err); + self.pending = None; + return None; + } + } + self.flush_pending() + } + + fn apply_auth(&mut self, outcome: AuthOutcome) { + match outcome { + AuthOutcome::Ok => { + self.phase = Phase::Watching { + items: Vec::new(), + selected: 0, + card: None, + note: None, + }; + } + other => { + if let Phase::Authing { pin, error } = &mut self.phase { + pin.clear(); + *error = Some(match other { + AuthOutcome::BadPin { attempts_left } => AuthError::BadPin(attempts_left), + AuthOutcome::Locked { retry_after_s } => AuthError::Locked(retry_after_s), + AuthOutcome::PinNotSet => AuthError::NotSet, + AuthOutcome::PinUnavailable | AuthOutcome::Ok => AuthError::Unavailable, + }); + } + } + } + } + + fn apply_list(&mut self, new_items: Vec) { + if let Phase::Watching { + items, selected, .. + } = &mut self.phase + { + // Preserve the selection across a refresh by id, not by index. An open + // card is intentionally left as-is: if its item has left the live set, + // the card stays visible and a re-open surfaces unknown_id (apply_get). + let selected_id = items.get(*selected).map(|s| s.id.clone()); + *items = new_items; + *selected = selected_id + .and_then(|id| items.iter().position(|s| s.id == id)) + .unwrap_or(0) + .min(items.len().saturating_sub(1)); + } + } + + fn apply_get(&mut self, outcome: GetOutcome) { + if let Phase::Watching { card, note, .. } = &mut self.phase { + match outcome { + GetOutcome::Card(c) => { + *card = Some(c); + *note = None; + } + GetOutcome::UnknownId => { + // The selected item vanished between list and get — drop any + // stale card and show a transient note instead of a dead card. + *card = None; + *note = Some("that request is no longer available".to_owned()); + } + } + } + } + + /// After a reply lands, send a parked user intent if one is waiting. + fn flush_pending(&mut self) -> Option { + match self.pending.take() { + Some(PendingIntent::Get(id)) => { + self.in_flight = true; + Some(transport::Request::Get(id)) + } + Some(PendingIntent::Auth) => { + // Re-derive the auth line from the (now cleared-on-failure) pin only + // if we are still on the auth screen with digits; otherwise drop it. + if let Phase::Authing { pin, .. } = &self.phase + && !pin.is_empty() + { + let line = pin.auth_line(); + self.in_flight = true; + Some(transport::Request::Auth(line)) + } else { + None + } + } + None => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{GetOutcome, Kind, Risk}; + + fn summary(id: &str) -> Summary { + Summary { + id: id.to_owned(), + kind: Kind::Send, + chain_id: 1, + to: "0xabc".to_owned(), + amount_wei: "0".to_owned(), + risk: Risk::Safe, + high_risk: false, + not_after_unix: 1, + } + } + + fn card(id: &str) -> Box { + Box::new(Card { + id: id.to_owned(), + chain_id: 1, + to: "0xabc".to_owned(), + amount_wei: "0".to_owned(), + decoded_call: None, + high_risk: false, + high_risk_reasons: vec![], + raw_data: "0x".to_owned(), + not_after_unix: 1, + }) + } + + /// Drive a model to the watch phase with the given items. + fn watching(items: Vec) -> Model { + let mut m = Model::new(); + assert!( + m.update(Msg::Reply(Reply::Hello { + server: "s".to_owned() + })) + .is_none() + ); + m.update(Msg::PinSubmit); // empty — no-op, but exercises the guard + // fake a successful auth + let _ = m.update(Msg::PinDigit('1')); + let req = m.update(Msg::PinSubmit); + assert!(matches!(req, Some(transport::Request::Auth(_)))); + assert!(m.update(Msg::Reply(Reply::Auth(AuthOutcome::Ok))).is_none()); + // deliver the first list + assert!(matches!( + m.update(Msg::Tick), + Some(transport::Request::List) + )); + m.update(Msg::Reply(Reply::List(items))); + m + } + + // ── PIN hygiene ── + + #[test] + fn pin_debug_never_shows_the_digits() { + let mut pin = Pin::default(); + for c in "483920".chars() { + pin.push(c); + } + let dbg = format!("{pin:?}"); + assert!( + !dbg.contains("483920"), + "PIN digits must not appear in Debug" + ); + assert!(dbg.contains("6 digits")); + } + + #[test] + fn model_debug_never_shows_the_pin() { + let mut m = Model::new(); + m.update(Msg::Reply(Reply::Hello { + server: "s".to_owned(), + })); + for c in "483920".chars() { + m.update(Msg::PinDigit(c)); + } + assert!(!format!("{m:?}").contains("483920")); + } + + #[test] + fn pin_ignores_non_digits_keeping_auth_json_safe() { + let mut pin = Pin::default(); + for c in "1\"2}3".chars() { + pin.push(c); + } + assert_eq!(pin.len(), 3); + let line = pin.auth_line(); + assert_eq!(&*line, r#"{"op":"auth","pin":"123"}"#); + } + + // ── one request in flight ── + + #[test] + fn tick_polls_list_only_when_idle() { + let mut m = watching(vec![summary("a")]); + // idle → tick polls + assert!(matches!( + m.update(Msg::Tick), + Some(transport::Request::List) + )); + // now in flight → a second tick is suppressed, not queued + assert!(m.update(Msg::Tick).is_none()); + // reply clears the flight; the next tick polls again + m.update(Msg::Reply(Reply::List(vec![summary("a")]))); + assert!(matches!( + m.update(Msg::Tick), + Some(transport::Request::List) + )); + } + + #[test] + fn a_user_open_while_in_flight_is_parked_and_sent_after_the_reply() { + let mut m = watching(vec![summary("a"), summary("b")]); + // start a list poll → in flight + assert!(matches!( + m.update(Msg::Tick), + Some(transport::Request::List) + )); + // user opens item 0 while the poll is outstanding → parked, nothing sent now + assert!(m.update(Msg::Open).is_none()); + // the poll reply lands → the parked get is flushed + let flushed = m.update(Msg::Reply(Reply::List(vec![summary("a"), summary("b")]))); + assert!(matches!(flushed, Some(transport::Request::Get(id)) if id == "a")); + } + + #[test] + fn parked_user_intent_is_latest_wins() { + let mut m = watching(vec![summary("a"), summary("b")]); + assert!(matches!( + m.update(Msg::Tick), + Some(transport::Request::List) + )); + m.update(Msg::Open); // parks get("a") + m.update(Msg::MoveDown); + m.update(Msg::Open); // replaces with get("b") — latest wins + let flushed = m.update(Msg::Reply(Reply::List(vec![summary("a"), summary("b")]))); + assert!(matches!(flushed, Some(transport::Request::Get(id)) if id == "b")); + } + + // ── watch behaviour ── + + #[test] + fn selection_survives_a_refresh_by_id() { + let mut m = watching(vec![summary("a"), summary("b"), summary("c")]); + m.update(Msg::MoveDown); + m.update(Msg::MoveDown); // selected = "c" + // refresh reorders: "c" is now first + m.update(Msg::Reply(Reply::List(vec![ + summary("c"), + summary("a"), + summary("b"), + ]))); + let Phase::Watching { + selected, items, .. + } = m.phase() + else { + panic!("watching"); + }; + assert_eq!( + items[*selected].id, "c", + "selection follows the id, not the index" + ); + } + + #[test] + fn opening_shows_the_card_and_back_closes_it() { + let mut m = watching(vec![summary("a")]); + assert!(matches!( + m.update(Msg::Open), + Some(transport::Request::Get(_)) + )); + m.update(Msg::Reply(Reply::Get(GetOutcome::Card(card("a"))))); + assert!(matches!(m.phase(), Phase::Watching { card: Some(_), .. })); + m.update(Msg::Back); + assert!(matches!(m.phase(), Phase::Watching { card: None, .. })); + } + + #[test] + fn a_vanished_selection_on_open_shows_a_note_not_a_dead_card() { + let mut m = watching(vec![summary("a")]); + assert!(matches!( + m.update(Msg::Open), + Some(transport::Request::Get(_)) + )); + // the item vanished between list and get + m.update(Msg::Reply(Reply::Get(GetOutcome::UnknownId))); + let Phase::Watching { card, note, .. } = m.phase() else { + panic!("watching"); + }; + assert!(card.is_none()); + assert!(note.is_some()); + } + + // ── auth failure ── + + #[test] + fn bad_pin_clears_the_pin_and_records_the_error() { + let mut m = Model::new(); + m.update(Msg::Reply(Reply::Hello { + server: "s".to_owned(), + })); + m.update(Msg::PinDigit('9')); + m.update(Msg::PinSubmit); + m.update(Msg::Reply(Reply::Auth(AuthOutcome::BadPin { + attempts_left: 2, + }))); + let Phase::Authing { pin, error } = m.phase() else { + panic!("still authing"); + }; + assert!(pin.is_empty(), "a bad PIN is cleared for re-entry"); + assert_eq!(error, &Some(AuthError::BadPin(2))); + } + + #[test] + fn a_fatal_reply_moves_to_the_fatal_phase() { + let mut m = Model::new(); + assert!( + m.update(Msg::Reply(Reply::Fatal(TransportError::ConnectionLost))) + .is_none() + ); + assert!(matches!( + m.phase(), + Phase::Fatal(TransportError::ConnectionLost) + )); + } +} diff --git a/src/lib.rs b/src/lib.rs index 8faad22..e8289ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,5 +4,6 @@ //! transport, and render layers are unit-testable in isolation. The wire contract //! is canonical in `docs/APPROVER-PROTOCOL.md`. +pub mod app; pub mod protocol; pub mod transport; From f08fe56fb5f719bdfc5f9e85d0831f087bd52d8d Mon Sep 17 00:00:00 2001 From: temrjan Date: Thu, 9 Jul 2026 11:56:37 +0500 Subject: [PATCH 5/7] feat(ui): watch/auth/card render (verbatim) + edge states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Immediate-mode render as a pure function of the Model (ratatui 0.30). Screens: connecting, unlock (PIN shown only as dots — render_auth receives just the digit count, never the digits, so it structurally cannot leak them), watch (queue list + verbatim card), and fatal (the transport reason). The card renders the core's fields as received — address, decimal amount_wei, 0x-hex decoded amounts, and raw_data all verbatim (AGENTS.md #1), with no re-derivation; an UNLIMITED flag and high-risk reasons are surfaced from the core's own flags. Edge states: empty queue shows a waiting message; a vanished selection shows the transient note, not a dead card. 6 TestBackend tests; red proven by dropping raw_data from the card render. --- src/lib.rs | 1 + src/ui.rs | 335 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 src/ui.rs diff --git a/src/lib.rs b/src/lib.rs index e8289ac..9d6caec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,3 +7,4 @@ pub mod app; pub mod protocol; pub mod transport; +pub mod ui; diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..8b82ba8 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,335 @@ +//! Rendering — a pure function of the [`Model`], immediate-mode (rebuilt every +//! frame). The console shows the core's values **verbatim** (`AGENTS.md` #1): the +//! card renders the fields as received, adding no interpretation. The PIN is shown +//! only as a row of dots — never the digits. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph}; + +use crate::app::{AuthError, Model, Phase}; +use crate::protocol::{Card, Summary}; + +/// Render the whole screen for the current model. +pub fn render(frame: &mut Frame, model: &Model) { + match model.phase() { + Phase::Connecting => { + render_centered(frame, "Connecting to the wallet…"); + } + Phase::Authing { pin, error } => render_auth(frame, pin.len(), error.as_ref()), + Phase::Watching { + items, + selected, + card, + note, + } => render_watch(frame, items, *selected, card.as_deref(), note.as_deref()), + Phase::Fatal(err) => render_centered(frame, &err.to_string()), + } +} + +fn render_centered(frame: &mut Frame, message: &str) { + let block = Block::bordered().title(" Rustok Console "); + let paragraph = Paragraph::new(message).block(block); + frame.render_widget(paragraph, frame.area()); +} + +fn render_auth(frame: &mut Frame, pin_len: usize, error: Option<&AuthError>) { + let mut lines = vec![ + Line::from("Enter your wallet PIN, then press Enter."), + Line::from(""), + // Only the count is shown — never the digits. + Line::from(Span::styled( + "●".repeat(pin_len), + Style::new().add_modifier(Modifier::BOLD), + )), + ]; + if let Some(err) = error { + lines.push(Line::from("")); + lines.push(Line::from(auth_error_text(err))); + } + let paragraph = Paragraph::new(lines).block(Block::bordered().title(" Unlock ")); + frame.render_widget(paragraph, frame.area()); +} + +fn auth_error_text(err: &AuthError) -> String { + match err { + AuthError::BadPin(left) => format!("Wrong PIN — {left} attempt(s) left."), + AuthError::Locked(secs) => format!("Locked out. Try again in {secs}s."), + AuthError::NotSet => "This wallet has no PIN set (run set-pin).".to_owned(), + AuthError::Unavailable => "PIN check unavailable — try again.".to_owned(), + } +} + +fn render_watch( + frame: &mut Frame, + items: &[Summary], + selected: usize, + card: Option<&Card>, + note: Option<&str>, +) { + let chunks = Layout::vertical([ + Constraint::Length(1), // header + Constraint::Min(3), // queue + Constraint::Min(6), // card / hint + Constraint::Length(1), // note / footer + ]) + .split(frame.area()); + + frame.render_widget( + Paragraph::new(format!(" Pending approvals: {}", items.len())), + chunks[0], + ); + + render_queue(frame, items, selected, chunks[1]); + render_detail(frame, card, chunks[2]); + + let footer = note.unwrap_or("↑/↓ select · enter open · q quit (approve/deny in C-PR-1b)"); + frame.render_widget(Paragraph::new(footer), chunks[3]); +} + +fn render_queue( + frame: &mut Frame, + items: &[Summary], + selected: usize, + area: ratatui::layout::Rect, +) { + let block = Block::bordered().title(" Queue "); + if items.is_empty() { + let empty = Paragraph::new("Queue is empty — waiting for approval requests…").block(block); + frame.render_widget(empty, area); + return; + } + let rows: Vec = items + .iter() + .map(|s| { + let flag = if s.high_risk { "⚠ " } else { " " }; + ListItem::new(format!( + "{flag}{kind:5} {to} {amount} wei", + kind = kind_word(s), + to = s.to, + amount = s.amount_wei + )) + }) + .collect(); + let mut state = ListState::default(); + state.select(Some(selected.min(items.len().saturating_sub(1)))); + let list = List::new(rows) + .block(block) + .highlight_symbol("▶ ") + .highlight_style(Style::new().add_modifier(Modifier::REVERSED)); + frame.render_stateful_widget(list, area, &mut state); +} + +fn kind_word(s: &Summary) -> &'static str { + match s.kind { + crate::protocol::Kind::Send => "send", + crate::protocol::Kind::Call => "call", + } +} + +/// Render the selected card **verbatim** — the core's fields as received, no +/// re-derivation. `None` shows a hint to open one. +fn render_detail(frame: &mut Frame, card: Option<&Card>, area: ratatui::layout::Rect) { + let block = Block::bordered().title(" Card "); + let Some(card) = card else { + let hint = + Paragraph::new("Select a request and press enter to see the full card.").block(block); + frame.render_widget(hint, area); + return; + }; + + let mut lines = vec![ + kv("to", &card.to), + kv("amount_wei", &card.amount_wei), + kv("chain_id", &card.chain_id.to_string()), + kv("raw_data", &card.raw_data), + ]; + if card.high_risk { + lines.push(Line::from(Span::styled( + format!("HIGH RISK: {}", card.high_risk_reasons.join(", ")), + Style::new().add_modifier(Modifier::BOLD), + ))); + } + match &card.decoded_call { + None => lines.push(Line::from("decoded_call: (none)")), + Some(dc) => { + lines.push(Line::from(format!("decoded_call.method: {}", dc.method))); + push_opt(&mut lines, "spender", dc.spender.as_deref()); + push_opt(&mut lines, "operator", dc.operator.as_deref()); + push_opt(&mut lines, "from", dc.from.as_deref()); + push_opt(&mut lines, "to", dc.to.as_deref()); + push_opt(&mut lines, "token", dc.token.as_deref()); + push_opt(&mut lines, "amount", dc.amount.as_deref()); + push_opt(&mut lines, "deadline", dc.deadline.as_deref()); + if dc.is_unlimited == Some(true) { + lines.push(Line::from(Span::styled( + "amount: UNLIMITED", + Style::new().add_modifier(Modifier::BOLD), + ))); + } + } + } + frame.render_widget(Paragraph::new(lines).block(block), area); +} + +fn kv(key: &str, value: &str) -> Line<'static> { + Line::from(format!("{key}: {value}")) +} + +fn push_opt(lines: &mut Vec>, key: &str, value: Option<&str>) { + if let Some(v) = value { + lines.push(Line::from(format!("decoded_call.{key}: {v}"))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{Model, Msg}; + use crate::protocol::{AuthOutcome, Card, DecodedCall, Kind, Risk}; + use crate::transport::Reply; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + /// Render the model into a fixed grid and flatten the buffer to a String for + /// substring assertions. + fn draw(model: &Model, w: u16, h: u16) -> String { + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| render(f, model)).unwrap(); + terminal + .backend() + .buffer() + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect() + } + + fn summary(id: &str, to: &str, amount: &str, high_risk: bool) -> Summary { + Summary { + id: id.to_owned(), + kind: Kind::Call, + chain_id: 1, + to: to.to_owned(), + amount_wei: amount.to_owned(), + risk: Risk::Safe, + high_risk, + not_after_unix: 1, + } + } + + fn to_watching(model: &mut Model, items: Vec) { + model.update(Msg::Reply(Reply::Hello { + server: "s".to_owned(), + })); + model.update(Msg::PinDigit('1')); + model.update(Msg::PinSubmit); + model.update(Msg::Reply(Reply::Auth(AuthOutcome::Ok))); + model.update(Msg::Tick); + model.update(Msg::Reply(Reply::List(items))); + } + + #[test] + fn auth_screen_masks_the_pin_with_dots_never_the_digits() { + let mut m = Model::new(); + m.update(Msg::Reply(Reply::Hello { + server: "s".to_owned(), + })); + for c in "4839".chars() { + m.update(Msg::PinDigit(c)); + } + let screen = draw(&m, 60, 12); + assert!(screen.contains("●●●●"), "four dots for four digits"); + assert!(!screen.contains("4839"), "the digits must never render"); + } + + #[test] + fn empty_queue_shows_a_waiting_message() { + let mut m = Model::new(); + to_watching(&mut m, vec![]); + let screen = draw(&m, 80, 20); + assert!(screen.contains("Queue is empty")); + } + + #[test] + fn queue_lists_items_verbatim() { + let mut m = Model::new(); + to_watching( + &mut m, + vec![summary( + "a1", + "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + "100000000000000000", + false, + )], + ); + let screen = draw(&m, 90, 20); + // the address and the decimal wei render exactly as received (verbatim). + assert!(screen.contains("0x742d35Cc6634C0532925a3b844Bc454e4438f44e")); + assert!(screen.contains("100000000000000000")); + } + + #[test] + fn open_card_renders_decoded_fields_and_raw_data_verbatim() { + let mut m = Model::new(); + to_watching(&mut m, vec![summary("a1", "0xabc", "0", true)]); + m.update(Msg::Open); + let boxed = Box::new(Card { + id: "a1".to_owned(), + chain_id: 1, + to: "0xabc".to_owned(), + amount_wei: "0".to_owned(), + decoded_call: Some(DecodedCall { + method: "approve".to_owned(), + spender: Some("0xdeadbeef".to_owned()), + operator: None, + from: None, + to: None, + token: None, + amount: Some("0xffffffffffffffff".to_owned()), + deadline: None, + approved: None, + is_unlimited: Some(true), + }), + high_risk: true, + high_risk_reasons: vec!["unlimited_approval".to_owned()], + raw_data: "0x095ea7b3deadbeef".to_owned(), + not_after_unix: 1, + }); + m.update(Msg::Reply(Reply::Get(crate::protocol::GetOutcome::Card( + boxed, + )))); + let screen = draw(&m, 100, 24); + assert!(screen.contains("approve"), "method rendered"); + assert!(screen.contains("0xdeadbeef"), "spender verbatim"); + // the 0x-hex amount is shown as received, not converted to a number + assert!(screen.contains("0xffffffffffffffff"), "hex amount verbatim"); + assert!(screen.contains("0x095ea7b3deadbeef"), "raw_data verbatim"); + assert!(screen.contains("UNLIMITED")); + } + + #[test] + fn a_vanished_selection_note_is_shown() { + let mut m = Model::new(); + to_watching(&mut m, vec![summary("a1", "0xabc", "0", false)]); + m.update(Msg::Open); + m.update(Msg::Reply(Reply::Get( + crate::protocol::GetOutcome::UnknownId, + ))); + let screen = draw(&m, 90, 20); + assert!(screen.contains("no longer available")); + } + + #[test] + fn fatal_phase_renders_the_reason() { + let mut m = Model::new(); + m.update(Msg::Reply(Reply::Fatal( + crate::transport::TransportError::NotConnected, + ))); + let screen = draw(&m, 80, 10); + assert!(screen.contains("wallet not running")); + } +} From 2b5e24e23d74fa4ba30b640f3cc8c978334b71f9 Mon Sep 17 00:00:00 2001 From: temrjan Date: Thu, 9 Jul 2026 12:00:00 +0500 Subject: [PATCH 6/7] feat(console): wire the event loop, key mapping, exit codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the stub with the real binary: ratatui::try_init (no-TTY → clear message + exit 3), the socket worker via Transport, and the MVU event loop — draw, read a key (Press only), map it to a Msg by phase, fold it into the Model, send any resulting request, drain worker replies, and tick the list poll every 2.5s. A fatal phase is shown until a keypress, then exits. ratatui installs the panic hook that restores the terminal. Key map and exit codes are pure functions with unit tests (Ctrl-C quits anywhere; digits/backspace/enter on the PIN screen; ↑↓/enter/esc/q in watch; connecting & fatal ignore ordinary keys; unsupported-proto → upgrade code). 5 tests. approve/deny + full TTY gate: C-PR-1b. --- src/main.rs | 252 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 241 insertions(+), 11 deletions(-) diff --git a/src/main.rs b/src/main.rs index fa3f93a..670c0d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,247 @@ -//! Rustok Console — the human face of the Rustok wallet. +//! Rustok Console — terminal approval screen (the human face of the wallet). //! -//! Bootstrap stub: the v0.1 client (watch + one-shot approve) lands in C-PR-1. -//! The wire contract it will implement is canonical in `docs/APPROVER-PROTOCOL.md`. -//! Until then this binary only identifies itself and exits non-zero, so nothing -//! can mistake the skeleton for a working approver (fail-closed from day one). +//! v0.1 (C-PR-1a): connect → hello → PIN → **watch** the queue read-only. Keys are +//! mapped to [`Msg`] and folded into the [`Model`]; the socket worker runs on its +//! own thread so a slow core never freezes the UI. `approve`/`deny` land in +//! C-PR-1b. UI goes to stderr (ratatui's alternate screen); the exit code carries +//! the outcome. use std::process::ExitCode; +use std::time::{Duration, Instant}; + +use ratatui::DefaultTerminal; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + +use rustok_console::app::{Model, Msg, Phase}; +use rustok_console::transport::{Transport, TransportError}; +use rustok_console::ui; + +/// Default approver socket path (overridable for tests / non-standard layouts). +const DEFAULT_SOCKET: &str = "/run/wallet/approve.sock"; +/// How often the watch screen polls `list`. +const POLL: Duration = Duration::from_millis(2500); + +// Exit codes (AGENTS.md #7). C-PR-1a covers the read-only subset; approve/deny +// outcomes (approved/rejected/expired) arrive with C-PR-1b. +const EXIT_ABORTED: u8 = 0; +const EXIT_FATAL: u8 = 1; +const EXIT_UPGRADE: u8 = 2; +const EXIT_NO_TTY: u8 = 3; fn main() -> ExitCode { - eprintln!( - "rustok-console {}: not implemented yet — the v0.1 approver client lands in C-PR-1.\n\ - Protocol contract: docs/APPROVER-PROTOCOL.md (proto 1).", - env!("CARGO_PKG_VERSION") - ); - ExitCode::FAILURE + let path = std::env::var("RUSTOK_APPROVE_SOCK").unwrap_or_else(|_| DEFAULT_SOCKET.to_owned()); + + // No TTY → view-only is not possible for an interactive approver; fail clearly + // rather than half-render. (The full no-TTY view-only gate is C-PR-1b.) + let Ok(terminal) = ratatui::try_init() else { + eprintln!("rustok-console needs an interactive terminal (a TTY)."); + return ExitCode::from(EXIT_NO_TTY); + }; + + let transport = Transport::connect(&path); + let code = run(terminal, &transport); + ratatui::restore(); + ExitCode::from(code) +} + +/// The event loop: draw, read input, drain worker replies, tick the poll. Returns +/// the exit code. A `Fatal` phase is shown until a keypress, then exits. +fn run(mut terminal: DefaultTerminal, transport: &Transport) -> u8 { + let mut model = Model::new(); + let mut last_tick = Instant::now(); + + loop { + if terminal.draw(|f| ui::render(f, &model)).is_err() { + return EXIT_FATAL; + } + + if let Phase::Fatal(err) = model.phase() { + let code = fatal_code(err); + wait_for_key(); + return code; + } + + let timeout = POLL.saturating_sub(last_tick.elapsed()); + match event::poll(timeout) { + Ok(true) => { + if let Ok(Event::Key(key)) = event::read() + && key.kind == KeyEventKind::Press + && let Some(msg) = map_key(&key, model.phase()) + && let Some(req) = model.update(msg) + { + transport.send(req); + } + } + Ok(false) => {} + Err(_) => return EXIT_FATAL, + } + + // Drain everything the worker has answered since the last pass. + while let Some(reply) = transport.try_recv() { + if let Some(req) = model.update(Msg::Reply(reply)) { + transport.send(req); + } + } + + if last_tick.elapsed() >= POLL { + if let Some(req) = model.update(Msg::Tick) { + transport.send(req); + } + last_tick = Instant::now(); + } + + if model.should_quit() { + return EXIT_ABORTED; + } + } +} + +/// Block until the next key press (so a human can read a fatal message). +fn wait_for_key() { + loop { + if let Ok(Event::Key(k)) = event::read() + && k.kind == KeyEventKind::Press + { + break; + } + } +} + +fn fatal_code(err: &TransportError) -> u8 { + match err { + TransportError::UnsupportedProto(_) => EXIT_UPGRADE, + _ => EXIT_FATAL, + } +} + +/// Map a key press to a message, given the current phase. Returns `None` for keys +/// with no meaning in that phase. +fn map_key(key: &KeyEvent, phase: &Phase) -> Option { + // Ctrl-C always quits. + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + return Some(Msg::Quit); + } + match phase { + // Waiting for the handshake / showing a fatal reason: no interactive keys + // (fatal is handled by `wait_for_key`). + Phase::Connecting | Phase::Fatal(_) => None, + Phase::Authing { .. } => match key.code { + KeyCode::Char(c) if c.is_ascii_digit() => Some(Msg::PinDigit(c)), + KeyCode::Backspace => Some(Msg::PinBackspace), + KeyCode::Enter => Some(Msg::PinSubmit), + KeyCode::Esc => Some(Msg::Quit), + _ => None, + }, + Phase::Watching { .. } => match key.code { + KeyCode::Up | KeyCode::Char('k') => Some(Msg::MoveUp), + KeyCode::Down | KeyCode::Char('j') => Some(Msg::MoveDown), + KeyCode::Enter => Some(Msg::Open), + KeyCode::Esc | KeyCode::Backspace => Some(Msg::Back), + KeyCode::Char('q') => Some(Msg::Quit), + _ => None, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustok_console::app::Pin; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn authing() -> Phase { + Phase::Authing { + pin: Pin::default(), + error: None, + } + } + + fn watching() -> Phase { + Phase::Watching { + items: vec![], + selected: 0, + card: None, + note: None, + } + } + + #[test] + fn ctrl_c_quits_from_any_phase() { + let k = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + assert!(matches!(map_key(&k, &Phase::Connecting), Some(Msg::Quit))); + assert!(matches!(map_key(&k, &authing()), Some(Msg::Quit))); + assert!(matches!(map_key(&k, &watching()), Some(Msg::Quit))); + } + + #[test] + fn auth_phase_maps_digits_backspace_enter() { + assert!(matches!( + map_key(&key(KeyCode::Char('7')), &authing()), + Some(Msg::PinDigit('7')) + )); + assert!(matches!( + map_key(&key(KeyCode::Backspace), &authing()), + Some(Msg::PinBackspace) + )); + assert!(matches!( + map_key(&key(KeyCode::Enter), &authing()), + Some(Msg::PinSubmit) + )); + // a non-digit letter is ignored on the PIN screen + assert!(map_key(&key(KeyCode::Char('a')), &authing()).is_none()); + } + + #[test] + fn watch_phase_maps_navigation_and_actions() { + assert!(matches!( + map_key(&key(KeyCode::Up), &watching()), + Some(Msg::MoveUp) + )); + assert!(matches!( + map_key(&key(KeyCode::Down), &watching()), + Some(Msg::MoveDown) + )); + assert!(matches!( + map_key(&key(KeyCode::Enter), &watching()), + Some(Msg::Open) + )); + assert!(matches!( + map_key(&key(KeyCode::Esc), &watching()), + Some(Msg::Back) + )); + assert!(matches!( + map_key(&key(KeyCode::Char('q')), &watching()), + Some(Msg::Quit) + )); + } + + #[test] + fn connecting_and_fatal_ignore_ordinary_keys() { + assert!(map_key(&key(KeyCode::Enter), &Phase::Connecting).is_none()); + assert!(map_key(&key(KeyCode::Char('x')), &Phase::Connecting).is_none()); + assert!( + map_key( + &key(KeyCode::Enter), + &Phase::Fatal(TransportError::ConnectionLost) + ) + .is_none() + ); + } + + #[test] + fn fatal_code_distinguishes_upgrade_from_the_rest() { + assert_eq!( + fatal_code(&TransportError::UnsupportedProto(vec![2])), + EXIT_UPGRADE + ); + assert_eq!(fatal_code(&TransportError::NotConnected), EXIT_FATAL); + assert_eq!(fatal_code(&TransportError::ConnectionLost), EXIT_FATAL); + assert_eq!( + fatal_code(&TransportError::Protocol("x".to_owned())), + EXIT_FATAL + ); + } } From 5e118802f84ec3887029667234655ef46f61cab6 Mon Sep 17 00:00:00 2001 From: temrjan Date: Thu, 9 Jul 2026 13:30:24 +0500 Subject: [PATCH 7/7] =?UTF-8?q?fix(console):=20Gate-2=20=E2=80=94=20UI?= =?UTF-8?q?=E2=86=92stderr,=20PIN=20capacity,=20Drop=20shutdown=20+=20mino?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three blockers (all in this branch's new code): - B1 main.rs: render to STDERR, not stdout (invariant #7). ratatui::init uses stdout; replaced with hand-rolled try_init/restore/set_panic_hook on a CrosstermBackend. enable/disable_raw_mode open /dev/tty directly, so only the alternate-screen escape + backend target stderr. run() unchanged (backend-agnostic via &mut Frame). Keeps stdout clean for C-PR-1b's decision. - B2 app.rs Pin::auth_line: reserved 21 bytes for a 22+len line → String realloc freed the old buffer (with the PIN) WITHOUT zeroizing. Now reserves PREFIX.len()+len+SUFFIX.len() via named constants (format changes stay correct). Test asserts capacity == len (no realloc) for pin_len 1..=12. - B3 transport.rs Transport::drop could hang forever: closing the request channel does not interrupt a worker parked in read_line, so join() blocked on a stalled server (Ctrl-C during a long wait → SIGKILL needed). Connect is now synchronous so a stream clone is kept; Drop shutdown(Both)s it to unblock the read before join. Test proves drop returns (recv_timeout) against a stall server. Minors folded in: render_detail wraps (never truncates a card value); impl Error for TransportError; drop unused crossterm/insta deps (all via ratatui::crossterm; no insta snapshots); verbatim render tests now assert label+value on one line (catches a field swap). Nits: pop-on-empty + nav-edge tests. Gates green: fmt/clippy-0/test 52/deny. --- Cargo.lock | 56 ---------------------- Cargo.toml | 11 ++--- src/app.rs | 58 +++++++++++++++++++++-- src/main.rs | 49 +++++++++++++++++-- src/transport.rs | 119 +++++++++++++++++++++++++++++++++++++++++------ src/ui.rs | 83 ++++++++++++++++++++++++--------- 6 files changed, 271 insertions(+), 105 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b102a00..61513fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,17 +142,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "console" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" -dependencies = [ - "encode_unicode", - "libc", - "windows-sys", -] - [[package]] name = "convert_case" version = "0.10.0" @@ -317,12 +306,6 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "equivalent" version = "1.0.2" @@ -364,12 +347,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - [[package]] name = "filedescriptor" version = "0.8.3" @@ -511,18 +488,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "insta" -version = "1.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" -dependencies = [ - "console", - "once_cell", - "similar", - "tempfile", -] - [[package]] name = "instability" version = "0.3.12" @@ -1133,8 +1098,6 @@ dependencies = [ name = "rustok-console" version = "0.0.1" dependencies = [ - "crossterm", - "insta", "ratatui", "serde", "serde_json", @@ -1250,12 +1213,6 @@ dependencies = [ "libc", ] -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - [[package]] name = "siphasher" version = "1.0.3" @@ -1329,19 +1286,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys", -] - [[package]] name = "termina" version = "0.3.3" diff --git a/Cargo.toml b/Cargo.toml index b7a4a48..a586464 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,20 +9,15 @@ repository = "https://github.com/rustok-org/console" [dependencies] # TUI: immediate-mode render + its own event loop (crossterm::event::poll), no async -# runtime — per AGENTS.md. crossterm is pinned to the version ratatui 0.30 re-exports -# so the event API and the backend agree. +# runtime — per AGENTS.md. crossterm is used through ratatui's re-export +# (ratatui::crossterm::…), so it is not a direct dependency; ratatui pins the +# matching version. ratatui = "0.30" -crossterm = "0.29" # Wire protocol (JSON Lines) — serde types mirror docs/APPROVER-PROTOCOL.md. serde = { version = "1", features = ["derive"] } serde_json = "1" # PIN hygiene: the PIN and its serialized auth request live in Zeroizing buffers. zeroize = "1" -[dev-dependencies] -# Snapshot assertions for TUI render tests (TestBackend), where a snapshot reads -# clearer than field-by-field checks. -insta = "1" - [lints.rust] unsafe_code = "forbid" diff --git a/src/app.rs b/src/app.rs index 63620f0..2d611ef 100644 --- a/src/app.rs +++ b/src/app.rs @@ -63,10 +63,17 @@ impl Pin { /// so no JSON escaping is needed. #[must_use] pub fn auth_line(&self) -> Zeroizing { - let mut line = Zeroizing::new(String::with_capacity(21 + self.0.len())); - line.push_str(r#"{"op":"auth","pin":""#); + const PREFIX: &str = r#"{"op":"auth","pin":""#; + const SUFFIX: &str = r#""}"#; + // Reserve exactly so the buffer never reallocates — a realloc would free + // the old allocation (with the PIN in it) WITHOUT zeroizing. Named + // constants keep the reservation correct if the format ever changes. + let mut line = Zeroizing::new(String::with_capacity( + PREFIX.len() + self.0.len() + SUFFIX.len(), + )); + line.push_str(PREFIX); line.push_str(&self.0); - line.push_str(r#""}"#); + line.push_str(SUFFIX); line } } @@ -486,6 +493,24 @@ mod tests { assert_eq!(&*line, r#"{"op":"auth","pin":"123"}"#); } + #[test] + fn pin_auth_line_reserves_exactly_so_the_buffer_never_reallocates() { + // A realloc frees the old buffer (with the PIN) WITHOUT zeroizing it. The + // reserved capacity must fit the assembled line exactly, for any PIN length. + for n in 1..=12 { + let mut pin = Pin::default(); + for _ in 0..n { + pin.push('9'); + } + let line = pin.auth_line(); + assert_eq!( + line.capacity(), + line.len(), + "auth_line must reserve exactly (no realloc) for pin_len {n}" + ); + } + } + // ── one request in flight ── #[test] @@ -621,4 +646,31 @@ mod tests { Phase::Fatal(TransportError::ConnectionLost) )); } + + #[test] + fn pin_pop_on_empty_is_safe() { + let mut pin = Pin::default(); + pin.pop(); // must not panic on an empty PIN + assert!(pin.is_empty()); + } + + #[test] + fn navigation_saturates_at_the_edges() { + let mut m = watching(vec![summary("a"), summary("b")]); + m.update(Msg::MoveUp); // already at the top → stays at 0 + let Phase::Watching { selected, .. } = m.phase() else { + panic!("watching"); + }; + assert_eq!(*selected, 0); + for _ in 0..5 { + m.update(Msg::MoveDown); // past the end → clamps to the last row + } + let Phase::Watching { + selected, items, .. + } = m.phase() + else { + panic!("watching"); + }; + assert_eq!(*selected, items.len() - 1); + } } diff --git a/src/main.rs b/src/main.rs index 670c0d5..83eac64 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,16 +6,27 @@ //! C-PR-1b. UI goes to stderr (ratatui's alternate screen); the exit code carries //! the outcome. +use std::io::{self, Stderr}; use std::process::ExitCode; use std::time::{Duration, Instant}; -use ratatui::DefaultTerminal; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::crossterm::execute; +use ratatui::crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; use rustok_console::app::{Model, Msg, Phase}; use rustok_console::transport::{Transport, TransportError}; use rustok_console::ui; +/// The console's terminal — rendered to **stderr**, so stdout stays clean for a +/// machine-readable decision (invariant #7; the approval verdict lands on stdout +/// in C-PR-1b). `ratatui::init` would use stdout, so the setup is done by hand. +type Tui = Terminal>; + /// Default approver socket path (overridable for tests / non-standard layouts). const DEFAULT_SOCKET: &str = "/run/wallet/approve.sock"; /// How often the watch screen polls `list`. @@ -33,20 +44,50 @@ fn main() -> ExitCode { // No TTY → view-only is not possible for an interactive approver; fail clearly // rather than half-render. (The full no-TTY view-only gate is C-PR-1b.) - let Ok(terminal) = ratatui::try_init() else { + let Ok(terminal) = try_init() else { eprintln!("rustok-console needs an interactive terminal (a TTY)."); return ExitCode::from(EXIT_NO_TTY); }; let transport = Transport::connect(&path); let code = run(terminal, &transport); - ratatui::restore(); + let _ = restore(); ExitCode::from(code) } +/// Set up the terminal on **stderr** (raw mode + alternate screen) and install a +/// panic hook that restores it. Mirrors `ratatui::init`, but targets stderr so +/// stdout stays clean. `enable_raw_mode` opens `/dev/tty` directly, so only the +/// alternate-screen escape and the backend are pointed at stderr. +/// +/// # Errors +/// Propagates the raw-mode / alternate-screen error — notably when there is no TTY. +fn try_init() -> io::Result { + set_panic_hook(); + enable_raw_mode()?; + let mut stderr = io::stderr(); + execute!(stderr, EnterAlternateScreen)?; + Terminal::new(CrosstermBackend::new(stderr)) +} + +/// Restore the terminal: leave the alternate screen, disable raw mode. +fn restore() -> io::Result<()> { + execute!(io::stderr(), LeaveAlternateScreen)?; + disable_raw_mode() +} + +/// Restore the terminal before a panic prints, so the message stays readable. +fn set_panic_hook() { + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = restore(); + hook(info); + })); +} + /// The event loop: draw, read input, drain worker replies, tick the poll. Returns /// the exit code. A `Fatal` phase is shown until a keypress, then exits. -fn run(mut terminal: DefaultTerminal, transport: &Transport) -> u8 { +fn run(mut terminal: Tui, transport: &Transport) -> u8 { let mut model = Model::new(); let mut last_tick = Instant::now(); diff --git a/src/transport.rs b/src/transport.rs index 94aca04..2940aed 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -97,28 +97,49 @@ impl std::fmt::Display for TransportError { } } +impl std::error::Error for TransportError {} + /// The MVU layer's handle to the worker: send [`Request`]s, receive [`Reply`]s. -/// Dropping it closes the request channel, which ends the worker. +/// Dropping it ends the worker (see [`Transport::drop`]). pub struct Transport { req_tx: Sender, reply_rx: Receiver, worker: Option>, + /// A clone of the socket kept solely so `Drop` can `shutdown` it and unblock a + /// worker parked in a blocking read. `None` if the connect failed. + shutdown: Option, } impl Transport { /// Connect to the approver socket at `path` and start the worker. The worker /// performs the `hello` handshake immediately; its first [`Reply`] is either - /// [`Reply::Hello`] or a [`Reply::Fatal`]. Non-blocking: returns at once. + /// [`Reply::Hello`] or a [`Reply::Fatal`]. The connect itself is synchronous — + /// a local UNIX connect does not block on a handshake — so a clone of the + /// stream can be kept for [`Transport::drop`] to shut down; everything after + /// (handshake, requests) runs on the worker thread. #[must_use] pub fn connect(path: impl AsRef) -> Self { - let path = path.as_ref().to_path_buf(); let (req_tx, req_rx) = channel::(); let (reply_tx, reply_rx) = channel::(); - let worker = std::thread::spawn(move || worker_loop(&path, &req_rx, &reply_tx)); + + let (worker, shutdown) = match UnixStream::connect(path.as_ref()) { + Ok(stream) => { + // Closing the request channel does NOT interrupt a blocking + // read_line in the worker; a shutdown on this clone does. + let shutdown = stream.try_clone().ok(); + let worker = std::thread::spawn(move || worker_loop(stream, &req_rx, &reply_tx)); + (Some(worker), shutdown) + } + Err(_) => { + let _ = reply_tx.send(Reply::Fatal(TransportError::NotConnected)); + (None, None) + } + }; Self { req_tx, reply_rx, - worker: Some(worker), + worker, + shutdown, } } @@ -143,9 +164,15 @@ impl Transport { impl Drop for Transport { fn drop(&mut self) { - // Closing the request channel ends the worker's `recv` loop; join so the - // thread (and its socket) is gone before we return. + // Close the request channel (ends the worker's idle `recv`) AND shut the + // socket down. Closing the channel alone does NOT interrupt a worker parked + // in a blocking read — a stalled server would then hang `join` forever; the + // shutdown unblocks that read. Then join so the thread and its socket are + // gone before we return. drop(std::mem::replace(&mut self.req_tx, channel().0)); + if let Some(stream) = &self.shutdown { + let _ = stream.shutdown(std::net::Shutdown::Both); + } if let Some(worker) = self.worker.take() { let _ = worker.join(); } @@ -154,13 +181,9 @@ impl Drop for Transport { /// The worker body: connect, handshake, then serve one request at a time until /// the request channel closes or the connection fails. -fn worker_loop(path: &Path, req_rx: &Receiver, reply_tx: &Sender) { - let Ok(stream) = UnixStream::connect(path) else { - let _ = reply_tx.send(Reply::Fatal(TransportError::NotConnected)); - return; - }; +fn worker_loop(stream: UnixStream, req_rx: &Receiver, reply_tx: &Sender) { let Ok(write_half) = stream.try_clone() else { - let _ = reply_tx.send(Reply::Fatal(TransportError::NotConnected)); + let _ = reply_tx.send(Reply::Fatal(TransportError::ConnectionLost)); return; }; let mut writer = write_half; @@ -480,4 +503,74 @@ mod tests { Some(Reply::Fatal(TransportError::Protocol(_))) )); } + + /// Accepts and reads the hello, signals `ready`, then blocks on a second read — + /// holding the connection open without ever replying. Only a client-side + /// shutdown ends it. + struct StallServer { + path: PathBuf, + handle: Option>, + ready: Receiver<()>, + } + + impl StallServer { + fn start(tag: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "rustok_console_t1a_{}_{tag}.sock", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path).expect("bind stall socket"); + let (ready_tx, ready) = channel(); + let handle = std::thread::spawn(move || { + let (stream, _) = listener.accept().expect("accept"); + let mut reader = BufReader::new(stream); + let mut line = String::new(); + let _ = reader.read_line(&mut line); // read the hello request + let _ = ready_tx.send(()); // the worker is now parked on the reply read + let mut sink = String::new(); + let _ = reader.read_line(&mut sink); // block until the client shuts down + }); + Self { + path, + handle: Some(handle), + ready, + } + } + + fn wait_ready(&self) { + let _ = self.ready.recv(); + } + } + + impl Drop for StallServer { + fn drop(&mut self) { + if let Some(h) = self.handle.take() { + let _ = h.join(); + } + let _ = std::fs::remove_file(&self.path); + } + } + + #[test] + fn drop_does_not_hang_when_the_server_stalls() { + // The server reads the hello but never replies, so the worker is parked in + // a blocking read. Dropping the Transport must shut the socket down and + // return — not hang on join. Red without the Drop shutdown (times out). + let server = StallServer::start("stall"); + let t = Transport::connect(&server.path); + server.wait_ready(); + + let (done_tx, done_rx) = channel(); + std::thread::spawn(move || { + drop(t); + let _ = done_tx.send(()); + }); + assert!( + done_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .is_ok(), + "Transport::drop must shut the socket down and return, not hang" + ); + } } diff --git a/src/ui.rs b/src/ui.rs index 8b82ba8..7e6d15a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,7 +7,7 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Layout}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph}; +use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph, Wrap}; use crate::app::{AuthError, Model, Phase}; use crate::protocol::{Card, Summary}; @@ -171,7 +171,15 @@ fn render_detail(frame: &mut Frame, card: Option<&Card>, area: ratatui::layout:: } } } - frame.render_widget(Paragraph::new(lines).block(block), area); + // Wrap (never truncate): a clear-signing card must show the whole value — + // a silently clipped raw_data or address would be the one lie this screen + // exists to prevent. trim: false keeps the exact bytes, including leading space. + frame.render_widget( + Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }), + area, + ); } fn kv(key: &str, value: &str) -> Line<'static> { @@ -193,21 +201,31 @@ mod tests { use ratatui::Terminal; use ratatui::backend::TestBackend; - /// Render the model into a fixed grid and flatten the buffer to a String for - /// substring assertions. - fn draw(model: &Model, w: u16, h: u16) -> String { + /// Render into a fixed grid, returning the screen as rows. Row-level checks + /// catch a field rendered under the WRONG label (a swap) — which a + /// whole-screen substring check would miss. + fn draw_rows(model: &Model, w: u16, h: u16) -> Vec { let backend = TestBackend::new(w, h); let mut terminal = Terminal::new(backend).unwrap(); terminal.draw(|f| render(f, model)).unwrap(); - terminal - .backend() - .buffer() - .content() - .iter() - .map(ratatui::buffer::Cell::symbol) + let buffer = terminal.backend().buffer(); + (0..h) + .map(|y| (0..w).map(|x| buffer[(x, y)].symbol()).collect::()) .collect() } + /// Flatten to one String for checks that do not care about layout. + fn draw(model: &Model, w: u16, h: u16) -> String { + draw_rows(model, w, h).join("\n") + } + + /// True if some rendered line contains all `fragments` — a label+value + /// adjacency check, so a swapped field is caught. + fn has_line_with(rows: &[String], fragments: &[&str]) -> bool { + rows.iter() + .any(|row| fragments.iter().all(|f| row.contains(f))) + } + fn summary(id: &str, to: &str, amount: &str, high_risk: bool) -> Summary { Summary { id: id.to_owned(), @@ -266,10 +284,19 @@ mod tests { false, )], ); - let screen = draw(&m, 90, 20); - // the address and the decimal wei render exactly as received (verbatim). - assert!(screen.contains("0x742d35Cc6634C0532925a3b844Bc454e4438f44e")); - assert!(screen.contains("100000000000000000")); + let rows = draw_rows(&m, 90, 20); + // Address AND decimal wei on the SAME line, verbatim — a swap would split + // them across lines. + assert!( + has_line_with( + &rows, + &[ + "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + "100000000000000000", + ], + ), + "address and amount must render together, exactly as received" + ); } #[test] @@ -302,13 +329,27 @@ mod tests { m.update(Msg::Reply(Reply::Get(crate::protocol::GetOutcome::Card( boxed, )))); - let screen = draw(&m, 100, 24); - assert!(screen.contains("approve"), "method rendered"); - assert!(screen.contains("0xdeadbeef"), "spender verbatim"); + let rows = draw_rows(&m, 100, 24); + // Each value under its OWN label on one line — catches a field swap (e.g. + // spender rendered where `to` should be). + assert!( + has_line_with(&rows, &["method", "approve"]), + "method under its label" + ); + assert!( + has_line_with(&rows, &["spender", "0xdeadbeef"]), + "spender under its label (not swapped into another field)" + ); // the 0x-hex amount is shown as received, not converted to a number - assert!(screen.contains("0xffffffffffffffff"), "hex amount verbatim"); - assert!(screen.contains("0x095ea7b3deadbeef"), "raw_data verbatim"); - assert!(screen.contains("UNLIMITED")); + assert!( + has_line_with(&rows, &["amount", "0xffffffffffffffff"]), + "hex amount verbatim under its label" + ); + assert!( + has_line_with(&rows, &["raw_data", "0x095ea7b3deadbeef"]), + "raw_data verbatim under its label" + ); + assert!(rows.iter().any(|r| r.contains("UNLIMITED"))); } #[test]