diff --git a/Cargo.lock b/Cargo.lock index ae2018c..dbba543 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -102,6 +108,53 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -120,6 +173,24 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.11.1" @@ -156,6 +227,53 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee4243f1f26fc7a42710e7439c149e2b10b05472f88090acce52632f231a73a" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.62" @@ -273,6 +391,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -288,6 +420,31 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "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" @@ -334,6 +491,40 @@ dependencies = [ "syn", ] +[[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", +] + +[[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", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -347,6 +538,9 @@ dependencies = [ "anyhow", "clap", "dd-client-core", + "dd-client-session", + "libc", + "ratatui", "serde_json", "tokio", ] @@ -361,7 +555,6 @@ dependencies = [ "futures-util", "hex", "jsonwebtoken", - "libc", "rand 0.8.6", "reqwest", "serde", @@ -377,10 +570,33 @@ dependencies = [ name = "dd-client-ffi" version = "0.1.0" dependencies = [ + "anyhow", "dd-client-core", + "dd-client-session", "serde_json", "tempfile", + "thiserror 2.0.18", "tokio", + "uniffi", +] + +[[package]] +name = "dd-client-session" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "chacha20poly1305", + "dd-client-core", + "hex", + "hkdf", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "tokio", + "vt100", + "x25519-dalek", ] [[package]] @@ -414,6 +630,18 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -442,6 +670,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -451,6 +685,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -550,6 +793,34 @@ dependencies = [ "polyval", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "heck" version = "0.5.0" @@ -562,6 +833,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -766,6 +1055,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -787,6 +1082,15 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -796,6 +1100,19 @@ 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", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -808,6 +1125,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -847,6 +1173,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[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" @@ -859,12 +1191,30 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[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.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -877,6 +1227,28 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[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.0" @@ -884,10 +1256,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] +[[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-bigint" version = "0.4.6" @@ -940,6 +1323,35 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem" version = "3.0.6" @@ -962,6 +1374,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "poly1305" version = "0.8.0" @@ -1032,7 +1450,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1053,7 +1471,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1147,6 +1565,36 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -1214,6 +1662,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1223,7 +1684,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1274,11 +1735,41 @@ 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 = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -1363,6 +1854,37 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simple_asn1" version = "0.6.4" @@ -1371,10 +1893,16 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.18", "time", ] +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "slab" version = "0.4.12" @@ -1387,6 +1915,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "snow" version = "0.9.6" @@ -1419,12 +1953,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[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.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1471,17 +2033,46 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1603,6 +2194,15 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "tower" version = "0.5.3" @@ -1688,7 +2288,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror", + "thiserror 2.0.18", "utf-8", ] @@ -1698,12 +2298,165 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "uniffi" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cb08c58c7ed7033150132febe696bef553f891b1ede57424b40d87a89e3c170" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cade167af943e189a55020eda2c314681e223f1e42aca7c4e52614c2b627698f" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "once_cell", + "paste", + "serde", + "textwrap", + "toml", + "uniffi_meta", + "uniffi_udl", +] + +[[package]] +name = "uniffi_checksum_derive" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "802d2051a700e3ec894c79f80d2705b69d85844dafbbe5d1a92776f8f48b563a" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "uniffi_core" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7687007d2546c454d8ae609b105daceb88175477dac280707ad6d95bcd6f1f" +dependencies = [ + "anyhow", + "bytes", + "log", + "once_cell", + "paste", + "static_assertions", +] + +[[package]] +name = "uniffi_macros" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c65a5b12ec544ef136693af8759fb9d11aefce740fb76916721e876639033b" +dependencies = [ + "bincode", + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a74ed96c26882dac1ca9b93ca23c827e284bacbd7ec23c6f0b0372f747d59e4" +dependencies = [ + "anyhow", + "bytes", + "siphasher", + "uniffi_checksum_derive", +] + +[[package]] +name = "uniffi_testing" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6f984f0781f892cc864a62c3a5c60361b1ccbd68e538e6c9fbced5d82268ac" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "fs-err", + "once_cell", +] + +[[package]] +name = "uniffi_udl" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037820a4cfc4422db1eaa82f291a3863c92c7d1789dc513489c36223f9b4cdfc" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "uniffi_testing", + "weedle2", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -1762,6 +2515,39 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width 0.1.14", + "vte", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "want" version = "0.3.1" @@ -1879,6 +2665,37 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + +[[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-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 0ebf93c..45550fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/dd-client-core", + "crates/dd-client-session", "crates/dd-client-cli", "crates/dd-client-ffi", ] diff --git a/apps/ios/DevOpsDefender/ContentView.swift b/apps/ios/DevOpsDefender/ContentView.swift index aa47ed6..188e707 100644 --- a/apps/ios/DevOpsDefender/ContentView.swift +++ b/apps/ios/DevOpsDefender/ContentView.swift @@ -1,24 +1,193 @@ import SwiftUI struct ContentView: View { + @StateObject private var model = SessionModel() + var body: some View { NavigationStack { - List { - Section("Pairing") { - LabeledContent("Device key", value: "Not generated") - Button("Generate enrollment URL") {} + if model.connected { + SessionView(model: model) + .navigationTitle("Session") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Disconnect") { model.disconnect() } + } + } + } else { + ConnectForm(model: model) + .navigationTitle("DevOps Defender") + } + } + } +} + +/// Connect form. `keyPath` defaults into the app's container; the device key is +/// created on first use and enrolled out-of-band via the CP `/admin/enroll` URL +/// (`keygen` returns it). +struct ConnectForm: View { + @ObservedObject var model: SessionModel + @State private var agentUrl = "https://" + @State private var sessionId = "" + @State private var insecure = false + + private var keyPath: String { + let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + return dir.appendingPathComponent("noise.key").path + } + + var body: some View { + Form { + Section("Agent") { + TextField("Agent URL", text: $agentUrl) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("Session ID", text: $sessionId) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Toggle("Skip quote verification", isOn: $insecure) + } + Section { + Button(model.connecting ? "Connecting…" : "Connect") { + model.connect( + agentUrl: agentUrl, keyPath: keyPath, + sessionId: sessionId, insecure: insecure) } + .disabled(model.connecting || agentUrl.isEmpty || sessionId.isEmpty) + } + Section { Text(model.status).foregroundStyle(.secondary) } + } + } +} + +/// The session: a mode picker, the streamed block document, and (in Interact) +/// an input bar. +struct SessionView: View { + @ObservedObject var model: SessionModel + @State private var draft = "" - Section("Agents") { - ContentUnavailableView("No agents", systemImage: "server.rack") + var body: some View { + VStack(spacing: 0) { + Picker("Mode", selection: Binding(get: { model.mode }, set: model.setMode)) { + Text("Watch").tag(FfiMode.watch) + Text("Interact").tag(FfiMode.interact) + Text("Raw").tag(FfiMode.raw) + } + .pickerStyle(.segmented) + .padding(8) + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(Array(model.blocks.enumerated()), id: \.offset) { idx, block in + BlockView(block: block, interactive: model.mode == .interact) { opt, i in + model.pick(option: opt, index: i) + } + .id(idx) + } + } + .padding(.horizontal) + } + .onChange(of: model.blocks.count) { _, count in + if count > 0 { proxy.scrollTo(count - 1, anchor: .bottom) } } } - .navigationTitle("DevOps Defender") + + if model.mode == .interact { + HStack { + TextField("Send to session…", text: $draft) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button("Send") { + model.send(draft + "\n") + draft = "" + } + .disabled(draft.isEmpty) + } + .padding(8) + } } } } -#Preview { - ContentView() +/// Renders one block. The whole point of the design: markdown reads as markdown, +/// menus are tappable, code/diffs are monospaced — not a terminal. +struct BlockView: View { + let block: FfiBlock + let interactive: Bool + let onPick: (FfiMenuOption, Int) -> Void + + var body: some View { + switch block { + case let .markdown(text, _): + Text((try? AttributedString(markdown: text)) ?? AttributedString(text)) + .frame(maxWidth: .infinity, alignment: .leading) + + case let .code(lang, text, _): + VStack(alignment: .leading, spacing: 2) { + if let lang { Text(lang).font(.caption2).foregroundStyle(.secondary) } + Text(text) + .font(.system(.body, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(8) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) + + case let .diff(unified, _): + DiffView(unified: unified) + + case let .menu(title, options, selected, resolved): + VStack(alignment: .leading, spacing: 6) { + if let title { Text(title).font(.headline) } + ForEach(Array(options.enumerated()), id: \.offset) { i, opt in + Button { onPick(opt, i) } label: { + HStack { + Image(systemName: selected == UInt32(i) + ? "largecircle.fill.circle" : "circle") + Text(opt.label) + Spacer() + } + } + .buttonStyle(.bordered) + .disabled(!interactive || resolved) + } + } + .padding(8) + .background(.yellow.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + + case let .input(prompt): + Text(prompt).italic().foregroundStyle(.secondary) + + case let .rawTerminal(screen): + Text(screen) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } } +struct DiffView: View { + let unified: String + var body: some View { + VStack(alignment: .leading, spacing: 0) { + let lines = unified.split(separator: "\n", omittingEmptySubsequences: false) + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + Text(String(line)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(color(for: line.first)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(6) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) + } + + private func color(for first: Character?) -> Color { + switch first { + case "+": return .green + case "-": return .red + case "@": return .purple + default: return .primary + } + } +} diff --git a/apps/ios/DevOpsDefender/SessionModel.swift b/apps/ios/DevOpsDefender/SessionModel.swift new file mode 100644 index 0000000..7542edb --- /dev/null +++ b/apps/ios/DevOpsDefender/SessionModel.swift @@ -0,0 +1,106 @@ +import Foundation +import SwiftUI + +// Types referenced here — `SessionHandle`, `BlockObserver`, `FfiBlock`, +// `FfiMode`, `FfiMenuOption`, `keygen`, `KeygenResult` — come from the +// UniFFI-generated `dd-client-ffi` bindings. Generate them with: +// +// cargo build -p dd-client-ffi --release # produces the staticlib +// cargo run -p uniffi-bindgen -- generate \ +// --library target/release/libdd_client_ffi.a --language swift --out-dir apps/ios/Generated +// +// then add Generated/*.swift + the static lib (as an xcframework) to the target. + +/// Observable wrapper around a live `SessionHandle`. All interpretation lives in +/// Rust; this just mirrors the block snapshot into SwiftUI on change. +@MainActor +final class SessionModel: ObservableObject { + @Published var blocks: [FfiBlock] = [] + @Published var mode: FfiMode = .watch + @Published var status: String = "Not connected" + @Published var connected = false + @Published var connecting = false + + private var handle: SessionHandle? + private var observer: ChangeObserver? + + func connect(agentUrl: String, keyPath: String, sessionId: String, insecure: Bool) { + connecting = true + status = "Connecting…" + Task.detached { [weak self] in + do { + // attach() blocks on the Noise handshake — off the main actor. + let handle = try SessionHandle.attach( + agentUrl: agentUrl, + keyPath: keyPath, + sessionId: sessionId, + insecureSkipQuoteVerify: insecure, + jwksUrl: "https://portal.trustauthority.intel.com/certs", + issuer: "https://portal.trustauthority.intel.com" + ) + await self?.onAttached(handle) + } catch { + await self?.onFailed(error) + } + } + } + + private func onAttached(_ handle: SessionHandle) { + self.handle = handle + let observer = ChangeObserver(model: self) + self.observer = observer + handle.subscribe(observer: observer) + self.mode = handle.mode() + self.connecting = false + self.connected = true + self.status = "Connected" + reload() + } + + private func onFailed(_ error: Error) { + self.connecting = false + self.status = "Failed: \(error)" + } + + func reload() { + blocks = handle?.blocks() ?? [] + } + + func setMode(_ newMode: FfiMode) { + handle?.setMode(mode: newMode) + mode = newMode + } + + /// Send text to the session (ignored by the engine in Watch — read-only). + func send(_ text: String) { + handle?.sendText(text: text) + } + + /// Pick a menu option: send its hotkey when known, else its 1-based number. + func pick(option: FfiMenuOption, index: Int) { + if let hotkey = option.hotkey { + send(hotkey) + } else { + send(String(index + 1)) + } + } + + func disconnect() { + handle?.close() + handle = nil + observer = nil + connected = false + status = "Disconnected" + blocks = [] + } +} + +/// Bridges the Rust `BlockObserver` callback onto the main actor. +final class ChangeObserver: BlockObserver { + weak var model: SessionModel? + init(model: SessionModel) { self.model = model } + + func onChanged() { + Task { @MainActor [weak model] in model?.reload() } + } +} diff --git a/apps/ios/README.md b/apps/ios/README.md index 8e12b9e..7330934 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -2,25 +2,52 @@ The iOS client should be a native SwiftUI app backed by the Rust client core. -Initial split: +Split: - SwiftUI owns screens, navigation, notifications, Keychain access, and iOS lifecycle. -- `dd-client-core` owns protocol behavior: pairing keys, quote verification, - direct agent Noise transport, session RPCs, and PTY bytes. -- `dd-client-ffi` exposes a C-compatible bridge that can be linked into an - Xcode target as a static library. +- `dd-client-session` owns interpretation: blocks, the floor/agent derivers, + view modes, and history decryption — shared verbatim with the CLI. +- `dd-client-core` owns protocol behavior: pairing keys, quote verification + (verify-only, no Intel account), Noise transport, session RPCs, PTY bytes. +- `dd-client-ffi` exposes all of the above over **UniFFI** (Swift + Kotlin + generated from one Rust surface) — no hand-written C. -First screen to build: +The app is a renderer for the structured chat document the engine produces: +`SessionHandle.blocks()` returns typed `FfiBlock`s, a `BlockObserver` fires on +change, and `setMode`/`sendText` drive Watch ⇄ Interact ⇄ Raw. No protocol, +crypto, or terminal-interpretation logic lives in Swift. See `SessionModel.swift` +and `ContentView.swift`. -1. Generate or load a device key from Keychain-backed storage. -2. Display the public key and CP enrollment URL. -3. Open the enrollment URL in an authenticated browser session. -4. After enrollment, list routed agents and connect directly to the selected - agent over Noise. +## Generating the UniFFI bindings -The iOS app should not embed a browser shell or PWA. It should be a native -client using the same core as the CLI. +The Swift in this folder references types (`SessionHandle`, `FfiBlock`, +`FfiMode`, `keygen`, …) emitted by UniFFI. Generate them before building: + +```bash +# from the repo root +cargo build -p dd-client-ffi --release +cargo run -p dd-client-ffi --bin uniffi-bindgen -- generate \ + --library target/release/libdd_client_ffi.dylib \ + --language swift --out-dir apps/ios/Generated +``` + +Then add `apps/ios/Generated/*.swift` to the target and link the Rust static +library as an `xcframework` (build `aarch64-apple-ios` + the simulator/macABI +triples and `xcodebuild -create-xcframework`). The generated `*.modulemap` +header path is wired via the xcframework. + +> The Rust FFI crate is compile/clippy/test-verified on Linux. The binding +> generation and the iOS build require the Apple toolchain (Xcode), which isn't +> available in CI here — run the steps above on macOS. + +First-run flow: + +1. Generate or load the device key (`keygen`), Keychain-backed. +2. Show the pubkey + CP enrollment URL; enroll in an authenticated browser. +3. After enrollment, attach to a session and render its block document. + +The iOS app does not embed a browser shell or PWA. macOS testing target: diff --git a/crates/dd-client-cli/Cargo.toml b/crates/dd-client-cli/Cargo.toml index 3f5377d..e92e6ba 100644 --- a/crates/dd-client-cli/Cargo.toml +++ b/crates/dd-client-cli/Cargo.toml @@ -13,6 +13,9 @@ path = "src/main.rs" anyhow = "1" clap = { version = "4", features = ["derive", "env"] } dd-client-core = { path = "../dd-client-core" } +dd-client-session = { path = "../dd-client-session" } +libc = "0.2" +ratatui = "0.29" serde_json = "1" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["io-std", "io-util", "macros", "rt-multi-thread", "sync", "time"] } diff --git a/crates/dd-client-cli/src/main.rs b/crates/dd-client-cli/src/main.rs index 6778815..6d7d95d 100644 --- a/crates/dd-client-cli/src/main.rs +++ b/crates/dd-client-cli/src/main.rs @@ -1,14 +1,16 @@ use std::path::PathBuf; -use anyhow::{anyhow, Context}; +use anyhow::anyhow; use clap::{Args, Parser, Subcommand}; use dd_client_core::{ - attach_session, close_session, connect, create_session, enrollment_url, exec, list_recipes, - list_sessions, public_key_hex, replay_session, resize_session, session_id, ConnectionOptions, - CreateSessionRequest, ExecRequest, IntelTrustAuthority, QuoteVerification, + close_session, connect, create_session, enrollment_url, exec, list_recipes, list_sessions, + load_or_create_key, public_key_hex, replay_session, resize_session, session_id, + ConnectionOptions, CreateSessionRequest, ExecRequest, IntelTrustAuthority, QuoteVerification, }; +use dd_client_session::ViewMode; + +mod session_ui; -const DEFAULT_ITA_BASE_URL: &str = "https://api.trustauthority.intel.com"; const DEFAULT_ITA_JWKS_URL: &str = "https://portal.trustauthority.intel.com/certs"; const DEFAULT_ITA_ISSUER: &str = "https://portal.trustauthority.intel.com"; @@ -31,6 +33,7 @@ enum Command { Resize(ResizeArgs), Close(SessionArgs), Attach(SessionArgs), + Watch(SessionArgs), Shell(CreateArgs), Exec(ExecArgs), } @@ -59,14 +62,24 @@ struct ConnectArgs { key: PathBuf, #[arg(long)] insecure_skip_quote_verify: bool, - #[arg(long, env = "DD_ITA_API_KEY")] - ita_api_key: Option, - #[arg(long, env = "DD_ITA_BASE_URL", default_value = DEFAULT_ITA_BASE_URL)] - ita_base_url: String, #[arg(long, env = "DD_ITA_JWKS_URL", default_value = DEFAULT_ITA_JWKS_URL)] ita_jwks_url: String, #[arg(long, env = "DD_ITA_ISSUER", default_value = DEFAULT_ITA_ISSUER)] ita_issuer: String, + /// Pin the agent measurement: accepted MRTD(s), hex (repeatable / comma-sep). + /// Unset = unpinned (warns). Source this from a trusted pin, not the agent. + #[arg( + long = "expected-mrtd", + env = "DD_EXPECTED_MRTD", + value_delimiter = ',' + )] + expected_mrtd: Vec, + /// Required TCB status when an MRTD is pinned (e.g. "UpToDate"). + #[arg(long, env = "DD_EXPECTED_TCB")] + expected_tcb: Option, + /// Structured deriver: "floor" (any TUI) or "claude" (Claude Code stream-json). + #[arg(long, default_value = "floor")] + adapter: String, } #[derive(Args)] @@ -142,8 +155,14 @@ async fn main() -> anyhow::Result<()> { print_json(create_session(&mut conn, &create_request(&args)).await?)?; } Command::Replay(args) => { + // Decrypt history client-side with the device key: the enclave seals + // each record to paired device pubkeys and cannot read it back. + let secret = load_or_create_key(&args.connect.key).await?; let mut conn = connect(&connection_options(args.connect)?).await?; - print_json(replay_session(&mut conn, &args.id).await?)?; + let response = replay_session(&mut conn, &args.id).await?; + let bytes = dd_client_session::history::decrypt_replay(&secret, &response)?; + use std::io::Write; + std::io::stdout().write_all(&bytes)?; } Command::Resize(args) => { let mut conn = connect(&connection_options(args.connect)?).await?; @@ -154,14 +173,21 @@ async fn main() -> anyhow::Result<()> { print_json(close_session(&mut conn, &args.id).await?)?; } Command::Attach(args) => { + let adapter = args.connect.adapter.clone(); + let conn = connect(&connection_options(args.connect)?).await?; + session_ui::run(conn, &args.id, ViewMode::Watch, &adapter).await?; + } + Command::Watch(args) => { + let adapter = args.connect.adapter.clone(); let conn = connect(&connection_options(args.connect)?).await?; - attach_session(conn, &args.id).await?; + session_ui::run(conn, &args.id, ViewMode::Watch, &adapter).await?; } Command::Shell(args) => { + let adapter = args.connect.adapter.clone(); let mut conn = connect(&connection_options(args.connect.clone())?).await?; let session = create_session(&mut conn, &create_request(&args)).await?; let id = session_id(&session)?; - attach_session(conn, &id).await?; + session_ui::run(conn, &id, ViewMode::Watch, &adapter).await?; } Command::Exec(args) => { let mut conn = connect(&connection_options(args.connect)?).await?; @@ -193,12 +219,15 @@ fn connection_options(args: ConnectArgs) -> anyhow::Result { QuoteVerification::InsecureSkip } else { QuoteVerification::IntelTrustAuthority(IntelTrustAuthority { - api_key: args - .ita_api_key - .context("DD_ITA_API_KEY or --ita-api-key is required")?, - base_url: args.ita_base_url, jwks_url: args.ita_jwks_url, issuer: args.ita_issuer, + expected_mrtds: args + .expected_mrtd + .iter() + .map(|m| m.trim().to_lowercase()) + .filter(|m| !m.is_empty()) + .collect(), + expected_tcb: args.expected_tcb, }) }; Ok(ConnectionOptions { diff --git a/crates/dd-client-cli/src/session_ui.rs b/crates/dd-client-cli/src/session_ui.rs new file mode 100644 index 0000000..9d665c4 --- /dev/null +++ b/crates/dd-client-cli/src/session_ui.rs @@ -0,0 +1,550 @@ +//! The flippable session UI: Watch ⇄ Interact ⇄ Raw over one live attachment. +//! +//! A single pump task feeds every output frame into the [`SessionEngine`] (and, +//! while in Raw, straight to the tty). The frontend switches *lenses* over that +//! one engine: a ratatui structured view for Watch/Interact, and a real-tty +//! passthrough for Raw. `Tab`/`Shift-Tab` cycle while structured; `Ctrl-]`+`Tab` +//! pops out of Raw. The engine keeps deriving in every mode, so flipping is +//! lossless. +//! +//! NOTE: the multi-regime live flip is wired against the tested engine/chord/mode +//! logic but has not been exercised against a live PTY here. + +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::anyhow; +use dd_client_core::NoiseConnection; +use dd_client_session::block::Block as SBlock; +use dd_client_session::derive::{ClaudeCodeAdapter, ScreenSnapshot}; +use dd_client_session::input::{ChordParser, RawAction}; +use dd_client_session::transport::{self, Outbound}; +use dd_client_session::{SessionEngine, ViewMode}; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Text}; +use ratatui::widgets::{Block as TuiBlock, Borders, Paragraph, Wrap}; +use ratatui::Frame; +use tokio::io::AsyncReadExt; +use tokio::sync::broadcast::error::RecvError; +use tokio::sync::mpsc; + +/// What a regime returns to the orchestrator. +enum Flow { + Quit, + SwitchTo(ViewMode), +} + +pub async fn run( + mut conn: NoiseConnection, + id: &str, + start: ViewMode, + adapter: &str, +) -> anyhow::Result<()> { + let ack = conn + .call(serde_json::json!({ + "method": "shell.attach_session", + "id": id, + "tail": true, + "readonly": start.is_readonly(), + })) + .await?; + if ack.get("error").is_some() { + anyhow::bail!("attach failed: {}", serde_json::to_string(&ack)?); + } + + let engine = match adapter { + "claude" | "claude-code" => SessionEngine::with_adapter(Box::new(ClaudeCodeAdapter::new())), + _ => SessionEngine::new(), + }; + let mode = Arc::new(Mutex::new(start)); + let (in_tx, in_rx) = mpsc::channel::(256); + + // Persistent pump: feed the engine always; mirror to the tty only in Raw. + let pump_engine = engine.clone(); + let pump_mode = mode.clone(); + let pump = tokio::spawn(async move { + let mut out = std::io::stdout(); + let r = transport::run(conn, in_rx, |bytes| { + pump_engine.feed_output(bytes); + if *pump_mode.lock().expect("mode poisoned") == ViewMode::Raw { + let _ = out.write_all(bytes); + let _ = out.flush(); + } + }) + .await; + pump_engine.finish(); + r + }); + + let result = loop { + let current = *mode.lock().expect("mode poisoned"); + let outcome = if current.is_structured() { + run_structured(&engine, &mode, &in_tx).await + } else { + run_raw(&engine, &mode, &in_tx).await + }; + match outcome { + Ok(Flow::Quit) => break Ok(()), + Ok(Flow::SwitchTo(m)) => *mode.lock().expect("mode poisoned") = m, + Err(e) => break Err(e), + } + }; + + drop(in_tx); + let _ = pump.await; + result +} + +// ── Structured regime (Watch / Interact) ─────────────────────────────────── + +async fn run_structured( + engine: &SessionEngine, + mode: &Arc>, + in_tx: &mpsc::Sender, +) -> anyhow::Result { + let (snapshot, mut rx) = engine.subscribe(); + + // Key reader on a blocking thread (poll so it exits promptly on quit). + let stop = Arc::new(AtomicBool::new(false)); + let (key_tx, mut key_rx) = mpsc::channel::(64); + let stop_reader = stop.clone(); + let reader = tokio::task::spawn_blocking(move || loop { + if stop_reader.load(Ordering::Relaxed) { + break; + } + if let Ok(true) = event::poll(Duration::from_millis(100)) { + match event::read() { + Ok(ev) => { + if key_tx.blocking_send(ev).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + let mut terminal = ratatui::init(); + let mut blocks = snapshot; + let mut scroll: u16 = 0; + let mut follow = true; + + let flow = loop { + let current = *mode.lock().expect("mode poisoned"); + let menu = if current == ViewMode::Interact { + engine.detect_menu() + } else { + None + }; + + let screen = engine.screen_snapshot(); + if let Err(e) = terminal.draw(|f| { + draw( + f, + current, + &blocks, + &screen, + menu.as_ref(), + &mut scroll, + follow, + ) + }) { + break Err(e.into()); + } + + tokio::select! { + ev = async { + match rx.recv().await { + Ok(_) | Err(RecvError::Lagged(_)) => Some(()), + Err(RecvError::Closed) => None, + } + } => { + if ev.is_some() { + blocks = engine.snapshot(); + } // Closed: pump ended; keep the view until the user quits. + } + key = key_rx.recv() => { + let Some(Event::Key(k)) = key else { + if key.is_none() { break Ok(Flow::Quit); } + if let Some(Event::Resize(cols, rows)) = key { engine.resize(rows, cols); } + continue; + }; + match classify_structured_key(current, &k) { + StructAction::Quit => break Ok(Flow::Quit), + StructAction::CycleNext | StructAction::CyclePrev => { + let nm = if matches!(classify_structured_key(current, &k), StructAction::CycleNext) { + current.next() + } else { + current.prev() + }; + *mode.lock().expect("mode poisoned") = nm; + if !nm.is_structured() { + break Ok(Flow::SwitchTo(nm)); + } + // Watch ⇄ Interact: stay in this regime. + } + StructAction::ScrollUp => { follow = false; scroll = scroll.saturating_sub(1); } + StructAction::ScrollDown => scroll = scroll.saturating_add(1), + StructAction::PageUp => { follow = false; scroll = scroll.saturating_sub(10); } + StructAction::PageDown => scroll = scroll.saturating_add(10), + StructAction::Follow => follow = true, + StructAction::ForwardKey => { + if let Some(bytes) = key_to_bytes(&k) { + if in_tx.send(Outbound::Bytes(bytes)).await.is_err() { + break Ok(Flow::Quit); + } + } + } + StructAction::Ignore => {} + } + } + } + }; + + ratatui::restore(); + stop.store(true, Ordering::Relaxed); + let _ = reader.await; + flow +} + +enum StructAction { + Quit, + CycleNext, + CyclePrev, + ScrollUp, + ScrollDown, + PageUp, + PageDown, + Follow, + ForwardKey, + Ignore, +} + +fn classify_structured_key(mode: ViewMode, k: &KeyEvent) -> StructAction { + // Tab/Shift-Tab always cycle (reserved from the app in structured modes). + match k.code { + KeyCode::Tab => return StructAction::CycleNext, + KeyCode::BackTab => return StructAction::CyclePrev, + KeyCode::Char('c') if k.modifiers.contains(KeyModifiers::CONTROL) => { + return StructAction::Quit + } + _ => {} + } + + match mode { + ViewMode::Watch => match k.code { + KeyCode::Char('q') | KeyCode::Esc => StructAction::Quit, + KeyCode::Up => StructAction::ScrollUp, + KeyCode::Down => StructAction::ScrollDown, + KeyCode::PageUp => StructAction::PageUp, + KeyCode::PageDown => StructAction::PageDown, + KeyCode::End => StructAction::Follow, + _ => StructAction::Ignore, + }, + // Interact forwards keystrokes to the PTY (drive the live menu), except + // the reserved cycle keys above. PgUp/PgDn still scroll the transcript. + ViewMode::Interact => match k.code { + KeyCode::PageUp => StructAction::PageUp, + KeyCode::PageDown => StructAction::PageDown, + _ => StructAction::ForwardKey, + }, + ViewMode::Raw => StructAction::Ignore, + } +} + +// ── Raw regime ────────────────────────────────────────────────────────────── + +async fn run_raw( + engine: &SessionEngine, + _mode: &Arc>, + in_tx: &mpsc::Sender, +) -> anyhow::Result { + let _raw = RawMode::enter()?; + + // Repaint the current screen so Raw doesn't start blank. + { + let mut out = std::io::stdout(); + let _ = out.write_all(b"\x1b[2J\x1b[H"); + let _ = out.write_all(&engine.screen_formatted()); + let _ = out.flush(); + } + eprint!("\r\n[RAW] Ctrl-] Tab → structured · Ctrl-] Ctrl-] → detach · Ctrl-D → EOF\r\n"); + + let mut chord = ChordParser::new(); + let mut stdin = tokio::io::stdin(); + let mut buf = [0u8; 4096]; + + loop { + let n = match stdin.read(&mut buf).await { + Ok(0) | Err(_) => return Ok(Flow::Quit), + Ok(n) => n, + }; + for action in chord.feed(&buf[..n]) { + match action { + RawAction::Forward(bytes) => { + if in_tx.send(Outbound::Bytes(bytes)).await.is_err() { + return Ok(Flow::Quit); + } + } + RawAction::ForwardThenStop(bytes) => { + let _ = in_tx.send(Outbound::BytesThenStop(bytes)).await; + return Ok(Flow::Quit); + } + RawAction::ExitToStructured => return Ok(Flow::SwitchTo(ViewMode::Watch)), + RawAction::Detach => return Ok(Flow::Quit), + } + } + } +} + +/// Map a crossterm key to the bytes a PTY expects. Returns `None` for keys with +/// no byte representation (the reserved cycle keys never reach here). +fn key_to_bytes(k: &KeyEvent) -> Option> { + let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); + Some(match k.code { + KeyCode::Char(c) if ctrl && c.is_ascii_alphabetic() => { + vec![(c.to_ascii_uppercase() as u8) & 0x1f] + } + KeyCode::Char(c) => c.to_string().into_bytes(), + KeyCode::Enter => vec![b'\r'], + KeyCode::Backspace => vec![0x7f], + KeyCode::Esc => vec![0x1b], + KeyCode::Up => b"\x1b[A".to_vec(), + KeyCode::Down => b"\x1b[B".to_vec(), + KeyCode::Right => b"\x1b[C".to_vec(), + KeyCode::Left => b"\x1b[D".to_vec(), + KeyCode::Home => b"\x1b[H".to_vec(), + KeyCode::End => b"\x1b[F".to_vec(), + KeyCode::Delete => b"\x1b[3~".to_vec(), + _ => return None, + }) +} + +// ── Rendering ───────────────────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +fn draw( + f: &mut Frame, + mode: ViewMode, + blocks: &[SBlock], + screen: &ScreenSnapshot, + menu: Option<&dd_client_session::block::Menu>, + scroll: &mut u16, + follow: bool, +) { + let area = f.area(); + let menu_h = menu.map(|m| m.options.len() as u16 + 2).unwrap_or(0); + let rows = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(menu_h), + Constraint::Length(1), + ]) + .split(area); + let body: Rect = rows[0]; + + // Full-screen (alt-screen) apps like Codex paint a grid with absolute cursor + // moves; the line-oriented floor mangles them. Render the faithful vt100 grid + // instead. Plain scrolling output keeps the structured block view. + let on_screen = screen.alternate; + let text = if on_screen { + render_screen(screen) + } else { + render_blocks(blocks) + }; + let total = text.lines.len() as u16; + let inner_h = body.height.saturating_sub(2); + let max_scroll = total.saturating_sub(inner_h); + if on_screen { + *scroll = 0; // the grid is the viewport — no scrollback + } else if follow || *scroll > max_scroll { + *scroll = max_scroll; + } + + let integrity = if mode.is_readonly() { + "clean" + } else { + "controlled" + }; + let kind = if on_screen { "screen" } else { "blocks" }; + let title = format!(" {} ({integrity}) — {kind}, {total} lines ", mode.label()); + let para = Paragraph::new(text) + .wrap(Wrap { trim: false }) + .scroll((*scroll, 0)) + .block(TuiBlock::default().borders(Borders::ALL).title(title)); + f.render_widget(para, body); + + if let Some(menu) = menu { + f.render_widget(render_menu(menu), rows[1]); + } + + let hints = match mode { + ViewMode::Watch => "q quit · ↑/↓ scroll · End follow · Tab→Interact", + ViewMode::Interact => "keys→session · PgUp/PgDn scroll · Tab→Raw · Shift-Tab→Watch", + ViewMode::Raw => "", + }; + let status = Paragraph::new(Line::styled( + format!(" {hints} "), + Style::default().fg(Color::DarkGray), + )); + f.render_widget(status, rows[2]); +} + +fn render_menu(menu: &dd_client_session::block::Menu) -> Paragraph<'static> { + let mut lines: Vec = Vec::new(); + for (i, opt) in menu.options.iter().enumerate() { + let marker = if menu.selected == Some(i) { + "❯ " + } else { + " " + }; + let key = opt + .hotkey + .map(|c| format!("{c}. ")) + .unwrap_or_else(|| " ".to_string()); + let style = if menu.selected == Some(i) { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default().fg(Color::Cyan) + }; + lines.push(Line::styled(format!("{marker}{key}{}", opt.label), style)); + } + Paragraph::new(Text::from(lines)).block( + TuiBlock::default() + .borders(Borders::ALL) + .title(" menu (arrows/enter) ") + .border_style(Style::default().fg(Color::Yellow)), + ) +} + +/// Render the vt100 grid faithfully — preserves the column spacing alt-screen +/// TUIs depend on. Reverse-video cells (selected menu rows) keep their highlight. +fn render_screen(screen: &ScreenSnapshot) -> Text<'static> { + let last = screen + .rows + .iter() + .rposition(|r| !r.text.trim().is_empty()) + .map(|i| i + 1) + .unwrap_or(0); + let lines = screen.rows[..last] + .iter() + .map(|r| { + if r.inverse { + Line::styled( + r.text.clone(), + Style::default().add_modifier(Modifier::REVERSED), + ) + } else { + Line::raw(r.text.clone()) + } + }) + .collect::>(); + Text::from(lines) +} + +fn render_blocks(blocks: &[SBlock]) -> Text<'static> { + let mut lines: Vec = Vec::new(); + for block in blocks { + match block { + SBlock::Markdown { text, .. } => { + for l in text.lines() { + lines.push(render_markdown_line(l)); + } + } + SBlock::Code { lang, text, .. } => { + let header = match lang { + Some(l) => format!("┌─ code ({l})"), + None => "┌─ code".to_string(), + }; + lines.push(Line::styled(header, Style::default().fg(Color::DarkGray))); + for l in text.lines() { + lines.push(Line::styled( + format!("│ {l}"), + Style::default().fg(Color::Cyan), + )); + } + } + SBlock::Diff { unified, .. } => { + for l in unified.lines() { + let style = match l.as_bytes().first() { + Some(b'+') => Style::default().fg(Color::Green), + Some(b'-') => Style::default().fg(Color::Red), + Some(b'@') => Style::default().fg(Color::Magenta), + _ => Style::default().fg(Color::Gray), + }; + lines.push(Line::styled(l.to_string(), style)); + } + } + SBlock::RawTerminal { screen } => { + for l in screen.lines() { + lines.push(Line::raw(l.to_string())); + } + } + SBlock::Menu(_) | SBlock::Input(_) => {} + } + lines.push(Line::raw(String::new())); + } + Text::from(lines) +} + +fn render_markdown_line(l: &str) -> Line<'static> { + let t = l.trim_start(); + if t.starts_with("# ") || t.starts_with("## ") || t.starts_with("### ") { + Line::styled( + l.to_string(), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + } else { + Line::raw(l.to_string()) + } +} + +struct RawMode { + #[cfg(unix)] + original: Option, +} + +impl RawMode { + fn enter() -> anyhow::Result { + #[cfg(unix)] + { + if unsafe { libc::isatty(libc::STDIN_FILENO) } != 1 { + return Ok(Self { original: None }); + } + let mut original = std::mem::MaybeUninit::::uninit(); + if unsafe { libc::tcgetattr(libc::STDIN_FILENO, original.as_mut_ptr()) } != 0 { + return Err(anyhow!("tcgetattr: {}", std::io::Error::last_os_error())); + } + let original = unsafe { original.assume_init() }; + let mut raw = original; + unsafe { libc::cfmakeraw(&mut raw) }; + if unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &raw) } != 0 { + return Err(anyhow!( + "tcsetattr raw: {}", + std::io::Error::last_os_error() + )); + } + Ok(Self { + original: Some(original), + }) + } + #[cfg(not(unix))] + { + Ok(Self {}) + } + } +} + +impl Drop for RawMode { + fn drop(&mut self) { + #[cfg(unix)] + if let Some(original) = &self.original { + let _ = unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, original) }; + } + } +} diff --git a/crates/dd-client-core/Cargo.toml b/crates/dd-client-core/Cargo.toml index d2a7a8d..31455ef 100644 --- a/crates/dd-client-core/Cargo.toml +++ b/crates/dd-client-core/Cargo.toml @@ -12,13 +12,12 @@ chrono = { version = "0.4", features = ["serde"] } futures-util = "0.3" hex = "0.4" jsonwebtoken = "9" -libc = "0.2" rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" snow = { version = "0.9", default-features = false, features = ["default-resolver"] } -tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "rt-multi-thread", "sync"] } +tokio = { version = "1", features = ["fs", "macros", "net", "rt-multi-thread", "sync"] } tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } urlencoding = "2" x25519-dalek = { version = "2", features = ["static_secrets"] } diff --git a/crates/dd-client-core/src/ita.rs b/crates/dd-client-core/src/ita.rs index ef4bc2d..ea4858d 100644 --- a/crates/dd-client-core/src/ita.rs +++ b/crates/dd-client-core/src/ita.rs @@ -49,46 +49,14 @@ impl Claims { attester_type: get("attester_type"), mrtd: get("tdx_mrtd"), mrsigner: get("tdx_mrsigner"), - report_data: get("attester_held_data"), + // Intel TDX tokens carry the quote's report_data as `tdx_report_data`; + // `attester_held_data` only appears if held-data was submitted at mint. + report_data: get("attester_held_data").or_else(|| get("tdx_report_data")), extra: v, } } } -#[derive(Serialize)] -struct MintRequest<'a> { - quote: &'a str, -} - -#[derive(Deserialize)] -struct MintResponse { - token: String, -} - -pub async fn mint( - http: &Client, - base_url: &str, - api_key: &str, - quote_b64: &str, -) -> anyhow::Result { - let url = format!("{}/appraisal/v1/attest", base_url.trim_end_matches('/')); - let resp = http - .post(&url) - .header("x-api-key", api_key) - .header("Accept", "application/json") - .json(&MintRequest { quote: quote_b64 }) - .send() - .await - .with_context(|| format!("ITA mint {url}"))?; - let status = resp.status(); - if !status.is_success() { - let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("ITA mint {status}: {body}"); - } - let body: MintResponse = resp.json().await?; - Ok(body.token) -} - pub struct Verifier { jwks_url: String, issuer: String, diff --git a/crates/dd-client-core/src/lib.rs b/crates/dd-client-core/src/lib.rs index b23069d..d2d6ef9 100644 --- a/crates/dd-client-core/src/lib.rs +++ b/crates/dd-client-core/src/lib.rs @@ -1,6 +1,7 @@ mod ita; use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; use anyhow::{anyhow, Context}; use base64::Engine as _; @@ -9,7 +10,6 @@ use rand::rngs::OsRng; use reqwest::Client as HttpClient; use serde_json::Value; use snow::{Builder, TransportState}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; @@ -17,9 +17,6 @@ use x25519_dalek::{PublicKey, StaticSecret}; const NOISE_PATTERN: &str = "Noise_IK_25519_ChaChaPoly_BLAKE2s"; const MAX_NOISE_MSG: usize = 65535; -const ATTACH_CHUNK: usize = 4096; -const CTRL_D: u8 = 0x04; -const CTRL_RIGHT_BRACKET: u8 = 0x1d; type WsStream = WebSocketStream>; type WsSink = futures_util::stream::SplitSink; @@ -27,10 +24,16 @@ type WsRead = futures_util::stream::SplitStream; #[derive(Debug, Clone)] pub struct IntelTrustAuthority { - pub api_key: String, - pub base_url: String, + /// Intel's public JWKS endpoint. Verification only — no API key, no account: + /// the agent mints the token; the client just checks the signature. pub jwks_url: String, pub issuer: String, + /// Expected MRTDs (lowercase hex), any-of. Empty = measurement unpinned + /// (verifies genuineness but not *which code* — warns). Pin to a value from a + /// source independent of the agent (committed pin / signed release manifest). + pub expected_mrtds: Vec, + /// Required TCB status when pinned (e.g. "UpToDate"). + pub expected_tcb: Option, } #[derive(Debug, Clone)] @@ -77,6 +80,77 @@ impl NoiseConnection { out.truncate(n); Ok(serde_json::from_slice(&out)?) } + + /// Split into independently-ownable write/read halves so a caller can run a + /// duplex pump loop (e.g. the session engine forwarding keystrokes while + /// streaming PTY output). The Noise transport (encryption) stays inside core, + /// shared between the halves behind a brief, non-async lock — the lock is + /// never held across an `.await`. + pub fn split(self) -> (NoiseWriter, NoiseReader) { + let transport = Arc::new(Mutex::new(self.transport)); + ( + NoiseWriter { + transport: transport.clone(), + sink: self.sink, + }, + NoiseReader { + transport, + stream: self.stream, + }, + ) + } +} + +/// Write half of a split [`NoiseConnection`]. Encrypts plaintext into Noise +/// transport frames and sends them. +pub struct NoiseWriter { + transport: Arc>, + sink: WsSink, +} + +impl NoiseWriter { + /// Encrypt `plain` and send it as one Noise transport frame. + pub async fn send(&mut self, plain: &[u8]) -> anyhow::Result<()> { + let frame = { + let mut transport = self + .transport + .lock() + .map_err(|_| anyhow!("noise transport lock poisoned"))?; + let mut cipher = vec![0u8; plain.len() + 16]; + let n = transport.write_message(plain, &mut cipher)?; + cipher.truncate(n); + cipher + }; + self.sink.send(WsMessage::Binary(frame.into())).await?; + Ok(()) + } +} + +/// Read half of a split [`NoiseConnection`]. Receives Noise transport frames and +/// decrypts them. +pub struct NoiseReader { + transport: Arc>, + stream: WsRead, +} + +impl NoiseReader { + /// Receive and decrypt the next Noise transport frame. `Ok(None)` once the + /// socket closes. + pub async fn recv(&mut self) -> anyhow::Result>> { + let Some(cipher) = next_binary(&mut self.stream).await? else { + return Ok(None); + }; + let mut plain = vec![0u8; cipher.len()]; + let n = { + let mut transport = self + .transport + .lock() + .map_err(|_| anyhow!("noise transport lock poisoned"))?; + transport.read_message(&cipher, &mut plain)? + }; + plain.truncate(n); + Ok(Some(plain)) + } } pub async fn connect(opts: &ConnectionOptions) -> anyhow::Result { @@ -183,75 +257,18 @@ pub async fn exec(conn: &mut NoiseConnection, request: &ExecRequest) -> anyhow:: .await } -pub async fn attach_session(mut conn: NoiseConnection, id: &str) -> anyhow::Result<()> { - let ack = conn - .call(serde_json::json!({ - "method": "shell.attach_session", - "id": id, - "tail": true, - })) - .await?; - if ack.get("error").is_some() { - anyhow::bail!("attach failed: {}", serde_json::to_string(&ack)?); - } - - eprintln!("attached; Ctrl-] detaches, Ctrl-D sends EOF and disconnects"); - - let _raw = RawMode::enter()?; - let mut stdin = tokio::io::stdin(); - let mut stdout = tokio::io::stdout(); - let mut in_buf = [0u8; ATTACH_CHUNK]; - - loop { - tokio::select! { - n = stdin.read(&mut in_buf) => { - let n = n?; - if n == 0 { - break; - } - match attach_input_action(&in_buf[..n]) { - AttachInputAction::Forward => { - send_encrypted(&mut conn.transport, &mut conn.sink, &in_buf[..n]).await?; - } - AttachInputAction::ForwardThenDisconnect => { - send_encrypted(&mut conn.transport, &mut conn.sink, &in_buf[..n]).await?; - break; - } - AttachInputAction::Disconnect => break, - } - } - frame = next_binary(&mut conn.stream) => { - let Some(cipher) = frame? else { - break; - }; - let mut plain = vec![0u8; cipher.len()]; - let n = conn.transport.read_message(&cipher, &mut plain)?; - stdout.write_all(&plain[..n]).await?; - stdout.flush().await?; - } - } - } - Ok(()) -} - -#[derive(Debug, Eq, PartialEq)] -enum AttachInputAction { - Forward, - ForwardThenDisconnect, - Disconnect, -} - -fn attach_input_action(bytes: &[u8]) -> AttachInputAction { - match bytes { - [CTRL_D] => AttachInputAction::ForwardThenDisconnect, - [CTRL_RIGHT_BRACKET] => AttachInputAction::Disconnect, - _ => AttachInputAction::Forward, - } -} - pub fn session_id(value: &Value) -> anyhow::Result { if let Some(error) = value.get("error") { - anyhow::bail!("create failed: {error}"); + // The Noise gateway wraps upstream failures as {error, detail}; the detail + // carries the real cause (e.g. "unknown recipe: codex"). Surface both. + let msg = error + .as_str() + .map(String::from) + .unwrap_or_else(|| error.to_string()); + match value.get("detail").and_then(Value::as_str) { + Some(detail) => anyhow::bail!("create failed: {msg}: {detail}"), + None => anyhow::bail!("create failed: {msg}"), + } } value .get("id") @@ -346,10 +363,10 @@ async fn fetch_and_verify_server_pubkey( .pointer("/noise/pubkey_hex") .and_then(Value::as_str) .ok_or_else(|| anyhow!("{url} did not include noise.pubkey_hex"))?; - let quote_b64 = body - .pointer("/noise/quote_b64") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("{url} did not include noise.quote_b64"))?; + // The agent mints an ITA appraisal of its Noise quote and serves it here; the + // client only verifies it (public JWKS — no account). Optional so the + // InsecureSkip path and older agents still work. + let ita_token = body.pointer("/noise/ita_token").and_then(Value::as_str); let bytes = hex::decode(pubkey_hex).context("decode noise.pubkey_hex")?; if bytes.len() != 32 { anyhow::bail!( @@ -359,13 +376,13 @@ async fn fetch_and_verify_server_pubkey( } let mut out = [0u8; 32]; out.copy_from_slice(&bytes); - verify_quote_binding(http, quote_b64, &out, &opts.quote_verification).await?; + verify_quote_binding(http, ita_token, &out, &opts.quote_verification).await?; Ok(out) } async fn verify_quote_binding( http: &HttpClient, - quote_b64: &str, + ita_token: Option<&str>, pubkey: &[u8; 32], verification: &QuoteVerification, ) -> anyhow::Result<()> { @@ -374,19 +391,49 @@ async fn verify_quote_binding( return Ok(()); }; - let token = ita::mint(http, &config.base_url, &config.api_key, quote_b64) - .await - .map_err(|e| anyhow!("ITA quote appraisal failed: {e}"))?; + let token = ita_token.ok_or_else(|| { + anyhow!( + "agent /health did not include noise.ita_token; update the agent or \ + pass --insecure-skip-quote-verify" + ) + })?; let verifier = ita::Verifier::new(http.clone(), config.jwks_url.clone(), config.issuer.clone()); let claims = verifier - .verify(&token) + .verify(token) .await .map_err(|e| anyhow!("ITA token verification failed: {e}"))?; let report_data = claims .report_data .as_deref() .ok_or_else(|| anyhow!("ITA token missing attester_held_data/report_data"))?; - verify_report_data(report_data, pubkey) + verify_report_data(report_data, pubkey)?; + verify_measurement(&claims, config) +} + +/// Pin the enclave measurement: attestation proves a genuine TDX VM, but only +/// matching the MRTD proves it's running *our* code. Unpinned ⇒ warn (don't fail). +fn verify_measurement(claims: &ita::Claims, config: &IntelTrustAuthority) -> anyhow::Result<()> { + if config.expected_mrtds.is_empty() { + eprintln!( + "warning: agent measurement is unpinned (no --expected-mrtd); attestation proves a \ + genuine TDX enclave but not which code it runs" + ); + return Ok(()); + } + let mrtd = claims.mrtd.as_deref().unwrap_or("").to_lowercase(); + if !config.expected_mrtds.contains(&mrtd) { + anyhow::bail!( + "agent MRTD {} not in expected allowlist", + if mrtd.is_empty() { "" } else { &mrtd } + ); + } + if let Some(want) = &config.expected_tcb { + let got = claims.tcb_status.as_deref().unwrap_or(""); + if got != want { + anyhow::bail!("agent TCB status {got:?} != expected {want:?}"); + } + } + Ok(()) } fn verify_report_data(report_data: &str, pubkey: &[u8; 32]) -> anyhow::Result<()> { @@ -452,48 +499,6 @@ fn normalize_http_base(base_url: &str) -> String { } } -struct RawMode { - #[cfg(unix)] - original: Option, -} - -impl RawMode { - fn enter() -> anyhow::Result { - #[cfg(unix)] - { - if unsafe { libc::isatty(libc::STDIN_FILENO) } != 1 { - return Ok(Self { original: None }); - } - let mut original = std::mem::MaybeUninit::::uninit(); - if unsafe { libc::tcgetattr(libc::STDIN_FILENO, original.as_mut_ptr()) } != 0 { - return Err(std::io::Error::last_os_error()).context("tcgetattr"); - } - let original = unsafe { original.assume_init() }; - let mut raw = original; - unsafe { libc::cfmakeraw(&mut raw) }; - if unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &raw) } != 0 { - return Err(std::io::Error::last_os_error()).context("tcsetattr raw"); - } - Ok(Self { - original: Some(original), - }) - } - #[cfg(not(unix))] - { - Ok(Self {}) - } - } -} - -impl Drop for RawMode { - fn drop(&mut self) { - #[cfg(unix)] - if let Some(original) = &self.original { - let _ = unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, original) }; - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -555,24 +560,33 @@ mod tests { verify_report_data(&encoded, &pubkey).unwrap(); } - #[test] - fn attach_input_detaches_on_ctrl_right_bracket() { - assert_eq!( - attach_input_action(&[CTRL_RIGHT_BRACKET]), - AttachInputAction::Disconnect - ); + fn ita_config(mrtds: &[&str], tcb: Option<&str>) -> IntelTrustAuthority { + IntelTrustAuthority { + jwks_url: String::new(), + issuer: String::new(), + expected_mrtds: mrtds.iter().map(|s| s.to_string()).collect(), + expected_tcb: tcb.map(String::from), + } + } + + fn claims(mrtd: &str, tcb: &str) -> ita::Claims { + ita::Claims { + mrtd: Some(mrtd.into()), + tcb_status: Some(tcb.into()), + ..Default::default() + } } #[test] - fn attach_input_sends_eof_then_disconnects_on_ctrl_d() { - assert_eq!( - attach_input_action(&[CTRL_D]), - AttachInputAction::ForwardThenDisconnect - ); + fn measurement_unpinned_warns_but_passes() { + assert!(verify_measurement(&claims("aa", "OutOfDate"), &ita_config(&[], None)).is_ok()); } #[test] - fn attach_input_forwards_regular_bytes() { - assert_eq!(attach_input_action(b"exit\n"), AttachInputAction::Forward); + fn measurement_pinned_accepts_match_rejects_others() { + let cfg = ita_config(&["aa", "bb"], Some("UpToDate")); + assert!(verify_measurement(&claims("bb", "UpToDate"), &cfg).is_ok()); + assert!(verify_measurement(&claims("cc", "UpToDate"), &cfg).is_err()); // wrong mrtd + assert!(verify_measurement(&claims("aa", "OutOfDate"), &cfg).is_err()); // bad tcb } } diff --git a/crates/dd-client-ffi/Cargo.toml b/crates/dd-client-ffi/Cargo.toml index 4fc6283..1d5abab 100644 --- a/crates/dd-client-ffi/Cargo.toml +++ b/crates/dd-client-ffi/Cargo.toml @@ -9,10 +9,17 @@ repository.workspace = true crate-type = ["cdylib", "staticlib", "rlib"] [dependencies] +anyhow = "1" dd-client-core = { path = "../dd-client-core" } +dd-client-session = { path = "../dd-client-session" } serde_json = "1" -tokio = { version = "1", features = ["fs", "rt"] } +thiserror = "2" +tokio = { version = "1", features = ["fs", "rt-multi-thread", "sync", "macros"] } +uniffi = { version = "0.28", features = ["cli"] } + +[[bin]] +name = "uniffi-bindgen" +path = "src/bin/uniffi-bindgen.rs" [dev-dependencies] tempfile = "3" - diff --git a/crates/dd-client-ffi/src/bin/uniffi-bindgen.rs b/crates/dd-client-ffi/src/bin/uniffi-bindgen.rs new file mode 100644 index 0000000..8f8c1e0 --- /dev/null +++ b/crates/dd-client-ffi/src/bin/uniffi-bindgen.rs @@ -0,0 +1,4 @@ +//! Binding generator entry point: `cargo run -p dd-client-ffi --bin uniffi-bindgen`. +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/crates/dd-client-ffi/src/lib.rs b/crates/dd-client-ffi/src/lib.rs index 9f2748d..4d01a6b 100644 --- a/crates/dd-client-ffi/src/lib.rs +++ b/crates/dd-client-ffi/src/lib.rs @@ -1,96 +1,320 @@ -use std::ffi::{CStr, CString}; -use std::os::raw::c_char; +//! UniFFI bindings: one Rust surface, Swift + Kotlin generated from it. +//! +//! The companion app is a thin renderer over `dd-client-session`. All +//! interpretation (blocks, modes, attestation, history) stays here in Rust; the +//! foreign side gets a snapshot of typed blocks and a change callback, and sends +//! input / switches mode. No protocol or crypto logic crosses into Swift. +//! +//! Everything is synchronous across the FFI (a shared multi-thread tokio runtime +//! does the async work internally), so Swift never has to bridge Rust futures. +//! +//! NOTE: this crate is compile-verified on Linux. The generated Swift bindings +//! and the iOS app are built with the Apple toolchain (`uniffi-bindgen generate +//! --library --language swift`), which isn't available in this +//! environment — see apps/ios. + use std::path::Path; +use std::sync::{Mutex, OnceLock}; -#[no_mangle] -pub extern "C" fn dd_client_keygen( - key_path: *const c_char, - cp_url: *const c_char, - label: *const c_char, -) -> *mut c_char { - let result = keygen_response(key_path, cp_url, label); - into_c_string(result) -} - -#[no_mangle] -/// # Safety -/// -/// `value` must be a pointer returned by this library, and it must not have -/// already been freed. -pub unsafe extern "C" fn dd_client_string_free(value: *mut c_char) { - if value.is_null() { - return; - } - let _ = unsafe { CString::from_raw(value) }; -} - -fn keygen_response( - key_path: *const c_char, - cp_url: *const c_char, - label: *const c_char, -) -> serde_json::Value { - match keygen(key_path, cp_url, label) { - Ok(value) => value, - Err(error) => serde_json::json!({ - "ok": false, - "error": error, - }), - } +use dd_client_core::{connect, ConnectionOptions, IntelTrustAuthority, QuoteVerification}; +use dd_client_session::block::{Block, MenuState as SMenuState}; +use dd_client_session::transport::{self, Outbound}; +use dd_client_session::{SessionEngine, ViewMode}; +use tokio::runtime::Runtime; +use tokio::sync::broadcast::error::RecvError; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +uniffi::setup_scaffolding!(); + +/// Shared multi-thread runtime: connect handshakes block on it, the pump and the +/// observer-drain run on it. +fn rt() -> &'static Runtime { + static RT: OnceLock = OnceLock::new(); + RT.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("build tokio runtime") + }) +} + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum FfiError { + #[error("{0}")] + Message(String), } -fn keygen( - key_path: *const c_char, - cp_url: *const c_char, - label: *const c_char, -) -> Result { - let key_path = required_c_string(key_path, "key_path")?; - let cp_url = optional_c_string(cp_url)?; - let label = optional_c_string(label)?; - - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| e.to_string())?; - let pubkey_hex = runtime +fn err(e: impl std::fmt::Display) -> FfiError { + FfiError::Message(e.to_string()) +} + +#[derive(uniffi::Record)] +pub struct KeygenResult { + pub pubkey_hex: String, + pub enrollment_url: Option, +} + +/// Generate (or load) the device key and return its pubkey, plus the CP +/// enrollment URL when `cp_url` + `label` are given. +#[uniffi::export] +pub fn keygen( + key_path: String, + cp_url: Option, + label: Option, +) -> Result { + let pubkey_hex = rt() .block_on(dd_client_core::public_key_hex(Path::new(&key_path))) - .map_err(|e| e.to_string())?; + .map_err(err)?; let enrollment_url = match (cp_url.as_deref(), label.as_deref()) { - (Some(cp_url), Some(label)) => { - Some(dd_client_core::enrollment_url(cp_url, &pubkey_hex, label)) - } + (Some(cp), Some(l)) => Some(dd_client_core::enrollment_url(cp, &pubkey_hex, l)), _ => None, }; + Ok(KeygenResult { + pubkey_hex, + enrollment_url, + }) +} - Ok(serde_json::json!({ - "ok": true, - "pubkey_hex": pubkey_hex, - "enrollment_url": enrollment_url, - })) +#[derive(Clone, Copy, uniffi::Enum)] +pub enum FfiMode { + Watch, + Interact, + Raw, } -fn required_c_string(ptr: *const c_char, name: &str) -> Result { - optional_c_string(ptr)?.ok_or_else(|| format!("{name} is required")) +impl From for ViewMode { + fn from(m: FfiMode) -> Self { + match m { + FfiMode::Watch => ViewMode::Watch, + FfiMode::Interact => ViewMode::Interact, + FfiMode::Raw => ViewMode::Raw, + } + } +} +impl From for FfiMode { + fn from(m: ViewMode) -> Self { + match m { + ViewMode::Watch => FfiMode::Watch, + ViewMode::Interact => FfiMode::Interact, + ViewMode::Raw => FfiMode::Raw, + } + } +} + +#[derive(uniffi::Record)] +pub struct FfiMenuOption { + pub label: String, + pub hotkey: Option, +} + +#[derive(uniffi::Enum)] +pub enum FfiBlock { + Markdown { + text: String, + complete: bool, + }, + Code { + lang: Option, + text: String, + complete: bool, + }, + Diff { + unified: String, + complete: bool, + }, + Menu { + title: Option, + options: Vec, + selected: Option, + resolved: bool, + }, + Input { + prompt: String, + }, + RawTerminal { + screen: String, + }, } -fn optional_c_string(ptr: *const c_char) -> Result, String> { - if ptr.is_null() { - return Ok(None); +impl From<&Block> for FfiBlock { + fn from(b: &Block) -> Self { + match b { + Block::Markdown { text, complete } => FfiBlock::Markdown { + text: text.clone(), + complete: *complete, + }, + Block::Code { + lang, + text, + complete, + } => FfiBlock::Code { + lang: lang.clone(), + text: text.clone(), + complete: *complete, + }, + Block::Diff { unified, complete } => FfiBlock::Diff { + unified: unified.clone(), + complete: *complete, + }, + Block::Menu(menu) => FfiBlock::Menu { + title: menu.title.clone(), + options: menu + .options + .iter() + .map(|o| FfiMenuOption { + label: o.label.clone(), + hotkey: o.hotkey.map(|c| c.to_string()), + }) + .collect(), + selected: menu.selected.map(|s| s as u32), + resolved: matches!(menu.state, SMenuState::Resolved { .. }), + }, + Block::Input(input) => FfiBlock::Input { + prompt: input.prompt.clone(), + }, + Block::RawTerminal { screen } => FfiBlock::RawTerminal { + screen: screen.clone(), + }, + } } - let s = unsafe { CStr::from_ptr(ptr) } - .to_str() - .map_err(|e| e.to_string())? - .to_owned(); - Ok(Some(s)) -} - -fn into_c_string(value: serde_json::Value) -> *mut c_char { - let text = serde_json::to_string(&value) - .unwrap_or_else(|e| format!(r#"{{"ok":false,"error":"serialize response: {e}"}}"#)); - CString::new(text) - .unwrap_or_else(|_| { - CString::new(r#"{"ok":false,"error":"response contained nul"}"#).unwrap() +} + +/// Foreign-implemented: called whenever the block document changes. The app +/// re-reads [`SessionHandle::blocks`] and re-renders. +#[uniffi::export(callback_interface)] +pub trait BlockObserver: Send + Sync { + fn on_changed(&self); +} + +/// A live attached session. Holds the engine + the pump driving it; the foreign +/// side renders [`Self::blocks`] and reacts to a [`BlockObserver`]. +#[derive(uniffi::Object)] +pub struct SessionHandle { + engine: SessionEngine, + input: mpsc::Sender, + mode: Mutex, + pump: Mutex>>, + drain: Mutex>>, +} + +#[uniffi::export] +impl SessionHandle { + /// Connect to `agent_url`, attach to `session_id` (read-only posture), and + /// start deriving blocks. Verifies the agent's served ITA token against the + /// public JWKS unless `insecure_skip_quote_verify`. + #[uniffi::constructor] + pub fn attach( + agent_url: String, + key_path: String, + session_id: String, + insecure_skip_quote_verify: bool, + jwks_url: String, + issuer: String, + ) -> Result, FfiError> { + let quote_verification = if insecure_skip_quote_verify { + QuoteVerification::InsecureSkip + } else { + QuoteVerification::IntelTrustAuthority(IntelTrustAuthority { + jwks_url, + issuer, + // Measurement pinning on mobile is a follow-up (needs a trusted + // pin source); unpinned for now (warns, still verifies genuineness). + expected_mrtds: Vec::new(), + expected_tcb: None, + }) + }; + let opts = ConnectionOptions { + agent_url, + key_path: key_path.into(), + quote_verification, + }; + rt().block_on(async move { + let mut conn = connect(&opts).await.map_err(err)?; + let ack = conn + .call(serde_json::json!({ + "method": "shell.attach_session", + "id": session_id, + "tail": true, + "readonly": true, + })) + .await + .map_err(err)?; + if ack.get("error").is_some() { + return Err(FfiError::Message(format!("attach failed: {ack}"))); + } + let engine = SessionEngine::new(); + let (input, in_rx) = mpsc::channel::(256); + let pump_engine = engine.clone(); + let pump = rt().spawn(async move { + let _ = transport::run(conn, in_rx, |bytes| pump_engine.feed_output(bytes)).await; + pump_engine.finish(); + }); + Ok(std::sync::Arc::new(SessionHandle { + engine, + input, + mode: Mutex::new(ViewMode::Watch), + pump: Mutex::new(Some(pump)), + drain: Mutex::new(None), + })) }) - .into_raw() + } + + /// Current block document. + pub fn blocks(&self) -> Vec { + self.engine.snapshot().iter().map(FfiBlock::from).collect() + } + + /// Register a change observer. Replaces any previous one. + pub fn subscribe(&self, observer: Box) { + let (_snapshot, mut rx) = self.engine.subscribe(); + let handle = rt().spawn(async move { + // Exits when the broadcast closes (Err(Closed) doesn't match). + while let Ok(_) | Err(RecvError::Lagged(_)) = rx.recv().await { + observer.on_changed(); + } + }); + if let Some(old) = self.drain.lock().expect("drain lock").replace(handle) { + old.abort(); + } + } + + /// Send text to the session (dropped in Watch — read-only). + pub fn send_text(&self, text: String) { + if *self.mode.lock().expect("mode lock") == ViewMode::Watch { + return; + } + let _ = self.input.try_send(Outbound::Bytes(text.into_bytes())); + } + + pub fn mode(&self) -> FfiMode { + (*self.mode.lock().expect("mode lock")).into() + } + + pub fn set_mode(&self, mode: FfiMode) { + *self.mode.lock().expect("mode lock") = mode.into(); + } + + /// Stop the session (the remote session stays alive — this just detaches). + pub fn close(&self) { + if let Some(h) = self.drain.lock().expect("drain lock").take() { + h.abort(); + } + if let Some(h) = self.pump.lock().expect("pump lock").take() { + h.abort(); + } + } +} + +impl Drop for SessionHandle { + fn drop(&mut self) { + if let Some(h) = self.drain.get_mut().expect("drain lock").take() { + h.abort(); + } + if let Some(h) = self.pump.get_mut().expect("pump lock").take() { + h.abort(); + } + } } #[cfg(test)] @@ -98,30 +322,46 @@ mod tests { use super::*; #[test] - fn keygen_returns_enrollment_url() { + fn keygen_returns_pubkey_and_enrollment_url() { let dir = tempfile::tempdir().unwrap(); - let key_path = - CString::new(dir.path().join("noise.key").to_string_lossy().as_ref()).unwrap(); - let cp_url = CString::new("https://cp.example.com").unwrap(); - let label = CString::new("ios phone").unwrap(); - - let value = keygen_response(key_path.as_ptr(), cp_url.as_ptr(), label.as_ptr()); - - assert_eq!(value["ok"], true); - assert!(value["pubkey_hex"].as_str().unwrap().len() == 64); + let key = dir.path().join("noise.key").to_string_lossy().into_owned(); + let out = keygen( + key, + Some("https://cp.example.com".into()), + Some("ios phone".into()), + ) + .unwrap(); + assert_eq!(out.pubkey_hex.len(), 64); assert_eq!( - value["enrollment_url"], - "https://cp.example.com/admin/enroll?pubkey=".to_string() - + value["pubkey_hex"].as_str().unwrap() - + "&label=ios%20phone" + out.enrollment_url.unwrap(), + format!( + "https://cp.example.com/admin/enroll?pubkey={}&label=ios%20phone", + out.pubkey_hex + ) ); } #[test] - fn keygen_rejects_missing_key_path() { - let value = keygen_response(std::ptr::null(), std::ptr::null(), std::ptr::null()); + fn keygen_without_cp_has_no_url() { + let dir = tempfile::tempdir().unwrap(); + let key = dir.path().join("k.key").to_string_lossy().into_owned(); + let out = keygen(key, None, None).unwrap(); + assert_eq!(out.pubkey_hex.len(), 64); + assert!(out.enrollment_url.is_none()); + } - assert_eq!(value["ok"], false); - assert!(value["error"].as_str().unwrap().contains("key_path")); + #[test] + fn ffi_block_maps_from_session_block() { + let b = Block::Markdown { + text: "hi".into(), + complete: true, + }; + match FfiBlock::from(&b) { + FfiBlock::Markdown { text, complete } => { + assert_eq!(text, "hi"); + assert!(complete); + } + _ => panic!("wrong variant"), + } } } diff --git a/crates/dd-client-session/Cargo.toml b/crates/dd-client-session/Cargo.toml new file mode 100644 index 0000000..a07913b --- /dev/null +++ b/crates/dd-client-session/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "dd-client-session" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +anyhow = "1" +base64 = "0.22" +chacha20poly1305 = "0.10" +dd-client-core = { path = "../dd-client-core" } +hex = "0.4" +hkdf = "0.12" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tokio = { version = "1", features = ["macros", "rt", "sync"] } +vt100 = "0.15" +x25519-dalek = { version = "2", features = ["static_secrets"] } + +[dev-dependencies] +rand = "0.8" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } diff --git a/crates/dd-client-session/src/block.rs b/crates/dd-client-session/src/block.rs new file mode 100644 index 0000000..310785e --- /dev/null +++ b/crates/dd-client-session/src/block.rs @@ -0,0 +1,165 @@ +//! The structured "chat document" model. +//! +//! A session is rendered as an ordered list of typed [`Block`]s. The engine +//! holds the authoritative log (see [`crate::stream`]) and publishes +//! [`BlockEvent`] deltas; frontends either replay deltas onto their own copy or +//! re-read a snapshot. Phase 1 only populates `Markdown`/`Code`/`Diff` (and a +//! trailing `RawTerminal`); `Menu`/`Input` are defined here but produced later. + +/// Engine-assigned, monotonically increasing block identifier. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct BlockId(pub u64); + +/// Bumps on every edit to a block, so a consumer can detect missed updates. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Revision(pub u64); + +/// One rendered element of the chat document. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Block { + Markdown { + text: String, + complete: bool, + }, + Code { + lang: Option, + text: String, + complete: bool, + }, + Diff { + unified: String, + complete: bool, + }, + Menu(Menu), + Input(InputPrompt), + /// The raw terminal screen — the always-available source of truth. Phase 1 + /// carries plain text; Phase 2 upgrades this to a styled grid snapshot. + RawTerminal { + screen: String, + }, +} + +/// The variant tag, used by [`BlockEvent::Append`] so a consumer can allocate an +/// empty block of the right kind before patches arrive. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BlockKind { + Markdown, + Code, + Diff, + Menu, + Input, + RawTerminal, +} + +impl Block { + pub fn kind(&self) -> BlockKind { + match self { + Block::Markdown { .. } => BlockKind::Markdown, + Block::Code { .. } => BlockKind::Code, + Block::Diff { .. } => BlockKind::Diff, + Block::Menu(_) => BlockKind::Menu, + Block::Input(_) => BlockKind::Input, + Block::RawTerminal { .. } => BlockKind::RawTerminal, + } + } + + /// An empty block of the given kind, as materialized on [`BlockEvent::Append`]. + pub fn empty(kind: BlockKind) -> Block { + match kind { + BlockKind::Markdown => Block::Markdown { + text: String::new(), + complete: false, + }, + BlockKind::Code => Block::Code { + lang: None, + text: String::new(), + complete: false, + }, + BlockKind::Diff => Block::Diff { + unified: String::new(), + complete: false, + }, + BlockKind::Menu => Block::Menu(Menu::default()), + BlockKind::Input => Block::Input(InputPrompt::default()), + BlockKind::RawTerminal => Block::RawTerminal { + screen: String::new(), + }, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Menu { + pub title: Option, + pub options: Vec, + /// Highlighted row as last observed on screen. + pub selected: Option, + pub state: MenuState, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MenuOption { + pub label: String, + pub hotkey: Option, + /// Source screen row, used by keystroke synthesis in Phase 2. + pub raw_row: u16, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MenuState { + #[default] + Live, + Resolved { + chosen: usize, + }, + Stale, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct InputPrompt { + pub prompt: String, + pub kind: InputKind, + pub state: InputState, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum InputKind { + #[default] + FreeForm, + Text, + Password, + YesNo, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum InputState { + #[default] + Awaiting, + Submitted, +} + +/// A delta published when the authoritative log changes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BlockEvent { + /// A new block began. + Append { id: BlockId, kind: BlockKind }, + /// An existing block changed. + Update { + id: BlockId, + rev: Revision, + patch: BlockPatch, + }, + /// A block is complete; no more edits. + Finalize { id: BlockId, rev: Revision }, + /// Blocks from `from` onward were invalidated (e.g. an alt-screen clear). + Truncate { from: BlockId }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BlockPatch { + AppendText(String), + ReplaceText(String), + MenuSelect(usize), + MenuResolve(usize), + InputSubmit, +} diff --git a/crates/dd-client-session/src/derive/claude_code.rs b/crates/dd-client-session/src/derive/claude_code.rs new file mode 100644 index 0000000..a52c7f1 --- /dev/null +++ b/crates/dd-client-session/src/derive/claude_code.rs @@ -0,0 +1,291 @@ +//! Per-agent adapter for Claude Code's `--output-format stream-json`. +//! +//! When an agent is launched in a structured mode, we get lossless blocks +//! instead of scraping a TUI. This adapter consumes newline-delimited JSON +//! events and maps them to blocks: +//! * `assistant` message text → Markdown (one block per assistant turn) +//! * `assistant` `tool_use` → a Markdown header + a Code block of the input +//! * `user` `tool_result` → a Code block (the tool output) +//! * `result` → a final Markdown block +//! +//! It targets the default (non-partial) event stream; incremental +//! `content_block_delta` streaming is a future enrichment. A line that doesn't +//! parse is surfaced as Markdown rather than dropped, so nothing is lost. If +//! parsing mostly fails, [`confidence`](Adapter::confidence) drops and the +//! engine can fall back to the floor. + +use serde_json::Value; + +use crate::block::{BlockId, BlockKind, BlockPatch}; + +use super::{Adapter, BlockSink, Confidence}; + +#[derive(Default)] +pub struct ClaudeCodeAdapter { + buf: Vec, + /// Open Markdown block accumulating assistant text, if any. + text_block: Option, + events_ok: u32, + parse_errors: u32, +} + +impl ClaudeCodeAdapter { + pub fn new() -> Self { + Self::default() + } + + fn close_text(&mut self, sink: &mut dyn BlockSink) { + if let Some(id) = self.text_block.take() { + sink.finalize(id); + } + } + + fn push_markdown(&mut self, text: &str, sink: &mut dyn BlockSink) { + let id = match self.text_block { + Some(id) => id, + None => { + let id = sink.append(BlockKind::Markdown); + self.text_block = Some(id); + id + } + }; + sink.patch(id, BlockPatch::AppendText(text.to_string())); + } + + fn push_code(&mut self, lang: Option<&str>, text: &str, sink: &mut dyn BlockSink) { + self.close_text(sink); + let _ = lang; // lang carried for future styling; block records text now + let id = sink.append(BlockKind::Code); + sink.patch(id, BlockPatch::AppendText(text.to_string())); + sink.finalize(id); + } + + fn handle_event(&mut self, v: &Value, sink: &mut dyn BlockSink) { + match v.get("type").and_then(Value::as_str) { + Some("assistant") => self.handle_assistant(v, sink), + Some("user") => self.handle_tool_result(v, sink), + Some("result") => { + self.close_text(sink); + if let Some(text) = v.get("result").and_then(Value::as_str) { + let id = sink.append(BlockKind::Markdown); + sink.patch(id, BlockPatch::AppendText(format!("{text}\n"))); + sink.finalize(id); + } + } + // system/init and unknown types carry no user-facing content. + _ => {} + } + } + + fn handle_assistant(&mut self, v: &Value, sink: &mut dyn BlockSink) { + let Some(content) = v.pointer("/message/content").and_then(Value::as_array) else { + return; + }; + for block in content { + match block.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + self.push_markdown(&format!("{text}\n"), sink); + } + } + Some("tool_use") => { + let name = block.get("name").and_then(Value::as_str).unwrap_or("tool"); + self.push_markdown(&format!("⚙ {name}\n"), sink); + let input = block + .get("input") + .map(|i| serde_json::to_string_pretty(i).unwrap_or_default()) + .unwrap_or_default(); + if !input.is_empty() { + self.push_code(Some("json"), &format!("{input}\n"), sink); + } + } + _ => {} + } + } + } + + fn handle_tool_result(&mut self, v: &Value, sink: &mut dyn BlockSink) { + let Some(content) = v.pointer("/message/content").and_then(Value::as_array) else { + return; + }; + for block in content { + if block.get("type").and_then(Value::as_str) == Some("tool_result") { + let text = match block.get("content") { + Some(Value::String(s)) => s.clone(), + Some(other) => serde_json::to_string_pretty(other).unwrap_or_default(), + None => continue, + }; + self.push_code(None, &format!("{text}\n"), sink); + } + } + } +} + +impl Adapter for ClaudeCodeAdapter { + fn name(&self) -> &str { + "claude-code" + } + + fn feed(&mut self, bytes: &[u8], sink: &mut dyn BlockSink) { + self.buf.extend_from_slice(bytes); + while let Some(pos) = self.buf.iter().position(|&b| b == b'\n') { + let line: Vec = self.buf.drain(..=pos).collect(); + let line = &line[..line.len() - 1]; + if line.iter().all(|b| b.is_ascii_whitespace()) { + continue; + } + match serde_json::from_slice::(line) { + Ok(v) => { + self.events_ok += 1; + self.handle_event(&v, sink); + } + Err(_) => { + // Don't drop anything we couldn't parse. + self.parse_errors += 1; + let text = String::from_utf8_lossy(line).into_owned(); + self.push_markdown(&format!("{text}\n"), sink); + } + } + } + } + + fn flush(&mut self, sink: &mut dyn BlockSink) { + if !self.buf.is_empty() { + let rest: Vec = std::mem::take(&mut self.buf); + if let Ok(v) = serde_json::from_slice::(&rest) { + self.events_ok += 1; + self.handle_event(&v, sink); + } + } + self.close_text(sink); + } + + fn confidence(&self) -> Confidence { + match (self.events_ok, self.parse_errors) { + (0, _) => Confidence::Low, + (_, 0) => Confidence::High, + _ => Confidence::Medium, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::block::Block; + use crate::stream::BlockLog; + + #[derive(Default)] + struct TestSink { + log: BlockLog, + } + impl BlockSink for TestSink { + fn append(&mut self, kind: BlockKind) -> BlockId { + self.log.append(kind).0 + } + fn patch(&mut self, id: BlockId, patch: BlockPatch) { + self.log.patch(id, patch); + } + fn finalize(&mut self, id: BlockId) { + self.log.finalize(id); + } + fn truncate(&mut self, from: BlockId) { + self.log.truncate(from); + } + } + + fn derive(input: &[u8]) -> Vec { + let mut a = ClaudeCodeAdapter::new(); + let mut sink = TestSink::default(); + a.feed(input, &mut sink); + a.flush(&mut sink); + sink.log.snapshot() + } + + #[test] + fn assistant_text_becomes_markdown() { + let line = + br#"{"type":"assistant","message":{"content":[{"type":"text","text":"Hello there"}]}}"#; + let mut input = line.to_vec(); + input.push(b'\n'); + assert_eq!( + derive(&input), + vec![Block::Markdown { + text: "Hello there\n".into(), + complete: true + }] + ); + } + + #[test] + fn tool_use_becomes_header_plus_code() { + let line = br#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}}"#; + let mut input = line.to_vec(); + input.push(b'\n'); + let blocks = derive(&input); + assert_eq!( + blocks[0], + Block::Markdown { + text: "⚙ Bash\n".into(), + complete: true + } + ); + match &blocks[1] { + Block::Code { text, .. } => assert!(text.contains("\"command\": \"ls\"")), + other => panic!("expected code block, got {other:?}"), + } + } + + #[test] + fn result_event_is_final_markdown() { + let line = br#"{"type":"result","subtype":"success","result":"done"}"#; + let mut input = line.to_vec(); + input.push(b'\n'); + assert_eq!( + derive(&input), + vec![Block::Markdown { + text: "done\n".into(), + complete: true + }] + ); + } + + #[test] + fn unparseable_line_is_surfaced_not_dropped() { + let blocks = derive(b"not json at all\n"); + assert_eq!( + blocks, + vec![Block::Markdown { + text: "not json at all\n".into(), + complete: true + }] + ); + } + + #[test] + fn confidence_high_on_clean_parse_low_on_none() { + let mut a = ClaudeCodeAdapter::new(); + assert_eq!(a.confidence(), Confidence::Low); + let mut sink = TestSink::default(); + a.feed(b"{\"type\":\"system\"}\n", &mut sink); + assert_eq!(a.confidence(), Confidence::High); + } + + #[test] + fn json_split_across_feeds() { + let mut a = ClaudeCodeAdapter::new(); + let mut sink = TestSink::default(); + a.feed( + br#"{"type":"assistant","message":{"content":[{"type":"text",""#, + &mut sink, + ); + a.feed(b"text\":\"hi\"}]}}\n", &mut sink); + assert_eq!( + sink.log.snapshot(), + vec![Block::Markdown { + text: "hi\n".into(), + complete: false + }] + ); + } +} diff --git a/crates/dd-client-session/src/derive/floor.rs b/crates/dd-client-session/src/derive/floor.rs new file mode 100644 index 0000000..0610887 --- /dev/null +++ b/crates/dd-client-session/src/derive/floor.rs @@ -0,0 +1,379 @@ +//! The universal floor: a line-oriented interpreter that works for any TUI. +//! +//! It strips ANSI control sequences incrementally (state persists across feed +//! boundaries), splits the output into logical lines, and groups them into +//! `Markdown` / `Code` / `Diff` blocks. A bare carriage return rewrites the +//! current line (the common spinner/progress idiom), which keeps transient +//! redraws out of the block log. +//! +//! Honest limits (Phase 1): output is line-buffered, so a long token-streamed +//! line without newlines appears only once it completes; full-screen +//! (alt-screen) apps that paint absolutely are not modeled here — Phase 2's +//! `vt100` screen handles those and powers menu detection + Raw mode. + +use crate::block::{BlockId, BlockKind, BlockPatch}; + +use super::{Adapter, BlockSink, Confidence}; + +#[derive(Clone, Copy)] +enum Ansi { + Normal, + Esc, + Csi, + Osc, + OscEsc, +} + +pub struct FloorAdapter { + ansi: Ansi, + line: Vec, + /// A carriage return was seen but not yet acted on. `\r\n` is a clean line + /// ending; a bare `\r` followed by content rewrites the line (spinners). + pending_cr: bool, + in_code: bool, + in_diff: bool, + current: Option<(BlockId, BlockKind)>, +} + +impl Default for FloorAdapter { + fn default() -> Self { + Self { + ansi: Ansi::Normal, + line: Vec::new(), + pending_cr: false, + in_code: false, + in_diff: false, + current: None, + } + } +} + +impl FloorAdapter { + pub fn new() -> Self { + Self::default() + } + + /// Ensure the open block is of `kind`, finalizing a different open block + /// first. Returns the id to patch. + fn ensure(&mut self, kind: BlockKind, sink: &mut dyn BlockSink) -> BlockId { + if let Some((id, k)) = self.current { + if k == kind { + return id; + } + sink.finalize(id); + } + let id = sink.append(kind); + self.current = Some((id, kind)); + id + } + + /// If a bare carriage return is pending, the upcoming content rewrites the + /// line from the start, so clear what we had. + fn rewrite_if_pending_cr(&mut self) { + if self.pending_cr { + self.line.clear(); + self.pending_cr = false; + } + } + + fn append_line(&mut self, kind: BlockKind, mut text: String, sink: &mut dyn BlockSink) { + let id = self.ensure(kind, sink); + text.push('\n'); + sink.patch(id, BlockPatch::AppendText(text)); + } + + fn emit_line(&mut self, bytes: Vec, sink: &mut dyn BlockSink) { + let line = String::from_utf8_lossy(&bytes).into_owned(); + let trimmed = line.trim_start(); + + // Fenced code toggles. The fence line itself is not content. + if trimmed.starts_with("```") { + if self.in_code { + self.in_code = false; + if let Some((id, _)) = self.current.take() { + sink.finalize(id); + } + } else { + // Close any open block, then open a code block. + if let Some((id, _)) = self.current.take() { + sink.finalize(id); + } + self.in_code = true; + let id = sink.append(BlockKind::Code); + self.current = Some((id, BlockKind::Code)); + } + return; + } + + if self.in_code { + self.append_line(BlockKind::Code, line, sink); + return; + } + + if !self.in_diff && (trimmed.starts_with("@@ ") || line.starts_with("diff --git ")) { + self.in_diff = true; + } + if self.in_diff { + if is_diff_line(&line) { + self.append_line(BlockKind::Diff, line, sink); + return; + } + self.in_diff = false; + if let Some((id, BlockKind::Diff)) = self.current { + sink.finalize(id); + self.current = None; + } + } + + self.append_line(BlockKind::Markdown, line, sink); + } +} + +fn is_diff_line(line: &str) -> bool { + line.starts_with("diff --git") + || matches!( + line.as_bytes().first(), + Some(b'+' | b'-' | b' ' | b'@' | b'\\') + ) +} + +impl Adapter for FloorAdapter { + fn name(&self) -> &str { + "floor" + } + + fn feed(&mut self, bytes: &[u8], sink: &mut dyn BlockSink) { + for &b in bytes { + self.ansi = match self.ansi { + Ansi::Normal => match b { + 0x1b => Ansi::Esc, + b'\n' => { + self.pending_cr = false; + let line = std::mem::take(&mut self.line); + self.emit_line(line, sink); + Ansi::Normal + } + b'\r' => { + self.pending_cr = true; + Ansi::Normal + } + 0x08 => { + self.rewrite_if_pending_cr(); + self.line.pop(); + Ansi::Normal + } + b'\t' => { + self.rewrite_if_pending_cr(); + self.line.push(b' '); + Ansi::Normal + } + // Drop other C0 controls; keep printable bytes (incl. UTF-8). + 0x00..=0x1f => Ansi::Normal, + _ => { + self.rewrite_if_pending_cr(); + self.line.push(b); + Ansi::Normal + } + }, + Ansi::Esc => match b { + b'[' => Ansi::Csi, + b']' => Ansi::Osc, + _ => Ansi::Normal, + }, + // CSI ends on a final byte 0x40..=0x7e; params/intermediates continue. + Ansi::Csi => { + if (0x40..=0x7e).contains(&b) { + Ansi::Normal + } else { + Ansi::Csi + } + } + // OSC ends on BEL or ST (ESC \). + Ansi::Osc => match b { + 0x07 => Ansi::Normal, + 0x1b => Ansi::OscEsc, + _ => Ansi::Osc, + }, + Ansi::OscEsc => Ansi::Normal, + }; + } + } + + fn flush(&mut self, sink: &mut dyn BlockSink) { + if !self.line.is_empty() { + let line = std::mem::take(&mut self.line); + self.emit_line(line, sink); + } + if let Some((id, _)) = self.current.take() { + sink.finalize(id); + } + } + + fn confidence(&self) -> Confidence { + Confidence::Floor + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::block::{Block, BlockEvent}; + use crate::stream::{BlockLog, BlockView}; + + /// Test sink backed by a real `BlockLog`, also recording the event stream so + /// we can assert a `BlockView` reconstructs the same thing. + #[derive(Default)] + struct TestSink { + log: BlockLog, + events: Vec, + } + impl BlockSink for TestSink { + fn append(&mut self, kind: BlockKind) -> BlockId { + let (id, ev) = self.log.append(kind); + self.events.push(ev); + id + } + fn patch(&mut self, id: BlockId, patch: BlockPatch) { + if let Some(ev) = self.log.patch(id, patch) { + self.events.push(ev); + } + } + fn finalize(&mut self, id: BlockId) { + if let Some(ev) = self.log.finalize(id) { + self.events.push(ev); + } + } + fn truncate(&mut self, from: BlockId) { + if let Some(ev) = self.log.truncate(from) { + self.events.push(ev); + } + } + } + + fn derive(input: &[u8]) -> TestSink { + let mut floor = FloorAdapter::new(); + let mut sink = TestSink::default(); + floor.feed(input, &mut sink); + floor.flush(&mut sink); + // Sanity: replaying the event stream reconstructs the snapshot. + let mut view = BlockView::default(); + for ev in &sink.events { + view.apply(ev); + } + assert_eq!(view.blocks(), sink.log.snapshot()); + sink + } + + #[test] + fn plain_lines_become_one_markdown_block() { + let s = derive(b"hello\nworld\n"); + assert_eq!( + s.log.snapshot(), + vec![Block::Markdown { + text: "hello\nworld\n".into(), + complete: true + }] + ); + } + + #[test] + fn strips_ansi_color_and_cursor_codes() { + // Colored "ok" then a cursor-move that should vanish. + let s = derive(b"\x1b[32mok\x1b[0m\n\x1b[2Kdone\n"); + assert_eq!( + s.log.snapshot(), + vec![Block::Markdown { + text: "ok\ndone\n".into(), + complete: true + }] + ); + } + + #[test] + fn crlf_line_endings_are_clean() { + // Real PTY output uses CRLF; the CR must not wipe the line. + let s = derive(b"hello\r\nworld\r\n"); + assert_eq!( + s.log.snapshot(), + vec![Block::Markdown { + text: "hello\nworld\n".into(), + complete: true + }] + ); + } + + #[test] + fn carriage_return_rewrites_progress_line() { + // A spinner redrawing the same line; only the final state survives. + let s = derive(b"working 10%\rworking 80%\rdone\n"); + assert_eq!( + s.log.snapshot(), + vec![Block::Markdown { + text: "done\n".into(), + complete: true + }] + ); + } + + #[test] + fn fenced_code_becomes_code_block_between_markdown() { + let s = derive(b"intro\n```rust\nfn main() {}\n```\noutro\n"); + assert_eq!( + s.log.snapshot(), + vec![ + Block::Markdown { + text: "intro\n".into(), + complete: true + }, + Block::Code { + lang: None, + text: "fn main() {}\n".into(), + complete: true + }, + Block::Markdown { + text: "outro\n".into(), + complete: true + }, + ] + ); + } + + #[test] + fn unified_diff_hunk_becomes_diff_block() { + let input = b"edit:\n@@ -1,2 +1,2 @@\n-old\n+new\n done\nafter\n"; + let s = derive(input); + assert_eq!( + s.log.snapshot(), + vec![ + Block::Markdown { + text: "edit:\n".into(), + complete: true + }, + Block::Diff { + unified: "@@ -1,2 +1,2 @@\n-old\n+new\n done\n".into(), + complete: true + }, + Block::Markdown { + text: "after\n".into(), + complete: true + }, + ] + ); + } + + #[test] + fn ansi_sequence_split_across_feeds_is_still_stripped() { + let mut floor = FloorAdapter::new(); + let mut sink = TestSink::default(); + floor.feed(b"a\x1b[", &mut sink); // escape split mid-sequence + floor.feed(b"31mb\n", &mut sink); + floor.flush(&mut sink); + assert_eq!( + sink.log.snapshot(), + vec![Block::Markdown { + text: "ab\n".into(), + complete: true + }] + ); + } +} diff --git a/crates/dd-client-session/src/derive/menu.rs b/crates/dd-client-session/src/derive/menu.rs new file mode 100644 index 0000000..49d2385 --- /dev/null +++ b/crates/dd-client-session/src/derive/menu.rs @@ -0,0 +1,216 @@ +//! Menu / prompt detection over a screen snapshot. +//! +//! This is the deliberately-fragile floor heuristic the design calls out: from a +//! grid of text + per-row highlight flags, guess whether the app is presenting a +//! selectable list. It recognizes two shapes: +//! * a run of ≥2 numbered/bulleted option lines, with the selected row marked +//! by reverse-video or a leading `>`/`❯` arrow; +//! * a single yes/no prompt (`(y/n)`, `[Y/n]`). +//! +//! Callers gate this on output quiescence (the agent has gone quiet ⇒ likely +//! awaiting input) — see the engine. Per-agent adapters replace this with exact +//! structure for the agents people actually use. + +use crate::block::{Menu, MenuOption, MenuState}; + +use super::screen::ScreenSnapshot; + +const ARROW_MARKERS: [char; 5] = ['>', '❯', '➤', '●', '*']; + +/// Detect a menu on the current screen, or `None`. +pub fn detect_menu(screen: &ScreenSnapshot) -> Option { + if let Some(menu) = detect_option_list(screen) { + return Some(menu); + } + detect_yes_no(screen) +} + +fn detect_option_list(screen: &ScreenSnapshot) -> Option { + let rows = &screen.rows; + // Find the last (closest-to-bottom) run of ≥2 consecutive option lines. + let mut best: Option<(usize, usize)> = None; + let mut i = 0; + while i < rows.len() { + if option_label(&rows[i].text).is_some() { + let start = i; + while i < rows.len() && option_label(&rows[i].text).is_some() { + i += 1; + } + if i - start >= 2 { + best = Some((start, i)); + } + } else { + i += 1; + } + } + let (start, end) = best?; + + let mut options = Vec::new(); + let mut selected = None; + for (idx, row) in rows[start..end].iter().enumerate() { + let (label, hotkey) = option_label(&row.text)?; + if row.inverse || starts_with_marker(&row.text) { + selected = Some(idx); + } + options.push(MenuOption { + label, + hotkey, + raw_row: (start + idx) as u16, + }); + } + Some(Menu { + title: None, + options, + selected, + state: MenuState::Live, + }) +} + +fn detect_yes_no(screen: &ScreenSnapshot) -> Option { + let row = screen + .rows + .iter() + .rev() + .find(|r| !r.text.trim().is_empty())?; + let lower = row.text.to_lowercase(); + if !(lower.contains("(y/n)") || lower.contains("[y/n]") || lower.contains("(yes/no)")) { + return None; + } + Some(Menu { + title: Some(row.text.trim().to_string()), + options: vec![ + MenuOption { + label: "Yes".into(), + hotkey: Some('y'), + raw_row: 0, + }, + MenuOption { + label: "No".into(), + hotkey: Some('n'), + raw_row: 0, + }, + ], + selected: None, + state: MenuState::Live, + }) +} + +fn starts_with_marker(line: &str) -> bool { + line.trim_start() + .starts_with(|c: char| ARROW_MARKERS.contains(&c)) +} + +/// Parse an option line, returning `(label, hotkey)` if it looks like one. +/// Accepts an optional leading arrow/bullet marker, then either a number +/// (`1.`/`1)`) or a bracketed/lettered hotkey, then the label text. +fn option_label(line: &str) -> Option<(String, Option)> { + let mut s = line.trim_start(); + // Strip a leading selection marker. + if let Some(rest) = s.strip_prefix(|c: char| ARROW_MARKERS.contains(&c)) { + s = rest.trim_start(); + } + if s.is_empty() { + return None; + } + + // Numbered: "1. label" or "1) label". + let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect(); + if !digits.is_empty() { + let rest = &s[digits.len()..]; + if let Some(label) = rest.strip_prefix(['.', ')']) { + let label = label.trim(); + if !label.is_empty() { + let hotkey = digits.chars().next().filter(|_| digits.len() == 1); + return Some((label.to_string(), hotkey)); + } + } + } + + // Bracketed letter: "[a] label" or "(a) label". + let bytes = s.as_bytes(); + if bytes.len() >= 3 { + let (open, close) = (bytes[0], bytes[2]); + let mid = bytes[1] as char; + if (open == b'[' && close == b']' || open == b'(' && close == b')') + && mid.is_ascii_alphanumeric() + { + let label = s[3..].trim(); + if !label.is_empty() { + return Some((label.to_string(), Some(mid.to_ascii_lowercase()))); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::derive::screen::RowSnapshot; + + fn screen(rows: &[(&str, bool)]) -> ScreenSnapshot { + ScreenSnapshot { + rows: rows + .iter() + .map(|(t, inv)| RowSnapshot { + text: (*t).to_string(), + inverse: *inv, + }) + .collect(), + cursor: (0, 0), + alternate: false, + } + } + + #[test] + fn numbered_list_with_inverse_selection() { + let s = screen(&[ + ("Choose an option:", false), + ("1. Apply", false), + ("2. Skip", true), // highlighted + ("3. Quit", false), + ]); + let menu = detect_menu(&s).expect("menu"); + assert_eq!(menu.options.len(), 3); + assert_eq!(menu.options[0].label, "Apply"); + assert_eq!(menu.options[0].hotkey, Some('1')); + assert_eq!(menu.selected, Some(1)); + } + + #[test] + fn arrow_marker_marks_selection() { + let s = screen(&[("> 1) yes", false), (" 2) no", false)]); + let menu = detect_menu(&s).expect("menu"); + assert_eq!(menu.selected, Some(0)); + assert_eq!(menu.options[1].label, "no"); + } + + #[test] + fn yes_no_prompt() { + let s = screen(&[("Proceed with deploy? (y/n)", false)]); + let menu = detect_menu(&s).expect("menu"); + assert_eq!(menu.options.len(), 2); + assert_eq!(menu.options[0].hotkey, Some('y')); + assert_eq!(menu.options[1].hotkey, Some('n')); + } + + #[test] + fn plain_prose_is_not_a_menu() { + let s = screen(&[ + ("Here is a paragraph of normal output.", false), + ("It continues on a second line.", false), + ]); + assert!(detect_menu(&s).is_none()); + } + + #[test] + fn single_option_is_not_enough() { + let s = screen(&[ + ("intro", false), + ("1. only one", false), + ("trailing", false), + ]); + assert!(detect_menu(&s).is_none()); + } +} diff --git a/crates/dd-client-session/src/derive/mod.rs b/crates/dd-client-session/src/derive/mod.rs new file mode 100644 index 0000000..6f518c9 --- /dev/null +++ b/crates/dd-client-session/src/derive/mod.rs @@ -0,0 +1,46 @@ +//! Derivation: turning the PTY byte stream into structured blocks. +//! +//! The [`FloorAdapter`] is always present and works for any TUI by interpreting +//! its output. Later phases add per-agent [`Adapter`]s that consume a structured +//! mode (e.g. Claude Code stream-json) for lossless blocks. Both write through a +//! [`BlockSink`], so the engine doesn't care which produced a block. + +pub mod claude_code; +pub mod floor; +pub mod menu; +pub mod screen; + +use crate::block::{BlockId, BlockKind, BlockPatch}; + +pub use claude_code::ClaudeCodeAdapter; +pub use floor::FloorAdapter; +pub use screen::{Screen, ScreenSnapshot}; + +/// Where an adapter pushes block mutations. The engine implements this over its +/// authoritative log + event broadcast. +pub trait BlockSink { + fn append(&mut self, kind: BlockKind) -> BlockId; + fn patch(&mut self, id: BlockId, patch: BlockPatch); + fn finalize(&mut self, id: BlockId); + fn truncate(&mut self, from: BlockId); +} + +/// How faithful an adapter believes its block model is. The engine prefers the +/// highest-confidence adapter; the floor is always [`Confidence::Floor`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Confidence { + Floor, + Low, + Medium, + High, +} + +pub trait Adapter: Send { + fn name(&self) -> &str; + /// Feed raw PTY bytes; push any resulting block mutations to `sink`. + fn feed(&mut self, bytes: &[u8], sink: &mut dyn BlockSink); + /// Flush any buffered partial line and finalize the open block (called once + /// the session output ends). + fn flush(&mut self, sink: &mut dyn BlockSink); + fn confidence(&self) -> Confidence; +} diff --git a/crates/dd-client-session/src/derive/screen.rs b/crates/dd-client-session/src/derive/screen.rs new file mode 100644 index 0000000..8d21914 --- /dev/null +++ b/crates/dd-client-session/src/derive/screen.rs @@ -0,0 +1,135 @@ +//! Headless terminal screen, wrapping `vt100`. +//! +//! Two jobs: (1) produce a faithful text snapshot of the current screen for the +//! `RawTerminal` block / Raw-mode rendering, and (2) expose per-row highlight +//! info so menu detection can spot the selected option. The `vt100` crate is +//! kept behind this wrapper so the heuristics never depend on it directly. + +use vt100::Parser; + +const DEFAULT_ROWS: u16 = 24; +const DEFAULT_COLS: u16 = 80; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RowSnapshot { + pub text: String, + /// True if any cell in the row is reverse-video — the strongest signal a TUI + /// uses to mark a highlighted/selected menu row. + pub inverse: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScreenSnapshot { + pub rows: Vec, + /// (row, col) of the cursor. + pub cursor: (u16, u16), + /// Whether the app is on the alternate screen (full-screen TUI). + pub alternate: bool, +} + +impl ScreenSnapshot { + /// The screen as plain text, trailing blank rows trimmed. + pub fn plain(&self) -> String { + let last = self + .rows + .iter() + .rposition(|r| !r.text.trim().is_empty()) + .map(|i| i + 1) + .unwrap_or(0); + self.rows[..last] + .iter() + .map(|r| r.text.as_str()) + .collect::>() + .join("\n") + } +} + +pub struct Screen { + parser: Parser, +} + +impl Default for Screen { + fn default() -> Self { + Self::new(DEFAULT_ROWS, DEFAULT_COLS) + } +} + +impl Screen { + pub fn new(rows: u16, cols: u16) -> Self { + Self { + parser: Parser::new(rows, cols, 0), + } + } + + pub fn process(&mut self, bytes: &[u8]) { + self.parser.process(bytes); + } + + pub fn resize(&mut self, rows: u16, cols: u16) { + self.parser.set_size(rows, cols); + } + + /// Escape-sequence bytes that repaint the current screen from scratch — + /// used to restore the display when entering Raw mode. + pub fn formatted(&self) -> Vec { + self.parser.screen().contents_formatted() + } + + pub fn snapshot(&self) -> ScreenSnapshot { + let screen = self.parser.screen(); + let (rows, cols) = screen.size(); + let mut out = Vec::with_capacity(rows as usize); + for r in 0..rows { + let mut text = String::new(); + let mut inverse = false; + for c in 0..cols { + if let Some(cell) = screen.cell(r, c) { + let contents = cell.contents(); + if contents.is_empty() { + text.push(' '); + } else { + text.push_str(&contents); + } + if cell.inverse() { + inverse = true; + } + } + } + out.push(RowSnapshot { + text: text.trim_end().to_string(), + inverse, + }); + } + ScreenSnapshot { + rows: out, + cursor: screen.cursor_position(), + alternate: screen.alternate_screen(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_plain_text() { + let mut s = Screen::new(4, 20); + s.process(b"hello\r\nworld\r\n"); + let snap = s.snapshot(); + assert_eq!(snap.rows[0].text, "hello"); + assert_eq!(snap.rows[1].text, "world"); + assert_eq!(snap.plain(), "hello\nworld"); + } + + #[test] + fn detects_inverse_row() { + let mut s = Screen::new(4, 20); + // Normal line, then a reverse-video line. + s.process(b"normal\r\n\x1b[7mselected\x1b[0m\r\n"); + let snap = s.snapshot(); + assert!(!snap.rows[0].inverse); + assert!(snap.rows[1].inverse); + assert_eq!(snap.rows[1].text, "selected"); + } +} diff --git a/crates/dd-client-session/src/engine.rs b/crates/dd-client-session/src/engine.rs new file mode 100644 index 0000000..a173593 --- /dev/null +++ b/crates/dd-client-session/src/engine.rs @@ -0,0 +1,186 @@ +//! The session engine: owns the authoritative [`BlockLog`], the floor deriver, +//! and the vt100 [`Screen`], and publishes a [`BlockEvent`] broadcast. +//! +//! The frontend feeds every output frame to [`SessionEngine::feed_output`]; the +//! engine drives both the floor (→ structured blocks) and the screen (→ Raw-mode +//! rendering + menu detection). It is cheaply cloneable (shared state) so the +//! pump task and the renderer can each hold one. + +use std::sync::{Arc, Mutex}; + +use tokio::sync::broadcast; + +use crate::block::{Block, BlockEvent, BlockId, BlockKind, BlockPatch}; +use crate::derive::menu::detect_menu; +use crate::derive::screen::ScreenSnapshot; +use crate::derive::{Adapter, BlockSink, FloorAdapter, Screen}; +use crate::stream::BlockLog; + +const EVENT_CAPACITY: usize = 4096; + +#[derive(Clone)] +pub struct SessionEngine { + log: Arc>, + tx: broadcast::Sender, + adapter: Arc>>, + screen: Arc>, +} + +impl Default for SessionEngine { + fn default() -> Self { + Self::new() + } +} + +impl SessionEngine { + /// Engine with the universal floor deriver. + pub fn new() -> Self { + Self::with_adapter(Box::new(FloorAdapter::new())) + } + + /// Engine with a specific structured deriver (e.g. a per-agent adapter). The + /// vt100 screen always runs alongside for Raw-mode rendering + menu detection. + pub fn with_adapter(adapter: Box) -> Self { + let (tx, _) = broadcast::channel(EVENT_CAPACITY); + Self { + log: Arc::new(Mutex::new(BlockLog::new())), + tx, + adapter: Arc::new(Mutex::new(adapter)), + screen: Arc::new(Mutex::new(Screen::default())), + } + } + + /// Feed one decrypted output frame: drives the structured adapter and the + /// screen (grid). Called from the transport pump. + pub fn feed_output(&self, bytes: &[u8]) { + { + let mut adapter = self.adapter.lock().expect("adapter poisoned"); + let mut sink = self.sink(); + adapter.feed(bytes, &mut sink); + } + self.screen.lock().expect("screen poisoned").process(bytes); + } + + /// Flush the adapter's buffered state and finalize the open block (once the + /// session output ends). + pub fn finish(&self) { + let mut adapter = self.adapter.lock().expect("adapter poisoned"); + let mut sink = self.sink(); + adapter.flush(&mut sink); + } + + pub fn resize(&self, rows: u16, cols: u16) { + self.screen + .lock() + .expect("screen poisoned") + .resize(rows, cols); + } + + /// Current screen snapshot — for Raw-mode rendering and menu detection. + pub fn screen_snapshot(&self) -> ScreenSnapshot { + self.screen.lock().expect("screen poisoned").snapshot() + } + + /// Repaint bytes for the current screen — written to the tty on Raw entry. + pub fn screen_formatted(&self) -> Vec { + self.screen.lock().expect("screen poisoned").formatted() + } + + /// Detect a menu on the current screen, if any (Interact mode). + pub fn detect_menu(&self) -> Option { + detect_menu(&self.screen_snapshot()) + } + + /// A consistent snapshot plus a receiver for subsequent deltas. The lock is + /// held across `subscribe()` + `snapshot()` so no event slips between them. + pub fn subscribe(&self) -> (Vec, broadcast::Receiver) { + let log = self.log.lock().expect("block log poisoned"); + let rx = self.tx.subscribe(); + let snap = log.snapshot(); + (snap, rx) + } + + /// Current blocks — used by a renderer to resync after a broadcast lag. + pub fn snapshot(&self) -> Vec { + self.log.lock().expect("block log poisoned").snapshot() + } + + fn sink(&self) -> EngineSink { + EngineSink { + log: self.log.clone(), + tx: self.tx.clone(), + } + } +} + +/// A [`BlockSink`] that mutates the engine's log and broadcasts each delta. The +/// log lock is held across the broadcast so [`SessionEngine::subscribe`] stays +/// race-free. +struct EngineSink { + log: Arc>, + tx: broadcast::Sender, +} + +impl BlockSink for EngineSink { + fn append(&mut self, kind: BlockKind) -> BlockId { + let mut log = self.log.lock().expect("block log poisoned"); + let (id, ev) = log.append(kind); + let _ = self.tx.send(ev); + id + } + + fn patch(&mut self, id: BlockId, patch: BlockPatch) { + let mut log = self.log.lock().expect("block log poisoned"); + if let Some(ev) = log.patch(id, patch) { + let _ = self.tx.send(ev); + } + } + + fn finalize(&mut self, id: BlockId) { + let mut log = self.log.lock().expect("block log poisoned"); + if let Some(ev) = log.finalize(id) { + let _ = self.tx.send(ev); + } + } + + fn truncate(&mut self, from: BlockId) { + let mut log = self.log.lock().expect("block log poisoned"); + if let Some(ev) = log.truncate(from) { + let _ = self.tx.send(ev); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn feed_output_lands_in_log_screen_and_broadcasts() { + let engine = SessionEngine::new(); + let (snap0, mut rx) = engine.subscribe(); + assert!(snap0.is_empty()); + + engine.feed_output(b"hello\r\n"); + engine.finish(); + + assert!(matches!(rx.try_recv(), Ok(BlockEvent::Append { .. }))); + assert_eq!( + engine.snapshot(), + vec![Block::Markdown { + text: "hello\n".into(), + complete: true + }] + ); + assert_eq!(engine.screen_snapshot().rows[0].text, "hello"); + } + + #[tokio::test] + async fn menu_detected_from_screen() { + let engine = SessionEngine::new(); + engine.feed_output(b"Pick:\r\n1. apply\r\n\x1b[7m2. skip\x1b[0m\r\n"); + let menu = engine.detect_menu().expect("menu"); + assert_eq!(menu.options.len(), 2); + assert_eq!(menu.selected, Some(1)); + } +} diff --git a/crates/dd-client-session/src/history.rs b/crates/dd-client-session/src/history.rs new file mode 100644 index 0000000..69dccbd --- /dev/null +++ b/crates/dd-client-session/src/history.rs @@ -0,0 +1,242 @@ +//! Client-side decryption of end-to-end-encrypted session history. +//! +//! `dd-sessiond` seals each transcript record to the paired device pubkeys and +//! `replay` returns the sealed lines — the enclave cannot read them back. This +//! module is the matching opener: with the device's X25519 secret, recover the +//! content key from whichever recipient stanza is ours, decrypt the record, and +//! reconstruct the terminal byte stream. +//! +//! Wire format (must match `dd/src/sessiond.rs::seal_record`): one compact-JSON +//! line per record — +//! ```json +//! {"v":2,"rcpts":[{"epk":"","n":"","wk":""}],"bn":"","body":""} +//! ``` +//! `body` = ChaCha20Poly1305(CEK, bn, serde(TranscriptRecord)); each stanza wraps +//! CEK to one device via ephemeral X25519 + HKDF-SHA256(info = +//! "dd-sessiond-e2e-v2" ‖ epk ‖ recipient_pubkey). + +use base64::Engine as _; +use chacha20poly1305::aead::{Aead, KeyInit}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; +use hkdf::Hkdf; +use serde::Deserialize; +use serde_json::Value; +use sha2::Sha256; +use x25519_dalek::{PublicKey, StaticSecret}; + +const KDF_INFO_PREFIX: &[u8] = b"dd-sessiond-e2e-v2"; +const B64: base64::engine::general_purpose::GeneralPurpose = + base64::engine::general_purpose::STANDARD; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct TranscriptRecord { + pub ts: i64, + pub kind: String, + pub data_b64: String, +} + +#[derive(Deserialize)] +struct SealedLine { + v: u32, + rcpts: Vec, + bn: String, + body: String, +} + +#[derive(Deserialize)] +struct RecipientStanza { + epk: String, + n: String, + wk: String, +} + +/// Derive the per-recipient key-wrapping key — must match the server's +/// `derive_wrap_key`. +fn derive_wrap_key(shared: &[u8; 32], epk: &[u8; 32], rpk: &[u8; 32]) -> [u8; 32] { + let hk = Hkdf::::new(None, shared); + let mut info = Vec::with_capacity(KDF_INFO_PREFIX.len() + 64); + info.extend_from_slice(KDF_INFO_PREFIX); + info.extend_from_slice(epk); + info.extend_from_slice(rpk); + let mut out = [0u8; 32]; + hk.expand(&info, &mut out) + .expect("hkdf expand of 32 bytes never fails"); + out +} + +fn hex32(s: &str) -> anyhow::Result<[u8; 32]> { + let v = hex::decode(s)?; + let arr: [u8; 32] = v + .try_into() + .map_err(|_| anyhow::anyhow!("expected 32 bytes"))?; + Ok(arr) +} + +fn hex12(s: &str) -> anyhow::Result<[u8; 12]> { + let v = hex::decode(s)?; + let arr: [u8; 12] = v + .try_into() + .map_err(|_| anyhow::anyhow!("expected 12 bytes"))?; + Ok(arr) +} + +/// Open one sealed line with the device secret. Returns `Ok(None)` if this device +/// is not a recipient (no stanza decrypts), `Err` only on malformed input. +pub fn open_record( + device_secret: &StaticSecret, + line: &str, +) -> anyhow::Result> { + let sealed: SealedLine = serde_json::from_str(line)?; + if sealed.v != 2 { + anyhow::bail!("unsupported sealed-record version {}", sealed.v); + } + let device_pk = *PublicKey::from(device_secret).as_bytes(); + let bn = hex12(&sealed.bn)?; + let body = B64.decode(&sealed.body)?; + + for st in &sealed.rcpts { + let epk = hex32(&st.epk)?; + let n = hex12(&st.n)?; + let wk = B64.decode(&st.wk)?; + let shared = device_secret.diffie_hellman(&PublicKey::from(epk)); + let wrap_key = derive_wrap_key(shared.as_bytes(), &epk, &device_pk); + // Wrong recipient → AEAD tag mismatch → try the next stanza. + let Ok(cek) = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)) + .decrypt(Nonce::from_slice(&n), wk.as_ref()) + else { + continue; + }; + let cek: [u8; 32] = cek + .try_into() + .map_err(|_| anyhow::anyhow!("content key wrong length"))?; + let plain = ChaCha20Poly1305::new(Key::from_slice(&cek)) + .decrypt(Nonce::from_slice(&bn), body.as_ref()) + .map_err(|e| anyhow::anyhow!("decrypt record body: {e}"))?; + let record: TranscriptRecord = serde_json::from_slice(&plain)?; + return Ok(Some(record)); + } + Ok(None) +} + +/// Reconstruct the terminal byte stream from a `replay` response. Handles both +/// the v2 sealed `records` array (decrypted with `device_secret`) and the legacy +/// plaintext `bytes_b64` shape (older agents), so the client works across the +/// server rollout. +pub fn decrypt_replay(device_secret: &StaticSecret, response: &Value) -> anyhow::Result> { + if let Some(records) = response.get("records").and_then(Value::as_array) { + let mut out = Vec::new(); + for line in records.iter().filter_map(Value::as_str) { + let Some(record) = open_record(device_secret, line)? else { + continue; // not our recipient — skip + }; + if matches!(record.kind.as_str(), "pty" | "stdout" | "stderr") { + out.extend_from_slice(&B64.decode(&record.data_b64)?); + } + } + return Ok(out); + } + if let Some(b64) = response.get("bytes_b64").and_then(Value::as_str) { + return Ok(B64.decode(b64)?); // legacy plaintext replay + } + anyhow::bail!("replay response had neither `records` nor `bytes_b64`") +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::rngs::OsRng; + + /// Seal exactly as `dd/src/sessiond.rs::seal_record` does, so this is a + /// cross-repo wire-compatibility test, not just a self-consistency one. + fn seal_record(recipients: &[[u8; 32]], plain: &[u8]) -> String { + use rand::RngCore; + let mut rng = OsRng; + let mut cek = [0u8; 32]; + rng.fill_bytes(&mut cek); + let mut bn = [0u8; 12]; + rng.fill_bytes(&mut bn); + let body = ChaCha20Poly1305::new(Key::from_slice(&cek)) + .encrypt(Nonce::from_slice(&bn), plain) + .unwrap(); + + let mut rcpts = Vec::new(); + for r in recipients { + let mut e_bytes = [0u8; 32]; + rng.fill_bytes(&mut e_bytes); + let e_sk = StaticSecret::from(e_bytes); + let e_pk = PublicKey::from(&e_sk); + let shared = e_sk.diffie_hellman(&PublicKey::from(*r)); + let wrap_key = derive_wrap_key(shared.as_bytes(), e_pk.as_bytes(), r); + let mut n = [0u8; 12]; + rng.fill_bytes(&mut n); + let wk = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)) + .encrypt(Nonce::from_slice(&n), cek.as_ref()) + .unwrap(); + rcpts.push(serde_json::json!({ + "epk": hex::encode(e_pk.as_bytes()), + "n": hex::encode(n), + "wk": B64.encode(wk), + })); + } + serde_json::json!({ + "v": 2, "rcpts": rcpts, "bn": hex::encode(bn), "body": B64.encode(body), + }) + .to_string() + } + + fn record_line(recipients: &[[u8; 32]], kind: &str, data: &[u8]) -> String { + let rec = serde_json::json!({ "ts": 1, "kind": kind, "data_b64": B64.encode(data) }); + seal_record(recipients, serde_json::to_string(&rec).unwrap().as_bytes()) + } + + fn device() -> (StaticSecret, [u8; 32]) { + let sk = StaticSecret::random_from_rng(OsRng); + let pk = *PublicKey::from(&sk).as_bytes(); + (sk, pk) + } + + #[test] + fn opens_record_for_recipient() { + let (sk, pk) = device(); + let line = record_line(&[pk], "pty", b"hello"); + let rec = open_record(&sk, &line).unwrap().expect("recipient"); + assert_eq!(rec.kind, "pty"); + assert_eq!(B64.decode(rec.data_b64).unwrap(), b"hello"); + } + + #[test] + fn non_recipient_gets_none() { + let (_sk_a, pk_a) = device(); + let (sk_b, _pk_b) = device(); // B is not a recipient + let line = record_line(&[pk_a], "pty", b"secret"); + assert!(open_record(&sk_b, &line).unwrap().is_none()); + } + + #[test] + fn multi_recipient_each_opens() { + let (sk_a, pk_a) = device(); + let (sk_b, pk_b) = device(); + let line = record_line(&[pk_a, pk_b], "pty", b"shared"); + assert!(open_record(&sk_a, &line).unwrap().is_some()); + assert!(open_record(&sk_b, &line).unwrap().is_some()); + } + + #[test] + fn decrypt_replay_reconstructs_pty_stream_and_skips_meta() { + let (sk, pk) = device(); + let records = vec![ + Value::String(record_line(&[pk], "meta", b"{...}")), + Value::String(record_line(&[pk], "pty", b"foo")), + Value::String(record_line(&[pk], "stdout", b"bar")), + ]; + let resp = serde_json::json!({ "id": "s", "version": 2, "records": records }); + assert_eq!(decrypt_replay(&sk, &resp).unwrap(), b"foobar"); + } + + #[test] + fn decrypt_replay_handles_legacy_plaintext() { + let (sk, _pk) = device(); + let resp = serde_json::json!({ "id": "s", "bytes_b64": B64.encode(b"legacy") }); + assert_eq!(decrypt_replay(&sk, &resp).unwrap(), b"legacy"); + } +} diff --git a/crates/dd-client-session/src/input.rs b/crates/dd-client-session/src/input.rs new file mode 100644 index 0000000..00e0b07 --- /dev/null +++ b/crates/dd-client-session/src/input.rs @@ -0,0 +1,196 @@ +//! Raw-mode escape chord parsing and menu keystroke synthesis. +//! +//! In Raw mode the app owns `Tab`, so we can't use it to leave. Instead `Ctrl-]` +//! is a prefix: `Ctrl-] Tab` pops back to the structured view, `Ctrl-] Ctrl-]` +//! or `Ctrl-] d` detaches (keeps the remote session alive). `Ctrl-D` still sends +//! EOF and disconnects. Everything else is forwarded verbatim. +//! +//! For menu selection there is no structured channel to a TUI — it awaits a +//! keypress — so a pick is replayed as keystrokes: the option's hotkey when +//! known, otherwise arrow-stepping from the last-observed selection plus Enter. + +use crate::block::MenuOption; + +pub const CTRL_RIGHT_BRACKET: u8 = 0x1d; +pub const CTRL_D: u8 = 0x04; +const TAB: u8 = 0x09; + +/// What the Raw-mode chord parser decided for a span of input. +#[derive(Debug, PartialEq, Eq)] +pub enum RawAction { + /// Forward these bytes to the PTY. + Forward(Vec), + /// Forward these bytes, then disconnect (EOF). + ForwardThenStop(Vec), + /// Leave Raw, return to the structured view (session stays attached). + ExitToStructured, + /// Detach: stop the client but keep the remote session alive. + Detach, +} + +/// Stateful chord parser: `Ctrl-]` may arrive at the end of one read and its +/// continuation at the start of the next, so the "armed" flag persists. +#[derive(Default)] +pub struct ChordParser { + armed: bool, +} + +impl ChordParser { + pub fn new() -> Self { + Self::default() + } + + pub fn feed(&mut self, bytes: &[u8]) -> Vec { + let mut actions = Vec::new(); + let mut pending: Vec = Vec::new(); + + for &b in bytes { + if self.armed { + self.armed = false; + match b { + TAB => { + flush(&mut pending, &mut actions); + actions.push(RawAction::ExitToStructured); + } + CTRL_RIGHT_BRACKET | b'd' => { + flush(&mut pending, &mut actions); + actions.push(RawAction::Detach); + } + // Not a chord: the swallowed Ctrl-] was literal input. + _ => { + pending.push(CTRL_RIGHT_BRACKET); + self.consume_plain(b, &mut pending, &mut actions); + } + } + } else { + self.consume_plain(b, &mut pending, &mut actions); + } + } + flush(&mut pending, &mut actions); + actions + } + + fn consume_plain(&mut self, b: u8, pending: &mut Vec, actions: &mut Vec) { + match b { + CTRL_RIGHT_BRACKET => self.armed = true, + CTRL_D => { + flush(pending, actions); + actions.push(RawAction::ForwardThenStop(vec![CTRL_D])); + } + _ => pending.push(b), + } + } +} + +fn flush(pending: &mut Vec, actions: &mut Vec) { + if !pending.is_empty() { + actions.push(RawAction::Forward(std::mem::take(pending))); + } +} + +/// Synthesize the keystrokes that select `target` in a menu, given the +/// last-observed highlighted index. Prefers the option's hotkey (absolute, +/// avoids stale-cursor drift); otherwise arrow-steps and confirms with Enter. +pub fn menu_select_keys(current: Option, target: usize, option: &MenuOption) -> Vec { + if let Some(c) = option.hotkey { + // Hotkey selection (numbered/lettered/yes-no) acts on the keypress. + return vec![c as u8]; + } + let cur = current.unwrap_or(0); + let (seq, count): (&[u8], usize) = if target >= cur { + (b"\x1b[B", target - cur) // Down + } else { + (b"\x1b[A", cur - target) // Up + }; + let mut keys = Vec::with_capacity(seq.len() * count + 1); + for _ in 0..count { + keys.extend_from_slice(seq); + } + keys.push(b'\r'); + keys +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opt(hotkey: Option) -> MenuOption { + MenuOption { + label: "x".into(), + hotkey, + raw_row: 0, + } + } + + #[test] + fn forwards_plain_bytes() { + let mut p = ChordParser::new(); + assert_eq!(p.feed(b"ls\n"), vec![RawAction::Forward(b"ls\n".to_vec())]); + } + + #[test] + fn ctrl_rbracket_then_tab_exits() { + let mut p = ChordParser::new(); + assert_eq!( + p.feed(&[CTRL_RIGHT_BRACKET, TAB]), + vec![RawAction::ExitToStructured] + ); + } + + #[test] + fn double_ctrl_rbracket_detaches() { + let mut p = ChordParser::new(); + assert_eq!( + p.feed(&[CTRL_RIGHT_BRACKET, CTRL_RIGHT_BRACKET]), + vec![RawAction::Detach] + ); + } + + #[test] + fn ctrl_d_forwards_then_stops() { + let mut p = ChordParser::new(); + assert_eq!( + p.feed(&[CTRL_D]), + vec![RawAction::ForwardThenStop(vec![CTRL_D])] + ); + } + + #[test] + fn chord_split_across_feeds() { + let mut p = ChordParser::new(); + assert_eq!(p.feed(&[CTRL_RIGHT_BRACKET]), vec![]); // armed, nothing yet + assert_eq!(p.feed(&[TAB]), vec![RawAction::ExitToStructured]); + } + + #[test] + fn ctrl_rbracket_then_other_is_literal() { + let mut p = ChordParser::new(); + // Ctrl-] then 'x' was not a chord: forward both. + assert_eq!( + p.feed(&[CTRL_RIGHT_BRACKET, b'x']), + vec![RawAction::Forward(vec![CTRL_RIGHT_BRACKET, b'x'])] + ); + } + + #[test] + fn hotkey_selection_is_absolute() { + assert_eq!(menu_select_keys(Some(0), 2, &opt(Some('3'))), vec![b'3']); + } + + #[test] + fn arrow_selection_steps_down_then_enter() { + // From row 0 to row 2: Down, Down, Enter. + assert_eq!( + menu_select_keys(Some(0), 2, &opt(None)), + b"\x1b[B\x1b[B\r".to_vec() + ); + } + + #[test] + fn arrow_selection_steps_up() { + assert_eq!( + menu_select_keys(Some(3), 1, &opt(None)), + b"\x1b[A\x1b[A\r".to_vec() + ); + } +} diff --git a/crates/dd-client-session/src/lib.rs b/crates/dd-client-session/src/lib.rs new file mode 100644 index 0000000..6d6cd0c --- /dev/null +++ b/crates/dd-client-session/src/lib.rs @@ -0,0 +1,23 @@ +//! The DevOps Defender client session engine. +//! +//! This crate sits between [`dd_client_core`] (Noise transport, quote +//! verification, RPCs, keys) and the platform frontends (CLI, iOS). It owns the +//! interpretation layer: it consumes the PTY byte stream of an attached session +//! and — in later phases — derives a structured "chat document" (markdown +//! blocks + menus), tracks view modes, and routes input. +//! +//! Phase 0 establishes only the seam: [`transport`] drives the Noise duplex +//! (forward input frames, surface decrypted output frames) with no terminal +//! coupling, so the same loop serves every frontend. + +pub mod block; +pub mod derive; +pub mod engine; +pub mod history; +pub mod input; +pub mod mode; +pub mod stream; +pub mod transport; + +pub use engine::SessionEngine; +pub use mode::ViewMode; diff --git a/crates/dd-client-session/src/mode.rs b/crates/dd-client-session/src/mode.rs new file mode 100644 index 0000000..cc940a8 --- /dev/null +++ b/crates/dd-client-session/src/mode.rs @@ -0,0 +1,97 @@ +//! View-mode state machine and its mapping onto the server integrity model. +//! +//! The three modes are projections of one live session. Watch is read-only +//! (transmits nothing → `Clean`); Interact and Raw can send input (`Controlled`). +//! Tab cycles forward, Shift-Tab back; the indicator and the taint badge are the +//! same thing. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ViewMode { + /// Read-only structured render. Transmits nothing. + Watch, + /// Structured render with live menus/input. + Interact, + /// Full PTY passthrough. + Raw, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Integrity { + Clean, + Controlled, +} + +impl ViewMode { + pub fn next(self) -> Self { + match self { + ViewMode::Watch => ViewMode::Interact, + ViewMode::Interact => ViewMode::Raw, + ViewMode::Raw => ViewMode::Watch, + } + } + + pub fn prev(self) -> Self { + match self { + ViewMode::Watch => ViewMode::Raw, + ViewMode::Interact => ViewMode::Watch, + ViewMode::Raw => ViewMode::Interact, + } + } + + /// Whether this mode is allowed to transmit input/signals. + pub fn is_readonly(self) -> bool { + matches!(self, ViewMode::Watch) + } + + /// Whether this mode renders structured blocks (vs. raw passthrough). + pub fn is_structured(self) -> bool { + !matches!(self, ViewMode::Raw) + } + + pub fn integrity(self) -> Integrity { + if self.is_readonly() { + Integrity::Clean + } else { + Integrity::Controlled + } + } + + pub fn label(self) -> &'static str { + match self { + ViewMode::Watch => "WATCH", + ViewMode::Interact => "INTERACT", + ViewMode::Raw => "RAW", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cycles_forward_and_back() { + assert_eq!(ViewMode::Watch.next(), ViewMode::Interact); + assert_eq!(ViewMode::Interact.next(), ViewMode::Raw); + assert_eq!(ViewMode::Raw.next(), ViewMode::Watch); + assert_eq!(ViewMode::Watch.prev(), ViewMode::Raw); + assert_eq!(ViewMode::Raw.prev(), ViewMode::Interact); + } + + #[test] + fn only_watch_is_clean_and_readonly() { + assert!(ViewMode::Watch.is_readonly()); + assert_eq!(ViewMode::Watch.integrity(), Integrity::Clean); + for m in [ViewMode::Interact, ViewMode::Raw] { + assert!(!m.is_readonly()); + assert_eq!(m.integrity(), Integrity::Controlled); + } + } + + #[test] + fn raw_is_the_only_unstructured_mode() { + assert!(ViewMode::Watch.is_structured()); + assert!(ViewMode::Interact.is_structured()); + assert!(!ViewMode::Raw.is_structured()); + } +} diff --git a/crates/dd-client-session/src/stream.rs b/crates/dd-client-session/src/stream.rs new file mode 100644 index 0000000..2b441d6 --- /dev/null +++ b/crates/dd-client-session/src/stream.rs @@ -0,0 +1,228 @@ +//! Authoritative block log + delta replay. +//! +//! [`BlockLog`] is the engine's source of truth. Each mutation returns the +//! [`BlockEvent`] it produced so the caller can broadcast it. Consumers keep a +//! [`BlockView`] seeded from a snapshot and replay events onto it — the same +//! logic the FFI/iOS layer will use, so it's defined and tested once here. + +use std::collections::HashMap; + +use crate::block::{Block, BlockEvent, BlockId, BlockKind, BlockPatch, Revision}; + +/// The engine-owned, authoritative ordered log of blocks. +#[derive(Default)] +pub struct BlockLog { + blocks: Vec<(BlockId, Revision, Block)>, + index: HashMap, + next_id: u64, +} + +impl BlockLog { + pub fn new() -> Self { + Self::default() + } + + /// Append a fresh, empty block of `kind`. Returns the new id and the event. + pub fn append(&mut self, kind: BlockKind) -> (BlockId, BlockEvent) { + let id = BlockId(self.next_id); + self.next_id += 1; + self.index.insert(id, self.blocks.len()); + self.blocks.push((id, Revision(0), Block::empty(kind))); + (id, BlockEvent::Append { id, kind }) + } + + /// Apply a patch to an existing block, bumping its revision. Returns the + /// event, or `None` if the id is unknown. + pub fn patch(&mut self, id: BlockId, patch: BlockPatch) -> Option { + let idx = *self.index.get(&id)?; + let (_, rev, block) = &mut self.blocks[idx]; + apply_patch(block, &patch); + rev.0 += 1; + let rev = *rev; + Some(BlockEvent::Update { id, rev, patch }) + } + + /// Mark a block complete. Returns the event, or `None` if unknown. + pub fn finalize(&mut self, id: BlockId) -> Option { + let idx = *self.index.get(&id)?; + let (_, rev, block) = &mut self.blocks[idx]; + set_complete(block, true); + rev.0 += 1; + Some(BlockEvent::Finalize { id, rev: *rev }) + } + + /// Drop `from` and everything after it. Returns the event, or `None` if unknown. + pub fn truncate(&mut self, from: BlockId) -> Option { + let idx = *self.index.get(&from)?; + for (id, _, _) in self.blocks.drain(idx..) { + self.index.remove(&id); + } + Some(BlockEvent::Truncate { from }) + } + + /// A point-in-time copy of the rendered blocks, in order. + pub fn snapshot(&self) -> Vec { + self.blocks.iter().map(|(_, _, b)| b.clone()).collect() + } + + /// The id of the last block, if any (used by the floor to extend it). + pub fn last_id(&self) -> Option { + self.blocks.last().map(|(id, _, _)| *id) + } + + pub fn is_empty(&self) -> bool { + self.blocks.is_empty() + } +} + +/// A consumer's local mirror, kept current by replaying [`BlockEvent`]s. +#[derive(Default)] +pub struct BlockView { + blocks: Vec, + index: HashMap, + order: Vec, +} + +impl BlockView { + /// Seed from a snapshot. Subsequent ids must be replayed via [`Self::apply`]; + /// the snapshot path can't know ids, so a fresh view is normally built purely + /// from the event stream when ids matter. For render-only use, [`Self::blocks`] + /// is what you draw. + pub fn from_snapshot(blocks: Vec) -> Self { + Self { + blocks, + index: HashMap::new(), + order: Vec::new(), + } + } + + pub fn blocks(&self) -> &[Block] { + &self.blocks + } + + /// Replay one delta. Unknown ids on update/finalize are ignored (the view may + /// have been seeded from a snapshot that predates them). + pub fn apply(&mut self, event: &BlockEvent) { + match event { + BlockEvent::Append { id, kind } => { + self.index.insert(*id, self.blocks.len()); + self.order.push(*id); + self.blocks.push(Block::empty(*kind)); + } + BlockEvent::Update { id, patch, .. } => { + if let Some(&idx) = self.index.get(id) { + apply_patch(&mut self.blocks[idx], patch); + } + } + BlockEvent::Finalize { id, .. } => { + if let Some(&idx) = self.index.get(id) { + set_complete(&mut self.blocks[idx], true); + } + } + BlockEvent::Truncate { from } => { + if let Some(&idx) = self.index.get(from) { + for id in self.order.drain(idx..) { + self.index.remove(&id); + } + self.blocks.truncate(idx); + } + } + } + } +} + +fn apply_patch(block: &mut Block, patch: &BlockPatch) { + match (block, patch) { + (Block::Markdown { text, .. }, BlockPatch::AppendText(s)) => text.push_str(s), + (Block::Code { text, .. }, BlockPatch::AppendText(s)) => text.push_str(s), + (Block::Diff { unified, .. }, BlockPatch::AppendText(s)) => unified.push_str(s), + (Block::RawTerminal { screen }, BlockPatch::AppendText(s)) => screen.push_str(s), + (Block::Markdown { text, .. }, BlockPatch::ReplaceText(s)) => *text = s.clone(), + (Block::Code { text, .. }, BlockPatch::ReplaceText(s)) => *text = s.clone(), + (Block::Diff { unified, .. }, BlockPatch::ReplaceText(s)) => *unified = s.clone(), + (Block::RawTerminal { screen }, BlockPatch::ReplaceText(s)) => *screen = s.clone(), + (Block::Menu(menu), BlockPatch::MenuSelect(i)) => menu.selected = Some(*i), + (Block::Menu(menu), BlockPatch::MenuResolve(i)) => { + menu.state = crate::block::MenuState::Resolved { chosen: *i } + } + (Block::Input(input), BlockPatch::InputSubmit) => { + input.state = crate::block::InputState::Submitted + } + // Mismatched patch/block kinds are ignored — the producer never emits them. + _ => {} + } +} + +fn set_complete(block: &mut Block, value: bool) { + match block { + Block::Markdown { complete, .. } + | Block::Code { complete, .. } + | Block::Diff { complete, .. } => *complete = value, + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn append_patch_finalize_roundtrips_through_view() { + let mut log = BlockLog::new(); + let (id, e_append) = log.append(BlockKind::Markdown); + let e_patch = log + .patch(id, BlockPatch::AppendText("hello ".into())) + .unwrap(); + let e_patch2 = log + .patch(id, BlockPatch::AppendText("world".into())) + .unwrap(); + let e_final = log.finalize(id).unwrap(); + + let mut view = BlockView::default(); + for ev in [&e_append, &e_patch, &e_patch2, &e_final] { + view.apply(ev); + } + assert_eq!( + view.blocks(), + &[Block::Markdown { + text: "hello world".into(), + complete: true + }] + ); + // The authoritative snapshot agrees. + assert_eq!(log.snapshot(), view.blocks()); + } + + #[test] + fn truncate_drops_tail_in_both() { + let mut log = BlockLog::new(); + let (a, ea) = log.append(BlockKind::Markdown); + let (_b, eb) = log.append(BlockKind::Markdown); + let (_c, ec) = log.append(BlockKind::Markdown); + let et = log.truncate(a).unwrap(); + + let mut view = BlockView::default(); + for ev in [&ea, &eb, &ec, &et] { + view.apply(ev); + } + assert!(view.blocks().is_empty()); + assert!(log.is_empty()); + } + + #[test] + fn revision_bumps_on_each_patch() { + let mut log = BlockLog::new(); + let (id, _) = log.append(BlockKind::Markdown); + let BlockEvent::Update { rev: r1, .. } = + log.patch(id, BlockPatch::AppendText("a".into())).unwrap() + else { + panic!("expected update"); + }; + let BlockEvent::Update { rev: r2, .. } = + log.patch(id, BlockPatch::AppendText("b".into())).unwrap() + else { + panic!("expected update"); + }; + assert!(r2 > r1); + } +} diff --git a/crates/dd-client-session/src/transport.rs b/crates/dd-client-session/src/transport.rs new file mode 100644 index 0000000..e51691f --- /dev/null +++ b/crates/dd-client-session/src/transport.rs @@ -0,0 +1,61 @@ +//! Drives the Noise duplex for an attached session. +//! +//! Forwards plaintext input frames toward the peer and hands each decrypted +//! output frame to a sink callback, until the input side closes, a [`Outbound::Stop`] +//! is received, or the socket ends. This is deliberately platform-agnostic — no +//! termios, no stdin/stdout. The frontend owns terminal I/O and feeds this loop +//! over an [`mpsc`] channel; the engine (later phases) will sit on top, deriving +//! blocks from the output frames. + +use dd_client_core::NoiseConnection; +use tokio::sync::mpsc; + +/// A message the frontend sends toward the attached session. +#[derive(Debug)] +pub enum Outbound { + /// Plaintext bytes to encrypt and forward to the peer (e.g. keystrokes). + Bytes(Vec), + /// Forward these bytes, then stop the loop (e.g. EOF + disconnect). + BytesThenStop(Vec), + /// Stop the loop without sending anything (e.g. detach). + Stop, +} + +/// Run the duplex pump. `on_output` is invoked with each decrypted inbound +/// frame, in order. Returns when input closes, a [`Outbound::Stop`] arrives, or +/// the socket closes. +/// +/// `on_output` is synchronous by design: a raw-terminal frontend writes the +/// bytes straight to its tty inside the callback. Higher layers pass a callback +/// that feeds the derivation engine. +pub async fn run( + conn: NoiseConnection, + mut input: mpsc::Receiver, + mut on_output: F, +) -> anyhow::Result<()> +where + F: FnMut(&[u8]) + Send, +{ + let (mut writer, mut reader) = conn.split(); + loop { + tokio::select! { + msg = input.recv() => { + match msg { + None | Some(Outbound::Stop) => break, + Some(Outbound::Bytes(b)) => writer.send(&b).await?, + Some(Outbound::BytesThenStop(b)) => { + writer.send(&b).await?; + break; + } + } + } + frame = reader.recv() => { + match frame? { + None => break, + Some(plain) => on_output(&plain), + } + } + } + } + Ok(()) +}