diff --git a/.gitignore b/.gitignore index 7577132..86f8aff 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ kata-static-*.tar.zst /demo/netdebug/netdebug /demo/opencode/opencode .bunkerbox/ +demo/crush/crush diff --git a/Cargo.lock b/Cargo.lock index f6277ae..2250c68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,21 @@ dependencies = [ "subtle", ] +[[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 = "anstream" version = "1.0.0" @@ -87,6 +102,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[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" @@ -99,11 +144,35 @@ 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.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -114,6 +183,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bunkerbox" version = "0.3.0" @@ -122,11 +197,13 @@ dependencies = [ "base64", "clap", "colored", + "crossterm 0.28.1", "glob", "libc", "pbkdf2", "rand", - "rpassword", + "ratatui", + "ratatui-glamour", "serde", "serde_json", "serde_yaml", @@ -134,14 +211,36 @@ dependencies = [ "tempfile", "tokio", "tokio-vsock", + "vt100", ] +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[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" @@ -166,18 +265,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" dependencies = [ "anstream", "anstyle", @@ -206,6 +305,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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", + "serde", + "static_assertions", +] + +[[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" @@ -215,6 +338,57 @@ 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.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "serde", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "serde", + "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" @@ -226,6 +400,16 @@ dependencies = [ "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 = "ctr" version = "0.9.2" @@ -235,6 +419,74 @@ dependencies = [ "cipher", ] +[[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.119", +] + +[[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.119", +] + +[[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.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -246,6 +498,21 @@ dependencies = [ "subtle", ] +[[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.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + [[package]] name = "equivalent" version = "1.0.2" @@ -262,11 +529,71 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[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 = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "futures" @@ -324,7 +651,7 @@ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -377,6 +704,18 @@ dependencies = [ "wasi", ] +[[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" @@ -385,7 +724,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -400,15 +739,43 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +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 = "hmac" @@ -419,6 +786,12 @@ dependencies = [ "digest", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.14.0" @@ -426,7 +799,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", ] [[package]] @@ -438,12 +820,34 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[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" @@ -451,56 +855,228 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "libc" -version = "0.2.186" +name = "js-sys" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "kasuari" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.19", +] [[package]] -name = "memchr" -version = "2.8.3" +name = "lab" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" [[package]] -name = "memoffset" -version = "0.9.1" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "mio" -version = "1.2.2" +name = "libc" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] -name = "nix" -version = "0.31.3" +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.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +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 0.29.0", + "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "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.119", +] + +[[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 = "numtoa" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" + [[package]] name = "once_cell" version = "1.21.4" @@ -519,6 +1095,62 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[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.119", +] + +[[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 = "pbkdf2" version = "0.12.2" @@ -529,6 +1161,100 @@ dependencies = [ "hmac", ] +[[package]] +name = "pest" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +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.119", +] + +[[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" @@ -547,6 +1273,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -558,22 +1296,28 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" 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" @@ -611,23 +1355,187 @@ dependencies = [ ] [[package]] -name = "rpassword" -version = "7.5.4" +name = "ratatui" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ - "libc", - "rtoolbox", - "windows-sys 0.61.2", + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termion", + "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.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.19", + "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 0.28.1", + "crossterm 0.29.0", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-glamour" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839e8afaa7fa994cceed3814118e22fa56fbc62be71db1506b8d88825a039691" +dependencies = [ + "crossterm 0.28.1", + "ratatui", + "unicode-width", +] + +[[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-termion" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87c732202fa5a71a9da0991013f0853e53f87048f45198e3a1ca3ee722accc2f" +dependencies = [ + "instability", + "ratatui-core", + "termion", +] + +[[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 = "rtoolbox" -version = "0.0.5" +name = "ratatui-widgets" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ + "bitflags 2.13.1", + "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.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +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 = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", "libc", + "linux-raw-sys 0.4.15", "windows-sys 0.59.0", ] @@ -637,24 +1545,42 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] +[[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" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -662,29 +1588,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -717,6 +1643,27 @@ dependencies = [ "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" @@ -727,12 +1674,24 @@ dependencies = [ "libc", ] +[[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 = "socket2" version = "0.6.5" @@ -743,12 +1702,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -757,9 +1743,31 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.118" +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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -775,15 +1783,164 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix 1.1.4", + "signal-hook", "windows-sys 0.61.2", ] +[[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 = "termion" +version = "4.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44138a9ae08f0f502f24104d82517ef4da7330c35acd638f1f29d3cd5475ecb" +dependencies = [ + "libc", + "numtoa", + "serde", +] + +[[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.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "serde", + "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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[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.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +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 = "tokio" -version = "1.53.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -797,13 +1954,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -825,12 +1982,41 @@ 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 = "universal-hash" version = "0.5.1" @@ -853,6 +2039,19 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -866,7 +2065,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba782755fc073877e567c2253c0be48e4aa9a254c232d36d3985dfae0bd5205" dependencies = [ "libc", - "nix", + "nix 0.31.3", +] + +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", ] [[package]] @@ -875,6 +2104,156 @@ 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.119", + "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", + "serde", + "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", + "serde", + "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" @@ -963,24 +2342,30 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "zerocopy" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c40994a..c380730 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,13 +12,16 @@ glob = "0.3" libc = "0.2" pbkdf2 = "0.12" rand = "0.8" -rpassword = "7" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" sha2 = "0.10" tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "sync", "io-util", "process"] } tokio-vsock = "0.7" +ratatui = "0.30" +crossterm = "0.28" +vt100 = "0.16" +ratatui-glamour = "0.3" [dev-dependencies] tempfile = "3" @@ -41,3 +44,7 @@ path = "src/main.rs" [[bin]] name = "bunkerbox-vscomm" path = "src/bin/bunkerbox-vscomm.rs" + +[[bin]] +name = "bunkerbox-status" +path = "src/bin/bunkerbox-status.rs" diff --git a/Makefile b/Makefile index e2f0604..8936b6a 100644 --- a/Makefile +++ b/Makefile @@ -42,12 +42,26 @@ ensure-toolchain: rustup target add $(VSCOMM_TARGET) dev: ensure-toolchain - cargo build + cargo build --bin bunkerbox --bin bunkerbox-image cargo build --bin bunkerbox-vscomm --target $(VSCOMM_TARGET) + cargo build --bin bunkerbox-status --target $(VSCOMM_TARGET) + rm -rf target/dist + mkdir -p target/dist + cp target/debug/bunkerbox target/dist/ + cp target/debug/bunkerbox-image target/dist/ + cp target/$(VSCOMM_TARGET)/debug/bunkerbox-vscomm target/dist/ + cp target/$(VSCOMM_TARGET)/debug/bunkerbox-status target/dist/ release: ensure-toolchain - cargo build --release + cargo build --bin bunkerbox --bin bunkerbox-image --release cargo build --bin bunkerbox-vscomm --target $(VSCOMM_TARGET) --release + cargo build --bin bunkerbox-status --target $(VSCOMM_TARGET) --release + rm -rf target/dist + mkdir -p target/dist + cp target/release/bunkerbox target/dist/ + cp target/release/bunkerbox-image target/dist/ + cp target/$(VSCOMM_TARGET)/release/bunkerbox-vscomm target/dist/ + cp target/$(VSCOMM_TARGET)/release/bunkerbox-status target/dist/ check: cargo fmt --all @@ -64,6 +78,7 @@ setup: dev musl-vscomm: ensure-toolchain cargo build --bin bunkerbox-vscomm --target $(VSCOMM_TARGET) + cargo build --bin bunkerbox-status --target $(VSCOMM_TARGET) image: dev @if [ -z "$(IMAGE)" ]; then echo "usage: make image IMAGE=images/name.conf" >&2; exit 1; fi diff --git a/README.md b/README.md index cbb6f41..5fcef15 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,11 @@ # Bunkerbox -[![CI](https://github.com/anomalyco/bunkerbox/actions/workflows/ci.yml/badge.svg)](https://github.com/anomalyco/bunkerbox/actions/workflows/ci.yml) +[![CI](https://github.com/tinythings/bunkerbox/actions/workflows/ci.yml/badge.svg)](https://github.com/tinythings/bunkerbox/actions/workflows/ci.yml) [![Rust](https://img.shields.io/badge/rust-1.97.1-orange)](https://www.rust-lang.org) -[![License](https://img.shields.io/github/license/anomalyco/bunkerbox)](https://github.com/anomalyco/bunkerbox/blob/main/LICENSE) +[![License](https://img.shields.io/github/license/tinythings/bunkerbox)](https://github.com/tinythings/bunkerbox/blob/main/LICENSE) +[![Release](https://img.shields.io/github/v/release/tinythings/bunkerbox)](https://github.com/tinythings/bunkerbox/releases) +[![Docs](https://github.com/tinythings/bunkerbox/actions/workflows/docs.yml/badge.svg)](https://github.com/tinythings/bunkerbox/actions/workflows/docs.yml) **Run powerful development agents without handing them your whole machine.** diff --git a/demo/crush/crush b/demo/crush/crush deleted file mode 120000 index 7744ad2..0000000 --- a/demo/crush/crush +++ /dev/null @@ -1 +0,0 @@ -/home/boma6672/work/bunker-project/bunkerbox/target/debug/bunkerbox \ No newline at end of file diff --git a/demo/netdebug/run-demo.sh b/demo/netdebug/run-demo.sh index 88d5298..c32288c 100755 --- a/demo/netdebug/run-demo.sh +++ b/demo/netdebug/run-demo.sh @@ -113,6 +113,9 @@ files: echo echo "Interactive shell." + echo + echo "To test TUI rendering: btop or htop" + echo "To test VSOCK: bunkerbox-status popup info \"\" \"hello from vm\"" exec /bin/sh containerfile: | @@ -120,17 +123,30 @@ containerfile: | RUN apk add --no-cache \ bind-tools \ + btop \ busybox-extras \ ca-certificates \ curl \ + htop \ iproute2 \ iputils \ netcat-openbsd \ + musl-locales \ openssl + ENV LANG=C.UTF-8 + + RUN mkdir -p /workspace /usr/local/bunkerbox/bin \ + && chmod 0777 /workspace + COPY debug.sh /usr/local/bin/debug.sh RUN chmod 0755 /usr/local/bin/debug.sh + COPY bunkerbox-vscomm /usr/local/bunkerbox/bin/bunkerbox-vscomm + COPY bunkerbox-status /usr/local/bunkerbox/bin/bunkerbox-status + + ENV PATH="/usr/local/bunkerbox/bin:$PATH" + WORKDIR /workspace ENTRYPOINT ["/usr/local/bin/debug.sh"] EOF_IMAGE @@ -141,7 +157,6 @@ cat > "$RUNTIME_CONF" <.conf` next to the OCI archive. The conf includes `oci` (the archive path) and `image` (the image tag) merged with everything from the `runtime:` section. - -For the OpenCode config above, the builder would produce: +The builder writes a `.conf` file next to the archive, merging your `runtime:` settings with the archive path and image tag. For the OpenCode example above, you get: -```text +``` bunkerbox-opencode-1.17.18.oci opencode.conf ``` -The packager installs both files. The `opencode.conf` is ready to use — no hand-editing needed. +Both files go into the package install. No hand-editing needed. See [Runtime config](runtime.md) for all options. diff --git a/docs/guides/custom-images.md b/docs/guides/custom-images.md index ad2d42c..d8cca65 100644 --- a/docs/guides/custom-images.md +++ b/docs/guides/custom-images.md @@ -1,49 +1,81 @@ -# Custom images +# Creating a custom image -A custom image config lets you package another tool for Bunkerbox. +Packaging a new tool for Bunkerbox means writing an image config. It's a YAML file that describes your tool, the container it runs in, and how it should behave at runtime. -Start by adding a config file under `images/`. The existing `images/opencode.conf` is the best example to copy from because it shows the expected shape: image name, output archive, command, hooks section, and container recipe. +## Start from an example -Build a config with: +Copy one of the existing configs from `images/`. The simplest one to start from is the kilocode config: ```sh -make image IMAGE=images/opencode.conf +cp images/kilocode.conf images/my-tool.conf ``` -For your own tool, replace the path with your new config file. +## The config structure -## What the config must do +Every image config needs these parts: -The config must define the command that should run inside the container. It must also provide a container recipe that installs the tool. +| Section | What it does | +|---|---| +| `name`, `image`, `output` | Identifies the tool and names the output archive | +| `command` | The command that runs inside the container | +| `containerfile` | The recipe that installs your tool | +| `runtime` | Settings for how the tool runs on the user's machine | -The container recipe must copy the generated Bunkerbox entrypoint into the image: +## The container recipe -```text -COPY bunker-entrypoint /usr/local/bin/bunker-entrypoint -``` +Your container recipe (`containerfile`) must install your tool and set up the Bunkerbox runtime. Here's the pattern every mature image uses: + +```dockerfile +FROM docker.io/library/alpine:3.22 + +ARG MY_TOOL_VERSION -It must then use that generated file as the entrypoint: +# Install your tool and its dependencies +RUN apk add --no-cache bash ca-certificates curl git \ + && curl -fsSL "https://example.com/my-tool-linux-musl.tar.gz" \ + -o /tmp/my-tool.tar.gz \ + && tar -xzf /tmp/my-tool.tar.gz -C /usr/local/bin \ + && chmod 0755 /usr/local/bin/my-tool-app \ + && rm -f /tmp/my-tool.tar.gz -```text +# Required Bunkerbox directories +RUN mkdir -p /workspace /home/bunkerbox /usr/local/bunkerbox/bin \ + && chmod 0777 /workspace /home/bunkerbox /usr/local/bunkerbox/bin + +# Required Bunkerbox files +COPY bunker-entrypoint /usr/local/bin/bunker-entrypoint +RUN chmod 0755 /usr/local/bin/bunker-entrypoint +COPY bunkerbox-vscomm /usr/local/bunkerbox/bin/bunkerbox-vscomm +COPY bunkerbox-status /usr/local/bunkerbox/bin/bunkerbox-status + +ENV HOME=/home/bunkerbox +WORKDIR /workspace ENTRYPOINT ["/usr/local/bin/bunker-entrypoint"] ``` -That entrypoint is what makes persistence and hooks work. +Key points: +- Use an `x86_64` musl-based image like `alpine:3.22` +- Copy `bunker-entrypoint` and set it as `ENTRYPOINT` +- Copy `bunkerbox-vscomm` and `bunkerbox-status` — they go in `/usr/local/bunkerbox/bin/` +- Create `/workspace` and `/home/bunkerbox` with write permissions -## Hooks +## Adding hooks -If the tool needs setup or cleanup, add hooks to the image config. +Hooks run shell commands at specific points during your tool's lifecycle: ```yaml hooks: before-app: | - echo "starting app" + bunkerbox-status status set 'Launching my-tool...' + bunkerbox-status popup hide "SEC_2" ``` -Hooks run inside the container, not on the host. +During startup, the status overlay shows "Launching my-tool..." and then fades after 2 seconds while your tool is already running. + +For more details on hooks and what commands you can run, see [Hooks](../config/hooks.md). -## Packaging the tool +## Packaging -After the image exists, packaging needs a runtime config and an app command. The runtime config tells Bunkerbox where the OCI archive is and how to run it. The app command is usually a symlink to the Bunkerbox binary. +After building, you'll have two files: the OCI archive and a runtime config. Both go into the system package. The runtime config tells Bunkerbox how to run your tool — workspace mode, network settings, which files to encrypt. Users invoke your tool through a symlink that points at the Bunkerbox binary. -Read [Packaging](packaging.md) for the full model. +Read [Packaging](packaging.md) for the full distribution model. diff --git a/images/crush.conf b/images/crush.conf index 92538f9..b785c24 100644 --- a/images/crush.conf +++ b/images/crush.conf @@ -10,19 +10,25 @@ build_args: hooks: # runs before persistent home is loaded - before-home-load: + before-home-load: | + bunkerbox-status status set 'Loading persistent home...' # runs before app starts - before-app: + before-app: | + bunkerbox-status status set 'Launching crush...' + bunkerbox-status popup hide --on-find-ascii # runs after app exits - after-app: + after-app: | + bunkerbox-status status set '' 'Exiting crush...' # runs only when app exits non-zero - app-error: + app-error: | + bunkerbox-status popup info 'Error' 'crush exited abnormally' # runs after persistent home is saved - after-home-save: + after-home-save: | + bunkerbox-status status set '' 'Saving home...' containerfile: | FROM docker.io/library/alpine:3.22 @@ -38,6 +44,7 @@ containerfile: | ncurses-terminfo-base \ openssh-client \ ripgrep \ + musl-locales \ && curl -fsSL \ "https://github.com/charmbracelet/crush/releases/download/v${CRUSH_VERSION}/crush_${CRUSH_VERSION}_Linux_x86_64.tar.gz" \ -o /tmp/crush.tar.gz \ @@ -46,6 +53,8 @@ containerfile: | && rm -f /tmp/crush.tar.gz \ && crush --version + ENV LANG=C.UTF-8 + RUN mkdir -p /workspace /home/bunkerbox /usr/local/bunkerbox/bin \ && chmod 0777 /workspace /home/bunkerbox /usr/local/bunkerbox/bin @@ -53,6 +62,7 @@ containerfile: | RUN chmod 0755 /usr/local/bin/bunker-entrypoint COPY bunkerbox-vscomm /usr/local/bunkerbox/bin/bunkerbox-vscomm + COPY bunkerbox-status /usr/local/bunkerbox/bin/bunkerbox-status ENV HOME=/home/bunkerbox \ XDG_CONFIG_HOME=/home/bunkerbox/.config \ diff --git a/images/kilocode.conf b/images/kilocode.conf index 0208493..c1d7356 100644 --- a/images/kilocode.conf +++ b/images/kilocode.conf @@ -10,19 +10,25 @@ build_args: hooks: # runs before persistent home is loaded - before-home-load: + before-home-load: | + bunkerbox-status status set 'Loading persistent home...' # runs before app starts - before-app: + before-app: | + bunkerbox-status spinner show 'Launching kilocode. Please wait...' + bunkerbox-status popup hide --on-find="7.4.11" # runs after app exits - after-app: + after-app: | + bunkerbox-status status set '' 'Exiting kilocode...' # runs only when app exits non-zero - app-error: + app-error: | + bunkerbox-status popup info 'Error' 'kilocode exited abnormally' # runs after persistent home is saved - after-home-save: + after-home-save: | + bunkerbox-status status set '' 'Saving home...' containerfile: | FROM docker.io/library/alpine:3.22 @@ -38,6 +44,7 @@ containerfile: | ncurses-terminfo-base \ openssh-client \ ripgrep \ + musl-locales \ && curl -fsSL \ "https://github.com/Kilo-Org/kilocode/releases/download/v${KILOCODE_VERSION}/kilo-linux-x64-baseline-musl.tar.gz" \ -o /tmp/kilocode.tar.gz \ @@ -46,6 +53,8 @@ containerfile: | && rm -f /tmp/kilocode.tar.gz \ && kilo --version + ENV LANG=C.UTF-8 + RUN mkdir -p /workspace /home/bunkerbox /usr/local/bunkerbox/bin \ && chmod 0777 /workspace /home/bunkerbox /usr/local/bunkerbox/bin @@ -54,6 +63,8 @@ containerfile: | COPY bunkerbox-vscomm /usr/local/bunkerbox/bin/bunkerbox-vscomm + COPY bunkerbox-status /usr/local/bunkerbox/bin/bunkerbox-status + ENV HOME=/home/bunkerbox \ XDG_CONFIG_HOME=/home/bunkerbox/.config \ XDG_DATA_HOME=/home/bunkerbox/.local/share \ diff --git a/images/opencode.conf b/images/opencode.conf index 50c5046..7aa9f20 100644 --- a/images/opencode.conf +++ b/images/opencode.conf @@ -10,19 +10,25 @@ build_args: hooks: # runs before persistent home is loaded - before-home-load: + before-home-load: | + bunkerbox-status status set 'Loading persistent home...' # runs before app starts - before-app: + before-app: | + bunkerbox-status status set 'Launching opencode...' + bunkerbox-status popup hide --on-find-ascii # runs after app exits - after-app: + after-app: | + bunkerbox-status status set '' 'Exiting opencode...' # runs only when app exits non-zero - app-error: + app-error: | + bunkerbox-status popup info 'Error' 'opencode exited abnormally' # runs after persistent home is saved - after-home-save: + after-home-save: | + bunkerbox-status status set '' 'Saving home...' containerfile: | FROM docker.io/library/alpine:3.22 @@ -38,6 +44,7 @@ containerfile: | ncurses-terminfo-base \ openssh-client \ ripgrep \ + musl-locales \ && curl -fsSL \ "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-x64-baseline-musl.tar.gz" \ -o /tmp/opencode.tar.gz \ @@ -46,6 +53,8 @@ containerfile: | && rm -f /tmp/opencode.tar.gz \ && opencode --version + ENV LANG=C.UTF-8 + RUN mkdir -p /workspace /home/bunkerbox /usr/local/bunkerbox/bin \ && chmod 0777 /workspace /home/bunkerbox /usr/local/bunkerbox/bin @@ -53,6 +62,7 @@ containerfile: | RUN chmod 0755 /usr/local/bin/bunker-entrypoint COPY bunkerbox-vscomm /usr/local/bunkerbox/bin/bunkerbox-vscomm + COPY bunkerbox-status /usr/local/bunkerbox/bin/bunkerbox-status ENV HOME=/home/bunkerbox \ XDG_CONFIG_HOME=/home/bunkerbox/.config \ diff --git a/src/bin/bunkerbox-image.rs b/src/bin/bunkerbox-image.rs index 50d2c07..f1a7289 100644 --- a/src/bin/bunkerbox-image.rs +++ b/src/bin/bunkerbox-image.rs @@ -16,6 +16,7 @@ struct ImageConfig { name: String, image: String, output: PathBuf, + #[serde(default)] command: Vec, #[serde(default)] overwrite: bool, @@ -127,8 +128,8 @@ fn build_image(config: &ImageConfig) -> Result<(), String> { return Err("image config name is required".to_string()); } - if config.command.is_empty() || config.command.iter().any(|part| part.trim().is_empty()) { - return Err("image config command is required".to_string()); + if !config.command.is_empty() && config.command.iter().any(|part| part.trim().is_empty()) { + return Err("image config command must not contain empty strings".to_string()); } if config.output.exists() { @@ -224,6 +225,16 @@ run_app() {{ return "$status" }} +VSCOMM_BIN="/usr/local/bunkerbox/bin" +if [ -x "$VSCOMM_BIN/bunkerbox-vscomm" ]; then + "$VSCOMM_BIN/bunkerbox-vscomm" install + export PATH="$VSCOMM_BIN:$PATH" +fi + +if ! command -v bunkerbox-status >/dev/null 2>&1; then + bunkerbox-status() {{ :; }} +fi + if [ -n "${{BUNKERBOX_PERSIST_HOME:-}}" ]; then hook_before_home_load @@ -234,12 +245,6 @@ if [ -n "${{BUNKERBOX_PERSIST_HOME:-}}" ]; then export XDG_CACHE_HOME="$HOME/.cache" fi -VSCOMM_BIN="/usr/local/bunkerbox/bin" -if [ -x "$VSCOMM_BIN/bunkerbox-vscomm" ]; then - "$VSCOMM_BIN/bunkerbox-vscomm" install - export PATH="$VSCOMM_BIN:$PATH" -fi - set +e run_app status=$? @@ -281,11 +286,16 @@ fn write_build_context(config: &ImageConfig, build_dir: &Path) -> Result<(), Str fs::copy(&vscomm_path, &dest).map_err(|err| format!("failed to copy vscomm binary {}: {err}", dest.display()))?; fs::set_permissions(&dest, fs::Permissions::from_mode(0o755)).map_err(|err| format!("failed to chmod {}: {err}", dest.display()))?; + let status_path = find_status_binary()?; + let dest = build_dir.join("bunkerbox-status"); + fs::copy(&status_path, &dest).map_err(|err| format!("failed to copy status binary {}: {err}", dest.display()))?; + fs::set_permissions(&dest, fs::Permissions::from_mode(0o755)).map_err(|err| format!("failed to chmod {}: {err}", dest.display()))?; + for file in &config.files { if file.path.is_absolute() || file.path.components().any(|part| matches!(part, std::path::Component::ParentDir)) { return Err(format!("unsafe build file path: {}", file.path.display())); } - if file.path == Path::new("bunker-entrypoint") || file.path == Path::new("bunkerbox-vscomm") { + if file.path == Path::new("bunker-entrypoint") || file.path == Path::new("bunkerbox-vscomm") || file.path == Path::new("bunkerbox-status") { return Err(format!("image config files must not override reserved file: {}", file.path.display())); } @@ -302,21 +312,28 @@ fn write_build_context(config: &ImageConfig, build_dir: &Path) -> Result<(), Str Ok(()) } -fn find_vscomm_binary() -> Result { +fn dist_dir() -> Result { let exe = env::current_exe().map_err(|e| format!("failed to locate self: {e}"))?; - let parent = exe.parent().ok_or_else(|| "failed to determine parent directory".to_string())?; - let target_dir = parent.parent().ok_or_else(|| "failed to determine target directory".to_string())?; + let target = exe.parent().and_then(|p| p.parent()).ok_or_else(|| "cannot determine target directory".to_string())?; + Ok(target.join("dist")) +} - for profile in &["release", "debug"] { - let musl_candidate = target_dir.join("x86_64-unknown-linux-musl").join(profile).join("bunkerbox-vscomm"); - if musl_candidate.is_file() { - return Ok(musl_candidate); - } +fn find_vscomm_binary() -> Result { + let path = dist_dir()?.join("bunkerbox-vscomm"); + if path.is_file() { + Ok(path) + } else { + Err("bunkerbox-vscomm not found in target/dist/. Run: make dev".into()) } +} - Err("musl-static bunkerbox-vscomm not found in target/x86_64-unknown-linux-musl/{release,debug}/\n\ - build it first: make musl-vscomm" - .to_string()) +fn find_status_binary() -> Result { + let path = dist_dir()?.join("bunkerbox-status"); + if path.is_file() { + Ok(path) + } else { + Err("bunkerbox-status not found in target/dist/. Run: make dev".into()) + } } fn podman_build(config: &ImageConfig, build_dir: &Path) -> Result<(), String> { diff --git a/src/bin/bunkerbox-status.rs b/src/bin/bunkerbox-status.rs new file mode 100644 index 0000000..f1643f9 --- /dev/null +++ b/src/bin/bunkerbox-status.rs @@ -0,0 +1,162 @@ +#[path = "../vscomm/mod.rs"] +mod vscomm; + +use std::env; +use std::io::{self, Read, Write}; +use std::mem; +use std::os::unix::io::RawFd; +use vscomm::{Frame, FrameType, STATUS_PORT}; + +const HOST_CID: u32 = 2; + +fn main() { + let result = run(); + if let Err(err) = result { + eprintln!("bunkerbox-status: {err}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let args: Vec = env::args().collect(); + let mut find_pattern: Option = None; + let mut find_ascii = false; + let mut timeout_secs: Option = None; + + let mut i = 1; + let mut positional = vec![args[0].clone()]; + while i < args.len() { + if args[i] == "--help" { + print_help(&args); + std::process::exit(0); + } else if args[i] == "--on-find" && i + 1 < args.len() { + find_pattern = Some(args[i + 1].clone()); + i += 2; + } else if let Some(p) = args[i].strip_prefix("--on-find=") { + find_pattern = Some(p.to_string()); + i += 1; + } else if args[i] == "--on-find-ascii" { + find_ascii = true; + i += 1; + } else if args[i] == "--on-timeout" && i + 1 < args.len() { + timeout_secs = args[i + 1].parse::().ok(); + i += 2; + } else if let Some(s) = args[i].strip_prefix("--on-timeout=") { + timeout_secs = s.parse::().ok(); + i += 1; + } else { + positional.push(args[i].clone()); + i += 1; + } + } + + let widget = positional.get(1).ok_or_else(|| "usage: bunkerbox-status [flags] [value]".to_string())?; + let command = positional.get(2).ok_or_else(|| "usage: bunkerbox-status [flags] [value]".to_string())?; + let pos_value = positional.get(3).map(|s| s.as_str()).unwrap_or(""); + + let options = timeout_secs.map(|s| format!("SEC_{s}")).unwrap_or_default(); + let value = if find_ascii { + "ASCII".to_string() + } else if let Some(p) = find_pattern { + p + } else { + pos_value.to_string() + }; + + if !widget.is_empty() && !command.is_empty() { + let payload = vscomm::encode_ui_payload(widget, command, &options, &value); + let frame = Frame::new(FrameType::UiCommand, payload); + + let mut stream = vsock_connect(HOST_CID, STATUS_PORT).map_err(|e| format!("vsock connect: {e}"))?; + frame.write(&mut stream).map_err(|e| format!("send: {e}"))?; + stream.flush().map_err(|e| format!("flush: {e}"))?; + } + + Ok(()) +} + +fn print_help(args: &[String]) { + let name = args.first().map(|s| s.as_str()).unwrap_or("bunkerbox-status"); + eprintln!("Usage: {name} [flags] [value]\n"); + eprintln!("Send a UI command to the bunkerbox TUI over vsock.\n"); + eprintln!("Flags:"); + eprintln!(" --on-find= Hide popup when exact text appears on screen"); + eprintln!(" --on-find-ascii Hide popup when [a-zA-Z0-9] appears on screen"); + eprintln!(" --on-timeout= Hide popup after N seconds"); + eprintln!(" --help Show this message\n"); + eprintln!("Examples:"); + eprintln!(" {name} popup hide --on-find-ascii"); + eprintln!(" {name} popup hide --on-find='$' --on-timeout=10"); + eprintln!(" {name} status set \"Running: unit tests\""); +} + +fn vsock_connect(cid: u32, port: u32) -> io::Result { + let delays = [100u64, 500, 1000]; + for (i, &ms) in delays.iter().enumerate() { + match try_connect(cid, port) { + Ok(s) => return Ok(s), + Err(e) if i < delays.len() - 1 => { + eprintln!("bunkerbox-status: vsock connect attempt {} failed: {e}", i + 1); + std::thread::sleep(std::time::Duration::from_millis(ms)); + } + Err(e) => return Err(e), + } + } + unreachable!() +} + +fn try_connect(cid: u32, port: u32) -> io::Result { + unsafe { + let fd = libc::socket(libc::AF_VSOCK, libc::SOCK_STREAM, 0); + if fd < 0 { + return Err(io::Error::last_os_error()); + } + + let addr = libc::sockaddr_vm { svm_family: libc::AF_VSOCK as u16, svm_reserved1: 0, svm_port: port, svm_cid: cid, svm_zero: [0u8; 4] }; + + let addr_ptr = &addr as *const libc::sockaddr_vm as *const libc::sockaddr; + let addr_len = mem::size_of::() as libc::socklen_t; + + if libc::connect(fd, addr_ptr, addr_len) < 0 { + let err = io::Error::last_os_error(); + libc::close(fd); + return Err(err); + } + + Ok(VsockStream { fd }) + } +} + +struct VsockStream { + fd: RawFd, +} + +impl Read for VsockStream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let ret = unsafe { libc::read(self.fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; + if ret < 0 { + return Err(io::Error::last_os_error()); + } + Ok(ret as usize) + } +} + +impl Write for VsockStream { + fn write(&mut self, buf: &[u8]) -> io::Result { + let ret = unsafe { libc::write(self.fd, buf.as_ptr() as *const libc::c_void, buf.len()) }; + if ret < 0 { + return Err(io::Error::last_os_error()); + } + Ok(ret as usize) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl Drop for VsockStream { + fn drop(&mut self) { + unsafe { libc::close(self.fd) }; + } +} diff --git a/src/bin/bunkerbox-vscomm.rs b/src/bin/bunkerbox-vscomm.rs index 49035e8..8174fcf 100644 --- a/src/bin/bunkerbox-vscomm.rs +++ b/src/bin/bunkerbox-vscomm.rs @@ -8,7 +8,7 @@ use std::mem; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use vscomm::{ExecRequest, Frame, FrameType, VSCOMM_BIN_DIR, VSOCK_PORT}; +use vscomm::{encode_ui_payload, ExecRequest, Frame, FrameType, STATUS_PORT, VSCOMM_BIN_DIR, VSOCK_PORT}; const HOST_CID: u32 = 2; @@ -34,6 +34,10 @@ fn run() -> Result<(), String> { .ok_or_else(|| "bunkerbox-vscomm must be invoked via symlink (not directly)".to_string())? .to_string(); + let label = if args.len() > 1 { format!("{} {}", invoked_as, args[1..].join(" ")) } else { invoked_as.clone() }; + + send_status(&format!("Running: {label}")); + let cwd = env::current_dir().map_err(|e| format!("cwd: {e}"))?; let env_vars: Vec<(String, String)> = env::vars().collect(); @@ -58,6 +62,7 @@ fn run() -> Result<(), String> { io::stderr().flush().map_err(|e| format!("flush stderr: {e}"))?; } FrameType::Exit => { + send_status(&format!("Done: {label}")); if response.payload.len() >= 4 { let code = i32::from_le_bytes([response.payload[0], response.payload[1], response.payload[2], response.payload[3]]); std::process::exit(code); @@ -78,7 +83,6 @@ fn install_symlinks() -> Result<(), String> { let vscomm_path = env::current_exe().map_err(|e| format!("failed to locate vscomm binary: {e}"))?; - let mut installed = 0; for entry in &entries { let cmd = extract_command_name(entry); if cmd.is_empty() { @@ -92,11 +96,6 @@ fn install_symlinks() -> Result<(), String> { let _ = fs::remove_file(&target); } std::os::unix::fs::symlink(&vscomm_path, &target).map_err(|e| format!("symlink {cmd}: {e}"))?; - installed += 1; - } - - if installed > 0 { - eprintln!("bunkerbox-vscomm: installed {installed} passthrough commands"); } Ok(()) @@ -179,6 +178,20 @@ fn command_exists_in_path_except(cmd: &str, except: &Path) -> bool { false } +fn send_status(msg: &str) { + let result = (|| -> Result<(), String> { + let payload = encode_ui_payload("status", "set", "", msg); + let frame = Frame::new(FrameType::UiCommand, payload); + let mut stream = vsock_connect(HOST_CID, STATUS_PORT).map_err(|e| format!("status connect: {e}"))?; + frame.write(&mut stream).map_err(|e| format!("status write: {e}"))?; + stream.flush().map_err(|e| format!("status flush: {e}"))?; + Ok(()) + })(); + if let Err(e) = result { + eprintln!("bunkerbox-vscomm: status: {e}"); + } +} + fn vsock_connect(cid: u32, port: u32) -> io::Result { unsafe { let fd = libc::socket(libc::AF_VSOCK, libc::SOCK_STREAM, 0); diff --git a/src/cfg.rs b/src/cfg.rs index eb17e20..89b5e9c 100644 --- a/src/cfg.rs +++ b/src/cfg.rs @@ -25,15 +25,21 @@ const DEFAULT_EXCLUDE: &[&str] = &[ const DEFAULT_SESSION_CLEANUP: &[&str] = &[ ".bunker", ".npm", + ".cache", + ".cargo/registry", + ".cargo/git", + ".rustup", ".config/kilo/node_modules", + ".config/kilo/target", ".config/opencode/node_modules", + ".config/opencode/target", ".config/crush/node_modules", + ".config/crush/target", ".local/share/kilo/log", ".local/share/opencode/log", ".local/share/crush/log", ".local/share/kilo/snapshot", ".local/share/kilo/storage/session_diff", - ".cache", ]; const MIN_QUOTA: u64 = 5 * 1024 * 1024 * 1024; @@ -91,6 +97,8 @@ pub struct RuntimeConfig { pub session_mb: Option, #[serde(default)] pub session_cleanup: Option>, + #[serde(default)] + pub command: Option>, } impl RuntimeConfig { diff --git a/src/clidef.rs b/src/clidef.rs index bd466f9..b453d84 100644 --- a/src/clidef.rs +++ b/src/clidef.rs @@ -47,6 +47,8 @@ pub fn cli(version: &'static str) -> Command { .next_help_heading("Other") .arg(Arg::new("share").long("share").help("Override bunkerbox share directory")) .arg(Arg::new("workspace").long("workspace").value_parser(["share", "clone"]).help("Override workspace mode: share or clone")) + .arg(Arg::new("verbose").long("verbose").action(ArgAction::SetTrue).help("Print log messages to stderr")) + .arg(Arg::new("log").long("log").value_name("PATH").help("Write log messages to the given file")) .arg(help_arg()) .arg(Arg::new("version").short('v').long("version").action(ArgAction::SetTrue).help("Get the current version.")) .disable_help_flag(true) diff --git a/src/kata.rs b/src/kata.rs index bdbd4a0..5803512 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -11,6 +11,8 @@ use std::collections::BTreeSet; use std::fs; use std::io::{BufRead, BufReader}; use std::net::{IpAddr, ToSocketAddrs}; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::io::RawFd; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::thread; @@ -27,8 +29,19 @@ const BRIDGE_NAME: &str = "bunkerbox0"; /// /// # Returns /// `Ok(())` on successful container execution, `Err(String)` on failure. +fn cleanup_partial_session(session_dir: Option<&PathBuf>, home_path: Option<&PathBuf>) { + if let (Some(sd), Some(hp)) = (session_dir, home_path) { + let session_img = hp.parent().expect("home has parent").join(".bunker").join("session.img"); + crate::logging::log("cleaning up partial session..."); + let _ = run_command_allow_failure("sudo", &["umount", &format!("{}", sd.display())]); + let _ = run_command_allow_failure("sudo", &["rmdir", &format!("{}", sd.display())]); + let _ = run_command_allow_failure("rm", &["-f", &format!("{}", session_img.display())]); + } +} + pub fn run( config: &RuntimeConfig, workspace: WorkspaceHandle, container_name: &str, _share_dir: &Path, app_name: &str, vsock_enabled: bool, + _status_fd: RawFd, ) -> Result<(), String> { if !config.oci.is_file() { return Err(format!("OCI archive not found: {}", config.oci.display())); @@ -38,6 +51,8 @@ pub fn run( let home_path = (config.home == Some(HomeMode::Persist)).then(|| config.home_path.clone().unwrap_or_else(|| default_home_path(app_name))); + crate::logging::log("Preparing runtime..."); + let needs_bridge = config.network == Some(NetworkMode::Bridge); let ctr_name = container_name.to_string(); let oci_path = config.oci.to_string_lossy().into_owned(); @@ -51,7 +66,11 @@ pub fn run( run_command_allow_failure("sudo", &["mkdir", "-p", &format!("{}", runtime_dir.display())])?; run_command_quiet("sudo", &["chown", &user, &format!("{}", runtime_dir.display())])?; + crate::logging::log("Starting containerd..."); + let setup_handle = std::thread::spawn(move || -> Result<(), String> { + let just_created = ensure_containerd_dropin()?; + let active = Command::new("sudo") .args(["systemctl", "is-active", "containerd"]) .stdout(Stdio::null()) @@ -61,6 +80,8 @@ pub fn run( .unwrap_or(false); if !active { run_command_quiet("sudo", &["systemctl", "start", "containerd"])?; + } else if just_created { + run_command_quiet("sudo", &["systemctl", "restart", "containerd"])?; } if needs_bridge { @@ -88,6 +109,7 @@ pub fn run( if !passphrase.is_empty() { if let Some(ref hp) = home_path { if hp.is_dir() { + crate::logging::log("Unsealing home..."); unseal_home(hp, &passphrase)?; } } @@ -95,14 +117,25 @@ pub fn run( let cleanup_patterns = config.effective_session_cleanup(); if let Some(ref hp) = home_path { - eprintln!("bunkerbox: cleaning up home directory..."); + crate::logging::log("Cleaning home..."); cleanup_home_junk(hp, &cleanup_patterns); } - let session_mb = config.session_mb(); + let mut session_mb = config.session_mb(); let session_dir: Option = if session_mb > 0 { if let Some(ref hp) = home_path { - Some(setup_session(hp, session_mb, uid, gid)?) + let home_size = dir_size(hp); + if home_size == 0 { + None + } else { + let measured_mb = ((home_size as f64 * 1.2) / (1024.0 * 1024.0)).ceil() as u32; + if config.session_mb.is_none() || measured_mb > session_mb { + session_mb = measured_mb.max(50); + crate::logging::log(&format!("home dir is {} MB, auto-sized session to {} MB", home_size / (1024 * 1024), session_mb)); + } + crate::logging::log("Setting up session image..."); + Some(setup_session(hp, session_mb, uid, gid)?) + } } else { None } @@ -112,19 +145,33 @@ pub fn run( match setup_handle.join() { Ok(Ok(())) => {} - Ok(Err(e)) => return Err(e), - Err(_) => return Err("setup thread panicked".to_string()), + Ok(Err(e)) => { + cleanup_partial_session(session_dir.as_ref(), home_path.as_ref()); + return Err(e); + } + Err(_) => { + cleanup_partial_session(session_dir.as_ref(), home_path.as_ref()); + return Err("setup thread panicked".to_string()); + } } let bridge_firewall = config.network == Some(NetworkMode::Bridge) && config.allow.is_some(); - let resolv_conf = if config.network.is_some() { Some(write_resolv_conf()?) } else { None }; + let resolv_conf = if config.network.is_some() { + crate::logging::log("Writing resolv.conf..."); + Some(write_resolv_conf()?) + } else { + None + }; if bridge_firewall { + crate::logging::log("Applying firewall rules..."); ensure_bridge_egress_firewall(config, resolv_conf.as_deref())?; } let resolv_conf_mount = resolv_conf.as_ref().map(|path| format!("type=bind,src={},dst=/etc/resolv.conf,options=rbind:ro", path.display())); let workspace_mount = format!("type=bind,src={},dst=/workspace,options=rbind:rw", workspace.path().display()); let mut container_env = Vec::new(); + let mut tools_mount: Option = None; + let init_cmd = String::from("/bunkerbox-tools/init.sh"); let home_mount = if let Some(ref hp) = home_path { let src = if let Some(ref sd) = session_dir { sd.display().to_string() @@ -146,6 +193,24 @@ pub fn run( container_env.push(format!("BUNKERBOX_VSOCK_PORT={VSOCK_PORT}")); } + if let Some(ref cmds) = config.command { + if !cmds.is_empty() { + let tools_dir = match std::env::var("BUNKERBOX_TOOLS_DIR") { + Ok(d) if !d.is_empty() => PathBuf::from(d), + _ => { + let dir = std::env::temp_dir().join(format!("bunkerbox-tools-{}", std::process::id())); + fs::create_dir_all(&dir).map_err(|e| format!("create tools dir: {e}"))?; + let init = crate::wrap::generate_init_script(cmds); + fs::write(dir.join("init.sh"), init.as_bytes()).map_err(|e| format!("write init.sh: {e}"))?; + fs::set_permissions(dir.join("init.sh"), fs::Permissions::from_mode(0o755)).map_err(|e| format!("chmod init.sh: {e}"))?; + dir + } + }; + + tools_mount = Some(format!("type=bind,src={},dst=/bunkerbox-tools,options=rbind:ro", tools_dir.display())); + } + } + let mut args = Vec::new(); if config.network == Some(NetworkMode::Bridge) { @@ -187,6 +252,11 @@ pub fn run( args.push(mount.as_str()); } + if let Some(mount) = &tools_mount { + args.push("--mount"); + args.push(mount.as_str()); + } + match config.network { Some(NetworkMode::Bridge) => args.push("--cni"), Some(NetworkMode::Host) => args.push("--net-host"), @@ -196,8 +266,18 @@ pub fn run( args.push(&config.image); args.push(container_name); - let result = run_command("sudo", &args); + if tools_mount.is_some() { + crate::logging::log("Wrapping commands..."); + args.push(&init_cmd); + } + + crate::logging::log("Starting container..."); + + crate::logging::log("Connected"); + let result = run_command_interactive("sudo", &args); + + crate::logging::log("Container stopped"); if bridge_firewall { let _ = remove_bridge_egress_firewall(); } @@ -208,15 +288,19 @@ pub fn run( if let Some(ref sd) = session_dir { if let Some(ref hp) = home_path { + crate::logging::log("Saving session..."); let cleanup = config.effective_session_cleanup(); teardown_session(hp, sd, &cleanup); + crate::logging::log("session persisted"); } } if !passphrase.is_empty() { if let Some(ref hp) = home_path { if hp.is_dir() { + crate::logging::log("Sealing home..."); seal_home(hp, encrypt_patterns, &passphrase)?; + crate::logging::log("home sealed"); } } } @@ -280,7 +364,7 @@ fn write_resolv_conf() -> Result { fn ensure_bridge_egress_firewall(config: &RuntimeConfig, resolv_conf: Option<&Path>) -> Result<(), String> { remove_bridge_egress_firewall()?; run_command("sudo", &["modprobe", "br_netfilter"])?; - run_command("sudo", &["sysctl", "-w", "net.bridge.bridge-nf-call-iptables=1"])?; + run_command_quiet("sudo", &["sysctl", "-w", "net.bridge.bridge-nf-call-iptables=1"])?; run_command_allow_failure("sudo", &["iptables", "-N", "BUNKERBOX-EGRESS"])?; run_command("sudo", &["iptables", "-F", "BUNKERBOX-EGRESS"])?; run_command("sudo", &["iptables", "-I", "FORWARD", "1", "-s", BRIDGE_SUBNET, "-j", "BUNKERBOX-EGRESS"])?; @@ -455,9 +539,17 @@ fn ensure_bridge_cni_config() -> Result<(), String> { /// Force-kills and removes a containerd task/container and its CNI state if left over from a prior run. fn remove_stale_container(container_name: &str) -> Result<(), String> { - let _ = run_command_allow_failure("sudo", &["ctr", "tasks", "kill", "--signal", "SIGKILL", container_name]); - let _ = run_command_allow_failure("sudo", &["ctr", "tasks", "delete", "--force", container_name]); - let _ = run_command_allow_failure("sudo", &["ctr", "containers", "rm", container_name]); + crate::logging::log(&format!("remove_stale: checking for stale container '{container_name}'")); + + if run_command_allow_failure("sudo", &["ctr", "tasks", "kill", "--signal", "SIGKILL", container_name]).is_ok() { + crate::logging::log(&format!("remove_stale: killed stale task for '{container_name}'")); + } + if run_command_allow_failure("sudo", &["ctr", "tasks", "delete", "--force", container_name]).is_ok() { + crate::logging::log(&format!("remove_stale: deleted stale task for '{container_name}'")); + } + if run_command_allow_failure("sudo", &["ctr", "containers", "rm", container_name]).is_ok() { + crate::logging::log(&format!("remove_stale: removed stale container '{container_name}'")); + } let _ = run_command_allow_failure("sudo", &["rm", "-f", &format!("/var/lib/cni/networks/{BRIDGE_NAME}/{container_name}")]); Ok(()) } @@ -499,6 +591,25 @@ fn run_command(program: &str, args: &[&str]) -> Result<(), String> { Ok(()) } +/// Runs an interactive command with all stdio attached to the terminal. +/// Full-screen TUIs can write control sequences to stderr, so this path must +/// not line-buffer or re-emit stderr. +fn run_command_interactive(program: &str, args: &[&str]) -> Result<(), String> { + let status = Command::new(program) + .args(args) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .map_err(|err| format!("failed to run {program}: {err}"))?; + + if !status.success() { + return Err(format!("command failed with status {status}: {program}")); + } + + Ok(()) +} + /// Runs a command silently (no stdio) and ignores non-zero exit codes, only reporting spawn errors. fn run_command_allow_failure(program: &str, args: &[&str]) -> Result<(), String> { Command::new(program) @@ -529,6 +640,38 @@ fn run_command_quiet(program: &str, args: &[&str]) -> Result<(), String> { Ok(()) } +fn ensure_containerd_dropin() -> Result { + let dir = "/etc/systemd/system/containerd.service.d"; + let path = "/etc/systemd/system/containerd.service.d/bunkerbox.conf"; + let content = "[Service]\nStandardError=journal\n"; + + if std::fs::read_to_string(path).map(|c| c == content).unwrap_or(false) { + return Ok(false); + } + + run_command_quiet("sudo", &["mkdir", "-p", dir])?; + + let mut child = Command::new("sudo") + .args(["tee", path]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("sudo tee: {e}"))?; + + use std::io::Write; + child.stdin.as_mut().unwrap().write_all(content.as_bytes()).map_err(|e| format!("write dropin: {e}"))?; + drop(child.stdin.take()); + + let status = child.wait().map_err(|e| format!("sudo tee: {e}"))?; + if !status.success() { + return Err("failed to write containerd drop-in".to_string()); + } + + run_command_quiet("sudo", &["systemctl", "daemon-reload"])?; + Ok(true) +} + /// Prompts the user with a yes/no question and returns `true` on 'y'/'Y'. fn confirm(prompt: &str) -> bool { use std::io::{self, Read, Write}; @@ -544,9 +687,9 @@ fn confirm(prompt: &str) -> bool { buf[0] == b'y' || buf[0] == b'Y' } -/// Reads a passphrase from the terminal without echoing. +/// Reads a passphrase from the terminal via the TUI password popup. fn read_passphrase() -> Result { - rpassword::prompt_password("Bunkerbox passphrase: ").map_err(|err| format!("failed to read passphrase: {err}")) + crate::logging::prompt_password("Bunkerbox passphrase", "Enter passphrase") } /// Derives a 256-bit AES key from a passphrase and salt via PBKDF2-HMAC-SHA256 (100k iterations). @@ -594,11 +737,12 @@ fn decrypt_from_slice(passphrase: &str, encoded: &[u8]) -> Result, Strin /// Recursively walks `dir`, calling `f` with the full path and the path relative to `base` for each file. fn walk_files(base: &Path, dir: &Path, f: &mut dyn FnMut(&Path, &Path) -> Result<(), String>) -> Result<(), String> { for entry in fs::read_dir(dir).map_err(|e| format!("read_dir {}: {e}", dir.display()))? { - let path = entry.map_err(|e| format!("dir entry: {e}"))?.path(); - if path.is_dir() { - walk_files(base, &path, f)?; - } else if path.is_file() { - f(&path, path.strip_prefix(base).unwrap_or(&path))?; + let entry = entry.map_err(|e| format!("dir entry: {e}"))?; + let ft = entry.file_type().map_err(|e| format!("file_type: {e}"))?; + if ft.is_dir() { + walk_files(base, &entry.path(), f)?; + } else if ft.is_file() { + f(&entry.path(), entry.path().strip_prefix(base).unwrap_or(&entry.path()))?; } } Ok(()) @@ -642,7 +786,11 @@ fn seal_home(home: &Path, patterns: &[String], passphrase: &str) -> Result<(), S let compiled: Vec = patterns.iter().map(|p| glob::Pattern::new(p)).collect::, _>>().map_err(|e| format!("invalid glob pattern: {e}"))?; + crate::logging::log("seal: walking home tree..."); + + let scanned = std::cell::Cell::new(0u64); walk_files(home, home, &mut |path, rel| { + scanned.set(scanned.get() + 1); let Some(filename) = path.file_name().and_then(|n| n.to_str()) else { return Ok(()); }; @@ -655,6 +803,9 @@ fn seal_home(home: &Path, patterns: &[String], passphrase: &str) -> Result<(), S return Ok(()); } + let size = fs::metadata(path).map(|m| m.len()).unwrap_or(0); + crate::logging::log(&format!("sealing {} ({} bytes)", rel_str, size)); + let plaintext = fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?; let encrypted = encrypt_to_vec(passphrase, &plaintext)?; @@ -663,17 +814,20 @@ fn seal_home(home: &Path, patterns: &[String], passphrase: &str) -> Result<(), S fs::remove_file(path).map_err(|e| format!("remove {}: {e}", path.display()))?; Ok(()) - }) + })?; + + crate::logging::log(&format!("seal: walked {} files", scanned.get())); + Ok(()) } /// Creates and mounts an ext4 session image, copies the home contents, and recovers any leftover image. fn setup_session(home_path: &Path, session_mb: u32, uid: &str, gid: &str) -> Result { - let bunker_dir = home_path.join(".bunker"); + let bunker_dir = home_path.parent().expect("home has parent").join(".bunker"); let session_img = bunker_dir.join("session.img"); let session_dir = home_path.parent().ok_or("home path has no parent directory")?.join("session-mount"); if session_img.exists() { - eprintln!("bunkerbox: found leftover session.img, recovering..."); + crate::logging::log("found leftover session.img, recovering..."); let _ = run_command_allow_failure("sudo", &["mkdir", "-p", session_dir.to_str().unwrap()]); let _ = run_command_quiet("sudo", &["e2fsck", "-p", &format!("{}", session_img.display())]); @@ -684,16 +838,17 @@ fn setup_session(home_path: &Path, session_mb: u32, uid: &str, gid: &str) -> Res let _ = run_command_allow_failure("sudo", &["rm", "-rf", &format!("{}/lost+found", session_dir.display())]); let _ = run_command_allow_failure("sudo", &["umount", &format!("{}", session_dir.display())]); } else { - eprintln!("bunkerbox: warning: could not mount leftover session.img, discarding"); + crate::logging::log("warning: could not mount leftover session.img, discarding"); } let _ = run_command_allow_failure("sudo", &["rm", "-rf", &format!("{}", session_dir.display())]); let _ = run_command_allow_failure("rm", &["-f", &format!("{}", session_img.display())]); } - eprintln!("bunkerbox: setting up session..."); + crate::logging::log("setting up session..."); - run_command_allow_failure("mkdir", &["-p", &format!("{}", bunker_dir.display())])?; + run_command_quiet("sudo", &["mkdir", "-p", &format!("{}", bunker_dir.display())])?; + run_command_quiet("sudo", &["chown", &format!("{}:{}", uid, gid), &format!("{}", bunker_dir.display())])?; run_command_quiet("dd", &["if=/dev/zero", &format!("of={}", session_img.display()), "bs=1M", &format!("count={}", session_mb)])?; run_command_quiet("mke2fs", &["-F", "-t", "ext4", &format!("{}", session_img.display())])?; run_command_allow_failure("sudo", &["mkdir", "-p", &format!("{}", session_dir.display())])?; @@ -704,7 +859,11 @@ fn setup_session(home_path: &Path, session_mb: u32, uid: &str, gid: &str) -> Res let home_size = dir_size(home_path); let max_size = (session_mb as u64) * 1024 * 1024; if home_size > max_size { - eprintln!("bunkerbox: warning: home directory is {} MB but session is only {} MB - copy may fail", home_size / (1024 * 1024), session_mb); + crate::logging::log(&format!( + "warning: home directory ({} MB) exceeds session image ({} MB) — copy may fail", + home_size / (1024 * 1024), + session_mb + )); } let cp_status = run_command_quiet("cp", &["-a", &format!("{}/.", home_path.display()), &format!("{}/", session_dir.display())]); @@ -720,16 +879,20 @@ fn setup_session(home_path: &Path, session_mb: u32, uid: &str, gid: &str) -> Res /// Copies session changes back to home, unmounts, and removes the session image and mount point. fn teardown_session(home_path: &Path, session_dir: &Path, cleanup_patterns: &[String]) { - eprintln!("bunkerbox: preparing to persist session..."); + crate::logging::log("preparing to persist session..."); cleanup_home_junk(session_dir, cleanup_patterns); + crate::logging::log("teardown: cleanup done, copying session back..."); let _ = run_command_quiet("cp", &["-Rup", &format!("{}/.", session_dir.display()), &format!("{}", home_path.display())]); + crate::logging::log("teardown: copy done, unmounting..."); let _ = run_command_allow_failure("sudo", &["umount", &format!("{}", session_dir.display())]); + crate::logging::log("teardown: unmount done, removing image..."); - let session_img = home_path.join(".bunker").join("session.img"); + let session_img = home_path.parent().expect("home has parent").join(".bunker").join("session.img"); let _ = run_command_allow_failure("rm", &["-f", &format!("{}", session_img.display())]); let _ = run_command_allow_failure("sudo", &["rmdir", &format!("{}", session_dir.display())]); + crate::logging::log("teardown: image removed"); } /// Checks that containerd >= 2.2.5 is installed (required for Kata networking). Skippable via env var. @@ -816,8 +979,9 @@ fn cleanup_home_junk(path: &Path, patterns: &[String]) { for pattern in patterns { let target = path.join(pattern); if target.exists() { - eprintln!("bunkerbox: purging {pattern}"); + crate::logging::log(&format!("purging {pattern}...")); let _ = run_command_allow_failure("rm", &["-rf", &format!("{}", target.display())]); + crate::logging::log(&format!("purged {pattern}")); } } } @@ -828,13 +992,13 @@ fn dir_size(path: &Path) -> u64 { return total; }; for entry in entries.flatten() { - let Ok(meta) = entry.metadata() else { + let Ok(ft) = entry.file_type() else { continue; }; - if meta.is_dir() { + if ft.is_dir() { total = total.saturating_add(dir_size(&entry.path())); - } else if meta.is_file() { - total = total.saturating_add(meta.len()); + } else if ft.is_file() { + total = total.saturating_add(entry.metadata().map(|m| m.len()).unwrap_or(0)); } } total diff --git a/src/lib.rs b/src/lib.rs index d63a0e9..f7c2318 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,8 +4,11 @@ pub mod clidef; pub mod cmdrun; pub mod daemon; pub mod kata; +pub mod logging; pub mod overlay; pub mod proxy; pub mod sandbox; +pub mod tui; pub mod vscomm; pub mod workspace; +pub mod wrap; diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..0c2fd77 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,92 @@ +use std::cell::RefCell; +use std::fs::OpenOptions; +use std::io::Write; +use std::os::unix::io::RawFd; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; + +static VERBOSE: AtomicBool = AtomicBool::new(false); +static LOG_FILE: Mutex> = Mutex::new(None); + +thread_local! { + static STATUS_FD: RefCell> = const { RefCell::new(None) }; +} + +pub fn configure(verbose: bool, log_file: Option) { + VERBOSE.store(verbose, Ordering::Relaxed); + *LOG_FILE.lock().unwrap() = log_file; +} + +pub fn log_path() -> String { + LOG_FILE.lock().unwrap().clone().unwrap_or_else(|| "/tmp/bunkerbox.log".to_string()) +} + +pub fn set_status_fd(fd: RawFd) { + STATUS_FD.with(|f| *f.borrow_mut() = Some(fd)); +} + +/// Sends a password prompt to the TUI, blocks reading the response from the status fd. +pub fn prompt_password(title: &str, prompt: &str) -> Result { + let fd = STATUS_FD.with(|f| f.borrow().ok_or("status fd not set".to_string()))?; + + let payload = crate::vscomm::encode_ui_payload("password", "show", title, prompt); + let mut buf = b"@".to_vec(); + buf.extend_from_slice(&payload); + buf.push(b'\n'); + unsafe { + libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()); + } + + let mut response = Vec::new(); + let mut byte = [0u8; 1]; + loop { + let n = unsafe { libc::read(fd, byte.as_mut_ptr() as *mut libc::c_void, 1) }; + if n <= 0 { + return Err("failed to read password response".to_string()); + } + if byte[0] == b'\n' { + break; + } + response.push(byte[0]); + } + + let payload = crate::vscomm::encode_ui_payload("password", "hide", "", ""); + let mut buf = b"@".to_vec(); + buf.extend_from_slice(&payload); + buf.push(b'\n'); + unsafe { + libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()); + } + + String::from_utf8(response).map_err(|e| format!("invalid password encoding: {e}")) +} + +pub fn log(msg: &str) { + let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); + let line = format!("[{ts}] {msg}\n"); + + if VERBOSE.load(Ordering::Relaxed) { + eprint!("[bb] {line}"); + } + + if let Some(ref path) = *LOG_FILE.lock().unwrap() { + if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) { + let _ = f.write_all(line.as_bytes()); + } + } + + STATUS_FD.with(|f| { + if let Some(fd) = *f.borrow() { + let log_ref = LOG_FILE.lock().unwrap(); + let title = if let Some(ref path) = *log_ref { format!("Log: {path}") } else { "Bunkerbox".to_string() }; + drop(log_ref); + let payload = crate::vscomm::encode_ui_payload("popup", "info", &title, msg); + let mut buf = b"@".to_vec(); + buf.extend_from_slice(&payload); + buf.push(b'\n'); + unsafe { + libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()); + } + } + }); +} diff --git a/src/main.rs b/src/main.rs index 3065a90..1283310 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,11 @@ use bunkerbox::cfg::{ProjectConfig, WorkspaceMode}; -use bunkerbox::{cfg, cfgsetup, clidef, cmdrun, daemon, kata, overlay, workspace}; +use bunkerbox::{cfg, cfgsetup, clidef, cmdrun, daemon, kata, logging, overlay, tui, vscomm, workspace}; +use std::cell::RefCell; use std::ffi::OsString; +use std::os::unix::io::RawFd; use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; fn main() { if let Err(err) = run() { @@ -11,6 +15,10 @@ fn main() { } fn run() -> Result<(), String> { + let verbose = flag_present("verbose"); + let log_file = option_from_args("log")?.map(|v| v.to_string_lossy().into_owned()); + logging::configure(verbose, log_file); + let workspace_override = workspace_mode_from_args()?; if cfg::RuntimeConfig::invoked_name()? != clidef::APPNAME { @@ -140,6 +148,22 @@ fn option_from_args(name: &str) -> Result, String> { Ok(None) } +fn flag_present(name: &str) -> bool { + let long = format!("--{name}"); + let prefix = format!("--{name}="); + for arg in std::env::args_os().skip(1) { + if arg == long.as_str() { + return true; + } + if let Some(s) = arg.to_str() { + if s.starts_with(&prefix) { + return true; + } + } + } + false +} + fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option, share_dir: &Path) -> Result<(), String> { if config.oci.as_os_str().is_empty() { return Err("runtime config missing oci".to_string()); @@ -151,37 +175,213 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option = config.allow.clone().unwrap_or_default().into_iter().chain(env.image.allow.clone().unwrap_or_default()).collect(); - let daemon = if !env.project.passthrough.is_empty() { - Some(daemon::VsockDaemon::start( - env.project.passthrough.clone(), - env.project.env, - workspace_path, - env.profiles.clone(), - share_dir.to_path_buf(), - merged_allow, - )?) - } else { - None - }; + let passthrough = env.project.passthrough.clone(); + let env_mode = env.project.env; + let profiles = env.profiles.clone(); + let share_dir_owned = share_dir.to_path_buf(); + let daemon_holder = Rc::new(RefCell::new(None)); + let daemon_clone = daemon_holder.clone(); + + let mut sock_fds = [-1i32, -1]; + unsafe { + libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, sock_fds.as_mut_ptr()); + } + let (parent_fd, child_fd) = (sock_fds[0], sock_fds[1]); + + let (cols, rows) = crossterm::terminal::size().map_err(|e| format!("terminal size: {e}"))?; - let result = kata::run(&config, ws, &container_name, share_dir, &name, daemon.is_some()); + let winsize = libc::winsize { ws_row: rows, ws_col: cols, ws_xpixel: 0, ws_ypixel: 0 }; - if let Some(d) = daemon { + let mut master: RawFd = -1; + let pid = unsafe { libc::forkpty(&mut master, std::ptr::null_mut(), std::ptr::null(), &winsize) }; + + if pid == -1 { + return Err("forkpty failed".to_string()); + } + + if pid == 0 { + unsafe { libc::close(parent_fd) }; + + let status_fd = child_fd; + bunkerbox::logging::set_status_fd(status_fd); + bunkerbox::logging::log("Starting..."); + + if !std::process::Command::new("sudo") + .arg("-n") + .arg("true") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + { + let pass = bunkerbox::logging::prompt_password("Sudo password", "Enter your sudo password")?; + + let mut child = std::process::Command::new("sudo") + .arg("-S") + .arg("-v") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|e| format!("failed to run sudo: {e}"))?; + + use std::io::Write; + child.stdin.as_mut().unwrap().write_all(pass.as_bytes()).map_err(|e| format!("failed to write sudo password: {e}"))?; + drop(child.stdin.take()); + + let status = child.wait().map_err(|e| format!("sudo failed: {e}"))?; + if !status.success() { + return Err("sudo: authentication failed".to_string()); + } + } + + std::thread::spawn(|| loop { + std::thread::sleep(std::time::Duration::from_secs(240)); + let _ = std::process::Command::new("sudo") + .arg("-n") + .arg("-v") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + }); + + let ws = workspace::resolve(workspace_mode, quota, exclude.as_deref(), &name)?; + let wp = ws.path().to_path_buf(); + { + let mut msg = wp.to_string_lossy().into_owned().into_bytes(); + msg.push(b'\n'); + unsafe { + libc::write(status_fd, msg.as_ptr() as *const libc::c_void, msg.len()); + } + } + + let vsock_enabled = !passthrough.is_empty(); + let code = match kata::run(&config, ws, &container_name, share_dir, &name, vsock_enabled, status_fd) { + Ok(()) => 0, + Err(e) => { + eprintln!("bunkerbox: {e}"); + + let msg = e.to_string(); + let payload = bunkerbox::vscomm::encode_ui_payload("popup", "info", "Error", &msg); + let mut buf = b"@".to_vec(); + buf.extend_from_slice(&payload); + buf.push(b'\n'); + unsafe { + libc::write(status_fd, buf.as_ptr() as *const libc::c_void, buf.len()); + } + std::thread::sleep(std::time::Duration::from_millis(500)); + + 1 + } + }; + unsafe { libc::close(status_fd) }; + bunkerbox::logging::log("exiting"); + unsafe { libc::exit(code) }; + } + + unsafe { libc::close(child_fd) }; + + let overlay: Arc> = Arc::new(Mutex::new(tui::OverlayState::new())); + let overlay_clone = overlay.clone(); + + let tui_result = tui::event_loop( + master, + rows, + cols, + parent_fd, + move |path_bytes: Vec| { + let wp = PathBuf::from(String::from_utf8_lossy(&path_bytes).into_owned()); + if !passthrough.is_empty() { + *daemon_clone.borrow_mut() = Some(daemon::VsockDaemon::start(passthrough, env_mode, wp, profiles, share_dir_owned, merged_allow)?); + } + + let overlay = overlay_clone; + tokio::spawn(async move { + status_listener(overlay).await; + }); + + Ok(()) + }, + overlay, + ); + + let mut status: i32 = 0; + unsafe { libc::waitpid(pid, &mut status, 0) }; + unsafe { libc::close(master) }; + + if let Some(d) = daemon_holder.borrow_mut().take() { tokio::runtime::Handle::current().block_on(d.shutdown()); } - result + tui_result?; + + if status != 0 { + return Err(format!("child exited with status {status}")); + } + + Ok(()) +} + +async fn status_listener(overlay: Arc>) { + use std::time::Duration; + use tokio::io::AsyncReadExt; + use tokio_vsock::VsockListener; + + let listener = loop { + match VsockListener::bind(tokio_vsock::VsockAddr::new(libc::VMADDR_CID_ANY, vscomm::STATUS_PORT)) { + Ok(l) => break l, + Err(e) => { + eprintln!("bunkerbox: vsock status listener bind failed ({e}), retrying..."); + std::thread::sleep(Duration::from_millis(500)); + } + } + }; + + loop { + let (mut stream, _peer) = match listener.accept().await { + Ok(c) => c, + Err(_) => continue, + }; + + let overlay = overlay.clone(); + tokio::spawn(async move { + let mut header = [0u8; 6]; + if stream.read_exact(&mut header).await.is_err() { + return; + } + + let frame_type_raw = u16::from_le_bytes([header[0], header[1]]); + let payload_len = u32::from_le_bytes([header[2], header[3], header[4], header[5]]) as usize; + + let Some(ft) = vscomm::FrameType::from_u16(frame_type_raw) else { + return; + }; + + if !matches!(ft, vscomm::FrameType::UiCommand) { + return; + } + + let mut payload = vec![0u8; payload_len]; + if payload_len > 0 && stream.read_exact(&mut payload).await.is_err() { + return; + } + + if let Some((widget, cmd, opts, val)) = vscomm::decode_ui_payload(&payload) { + tui::dispatch_ui_command(&mut overlay.lock().unwrap(), widget, cmd, opts, val); + } + }); + } } fn print_subcommand_help(name: &str) -> Result<(), String> { diff --git a/src/overlay.rs b/src/overlay.rs index f04de35..3252b4f 100644 --- a/src/overlay.rs +++ b/src/overlay.rs @@ -131,12 +131,22 @@ impl CowWorkspace { /// Unmounts stale overlay and loop mounts from previous runs, then detaches the loop device. fn cleanup_stale(mount_point: &Path, loop_mount: &Path, loopback: &Path) -> Result<(), String> { - Self::run_command_allow_failure("umount", &[&mount_point.to_string_lossy()])?; + match Self::run_command_allow_failure("umount", &[&mount_point.to_string_lossy()]) { + Ok(()) => crate::logging::log(&format!("cleanup_stale: unmounted overlay {}", mount_point.display())), + Err(e) => crate::logging::log(&format!("cleanup_stale: WARNING overlay umount {} failed: {e}", mount_point.display())), + } - Self::run_command_allow_failure("umount", &[&loop_mount.to_string_lossy()])?; + match Self::run_command_allow_failure("umount", &[&loop_mount.to_string_lossy()]) { + Ok(()) => crate::logging::log(&format!("cleanup_stale: unmounted loop mount {}", loop_mount.display())), + Err(e) => crate::logging::log(&format!("cleanup_stale: WARNING loop umount {} failed: {e}", loop_mount.display())), + } if loopback.exists() { - Self::run_command_allow_failure("losetup", &["-d", &Self::find_loop_device(loopback).unwrap_or_default()])?; + let dev = Self::find_loop_device(loopback).unwrap_or_default(); + match Self::run_command_allow_failure("losetup", &["-d", &dev]) { + Ok(()) => crate::logging::log(&format!("cleanup_stale: detached loop device {dev}")), + Err(e) => crate::logging::log(&format!("cleanup_stale: WARNING losetup -d {dev} failed: {e}")), + } } Ok(()) @@ -187,18 +197,19 @@ impl CowWorkspace { fn check(dir: &Path) -> bool { let Ok(entries) = fs::read_dir(dir) else { return false }; for entry in entries.flatten() { - let path = entry.path(); - let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue }; + let Ok(ft) = entry.file_type() else { continue }; + let file_name = entry.file_name(); + let Some(name) = file_name.to_str() else { continue }; if name.starts_with('.') || name.ends_with(".wh") { continue; } - if path.is_dir() { - if check(&path) { + if ft.is_dir() { + if check(&entry.path()) { return true; } continue; } - if path.is_file() { + if ft.is_file() { return true; } } @@ -335,6 +346,7 @@ fn sync_upper(upper_dir: &Path, repo_root: &Path, sessions_dir: &Path, app_name: } save_manifest(&manifest_path, &manifest_new)?; + crate::logging::log(&format!("workspace sync: added {count_add}, deleted {count_del}")); Ok((count_add, count_del)) } @@ -350,19 +362,23 @@ fn sync_upper_dir( } let mut has_opq = false; + let mut scanned: u64 = 0; for entry in fs::read_dir(current).map_err(|e| format!("failed to read {}: {e}", current.display()))? { let entry = entry.map_err(|e| format!("failed to read entry: {e}"))?; let path = entry.path(); - let metadata = entry.metadata().map_err(|e| format!("failed to stat {}: {e}", path.display()))?; + let ft = entry.file_type().map_err(|e| format!("failed to stat {}: {e}", path.display()))?; + + scanned += 1; + if scanned.is_multiple_of(500) { + crate::logging::log(&format!("workspace sync: scanned {scanned} entries in {}...", current.display())); + } let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; - let file_type = metadata.file_type(); - - if file_type.is_char_device() { + if ft.is_char_device() { if name == ".wh..wh..opq" { has_opq = true; continue; @@ -383,7 +399,7 @@ fn sync_upper_dir( continue; } - if file_type.is_symlink() { + if ft.is_symlink() { continue; } @@ -391,12 +407,12 @@ fn sync_upper_dir( let rel_str = rel.to_string_lossy().to_string(); let dest = repo_root.join(rel); - if metadata.is_dir() { + if ft.is_dir() { sync_upper_dir(base, &path, repo_root, count_add, count_del, _manifest_old, manifest_new)?; continue; } - if metadata.is_file() { + if ft.is_file() { let needs_copy = fs::read(&path).ok().and_then(|src| fs::read(&dest).ok().map(|dest_data| src != dest_data)).unwrap_or(true); if needs_copy { if let Some(parent) = dest.parent() { @@ -456,6 +472,7 @@ fn save_manifest(path: &Path, manifest: &BTreeSet) -> Result<(), String> /// removes session state, and unmounts the overlay and loop device. impl Drop for CowWorkspace { fn drop(&mut self) { + crate::logging::log("workspace: dropping..."); let sessions_dir = self.overlay_dir.join("sessions"); for entry in fs::read_dir(&sessions_dir).into_iter().flatten() { @@ -465,25 +482,30 @@ impl Drop for CowWorkspace { let stem = path.file_stem().unwrap_or_default().to_string_lossy(); let synced_marker = sessions_dir.join(format!("{stem}.synced")); if !synced_marker.exists() && self.has_unsynced_changes() { - eprintln!("bunkerbox: syncing changes back to repo..."); + crate::logging::log("workspace: syncing changes back to repo..."); let upper_dir = self.loop_mount.join("upper"); let repo_root = self.overlay_dir.parent().expect("overlay_dir has no parent"); let _ = sync_upper(&upper_dir, repo_root, &sessions_dir, &stem); - eprintln!("bunkerbox: sync complete."); + crate::logging::log("workspace: sync complete."); } let _ = fs::remove_file(&path); let _ = fs::remove_file(&synced_marker); } } + crate::logging::log("workspace: removing sessions dir..."); let _ = fs::remove_dir_all(&sessions_dir); + crate::logging::log("workspace: unmounting overlay..."); let _ = Self::run_command_allow_failure("umount", &[&self.mount_point.to_string_lossy()]); + crate::logging::log("workspace: unmounting loop mount..."); let _ = Self::run_command_allow_failure("umount", &[&self.loop_mount.to_string_lossy()]); if let Some(dev) = Self::find_loop_device(&self.loopback) { if !dev.is_empty() { + crate::logging::log("workspace: detaching loop device..."); let _ = Self::run_command_allow_failure("losetup", &["-d", &dev]); } } + crate::logging::log("workspace: dropped"); } } diff --git a/src/proxy.rs b/src/proxy.rs index 84d4b16..b56c28e 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -72,9 +72,7 @@ async fn handle_client(mut client: TcpStream, allow: &[String]) -> Result<(), St (h, p, true) } else if target.starts_with("http://") { let url = target.strip_prefix("http://").ok_or_else(|| format!("malformed URL: {target}"))?; - let (h, p) = url.split_once('/').map_or_else(|| (url, "80"), |(hp, _)| { - hp.split_once(':').unwrap_or((hp, "80")) - }); + let (h, p) = url.split_once('/').map_or_else(|| (url, "80"), |(hp, _)| hp.split_once(':').unwrap_or((hp, "80"))); (h.to_string(), p.to_string(), false) } else { return Err(format!("unsupported request: {first_line}")); @@ -87,9 +85,7 @@ async fn handle_client(mut client: TcpStream, allow: &[String]) -> Result<(), St } let upstream_addr = format!("{host}:{port}"); - let mut upstream = TcpStream::connect(&upstream_addr) - .await - .map_err(|e| format!("connect to {upstream_addr}: {e}"))?; + let mut upstream = TcpStream::connect(&upstream_addr).await.map_err(|e| format!("connect to {upstream_addr}: {e}"))?; if is_connect { let established = b"HTTP/1.1 200 Connection Established\r\n\r\n"; diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs index b8283cc..aa3d1a2 100644 --- a/src/sandbox/mod.rs +++ b/src/sandbox/mod.rs @@ -124,82 +124,5 @@ fn get_builtin_profile(name: &str) -> Result<&str, String> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_profile() { - let yaml = r#" -name: test -bin: - ls: /usr/bin/ls -ro: - - /lib -rw: - - "${HOME}/.cache" -env: - FOO: bar -network: none -shell: /bin/sh -"#; - let profile = parse_profile_yaml(yaml).unwrap(); - assert_eq!(profile.name, "test"); - assert_eq!(profile.bin.get("ls").unwrap(), &std::path::PathBuf::from("/usr/bin/ls")); - assert_eq!(profile.ro.len(), 1); - assert_eq!(profile.rw.len(), 1); - assert!(matches!(profile.network, NetworkMode::None)); - } - - #[test] - fn test_merge_profiles() { - let p1 = Profile { - name: "a".into(), - bin: { - let mut m = BTreeMap::new(); - m.insert("cmd1".into(), "/usr/bin/cmd1".into()); - m - }, - ro: vec!["/lib".into()], - rw: vec!["/cache".into()], - env: { - let mut m = BTreeMap::new(); - m.insert("A".into(), "1".into()); - m - }, - network: NetworkMode::None, - shell: "/bin/sh".into(), - }; - let p2 = Profile { - name: "b".into(), - bin: { - let mut m = BTreeMap::new(); - m.insert("cmd2".into(), "/usr/bin/cmd2".into()); - m - }, - ro: vec!["/usr/lib".into()], - rw: vec!["/other".into()], - env: { - let mut m = BTreeMap::new(); - m.insert("B".into(), "2".into()); - m - }, - network: NetworkMode::None, - shell: "/bin/dash".into(), - }; - let merged = MergedProfile::from_profiles(&[p1, p2]); - assert_eq!(merged.bin.len(), 2); - assert_eq!(merged.ro.len(), 2); - assert_eq!(merged.rw.len(), 2); - assert_eq!(merged.env.len(), 2); - assert_eq!(merged.shell, PathBuf::from("/bin/dash")); - assert_eq!(merged.name, "a+b"); - } - - #[test] - fn test_resolve_builtin() { - let profile = resolve_profile("make", std::path::Path::new("/nonexistent")).unwrap(); - assert_eq!(profile.name, "make"); - assert!(profile.bin.contains_key("make")); - assert!(profile.bin.contains_key("gcc")); - } -} +#[path = "ut.rs"] +mod sandbox_tests; diff --git a/src/sandbox/ut.rs b/src/sandbox/ut.rs new file mode 100644 index 0000000..7c602c0 --- /dev/null +++ b/src/sandbox/ut.rs @@ -0,0 +1,79 @@ +use super::*; +use std::collections::BTreeMap; +use std::path::PathBuf; + +#[test] +fn test_parse_profile() { + let yaml = r#" +name: test +bin: + ls: /usr/bin/ls +ro: + - /lib +rw: + - "${HOME}/.cache" +env: + FOO: bar +network: none +shell: /bin/sh +"#; + let profile = parse_profile_yaml(yaml).unwrap(); + assert_eq!(profile.name, "test"); + assert_eq!(profile.bin.get("ls").unwrap(), &std::path::PathBuf::from("/usr/bin/ls")); + assert_eq!(profile.ro.len(), 1); + assert_eq!(profile.rw.len(), 1); + assert!(matches!(profile.network, NetworkMode::None)); +} + +#[test] +fn test_merge_profiles() { + let p1 = Profile { + name: "a".into(), + bin: { + let mut m = BTreeMap::new(); + m.insert("cmd1".into(), "/usr/bin/cmd1".into()); + m + }, + ro: vec!["/lib".into()], + rw: vec!["/cache".into()], + env: { + let mut m = BTreeMap::new(); + m.insert("A".into(), "1".into()); + m + }, + network: NetworkMode::None, + shell: "/bin/sh".into(), + }; + let p2 = Profile { + name: "b".into(), + bin: { + let mut m = BTreeMap::new(); + m.insert("cmd2".into(), "/usr/bin/cmd2".into()); + m + }, + ro: vec!["/usr/lib".into()], + rw: vec!["/other".into()], + env: { + let mut m = BTreeMap::new(); + m.insert("B".into(), "2".into()); + m + }, + network: NetworkMode::None, + shell: "/bin/dash".into(), + }; + let merged = MergedProfile::from_profiles(&[p1, p2]); + assert_eq!(merged.bin.len(), 2); + assert_eq!(merged.ro.len(), 2); + assert_eq!(merged.rw.len(), 2); + assert_eq!(merged.env.len(), 2); + assert_eq!(merged.shell, PathBuf::from("/bin/dash")); + assert_eq!(merged.name, "a+b"); +} + +#[test] +fn test_resolve_builtin() { + let profile = resolve_profile("make", std::path::Path::new("/nonexistent")).unwrap(); + assert_eq!(profile.name, "make"); + assert!(profile.bin.contains_key("make")); + assert!(profile.bin.contains_key("gcc")); +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..c4a9e6d --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,798 @@ +use std::io; +use std::os::fd::AsRawFd; +use std::os::unix::io::RawFd; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use crossterm::cursor; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; +use crossterm::ExecutableCommand; +use ratatui::backend::CrosstermBackend; +use ratatui::prelude::*; + +use ratatui::Terminal; + +use crate::vscomm::{self, parse_triggers, Trigger}; + +mod palette; +mod popup; +use popup::PopupWidget; + +static RESIZED: AtomicBool = AtomicBool::new(false); + +extern "C" fn handle_sigwinch(_: libc::c_int) { + RESIZED.store(true, Ordering::SeqCst); +} + +const WIDGET_PROGRESS: &str = "progress"; +const WIDGET_STATUS: &str = "status"; +const WIDGET_POPUP: &str = "popup"; +const WIDGET_SPINNER: &str = "spinner"; +const WIDGET_PASSWORD: &str = "password"; + +const CMD_SHOW: &str = "show"; +const CMD_HIDE: &str = "hide"; +const CMD_SET: &str = "set"; +const CMD_CLEAR: &str = "clear"; + +pub struct PendingAction { + pub widget: String, + pub command: String, + pub triggers: Vec, + pub value: String, + pub enqueued_at: Instant, + pub first_pty_at: Option, +} + +pub struct OverlayState { + pub status_text: String, + pub popup: PopupWidget, + pub popup_title: Option, + pub pending: Vec, + pub has_error: bool, + pub hide_on_ascii: bool, + pub hide_on_content: Option, + pub last_content_scan: Instant, +} + +impl Default for OverlayState { + fn default() -> Self { + Self::new() + } +} + +impl OverlayState { + pub fn new() -> Self { + Self { + status_text: String::new(), + popup: PopupWidget::new(), + popup_title: None, + pending: Vec::new(), + has_error: false, + hide_on_ascii: false, + hide_on_content: None, + last_content_scan: Instant::now(), + } + } +} + +pub fn dispatch_ui_command(state: &mut OverlayState, widget: &str, command: &str, options: &str, value: &str) { + if widget == WIDGET_POPUP && command == CMD_HIDE && !value.is_empty() { + if value == "ASCII" { + state.hide_on_ascii = true; + } else { + state.hide_on_content = Some(value.to_string()); + } + return; + } + + let triggers = parse_triggers(options); + if !triggers.is_empty() { + state.pending.push(PendingAction { + widget: widget.to_string(), + command: command.to_string(), + triggers, + value: value.to_string(), + enqueued_at: Instant::now(), + first_pty_at: None, + }); + return; + } + + match (widget, command) { + (WIDGET_PROGRESS, CMD_SET) => { + if let Ok(pct) = value.parse::() { + state.popup.set_progress(pct.clamp(0.0, 1.0), None); + } + } + (WIDGET_PROGRESS, CMD_SHOW) => { + if let Some((pct, label)) = value.split_once(';') { + let pct: f64 = pct.trim().parse().unwrap_or(0.0); + state.popup.show_progress("", pct.clamp(0.0, 1.0), Some(label.trim().to_string())); + } else if let Ok(pct) = value.parse::() { + state.popup.show_progress("", pct.clamp(0.0, 1.0), None); + } + } + (WIDGET_PROGRESS, CMD_HIDE) => { + state.popup.hide(); + } + (WIDGET_STATUS, "phase") => { + state.popup_title = if value.is_empty() { None } else { Some(value.to_string()) }; + } + (WIDGET_STATUS, CMD_SET) => { + let title = state.popup_title.clone(); + state.popup.show_info(title, value, Some(palette::FG), Some(palette::ACCENT)); + } + (WIDGET_STATUS, CMD_CLEAR) => { + state.popup.hide(); + state.popup_title = None; + } + (WIDGET_POPUP, CMD_SHOW) => { + state.popup.show_info(None, value, None, None); + } + (WIDGET_POPUP, "info") => { + let title = if options.is_empty() { None } else { Some(options.to_string()) }; + if title.as_deref() == Some("Error") { + state.has_error = true; + } + state.popup.show_info(title, value, Some(palette::FG), Some(palette::ACCENT)); + } + (WIDGET_POPUP, CMD_HIDE) => { + state.popup.hide(); + } + (WIDGET_SPINNER, CMD_SHOW) => { + state.popup.show_spinner(value); + } + (WIDGET_SPINNER, CMD_HIDE) => { + state.popup.hide(); + } + (WIDGET_PASSWORD, CMD_SHOW) => { + state.popup.show_password(options, value); + } + (WIDGET_PASSWORD, CMD_HIDE) => { + state.popup.hide(); + } + _ => {} + } +} + +/// Terminal emulator wrapper around [`vt100::Parser`] with DEC Special Graphics +/// character set translation and HVP-to-CUP normalization. +struct Term { + parser: vt100::Parser, + responses: Vec>, + escape_state: EscapeState, + g0_dec_special_graphics: bool, + g1_dec_special_graphics: bool, + using_g1_charset: bool, + application_cursor_keys: bool, + csi_bytes: Vec, +} + +#[derive(Clone, Copy)] +enum EscapeState { + Ground, + Escape, + CharsetSelect(u8), + Csi, + String, + StringEscape, +} + +impl Term { + /// Creates a new terminal of the given rows and columns. + fn new(rows: u16, cols: u16) -> Self { + Self { + parser: vt100::Parser::new(rows, cols, 0), + responses: Vec::new(), + escape_state: EscapeState::Ground, + g0_dec_special_graphics: false, + g1_dec_special_graphics: false, + using_g1_charset: false, + application_cursor_keys: false, + csi_bytes: Vec::new(), + } + } + + /// Returns a reference to the vt100 screen grid for rendering. + fn screen(&self) -> &vt100::Screen { + self.parser.screen() + } + + /// Resizes the terminal grid after a window resize event. + fn set_size(&mut self, rows: u16, cols: u16) { + self.parser.screen_mut().set_size(rows, cols); + } + + /// Feeds raw bytes through DEC translation then into the vt100 parser. + fn process(&mut self, bytes: &[u8]) { + self.process_bytes(bytes); + } + + fn drain_responses(&mut self) -> Vec> { + std::mem::take(&mut self.responses) + } + + #[allow(dead_code)] + fn application_cursor_keys(&self) -> bool { + self.application_cursor_keys + } + + fn active_dec_special_graphics(&self) -> bool { + if self.using_g1_charset { + self.g1_dec_special_graphics + } else { + self.g0_dec_special_graphics + } + } + + /// Translates `\e(0` DEC Special Graphics characters to Unicode + /// box-drawing glyphs, normalizes HVP (`CSI … f`) to CUP (`CSI … H`), + /// and answers stream-split terminal queries after preceding bytes have + /// been parsed. + fn process_bytes(&mut self, bytes: &[u8]) { + let mut translated = Vec::with_capacity(bytes.len()); + + for &byte in bytes { + match self.escape_state { + EscapeState::Ground => match byte { + 0x1b => { + self.escape_state = EscapeState::Escape; + } + 0x0e => { + self.using_g1_charset = true; + } + 0x0f => { + self.using_g1_charset = false; + } + 0x20..=0x7e if self.active_dec_special_graphics() => { + push_dec_special_graphic(&mut translated, byte); + } + _ => { + translated.push(byte); + } + }, + EscapeState::Escape => match byte { + b'(' | b')' => { + self.escape_state = EscapeState::CharsetSelect(byte); + } + b'[' => { + self.escape_state = EscapeState::Csi; + self.csi_bytes.clear(); + translated.push(0x1b); + translated.push(byte); + } + b']' | b'P' | b'^' | b'_' => { + self.escape_state = EscapeState::String; + translated.push(0x1b); + translated.push(byte); + } + _ => { + self.escape_state = EscapeState::Ground; + translated.push(0x1b); + translated.push(byte); + } + }, + EscapeState::CharsetSelect(charset) => { + match charset { + b'(' => self.g0_dec_special_graphics = byte == b'0', + b')' => self.g1_dec_special_graphics = byte == b'0', + _ => {} + } + self.escape_state = EscapeState::Ground; + } + EscapeState::Csi => { + self.csi_bytes.push(byte); + if byte == b'f' { + translated.push(b'H'); + } else { + translated.push(byte); + } + if (0x40..=0x7e).contains(&byte) { + self.escape_state = EscapeState::Ground; + if self.handle_csi_complete(&mut translated) { + translated.clear(); + } + self.csi_bytes.clear(); + } + } + EscapeState::String => { + translated.push(byte); + match byte { + 0x07 => self.escape_state = EscapeState::Ground, + 0x1b => self.escape_state = EscapeState::StringEscape, + _ => {} + } + } + EscapeState::StringEscape => { + translated.push(byte); + self.escape_state = if byte == b'\\' { EscapeState::Ground } else { EscapeState::String }; + } + } + } + + if !translated.is_empty() { + self.parser.process(&translated); + } + } + + fn handle_csi_complete(&mut self, translated: &mut [u8]) -> bool { + match self.csi_bytes.as_slice() { + b"?1h" => self.application_cursor_keys = true, + b"?1l" => self.application_cursor_keys = false, + b"6n" => { + self.parser.process(translated); + let (cur_row, cur_col) = self.parser.screen().cursor_position(); + self.responses.push(format!("\x1b[{};{}R", cur_row + 1, cur_col + 1).into_bytes()); + return true; + } + b"18t" => { + self.parser.process(translated); + let (rows, cols) = self.parser.screen().size(); + self.responses.push(format!("\x1b[8;{};{}t", rows, cols).into_bytes()); + return true; + } + _ => {} + } + + false + } +} + +/// Converts a single byte from the DEC Special Graphics table to its Unicode +/// equivalent (e.g. `x` → `│`, `q` → `─`). Appends the UTF-8 bytes to `out`. +fn push_dec_special_graphic(out: &mut Vec, byte: u8) { + let mapped = match byte { + b'`' => '◆', + b'a' => '▒', + b'b' => '␉', + b'c' => '␌', + b'd' => '␍', + b'e' => '␊', + b'f' => '°', + b'g' => '±', + b'h' => '␤', + b'i' => '␋', + b'j' => '┘', + b'k' => '┐', + b'l' => '┌', + b'm' => '└', + b'n' => '┼', + b'o' => '⎺', + b'p' => '⎻', + b'q' => '─', + b'r' => '⎼', + b's' => '⎽', + b't' => '├', + b'u' => '┤', + b'v' => '┴', + b'w' => '┬', + b'x' => '│', + b'y' => '≤', + b'z' => '≥', + b'{' => 'π', + b'|' => '≠', + b'}' => '£', + b'~' => '·', + _ => { + out.push(byte); + return; + } + }; + let mut buf = [0u8; 4]; + out.extend_from_slice(mapped.encode_utf8(&mut buf).as_bytes()); +} + +fn hold_error_popup(overlay: &Arc>, term: &mut Terminal>, screen: &vt100::Screen) { + let is_error = overlay.lock().unwrap().has_error; + if !is_error { + return; + } + + { + let mut state = overlay.lock().unwrap(); + state.popup.show_info(Some("Error".to_string()), "Press any key to exit", Some(palette::FG), Some(palette::ACCENT)); + } + term.draw(|f| render_frame(f, screen, &overlay.lock().unwrap())).ok(); + let _ = event::read(); +} + +fn screen_contains(screen: &vt100::Screen, pattern: &str) -> bool { + if pattern.is_empty() { + return false; + } + let (rows, cols) = screen.size(); + let mut buf = String::with_capacity((rows * cols) as usize); + for row in 0..rows { + for col in 0..cols { + if let Some(cell) = screen.cell(row, col) { + if cell.is_wide_continuation() { + continue; + } + buf.push_str(cell.contents()); + } + } + } + buf.contains(pattern) +} + +fn screen_has_ascii_alphanumeric(screen: &vt100::Screen) -> bool { + let (rows, cols) = screen.size(); + for row in 0..rows { + for col in 0..cols { + if let Some(cell) = screen.cell(row, col) { + if cell.is_wide_continuation() { + continue; + } + if cell.contents().chars().any(|c| c.is_ascii_alphanumeric()) { + return true; + } + } + } + } + false +} + +/// Renders PTY output through ratatui until the child process exits. +/// +/// Forwards real keystrokes to the PTY master, parses terminal output through +/// [`vt100::Parser`], and draws each frame with full 24-bit color plus a +/// floating status overlay in the top-right corner. +/// +/// `status_fd` is polled continuously for newline-delimited messages. The +/// first message is forwarded to `on_setup` (workspace path); subsequent +/// messages update `overlay.status_text`. +/// +/// `overlay` is shared with the VSOCK status listener so VM-originated +/// UI commands can update popups, progress bars, and status text. +pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, status_fd: RawFd, on_setup: F, overlay: Arc>) -> Result<(), String> +where + F: FnOnce(Vec) -> Result<(), String>, +{ + let stdin_fd = io::stdin().as_raw_fd(); + + let mut stdout = io::stdout(); + terminal::enable_raw_mode().map_err(|e| format!("raw mode: {e}"))?; + stdout.execute(EnterAlternateScreen).map_err(|e| format!("alt screen: {e}"))?; + stdout.execute(cursor::Show).map_err(|e| format!("cursor: {e}"))?; + + let mut term = Term::new(rows, cols); + + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend).map_err(|e| format!("terminal: {e}"))?; + + let mut pty_buf = [0u8; 4096]; + + let mut last_rows = rows; + let mut last_cols = cols; + + let mut on_setup = Some(on_setup); + let mut status_buf = Vec::new(); + + unsafe { + libc::signal(libc::SIGWINCH, handle_sigwinch as *const () as libc::sighandler_t); + } + + loop { + if RESIZED.swap(false, Ordering::SeqCst) { + if let Ok((new_cols, new_rows)) = terminal::size() { + if new_cols != last_cols || new_rows != last_rows { + last_cols = new_cols; + last_rows = new_rows; + term.set_size(new_rows, new_cols); + let ws = libc::winsize { ws_row: new_rows, ws_col: new_cols, ws_xpixel: 0, ws_ypixel: 0 }; + unsafe { + libc::ioctl(master_fd, libc::TIOCSWINSZ, &ws); + } + } + } + } + + let mut fds = [ + libc::pollfd { fd: master_fd, events: libc::POLLIN, revents: 0 }, + libc::pollfd { fd: stdin_fd, events: libc::POLLIN, revents: 0 }, + libc::pollfd { fd: status_fd, events: libc::POLLIN, revents: 0 }, + ]; + + let ret = unsafe { libc::poll(fds.as_mut_ptr(), 3, 16) }; + + if ret == -1 { + let err = io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EINTR) { + continue; + } + terminal.backend_mut().execute(LeaveAlternateScreen).ok(); + terminal::disable_raw_mode().ok(); + return Err(format!("poll: {err}")); + } + + let mut pty_output = false; + + if fds[0].revents & (libc::POLLIN | libc::POLLHUP) != 0 { + let n = unsafe { libc::read(master_fd, pty_buf.as_mut_ptr() as *mut libc::c_void, pty_buf.len()) }; + if n > 0 { + term.process(&pty_buf[..n as usize]); + for response in term.drain_responses() { + unsafe { + libc::write(master_fd, response.as_ptr() as *const libc::c_void, response.len()); + } + } + pty_output = true; + } else { + hold_error_popup(&overlay, &mut terminal, term.screen()); + break; + } + } + + if fds[1].revents & libc::POLLIN != 0 { + if let Ok(Event::Key(key)) = event::read() { + if key.kind != KeyEventKind::Press { + continue; + } + + let is_password = overlay.lock().is_ok_and(|s| matches!(s.popup.content, popup::PopupContent::Password { .. })); + if is_password { + handle_password_key(&overlay, status_fd, key); + } else if let Some(bytes) = key_to_bytes(&key, term.application_cursor_keys()) { + unsafe { + libc::write(master_fd, bytes.as_ptr() as *const libc::c_void, bytes.len()); + } + } + } + } + + if fds[2].revents & (libc::POLLIN | libc::POLLHUP) != 0 { + let mut chunk = [0u8; 256]; + let n = unsafe { libc::read(status_fd, chunk.as_mut_ptr() as *mut libc::c_void, chunk.len()) }; + if n > 0 { + status_buf.extend_from_slice(&chunk[..n as usize]); + } + while let Some(pos) = status_buf.iter().position(|&b| b == b'\n') { + let line = String::from_utf8_lossy(&status_buf[..pos]).into_owned(); + status_buf.drain(..=pos); + if let Some(cb) = on_setup.take() { + cb(line.into_bytes())?; + } else if let Some(cmd) = line.strip_prefix('@') { + if let Some((widget, cmd, opts, val)) = vscomm::decode_ui_payload(cmd.as_bytes()) { + let mut state = overlay.lock().unwrap(); + dispatch_ui_command(&mut state, widget, cmd, opts, val); + } + } else { + let mut state = overlay.lock().unwrap(); + let title = state.popup_title.clone(); + state.popup.show_info(title, &line, Some(palette::FG), Some(palette::ACCENT)); + } + } + } + + { + let mut state = overlay.lock().unwrap(); + let now = Instant::now(); + + if pty_output { + for action in &mut state.pending { + if action.first_pty_at.is_none() && action.triggers.iter().any(|t| matches!(t, Trigger::OnPty | Trigger::DelayMsAfterPty(_))) { + action.first_pty_at = Some(now); + } + } + } + + if (now - state.last_content_scan).as_millis() >= 100 { + state.last_content_scan = now; + if state.hide_on_ascii && screen_has_ascii_alphanumeric(term.screen()) { + state.popup.hide(); + state.hide_on_ascii = false; + } + if let Some(ref pat) = state.hide_on_content.clone() { + if screen_contains(term.screen(), pat) { + state.popup.hide(); + state.hide_on_content = None; + } + } + } + + let mut i = 0; + while i < state.pending.len() { + let fire = state.pending[i].triggers.iter().any(|t| match t { + Trigger::OnPty => pty_output, + Trigger::DelayMs(d) => (now - state.pending[i].enqueued_at).as_millis() as u64 >= *d, + Trigger::DelayMsAfterPty(d) => { + if let Some(first) = state.pending[i].first_pty_at { + (now - first).as_millis() as u64 >= *d + } else { + false + } + } + }); + if fire { + let action = state.pending.remove(i); + dispatch_ui_command(&mut state, &action.widget, &action.command, "", &action.value); + } else { + i += 1; + } + } + + state.popup.tick(); + + terminal.draw(|f| render_frame(f, term.screen(), &state)).map_err(|e| format!("draw: {e}"))?; + } + } + + terminal.backend_mut().execute(LeaveAlternateScreen).ok(); + terminal::disable_raw_mode().ok(); + unsafe { + libc::signal(libc::SIGWINCH, libc::SIG_DFL); + } + + Ok(()) +} + +fn handle_password_key(overlay: &Arc>, status_fd: RawFd, key: KeyEvent) { + let mut state = overlay.lock().unwrap(); + if key.code == KeyCode::Enter { + if let Some(password) = state.popup.password_value() { + let mut response = password.into_bytes(); + response.push(b'\n'); + state.popup.hide(); + unsafe { + libc::write(status_fd, response.as_ptr() as *const libc::c_void, response.len()); + } + } + } else { + state.popup.handle_password_key(&key); + } +} + +fn key_to_bytes(key: &KeyEvent, app_cursor: bool) -> Option> { + match key.code { + KeyCode::Char(c) => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + if c.is_ascii_alphabetic() { + Some(vec![(c.to_ascii_lowercase() as u8) & 0x1f]) + } else { + None + } + } else if key.modifiers.contains(KeyModifiers::ALT) { + let mut v = vec![0x1b]; + let mut buf = [0u8; 4]; + let len = c.encode_utf8(&mut buf).len(); + v.extend_from_slice(&buf[..len]); + Some(v) + } else { + let mut buf = [0u8; 4]; + let len = c.encode_utf8(&mut buf).len(); + Some(buf[..len].to_vec()) + } + } + KeyCode::Enter => Some(vec![b'\r']), + KeyCode::Backspace => Some(vec![0x7f]), + KeyCode::Tab => Some(vec![b'\t']), + KeyCode::Esc => Some(vec![0x1b]), + KeyCode::Up => Some(if app_cursor { b"\x1bOA".to_vec() } else { b"\x1b[A".to_vec() }), + KeyCode::Down => Some(if app_cursor { b"\x1bOB".to_vec() } else { b"\x1b[B".to_vec() }), + KeyCode::Right => Some(if app_cursor { b"\x1bOC".to_vec() } else { b"\x1b[C".to_vec() }), + KeyCode::Left => Some(if app_cursor { b"\x1bOD".to_vec() } else { b"\x1b[D".to_vec() }), + KeyCode::Home => Some(if app_cursor { b"\x1bOH".to_vec() } else { b"\x1b[H".to_vec() }), + KeyCode::End => Some(if app_cursor { b"\x1bOF".to_vec() } else { b"\x1b[F".to_vec() }), + KeyCode::PageUp => Some(b"\x1b[5~".to_vec()), + KeyCode::PageDown => Some(b"\x1b[6~".to_vec()), + KeyCode::Delete => Some(b"\x1b[3~".to_vec()), + KeyCode::Insert => Some(b"\x1b[2~".to_vec()), + KeyCode::F(n) => fn_key(n), + _ => None, + } +} + +fn fn_key(n: u8) -> Option> { + match n { + 1 => Some(b"\x1bOP".to_vec()), + 2 => Some(b"\x1bOQ".to_vec()), + 3 => Some(b"\x1bOR".to_vec()), + 4 => Some(b"\x1bOS".to_vec()), + 5 => Some(b"\x1b[15~".to_vec()), + 6 => Some(b"\x1b[17~".to_vec()), + 7 => Some(b"\x1b[18~".to_vec()), + 8 => Some(b"\x1b[19~".to_vec()), + 9 => Some(b"\x1b[20~".to_vec()), + 10 => Some(b"\x1b[21~".to_vec()), + 11 => Some(b"\x1b[23~".to_vec()), + 12 => Some(b"\x1b[24~".to_vec()), + _ => None, + } +} + +/// Converts a [`vt100::Color`] to a [`ratatui::style::Color`], preserving +/// 24-bit RGB, 256-color indexed palette, and terminal default. +fn to_ratatui_color(c: vt100::Color) -> Color { + match c { + vt100::Color::Default => Color::Reset, + vt100::Color::Idx(i) => Color::Indexed(i), + vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b), + } +} + +/// Renders one frame: writes every vt100 screen cell to the ratatui buffer +/// with full color and attributes, then draws overlay widgets (popup, +/// progress bar, status box) on top. +fn render_frame(f: &mut Frame, screen: &vt100::Screen, overlay: &OverlayState) { + let area = f.area(); + let (rows, cols) = screen.size(); + let max_rows = area.height.min(rows); + let max_cols = area.width.min(cols); + let buf = f.buffer_mut(); + + for row in 0..max_rows { + let mut col: u16 = 0; + while col < max_cols { + let x = area.x + col; + let y = area.y + row; + + if let Some(cell) = screen.cell(row, col) { + if cell.is_wide_continuation() { + col += 1; + continue; + } + + let mut style = Style::default().fg(to_ratatui_color(cell.fgcolor())).bg(to_ratatui_color(cell.bgcolor())); + + if cell.bold() { + style = style.add_modifier(Modifier::BOLD); + } + if cell.dim() { + style = style.add_modifier(Modifier::DIM); + } + if cell.italic() { + style = style.add_modifier(Modifier::ITALIC); + } + if cell.underline() { + style = style.add_modifier(Modifier::UNDERLINED); + } + if cell.inverse() { + style = style.add_modifier(Modifier::REVERSED); + } + + let ch = cell.contents(); + let display: &str = if ch.is_empty() { " " } else { ch }; + + if let Some(c) = buf.cell_mut((x, y)) { + c.set_symbol(display); + c.set_style(style); + } + + if cell.is_wide() { + if col + 1 < max_cols { + if let Some(c) = buf.cell_mut((x + 1, y)) { + c.set_symbol(" "); + c.set_style(style); + } + } + col += 2; + } else { + col += 1; + } + } else { + if let Some(c) = buf.cell_mut((x, y)) { + c.set_symbol(" "); + c.set_style(Style::default()); + } + col += 1; + } + } + } + + { + let buf = f.buffer_mut(); + overlay.popup.render(area, buf); + } + + let (cursor_row, cursor_col) = screen.cursor_position(); + if cursor_row < max_rows && cursor_col < max_cols { + f.set_cursor_position((area.x + cursor_col, area.y + cursor_row)); + } +} + +#[cfg(test)] +#[path = "tui_ut.rs"] +mod tui_tests; diff --git a/src/tui/palette.rs b/src/tui/palette.rs new file mode 100644 index 0000000..202b3ad --- /dev/null +++ b/src/tui/palette.rs @@ -0,0 +1,44 @@ +#![allow(dead_code)] + +use ratatui::style::Color; + +// --------------------------------------------------------------------------- +// Industrial / bunker palette — cold steel, amber warning lights, radar cyan +// --------------------------------------------------------------------------- + +// ---- Neutral foundation ---- +pub const BG_0: Color = Color::Indexed(232); +pub const BG_1: Color = Color::Indexed(233); +pub const BG_2: Color = Color::Indexed(235); +pub const BG_3: Color = Color::Indexed(237); + +pub const GRAY_0: Color = Color::Indexed(239); +pub const GRAY_1: Color = Color::Indexed(244); +pub const GRAY_2: Color = Color::Indexed(249); + +// ---- Semantic aliases ---- + +pub const SURFACE: Color = Color::Indexed(236); +pub const FG: Color = Color::Indexed(253); +pub const MUTED: Color = Color::Indexed(243); +pub const FAINT: Color = Color::Indexed(238); +pub const BORDER: Color = Color::Indexed(240); + +pub const ACCENT: Color = Color::Indexed(208); +pub const PROCESSING: Color = Color::Indexed(39); +pub const SUCCESS: Color = Color::Indexed(42); +pub const ERROR: Color = Color::Indexed(197); +pub const WARNING: Color = Color::Indexed(178); +pub const HIGHLIGHT: Color = Color::Indexed(133); + +pub const POPUP_BG: Color = SURFACE; + +// ---- MS-DOS shadow colours ---- + +pub const SHADOW_BG: Color = Color::Black; +pub const SHADOW_FG: Color = SURFACE; + +// ---- Absolute contrast ---- + +pub const WHITE: Color = Color::White; +pub const BLACK: Color = Color::Black; diff --git a/src/tui/popup.rs b/src/tui/popup.rs new file mode 100644 index 0000000..c1f27b4 --- /dev/null +++ b/src/tui/popup.rs @@ -0,0 +1,338 @@ +use std::time::Instant; + +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Position, Rect}, + prelude::{Buffer, Style}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Clear, Gauge, Padding, Paragraph, Widget}, +}; +use ratatui_glamour::{ + color::blend_2d, + widgets::{passwordinput, spinner}, +}; + +pub struct PassModel(passwordinput::Model); +unsafe impl Send for PassModel {} +unsafe impl Sync for PassModel {} + +use super::palette; + +const SPINNER_FPS_MS: u64 = 83; + +pub enum PopupContent { + Spinner { message: String, model: spinner::Model, last_tick: Instant }, + Progress { title: String, percent: f64, label: Option }, + Password { title: String, prompt: String, model: Box }, + Info { title: Option, message: String, fg: ratatui::style::Color }, +} + +pub struct PopupWidget { + pub visible: bool, + pub content: PopupContent, + pub border_color: ratatui::style::Color, + shadow: bool, +} + +impl PopupWidget { + pub fn new() -> Self { + Self { + visible: false, + content: PopupContent::Info { title: None, message: String::new(), fg: palette::FG }, + border_color: palette::BORDER, + shadow: true, + } + } + + pub fn show_spinner(&mut self, message: impl Into) { + let mut model = spinner::Model::new(); + model.spinner = spinner::Spinner::mini_dot(); + model.style = Style::default().fg(palette::ACCENT); + self.content = PopupContent::Spinner { message: message.into(), model, last_tick: Instant::now() }; + self.visible = true; + } + + pub fn show_progress(&mut self, title: impl Into, percent: f64, label: Option) { + self.border_color = palette::ACCENT; + self.content = PopupContent::Progress { title: title.into(), percent: percent.clamp(0.0, 1.0), label }; + self.visible = true; + } + + pub fn set_progress(&mut self, pct: f64, lbl: Option) { + if let PopupContent::Progress { ref mut percent, ref mut label, .. } = &mut self.content { + *percent = pct.clamp(0.0, 1.0); + *label = lbl; + } + } + + pub fn show_password(&mut self, title: impl Into, prompt: impl Into) { + self.border_color = palette::ACCENT; + let mut model = passwordinput::Model::new(); + model.set_prompt(""); + model.focus(); + self.content = PopupContent::Password { title: title.into(), prompt: prompt.into(), model: Box::new(PassModel(model)) }; + self.visible = true; + } + + pub fn show_info( + &mut self, title: Option, message: impl Into, fg: Option, border_color: Option, + ) { + self.border_color = border_color.unwrap_or(palette::BORDER); + self.content = PopupContent::Info { title, message: message.into(), fg: fg.unwrap_or(palette::FG) }; + self.visible = true; + } + + pub fn tick(&mut self) { + if let PopupContent::Spinner { ref mut model, ref mut last_tick, .. } = &mut self.content { + if last_tick.elapsed().as_millis() as u64 >= SPINNER_FPS_MS { + let tick = model.tick(); + model.update(tick); + *last_tick = Instant::now(); + } + } + } + + pub fn hide(&mut self) { + self.visible = false; + } + + pub fn handle_password_key(&mut self, key: &crossterm::event::KeyEvent) { + if let PopupContent::Password { ref mut model, .. } = &mut self.content { + model.0.handle_key(key); + } + } + + pub fn password_value(&self) -> Option { + if let PopupContent::Password { ref model, .. } = &self.content { + Some(model.0.value()) + } else { + None + } + } + + /// Compute the natural content dimensions (width, height) for the popup interior. + fn content_size(&self) -> (u16, u16) { + match &self.content { + PopupContent::Spinner { message, model, .. } => { + let frame = model.view(); + let text = format!("{} {}", frame, message); + let w = (text.len() as u16 + 8).max(30); + (w, 5) + } + PopupContent::Progress { title, label, .. } => { + let label_w = label.as_ref().map_or(0, |l| l.len() as u16); + let w = (title.len() as u16).max(label_w).max(40); + (w + 8, 7) + } + PopupContent::Password { title: _, prompt, .. } => { + let w = (prompt.len() as u16 + 20).max(40); + (w, 7) + } + PopupContent::Info { message, .. } => { + let lines = text_lines(message); + let max_w = message.lines().map(|l| l.len() as u16).max().unwrap_or(20); + let w = max_w.max(30); + let h = lines.max(1) + 4; + (w + 8, h) + } + } + } + + pub fn render(&self, area: Rect, buf: &mut Buffer) { + if !self.visible { + return; + } + + let (content_w, content_h) = self.content_size(); + let width = content_w.min(area.width.saturating_sub(4)).max(20); + let height = content_h.min(area.height.saturating_sub(4)); + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + let canvas = Rect { x, y, width, height }; + + Clear.render(canvas, buf); + + self.render_gradient(canvas, buf); + + let title_line = self.build_title(); + let has_title = !matches!(&self.content, PopupContent::Spinner { .. }); + + let block = Block::default() + .title(if has_title { title_line } else { Line::from("") }) + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(self.border_color)) + .padding(Padding::horizontal(2)) + .style(Style::default()); + + let inner = block.inner(canvas); + block.render(canvas, buf); + + match &self.content { + PopupContent::Spinner { message, model, .. } => { + self.render_spinner(inner, buf, message, model); + } + PopupContent::Progress { title: _, percent, label } => { + self.render_progress(inner, buf, *percent, label); + } + PopupContent::Password { title: _, prompt, model } => { + self.render_password(inner, buf, prompt, model); + } + PopupContent::Info { message, fg, .. } => { + self.render_info(inner, buf, message, *fg); + } + } + + if self.shadow { + self.draw_shadow(canvas, height, buf); + } + } + + fn build_title(&self) -> Line<'static> { + let (title_text, bc) = match &self.content { + PopupContent::Progress { title, .. } => (title.clone(), self.border_color), + PopupContent::Password { title, .. } => (title.clone(), self.border_color), + PopupContent::Info { title, .. } => (title.clone().unwrap_or_default(), self.border_color), + PopupContent::Spinner { .. } => return Line::from(""), + }; + + if title_text.is_empty() { + return Line::from(""); + } + + let text: String = format!(" {} ", title_text); + Line::from(vec![ + Span::styled("\u{E0B2}", Style::default().fg(bc)), + Span::styled(text, Style::default().fg(palette::BLACK).bg(bc)), + Span::styled("\u{E0B0}", Style::default().fg(bc)), + ]) + } + + fn render_gradient(&self, canvas: Rect, buf: &mut Buffer) { + let stops: &[ratatui::style::Color] = &[palette::GRAY_0, palette::BG_2]; + let colors = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, stops); + for row in 0..canvas.height { + for col in 0..canvas.width { + let idx = row as usize * canvas.width as usize + col as usize; + if idx < colors.len() { + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { + cell.set_bg(colors[idx]); + } + } + } + } + } + + fn render_spinner(&self, inner: Rect, buf: &mut Buffer, message: &str, model: &spinner::Model) { + let frame_str = model.view(); + let text = format!("{} {}", frame_str, message); + let text_w = text.len() as u16; + + let [_, mid, _]: [Rect; 3] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1), Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + + let x = mid.x + (mid.width.saturating_sub(text_w)) / 2; + let text_area = Rect { x, y: mid.y, width: text_w, height: 1 }; + + let mut spans: Vec = vec![frame_str.spans[0].clone(), Span::raw(" ")]; + spans.push(Span::styled(message.to_string(), Style::default().fg(palette::FG))); + Paragraph::new(Line::from(spans)).alignment(Alignment::Left).render(text_area, buf); + } + + fn render_progress(&self, inner: Rect, buf: &mut Buffer, percent: f64, label: &Option) { + let [_, gauge_area, label_area, _]: [Rect; 4] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + + let pct = (percent * 100.0) as u16; + Gauge::default().gauge_style(Style::default().fg(palette::ACCENT).bg(palette::BG_3)).percent(pct).render(gauge_area, buf); + + if let Some(lbl) = label { + let pct_text = format!(" {} {}%", lbl, pct); + let w = pct_text.len() as u16; + let x = label_area.x + (label_area.width.saturating_sub(w)) / 2; + let rect = Rect { x, y: label_area.y, width: w, height: 1 }; + Paragraph::new(pct_text).style(Style::default().fg(palette::MUTED)).alignment(Alignment::Center).render(rect, buf); + } else { + let pct_text = format!("{}%", pct); + let w = pct_text.len() as u16; + let x = label_area.x + (label_area.width.saturating_sub(w)) / 2; + let rect = Rect { x, y: label_area.y, width: w, height: 1 }; + Paragraph::new(pct_text).style(Style::default().fg(palette::MUTED)).alignment(Alignment::Center).render(rect, buf); + } + } + + fn render_password(&self, inner: Rect, buf: &mut Buffer, prompt: &str, model: &PassModel) { + let [_, prompt_area, input_area, _]: [Rect; 4] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + + let pw = prompt.len() as u16; + let px = prompt_area.x + (prompt_area.width.saturating_sub(pw)) / 2; + Paragraph::new(prompt.to_string()) + .style(Style::default().fg(palette::FG)) + .alignment(Alignment::Center) + .render(Rect { x: px, y: prompt_area.y, width: pw, height: 1 }, buf); + + Paragraph::new(model.0.view()).render(input_area, buf); + } + + fn render_info(&self, inner: Rect, buf: &mut Buffer, message: &str, fg: ratatui::style::Color) { + let text = format!("\n{}", message); + Paragraph::new(text).alignment(Alignment::Center).style(Style::default().fg(fg)).render(inner, buf); + } + + fn draw_shadow(&self, canvas: Rect, height: u16, buf: &mut Buffer) { + let buf_area = buf.area(); + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + + for idx in 0..canvas.width { + let sx = canvas.x.saturating_add(2).saturating_add(idx); + let sy = canvas.y.saturating_add(height); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + + for offset in 0..2 { + for idx in 0..height { + let sx = canvas.x.saturating_add(canvas.width).saturating_add(offset); + let sy = canvas.y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } + } +} + +fn text_lines(text: &str) -> u16 { + let count = text.lines().count(); + if count == 0 { + 0 + } else { + count as u16 + } +} diff --git a/src/tui_ut.rs b/src/tui_ut.rs new file mode 100644 index 0000000..0782a8d --- /dev/null +++ b/src/tui_ut.rs @@ -0,0 +1,45 @@ +use super::Term; + +#[test] +fn cursor_report_uses_position_after_prior_bytes_in_same_chunk() { + let mut term = Term::new(24, 80); + + term.process(b"\x1b[10;20H\x1b[6n"); + + assert_eq!(term.drain_responses(), vec![b"\x1b[10;20R".to_vec()]); +} + +#[test] +fn cursor_report_handles_split_query() { + let mut term = Term::new(24, 80); + + term.process(b"\x1b[10;20H\x1b["); + assert!(term.drain_responses().is_empty()); + term.process(b"6n"); + + assert_eq!(term.drain_responses(), vec![b"\x1b[10;20R".to_vec()]); +} + +#[test] +fn cursor_mode_handles_split_sequences() { + let mut term = Term::new(24, 80); + + term.process(b"\x1b[?"); + term.process(b"1h"); + assert!(term.application_cursor_keys()); + + term.process(b"\x1b[?1"); + term.process(b"l"); + assert!(!term.application_cursor_keys()); +} + +#[test] +fn window_size_report_handles_split_query() { + let mut term = Term::new(24, 80); + + term.process(b"\x1b[18"); + assert!(term.drain_responses().is_empty()); + term.process(b"t"); + + assert_eq!(term.drain_responses(), vec![b"\x1b[8;24;80t".to_vec()]); +} diff --git a/src/vscomm/mod.rs b/src/vscomm/mod.rs index a744b48..ea5b843 100644 --- a/src/vscomm/mod.rs +++ b/src/vscomm/mod.rs @@ -5,6 +5,7 @@ use std::io::{self, Read, Write}; pub mod buildsys; pub const VSOCK_PORT: u32 = 9999; +pub const STATUS_PORT: u32 = 9998; pub const VSCOMM_BIN_DIR: &str = "/usr/local/bunkerbox/bin"; #[repr(u16)] @@ -15,6 +16,7 @@ pub enum FrameType { Stderr = 3, Exit = 4, Disconnect = 5, + UiCommand = 10, } impl FrameType { @@ -25,6 +27,7 @@ impl FrameType { 3 => Some(Self::Stderr), 4 => Some(Self::Exit), 5 => Some(Self::Disconnect), + 10 => Some(Self::UiCommand), _ => None, } } @@ -150,3 +153,64 @@ impl ExecRequest { Ok(Self { cwd, command, args, env }) } } + +pub fn encode_ui_payload(widget: &str, command: &str, options: &str, value: &str) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(widget.as_bytes()); + buf.push(0); + buf.extend_from_slice(command.as_bytes()); + buf.push(0); + buf.extend_from_slice(options.as_bytes()); + buf.push(0); + buf.extend_from_slice(value.as_bytes()); + buf.push(0); + buf +} + +pub fn decode_ui_payload(payload: &[u8]) -> Option<(&str, &str, &str, &str)> { + let mut parts = payload.split(|&b| b == 0); + let widget = std::str::from_utf8(parts.next()?).ok()?; + let command = std::str::from_utf8(parts.next()?).ok()?; + let options = std::str::from_utf8(parts.next()?).ok()?; + let value = std::str::from_utf8(parts.next()?).ok()?; + Some((widget, command, options, value)) +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Trigger { + OnPty, + DelayMs(u64), + DelayMsAfterPty(u64), +} + +pub fn parse_triggers(options: &str) -> Vec { + if options.is_empty() { + return Vec::new(); + } + + let mut has_on_pty = false; + let mut delay_ms: Option = None; + + for s in options.split(',') { + let s = s.trim(); + if s == "ON_PTY" { + has_on_pty = true; + } else if let Some(secs) = s.strip_prefix("SEC_") { + if let Ok(secs) = secs.parse::() { + delay_ms = Some((secs * 1000.0) as u64); + } + } + } + + if has_on_pty { + if let Some(ms) = delay_ms { + vec![Trigger::DelayMsAfterPty(ms)] + } else { + vec![Trigger::OnPty] + } + } else if let Some(ms) = delay_ms { + vec![Trigger::DelayMs(ms)] + } else { + Vec::new() + } +} diff --git a/src/wrap.rs b/src/wrap.rs new file mode 100644 index 0000000..eaba7c3 --- /dev/null +++ b/src/wrap.rs @@ -0,0 +1,34 @@ +/// Generates a POSIX shell init script that wraps each command +/// with `bunkerbox-status` progress reporting and runs them sequentially. +/// Falls through to an interactive shell after the command list. +pub fn generate_init_script(commands: &[String]) -> String { + let mut script = String::from( + "#!/bin/sh\n\ + export PATH=/usr/local/bunkerbox/bin:/bunkerbox-tools:$PATH\n\ + \n", + ); + + script.push_str("bunkerbox-vscomm install 2>/dev/null\n"); + script.push_str("cd /workspace\n\n"); + + let count = commands.len(); + for (i, cmd) in commands.iter().enumerate() { + let label = truncate_label(cmd, 60); + script.push_str(&format!("bunkerbox-status status set \"Running: {label}\"\n")); + if i == count.saturating_sub(1) { + script.push_str("bunkerbox-status popup hide --on-find-ascii\n"); + } + script.push_str(&format!("{cmd}\n")); + script.push_str(&format!("bunkerbox-status status set \"Done: {label}\"\n\n")); + } + + script +} + +fn truncate_label(s: &str, max: usize) -> &str { + if s.len() <= max { + s + } else { + &s[..max] + } +} diff --git a/tests/test_proxy.rs b/tests/test_proxy.rs index 7d1c423..829cee1 100644 --- a/tests/test_proxy.rs +++ b/tests/test_proxy.rs @@ -13,19 +13,11 @@ async fn proxy_allows_connect_to_allowed_host() { sock.write_all(&buf[..n]).await.unwrap(); }); - let (handle, proxy_port) = FilterProxy::new(vec!["localhost".into()]) - .bind_on(0) - .await - .unwrap(); + let (handle, proxy_port) = FilterProxy::new(vec!["localhost".into()]).bind_on(0).await.unwrap(); - let mut client = tokio::net::TcpStream::connect(format!("127.0.0.1:{proxy_port}")) - .await - .unwrap(); + let mut client = tokio::net::TcpStream::connect(format!("127.0.0.1:{proxy_port}")).await.unwrap(); - client - .write_all(format!("CONNECT localhost:{echo_port} HTTP/1.1\r\n\r\n").as_bytes()) - .await - .unwrap(); + client.write_all(format!("CONNECT localhost:{echo_port} HTTP/1.1\r\n\r\n").as_bytes()).await.unwrap(); let mut response = [0u8; 256]; let n = client.read(&mut response).await.unwrap(); @@ -42,19 +34,11 @@ async fn proxy_allows_connect_to_allowed_host() { #[tokio::test] async fn proxy_blocks_connect_to_denied_host() { - let (handle, proxy_port) = FilterProxy::new(vec!["only.this.host".into()]) - .bind_on(0) - .await - .unwrap(); + let (handle, proxy_port) = FilterProxy::new(vec!["only.this.host".into()]).bind_on(0).await.unwrap(); - let mut client = tokio::net::TcpStream::connect(format!("127.0.0.1:{proxy_port}")) - .await - .unwrap(); + let mut client = tokio::net::TcpStream::connect(format!("127.0.0.1:{proxy_port}")).await.unwrap(); - client - .write_all(b"CONNECT evil.com:443 HTTP/1.1\r\n\r\n") - .await - .unwrap(); + client.write_all(b"CONNECT evil.com:443 HTTP/1.1\r\n\r\n").await.unwrap(); let mut response = [0u8; 256]; let n = client.read(&mut response).await.unwrap(); @@ -70,27 +54,14 @@ async fn proxy_forwards_plain_http_to_allowed_host() { let srv_port = srv.local_addr().unwrap().port(); tokio::spawn(async move { let (mut sock, _) = srv.accept().await.unwrap(); - sock.write_all(b"HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nworld") - .await - .unwrap(); + sock.write_all(b"HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nworld").await.unwrap(); }); - let (handle, proxy_port) = FilterProxy::new(vec!["localhost".into()]) - .bind_on(0) - .await - .unwrap(); - - let mut client = tokio::net::TcpStream::connect(format!("127.0.0.1:{proxy_port}")) - .await - .unwrap(); - - client - .write_all( - format!("GET http://localhost:{srv_port}/items HTTP/1.1\r\nHost: localhost\r\n\r\n") - .as_bytes(), - ) - .await - .unwrap(); + let (handle, proxy_port) = FilterProxy::new(vec!["localhost".into()]).bind_on(0).await.unwrap(); + + let mut client = tokio::net::TcpStream::connect(format!("127.0.0.1:{proxy_port}")).await.unwrap(); + + client.write_all(format!("GET http://localhost:{srv_port}/items HTTP/1.1\r\nHost: localhost\r\n\r\n").as_bytes()).await.unwrap(); let mut response = Vec::new(); let mut buf = [0u8; 512];