diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b108c52 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1494 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alkcall" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49664a13ea571d7a6e59c9ed52d165dfdfe389c9a93bb301cc1fb86f29bae8bd" +dependencies = [ + "async-trait", + "bytes", + "futures", + "getrandom 0.4.3", + "jsonschema", + "parking_lot", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tracing", + "uuid", + "zeroize", +] + +[[package]] +name = "alktty" +version = "0.1.0" +dependencies = [ + "alkcall", + "async-trait", + "bytes", + "futures", + "futures-core", + "libc", + "portable-pty", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a699d3e77675e6aa4bfffe3b907c8b5f7ed3241f9965bffb25475ad4b08d05" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbd1086b01b9349fd4ef9a07433965af64c8ce8159abe633a189e4ff817bd13" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[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 = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "referencing" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbf332a2f81899f6836f22c03da73dae8a664c32e3016b84692c23cddadc95d" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "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.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "uuid-rng-internal", + "wasm-bindgen", +] + +[[package]] +name = "uuid-rng-internal" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb6e4d912010d7646ef4e5a0ba89bafc6d9f8fd617c53433fe2aa86d26a3b77" +dependencies = [ + "getrandom 0.4.3", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/src/adapter.rs b/src/adapter.rs new file mode 100644 index 0000000..fa9c5d1 --- /dev/null +++ b/src/adapter.rs @@ -0,0 +1,1523 @@ +//! `TtyAdapter` (`ProtocolHandler` on `alk/tty`) and the `drive_session` +//! three-pump bidirectional driver. +//! +//! This is the integration point where the wire format (ADR-052), the +//! backend trait (ADR-053), and the exit-chunk ordering (ADR-055) come +//! together. The adapter is backend-agnostic; backends are +//! wire-format-agnostic. The inversion is the `TtyBackend` trait. +//! +//! # Session lifecycle +//! +//! A `alk/tty` session on one bidi stream proceeds in three phases: +//! +//! 1. **Negotiation** — read a length-prefixed JSON frame, parse +//! `NegotiateRequest`, validate (`carriage == "raw"`, `cmd` non-empty), +//! look up the `TtyBackend`, run access control, construct `TtyParams`. +//! Errors → JSON error response in negotiation framing, stream close. +//! 2. **Allocation** — `backend.allocate(¶ms)`. Errors → +//! `allocate_failed` JSON error response, stream close. +//! 3. **Raw carriage** — three concurrent pumps: +//! - **A. stdout → client**: `TtyHandle.stdout` → stdout chunks +//! (stream_type 1); a concurrent stderr pump emits stderr chunks +//! (stream_type 2) when `TtyHandle.stderr` is `Some`. On backend stdout +//! EOF, emit a zero-length stdout sentinel. +//! - **B. client → backend**: stdin chunks (stream_type 0) → +//! `TtyHandle.stdin`; client→server control chunks (stream_type 3, +//! `STREAM_CTRL_IN`) → `ControlMessage` dispatch (`Resize`, `Signal`, +//! `Eof`). `STREAM_CTRL_OUT` (stream_type 4) from the client is a +//! protocol violation (it's the server→client half) and is ignored; +//! `Exit` arriving on `STREAM_CTRL_IN` is likewise a protocol +//! violation and ignored. Zero-length stdin chunk or read-half close +//! → EOF to backend stdin. +//! - **C. exit → exit chunk**: await `TtyHandle.exit_code`; on resolve, +//! enqueue `{"type":"exit","code":N}` as a server→client control +//! chunk (stream_type 4, `STREAM_CTRL_OUT`). On `TtyError` → +//! `{"type":"exit","code":-1}`. +//! +//! The adapter enforces the **exit-chunk-is-last** invariant (ADR-055): +//! it waits for BOTH the stdout/stderr pumps to complete AND `exit_code` +//! to resolve before enqueueing the exit chunk. A drainer task writes +//! chunks to the client in arrival order; the exit chunk is last. +//! +//! # Bidirectional control channel (Phase 7) +//! +//! The control channel is split into two halves so it is genuinely +//! bidirectional on the wire: `STREAM_CTRL_IN = 3` carries client→server +//! control (`Resize`, `Signal`, `Eof`); `STREAM_CTRL_OUT = 4` carries +//! server→client control (`Exit`). The previous single `STREAM_CONTROL = +//! 3` was documented as "bidirectional" but the adapter ignored `Exit` +//! from the client because the two directions were indistinguishable on +//! the same stream_type. The split makes the bidirectionality explicit +//! — see `docs/research/alknet-crate-extraction/findings.md` Phase 7. +//! +//! # Cancel cleanup (ADR-056) +//! +//! On connection drop or stream reset, the pump tasks are dropped, which +//! drops the `TtyHandle`, which drops the `exit_code` future without +//! driving it to completion — the backend's kill-on-`Drop` guard fires +//! and kills the session target. The adapter has no separate kill +//! method; the cleanup is wired into the `exit_code` future's `Drop` by +//! the backend. A client closing the write half (stdin EOF) does NOT +//! trigger cancel-cleanup — the session runs to completion. + +use std::collections::HashMap; +use std::sync::Arc; + +use alkcall::core::auth::{AuthContext, Identity}; +use alkcall::core::ownership::OwnershipProvider; +use alkcall::core::{Connection, HandlerError, ProtocolHandler, StreamError}; +use async_trait::async_trait; +use bytes::Bytes; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; +use tokio::sync::mpsc; +use tokio_stream::StreamExt; +use tracing::{debug, warn}; + +use crate::backend::{TtyBackend, TtyHandle}; +use crate::control::ControlMessage; +use crate::negotiation::{ + error_response_bytes, NegotiateRequest, NegotiationError, NegotiationReader, + NegotiationWriter, +}; +use crate::wire::{Chunk, ChunkReader, ChunkWriter, RawError, STREAM_CTRL_IN, STREAM_STDIN}; + +/// The scope required to open a `alk/tty` session (ADR-050). A two-way-door +/// choice (reversible: a deployment-configured scope, not a wire-format +/// constant). Callers without this scope get a `forbidden` negotiation error. +pub const TTY_OPEN_SCOPE: &str = "tty:open"; + +/// The `ProtocolHandler` for `alk/tty` (ADR-006, ADR-007). Holds a +/// `HashMap>` keyed by the negotiation frame's +/// `backend` string and an optional [`OwnershipProvider`] for the ADR-050 +/// resource-ownership check. `handle()` accepts the connection and loops +/// `accept_bi`, dispatching each bidi stream to a [`drive_session`] task. +/// +/// One `alk/tty` connection hosts multiple terminal sessions — one session +/// per bidi stream (DP-6). Sessions are independent: one session's exit +/// doesn't affect another. +pub struct TtyAdapter { + backends: Arc>>, + ownership: Option>, +} + +impl TtyAdapter { + /// Construct with the given backend map and no ownership provider + /// (scope-gate only — no resource-level ACL). + pub fn new(backends: HashMap>) -> Self { + Self { + backends: Arc::new(backends), + ownership: None, + } + } + + /// Construct with the given backend map and an ownership provider for + /// the ADR-050 resource-ownership check. + pub fn with_ownership( + backends: HashMap>, + ownership: Arc, + ) -> Self { + Self { + backends: Arc::new(backends), + ownership: Some(ownership), + } + } +} + +#[async_trait] +impl ProtocolHandler for TtyAdapter { + fn alpn(&self) -> &'static [u8] { + b"alk/tty" + } + + async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError> { + if let Some(identity) = auth.identity.clone() { + let _ = connection.set_identity(identity); + } + loop { + let stream = match connection.accept_bi().await { + Ok(stream) => stream, + Err(StreamError::ConnectionClosed) => break, + Err(StreamError::StreamClosed) => break, + Err(e) => return Err(HandlerError::from(e)), + }; + let backends = self.backends.clone(); + let ownership = self.ownership.clone(); + let identity = auth.identity.clone(); + tokio::spawn(async move { + // `stream` is a `BiStream` (ADR-092) — `AsyncRead + AsyncWrite + // + Send + Unpin`. Split into halves for `drive_session` + // (which takes separate `AsyncWrite` + `AsyncRead` args). The + // split is the stdlib idiom for `TcpStream`-style duplex + // streams; no per-handler wrapper. + let (client_read, client_write) = tokio::io::split(stream); + let _ = + drive_session(client_write, client_read, backends, ownership, identity).await; + }); + } + Ok(()) + } +} + +/// Check whether `identity` has `scope` in its scopes list. +fn has_scope(identity: &Option, scope: &str) -> bool { + identity + .as_ref() + .map(|id| id.scopes.iter().any(|s| s == scope)) + .unwrap_or(false) +} + +/// Send a negotiation error frame and close the write half. Consumes the +/// writer so the underlying transport's shutdown runs after the frame is +/// flushed. +async fn send_negotiation_error( + mut writer: NegotiationWriter, + error: &str, + fields: &[(&str, &str)], +) { + match error_response_bytes(error, fields) { + Ok(body) => { + if let Err(e) = writer.write_frame(&body).await { + debug!("tty: failed to write error frame: {e}"); + } + } + Err(e) => warn!("tty: failed to serialize error response: {e}"), + } + let _ = writer.into_inner().shutdown().await; +} + +/// Drive a `alk/tty` session end-to-end over a bidi stream. +/// +/// `client_send` / `client_recv` are the two halves of the bidi stream +/// (split from the `BiStream` yielded by `accept_bi` via `tokio::io::split`). +/// Returns when the session is complete (exit chunk sent, stream closed) or +/// when the stream is reset (cancel-cleanup path — no exit chunk sent). +/// +/// This is the per-stream session driver — generalized from the POC's +/// `session::drive_session` to the [`TtyBackend`] trait. +pub async fn drive_session( + client_send: impl AsyncWrite + Send + Unpin + 'static, + client_recv: impl AsyncRead + Send + Unpin + 'static, + backends: Arc>>, + ownership: Option>, + identity: Option, +) { + if let Err(e) = + drive_session_inner(client_send, client_recv, &backends, &ownership, &identity).await + { + debug!("tty: session ended with error: {e}"); + } +} + +async fn drive_session_inner( + client_send: W, + client_recv: R, + backends: &HashMap>, + ownership: &Option>, + identity: &Option, +) -> Result<(), std::io::Error> +where + W: AsyncWrite + Send + Unpin + 'static, + R: AsyncRead + Send + Unpin + 'static, +{ + let mut neg_reader = NegotiationReader::new(client_recv); + let neg_writer = NegotiationWriter::new(client_send); + + let frame = match neg_reader.read_frame().await { + Ok(f) => f, + Err(NegotiationError::ConnectionClosed) => return Ok(()), + Err(NegotiationError::Io(_)) => return Ok(()), + Err(NegotiationError::FrameTooLarge(_)) => { + send_negotiation_error( + neg_writer, + "malformed_negotiation", + &[("message", "frame too large")], + ) + .await; + return Ok(()); + } + Err(e) => { + debug!("tty: negotiation read error: {e}"); + return Ok(()); + } + }; + + let req: NegotiateRequest = match serde_json::from_slice(&frame) { + Ok(r) => r, + Err(e) => { + send_negotiation_error( + neg_writer, + "malformed_negotiation", + &[("message", &e.to_string())], + ) + .await; + return Ok(()); + } + }; + + if req.carriage != "raw" { + send_negotiation_error( + neg_writer, + "malformed_negotiation", + &[("message", "carriage must be 'raw'")], + ) + .await; + return Ok(()); + } + if req.cmd.is_empty() { + send_negotiation_error( + neg_writer, + "malformed_negotiation", + &[("message", "cmd must be non-empty")], + ) + .await; + return Ok(()); + } + + let backend = match backends.get(&req.backend) { + Some(b) => b.clone(), + None => { + send_negotiation_error(neg_writer, "unknown_backend", &[("backend", &req.backend)]) + .await; + return Ok(()); + } + }; + + if !has_scope(identity, TTY_OPEN_SCOPE) { + send_negotiation_error(neg_writer, "forbidden", &[]).await; + return Ok(()); + } + + let params = crate::backend::TtyParams::from(req); + + if let Some(provider) = ownership { + if let Some((kind, id)) = backend.resource_id(¶ms) { + let owns = identity + .as_ref() + .map(|id_ref| provider.owns(id_ref, kind, &id, "tty")) + .unwrap_or(false); + if !owns { + send_negotiation_error(neg_writer, "forbidden", &[]).await; + return Ok(()); + } + } + } + + let handle = match backend.allocate(¶ms).await { + Ok(h) => h, + Err(e) => { + send_negotiation_error( + neg_writer, + "allocate_failed", + &[("message", &e.to_string())], + ) + .await; + return Ok(()); + } + }; + + let client_read = neg_reader.into_inner(); + let client_write = neg_writer.into_inner(); + pump_session(client_write, client_read, handle).await +} + +/// Phase 3: the bidirectional pump. Three concurrent tasks plus a drainer. +/// +/// Enforces the exit-chunk-is-last invariant (ADR-055): the adapter waits for +/// BOTH the stdout/stderr pumps to complete AND `exit_code` to resolve +/// before enqueueing the exit chunk. +async fn pump_session( + client_write: W, + client_read: R, + handle: TtyHandle, +) -> Result<(), std::io::Error> +where + W: AsyncWrite + Send + Unpin + 'static, + R: AsyncRead + Send + Unpin + 'static, +{ + let (writer_tx, mut writer_rx) = mpsc::channel::(64); + + let TtyHandle { + stdin, + stdout, + stderr, + exit_code, + control, + } = handle; + + let writer_tx_out = writer_tx.clone(); + let stdout_pump = tokio::spawn(pump_stdout(stdout, writer_tx_out)); + + let stderr_pump = if let Some(stderr) = stderr { + let writer_tx_err = writer_tx.clone(); + Some(tokio::spawn(pump_stderr(stderr, writer_tx_err))) + } else { + None + }; + + let control_clone = control.clone(); + let input_pump = tokio::spawn(pump_client_to_backend(client_read, stdin, control_clone)); + + let exit_future = async { + let code = exit_code.await.unwrap_or(-1); + code + }; + + match stderr_pump { + Some(stderr_pump) => { + let (_stdout_join, _stderr_join, exit_code_value) = + tokio::join!(stdout_pump, stderr_pump, exit_future); + send_exit_chunk(&writer_tx, exit_code_value).await; + } + None => { + let (_stdout_join, exit_code_value) = tokio::join!(stdout_pump, exit_future); + send_exit_chunk(&writer_tx, exit_code_value).await; + } + } + + drop(writer_tx); + drop(input_pump); + + let mut chunk_writer = ChunkWriter::new(client_write); + while let Some(chunk) = writer_rx.recv().await { + if let Err(e) = chunk_writer.write_chunk(&chunk).await { + debug!("tty: write_chunk to client failed: {e}"); + break; + } + } + let _ = chunk_writer.into_inner().shutdown().await; + debug!("tty: session complete"); + Ok(()) +} + +async fn send_exit_chunk(writer_tx: &mpsc::Sender, code: i32) { + let exit_msg = ControlMessage::Exit { code }; + match exit_msg.to_json() { + Ok(json) => { + let chunk = Chunk::ctrl_out(json); + if writer_tx.send(chunk).await.is_err() { + debug!("tty: writer channel closed before exit chunk"); + } + } + Err(e) => warn!("tty: failed to serialize exit control chunk: {e}"), + } +} + +/// Pump backend stdout → stdout chunks (stream_type 1). On backend stdout +/// EOF, emit a zero-length stdout sentinel. +async fn pump_stdout( + mut stdout: std::pin::Pin + Send>>, + writer_tx: mpsc::Sender, +) { + while let Some(bytes) = stdout.next().await { + if bytes.is_empty() { + continue; + } + let chunk = Chunk::stdout(bytes); + if writer_tx.send(chunk).await.is_err() { + break; + } + } + let _ = writer_tx.send(Chunk::stdout(Bytes::new())).await; + debug!("tty: stdout pump done"); +} + +/// Pump backend stderr → stderr chunks (stream_type 2). +async fn pump_stderr( + mut stderr: std::pin::Pin + Send>>, + writer_tx: mpsc::Sender, +) { + while let Some(bytes) = stderr.next().await { + if bytes.is_empty() { + continue; + } + let chunk = Chunk::stderr(bytes); + if writer_tx.send(chunk).await.is_err() { + break; + } + } + debug!("tty: stderr pump done"); +} + +/// Pump client chunks → backend: stdin chunks → `TtyHandle.stdin`, +/// client→server control chunks (`STREAM_CTRL_IN`, stream_type 3) → +/// `ControlMessage` dispatch. On client read-half close or a zero-length +/// stdin chunk, signal EOF to the backend's stdin. +/// +/// # Direction enforcement (Phase 7) +/// +/// The control channel is split into two halves. This pump reads from +/// the client, so it dispatches only `STREAM_CTRL_IN` (client→server): +/// +/// - `Resize` / `Signal` / `Eof` → forward to the backend's control +/// handle (`TtyControlHandle::resize` / `signal` / `stdin.shutdown`). +/// - `Exit` arriving on `STREAM_CTRL_IN` is a protocol violation +/// (`Exit` is server→client only, belongs on `STREAM_CTRL_OUT`); the +/// adapter ignores it. (The previous single `STREAM_CONTROL = 3` +/// couldn't distinguish the two directions, so `Exit` from the client +/// was always ignored — the split makes the rejection explicit.) +/// - `STREAM_CTRL_OUT` (stream_type 4) from the client is a protocol +/// violation (it's the server→client half); the adapter ignores it. +async fn pump_client_to_backend( + client_read: R, + mut stdin: Box, + control: Option, +) where + R: AsyncRead + Send + Unpin + 'static, +{ + let mut chunk_reader = ChunkReader::new(client_read); + loop { + match chunk_reader.read_chunk().await { + Ok(chunk) => match chunk.stream_type { + STREAM_STDIN => { + if chunk.bytes.is_empty() { + let _ = stdin.shutdown().await; + debug!("tty: client stdin EOF (zero-length chunk)"); + } else if let Err(e) = stdin.write_all(&chunk.bytes).await { + warn!("tty: backend stdin write failed: {e}"); + break; + } + } + STREAM_CTRL_IN => match ControlMessage::from_slice(&chunk.bytes) { + Ok(ControlMessage::Resize { + cols, + rows, + pixel_width, + pixel_height, + }) => { + if let Some(c) = &control { + c.resize(cols, rows, pixel_width, pixel_height); + } + } + Ok(ControlMessage::Signal { name }) => { + if let Some(c) = &control { + c.signal(&name); + } + } + Ok(ControlMessage::Eof) => { + let _ = stdin.shutdown().await; + debug!("tty: client stdin EOF (eof control)"); + } + Ok(ControlMessage::Exit { .. }) => { + debug!( + "tty: ignoring Exit control on STREAM_CTRL_IN \ + (server→client only; belongs on STREAM_CTRL_OUT)" + ); + } + Err(e) => { + debug!("tty: ignoring unknown control type: {e}"); + } + }, + crate::wire::STREAM_CTRL_OUT => { + debug!( + "tty: ignoring STREAM_CTRL_OUT (stream_type 4) from client \ + (server→client half; client should not write on it)" + ); + } + other => { + debug!("tty: ignoring stream_type {other} from client"); + } + }, + Err(RawError::ConnectionClosed) => { + debug!("tty: client closed read half"); + let _ = stdin.shutdown().await; + break; + } + Err(e) => { + debug!("tty: read_chunk error: {e}"); + let _ = stdin.shutdown().await; + break; + } + } + } + debug!("tty: client→server pump done"); +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::collections::HashMap as StdHashMap; + use std::future::Future; + use std::pin::Pin; + use std::sync::Mutex as StdMutex; + use std::task::{Context, Poll}; + + use alkcall::core::auth::Identity; + use alkcall::core::ownership::InMemoryOwnershipStore; + use alkcall::core::OwnershipStore; + use tokio::io::duplex; + use tokio::sync::{mpsc, oneshot, Mutex}; + use tokio_stream::wrappers::ReceiverStream; + + use crate::backend::{BoxFuture, MockControl, TtyControlHandle, TtyError, TtyParams}; + use crate::wire::STREAM_STDOUT; + + const TEST_NEG: &str = r#"{"carriage":"raw","backend":"mock","cmd":["bash"]}"#; + + fn identity_with_scope(scope: &str) -> Option { + Some(Identity { + id: "test-user".to_string(), + scopes: vec![scope.to_string()], + resources: StdHashMap::new(), + }) + } + + fn identity_no_scope() -> Option { + Some(Identity { + id: "test-user".to_string(), + scopes: vec![], + resources: StdHashMap::new(), + }) + } + + fn make_backends(backend: Arc) -> Arc>> { + let mut map: StdHashMap> = StdHashMap::new(); + map.insert("mock".to_string(), backend); + Arc::new(map) + } + + /// A test backend that wires the adapter to channels the test drives. + /// + /// `allocate()` swaps in fresh channels and stashes the test-facing + /// senders/receivers behind shared mutexes so the test can drive + /// stdout (send), read stdin (recv), and resolve exit (send). A + /// `ready` oneshot lets the test wait for allocation before taking the + /// channels (the adapter spawns `drive_session` async; allocation + /// happens after the negotiation frame is read). + struct TestBackend { + stdout_tx: StdMutex>>, + stderr_tx: StdMutex>>, + stdin_rx: StdMutex>>, + exit_tx: StdMutex>>>, + ready_tx: StdMutex>>, + ready_rx: Mutex>>, + control: Arc, + resource: Option<(&'static str, String)>, + allocate_fail: bool, + cancel_dropped: Arc>, + } + + impl TestBackend { + fn builder() -> TestBackendBuilder { + TestBackendBuilder { + resource: None, + allocate_fail: false, + } + } + + /// Wait until `allocate()` has run and the channels are available. + async fn wait_allocated(&self) { + if let Some(rx) = self.ready_rx.lock().await.take() { + let _ = rx.await; + } + } + + async fn take_stdout_tx(&self) -> Option> { + self.wait_allocated().await; + self.stdout_tx.lock().unwrap().take() + } + + async fn take_stderr_tx(&self) -> Option> { + self.wait_allocated().await; + self.stderr_tx.lock().unwrap().take() + } + + async fn take_stdin_rx(&self) -> Option> { + self.wait_allocated().await; + self.stdin_rx.lock().unwrap().take() + } + + async fn take_exit_tx(&self) -> Option>> { + self.wait_allocated().await; + self.exit_tx.lock().unwrap().take() + } + } + + struct TestBackendBuilder { + resource: Option<(&'static str, String)>, + allocate_fail: bool, + } + + impl TestBackendBuilder { + fn with_resource(mut self, kind: &'static str, id: &str) -> Self { + self.resource = Some((kind, id.to_string())); + self + } + + fn with_allocate_fail(mut self) -> Self { + self.allocate_fail = true; + self + } + + fn build(self) -> (Arc, Arc, Arc>) { + let cancel_dropped = Arc::new(Mutex::new(false)); + let (ready_tx, ready_rx) = oneshot::channel(); + let backend = Arc::new(TestBackend { + stdout_tx: StdMutex::new(None), + stderr_tx: StdMutex::new(None), + stdin_rx: StdMutex::new(None), + exit_tx: StdMutex::new(None), + ready_tx: StdMutex::new(Some(ready_tx)), + ready_rx: Mutex::new(Some(ready_rx)), + control: Arc::new(MockControl::default()), + resource: self.resource, + allocate_fail: self.allocate_fail, + cancel_dropped: cancel_dropped.clone(), + }); + (backend.clone(), backend.control.clone(), cancel_dropped) + } + } + + /// `AsyncWrite` adapter over `mpsc::Sender` — the test backend's + /// stdin sink. On shutdown, drops the sender so the test's + /// `stdin_rx` observes EOF (channel close). + struct TestStdinSink { + tx: Option>, + } + + impl tokio::io::AsyncWrite for TestStdinSink { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + match self.get_mut().tx.as_ref() { + Some(tx) => match tx.try_reserve() { + Ok(permit) => { + permit.send(Bytes::copy_from_slice(buf)); + Poll::Ready(Ok(buf.len())) + } + Err(mpsc::error::TrySendError::Full(_)) => Poll::Pending, + Err(mpsc::error::TrySendError::Closed(_)) => Poll::Ready(Err( + std::io::Error::new(std::io::ErrorKind::BrokenPipe, "stdin channel closed"), + )), + }, + None => Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "stdin shut down", + ))), + } + } + + fn poll_flush( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + self.tx.take(); + Poll::Ready(Ok(())) + } + } + + /// A kill-guard future wrapping `oneshot::Receiver>`. + /// On `Drop`-without-resolve, sets `cancel_dropped` to true (the + /// cancel-cleanup signal for tests — ADR-056). + struct ExitFuture { + rx: Option>>, + cancel_dropped: Arc>, + } + + impl Future for ExitFuture { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + if let Some(rx) = self.rx.as_mut() { + if let Poll::Ready(v) = Pin::new(rx).poll(cx) { + self.rx.take(); + let resolved: Result, _> = v; + let mapped: Result = resolved + .map_err(|_| TtyError::WaitFailed { + message: "exit_code sender dropped".to_string(), + }) + .and_then(|inner| inner); + return Poll::Ready(mapped); + } + } + Poll::Pending + } + } + + impl Drop for ExitFuture { + fn drop(&mut self) { + if self.rx.is_some() { + let cancel_dropped = self.cancel_dropped.clone(); + tokio::spawn(async move { + *cancel_dropped.lock().await = true; + }); + } + } + } + + #[async_trait] + impl TtyBackend for TestBackend { + async fn allocate(&self, _params: &TtyParams) -> Result { + if self.allocate_fail { + return Err(TtyError::AllocFailed { + message: "test allocate fail".to_string(), + }); + } + + let (stdout_tx, stdout_rx) = mpsc::channel::(8); + let (stderr_tx, stderr_rx) = mpsc::channel::(8); + let (stdin_tx, stdin_rx) = mpsc::channel::(8); + let (_exit_tx, exit_rx) = oneshot::channel::>(); + + *self.stdout_tx.lock().unwrap() = Some(stdout_tx); + *self.stderr_tx.lock().unwrap() = Some(stderr_tx); + *self.stdin_rx.lock().unwrap() = Some(stdin_rx); + + let stdout: Pin + Send>> = + Box::pin(ReceiverStream::new(stdout_rx)); + let stderr: Option + Send>>> = + Some(Box::pin(ReceiverStream::new(stderr_rx))); + let stdin: Box = + Box::new(TestStdinSink { tx: Some(stdin_tx) }); + let control = Some(TtyControlHandle::new(self.control.clone())); + + let cancel_dropped = self.cancel_dropped.clone(); + let exit_future = ExitFuture { + rx: Some(exit_rx), + cancel_dropped, + }; + let exit_code: BoxFuture> = Box::pin(exit_future); + + *self.exit_tx.lock().unwrap() = Some(_exit_tx); + + if let Some(tx) = self.ready_tx.lock().unwrap().take() { + let _ = tx.send(()); + } + + Ok(TtyHandle { + stdin, + stdout, + stderr, + exit_code, + control, + }) + } + + fn resource_id(&self, _params: &TtyParams) -> Option<(&'static str, String)> { + self.resource.clone() + } + } + + use crate::wire::ChunkReader as WireChunkReader; + + /// Test harness: drives a session over a duplex pair and provides + /// helpers for the client side to write negotiation/chunks and read + /// chunks/error frames. + struct ClientSide { + write: tokio::io::WriteHalf, + read: tokio::io::ReadHalf, + } + + impl ClientSide { + async fn write_negotiation(&mut self, body: &str) { + let len = body.len() as u32; + self.write.write_all(&len.to_be_bytes()).await.unwrap(); + self.write.write_all(body.as_bytes()).await.unwrap(); + self.write.flush().await.unwrap(); + } + + async fn write_chunk(&mut self, stream_type: u8, payload: &[u8]) { + let mut header = [0u8; 5]; + header[0] = stream_type; + let len = payload.len() as u32; + header[1..].copy_from_slice(&len.to_be_bytes()); + self.write.write_all(&header).await.unwrap(); + if !payload.is_empty() { + self.write.write_all(payload).await.unwrap(); + } + self.write.flush().await.unwrap(); + } + + async fn read_chunk(&mut self) -> (u8, Bytes) { + let mut reader = WireChunkReader::new(&mut self.read); + let chunk = reader.read_chunk().await.unwrap(); + (chunk.stream_type, chunk.bytes) + } + + async fn read_error_frame(&mut self) -> serde_json::Value { + use tokio::io::AsyncReadExt; + let mut len_buf = [0u8; 4]; + self.read.read_exact(&mut len_buf).await.unwrap(); + let len = u32::from_be_bytes(len_buf) as usize; + let mut body = vec![0u8; len]; + self.read.read_exact(&mut body).await.unwrap(); + serde_json::from_slice(&body).unwrap() + } + } + + fn make_client_and_server() -> (ClientSide, tokio::io::DuplexStream) { + let (a, b) = duplex(8 * 1024); + let (a_read, a_write) = tokio::io::split(a); + ( + ClientSide { + write: a_write, + read: a_read, + }, + b, + ) + } + + /// Split a bidirectional duplex stream into read/write halves and call + /// `drive_session` with them. + async fn drive_session_server( + server: tokio::io::DuplexStream, + backends: Arc>>, + ownership: Option>, + identity: Option, + ) { + let (server_read, server_write) = tokio::io::split(server); + drive_session(server_write, server_read, backends, ownership, identity).await; + } + + #[tokio::test] + async fn happy_path_negotiate_stdin_stdout_exit() { + let (backend, _control, _cancel) = TestBackend::builder().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client.write_negotiation(TEST_NEG).await; + + let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx"); + let _ = backend.take_stderr_tx().await; + stdout_tx.send(Bytes::from_static(b"hello")).await.unwrap(); + drop(stdout_tx); + + let exit_tx = backend.take_exit_tx().await.expect("exit tx"); + exit_tx.send(Ok(0)).unwrap(); + + let (st, bytes) = client.read_chunk().await; + assert_eq!(st, STREAM_STDOUT); + assert_eq!(bytes.as_ref(), b"hello"); + + let (st, bytes) = client.read_chunk().await; + assert_eq!(st, STREAM_STDOUT); + assert!(bytes.is_empty()); + + let (st, bytes) = client.read_chunk().await; + assert_eq!(st, crate::wire::STREAM_CTRL_OUT); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["type"], "exit"); + assert_eq!(v["code"], 0); + + let _ = session.await; + } + + #[tokio::test] + async fn exit_chunk_is_last_no_stdout_after_exit() { + let (backend, _control, _cancel) = TestBackend::builder().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client.write_negotiation(TEST_NEG).await; + + let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx"); + let _ = backend.take_stderr_tx().await; + stdout_tx.send(Bytes::from_static(b"out1")).await.unwrap(); + drop(stdout_tx); + + let exit_tx = backend.take_exit_tx().await.expect("exit tx"); + exit_tx.send(Ok(7)).unwrap(); + + loop { + let (st, bytes) = client.read_chunk().await; + if st == crate::wire::STREAM_CTRL_OUT { + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + if v["type"] == "exit" { + assert_eq!(v["code"], 7); + break; + } + } + } + let _ = session.await; + } + + #[tokio::test] + async fn stdin_eof_zero_length_chunk_closes_backend_stdin() { + let (backend, _control, _cancel) = TestBackend::builder().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client.write_negotiation(TEST_NEG).await; + + let stdin_rx = backend.take_stdin_rx().await.expect("stdin rx"); + + client.write_chunk(STREAM_STDIN, b"data").await; + client.write_chunk(STREAM_STDIN, b"").await; + + let mut received = Vec::new(); + let mut stdin_rx = stdin_rx; + while let Some(b) = stdin_rx.recv().await { + received.extend_from_slice(&b); + } + assert_eq!(received, b"data"); + + let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx"); + let _ = backend.take_stderr_tx().await; + drop(stdout_tx); + let exit_tx = backend.take_exit_tx().await.expect("exit tx"); + exit_tx.send(Ok(0)).unwrap(); + + let _ = session.await; + } + + #[tokio::test] + async fn resize_and_signal_control_dispatched() { + let (backend, control, _cancel) = TestBackend::builder().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client.write_negotiation(TEST_NEG).await; + + client + .write_chunk( + crate::wire::STREAM_CTRL_IN, + br#"{"type":"resize","cols":100,"rows":50}"#, + ) + .await; + client + .write_chunk( + crate::wire::STREAM_CTRL_IN, + br#"{"type":"signal","name":"INT"}"#, + ) + .await; + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + { + let resize = control.last_resize.lock().unwrap(); + assert_eq!(*resize, Some((100, 50, 0, 0))); + } + { + let signal = control.last_signal.lock().unwrap(); + assert_eq!(*signal, Some("INT".to_string())); + } + + let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx"); + let _ = backend.take_stderr_tx().await; + drop(stdout_tx); + let exit_tx = backend.take_exit_tx().await.expect("exit tx"); + exit_tx.send(Ok(0)).unwrap(); + let _ = session.await; + } + + #[tokio::test] + async fn unknown_control_type_ignored() { + let (backend, _control, _cancel) = TestBackend::builder().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client.write_negotiation(TEST_NEG).await; + + client + .write_chunk(crate::wire::STREAM_CTRL_IN, br#"{"type":"unknown"}"#) + .await; + + let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx"); + let _ = backend.take_stderr_tx().await; + stdout_tx + .send(Bytes::from_static(b"after-unknown")) + .await + .unwrap(); + drop(stdout_tx); + let exit_tx = backend.take_exit_tx().await.expect("exit tx"); + exit_tx.send(Ok(0)).unwrap(); + + let (st, bytes) = client.read_chunk().await; + assert_eq!(st, STREAM_STDOUT); + assert_eq!(bytes.as_ref(), b"after-unknown"); + + let _ = session.await; + } + + #[tokio::test] + async fn exit_control_from_client_ignored() { + // `Exit` is server→client only (belongs on `STREAM_CTRL_OUT`). + // Sending it on `STREAM_CTRL_IN` (client→server) is a protocol + // violation; the adapter ignores it and keeps pumping stdout. + let (backend, _control, _cancel) = TestBackend::builder().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client.write_negotiation(TEST_NEG).await; + + client + .write_chunk(crate::wire::STREAM_CTRL_IN, br#"{"type":"exit","code":99}"#) + .await; + + let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx"); + let _ = backend.take_stderr_tx().await; + stdout_tx + .send(Bytes::from_static(b"still-pumping")) + .await + .unwrap(); + drop(stdout_tx); + let exit_tx = backend.take_exit_tx().await.expect("exit tx"); + exit_tx.send(Ok(0)).unwrap(); + + let (st, bytes) = client.read_chunk().await; + assert_eq!(st, STREAM_STDOUT); + assert_eq!(bytes.as_ref(), b"still-pumping"); + + let _ = session.await; + } + + #[tokio::test] + async fn ctrl_out_from_client_ignored() { + // `STREAM_CTRL_OUT` (stream_type 4) is the server→client half. + // The client writing on it is a protocol violation; the adapter + // ignores the chunk and keeps pumping stdout (Phase 7). + let (backend, _control, _cancel) = TestBackend::builder().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client.write_negotiation(TEST_NEG).await; + + // Bogus: a client writing on the server→client control half. + client + .write_chunk( + crate::wire::STREAM_CTRL_OUT, + br#"{"type":"exit","code":99}"#, + ) + .await; + + let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx"); + let _ = backend.take_stderr_tx().await; + stdout_tx + .send(Bytes::from_static(b"after-bogus-ctrl-out")) + .await + .unwrap(); + drop(stdout_tx); + let exit_tx = backend.take_exit_tx().await.expect("exit tx"); + exit_tx.send(Ok(0)).unwrap(); + + let (st, bytes) = client.read_chunk().await; + assert_eq!(st, STREAM_STDOUT); + assert_eq!(bytes.as_ref(), b"after-bogus-ctrl-out"); + + let _ = session.await; + } + + #[tokio::test] + async fn exit_chunk_arrives_on_ctrl_out_not_ctrl_in() { + // Verifies the adapter emits `Exit` on `STREAM_CTRL_OUT` (4), not + // `STREAM_CTRL_IN` (3) — the Phase 7 bidirectionality fix. A client + // distinguishing the two halves can route exit vs. control + // without parsing the JSON tag first. + let (backend, _control, _cancel) = TestBackend::builder().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client.write_negotiation(TEST_NEG).await; + + let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx"); + let _ = backend.take_stderr_tx().await; + drop(stdout_tx); + let exit_tx = backend.take_exit_tx().await.expect("exit tx"); + exit_tx.send(Ok(42)).unwrap(); + + let (st, bytes) = client.read_chunk().await; + assert_eq!(st, STREAM_STDOUT); + assert!(bytes.is_empty()); + + let (st, bytes) = client.read_chunk().await; + assert_eq!( + st, + crate::wire::STREAM_CTRL_OUT, + "exit chunk must arrive on STREAM_CTRL_OUT (4), not STREAM_CTRL_IN (3)" + ); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["type"], "exit"); + assert_eq!(v["code"], 42); + + let _ = session.await; + } + + #[tokio::test] + async fn unknown_backend_error() { + let (backend, _control, _cancel) = TestBackend::builder().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client + .write_negotiation(r#"{"carriage":"raw","backend":"nope","cmd":["bash"]}"#) + .await; + + let err = client.read_error_frame().await; + assert_eq!(err["error"], "unknown_backend"); + assert_eq!(err["backend"], "nope"); + + let _ = session.await; + } + + #[tokio::test] + async fn malformed_negotiation_bad_json() { + let (backend, _control, _cancel) = TestBackend::builder().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client.write_negotiation("not json").await; + + let err = client.read_error_frame().await; + assert_eq!(err["error"], "malformed_negotiation"); + + let _ = session.await; + } + + #[tokio::test] + async fn malformed_negotiation_carriage_not_raw() { + let (backend, _control, _cancel) = TestBackend::builder().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client + .write_negotiation(r#"{"carriage":"json","backend":"mock","cmd":["bash"]}"#) + .await; + + let err = client.read_error_frame().await; + assert_eq!(err["error"], "malformed_negotiation"); + + let _ = session.await; + } + + #[tokio::test] + async fn malformed_negotiation_empty_cmd() { + let (backend, _control, _cancel) = TestBackend::builder().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client + .write_negotiation(r#"{"carriage":"raw","backend":"mock","cmd":[]}"#) + .await; + + let err = client.read_error_frame().await; + assert_eq!(err["error"], "malformed_negotiation"); + + let _ = session.await; + } + + #[tokio::test] + async fn allocate_failed_error() { + let (backend, _control, _cancel) = TestBackend::builder().with_allocate_fail().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client.write_negotiation(TEST_NEG).await; + + let err = client.read_error_frame().await; + assert_eq!(err["error"], "allocate_failed"); + + let _ = session.await; + } + + #[tokio::test] + async fn exit_error_sends_minus_one() { + let (backend, _control, _cancel) = TestBackend::builder().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client.write_negotiation(TEST_NEG).await; + + let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx"); + let _ = backend.take_stderr_tx().await; + drop(stdout_tx); + let exit_tx = backend.take_exit_tx().await.expect("exit tx"); + exit_tx + .send(Err(TtyError::WaitFailed { + message: "boom".to_string(), + })) + .unwrap(); + + loop { + let (st, bytes) = client.read_chunk().await; + if st == crate::wire::STREAM_CTRL_OUT { + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["type"], "exit"); + assert_eq!(v["code"], -1); + break; + } + } + + let _ = session.await; + } + + #[tokio::test] + async fn cancel_cleanup_drops_exit_future() { + let (backend, _control, cancel) = TestBackend::builder().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client.write_negotiation(TEST_NEG).await; + + let _stdout_tx = backend.take_stdout_tx().await.expect("stdout tx"); + let _exit_tx = backend.take_exit_tx().await.expect("exit tx"); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + session.abort(); + let _ = session.await; + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert!( + *cancel.lock().await, + "exit_code future dropped without resolve" + ); + } + + #[tokio::test] + async fn scope_gate_forbidden_without_tty_open() { + let (backend, _control, _cancel) = TestBackend::builder().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_no_scope(); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client.write_negotiation(TEST_NEG).await; + + let err = client.read_error_frame().await; + assert_eq!(err["error"], "forbidden"); + + let _ = session.await; + } + + #[tokio::test] + async fn ownership_check_denies_non_owner() { + let (backend, _control, _cancel) = TestBackend::builder() + .with_resource("container", "c1") + .build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let store = Arc::new(InMemoryOwnershipStore::new()); + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, Some(store), identity).await; + }); + + client.write_negotiation(TEST_NEG).await; + + let err = client.read_error_frame().await; + assert_eq!(err["error"], "forbidden"); + + let _ = session.await; + } + + #[tokio::test] + async fn ownership_check_allows_owner() { + let (backend, _control, _cancel) = TestBackend::builder() + .with_resource("container", "c1") + .build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let store = Arc::new(InMemoryOwnershipStore::new()); + let owner = Identity { + id: "test-user".to_string(), + scopes: vec![TTY_OPEN_SCOPE.to_string()], + resources: StdHashMap::new(), + }; + store.record(&owner, "container", "c1").await.unwrap(); + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, Some(store), identity).await; + }); + + client.write_negotiation(TEST_NEG).await; + + let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx"); + let _ = backend.take_stderr_tx().await; + drop(stdout_tx); + let exit_tx = backend.take_exit_tx().await.expect("exit tx"); + exit_tx.send(Ok(0)).unwrap(); + + loop { + let (st, bytes) = client.read_chunk().await; + if st == crate::wire::STREAM_CTRL_OUT { + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["type"], "exit"); + assert_eq!(v["code"], 0); + break; + } + } + + let _ = session.await; + } + + #[tokio::test] + async fn stderr_pump_concurrent_with_stdout() { + let (backend, _control, _cancel) = TestBackend::builder().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client.write_negotiation(TEST_NEG).await; + + let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx"); + let stderr_tx = backend.take_stderr_tx().await.expect("stderr tx"); + stdout_tx.send(Bytes::from_static(b"out")).await.unwrap(); + stderr_tx.send(Bytes::from_static(b"err")).await.unwrap(); + drop(stdout_tx); + drop(stderr_tx); + let exit_tx = backend.take_exit_tx().await.expect("exit tx"); + exit_tx.send(Ok(0)).unwrap(); + + let mut saw_stdout = false; + let mut saw_stderr = false; + let mut saw_exit = false; + loop { + let (st, bytes) = client.read_chunk().await; + match st { + STREAM_STDOUT => { + assert!(!saw_exit, "stdout after exit"); + if bytes.is_empty() { + saw_stdout = true; + } else { + assert_eq!(bytes.as_ref(), b"out"); + } + } + crate::wire::STREAM_STDERR => { + assert!(!saw_exit, "stderr after exit"); + assert_eq!(bytes.as_ref(), b"err"); + saw_stderr = true; + } + crate::wire::STREAM_CTRL_OUT => { + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["type"], "exit"); + saw_exit = true; + break; + } + _ => panic!("unexpected stream_type {st}"), + } + } + assert!(saw_stdout); + assert!(saw_stderr); + assert!(saw_exit); + + let _ = session.await; + } + + #[tokio::test] + async fn eof_control_closes_stdin() { + let (backend, _control, _cancel) = TestBackend::builder().build(); + let backends = make_backends(backend.clone()); + let (mut client, server) = make_client_and_server(); + + let identity = identity_with_scope(TTY_OPEN_SCOPE); + let session = tokio::spawn(async move { + drive_session_server(server, backends, None, identity).await; + }); + + client.write_negotiation(TEST_NEG).await; + + let stdin_rx = backend.take_stdin_rx().await.expect("stdin rx"); + + client.write_chunk(STREAM_STDIN, b"first").await; + client + .write_chunk(crate::wire::STREAM_CTRL_IN, br#"{"type":"eof"}"#) + .await; + + let mut received = Vec::new(); + let mut stdin_rx = stdin_rx; + while let Some(b) = stdin_rx.recv().await { + received.extend_from_slice(&b); + } + assert_eq!(received, b"first"); + + let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx"); + let _ = backend.take_stderr_tx().await; + drop(stdout_tx); + let exit_tx = backend.take_exit_tx().await.expect("exit tx"); + exit_tx.send(Ok(0)).unwrap(); + let _ = session.await; + } +} diff --git a/src/backend.rs b/src/backend.rs new file mode 100644 index 0000000..1998403 --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,512 @@ +//! Backend trait and handle shapes: `TtyBackend`, `TtyHandle`, `TtyControl`, +//! `TtyControlHandle`, `TtyParams`, `TerminalParams`, and `TtyError`. +//! +//! This is the inversion point (ADR-053) between the wire-format adapter +//! (`crate::adapter`) and the backend crates (`alktty`'s own `local` +//! feature module, future `alknet-docker`, `alknet-ssh`). alktty +//! defines the trait; the +//! backends implement it. The trait shape is a one-way door — changing it +//! after backends exist is a rewrite across crates. The adapter holds a +//! `HashMap>` keyed by the negotiation frame's +//! `backend` string and pumps the `TtyHandle` fields bidirectionally; +//! backends produce handles, they do not write to the wire. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use futures_core::Stream; +use tokio::sync::{mpsc, oneshot}; +use tokio_stream::wrappers::ReceiverStream; + +use crate::negotiation::{NegotiateRequest, TerminalParamsWire}; + +/// A boxed, sendable future used for the [`TtyHandle::exit_code`] field. +/// +/// Equivalent to `Pin>`; defined as a +/// `futures::future::BoxFuture<'static, T>` to match the alkcall +/// convention (alkcall pulls in `futures`, so no new transitive dep). +/// Functionally identical to the hand-rolled `Pin>` — `futures::future::BoxFuture<'static, T>` is +/// exactly that alias. +pub type BoxFuture = futures::future::BoxFuture<'static, T>; + +/// The error type for [`TtyBackend::allocate`] and the +/// [`TtyHandle::exit_code`] future. +/// +/// `#[non_exhaustive]` so new variants are additive (two-way-door extension +/// within the one-way trait shape — ADR-053). +#[non_exhaustive] +#[derive(Debug, thiserror::Error)] +pub enum TtyError { + /// The PTY couldn't be allocated, the docker exec failed to start, the + /// SSH channel request was rejected. Returned by `allocate()`; the + /// adapter sends `{"error":"allocate_failed",...}` and closes. + #[error("allocate failed: {message}")] + AllocFailed { message: String }, + /// The backend couldn't reap the child / determine the exit code. + /// Returned by the `exit_code` future; the adapter sends + /// `{"type":"exit","code":-1}` (ADR-055 §4). + #[error("wait failed: {message}")] + WaitFailed { message: String }, + /// An I/O error from a backend's stream/handle. + #[error("io: {0}")] + Io(#[from] std::io::Error), + /// A backend-specific error not covered by the above (e.g., a bollard + /// API error, a russh protocol error). + #[error("backend-specific: {message}")] + Backend { message: String }, +} + +/// Terminal dimensions and mode hints for a PTY allocation +/// (`TtyParams::terminal: Some`). +/// +/// `modes` is reserved (OQ-44); backends MUST ignore its content in v1. +#[derive(Debug, Clone)] +pub struct TerminalParams { + /// `TERM` environment value (e.g., `"xterm-256color"`); `None` = + /// backend default. + pub term: Option, + pub cols: u16, + pub rows: u16, + pub pixel_width: u16, + pub pixel_height: u16, + /// Reserved — OQ-44; backends MUST ignore the content in v1. + pub modes: serde_json::Value, +} + +/// The allocation request the adapter passes to [`TtyBackend::allocate`]. +/// +/// `terminal: None` is pipe/runner mode (no PTY, separate stdout/stderr — +/// ADR-054). `terminal: Some` is PTY mode (stdout/stderr merged into +/// `stdout` by the kernel PTY, real terminal semantics). +/// +/// `backend_params` is an opaque JSON object the adapter passes verbatim; +/// each backend deserializes its own strongly-typed params struct from it. +/// alktty has zero knowledge of any backend's params shape. See ADR-053 +/// §"Backend params are opaque." +#[derive(Debug, Clone)] +pub struct TtyParams { + /// Terminal parameters. `None` = pipe mode (no PTY — ADR-054). `Some` = + /// allocate a PTY with these dimensions. + pub terminal: Option, + /// Command vector (argv[0] + args). Non-empty. + pub cmd: Vec, + /// Working directory (`None` = inherit/default). + pub cwd: Option, + /// Environment variables (empty = inherit). + pub env: HashMap, + /// Backend-specific selector fields from the negotiation frame, + /// unparsed. The adapter passes the JSON object through verbatim; the + /// backend deserializes its own strongly-typed params struct from it. + /// alktty has zero knowledge of any backend's params shape. + pub backend_params: serde_json::Map, +} + +impl From for TerminalParams { + fn from(w: TerminalParamsWire) -> Self { + Self { + term: w.term, + cols: w.cols, + rows: w.rows, + pixel_width: w.pixel_width, + pixel_height: w.pixel_height, + modes: w.modes, + } + } +} + +/// Map a wire negotiation frame to the allocation request the backend +/// consumes. The adapter calls this after parsing the negotiation +/// carriage; `backend_params` is passed through verbatim (alktty has +/// zero knowledge of any backend's params shape — ADR-053). Lives here so +/// the adapter does not hand-roll the mapping. +impl From for TtyParams { + fn from(req: NegotiateRequest) -> Self { + Self { + terminal: req.tty.map(TerminalParams::from), + cmd: req.cmd, + cwd: req.cwd, + env: req.env, + backend_params: req.backend_params, + } + } +} + +/// What a backend's `allocate()` produces. The adapter pumps these fields +/// bidirectionally against the wire format (ADR-052). +pub struct TtyHandle { + /// Stdin writer — bytes the adapter pumps from client stdin chunks. + /// `tokio::io::AsyncWrite` (the tokio flavor, not `futures::io` — they + /// are incompatible traits; the tokio stack is the adapter's runtime). + pub stdin: Box, + /// Stdout stream — bytes the adapter pumps to client stdout chunks. + /// Ends when the backend's stdout reaches EOF. + pub stdout: Pin + Send>>, + /// Stderr stream — `None` for PTY backends (stdout/stderr merged into + /// `stdout` by the kernel PTY). `Some` for pipe backends (separate + /// streams). + pub stderr: Option + Send>>>, + /// Exit code — a `Future` the adapter awaits. Resolves when the + /// process/container/SSH exec exits. The adapter sends the result as + /// the `{"type":"exit","code":N}` control chunk (ADR-055) and closes + /// the stream. This is a `BoxFuture`, not a method on `TtyHandle`, so + /// the adapter can `select` between exit and stream-close without + /// coupling to the other fields (REQ-TTY-01). + /// + /// # ADR-056 — kill-on-Drop contract + /// + /// Dropping this future without driving it to completion MUST kill the + /// session target (the child process, the docker exec, the SSH + /// channel's process). The kill is best-effort (a no-op if the target + /// already exited) but MUST be attempted even when the target is + /// blocked in a state that ignores stdin EOF (a daemon, a process in + /// uninterruptible sleep, a container whose process ignores channel + /// close). The adapter triggers this by dropping the `TtyHandle` on + /// session cancel (connection drop, stream reset, task panic); the + /// backend wires the kill into this future's `Drop`-on-cancel guard + /// (e.g. the local backend holds a `portable_pty::ChildKiller`; docker + /// holds the container id + bollard client; SSH holds the russh + /// channel). A backend that returns a bare `oneshot::Receiver` + /// (or any future without a kill-on-`Drop` guard) as `exit_code` + /// violates the contract and will orphan processes on cancel. The + /// `Drop` MUST be a no-op when the future resolved normally (the + /// adapter awaited it to completion). See ADR-056 and `tty-local.md` + /// §"Cancel-Cleanup" for the local backend's mechanism. + pub exit_code: BoxFuture>, + /// Control handle (resize, signal) — `Clone` so the adapter can hand + /// it to the spawned control-chunk dispatcher. `None` only when the + /// backend genuinely has no control path. See OQ-43. + pub control: Option, +} + +/// Control path for a live terminal session: resize and signal forwarding. +/// +/// Object-safe (`Send + Sync`, no `Clone` — `Clone` is not object-safe). +/// The `Clone`-ability lives on the [`TtyControlHandle`] newtype, which +/// holds the trait object behind an `Arc`. A backend produces its own +/// control type via `TtyControlHandle::new(Arc::new(MyControl))` without +/// the adapter knowing the concrete shape (OQ-43). +pub trait TtyControl: Send + Sync { + /// Resize the terminal. Maps to SSH `window-change`, docker exec + /// resize, or `ioctl(TIOCSWINSZ)` on a local PTY. No-op for pipe + /// backends without a PTY (the adapter still calls it; the backend + /// ignores). + fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16); + + /// Forward a signal by name. Best-effort delivery to the foreground + /// process group (see `tty-local.md` REQ-TTY-02). Unknown names fall + /// back to the backend's default kill. + fn signal(&self, name: &str); +} + +/// The `Clone`-able handle to a backend's control path. The +/// [`TtyControl`] trait is NOT `Clone` (`Clone` is not object-safe — +/// `fn clone(&self) -> Self` returns `Self`, which forbids `dyn` dispatch); +/// the `Clone`-ability lives on this concrete newtype, which holds the +/// trait object behind an `Arc`. The adapter clones the `Arc` to hand a +/// handle to the spawned control-chunk dispatcher. A backend produces its +/// own control type via `TtyControlHandle::new(Arc::new(MyControl))` +/// without the adapter knowing the concrete shape. See OQ-43. +#[derive(Clone)] +pub struct TtyControlHandle(Arc); + +impl TtyControlHandle { + /// Wrap a backend's control implementation. The backend typically + /// calls `TtyControlHandle::new(Arc::new(MyControl))` inside its + /// `allocate()`. + pub fn new(control: Arc) -> Self { + Self(control) + } + + /// Resize the terminal. Delegates to the inner [`TtyControl`]. + pub fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16) { + self.0.resize(cols, rows, pixel_width, pixel_height); + } + + /// Forward a signal by name. Delegates to the inner [`TtyControl`]. + pub fn signal(&self, name: &str) { + self.0.signal(name); + } +} + +/// The backend inversion point (ADR-053). alktty defines the trait; +/// the backend crates (alktty's `local` feature module, future +/// `alknet-docker`, `alknet-ssh`) implement it. The adapter holds a +/// `HashMap>` keyed by the negotiation frame's +/// `backend` string and dispatches by that key. +/// +/// # REQ-TTY-01 — backends need not be natively async +/// +/// The adapter-facing types this trait returns (`AsyncWrite`, +/// `Stream`, `BoxFuture`, `TtyControl`) are the **adapter's +/// contract**. A backend may expose blocking handles internally (e.g. +/// `portable_pty`'s blocking `std::io::{Read, Write}` + `Child::wait()`) +/// and bridge them to these async-facing types via dedicated std threads +/// or `tokio::task::spawn_blocking` feeding tokio mpsc/oneshot channels. +/// This bridging pattern is a **documented, supported implementation +/// strategy**, not a workaround. The local backend (alktty's `local` +/// the reference implementation: it spawns reader/writer/waiter threads +/// that feed `mpsc::Receiver` (stdout), an `AsyncWrite` adapter +/// over `mpsc::Sender` (stdin), and a `oneshot::Receiver` +/// wrapped in a kill-guard future (exit). The adapter consumes the bridged +/// async-facing types and is unaware of the threading. +/// +/// # ADR-056 — kill-on-Drop contract +/// +/// The [`TtyHandle::exit_code`] future returned by `allocate()` MUST kill +/// the session target when dropped without being driven to completion. See +/// the doc comment on [`TtyHandle::exit_code`] for the full contract. +#[async_trait] +pub trait TtyBackend: Send + Sync { + /// Allocate a terminal/process session and return the handles the + /// adapter pumps. The `backend` field of the negotiation frame + /// (ADR-052) selects which registered backend's `allocate` is called. + async fn allocate(&self, params: &TtyParams) -> Result; + + /// The pre-existing resource this session targets, for ownership + /// checks (ADR-050). `None` = no pre-existing resource (the session + /// creates its own — local process, SSH channel). `Some((kind, id))` + /// = the session targets an existing resource the caller must own + /// (e.g., `DockerTtyBackend` returns `Some(("container", id))`). The + /// adapter calls this at negotiation to gate access; the backend + /// extracts the id from its own `backend_params`. Default `None` + /// (most backends create their own resource). + fn resource_id(&self, _params: &TtyParams) -> Option<(&'static str, String)> { + None + } +} + +/// Mock `TtyControl` for tests. Records the last resize/signal call so +/// tests can assert delegation through [`TtyControlHandle`]. +#[derive(Default)] +pub struct MockControl { + pub last_resize: std::sync::Mutex>, + pub last_signal: std::sync::Mutex>, +} + +impl TtyControl for MockControl { + fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16) { + *self.last_resize.lock().expect("resize mutex poisoned") = + Some((cols, rows, pixel_width, pixel_height)); + } + + fn signal(&self, name: &str) { + *self.last_signal.lock().expect("signal mutex poisoned") = Some(name.to_string()); + } +} + +/// In-memory `TtyBackend` for tests. `allocate()` wires tokio mpsc +/// channels for stdin/stdout/stderr, a oneshot for `exit_code`, and a +/// mock [`TtyControl`]. The caller can drive the channels directly or via +/// the adapter pump. Use [`MockBackend::with_exit_code`] to fix the exit +/// code the handle resolves to. +#[derive(Default)] +pub struct MockBackend { + pub exit_code: Option, +} + +impl MockBackend { + pub fn new() -> Self { + Self::default() + } + + pub fn with_exit_code(exit_code: i32) -> Self { + Self { + exit_code: Some(exit_code), + } + } +} + +#[async_trait] +impl TtyBackend for MockBackend { + async fn allocate(&self, _params: &TtyParams) -> Result { + let (_stdout_tx, stdout_rx) = mpsc::channel::(8); + let (_stderr_tx, stderr_rx) = mpsc::channel::(8); + let (stdin_tx, _stdin_rx) = mpsc::channel::(8); + let (exit_tx, exit_rx) = oneshot::channel::>(); + + let code = self.exit_code.unwrap_or(0); + tokio::spawn(async move { + let _ = exit_tx.send(Ok(code)); + }); + + let stdout: Pin + Send>> = + Box::pin(ReceiverStream::new(stdout_rx)); + let stderr: Option + Send>>> = + Some(Box::pin(ReceiverStream::new(stderr_rx))); + let stdin: Box = + Box::new(MockStdinSink { tx: stdin_tx }); + let control = Some(TtyControlHandle::new(Arc::new(MockControl::default()))); + + let exit_code: BoxFuture> = Box::pin(async move { + exit_rx + .await + .map_err(|_| TtyError::WaitFailed { + message: "exit_code sender dropped".to_string(), + }) + .and_then(|r| r) + }); + + Ok(TtyHandle { + stdin, + stdout, + stderr, + exit_code, + control, + }) + } +} + +/// `AsyncWrite` adapter over an `mpsc::Sender` — the mock +/// backend's stdin sink. Copies the buffer into a `Bytes` and best-effort +/// sends; on a full channel returns `Pending`, on a closed channel returns +/// a broken-pipe error. +struct MockStdinSink { + tx: mpsc::Sender, +} + +impl tokio::io::AsyncWrite for MockStdinSink { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + use std::task::Poll; + match self.tx.try_reserve() { + Ok(permit) => { + permit.send(Bytes::copy_from_slice(buf)); + Poll::Ready(Ok(buf.len())) + } + Err(mpsc::error::TrySendError::Full(_)) => Poll::Pending, + Err(mpsc::error::TrySendError::Closed(_)) => Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "stdin channel closed", + ))), + } + } + + fn poll_flush( + self: Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_shutdown( + self: Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tty_control_handle_clone_delegates_resize_and_signal() { + let control = Arc::new(MockControl::default()); + let handle = TtyControlHandle::new(control.clone()); + let handle_clone = handle.clone(); + + handle.resize(80, 24, 0, 0); + handle_clone.signal("HUP"); + + let resize = control.last_resize.lock().expect("resize mutex poisoned"); + assert_eq!(*resize, Some((80, 24, 0, 0))); + let signal = control.last_signal.lock().expect("signal mutex poisoned"); + assert_eq!(*signal, Some("HUP".to_string())); + } + + #[tokio::test] + async fn mock_backend_allocates_and_exits() { + let backend = MockBackend::with_exit_code(42); + let params = TtyParams { + terminal: None, + cmd: vec!["echo".to_string(), "hi".to_string()], + cwd: None, + env: HashMap::new(), + backend_params: serde_json::Map::new(), + }; + let handle = backend.allocate(¶ms).await.expect("allocate"); + assert!(handle.stderr.is_some()); + assert!(handle.control.is_some()); + let code = handle.exit_code.await.expect("exit_code"); + assert_eq!(code, 42); + } + + #[tokio::test] + async fn mock_backend_resource_id_default_none() { + let backend = MockBackend::new(); + let params = TtyParams { + terminal: None, + cmd: vec!["true".to_string()], + cwd: None, + env: HashMap::new(), + backend_params: serde_json::Map::new(), + }; + assert!(backend.resource_id(¶ms).is_none()); + } + + #[test] + fn tty_params_from_negotiate_request_maps_fields() { + let req = NegotiateRequest { + carriage: "raw".to_string(), + backend: "local".to_string(), + tty: Some(TerminalParamsWire { + term: Some("xterm-256color".to_string()), + cols: 80, + rows: 24, + pixel_width: 0, + pixel_height: 0, + modes: serde_json::Value::Null, + }), + cmd: vec!["bash".to_string()], + cwd: Some(PathBuf::from("/tmp")), + env: HashMap::from([("FOO".to_string(), "bar".to_string())]), + backend_params: { + let mut m = serde_json::Map::new(); + m.insert( + "container".to_string(), + serde_json::Value::String("abc".to_string()), + ); + m + }, + }; + let params = TtyParams::from(req); + let term = params.terminal.expect("terminal"); + assert_eq!(term.term.as_deref(), Some("xterm-256color")); + assert_eq!(term.cols, 80); + assert_eq!(term.rows, 24); + assert_eq!(params.cmd, vec!["bash".to_string()]); + assert_eq!(params.cwd.as_deref(), Some(std::path::Path::new("/tmp"))); + assert_eq!(params.env.get("FOO").map(String::as_str), Some("bar")); + assert_eq!( + params + .backend_params + .get("container") + .and_then(|v| v.as_str()), + Some("abc"), + ); + } + + #[test] + fn tty_params_from_negotiate_request_pipe_mode() { + let req = NegotiateRequest { + carriage: "raw".to_string(), + backend: "local".to_string(), + tty: None, + cmd: vec!["true".to_string()], + cwd: None, + env: HashMap::new(), + backend_params: serde_json::Map::new(), + }; + let params = TtyParams::from(req); + assert!(params.terminal.is_none()); + } +} diff --git a/src/control.rs b/src/control.rs new file mode 100644 index 0000000..a236575 --- /dev/null +++ b/src/control.rs @@ -0,0 +1,275 @@ +//! Control messages carried in `stream_type 3` (`ctrl_in`) and +//! `stream_type 4` (`ctrl_out`) chunks (ADR-052, amended Phase 7). +//! +//! The control channel is split into two halves so it is genuinely +//! bidirectional on the wire: `STREAM_CTRL_IN = 3` carries client→server +//! control (`Resize`, `Signal`, `Eof`); `STREAM_CTRL_OUT = 4` carries +//! server→client control (`Exit`). The previous single +//! `STREAM_CONTROL = 3` was documented as "bidirectional" but the adapter +//! ignored `Exit` from the client because it had no way to distinguish +//! the two directions on the same stream_type — see the Phase 7 notes +//! in `docs/architecture/tty-wire.md` (the bidirectionality fix that +//! split the single `STREAM_CONTROL = 3` into `STREAM_CTRL_IN` (3) +//! and `STREAM_CTRL_OUT` (4)). +//! +//! Control chunks carry a JSON payload tagged by `type`. The schema is the +//! POC's `ControlMessage`: +//! +//! ```json +//! {"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0} +//! {"type":"signal","name":"INT"} +//! {"type":"eof"} +//! {"type":"exit","code":0} +//! ``` +//! +//! Control messages are rare (resize on window drag, signal on Ctrl-C), so +//! serialization cost is negligible versus data chunks. The JSON shape is +//! consistent with the call protocol's JSON-everything stance and easy to +//! extend: new types are additive on the `type` tag (ADR-052 §"Control +//! Channel"). +//! +//! Unknown `type` values: `from_slice` returns a `serde_json::Error`. The +//! adapter (task `tty/adapter`) ignores that error per the wire spec's +//! "unknown types are ignored" policy — this keeps the enum exhaustive and +//! the policy an adapter-level concern, not a schema-level leak. + +use serde::{Deserialize, Serialize}; + +/// A control message riding on `STREAM_CTRL_IN` (stream_type 3, +/// client→server) or `STREAM_CTRL_OUT` (stream_type 4, server→client). +/// +/// Direction and mapping (per `tty-wire.md` §"Control Channel"): +/// +/// | direction | stream_type | variant | maps to | +/// |----------------|-----------------|----------|----------------------------------------------------| +/// | client→server | `STREAM_CTRL_IN` (3) | `Resize` | SSH `window-change`, docker exec resize, `ioctl` | +/// | client→server | `STREAM_CTRL_IN` (3) | `Signal` | SSH `signal`, docker exec signal, `kill(-pgid, n)` | +/// | client→server | `STREAM_CTRL_IN` (3) | `Eof` | SSH channel EOF, docker stdin close, `ChildStdin` | +/// | server→client | `STREAM_CTRL_OUT` (4) | `Exit` | the completion signal (ADR-055) | +/// +/// The direction is enforced by the adapter, not by this enum: a `Resize` +/// arriving on `STREAM_CTRL_OUT` is a protocol violation (the adapter +/// ignores it), and an `Exit` arriving on `STREAM_CTRL_IN` is likewise a +/// protocol violation (the adapter ignores it). The split is what makes +/// the control channel genuinely bidirectional — the previous single +/// `STREAM_CONTROL = 3` was documented as "bidirectional" but the +/// adapter had to ignore `Exit` from the client because the two +/// directions were indistinguishable on the same stream_type. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ControlMessage { + /// Terminal window resize (client→server, `STREAM_CTRL_IN`). + /// + /// `pixel_width`/`pixel_height` default to 0 (most terminals don't + /// report pixel dimensions; SSH's `pty_request` carries them for + /// completeness). + Resize { + cols: u16, + rows: u16, + #[serde(default)] + pixel_width: u16, + #[serde(default)] + pixel_height: u16, + }, + /// Forward a signal to the child process group (client→server, + /// `STREAM_CTRL_IN`). + /// + /// `name` is an uppercase string from the supported set (see + /// [`signal_from_name`]). Unknown names fall back to the backend's + /// default kill in the adapter (tty-local.md REQ-TTY-02). + Signal { name: String }, + /// Client stdin is done (client→server, `STREAM_CTRL_IN`). The + /// server closes the backend's stdin (`ChildStdin::drop` / PTY writer + /// close) but keeps pumping stdout + the exit chunk. See + /// `tty-wire.md` §"Stdin Closure". + Eof, + /// Process exit code (server→client, `STREAM_CTRL_OUT`). The exit + /// chunk is the last control chunk before stream close (ADR-055). + /// `code` is `i32` matching `std::process::ExitStatus::code()`; + /// negative values are signal-terminated (e.g., `-9` for SIGKILL on + /// Unix). `-1` is the adapter's best-effort "backend could not + /// determine the exit code" sentinel (ADR-055 §4). + Exit { code: i32 }, +} + +impl ControlMessage { + /// Serialize to JSON bytes for the control chunk payload. + pub fn to_json(&self) -> serde_json::Result { + serde_json::to_vec(self).map(bytes::Bytes::from) + } + + /// Deserialize from a control chunk payload (UTF-8 JSON). + /// + /// Returns `serde_json::Error` on unknown `type` tags; the adapter + /// ignores that error per the wire spec's extensibility policy. + pub fn from_slice(b: &[u8]) -> serde_json::Result { + serde_json::from_slice(b) + } +} + +/// Map an uppercase signal name to a libc signal number. +/// +/// Supports the common set a terminal front-end would forward: `HUP`, +/// `INT`, `QUIT`, `TERM`, `KILL`, `USR1`, `USR2`, `TSTP`, `CONT` +/// (Ctrl-C → `INT`, Ctrl-\ → `QUIT`, Ctrl-Z → `TSTP`). Unknown names +/// return `None`; the caller (the local backend) decides whether to +/// ignore or fall back to the backend's default kill +/// (`portable_pty`'s `ChildKiller::kill` sends SIGHUP — see +/// tty-local.md REQ-TTY-02). +/// +/// Unix-only: the non-Unix path falls back to `ChildKiller::kill` +/// directly. The `#[cfg(unix)]` gate matches the POC. +#[cfg(unix)] +pub fn signal_from_name(name: &str) -> Option { + use libc::*; + match name { + "HUP" => Some(SIGHUP), + "INT" => Some(SIGINT), + "QUIT" => Some(SIGQUIT), + "TERM" => Some(SIGTERM), + "KILL" => Some(SIGKILL), + "USR1" => Some(SIGUSR1), + "USR2" => Some(SIGUSR2), + "TSTP" => Some(SIGTSTP), + "CONT" => Some(SIGCONT), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_resize() { + let msg = ControlMessage::Resize { + cols: 80, + rows: 24, + pixel_width: 0, + pixel_height: 0, + }; + let bytes = msg.to_json().unwrap(); + let back = ControlMessage::from_slice(&bytes).unwrap(); + assert_eq!(msg, back); + } + + #[test] + fn round_trip_resize_with_pixels() { + let msg = ControlMessage::Resize { + cols: 120, + rows: 40, + pixel_width: 800, + pixel_height: 600, + }; + let bytes = msg.to_json().unwrap(); + let back = ControlMessage::from_slice(&bytes).unwrap(); + assert_eq!(msg, back); + } + + #[test] + fn round_trip_signal() { + let msg = ControlMessage::Signal { + name: "INT".to_string(), + }; + let bytes = msg.to_json().unwrap(); + let back = ControlMessage::from_slice(&bytes).unwrap(); + assert_eq!(msg, back); + } + + #[test] + fn round_trip_eof() { + let msg = ControlMessage::Eof; + let bytes = msg.to_json().unwrap(); + let back = ControlMessage::from_slice(&bytes).unwrap(); + assert_eq!(msg, back); + } + + #[test] + fn round_trip_exit() { + let msg = ControlMessage::Exit { code: 42 }; + let bytes = msg.to_json().unwrap(); + let back = ControlMessage::from_slice(&bytes).unwrap(); + assert_eq!(msg, back); + } + + #[test] + fn to_json_emits_snake_case_type_tag() { + let resize = ControlMessage::Resize { + cols: 80, + rows: 24, + pixel_width: 0, + pixel_height: 0, + }; + let json: serde_json::Value = serde_json::from_slice(&resize.to_json().unwrap()).unwrap(); + assert_eq!(json["type"], "resize"); + assert_eq!(json["cols"], 80); + assert_eq!(json["rows"], 24); + assert_eq!(json["pixel_width"], 0); + assert_eq!(json["pixel_height"], 0); + + let signal = ControlMessage::Signal { + name: "INT".to_string(), + }; + let json: serde_json::Value = serde_json::from_slice(&signal.to_json().unwrap()).unwrap(); + assert_eq!(json["type"], "signal"); + assert_eq!(json["name"], "INT"); + + let eof = ControlMessage::Eof; + let json: serde_json::Value = serde_json::from_slice(&eof.to_json().unwrap()).unwrap(); + assert_eq!(json["type"], "eof"); + + let exit = ControlMessage::Exit { code: 0 }; + let json: serde_json::Value = serde_json::from_slice(&exit.to_json().unwrap()).unwrap(); + assert_eq!(json["type"], "exit"); + assert_eq!(json["code"], 0); + } + + #[test] + fn resize_omits_pixel_defaults_on_deserialize() { + let json = br#"{"type":"resize","cols":80,"rows":24}"#; + let msg = ControlMessage::from_slice(json).unwrap(); + match msg { + ControlMessage::Resize { + cols, + rows, + pixel_width, + pixel_height, + } => { + assert_eq!(cols, 80); + assert_eq!(rows, 24); + assert_eq!(pixel_width, 0); + assert_eq!(pixel_height, 0); + } + _ => panic!("expected Resize"), + } + } + + #[test] + fn from_slice_unknown_type_returns_error() { + let json = br#"{"type":"unknown"}"#; + assert!(ControlMessage::from_slice(json).is_err()); + } + + #[cfg(unix)] + #[test] + fn signal_from_name_known() { + use libc::*; + assert_eq!(signal_from_name("HUP"), Some(SIGHUP)); + assert_eq!(signal_from_name("INT"), Some(SIGINT)); + assert_eq!(signal_from_name("QUIT"), Some(SIGQUIT)); + assert_eq!(signal_from_name("TERM"), Some(SIGTERM)); + assert_eq!(signal_from_name("KILL"), Some(SIGKILL)); + assert_eq!(signal_from_name("USR1"), Some(SIGUSR1)); + assert_eq!(signal_from_name("USR2"), Some(SIGUSR2)); + assert_eq!(signal_from_name("TSTP"), Some(SIGTSTP)); + assert_eq!(signal_from_name("CONT"), Some(SIGCONT)); + } + + #[cfg(unix)] + #[test] + fn signal_from_name_unknown() { + assert_eq!(signal_from_name("NOPE"), None); + assert_eq!(signal_from_name(""), None); + assert_eq!(signal_from_name("int"), None); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..543406e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,56 @@ +//! alktty: Terminal session protocol for the `alk/tty` ALPN. +//! +//! Producer/consumer protocol crate on top of alkcall channels. Two +//! halves (per alkcall's protocol-crate pattern): +//! +//! - **Producer half** — [`adapter::TtyAdapter`] (direct `alk/tty` +//! ALPN via `ProtocolHandler`) + [`channels`] (registers the +//! `channels/tty/sub` open op via `ChannelCore::register_openable` +//! for the `alk/channels` multiplexed path). +//! - **Consumer half** — [`session::TtySession`] (typed client wrapper +//! around the wire protocol, with `connect_direct` and +//! `open_via_channels` constructors). +//! +//! Two-carriage wire format (ADR-052): a JSON negotiation frame, then +//! raw chunks (`[stream_type: u8][length: u32 be][payload]`). +//! Backend-agnostic via the [`backend::TtyBackend`] trait (ADR-053). +//! Depends on alkcall (ADR-057 — the negotiation framing is +//! self-contained; alkcall's `EventEnvelope` framing is not reused). +//! +//! # WASM target +//! +//! The default crate (no features) compiles to +//! `wasm32-unknown-unknown`. The [`local`] module is feature-gated +//! and non-wasm by design (`portable-pty` + `tokio::process` need a +//! real OS). Downstream TS/Python adapters compile the protocol +//! layer in a sandbox; the local-process backend runs on a real OS. +//! +//! # Local backend +//! +//! The local backend ([`local::LocalTtyBackend`]) is gated behind the +//! `local` feature. It implements [`backend::TtyBackend`] via +//! `portable_pty` (PTY mode, terminal semantics) and +//! `tokio::process::Command` (pipe mode, the runner case). The +//! blocking→async bridge for PTY mode uses three dedicated std +//! threads feeding tokio mpsc/oneshot channels (REQ-TTY-01). +//! +//! # Assembly pattern +//! +//! ```ignore +//! let mut backends = std::collections::HashMap::new(); +//! backends.insert( +//! "local".into(), +//! std::sync::Arc::new(alktty::local::LocalTtyBackend::new()) +//! as std::sync::Arc, +//! ); +//! let tty_adapter = alktty::adapter::TtyAdapter::new(backends); +//! ``` + +pub mod adapter; +pub mod backend; +pub mod control; +pub mod negotiation; +pub mod wire; + +#[cfg(feature = "local")] +pub mod local; \ No newline at end of file diff --git a/src/negotiation.rs b/src/negotiation.rs new file mode 100644 index 0000000..b32ebf1 --- /dev/null +++ b/src/negotiation.rs @@ -0,0 +1,526 @@ +//! Negotiation carriage: `NegotiateRequest`, `TerminalParamsWire`, +//! length-prefixed framing reader/writer, and the error response shape. +//! +//! Phase 1 of the `alk/tty` wire protocol (ADR-052). The client opens a +//! bidi stream and writes a single length-prefixed JSON frame carrying the +//! terminal parameters, backend selector, command, and environment. After +//! this frame, the stream switches to raw chunks (task `tty/wire-codec`). +//! +//! The framing is self-contained in alktty (ADR-057): a 4-byte +//! big-endian length prefix + UTF-8 JSON body. The format coincides with +//! alkcall's `EventEnvelope` framing by convention, not by code reuse +//! — alktty does not depend on alkcall's internal wire types. +//! +//! # Framing disambiguation +//! +//! A server-side error response (JSON, length-prefixed) and a successful +//! allocation's first raw chunk both begin with bytes the client reads +//! before knowing which framing applies. The disambiguation is by the +//! first byte: +//! +//! - An error frame's 4-byte big-endian length prefix starts with `0x00` +//! because error frames MUST be under 16 MiB ([`MAX_CHUNK_LEN`]) so the +//! high byte is zero (a wire-format invariant, not an assumption). +//! - A raw chunk's first byte is a `stream_type`. The server never sends +//! `0` (stdin — client→server only) or `3` (`STREAM_CTRL_IN` — +//! client→server only), so the server-sent set is `{1, 2, 4}` +//! (stdout, stderr, `STREAM_CTRL_OUT`); `0x00` is unambiguous. +//! +//! See ADR-052 §5 and `tty-wire.md` §"Constraints". + +use std::collections::HashMap; +use std::io; +use std::path::PathBuf; + +use bytes::Bytes; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +use crate::wire::MAX_CHUNK_LEN; + +/// The Phase 1 JSON negotiation frame payload (ADR-052). +/// +/// The client writes a single length-prefixed frame containing this struct, +/// then switches to raw chunks. The adapter parses it, dispatches on +/// `backend`, and passes `backend_params` verbatim to the selected +/// backend's `allocate()`. +/// +/// # Validation +/// +/// This struct only parses. The adapter (task `tty/adapter`) validates: +/// - `carriage` MUST be `"raw"` (else `malformed_negotiation`). +/// - `cmd` MUST be non-empty (else `malformed_negotiation`). +/// - `backend` MUST be a registered backend key (else `unknown_backend`). +/// +/// Backend-specific params validation is the backend's job (in `allocate()`). +/// +/// # `serde(flatten)` for backend-specific fields +/// +/// The negotiation frame's top-level JSON object carries both the shared +/// fields (`carriage`, `backend`, `tty`, `cmd`, `cwd`, `env`) and +/// backend-specific fields (e.g., `"container": "abc123"` for docker); the +/// latter land in `backend_params` via the `serde(flatten)` below. The +/// shared fields are consumed by name; whatever remains flows into the +/// `backend_params` map. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct NegotiateRequest { + /// `"raw"` in v1; any other value → `malformed_negotiation` (checked by + /// the adapter, not this parser). + pub carriage: String, + /// Backend selector key (`"local"`, `"docker"`, `"ssh"`). + pub backend: String, + /// `None` = pipe mode (no PTY — ADR-054). `Some` = allocate a PTY with + /// these dimensions. + #[serde(default)] + pub tty: Option, + /// Command vector (argv[0] + args); non-empty (checked by the adapter). + pub cmd: Vec, + /// Working directory (`None` = inherit/default). + #[serde(default)] + pub cwd: Option, + /// Environment variables (empty = inherit). + #[serde(default)] + pub env: HashMap, + /// Backend-specific selector fields, opaque to alktty. The adapter + /// passes this map through verbatim; each backend deserializes its own + /// strongly-typed params struct from it. See ADR-053 §"Backend params + /// are opaque." + /// + /// Populated by `serde(flatten)`: any top-level key not matching a named + /// field above lands here. + #[serde(flatten)] + pub backend_params: serde_json::Map, +} + +/// Terminal parameters carried in [`NegotiateRequest::tty`] (ADR-052). +/// +/// Maps to SSH's `pty_request` parameters, to docker's +/// `CreateExecOptions { tty: true }`, and to `portable_pty::PtySystem::openpty` +/// for the local backend. The `modes` field is reserved (OQ-44 — default +/// terminal modes suffice for the current scope); backends MUST ignore its +/// content in v1. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct TerminalParamsWire { + /// `TERM` environment value (e.g., `"xterm-256color"`); `None` = + /// backend default. + #[serde(default)] + pub term: Option, + /// Terminal columns. + pub cols: u16, + /// Terminal rows. + pub rows: u16, + /// Pixel width (most terminals don't report this; defaults to 0). + #[serde(default)] + pub pixel_width: u16, + /// Pixel height (most terminals don't report this; defaults to 0). + #[serde(default)] + pub pixel_height: u16, + /// Reserved — OQ-44; backends MUST ignore the content in v1. + #[serde(default)] + pub modes: serde_json::Value, +} + +/// Errors from the negotiation framing reader/writer. +/// +/// `ConnectionClosed` is returned (rather than `Io`) when `read_frame` hits +/// a clean `UnexpectedEof` reading either the length prefix or the body — +/// the peer closed the stream cleanly rather than failing the transport. +#[derive(Debug, thiserror::Error)] +pub enum NegotiationError { + /// Underlying transport I/O error (not a clean EOF). + #[error("io: {0}")] + Io(#[from] io::Error), + /// The peer closed the stream cleanly (unexpected EOF on the length + /// prefix or the body). + #[error("connection closed")] + ConnectionClosed, + /// The frame length exceeded [`MAX_CHUNK_LEN`]. A malformed length + /// prefix can't trigger an oversized allocation. + #[error("frame too large: {0}")] + FrameTooLarge(u32), + /// JSON parse error on the negotiation frame or error response. + #[error("json: {0}")] + Json(#[from] serde_json::Error), +} + +/// Reads length-prefixed negotiation frames from an [`AsyncRead`] transport. +/// +/// [`NegotiationReader::read_frame`] reads a 4-byte big-endian length +/// prefix, bounds-checks it against [`MAX_CHUNK_LEN`] (so a malformed +/// prefix can't trigger an oversized allocation), then reads the body. On a +/// clean `UnexpectedEof` it returns [`NegotiationError::ConnectionClosed`]. +/// +/// After reading the single negotiation frame, call +/// [`NegotiationReader::into_inner`] to reclaim the underlying stream for +/// raw-chunk reading (the reader buffers nothing past the frame boundary, +/// so the stream is clean for [`crate::wire::ChunkReader`]). +pub struct NegotiationReader { + reader: R, + len_buf: [u8; 4], +} + +impl NegotiationReader { + /// Wrap an [`AsyncRead`] transport in a negotiation frame reader. + pub fn new(reader: R) -> Self { + Self { + reader, + len_buf: [0u8; 4], + } + } + + /// Consume the reader and return the underlying transport. Use this + /// after reading the negotiation frame to reclaim the stream for + /// raw-chunk reading. + pub fn into_inner(self) -> R { + self.reader + } + + /// Read one length-prefixed frame: 4-byte BE length, bounds-check, + /// body. + /// + /// Returns the raw frame bytes (the caller deserializes JSON). On a + /// clean `UnexpectedEof` reading either the length prefix or the body, + /// returns [`NegotiationError::ConnectionClosed`]. On a length prefix + /// exceeding [`MAX_CHUNK_LEN`], returns + /// [`NegotiationError::FrameTooLarge`]. + pub async fn read_frame(&mut self) -> Result { + match self.reader.read_exact(&mut self.len_buf).await { + Ok(_) => {} + Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => { + return Err(NegotiationError::ConnectionClosed); + } + Err(e) => return Err(NegotiationError::Io(e)), + } + + let length = u32::from_be_bytes(self.len_buf); + if length > MAX_CHUNK_LEN { + return Err(NegotiationError::FrameTooLarge(length)); + } + + let mut buf = vec![0u8; length as usize]; + if length > 0 { + match self.reader.read_exact(&mut buf).await { + Ok(_) => {} + Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => { + return Err(NegotiationError::ConnectionClosed); + } + Err(e) => return Err(NegotiationError::Io(e)), + } + } + + Ok(Bytes::from(buf)) + } +} + +/// Writes length-prefixed negotiation frames to an [`AsyncWrite`] transport. +/// +/// [`NegotiationWriter::write_frame`] writes a 4-byte big-endian length +/// prefix followed by the body, then flushes. +/// +/// For server-side error responses, the body MUST be under 16 MiB +/// ([`MAX_CHUNK_LEN`]) so the high byte of the length prefix is `0x00` — +/// this is the wire-format invariant that makes the framing-disambiguation +/// trick sound (see ADR-052 §5). [`Self::write_frame`] does not enforce +/// this; callers building error responses with [`error_response_bytes`] are +/// well within the limit by construction. +pub struct NegotiationWriter { + writer: W, +} + +impl NegotiationWriter { + /// Wrap an [`AsyncWrite`] transport in a negotiation frame writer. + pub fn new(writer: W) -> Self { + Self { writer } + } + + /// Consume the writer and return the underlying transport. + pub fn into_inner(self) -> W { + self.writer + } + + /// Write one length-prefixed frame: 4-byte BE length + body + flush. + pub async fn write_frame(&mut self, body: &[u8]) -> Result<(), NegotiationError> { + let len = body.len() as u32; + self.writer.write_all(&len.to_be_bytes()).await?; + if !body.is_empty() { + self.writer.write_all(body).await?; + } + self.writer.flush().await?; + Ok(()) + } +} + +/// Serialize a negotiation error response to JSON bytes. +/// +/// Produces `{"error":"","":"",...}` — the +/// length-prefixed error frame the server sends when it cannot allocate +/// the session (unknown backend, malformed negotiation, allocate failed). +/// The caller writes the result via [`NegotiationWriter::write_frame`]. +/// +/// Error frames MUST be under 16 MiB ([`MAX_CHUNK_LEN`]) so the high byte +/// of the 4-byte length prefix is `0x00` (framing disambiguation — ADR-052 +/// §5). Realistic error responses are tens of bytes; this invariant holds +/// by construction. +pub fn error_response_bytes(error: &str, fields: &[(&str, &str)]) -> serde_json::Result> { + use serde_json::json; + let mut map = serde_json::Map::new(); + map.insert("error".to_string(), json!(error)); + for (k, v) in fields { + map.insert((*k).to_string(), json!(v)); + } + serde_json::to_vec(&map) +} + +#[cfg(test)] +mod tests { + use super::*; + + use tokio::io::duplex; + + #[test] + fn round_trip_negotiate_request_all_fields() { + let json = serde_json::json!({ + "carriage": "raw", + "backend": "local", + "tty": { + "term": "xterm-256color", + "cols": 80, + "rows": 24, + "pixel_width": 0, + "pixel_height": 0, + "modes": {} + }, + "cmd": ["/bin/bash", "-l"], + "cwd": "/tmp", + "env": {"FOO": "bar"}, + "container": "abc123" + }); + let req: NegotiateRequest = serde_json::from_value(json).expect("parse"); + assert_eq!(req.carriage, "raw"); + assert_eq!(req.backend, "local"); + let tty = req.tty.expect("tty"); + assert_eq!(tty.term.as_deref(), Some("xterm-256color")); + assert_eq!(tty.cols, 80); + assert_eq!(tty.rows, 24); + assert_eq!(tty.pixel_width, 0); + assert_eq!(tty.pixel_height, 0); + assert_eq!(tty.modes, serde_json::json!({})); + assert_eq!(req.cmd, vec!["/bin/bash".to_string(), "-l".to_string()]); + assert_eq!(req.cwd.as_deref(), Some(std::path::Path::new("/tmp"))); + assert_eq!(req.env.get("FOO").map(String::as_str), Some("bar")); + assert_eq!( + req.backend_params.get("container").and_then(|v| v.as_str()), + Some("abc123"), + ); + } + + #[test] + fn serde_flatten_captures_backend_specific_fields_into_backend_params() { + let json = serde_json::json!({ + "carriage": "raw", + "backend": "docker", + "cmd": ["bash"], + "container": "abc123", + "image": "ubuntu:22.04", + "remove": true + }); + let req: NegotiateRequest = serde_json::from_value(json).expect("parse"); + assert_eq!(req.backend_params.len(), 3); + assert_eq!( + req.backend_params.get("container").and_then(|v| v.as_str()), + Some("abc123"), + ); + assert_eq!( + req.backend_params.get("image").and_then(|v| v.as_str()), + Some("ubuntu:22.04"), + ); + assert_eq!( + req.backend_params.get("remove").and_then(|v| v.as_bool()), + Some(true), + ); + } + + #[test] + fn defaults_applied_when_optional_fields_absent() { + let json = serde_json::json!({ + "carriage": "raw", + "backend": "local", + "cmd": ["true"] + }); + let req: NegotiateRequest = serde_json::from_value(json).expect("parse"); + assert!(req.tty.is_none()); + assert!(req.cwd.is_none()); + assert!(req.env.is_empty()); + assert!(req.backend_params.is_empty()); + } + + #[test] + fn terminal_params_wire_defaults() { + let json = serde_json::json!({"cols": 80, "rows": 24}); + let tty: TerminalParamsWire = serde_json::from_value(json).expect("parse"); + assert!(tty.term.is_none()); + assert_eq!(tty.pixel_width, 0); + assert_eq!(tty.pixel_height, 0); + assert!(tty.modes.is_null()); + } + + #[test] + fn carriage_not_raw_still_parses_adapter_validates() { + let json = serde_json::json!({ + "carriage": "json", + "backend": "local", + "cmd": ["bash"] + }); + let req: NegotiateRequest = serde_json::from_value(json).expect("parse"); + assert_eq!(req.carriage, "json"); + } + + #[tokio::test] + async fn round_trip_frame_reader_writer() { + let (mut a, mut b) = duplex(8 * 1024); + let body = br#"{"carriage":"raw","backend":"local","cmd":["bash"]}"#; + let mut writer = NegotiationWriter::new(&mut a); + let mut reader = NegotiationReader::new(&mut b); + writer.write_frame(body).await.expect("write"); + let read = reader.read_frame().await.expect("read"); + assert_eq!(read.as_ref(), body); + } + + #[tokio::test] + async fn frame_too_large_on_length_exceeding_max_chunk_len() { + let (mut a, mut b) = duplex(8 * 1024); + let over = MAX_CHUNK_LEN + 1; + a.write_all(&over.to_be_bytes()).await.expect("write len"); + a.flush().await.expect("flush"); + + let mut reader = NegotiationReader::new(&mut b); + let err = reader.read_frame().await.unwrap_err(); + assert!(matches!(err, NegotiationError::FrameTooLarge(v) if v == over)); + } + + #[tokio::test] + async fn connection_closed_on_truncated_length_prefix() { + let (mut a, mut b) = duplex(8 * 1024); + a.write_all(&[0u8, 0]).await.expect("write partial"); + a.flush().await.expect("flush"); + a.shutdown().await.expect("shutdown"); + + let mut reader = NegotiationReader::new(&mut b); + let err = reader.read_frame().await.unwrap_err(); + assert!(matches!(err, NegotiationError::ConnectionClosed)); + } + + #[tokio::test] + async fn connection_closed_on_truncated_body() { + let (mut a, mut b) = duplex(8 * 1024); + a.write_all(&16u32.to_be_bytes()).await.expect("write len"); + a.write_all(b"short").await.expect("write partial body"); + a.flush().await.expect("flush"); + a.shutdown().await.expect("shutdown"); + + let mut reader = NegotiationReader::new(&mut b); + let err = reader.read_frame().await.unwrap_err(); + assert!(matches!(err, NegotiationError::ConnectionClosed)); + } + + #[tokio::test] + async fn connection_closed_clean_close_no_bytes() { + let (mut a, mut b) = duplex(8 * 1024); + a.shutdown().await.expect("shutdown"); + + let mut reader = NegotiationReader::new(&mut b); + let err = reader.read_frame().await.unwrap_err(); + assert!(matches!(err, NegotiationError::ConnectionClosed)); + } + + #[tokio::test] + async fn into_inner_reader_reclaims_stream() { + let (mut a, mut b) = duplex(8 * 1024); + let body = br#"{"carriage":"raw","backend":"local","cmd":["bash"]}"#; + let mut writer = NegotiationWriter::new(&mut a); + let mut reader = NegotiationReader::new(&mut b); + writer.write_frame(body).await.expect("write"); + let read = reader.read_frame().await.expect("read"); + assert_eq!(read.as_ref(), body); + + let reclaimed = reader.into_inner(); + let mut leftover = [0u8; 4]; + a.write_all(b"tail").await.expect("write leftover"); + a.flush().await.expect("flush"); + reclaimed + .read_exact(&mut leftover) + .await + .expect("read leftover"); + assert_eq!(&leftover, b"tail"); + } + + #[tokio::test] + async fn into_inner_writer_reclaims_stream() { + let (mut a, mut b) = duplex(8 * 1024); + let mut writer = NegotiationWriter::new(&mut a); + writer.write_frame(b"x").await.expect("write"); + let reclaimed = writer.into_inner(); + reclaimed.write_all(b"raw").await.expect("write raw"); + reclaimed.flush().await.expect("flush"); + + let mut len = [0u8; 4]; + b.read_exact(&mut len).await.expect("read len"); + assert_eq!(u32::from_be_bytes(len), 1); + let mut body = [0u8; 1]; + b.read_exact(&mut body).await.expect("read body"); + assert_eq!(&body, b"x"); + let mut tail = [0u8; 3]; + b.read_exact(&mut tail).await.expect("read tail"); + assert_eq!(&tail, b"raw"); + } + + #[test] + fn error_response_bytes_shape() { + let bytes = error_response_bytes("unknown_backend", &[("backend", "kubernetes")]) + .expect("serialize"); + let v: serde_json::Value = serde_json::from_slice(&bytes).expect("parse"); + assert_eq!(v["error"], "unknown_backend"); + assert_eq!(v["backend"], "kubernetes"); + } + + #[test] + fn error_response_bytes_no_extra_fields() { + let bytes = error_response_bytes("malformed_negotiation", &[("message", "bad")]) + .expect("serialize"); + let v: serde_json::Value = serde_json::from_slice(&bytes).expect("parse"); + assert_eq!(v["error"], "malformed_negotiation"); + assert_eq!(v["message"], "bad"); + assert_eq!(v.as_object().map(|m| m.len()), Some(2)); + } + + #[tokio::test] + async fn error_frame_first_byte_is_zero_for_framing_disambiguation() { + let (mut a, mut b) = duplex(8 * 1024); + let body = + error_response_bytes("unknown_backend", &[("backend", "kubernetes")]).expect("ser"); + let mut writer = NegotiationWriter::new(&mut a); + writer.write_frame(&body).await.expect("write"); + + let mut first = [0u8; 1]; + b.read_exact(&mut first).await.expect("read first byte"); + assert_eq!(first[0], 0x00); + + let mut len_rest = [0u8; 3]; + b.read_exact(&mut len_rest).await.expect("read len rest"); + let len = u32::from_be_bytes([first[0], len_rest[0], len_rest[1], len_rest[2]]); + let mut buf = vec![0u8; len as usize]; + b.read_exact(&mut buf).await.expect("read body"); + let v: serde_json::Value = serde_json::from_slice(&buf).expect("parse"); + assert_eq!(v["error"], "unknown_backend"); + assert_eq!(v["backend"], "kubernetes"); + } + + #[tokio::test] + async fn write_frame_empty_body_writes_length_zero() { + let (mut a, mut b) = duplex(8 * 1024); + let mut writer = NegotiationWriter::new(&mut a); + writer.write_frame(b"").await.expect("write"); + let mut reader = NegotiationReader::new(&mut b); + let read = reader.read_frame().await.expect("read"); + assert!(read.is_empty()); + } +} diff --git a/src/wire.rs b/src/wire.rs new file mode 100644 index 0000000..9668ef0 --- /dev/null +++ b/src/wire.rs @@ -0,0 +1,435 @@ +//! Raw chunk codec for the `alk/tty` bidi stream (ADR-052, Phase 2 +//! "raw carriage"). +//! +//! Wire format: +//! ```text +//! [stream_type: u8][length: u32 be][payload bytes] +//! ``` +//! +//! `stream_type`: +//! - 0 = stdin (client→server, raw bytes) +//! - 1 = stdout (server→client, raw bytes) +//! - 2 = stderr (server→client, raw bytes) +//! - 3 = ctrl_in (client→server, JSON control message — see [`crate::control`]) +//! - 4 = ctrl_out (server→client, JSON control message — see [`crate::control`]) +//! +//! The control channel is split into two halves so it is genuinely +//! bidirectional on the wire: `STREAM_CTRL_IN = 3` carries client→server +//! control (resize, signal, eof); `STREAM_CTRL_OUT = 4` carries +//! server→client control (exit). The previous single `STREAM_CONTROL = 3` +//! was documented as "bidirectional" but the adapter ignored `Exit` from +//! the client because it had no way to distinguish the two directions on +//! the same stream_type — see the Phase 7 notes in +//! `docs/architecture/tty-wire.md` (the bidirectionality fix that +//! split the single `STREAM_CONTROL = 3` into `STREAM_CTRL_IN` (3) +//! and `STREAM_CTRL_OUT` (4)). +//! +//! Zero-length data chunks are sentinels: a zero-length stdin chunk is EOF +//! from the client; a zero-length stdout chunk is "drained" from the +//! server. Control chunks are never zero-length (the JSON payload is at +//! least `{}`). The codec does not special-case sentinels — they are just +//! chunks with `length == 0`; the adapter interprets them. See +//! `docs/architecture/crates/tty/tty-wire.md` §"Sentinels". + +use std::io; + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +/// stdin channel (client→server, raw bytes). +pub const STREAM_STDIN: u8 = 0; +/// stdout channel (server→client, raw bytes). +pub const STREAM_STDOUT: u8 = 1; +/// stderr channel (server→client, raw bytes). +pub const STREAM_STDERR: u8 = 2; +/// Control channel, client→server half (JSON control message — +/// `Resize`, `Signal`, `Eof`). +pub const STREAM_CTRL_IN: u8 = 3; +/// Control channel, server→client half (JSON control message — `Exit`). +pub const STREAM_CTRL_OUT: u8 = 4; + +/// Chunk header length in bytes: 1 byte `stream_type` + 4 bytes `length`. +pub const CHUNK_HEADER_LEN: usize = 5; +/// Maximum payload length. A larger chunk is a `ChunkTooLarge` protocol +/// error. Shared with the negotiation module so error frames (which reuse +/// the 4-byte length-prefix framing) stay under 16 MiB — this keeps the +/// high byte of the length prefix `0x00`, which is what makes the +/// framing-disambiguation trick sound (see ADR-052 §5). +pub const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024; + +/// Errors from the raw chunk codec. +/// +/// `ConnectionClosed` is returned (rather than `Io`) when `read_chunk` +/// hits a clean `UnexpectedEof` reading either the header or the payload — +/// the peer closed the stream cleanly rather than failing the transport. +#[derive(Debug, thiserror::Error)] +pub enum RawError { + /// Underlying transport I/O error (not a clean EOF). + #[error("io: {0}")] + Io(#[from] io::Error), + /// The peer closed the stream cleanly (unexpected EOF on header or payload). + #[error("connection closed")] + ConnectionClosed, + /// The chunk header's `stream_type` byte was > 4. + #[error("invalid chunk header: stream type {0}")] + InvalidStreamType(u8), + /// The chunk payload length exceeded `MAX_CHUNK_LEN`. + #[error("chunk too large: {0}")] + ChunkTooLarge(u32), +} + +/// A single raw chunk on the wire: a `stream_type` byte's channel and the +/// payload bytes. +/// +/// Construct with [`Chunk::stdin`], [`Chunk::stdout`], [`Chunk::stderr`], +/// [`Chunk::ctrl_in`], or [`Chunk::ctrl_out`] for the five fixed +/// channels. +#[derive(Debug, Clone)] +pub struct Chunk { + /// The channel: one of [`STREAM_STDIN`], [`STREAM_STDOUT`], + /// [`STREAM_STDERR`], [`STREAM_CTRL_IN`], [`STREAM_CTRL_OUT`]. + pub stream_type: u8, + /// The payload bytes (raw for data channels, UTF-8 JSON for control). + pub bytes: bytes::Bytes, +} + +impl Chunk { + /// A stdin chunk (stream_type 0). + pub fn stdin(bytes: bytes::Bytes) -> Self { + Self { + stream_type: STREAM_STDIN, + bytes, + } + } + + /// A stdout chunk (stream_type 1). + pub fn stdout(bytes: bytes::Bytes) -> Self { + Self { + stream_type: STREAM_STDOUT, + bytes, + } + } + + /// A stderr chunk (stream_type 2). + pub fn stderr(bytes: bytes::Bytes) -> Self { + Self { + stream_type: STREAM_STDERR, + bytes, + } + } + + /// A client→server control chunk (stream_type 3) — `Resize`, `Signal`, + /// or `Eof`. + pub fn ctrl_in(bytes: bytes::Bytes) -> Self { + Self { + stream_type: STREAM_CTRL_IN, + bytes, + } + } + + /// A server→client control chunk (stream_type 4) — `Exit`. + pub fn ctrl_out(bytes: bytes::Bytes) -> Self { + Self { + stream_type: STREAM_CTRL_OUT, + bytes, + } + } +} + +/// Reads raw chunks from an [`AsyncRead`] transport. +/// +/// [`ChunkReader::read_chunk`] reads the 5-byte header, validates the +/// `stream_type` (≤ 4, else [`RawError::InvalidStreamType`]) and the +/// payload length (≤ [`MAX_CHUNK_LEN`], else [`RawError::ChunkTooLarge`]), +/// then reads the payload. On a clean `UnexpectedEof` reading either the +/// header or the payload, it returns [`RawError::ConnectionClosed`] — the +/// stream ended cleanly, not with a transport error. +pub struct ChunkReader { + reader: R, + header: [u8; CHUNK_HEADER_LEN], +} + +impl ChunkReader { + /// Wrap an [`AsyncRead`] transport in a chunk reader. + pub fn new(reader: R) -> Self { + Self { + reader, + header: [0u8; CHUNK_HEADER_LEN], + } + } + + /// Consume the reader and return the underlying transport. + pub fn into_inner(self) -> R { + self.reader + } + + /// Read one chunk: header, validate, payload. + pub async fn read_chunk(&mut self) -> Result { + match self.reader.read_exact(&mut self.header).await { + Ok(_) => {} + Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => { + return Err(RawError::ConnectionClosed); + } + Err(e) => return Err(RawError::Io(e)), + } + + let stream_type = self.header[0]; + if stream_type > 4 { + return Err(RawError::InvalidStreamType(stream_type)); + } + + let length = u32::from_be_bytes([ + self.header[1], + self.header[2], + self.header[3], + self.header[4], + ]); + if length > MAX_CHUNK_LEN { + return Err(RawError::ChunkTooLarge(length)); + } + + let mut buf = vec![0u8; length as usize]; + if length > 0 { + match self.reader.read_exact(&mut buf).await { + Ok(_) => {} + Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => { + return Err(RawError::ConnectionClosed); + } + Err(e) => return Err(RawError::Io(e)), + } + } + + Ok(Chunk { + stream_type, + bytes: bytes::Bytes::from(buf), + }) + } +} + +/// Writes raw chunks to an [`AsyncWrite`] transport. +/// +/// [`ChunkWriter::write_chunk`] writes the 5-byte header then the payload +/// (if non-empty), then flushes. [`ChunkWriter::write_stdin`], +/// [`ChunkWriter::write_ctrl_in_json`], and +/// [`ChunkWriter::write_ctrl_out_json`] are convenience helpers for the +/// most common write paths. +pub struct ChunkWriter { + writer: W, +} + +impl ChunkWriter { + /// Wrap an [`AsyncWrite`] transport in a chunk writer. + pub fn new(writer: W) -> Self { + Self { writer } + } + + /// Consume the writer and return the underlying transport. + pub fn into_inner(self) -> W { + self.writer + } + + /// Write a chunk: header + payload (if non-empty) + flush. + pub async fn write_chunk(&mut self, chunk: &Chunk) -> Result<(), RawError> { + let mut header = [0u8; CHUNK_HEADER_LEN]; + header[0] = chunk.stream_type; + let len = chunk.bytes.len() as u32; + header[1..].copy_from_slice(&len.to_be_bytes()); + self.writer.write_all(&header).await?; + if !chunk.bytes.is_empty() { + self.writer.write_all(&chunk.bytes).await?; + } + self.writer.flush().await?; + Ok(()) + } + + /// Write a stdin chunk (stream_type 0) directly from a byte slice. + pub async fn write_stdin(&mut self, bytes: &[u8]) -> Result<(), RawError> { + let mut header = [0u8; CHUNK_HEADER_LEN]; + header[0] = STREAM_STDIN; + let len = bytes.len() as u32; + header[1..].copy_from_slice(&len.to_be_bytes()); + self.writer.write_all(&header).await?; + if !bytes.is_empty() { + self.writer.write_all(bytes).await?; + } + self.writer.flush().await?; + Ok(()) + } + + /// Write a client→server control chunk (stream_type 3) carrying a JSON + /// payload (`Resize`, `Signal`, or `Eof`). + pub async fn write_ctrl_in_json(&mut self, json: &[u8]) -> Result<(), RawError> { + let mut header = [0u8; CHUNK_HEADER_LEN]; + header[0] = STREAM_CTRL_IN; + let len = json.len() as u32; + header[1..].copy_from_slice(&len.to_be_bytes()); + self.writer.write_all(&header).await?; + self.writer.write_all(json).await?; + self.writer.flush().await?; + Ok(()) + } + + /// Write a server→client control chunk (stream_type 4) carrying a JSON + /// payload (`Exit`). + pub async fn write_ctrl_out_json(&mut self, json: &[u8]) -> Result<(), RawError> { + let mut header = [0u8; CHUNK_HEADER_LEN]; + header[0] = STREAM_CTRL_OUT; + let len = json.len() as u32; + header[1..].copy_from_slice(&len.to_be_bytes()); + self.writer.write_all(&header).await?; + self.writer.write_all(json).await?; + self.writer.flush().await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use bytes::Bytes; + use tokio::io::{duplex, AsyncWriteExt}; + + async fn round_trip(stream_type: u8, payload: &[u8]) { + let (mut a, mut b) = duplex(8 * 1024); + let mut writer = ChunkWriter::new(&mut a); + let mut reader = ChunkReader::new(&mut b); + + let chunk = Chunk { + stream_type, + bytes: Bytes::copy_from_slice(payload), + }; + writer.write_chunk(&chunk).await.unwrap(); + + let read = reader.read_chunk().await.unwrap(); + assert_eq!(read.stream_type, stream_type); + assert_eq!(read.bytes.as_ref(), payload); + } + + #[tokio::test] + async fn round_trip_stdin() { + round_trip(STREAM_STDIN, b"hello stdin").await; + } + + #[tokio::test] + async fn round_trip_stdout() { + round_trip(STREAM_STDOUT, b"hello stdout").await; + } + + #[tokio::test] + async fn round_trip_stderr() { + round_trip(STREAM_STDERR, b"hello stderr").await; + } + + #[tokio::test] + async fn round_trip_ctrl_in() { + round_trip(STREAM_CTRL_IN, br#"{"type":"eof"}"#).await; + } + + #[tokio::test] + async fn round_trip_ctrl_out() { + round_trip(STREAM_CTRL_OUT, br#"{"type":"exit","code":0}"#).await; + } + + #[tokio::test] + async fn round_trip_empty_payload() { + round_trip(STREAM_STDIN, b"").await; + } + + #[tokio::test] + async fn round_trip_write_stdin_helper() { + let (mut a, mut b) = duplex(8 * 1024); + let mut writer = ChunkWriter::new(&mut a); + let mut reader = ChunkReader::new(&mut b); + + writer.write_stdin(b"piped").await.unwrap(); + let read = reader.read_chunk().await.unwrap(); + assert_eq!(read.stream_type, STREAM_STDIN); + assert_eq!(read.bytes.as_ref(), b"piped"); + } + + #[tokio::test] + async fn round_trip_write_ctrl_in_json_helper() { + let (mut a, mut b) = duplex(8 * 1024); + let mut writer = ChunkWriter::new(&mut a); + let mut reader = ChunkReader::new(&mut b); + + let json = br#"{"type":"resize","cols":80,"rows":24}"#; + writer.write_ctrl_in_json(json).await.unwrap(); + let read = reader.read_chunk().await.unwrap(); + assert_eq!(read.stream_type, STREAM_CTRL_IN); + assert_eq!(read.bytes.as_ref(), json); + } + + #[tokio::test] + async fn round_trip_write_ctrl_out_json_helper() { + let (mut a, mut b) = duplex(8 * 1024); + let mut writer = ChunkWriter::new(&mut a); + let mut reader = ChunkReader::new(&mut b); + + let json = br#"{"type":"exit","code":0}"#; + writer.write_ctrl_out_json(json).await.unwrap(); + let read = reader.read_chunk().await.unwrap(); + assert_eq!(read.stream_type, STREAM_CTRL_OUT); + assert_eq!(read.bytes.as_ref(), json); + } + + #[tokio::test] + async fn invalid_stream_type() { + let (mut a, mut b) = duplex(8 * 1024); + // 5 is one past the highest valid stream_type (4 = STREAM_CTRL_OUT). + a.write_all(&[5u8, 0, 0, 0, 0]).await.unwrap(); + a.flush().await.unwrap(); + + let mut reader = ChunkReader::new(&mut b); + let err = reader.read_chunk().await.unwrap_err(); + assert!(matches!(err, RawError::InvalidStreamType(5))); + } + + #[tokio::test] + async fn chunk_too_large() { + let (mut a, mut b) = duplex(8 * 1024); + let over = MAX_CHUNK_LEN + 1; + a.write_all(&[0u8]).await.unwrap(); + a.write_all(&over.to_be_bytes()).await.unwrap(); + a.flush().await.unwrap(); + + let mut reader = ChunkReader::new(&mut b); + let err = reader.read_chunk().await.unwrap_err(); + assert!(matches!(err, RawError::ChunkTooLarge(v) if v == over)); + } + + #[tokio::test] + async fn connection_closed_truncated_header() { + let (mut a, mut b) = duplex(8 * 1024); + a.write_all(&[0u8, 0]).await.unwrap(); + a.flush().await.unwrap(); + a.shutdown().await.unwrap(); + + let mut reader = ChunkReader::new(&mut b); + let err = reader.read_chunk().await.unwrap_err(); + assert!(matches!(err, RawError::ConnectionClosed)); + } + + #[tokio::test] + async fn connection_closed_truncated_payload() { + let (mut a, mut b) = duplex(8 * 1024); + a.write_all(&[0u8, 0, 0, 0, 8]).await.unwrap(); + a.write_all(b"short").await.unwrap(); + a.flush().await.unwrap(); + a.shutdown().await.unwrap(); + + let mut reader = ChunkReader::new(&mut b); + let err = reader.read_chunk().await.unwrap_err(); + assert!(matches!(err, RawError::ConnectionClosed)); + } + + #[tokio::test] + async fn connection_closed_clean_close_no_bytes() { + let (mut a, mut b) = duplex(8 * 1024); + a.shutdown().await.unwrap(); + + let mut reader = ChunkReader::new(&mut b); + let err = reader.read_chunk().await.unwrap_err(); + assert!(matches!(err, RawError::ConnectionClosed)); + } +}