From 4bc7a196954b485b8c2a958a559305dd651cb860 Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Wed, 12 Aug 2026 05:52:18 +0000 Subject: [PATCH] feat: extract alknet-call into alkcall, vendor core types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the alknet-call + alknet-channels unification. The call crate is extracted verbatim from /workspace/@alkdev/alknet/crates/alknet-call and the needed alknet-core types are vendored into src/core/ — alkcall is the home for these types going forward (no separate alkcore crate). Vendored core types (src/core/): - auth.rs: Identity, AuthToken, AuthContext, IdentityProvider - ownership.rs: OwnershipProvider, OwnershipStore, InMemoryOwnershipStore, OwnershipError - types.rs: ProtocolHandler, Connection, BiStream, BidiStreamSource, SendStream, RecvStream, HandlerError, StreamError, Capabilities, Secret, IdentityAlreadySet - Stripped: quinn/iroh/rustls deps, Connection::from_quinn/from_iroh (the dial lives in the consumer per ADR-089), config/credentials/ fingerprint/store modules, ConfigIdentityProvider/IdentityStore Call crate (src/client, src/protocol, src/registry/): - Copied verbatim from alknet-call; alknet_core::{auth,types,ownership} rebound to crate::core::{auth,types,ownership} - ALPN strings unchanged (alknet/call — wire-stable per ADR-006) - No behavioral changes Verification: - cargo test: 343 passed (0 failed) - cargo clippy --all-targets -- -D warnings: clean - cargo fmt --check: clean - cargo doc --no-deps: 1 pre-existing broken intra-doc link (CallAdapter in dispatch.rs — Phase 2 cleanup) Next: Phase 2 — channels greenfield implementation + ALPN rename + architecture doc porting. --- .gitignore | 3 + Cargo.lock | 1269 ++++++++++++++++++++++++++ Cargo.toml | 31 + src/client/call_client.rs | 197 ++++ src/client/from_call.rs | 1003 +++++++++++++++++++++ src/client/mod.rs | 107 +++ src/core/auth.rs | 101 +++ src/core/mod.rs | 20 + src/core/ownership.rs | 276 ++++++ src/core/types.rs | 943 ++++++++++++++++++++ src/lib.rs | 25 + src/protocol/abort.rs | 393 ++++++++ src/protocol/adapter.rs | 1236 +++++++++++++++++++++++++ src/protocol/connection.rs | 1024 +++++++++++++++++++++ src/protocol/dispatch.rs | 1363 ++++++++++++++++++++++++++++ src/protocol/mod.rs | 18 + src/protocol/pending.rs | 584 ++++++++++++ src/protocol/test_support.rs | 66 ++ src/protocol/wire.rs | 548 ++++++++++++ src/registry/context.rs | 313 +++++++ src/registry/discovery.rs | 1002 +++++++++++++++++++++ src/registry/env.rs | 1325 +++++++++++++++++++++++++++ src/registry/mod.rs | 12 + src/registry/registration.rs | 1634 ++++++++++++++++++++++++++++++++++ src/registry/spec.rs | 530 +++++++++++ 25 files changed, 14023 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/client/call_client.rs create mode 100644 src/client/from_call.rs create mode 100644 src/client/mod.rs create mode 100644 src/core/auth.rs create mode 100644 src/core/mod.rs create mode 100644 src/core/ownership.rs create mode 100644 src/core/types.rs create mode 100644 src/lib.rs create mode 100644 src/protocol/abort.rs create mode 100644 src/protocol/adapter.rs create mode 100644 src/protocol/connection.rs create mode 100644 src/protocol/dispatch.rs create mode 100644 src/protocol/mod.rs create mode 100644 src/protocol/pending.rs create mode 100644 src/protocol/test_support.rs create mode 100644 src/protocol/wire.rs create mode 100644 src/registry/context.rs create mode 100644 src/registry/discovery.rs create mode 100644 src/registry/env.rs create mode 100644 src/registry/mod.rs create mode 100644 src/registry/registration.rs create mode 100644 src/registry/spec.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed2d55c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target/ +node_modules/ +.worktrees/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3ea8b0c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1269 @@ +# 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.0" +dependencies = [ + "alktype", + "async-trait", + "futures", + "parking_lot", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "uuid", + "zeroize", +] + +[[package]] +name = "alktype" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a102e5ec20cc0209b7c72d4abaa2c9b4f44b72a4cee2706be98d19125c2318dc" +dependencies = [ + "jsonschema", + "serde_json", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[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 = "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 = "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 = "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 = "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", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +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 = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[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 = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "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 = "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 = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +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", +] + +[[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 0.16.1", + "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 = "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 = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +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 = "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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[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 = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ba63260 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "alkcall" +version = "0.1.0" +edition = "2021" +rust-version = "1.85" +license = "MIT OR Apache-2.0" +description = "Call + channels RPC: structured JSON operations, streaming subscriptions, service discovery, and N-channel multiplexing over one transport stream" +repository = "https://git.alk.dev/alkdev/alkcall" +readme = "README.md" +keywords = ["rpc", "json-rpc", "multiplexing", "wire-format", "alpn"] +categories = ["network-programming", "asynchronous", "encoding"] +exclude = [".opencode/", "docs/reviews/", "docs/research/", "docs/sdd_process.md", "Cargo.lock"] + +[lib] +name = "alkcall" + +[features] +default = [] + +[dependencies] +alktype = "0.1.0" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +async-trait = "0.1" +tracing = "0.1" +thiserror = "2" +uuid = { version = "1", features = ["v4"] } +futures = "0.3" +parking_lot = "0.12" +zeroize = { version = "1", features = ["alloc", "derive"] } \ No newline at end of file diff --git a/src/client/call_client.rs b/src/client/call_client.rs new file mode 100644 index 0000000..a005575 --- /dev/null +++ b/src/client/call_client.rs @@ -0,0 +1,197 @@ +//! `CallClient`: the outbound connection opener (ADR-017 §1). +//! +//! Runs the shared dispatch loop over a pre-established `Connection` +//! (delegated to [`crate::protocol::dispatch::Dispatcher`]). +//! `CallClient` is the connection-establishment half; `CallAdapter`'s accept +//! path is the inbound half; both produce a `CallConnection` and hand it to +//! the same `Dispatcher::run_loop` (ADR-017 §1). +//! +//! After establishment the connection is symmetric (ADR-017 §2): both sides +//! can send and receive `call.requested`. The `CallClient` is both a caller +//! (initiates outgoing calls via `CallConnection::call()`/`subscribe()`/ +//! `abort()`) and a callee (dispatches incoming calls against its registry). +//! +//! Transport-level connection establishment (QUIC dial, TCP+TLS, iroh) is +//! handled by `alknet-client`; `CallClient::spawn_dispatch` takes a +//! pre-established `Connection` and runs the call protocol over it. +//! +//! See `docs/architecture/crates/call/client-and-adapters.md` for the spec. + +use std::sync::Arc; + +use crate::core::auth::IdentityProvider; +use crate::core::types::Connection; + +use crate::protocol::connection::CallConnection; +use crate::protocol::dispatch::Dispatcher; +use crate::registry::registration::OperationRegistry; + +/// Outbound `alknet/call` connection opener (the #1 gap, ADR-017 §1). +/// +/// Peer authorization flows through the existing `AccessControl::check` gate +/// in `OperationRegistry::invoke` (ADR-029 §3) — no parallel `remote_safe`/ +/// `trusted_peer` gate. +pub struct CallClient { + registry: Arc, + identity_provider: Arc, +} + +impl CallClient { + pub fn new( + registry: Arc, + identity_provider: Arc, + ) -> Self { + Self { + registry, + identity_provider, + } + } + + pub fn registry(&self) -> &Arc { + &self.registry + } + + pub fn identity_provider(&self) -> &Arc { + &self.identity_provider + } + + /// Run the shared dispatch loop over a pre-established `Connection`. The + /// `CallClient` spawns the dispatcher task and returns a live + /// `CallConnection` the caller can use immediately. Used by the assembly + /// layer after `AlknetClient::dial_*` + `spawn_dispatch` and by + /// integration tests that wire a mock/loopback `Connection` directly. + pub fn spawn_dispatch(&self, connection: Connection) -> CallConnection { + let call_connection = Arc::new(CallConnection::new(connection)); + let dispatcher = Dispatcher::new( + Arc::clone(&self.registry), + Arc::clone(&self.identity_provider), + ); + let run_conn = Arc::clone(&call_connection); + tokio::spawn(async move { + dispatcher.run_loop(run_conn).await; + }); + (*call_connection).clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::auth::Identity; + use crate::core::types::Capabilities; + use crate::protocol::connection::CallConnection; + use crate::protocol::wire::ResponseEnvelope; + use crate::registry::registration::{ + make_handler, Handler, HandlerKind, HandlerRegistration, OperationProvenance, + }; + use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility}; + + use crate::protocol::sink_empty_connection as stub_connection; + + fn external_spec(name: &str) -> OperationSpec { + OperationSpec::new( + name, + OperationType::Query, + Visibility::External, + serde_json::json!({}), + serde_json::json!({}), + vec![], + AccessControl::default(), + None, + ) + } + + fn caps_inspect_handler() -> Handler { + make_handler(|_input, context| async move { + let has_google = context.capabilities.get("google").is_some(); + ResponseEnvelope::ok( + context.request_id, + serde_json::json!({ "has_google_capability": has_google }), + ) + }) + } + + struct NoopIdentityProvider; + impl crate::core::auth::IdentityProvider for NoopIdentityProvider { + fn resolve_from_fingerprint(&self, _fp: &str) -> Option { + None + } + fn resolve_from_token(&self, _token: &crate::core::auth::AuthToken) -> Option { + None + } + } + + fn registry_with_caps() -> Arc { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + external_spec("pub/run"), + HandlerKind::Once(caps_inspect_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new().with_api_key("google", "pub-key".to_string()), + )) + .unwrap(); + Arc::new(registry) + } + + fn dispatcher(registry: &Arc) -> Dispatcher { + Dispatcher::new(Arc::clone(registry), Arc::new(NoopIdentityProvider)) + } + + async fn dispatch(d: &Dispatcher, conn: &Arc, op: &str) -> ResponseEnvelope { + d.dispatch_requested( + conn, + "req-test".to_string(), + serde_json::json!({ "operationId": op, "input": {} }), + ) + .await + } + + #[tokio::test] + async fn external_op_dispatches_and_populates_capabilities() { + let registry = registry_with_caps(); + let d = dispatcher(®istry); + let conn = Arc::new(CallConnection::new(stub_connection())); + let response = dispatch(&d, &conn, "pub/run").await; + let out = response.result.expect("ok"); + assert_eq!( + out["has_google_capability"], + serde_json::json!(true), + "an External op's call must populate capabilities for the handler" + ); + } + + #[tokio::test] + async fn unknown_op_returns_not_found() { + let registry = Arc::new(OperationRegistry::new()); + let d = dispatcher(®istry); + let conn = Arc::new(CallConnection::new(stub_connection())); + let response = dispatch(&d, &conn, "no/such").await; + match response.result { + Err(e) => assert_eq!(e.code, "NOT_FOUND"), + other => panic!("expected NOT_FOUND, got {other:?}"), + } + } + + #[tokio::test] + async fn spawn_dispatch_returns_live_call_connection() { + let registry = registry_with_caps(); + let client = CallClient::new(Arc::clone(®istry), Arc::new(NoopIdentityProvider)); + let conn = client.spawn_dispatch(stub_connection()); + assert_eq!( + conn.connection() + .expect("quic connection present") + .remote_alpn(), + b"alknet/call" + ); + std::mem::drop(conn); + } + + #[test] + fn call_client_is_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } +} diff --git a/src/client/from_call.rs b/src/client/from_call.rs new file mode 100644 index 0000000..4d294df --- /dev/null +++ b/src/client/from_call.rs @@ -0,0 +1,1003 @@ +//! `from_call` adapter (ADR-017 §3): discovers the remote peer's `External` +//! operations via `services/list` + `services/schema` and registers them in +//! the connection's Layer 2 overlay as `FromCall`-provenance leaves with +//! forwarding handlers. +//! +//! The discovery mechanism (`services/list` + `services/schema`) is already +//! implemented in `registry/discovery.rs`; `from_call` is the client-side +//! consumer of that API. +//! +//! See `docs/architecture/crates/call/client-and-adapters.md` §from_call for +//! the spec and the v1 defaults (auto-on-reconnect, error-on-collision). + +use std::collections::HashSet; +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::client::AdapterError; +use crate::core::types::Capabilities; +use crate::protocol::connection::CallConnection; +use crate::protocol::wire::ResponseEnvelope; +use crate::registry::context::OperationContext; +use crate::registry::registration::{ + Handler, HandlerKind, HandlerRegistration, OperationProvenance, StreamingHandler, +}; +use crate::registry::spec::{ + AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility, +}; + +/// Configuration for [`from_call`]. +/// +/// Under the peer-keyed overlay model (ADR-029 §5), cross-peer collision +/// dissolves — same name on different peers lives in separate sub-overlays. +/// Same-peer collision stays an error (`AdapterError::SamePeerCollision`): +/// a peer shouldn't expose two ops with the same name. +#[derive(Debug, Clone, Default)] +pub struct FromCallConfig { + /// Optional namespace prefix applied to imported operation names. This is + /// local-naming sugar for when the importing node wants to expose a peer's + /// ops under a different name *locally* — not a disambiguation mechanism + /// (cross-peer collision dissolves under the peer-keyed model, ADR-029 + /// §5). Defaults to `None`. + pub namespace_prefix: Option, + /// Optional filter — import only operations whose names match. `None` + /// imports all `External` ops discovered via `services/list`. + pub operation_filter: Option>, +} + +impl FromCallConfig { + pub fn new() -> Self { + Self::default() + } + + pub fn with_namespace_prefix(mut self, prefix: impl Into) -> Self { + self.namespace_prefix = Some(prefix.into()); + self + } + + pub fn with_operation_filter(mut self, filter: HashSet) -> Self { + self.operation_filter = Some(filter); + self + } +} + +/// Discover the remote peer's `External` ops via `services/list` + +/// `services/schema` and construct `HandlerRegistration` bundles with +/// `FromCall` provenance and forwarding handlers. The caller registers the +/// bundles in the connection's overlay via +/// `CallConnection::register_imported_all()` — this is the peer-keyed +/// registration model (ADR-029 §5): the connection's overlay is the peer's +/// sub-overlay, aggregated into `PeerCompositeEnv` by `PeerId`. +/// +/// v1 defaults (two-way doors recorded in `client-and-adapters.md`): +/// - auto-on-reconnect: the overlay is per-connection (Layer 2, ADR-024), so +/// re-import on reconnect is naturally scoped; the assembly layer calls +/// `from_call` immediately after `AlknetClient::dial_*` + `spawn_dispatch`. +/// - same-peer collision = error: two ops with the same name from the same +/// peer (after applying the optional prefix) → `AdapterError::SamePeerCollision`. +/// Cross-peer collision dissolves (ADR-029 §5). +pub async fn from_call( + connection: &CallConnection, + config: FromCallConfig, +) -> Result, AdapterError> { + let discovered = discover_operations(connection).await?; + build_bundles( + discovered, + &config.namespace_prefix, + &config.operation_filter, + ) +} + +/// Pure bundle construction extracted from [`from_call`] for testability — +/// the discovery round-trip against a live `CallConnection` is exercised by +/// integration tests; the collision rule and the forwarded_for-populating +/// handler are unit-tested here. The `peer_id` parameter records which peer's +/// sub-overlay these bundles target (ADR-029 §5); it is metadata on the +/// bundles' forwarding handlers, not used for collision detection (collision +/// is same-peer only and is checked within this set). +fn build_bundles( + discovered: Vec, + namespace_prefix: &Option, + operation_filter: &Option>, +) -> Result, AdapterError> { + let mut bundles = Vec::with_capacity(discovered.len()); + let mut seen_names = HashSet::new(); + + for op_summary in discovered { + let remote_name = op_summary.name; + if let Some(filter) = operation_filter { + if !filter.contains(&remote_name) { + continue; + } + } + + let spec = rebuild_spec_for(&op_summary.schema, &remote_name, namespace_prefix)?; + + if !seen_names.insert(spec.name.clone()) { + return Err(AdapterError::SamePeerCollision { + message: format!( + "same-peer collision on import: {} (peer exposes two ops with the same name after prefix)", + spec.name + ), + }); + } + + let kind = match spec.op_type { + OperationType::Subscription => HandlerKind::Stream(make_streaming_forwarding_handler( + Arc::new(op_summary.connection.clone()), + remote_name, + )), + OperationType::Query | OperationType::Mutation => HandlerKind::Once( + make_forwarding_handler(Arc::new(op_summary.connection.clone()), remote_name), + ), + }; + bundles.push(HandlerRegistration::new( + spec, + kind, + OperationProvenance::FromCall, + None, + None, + Capabilities::new(), + )); + } + + Ok(bundles) +} + +#[derive(Clone)] +struct OpSummary { + name: String, + schema: Value, + connection: CallConnection, +} + +async fn discover_operations(connection: &CallConnection) -> Result, AdapterError> { + let response = connection.call("services/list", json!({})).await; + let output = response.result.map_err(|e| AdapterError::DiscoveryFailed { + message: format!("services/list failed: {} ({})", e.code, e.message), + })?; + let ops = output + .get("operations") + .and_then(|v| v.as_array()) + .ok_or_else(|| AdapterError::SchemaParse { + message: "services/list response missing 'operations' array".to_string(), + })?; + let mut summaries = Vec::with_capacity(ops.len()); + for op in ops { + let name = + op.get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| AdapterError::SchemaParse { + message: "services/list entry missing 'name'".to_string(), + })?; + let schema = fetch_schema(connection, name).await?; + summaries.push(OpSummary { + name: name.to_string(), + schema, + connection: connection.clone(), + }); + } + Ok(summaries) +} + +async fn fetch_schema(connection: &CallConnection, name: &str) -> Result { + let response = connection + .call("services/schema", json!({ "name": name })) + .await; + response.result.map_err(|e| AdapterError::DiscoveryFailed { + message: format!( + "services/schema for {name} failed: {} ({})", + e.code, e.message + ), + }) +} + +/// Rebuild an `OperationSpec` from the `services/schema` JSON, applying the +/// optional namespace prefix. The spec JSON shape matches `spec_to_json` in +/// `registry/discovery.rs`. +fn rebuild_spec_for( + schema_json: &Value, + remote_name: &str, + namespace_prefix: &Option, +) -> Result { + let op_type = parse_op_type( + schema_json + .get("op_type") + .and_then(|v| v.as_str()) + .ok_or_else(|| AdapterError::SchemaParse { + message: format!("schema for {remote_name} missing op_type"), + })?, + )?; + let visibility = parse_visibility( + schema_json + .get("visibility") + .and_then(|v| v.as_str()) + .unwrap_or("external"), + ); + let input_schema = schema_json + .get("input_schema") + .cloned() + .unwrap_or(Value::Null); + let output_schema = schema_json + .get("output_schema") + .cloned() + .unwrap_or(Value::Null); + let error_schemas = schema_json + .get("error_schemas") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(parse_error_definition).collect()) + .unwrap_or_default(); + let access_control = schema_json + .get("access_control") + .map(parse_access_control) + .unwrap_or_default(); + + let name = match namespace_prefix { + Some(prefix) if !prefix.is_empty() => format!("{prefix}/{remote_name}"), + _ => remote_name.to_string(), + }; + + Ok(OperationSpec::new( + name, + op_type, + visibility, + input_schema, + output_schema, + error_schemas, + access_control, + None, + )) +} + +fn parse_op_type(s: &str) -> Result { + match s { + "query" => Ok(OperationType::Query), + "mutation" => Ok(OperationType::Mutation), + "subscription" => Ok(OperationType::Subscription), + other => Err(AdapterError::SchemaParse { + message: format!("unknown op_type: {other}"), + }), + } +} + +fn parse_visibility(s: &str) -> Visibility { + match s { + "internal" => Visibility::Internal, + _ => Visibility::External, + } +} + +fn parse_error_definition(v: &Value) -> Option { + Some(ErrorDefinition { + code: v.get("code")?.as_str()?.to_string(), + description: v + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + schema: v.get("schema").cloned().unwrap_or(Value::Null), + http_status: v + .get("http_status") + .and_then(|v| v.as_u64()) + .map(|n| n as u16), + }) +} + +fn parse_access_control(v: &Value) -> AccessControl { + AccessControl { + required_scopes: v + .get("required_scopes") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|s| s.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(), + required_scopes_any: v + .get("required_scopes_any") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|s| s.as_str().map(String::from)) + .collect() + }), + resource_type: v + .get("resource_type") + .and_then(|v| v.as_str()) + .map(String::from), + resource_action: v + .get("resource_action") + .and_then(|v| v.as_str()) + .map(String::from), + } +} + +/// Construct a forwarding handler for a `FromCall` `Query`/`Mutation` leaf: +/// on invocation, calls the remote op via the `CallConnection` and returns +/// its `ResponseEnvelope` (single `call_with_payload()`, `HandlerKind::Once`). +/// `Subscription` ops use [`make_streaming_forwarding_handler`] instead. +/// +/// Per ADR-032 §3, the handler populates `forwarded_for` on the +/// `call.requested` payload from the hub's `OperationContext.identity` (the +/// end user the hub authenticated). The hub authenticates as itself when +/// forwarding. The spoke authorizes the hub (its direct caller); +/// `forwarded_for` is metadata, never read by `AccessControl::check`. +/// +/// If `context.identity` is `None` (the hub chose not to disclose, or has not +/// authenticated an originator), `forwarded_for` is omitted — the spoke +/// receives only the hub's identity. +fn make_forwarding_handler(connection: Arc, remote_name: String) -> Handler { + use crate::registry::registration::make_handler; + make_handler(move |input, context| { + let connection = Arc::clone(&connection); + let remote_name = remote_name.clone(); + async move { + let payload = build_forwarded_payload(&remote_name, input, &context); + // The forwarding handler invokes the remote op via the + // CallConnection. The parent_request_id participates in the abort + // cascade (ADR-016 §6): if the parent is aborted, the cascade + // reaches this handler, which sends call.aborted to the remote + // node; the remote node cascades to its own descendants. + // Cross-node abort is transparent. + let response = connection.call_with_payload(payload).await; + ResponseEnvelope { + request_id: context.request_id, + result: response.result, + } + } + }) +} + +/// Construct a streaming forwarding handler for a `FromCall` `Subscription` +/// leaf: on invocation, calls `CallConnection::subscribe_with_payload()` and +/// forwards the remote stream end-to-end. Each `call.responded` from the +/// remote becomes a stream item, `call.completed` ends the stream, and +/// `call.aborted` drops it (ADR-049 §8). No truncation, no first-value +/// fallback. +/// +/// `forwarded_for` is populated from `context.identity` (ADR-032 §3), exactly +/// as the request/response forwarding handler does — both via +/// `build_forwarded_payload` (no new payload-construction code). The +/// `subscribe_with_payload` path registers the request in +/// `PendingRequestMap`, so the abort cascade (ADR-016 §6) is already wired: +/// a parent abort drops the `SubscriptionStream`, which sends `call.aborted` +/// to the remote node. +fn make_streaming_forwarding_handler( + connection: Arc, + remote_name: String, +) -> StreamingHandler { + use crate::registry::registration::make_streaming_handler; + use futures::stream::{once, StreamExt}; + make_streaming_handler(move |input, context| { + let connection = Arc::clone(&connection); + let remote_name = remote_name.clone(); + once(async move { + let payload = build_forwarded_payload(&remote_name, input, &context); + connection.subscribe_with_payload(payload).await + }) + .flatten() + }) +} + +/// Build the `call.requested` payload for a forwarded call, populating +/// `forwarded_for` from the hub's `OperationContext.identity` (ADR-032 §3). +/// `forwarded_for` is omitted when `context.identity` is `None` (the hub +/// chooses not to disclose the originator). +fn build_forwarded_payload(operation_id: &str, input: Value, context: &OperationContext) -> Value { + let mut payload = serde_json::Map::new(); + payload.insert( + "operationId".to_string(), + Value::String(operation_id.to_string()), + ); + payload.insert("input".to_string(), input); + if let Some(originator) = &context.identity { + if let Ok(value) = serde_json::to_value(originator) { + payload.insert("forwarded_for".to_string(), value); + } + } + Value::Object(payload) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::auth::Identity; + use crate::core::types::Capabilities; + use crate::protocol::connection::CallConnection; + use crate::registry::registration::{make_handler, make_streaming_handler}; + use crate::registry::spec::OperationType; + use std::collections::HashMap; + use std::sync::Mutex as StdMutex; + + use crate::protocol::sink_empty_connection as stub_connection; + + fn sample_schema_json(name: &str, op_type: &str) -> Value { + json!({ + "name": name, + "namespace": name.split('/').next().unwrap_or(""), + "op_type": op_type, + "visibility": "external", + "input_schema": {"type": "object"}, + "output_schema": {"type": "string"}, + "error_schemas": [], + "access_control": {"required_scopes": []}, + }) + } + + #[test] + fn rebuild_spec_no_prefix_preserves_name() { + let schema = sample_schema_json("fs/readFile", "query"); + let spec = rebuild_spec_for(&schema, "fs/readFile", &None).expect("rebuild"); + assert_eq!(spec.name, "fs/readFile"); + assert_eq!(spec.op_type, OperationType::Query); + assert_eq!(spec.visibility, Visibility::External); + } + + #[test] + fn rebuild_spec_with_prefix_applies_prefix() { + let schema = sample_schema_json("fs/readFile", "query"); + let spec = + rebuild_spec_for(&schema, "fs/readFile", &Some("worker".to_string())).expect("rebuild"); + assert_eq!(spec.name, "worker/fs/readFile"); + } + + #[test] + fn rebuild_spec_unknown_op_type_returns_schema_parse() { + let schema = sample_schema_json("fs/readFile", "weird"); + match rebuild_spec_for(&schema, "fs/readFile", &None) { + Err(AdapterError::SchemaParse { .. }) => {} + other => panic!("expected SchemaParse, got {other:?}"), + } + } + + #[test] + fn rebuild_spec_missing_op_type_returns_schema_parse() { + let schema = json!({"name": "fs/readFile"}); + match rebuild_spec_for(&schema, "fs/readFile", &None) { + Err(AdapterError::SchemaParse { .. }) => {} + other => panic!("expected SchemaParse, got {other:?}"), + } + } + + #[test] + fn rebuild_spec_parses_error_schemas_and_acl() { + let schema = json!({ + "name": "fs/readFileErr", + "namespace": "fs", + "op_type": "query", + "visibility": "external", + "input_schema": {}, + "output_schema": {}, + "error_schemas": [{ + "code": "FILE_NOT_FOUND", + "description": "file not found", + "schema": {"type": "object"}, + "http_status": 404, + }], + "access_control": { + "required_scopes": ["fs:read"], + "required_scopes_any": null, + "resource_type": "fs", + "resource_action": "read", + }, + }); + let spec = rebuild_spec_for(&schema, "fs/readFileErr", &None).expect("rebuild"); + assert_eq!(spec.error_schemas.len(), 1); + assert_eq!(spec.error_schemas[0].code, "FILE_NOT_FOUND"); + assert_eq!(spec.error_schemas[0].http_status, Some(404)); + assert_eq!( + spec.access_control.required_scopes, + vec!["fs:read".to_string()] + ); + assert_eq!(spec.access_control.resource_type.as_deref(), Some("fs")); + } + + #[test] + fn from_call_config_builder_methods() { + let config = FromCallConfig::new() + .with_namespace_prefix("worker") + .with_operation_filter(HashSet::from(["fs/readFile".to_string()])); + assert_eq!(config.namespace_prefix.as_deref(), Some("worker")); + assert!(config.operation_filter.unwrap().contains("fs/readFile")); + } + + /// `from_call` against a stub `CallConnection` (no real transport) returns + /// a `DiscoveryFailed` because `services/list` can't dispatch on a mock + /// connection. This verifies the error path rather than the happy path + /// (the happy path is covered by the integration test in a later task). + #[tokio::test] + async fn from_call_against_mock_connection_returns_discovery_failed() { + let conn = CallConnection::new(stub_connection()); + let result = from_call(&conn, FromCallConfig::new()).await; + match result { + Err(AdapterError::DiscoveryFailed { .. }) => {} + Err(other) => panic!("expected DiscoveryFailed, got another error variant: {other}"), + Ok(_) => panic!("expected DiscoveryFailed on mock connection, got Ok"), + } + } + + #[test] + fn from_call_provenance_is_from_call_and_leaf_fields() { + // Verify the registration shape produced by from_call: provenance + // FromCall, no composition authority, no scoped_env, empty caps. + // Uses a synthetic spec to avoid the transport round-trip. + let spec = OperationSpec::new( + "worker/echo", + OperationType::Query, + Visibility::External, + json!({}), + json!({}), + vec![], + AccessControl::default(), + None, + ); + let handler = make_forwarding_handler( + Arc::new(CallConnection::new(stub_connection())), + "worker/echo".to_string(), + ); + let reg = HandlerRegistration::new( + spec, + HandlerKind::Once(handler), + OperationProvenance::FromCall, + None, + None, + Capabilities::new(), + ); + assert_eq!(reg.provenance, OperationProvenance::FromCall); + assert!(reg.composition_authority.is_none()); + assert!(reg.scoped_env.is_none()); + } + + // --- ADR-032: forwarded_for population -------------------------------- + + struct NoopEnv; + #[async_trait::async_trait] + impl crate::registry::env::OperationEnv for NoopEnv { + async fn invoke_with_policy( + &self, + _ns: &str, + _op: &str, + _input: Value, + parent: &OperationContext, + _policy: crate::registry::context::AbortPolicy, + ) -> ResponseEnvelope { + ResponseEnvelope::ok(parent.request_id.clone(), Value::Null) + } + fn contains(&self, _name: &str) -> bool { + false + } + } + + fn test_context(identity: Option) -> OperationContext { + use crate::registry::context::{AbortPolicy, ScopedPeerEnv}; + use std::collections::HashMap; + use std::time::{Duration, Instant}; + OperationContext { + request_id: "req-test".to_string(), + parent_request_id: None, + identity, + handler_identity: None, + forwarded_for: None, + capabilities: Capabilities::new(), + metadata: HashMap::new(), + scoped_env: ScopedPeerEnv::empty(), + env: Arc::new(NoopEnv), + abort_policy: AbortPolicy::default(), + deadline: Some(Instant::now() + Duration::from_secs(30)), + internal: false, + ownership: None, + } + } + + fn alice_identity() -> Identity { + Identity { + id: "alice".to_string(), + scopes: vec!["fs:read".to_string()], + resources: HashMap::new(), + } + } + + #[test] + fn build_forwarded_payload_populates_forwarded_for_from_context_identity() { + let ctx = test_context(Some(alice_identity())); + let payload = build_forwarded_payload("fs/readFile", json!({"p": 1}), &ctx); + assert_eq!(payload["operationId"], "fs/readFile"); + assert_eq!(payload["input"], json!({"p": 1})); + let forwarded_for = payload.get("forwarded_for").expect("forwarded_for present"); + assert_eq!(forwarded_for["id"], "alice"); + assert_eq!(forwarded_for["scopes"][0], "fs:read"); + } + + #[test] + fn build_forwarded_payload_omits_forwarded_for_when_context_identity_is_none() { + let ctx = test_context(None); + let payload = build_forwarded_payload("fs/readFile", json!({}), &ctx); + assert!(payload.get("forwarded_for").is_none()); + assert_eq!(payload["operationId"], "fs/readFile"); + } + + /// Verify the forwarding handler actually populates `forwarded_for` on + /// the wire payload it sends. We intercept the payload by using a handler + /// that records the payload passed to `call_with_payload`. Since + /// `call_with_payload` on a mock connection returns an error envelope + /// (no transport), we instead test the payload-construction function + /// directly (above) and rely on the handler wiring to call + /// `call_with_payload(payload)`. The handler's contract is: read + /// `context.identity`, build the payload, call. The payload-construction + /// function is the unit under test. + #[tokio::test] + async fn forwarding_handler_populates_forwarded_for_from_context_identity() { + let conn = Arc::new(CallConnection::new(stub_connection())); + let captured_payload = Arc::new(StdMutex::new(None::)); + let captured = Arc::clone(&captured_payload); + + let handler: Handler = { + let conn = Arc::clone(&conn); + make_handler(move |input, context| { + let conn = Arc::clone(&conn); + let captured = Arc::clone(&captured); + let remote_name = "fs/readFile".to_string(); + async move { + let payload = build_forwarded_payload(&remote_name, input, &context); + *captured.lock().unwrap() = Some(payload.clone()); + let response = conn.call_with_payload(payload).await; + ResponseEnvelope { + request_id: context.request_id, + result: response.result, + } + } + }) + }; + + let ctx = test_context(Some(alice_identity())); + let _ = handler(json!({}), ctx).await; + let payload = captured_payload.lock().unwrap().clone().expect("captured"); + assert_eq!(payload["forwarded_for"]["id"], "alice"); + assert_eq!(payload["operationId"], "fs/readFile"); + } + + #[tokio::test] + async fn forwarding_handler_omits_forwarded_for_when_context_identity_is_none() { + let conn = Arc::new(CallConnection::new(stub_connection())); + let captured_payload = Arc::new(StdMutex::new(None::)); + let captured = Arc::clone(&captured_payload); + + let handler: Handler = { + let conn = Arc::clone(&conn); + make_handler(move |input, context| { + let conn = Arc::clone(&conn); + let captured = Arc::clone(&captured); + let remote_name = "fs/readFile".to_string(); + async move { + let payload = build_forwarded_payload(&remote_name, input, &context); + *captured.lock().unwrap() = Some(payload.clone()); + let response = conn.call_with_payload(payload).await; + ResponseEnvelope { + request_id: context.request_id, + result: response.result, + } + } + }) + }; + + let ctx = test_context(None); + let _ = handler(json!({}), ctx).await; + let payload = captured_payload.lock().unwrap().clone().expect("captured"); + assert!( + payload.get("forwarded_for").is_none(), + "forwarded_for must be omitted when context.identity is None" + ); + } + + // --- ADR-029 §5: collision rule --------------------------------------- + + fn op_summary(name: &str, conn: &CallConnection) -> OpSummary { + OpSummary { + name: name.to_string(), + schema: sample_schema_json(name, "query"), + connection: conn.clone(), + } + } + + fn op_summary_typed(name: &str, op_type: &str, conn: &CallConnection) -> OpSummary { + OpSummary { + name: name.to_string(), + schema: sample_schema_json(name, op_type), + connection: conn.clone(), + } + } + + #[test] + fn build_bundles_same_peer_collision_returns_same_peer_collision_error() { + let conn = CallConnection::new(stub_connection()); + // Same peer exposing two ops that resolve to the same name after the + // (empty) prefix → SamePeerCollision. + let discovered = vec![ + op_summary("worker/exec", &conn), + op_summary("worker/exec", &conn), + ]; + match build_bundles(discovered, &None, &None) { + Err(AdapterError::SamePeerCollision { message }) => { + assert!(message.contains("worker/exec")); + } + Err(other) => panic!("expected SamePeerCollision, got another error: {other}"), + Ok(_) => panic!("expected SamePeerCollision, got Ok"), + } + } + + #[test] + fn build_bundles_same_peer_collision_after_prefix_returns_error() { + let conn = CallConnection::new(stub_connection()); + // Two ops with different remote names that collide after the prefix is + // applied (prefix drops, then same name) → SamePeerCollision. Here we + // use the same remote name twice, which is the canonical same-peer + // collision. + let discovered = vec![ + op_summary("fs/readFile", &conn), + op_summary("fs/readFile", &conn), + ]; + match build_bundles(discovered, &Some("worker".to_string()), &None) { + Err(AdapterError::SamePeerCollision { message }) => { + assert!(message.contains("worker/fs/readFile")); + } + Err(other) => panic!("expected SamePeerCollision, got another error: {other}"), + Ok(_) => panic!("expected SamePeerCollision, got Ok"), + } + } + + #[test] + fn build_bundles_cross_peer_same_name_does_not_collide() { + // Cross-peer collision dissolves (ADR-029 §5): the same name on + // different peers lives in separate sub-overlays. `from_call` runs + // per-connection (per-peer), so the `build_bundles` collision check is + // same-peer only. This test verifies that a single `build_bundles` + // call with distinct names succeeds — the cross-peer case is + // structurally separate `from_call` invocations on different + // connections, each producing its own bundle set with no collision. + let conn_a = CallConnection::new(stub_connection()); + let conn_b = CallConnection::new(stub_connection()); + + let bundles_a = build_bundles(vec![op_summary("container/exec", &conn_a)], &None, &None) + .expect("peer a bundles"); + let bundles_b = build_bundles(vec![op_summary("container/exec", &conn_b)], &None, &None) + .expect("peer b bundles"); + + assert_eq!(bundles_a.len(), 1); + assert_eq!(bundles_b.len(), 1); + assert_eq!(bundles_a[0].spec.name, "container/exec"); + assert_eq!(bundles_b[0].spec.name, "container/exec"); + // Same name, different peer sub-overlays — no collision. + } + + #[test] + fn build_bundles_distinct_names_in_same_peer_do_not_collide() { + let conn = CallConnection::new(stub_connection()); + let discovered = vec![ + op_summary("worker/exec", &conn), + op_summary("worker/status", &conn), + op_summary("fs/readFile", &conn), + ]; + let bundles = build_bundles(discovered, &None, &None).expect("distinct names ok"); + assert_eq!(bundles.len(), 3); + for b in &bundles { + assert_eq!(b.provenance, OperationProvenance::FromCall); + } + } + + #[test] + fn build_bundles_applies_namespace_prefix_without_collision() { + let conn = CallConnection::new(stub_connection()); + let discovered = vec![op_summary("exec", &conn), op_summary("status", &conn)]; + let bundles = + build_bundles(discovered, &Some("worker".to_string()), &None).expect("prefixed ok"); + assert_eq!(bundles[0].spec.name, "worker/exec"); + assert_eq!(bundles[1].spec.name, "worker/status"); + } + + #[test] + fn build_bundles_respects_operation_filter() { + let conn = CallConnection::new(stub_connection()); + let discovered = vec![ + op_summary("worker/exec", &conn), + op_summary("worker/status", &conn), + op_summary("fs/readFile", &conn), + ]; + let filter: HashSet = HashSet::from(["worker/exec".to_string()]); + let bundles = build_bundles(discovered, &None, &Some(filter)).expect("filtered ok"); + assert_eq!(bundles.len(), 1); + assert_eq!(bundles[0].spec.name, "worker/exec"); + } + + // --- ADR-049 §8: streaming forwarding for Subscription ops ------------- + + #[test] + fn build_bundles_subscription_op_produces_stream_kind() { + let conn = CallConnection::new(stub_connection()); + let discovered = vec![op_summary_typed("events/stream", "subscription", &conn)]; + let bundles = build_bundles(discovered, &None, &None).expect("bundles"); + assert_eq!(bundles.len(), 1); + assert_eq!(bundles[0].spec.op_type, OperationType::Subscription); + assert!( + matches!(bundles[0].handler, HandlerKind::Stream(_)), + "Subscription op must register HandlerKind::Stream" + ); + assert_eq!(bundles[0].provenance, OperationProvenance::FromCall); + assert!(bundles[0].composition_authority.is_none()); + assert!(bundles[0].scoped_env.is_none()); + } + + #[test] + fn build_bundles_query_op_produces_once_kind() { + let conn = CallConnection::new(stub_connection()); + let discovered = vec![op_summary_typed("fs/readFile", "query", &conn)]; + let bundles = build_bundles(discovered, &None, &None).expect("bundles"); + assert_eq!(bundles.len(), 1); + assert_eq!(bundles[0].spec.op_type, OperationType::Query); + assert!( + matches!(bundles[0].handler, HandlerKind::Once(_)), + "Query op must register HandlerKind::Once" + ); + } + + #[test] + fn build_bundles_mutation_op_produces_once_kind() { + let conn = CallConnection::new(stub_connection()); + let discovered = vec![op_summary_typed("fs/writeFile", "mutation", &conn)]; + let bundles = build_bundles(discovered, &None, &None).expect("bundles"); + assert_eq!(bundles.len(), 1); + assert_eq!(bundles[0].spec.op_type, OperationType::Mutation); + assert!( + matches!(bundles[0].handler, HandlerKind::Once(_)), + "Mutation op must register HandlerKind::Once" + ); + } + + #[test] + fn build_bundles_mixed_op_types_route_to_correct_kind() { + let conn = CallConnection::new(stub_connection()); + let discovered = vec![ + op_summary_typed("fs/readFile", "query", &conn), + op_summary_typed("fs/writeFile", "mutation", &conn), + op_summary_typed("events/stream", "subscription", &conn), + ]; + let bundles = build_bundles(discovered, &None, &None).expect("bundles"); + assert_eq!(bundles.len(), 3); + let by_name: std::collections::HashMap<&str, &HandlerKind> = bundles + .iter() + .map(|b| (b.spec.name.as_str(), &b.handler)) + .collect(); + assert!(matches!(by_name["fs/readFile"], HandlerKind::Once(_))); + assert!(matches!(by_name["fs/writeFile"], HandlerKind::Once(_))); + assert!(matches!(by_name["events/stream"], HandlerKind::Stream(_))); + } + + /// Verify `make_streaming_forwarding_handler` produces a `StreamingHandler` + /// that builds the forwarded payload with `forwarded_for` populated from + /// `context.identity` (ADR-032) and calls `subscribe_with_payload`. Since + /// `subscribe_with_payload` on a mock connection returns a closed stream + /// (no transport), we capture the payload by intercepting the build step: + /// the handler's contract is "build payload via `build_forwarded_payload`, + /// then call `subscribe_with_payload(payload)`". We mirror the existing + /// `forwarding_handler_populates_forwarded_for` test by constructing the + /// handler and exercising the payload-construction path it relies on, plus + /// asserting the produced stream terminates (the mock-connection path + /// yields one error envelope then ends — no truncation, no hang). + #[tokio::test] + async fn streaming_forwarding_handler_populates_forwarded_for_and_streams() { + use futures::stream::StreamExt; + + let conn = Arc::new(CallConnection::new(stub_connection())); + let captured_payload = Arc::new(StdMutex::new(None::)); + let captured = Arc::clone(&captured_payload); + + let handler: StreamingHandler = { + let conn = Arc::clone(&conn); + make_streaming_handler(move |input, context| { + let conn = Arc::clone(&conn); + let captured = Arc::clone(&captured); + let remote_name = "events/stream".to_string(); + use futures::stream::{once, StreamExt}; + once(async move { + let payload = build_forwarded_payload(&remote_name, input, &context); + *captured.lock().unwrap() = Some(payload.clone()); + conn.subscribe_with_payload(payload).await + }) + .flatten() + }) + }; + + let ctx = test_context(Some(alice_identity())); + let mut stream = handler(json!({}), ctx); + let first = stream.next().await; + assert!( + first.is_some(), + "streaming forwarding handler must produce at least one envelope" + ); + if let Some(env) = first { + assert!( + env.result.is_err(), + "mock connection has no transport, so the stream yields an error envelope" + ); + } + let second = stream.next().await; + assert!( + second.is_none(), + "stream must terminate after the error (no truncation, no hang)" + ); + + let payload = captured_payload.lock().unwrap().clone().expect("captured"); + assert_eq!(payload["operationId"], "events/stream"); + assert_eq!(payload["forwarded_for"]["id"], "alice"); + } + + /// The streaming forwarding handler omits `forwarded_for` when + /// `context.identity` is `None`, mirroring the request/response handler. + #[tokio::test] + async fn streaming_forwarding_handler_omits_forwarded_for_when_identity_none() { + use futures::stream::StreamExt; + + let conn = Arc::new(CallConnection::new(stub_connection())); + let captured_payload = Arc::new(StdMutex::new(None::)); + let captured = Arc::clone(&captured_payload); + + let handler: StreamingHandler = { + let conn = Arc::clone(&conn); + make_streaming_handler(move |input, context| { + let conn = Arc::clone(&conn); + let captured = Arc::clone(&captured); + let remote_name = "events/stream".to_string(); + use futures::stream::{once, StreamExt}; + once(async move { + let payload = build_forwarded_payload(&remote_name, input, &context); + *captured.lock().unwrap() = Some(payload.clone()); + conn.subscribe_with_payload(payload).await + }) + .flatten() + }) + }; + + let ctx = test_context(None); + let mut stream = handler(json!({}), ctx); + let _ = stream.next().await; + let payload = captured_payload.lock().unwrap().clone().expect("captured"); + assert!( + payload.get("forwarded_for").is_none(), + "forwarded_for must be omitted when context.identity is None" + ); + assert_eq!(payload["operationId"], "events/stream"); + } + + /// `make_streaming_forwarding_handler` produces a `StreamingHandler` (not a + /// `Handler`) — verifies the helper returns the right type and that + /// `build_bundles` wires it into `HandlerKind::Stream`. + #[test] + fn make_streaming_forwarding_handler_returns_streaming_handler() { + let handler = make_streaming_forwarding_handler( + Arc::new(CallConnection::new(stub_connection())), + "events/stream".to_string(), + ); + let reg = HandlerRegistration::new( + OperationSpec::new( + "events/stream", + OperationType::Subscription, + Visibility::External, + json!({}), + json!({}), + vec![], + AccessControl::default(), + None, + ), + HandlerKind::Stream(handler), + OperationProvenance::FromCall, + None, + None, + Capabilities::new(), + ); + assert!(matches!(reg.handler, HandlerKind::Stream(_))); + assert_eq!(reg.provenance, OperationProvenance::FromCall); + assert!(reg.composition_authority.is_none()); + assert!(reg.scoped_env.is_none()); + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs new file mode 100644 index 0000000..f506bfc --- /dev/null +++ b/src/client/mod.rs @@ -0,0 +1,107 @@ +//! Client adapters: turn external operation sources (JSON Schema, OpenAPI, +//! MCP, remote `from_call` peers) into `HandlerRegistration` bundles. +//! +//! See `docs/architecture/crates/call/client-and-adapters.md` for the +//! OperationAdapter trait and the Adapter Location Map, and +//! `docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md` +//! §5 for the trait contract. + +mod call_client; +mod from_call; + +pub use call_client::CallClient; +pub use from_call::{from_call, FromCallConfig}; + +use crate::registry::registration::HandlerRegistration; + +/// Errors produced by [`OperationAdapter::import`]. +/// +/// The variant set is the v1 default (two-way-door remainder, OQ-26); +/// `#[non_exhaustive]` lets downstream adapters (e.g. `alknet-http`'s +/// `from_openapi`/`from_mcp`) extend without breaking match arms. All +/// payloads are string messages — kept simple and `Send + Sync` by +/// construction. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum AdapterError { + /// `from_call` remote unreachable / `services/list` failed. + #[error("discovery failed: {message}")] + DiscoveryFailed { message: String }, + + /// `from_openapi` / `from_jsonschema` couldn't parse the spec. + #[error("schema parse error: {message}")] + SchemaParse { message: String }, + + /// Underlying transport error (QUIC for `from_call`, HTTP for adapters). + #[error("transport error: {message}")] + Transport { message: String }, + + /// HTTP 401 for `from_openapi`/`from_mcp`, auth rejected for `from_call`. + #[error("unauthorized: {message}")] + Unauthorized { message: String }, + + /// Same-peer namespace collision in `from_call` (ADR-029 §5; OQ-26). + /// Cross-peer collision dissolves (same name on different peers lives in + /// separate sub-overlays); same-peer collision stays an error — a peer + /// shouldn't expose two ops with the same name. + #[error("same-peer collision: {message}")] + SamePeerCollision { message: String }, +} + +/// Import a set of operations as `HandlerRegistration` bundles. +/// +/// Async because `from_call` requires async discovery (`services/list` + +/// `services/schema` over a QUIC connection); sync adapters (e.g. +/// `from_openapi` reading a static spec) trivially satisfy +/// an async trait — their `import()` bodies contain no `.await` points. +/// +/// See ADR-017 §5 (`docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md`) +/// and `docs/architecture/crates/call/client-and-adapters.md`. +#[async_trait::async_trait] +pub trait OperationAdapter: Send + Sync { + async fn import(&self) -> Result, AdapterError>; +} + +#[cfg(test)] +mod tests { + use super::*; + + struct OkAdapter; + + #[async_trait::async_trait] + impl OperationAdapter for OkAdapter { + async fn import(&self) -> Result, AdapterError> { + Ok(vec![]) + } + } + + struct ErrAdapter; + + #[async_trait::async_trait] + impl OperationAdapter for ErrAdapter { + async fn import(&self) -> Result, AdapterError> { + Err(AdapterError::SchemaParse { + message: "x".into(), + }) + } + } + + #[tokio::test] + async fn ok_adapter_imports_empty() { + let adapter = OkAdapter; + match adapter.import().await { + Ok(bundles) => assert!(bundles.is_empty()), + Err(e) => panic!("expected Ok, got Err: {e}"), + } + } + + #[tokio::test] + async fn err_adapter_returns_schema_parse() { + let adapter = ErrAdapter; + match adapter.import().await { + Ok(_) => panic!("expected Err"), + Err(AdapterError::SchemaParse { message }) => assert_eq!(message, "x"), + Err(other) => panic!("expected SchemaParse, got {other}"), + } + } +} diff --git a/src/core/auth.rs b/src/core/auth.rs new file mode 100644 index 0000000..c50a6bf --- /dev/null +++ b/src/core/auth.rs @@ -0,0 +1,101 @@ +//! Authentication primitives: `AuthContext`, `Identity`, `AuthToken`, +//! `IdentityProvider`. +//! +//! See `docs/architecture/` for the full specification. The trait-based +//! `IdentityProvider` is the integration point — the assembly layer supplies +//! the impl (config-backed, vault-backed, or persistence-adapter-backed) and +//! the call protocol resolves identity per-request through it. + +use std::collections::HashMap; +use std::net::SocketAddr; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Identity { + pub id: String, + pub scopes: Vec, + pub resources: HashMap>, +} + +#[derive(Debug, Clone)] +pub struct AuthToken { + pub raw: Vec, +} + +#[derive(Clone)] +pub struct AuthContext { + pub identity: Option, + pub alpn: Vec, + pub remote_addr: Option, + pub tls_client_fingerprint: Option, +} + +impl AuthContext { + /// Construct an `AuthContext` with no identity, no fingerprint, and no + /// remote address — only the ALPN is set. For POCs, tests, and handlers + /// that don't require auth. + pub fn anonymous(alpn: impl Into>) -> Self { + Self { + identity: None, + alpn: alpn.into(), + remote_addr: None, + tls_client_fingerprint: None, + } + } +} + +pub trait IdentityProvider: Send + Sync + 'static { + fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option; + fn resolve_from_token(&self, token: &AuthToken) -> Option; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_fields_and_equality() { + let mut resources = HashMap::new(); + resources.insert("service".to_string(), vec!["gitea".to_string()]); + let id = Identity { + id: "SHA256:abc123".to_string(), + scopes: vec!["relay:connect".to_string()], + resources, + }; + let id2 = id.clone(); + assert_eq!(id, id2); + assert_eq!(id.id, "SHA256:abc123"); + } + + #[test] + fn auth_token_is_clone() { + let token = AuthToken { + raw: b"alk_test".to_vec(), + }; + let cloned = token.clone(); + assert_eq!(token.raw, cloned.raw); + } + + #[test] + fn auth_context_is_clone() { + let ctx = AuthContext { + identity: None, + alpn: b"alknet/test".to_vec(), + remote_addr: None, + tls_client_fingerprint: None, + }; + let cloned = ctx.clone(); + assert_eq!(cloned.alpn, b"alknet/test"); + assert!(cloned.identity.is_none()); + } + + #[test] + fn auth_context_anonymous_sets_alpn_only() { + let ctx = AuthContext::anonymous(b"alknet/test"); + assert_eq!(ctx.alpn, b"alknet/test"); + assert!(ctx.identity.is_none()); + assert!(ctx.remote_addr.is_none()); + assert!(ctx.tls_client_fingerprint.is_none()); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..9b90013 --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,20 @@ +//! Vendored core types: `ProtocolHandler`, `Connection`, `BiStream`, +//! `BidiStreamSource`, `AuthContext`, `IdentityProvider`, `Identity`, +//! `AuthToken`, `Capabilities`, `OwnershipProvider`, `HandlerError`, +//! `StreamError`. +//! +//! These types are the home for the former `alknet-core` surface — lean, +//! no TLS, no transport coupling, no endpoint/accept-loop. The dial and +//! the TLS config are concerns of the consumer, not of this crate. When +//! the alknet mono-repo is reworked, it will consume alkcall's versions. + +pub mod auth; +pub mod ownership; +pub mod types; + +pub use auth::{AuthContext, AuthToken, Identity, IdentityProvider}; +pub use ownership::{InMemoryOwnershipStore, OwnershipError, OwnershipProvider, OwnershipStore}; +pub use types::{ + BiStream, BidiStreamSource, Capabilities, Connection, HandlerError, IdentityAlreadySet, + ProtocolHandler, RecvStream, Secret, SendStream, StreamError, +}; diff --git a/src/core/ownership.rs b/src/core/ownership.rs new file mode 100644 index 0000000..907c557 --- /dev/null +++ b/src/core/ownership.rs @@ -0,0 +1,276 @@ +//! Ownership store: `OwnershipProvider` (sync read trait), `OwnershipStore` +//! (async write trait), `InMemoryOwnershipStore` default adapter, and +//! `OwnershipError`. +//! +//! Runtime-spawned resources (containers, TTYs, workspace processes) have +//! derived ownership — whoever spawned the resource owns it. The static +//! `Identity.resources` model can't represent this (the resource didn't +//! exist when the identity was resolved), so `AccessControl::check` +//! consults `OwnershipProvider` at check time. + +use std::collections::HashMap; +use std::sync::RwLock; + +use async_trait::async_trait; + +use super::auth::Identity; + +#[non_exhaustive] +#[derive(Debug, thiserror::Error)] +pub enum OwnershipError { + #[error("backend error: {message}")] + Backend { message: String }, + #[error("not found: {entity}")] + NotFound { entity: String }, +} + +/// Read side: consulted by `AccessControl::check` on the dispatch hot path. +/// Sync — called in the dispatch loop, no `.await`. +pub trait OwnershipProvider: Send + Sync + 'static { + /// Does `identity` own `resource_type/resource_id` with `action`? + /// The `action` parameter is accepted but not gated on — the base model + /// is "owner can do anything they own." Per-action grants are a future + /// extension; this preserves the door without building the mechanism. + fn owns( + &self, + identity: &Identity, + resource_type: &str, + resource_id: &str, + action: &str, + ) -> bool; + + /// What resources of `resource_type` does `identity` own? Returns the + /// set of resource IDs the caller owns, for the handler to filter + /// against (the result-filter path). + fn owned_resources(&self, identity: &Identity, resource_type: &str) -> Vec; + + /// Does `identity` own *any* resource of `resource_type`? The scope-gate + /// path. + fn owns_any(&self, identity: &Identity, resource_type: &str) -> bool; +} + +/// Write side: called by the handler that manages the resource lifecycle. +/// Async — not on the dispatch hot path. The handler calls `record` on +/// spawn and `revoke` on teardown (handler-driven, not a reaper). The trait +/// takes `&self` so it can be shared as `Arc` (interior +/// mutability via `RwLock`). +#[async_trait] +pub trait OwnershipStore: Send + Sync + 'static { + /// Record that `identity` spawned `resource_type/resource_id`. + async fn record( + &self, + identity: &Identity, + resource_type: &str, + resource_id: &str, + ) -> Result<(), OwnershipError>; + + /// Revoke ownership of `resource_type/resource_id`. Called by the + /// handler on resource teardown. + async fn revoke(&self, resource_type: &str, resource_id: &str) -> Result<(), OwnershipError>; +} + +pub struct InMemoryOwnershipStore { + inner: RwLock>, +} + +impl InMemoryOwnershipStore { + pub fn new() -> Self { + Self { + inner: RwLock::new(HashMap::new()), + } + } +} + +impl Default for InMemoryOwnershipStore { + fn default() -> Self { + Self::new() + } +} + +impl OwnershipProvider for InMemoryOwnershipStore { + fn owns( + &self, + identity: &Identity, + resource_type: &str, + resource_id: &str, + _action: &str, + ) -> bool { + let inner = self.inner.read().unwrap_or_else(|e| e.into_inner()); + inner + .get(&(resource_type.to_string(), resource_id.to_string())) + .map(|owner| owner.id == identity.id) + .unwrap_or(false) + } + + fn owned_resources(&self, identity: &Identity, resource_type: &str) -> Vec { + let inner = self.inner.read().unwrap_or_else(|e| e.into_inner()); + inner + .iter() + .filter(|((rt, _), owner)| rt == resource_type && owner.id == identity.id) + .map(|((_, rid), _)| rid.clone()) + .collect() + } + + fn owns_any(&self, identity: &Identity, resource_type: &str) -> bool { + let inner = self.inner.read().unwrap_or_else(|e| e.into_inner()); + inner + .iter() + .any(|((rt, _), owner)| rt == resource_type && owner.id == identity.id) + } +} + +#[async_trait] +impl OwnershipStore for InMemoryOwnershipStore { + async fn record( + &self, + identity: &Identity, + resource_type: &str, + resource_id: &str, + ) -> Result<(), OwnershipError> { + let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner()); + inner.insert( + (resource_type.to_string(), resource_id.to_string()), + identity.clone(), + ); + Ok(()) + } + + /// Revoking a non-existent resource is a no-op: returns `Ok(())`. + /// Teardown paths are idempotent — a handler may call `revoke` on a + /// resource that was already removed. + async fn revoke(&self, resource_type: &str, resource_id: &str) -> Result<(), OwnershipError> { + let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner()); + inner.remove(&(resource_type.to_string(), resource_id.to_string())); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_identity(id: &str) -> Identity { + Identity { + id: id.to_string(), + scopes: vec![], + resources: HashMap::new(), + } + } + + #[tokio::test] + async fn record_owns_revoke_round_trip() { + let store = InMemoryOwnershipStore::new(); + let owner = make_identity("worker-a"); + + assert!(!store.owns(&owner, "container", "c1", "exec")); + assert!(!store.owns_any(&owner, "container")); + assert!(store.owned_resources(&owner, "container").is_empty()); + + store.record(&owner, "container", "c1").await.unwrap(); + assert!(store.owns(&owner, "container", "c1", "exec")); + assert!(store.owns(&owner, "container", "c1", "logs")); + assert!(store.owns_any(&owner, "container")); + assert_eq!(store.owned_resources(&owner, "container"), vec!["c1"]); + + store.revoke("container", "c1").await.unwrap(); + assert!(!store.owns(&owner, "container", "c1", "exec")); + assert!(!store.owns_any(&owner, "container")); + assert!(store.owned_resources(&owner, "container").is_empty()); + } + + #[tokio::test] + async fn owned_resources_returns_all_for_owner_with_multiple() { + let store = InMemoryOwnershipStore::new(); + let owner = make_identity("worker-a"); + store.record(&owner, "container", "c1").await.unwrap(); + store.record(&owner, "container", "c2").await.unwrap(); + store.record(&owner, "container", "c3").await.unwrap(); + + let mut owned = store.owned_resources(&owner, "container"); + owned.sort(); + assert_eq!(owned, vec!["c1", "c2", "c3"]); + } + + #[tokio::test] + async fn owned_resources_filters_by_resource_type() { + let store = InMemoryOwnershipStore::new(); + let owner = make_identity("worker-a"); + store.record(&owner, "container", "c1").await.unwrap(); + store.record(&owner, "tty", "t1").await.unwrap(); + + let owned_containers = store.owned_resources(&owner, "container"); + assert_eq!(owned_containers, vec!["c1"]); + let owned_ttys = store.owned_resources(&owner, "tty"); + assert_eq!(owned_ttys, vec!["t1"]); + } + + #[tokio::test] + async fn owns_any_returns_false_for_owner_with_no_resources_of_type() { + let store = InMemoryOwnershipStore::new(); + let owner = make_identity("worker-a"); + store.record(&owner, "container", "c1").await.unwrap(); + + assert!(store.owns_any(&owner, "container")); + assert!(!store.owns_any(&owner, "tty")); + } + + #[tokio::test] + async fn revoke_on_non_existent_resource_is_no_op() { + let store = InMemoryOwnershipStore::new(); + store.revoke("container", "never-existed").await.unwrap(); + } + + #[tokio::test] + async fn owns_returns_false_for_different_identity() { + let store = InMemoryOwnershipStore::new(); + let owner = make_identity("worker-a"); + let other = make_identity("worker-b"); + store.record(&owner, "container", "c1").await.unwrap(); + + assert!(store.owns(&owner, "container", "c1", "exec")); + assert!(!store.owns(&other, "container", "c1", "exec")); + assert!(!store.owns_any(&other, "container")); + assert!(store.owned_resources(&other, "container").is_empty()); + } + + #[tokio::test] + async fn record_replaces_existing_owner() { + let store = InMemoryOwnershipStore::new(); + let owner_a = make_identity("worker-a"); + let owner_b = make_identity("worker-b"); + store.record(&owner_a, "container", "c1").await.unwrap(); + store.record(&owner_b, "container", "c1").await.unwrap(); + + assert!(!store.owns(&owner_a, "container", "c1", "exec")); + assert!(store.owns(&owner_b, "container", "c1", "exec")); + } + + #[tokio::test] + async fn default_is_empty_store() { + let store = InMemoryOwnershipStore::default(); + let owner = make_identity("worker-a"); + assert!(store.owned_resources(&owner, "container").is_empty()); + assert!(!store.owns_any(&owner, "container")); + } + + #[test] + fn ownership_error_display_formatting() { + let backend = OwnershipError::Backend { + message: "disk full".to_string(), + }; + assert_eq!(backend.to_string(), "backend error: disk full"); + + let not_found = OwnershipError::NotFound { + entity: "container:c1".to_string(), + }; + assert_eq!(not_found.to_string(), "not found: container:c1"); + } + + #[test] + fn ownership_error_is_non_exhaustive() { + let err = OwnershipError::Backend { + message: "x".to_string(), + }; + let _ = err.to_string(); + } +} diff --git a/src/core/types.rs b/src/core/types.rs new file mode 100644 index 0000000..69b5460 --- /dev/null +++ b/src/core/types.rs @@ -0,0 +1,943 @@ +//! Core types: `ProtocolHandler`, `HandlerError`, `Connection`, `BiStream`, +//! `SendStream`, `RecvStream`, `StreamError`, `BidiStreamSource`, +//! `Capabilities`, `Secret`. +//! +//! See `docs/architecture/` for the full specification. These types are the +//! home for the former `alknet-core` surface — lean, no TLS, no transport +//! coupling, no endpoint/accept-loop. The dial and the TLS config are +//! concerns of the consumer, not of this crate. + +use std::collections::HashMap; +use std::io; +use std::net::SocketAddr; +use std::sync::{Mutex, OnceLock}; + +use async_trait::async_trait; +use tokio::io::{AsyncRead, AsyncWrite}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use super::auth::{AuthContext, Identity}; + +pub struct Secret { + inner: T, +} + +impl Secret { + pub fn new(value: T) -> Self { + Self { inner: value } + } + + pub fn expose_secret(&self) -> &T { + &self.inner + } +} + +impl Clone for Secret { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl Zeroize for Secret { + fn zeroize(&mut self) { + self.inner.zeroize(); + } +} + +impl Drop for Secret { + fn drop(&mut self) { + self.inner.zeroize(); + } +} + +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("[REDACTED]") + } +} + +pub struct Capabilities { + entries: HashMap>, +} + +impl Zeroize for Capabilities { + fn zeroize(&mut self) { + for (_, v) in self.entries.iter_mut() { + v.zeroize(); + } + self.entries.clear(); + } +} + +impl ZeroizeOnDrop for Capabilities {} + +impl Clone for Capabilities { + fn clone(&self) -> Self { + Self { + entries: self.entries.clone(), + } + } +} + +impl Capabilities { + pub fn new() -> Self { + Self { + entries: HashMap::new(), + } + } + + pub fn with_api_key(mut self, service: &str, key: String) -> Self { + self.entries + .insert(format!("api_key:{service}"), Secret::new(key)); + self + } + + pub fn with_http_token(mut self, service: &str, token: String) -> Self { + self.entries + .insert(format!("http_token:{service}"), Secret::new(token)); + self + } + + pub fn get(&self, service: &str) -> Option<&Secret> { + self.entries + .get(&format!("api_key:{service}")) + .or_else(|| self.entries.get(&format!("http_token:{service}"))) + } +} + +impl Default for Capabilities { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Debug for Capabilities { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Capabilities") + .field("entries", &format!("[{} redacted]", self.entries.len())) + .finish() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum IdentityAlreadySet { + #[error("connection identity already set")] + AlreadySet, +} + +pub enum HandlerError { + ConnectionClosed, + StreamError(io::Error), + AuthRequired, + Internal(Box), +} + +impl std::fmt::Debug for HandlerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ConnectionClosed => f.write_str("HandlerError::ConnectionClosed"), + Self::StreamError(e) => f.debug_tuple("HandlerError::StreamError").field(e).finish(), + Self::AuthRequired => f.write_str("HandlerError::AuthRequired"), + Self::Internal(e) => f.debug_tuple("HandlerError::Internal").field(e).finish(), + } + } +} + +impl std::fmt::Display for HandlerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ConnectionClosed => f.write_str("connection closed"), + Self::StreamError(e) => write!(f, "stream error: {e}"), + Self::AuthRequired => f.write_str("authentication required"), + Self::Internal(e) => write!(f, "internal handler error: {e}"), + } + } +} + +impl std::error::Error for HandlerError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::StreamError(e) => Some(e), + Self::Internal(e) => Some(e.as_ref()), + _ => None, + } + } +} + +pub enum StreamError { + ConnectionClosed, + StreamClosed, + Timeout, + Internal(io::Error), +} + +impl From for HandlerError { + fn from(e: StreamError) -> Self { + match e { + StreamError::ConnectionClosed => HandlerError::ConnectionClosed, + StreamError::StreamClosed => HandlerError::StreamError(io::Error::new( + io::ErrorKind::ConnectionReset, + "stream closed", + )), + StreamError::Timeout => HandlerError::StreamError(io::Error::new( + io::ErrorKind::TimedOut, + "stream timed out", + )), + StreamError::Internal(e) => HandlerError::StreamError(e), + } + } +} + +impl std::fmt::Debug for StreamError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ConnectionClosed => f.write_str("StreamError::ConnectionClosed"), + Self::StreamClosed => f.write_str("StreamError::StreamClosed"), + Self::Timeout => f.write_str("StreamError::Timeout"), + Self::Internal(e) => f.debug_tuple("StreamError::Internal").field(e).finish(), + } + } +} + +impl std::fmt::Display for StreamError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ConnectionClosed => f.write_str("connection closed"), + Self::StreamClosed => f.write_str("stream closed"), + Self::Timeout => f.write_str("stream timed out"), + Self::Internal(e) => write!(f, "stream error: {e}"), + } + } +} + +impl std::error::Error for StreamError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Internal(e) => Some(e), + _ => None, + } + } +} + +#[async_trait] +pub trait ProtocolHandler: Send + Sync + 'static { + fn alpn(&self) -> &'static [u8]; + async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError>; +} + +// --- BiStream: the handler leaf (ADR-092) --------------------------------- + +/// Internal helper trait — the union of `AsyncRead + AsyncWrite + Send`. +/// Not public; exists only to give `BiStream` a single boxed field. +trait AsyncReadWrite: AsyncRead + AsyncWrite + Send {} +impl AsyncReadWrite for T {} + +/// The handler leaf — a bidirectional byte stream (ADR-092). +/// +/// `accept_bi`/`open_bi` return a `BiStream`, not a split +/// `(SendStream, RecvStream)` pair. Handlers that want the split halves call +/// `tokio::io::split(&mut *stream)` (the stdlib idiom `tokio::io::split` +/// already provides for `TcpStream` and `TlsStream`). The +/// split is a stdlib call at the handler boundary, not a per-handler trait +/// wrapper. +/// +/// `BiStream: AsyncRead + AsyncWrite + Send + Unpin` by construction. +pub struct BiStream { + inner: Box, +} + +impl BiStream { + /// Join a read half and a write half into a single `BiStream`. The join + /// happens once, in the `BidiStreamSource` impl — handlers receive the + /// joined `BiStream` and never see the pair. + /// + /// Public so that downstream crates (the channels reassembly path, tests + /// that construct a `BiStream` from independent halves) can join their + /// own halves. The rule this normalizes: **the split never crosses a + /// crate boundary as part of a constructor** — `Connection::from_bidi` + /// takes a joined `BiStream`, and `BiStream::from_joined` is the join. + pub fn from_joined(reader: R, writer: W) -> Self + where + R: AsyncRead + Send + Unpin + 'static, + W: AsyncWrite + Send + Unpin + 'static, + { + Self { + inner: Box::new(tokio::io::join(reader, writer)), + } + } + + /// Wrap a single value that is already `AsyncRead + AsyncWrite` (e.g. + /// `tokio::io::DuplexStream`, `TlsStream`, + /// `russh::Channel::into_stream()`). Used by the single-stream + /// `BidiStreamSource` impl and by `Connection::from_bidi`. + pub(crate) fn from_bidi(stream: S) -> Self + where + S: AsyncRead + AsyncWrite + Send + Unpin + 'static, + { + Self { + inner: Box::new(stream), + } + } +} + +impl AsyncRead for BiStream { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(self.inner.as_mut()).poll_read(cx, buf) + } +} + +impl AsyncWrite for BiStream { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + std::pin::Pin::new(self.inner.as_mut()).poll_write(cx, buf) + } + + fn poll_flush( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(self.inner.as_mut()).poll_flush(cx) + } + + fn poll_shutdown( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(self.inner.as_mut()).poll_shutdown(cx) + } +} + +// --- SendStream / RecvStream: thin newtypes (ADR-092) --------------------- + +pub struct SendStream { + inner: Box, +} + +pub struct RecvStream { + inner: Box, +} + +impl SendStream { + /// Box a write half into the thin `SendStream` newtype. Used by + /// `into_sub_streams()` (ADR-074) and the channels reassembly path. + /// Not a constructor that feeds `Connection` — the split never crosses + /// a crate boundary as part of a constructor (ADR-092). + pub fn from_stream(stream: impl AsyncWrite + Send + Unpin + 'static) -> Self { + Self { + inner: Box::new(stream), + } + } +} + +impl RecvStream { + /// Box a read half into the thin `RecvStream` newtype. Used by + /// `into_sub_streams()` (ADR-074) and the channels reassembly path. + /// Not a constructor that feeds `Connection` — the split never crosses + /// a crate boundary as part of a constructor (ADR-092). + pub fn from_stream(stream: impl AsyncRead + Send + Unpin + 'static) -> Self { + Self { + inner: Box::new(stream), + } + } +} + +impl AsyncWrite for SendStream { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + std::pin::Pin::new(self.inner.as_mut()).poll_write(cx, buf) + } + + fn poll_flush( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(self.inner.as_mut()).poll_flush(cx) + } + + fn poll_shutdown( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(self.inner.as_mut()).poll_shutdown(cx) + } +} + +impl AsyncRead for RecvStream { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(self.inner.as_mut()).poll_read(cx, buf) + } +} + +/// Yield bidirectional streams to a `Connection`. Downstream crates implement +/// this trait to add connection shapes (channels, a future transport, a test +/// double beyond the single-stream case) without editing core. See ADR-070 +/// for the full rationale and ADR-065 for the yield-once contract the +/// `StreamBidiStreamSource` impl preserves. The return type is `BiStream` +/// (ADR-092) — the join happens once, in the impl, not per-handler. +#[async_trait] +pub trait BidiStreamSource: Send + Sync + 'static { + /// Yield the next bidirectional stream this connection provides. + /// + /// Transport semantics (carried from ADR-065): + /// - QUIC (quinn/iroh): returns a new bidi stream on each call, + /// `ConnectionClosed` when the underlying connection closes. + /// - Single-stream (TCP+TLS, SSH channel, WebTransport stream, wasm): + /// yields the underlying stream on the first call, then + /// `ConnectionClosed` on all subsequent calls. + /// - Channels: yields one bidi stream per channel, `ConnectionClosed` + /// when the channels connection closes. + async fn accept_bi(&self) -> Result; + + /// Open a bidirectional stream to the peer. + /// + /// Single-stream sources return `StreamClosed` (a single stream cannot + /// open new application streams — ADR-065). QUIC and channels sources + /// open new streams. + async fn open_bi(&self) -> Result; + + /// The peer's address, if available. Informational (NAT/proxy). + fn remote_addr(&self) -> Option; + + /// Close the connection. The `code`/`reason` args are QUIC application- + /// level close codes; non-QUIC sources ignore them (the drop is the + /// close — ADR-065 §"Negative"). See ADR-070 §"REQ-CORE-02" for the + /// rationale for keeping the QUIC-shaped signature on the trait. + fn close(&self, code: u32, reason: &str); +} + +/// Single-stream `BidiStreamSource` (TCP+TLS, SSH channel, WebTransport +/// stream, wasm stream — ADR-065). Crate-private; constructed via +/// `Connection::from_bidi`. `accept_bi` yields the underlying `BiStream` +/// once, then `ConnectionClosed`; `open_bi` returns `StreamClosed`. +struct StreamBidiStreamSource { + stream: Mutex>, + remote_addr: Option, +} + +#[async_trait] +impl BidiStreamSource for StreamBidiStreamSource { + async fn accept_bi(&self) -> Result { + let mut guard = self.stream.lock().expect("stream mutex poisoned"); + match guard.take() { + Some(stream) => Ok(stream), + None => Err(StreamError::ConnectionClosed), + } + } + + async fn open_bi(&self) -> Result { + Err(StreamError::StreamClosed) + } + + fn remote_addr(&self) -> Option { + self.remote_addr + } + + /// `code`/`reason` are ignored: a single stream has no QUIC-shaped + /// application-level close codes. The drop is the close (ADR-065 + /// §"Negative"). The `_` prefix is intentional — the signature matches + /// the public `Connection::close` API (ADR-070 §"REQ-CORE-02"). + fn close(&self, _code: u32, _reason: &str) { + let _ = self.stream.lock().expect("stream mutex poisoned").take(); + } +} + +pub struct Connection { + source: Box, + alpn: Vec, + identity: OnceLock, +} + +impl Connection { + /// Construct a `Connection` from a single bidirectional stream (e.g. + /// `tokio::io::DuplexStream`, `TlsStream`, + /// `russh::Channel::into_stream()`). The stream is wrapped in a + /// `BiStream` (ADR-092) and yielded by `accept_bi` once, then + /// `ConnectionClosed`. `open_bi` returns `StreamClosed` (a single + /// stream can't open new application streams — ADR-065). + /// + /// This is the only public stream constructor (ADR-092): the split + /// never crosses a crate boundary as part of a constructor. Handlers + /// that want the split halves call `tokio::io::split(&mut *stream)` on + /// the `BiStream` they receive from `accept_bi`. + pub fn from_bidi( + stream: impl AsyncRead + AsyncWrite + Send + Unpin + 'static, + alpn: Vec, + remote_addr: Option, + ) -> Self { + Self { + source: Box::new(StreamBidiStreamSource { + stream: Mutex::new(Some(BiStream::from_bidi(stream))), + remote_addr, + }), + alpn, + identity: OnceLock::new(), + } + } + + /// Construct from a caller-supplied `BidiStreamSource` impl. The + /// extension point for downstream crates — implement the trait and + /// construct a `Connection` from it without editing core. See ADR-070. + pub fn from_source(source: impl BidiStreamSource, alpn: Vec) -> Self { + Self { + source: Box::new(source), + alpn, + identity: OnceLock::new(), + } + } + + /// Yield the next bidirectional stream this connection provides. + /// + /// # Transport semantics + /// + /// - **QUIC (quinn/iroh)**: returns a new bidi stream on each call. + /// `ConnectionClosed` when the underlying connection closes. + /// - **TCP+TLS / single-stream**: yields the underlying stream on the + /// first call, then `ConnectionClosed` on all subsequent calls. + /// A single transport stream cannot open new application streams. + /// + /// Handlers that loop `accept_bi` (e.g. `TtyAdapter`) get one session + /// per single-stream connection; handlers that call once (e.g. + /// `HttpAdapter`) get the stream directly. Both are correct. The + /// return type is `BiStream` (ADR-092); handlers that want the split + /// halves call `tokio::io::split` on the `BiStream`. + pub async fn accept_bi(&self) -> Result { + self.source.accept_bi().await + } + + pub async fn open_bi(&self) -> Result { + self.source.open_bi().await + } + + pub fn remote_alpn(&self) -> &[u8] { + &self.alpn + } + + pub fn remote_addr(&self) -> Option { + self.source.remote_addr() + } + + pub fn close(&self, code: u32, reason: &str) { + self.source.close(code, reason) + } + + pub fn set_identity(&self, identity: Identity) -> Result<(), IdentityAlreadySet> { + self.identity + .set(identity) + .map_err(|_| IdentityAlreadySet::AlreadySet) + } + + pub fn identity(&self) -> Option<&Identity> { + self.identity.get() + } +} + +#[cfg(test)] +mod from_source_tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::sync::Arc; + + /// A minimal custom `BidiStreamSource` impl to prove `from_source` + /// delegates to a caller-supplied impl. Not a built-in — the whole + /// point of `from_source` is that a non-core type can drive `Connection`. + struct RecordingSource { + stream: Mutex>, + addr: Option, + closed: Arc>>, + } + + #[async_trait] + impl BidiStreamSource for RecordingSource { + async fn accept_bi(&self) -> Result { + match self.stream.lock().expect("mock mutex poisoned").take() { + Some(stream) => Ok(stream), + None => Err(StreamError::ConnectionClosed), + } + } + + async fn open_bi(&self) -> Result { + Err(StreamError::StreamClosed) + } + + fn remote_addr(&self) -> Option { + self.addr + } + + fn close(&self, code: u32, reason: &str) { + let _ = self + .closed + .lock() + .expect("mock closed mutex poisoned") + .replace((code, reason.to_string())); + } + } + + #[tokio::test] + async fn from_source_delegates_to_custom_impl() { + use tokio::io::AsyncReadExt; + use tokio::io::AsyncWriteExt; + + let (a, b) = tokio::io::duplex(64); + let (mut recv_b, mut send_b) = tokio::io::split(b); + let addr = Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7777)); + let recorded = Arc::new(Mutex::new(None)); + let conn = Connection::from_source( + RecordingSource { + stream: Mutex::new(Some(BiStream::from_bidi(a))), + addr, + closed: Arc::clone(&recorded), + }, + b"alknet/test".to_vec(), + ); + + assert_eq!(conn.remote_alpn(), b"alknet/test"); + assert_eq!(conn.remote_addr(), addr); + + let mut stream = conn.accept_bi().await.expect("first accept_bi yields"); + + stream.write_all(b"hello").await.expect("write round-trips"); + let mut buf = [0u8; 5]; + recv_b.read_exact(&mut buf).await.expect("driver reads"); + assert_eq!(&buf, b"hello"); + + send_b + .write_all(b"world") + .await + .expect("driver writes back"); + let mut buf = [0u8; 5]; + stream.read_exact(&mut buf).await.expect("read round-trips"); + assert_eq!(&buf, b"world"); + + match conn.accept_bi().await { + Err(StreamError::ConnectionClosed) => {} + Err(e) => panic!("expected ConnectionClosed on second accept_bi, got {e}"), + Ok(_) => panic!("expected ConnectionClosed on second accept_bi, got a stream"), + } + + match conn.open_bi().await { + Err(StreamError::StreamClosed) => {} + Err(e) => panic!("expected StreamClosed from open_bi, got {e}"), + Ok(_) => panic!("expected StreamClosed from open_bi, got a stream"), + } + + conn.close(42, "shutting down"); + assert_eq!( + recorded.lock().expect("recorded mutex poisoned").take(), + Some((42, "shutting down".to_string())) + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::pin::Pin; + use std::task::{Context, Poll}; + + /// A test-only `AsyncRead + AsyncWrite` pair equivalent to + /// `tokio::io::sink()` + `tokio::io::empty()`: reads yield EOF + /// immediately (zero bytes), writes discard. Exists because + /// `Connection::from_bidi` requires a single value that implements + /// both traits (ADR-092). Used only to construct a `Connection` for + /// tests that exercise `Connection`-level state (alpn, addr, identity) + /// without ever reading or writing the stream. + pub(crate) struct SinkEmpty; + + impl AsyncRead for SinkEmpty { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _buf: &mut tokio::io::ReadBuf<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + } + + impl AsyncWrite for SinkEmpty { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + + fn test_connection() -> Connection { + Connection::from_bidi( + SinkEmpty, + b"alknet/test".to_vec(), + Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1234)), + ) + } + + #[test] + fn capabilities_new_is_empty() { + let caps = Capabilities::new(); + assert!(caps.get("google").is_none()); + } + + #[test] + fn capabilities_with_api_key_then_get() { + let caps = Capabilities::new().with_api_key("google", "sekrit".to_string()); + let secret = caps.get("google").expect("api key present"); + assert_eq!(secret.expose_secret(), "sekrit"); + } + + #[test] + fn capabilities_with_http_token_then_get() { + let caps = Capabilities::new().with_http_token("github", "tok".to_string()); + let secret = caps.get("github").expect("http token present"); + assert_eq!(secret.expose_secret(), "tok"); + } + + #[test] + fn capabilities_clone_preserves_entries() { + let caps = Capabilities::new().with_api_key("google", "k".to_string()); + let cloned = caps.clone(); + assert_eq!( + cloned.get("google").map(|s| s.expose_secret().clone()), + Some("k".to_string()) + ); + assert_eq!( + caps.get("google").map(|s| s.expose_secret().clone()), + Some("k".to_string()) + ); + } + + #[test] + fn capabilities_zeroize_on_drop_clears_secret() { + let mut secret = Secret::new("sensitive".to_string()); + secret.zeroize(); + assert_eq!(secret.expose_secret(), ""); + } + + #[test] + fn capabilities_does_not_derive_serialize() { + fn assert_not_serialize() {} + assert_not_serialize::(); + } + + #[test] + fn capabilities_debug_redacts_entries() { + let caps = Capabilities::new().with_api_key("google", "sekrit".to_string()); + let s = format!("{:?}", caps); + assert!(s.contains("redacted")); + assert!(!s.contains("sekrit")); + } + + #[test] + fn secret_debug_redacts() { + let secret = Secret::new("hidden".to_string()); + assert_eq!(format!("{:?}", secret), "[REDACTED]"); + } + + #[test] + fn set_identity_once_succeeds_twice_errors() { + let conn = test_connection(); + let id = Identity { + id: "alk_test".to_string(), + scopes: vec!["relay:connect".to_string()], + resources: HashMap::new(), + }; + assert!(conn.set_identity(id.clone()).is_ok()); + assert!(matches!( + conn.set_identity(id), + Err(IdentityAlreadySet::AlreadySet) + )); + } + + #[test] + fn identity_get_returns_set_value() { + let conn = test_connection(); + assert!(conn.identity().is_none()); + let id = Identity { + id: "alk_test".to_string(), + scopes: vec![], + resources: HashMap::new(), + }; + conn.set_identity(id.clone()).unwrap(); + assert_eq!(conn.identity(), Some(&id)); + } + + #[test] + fn connection_remote_alpn_and_addr_from_bidi() { + let conn = test_connection(); + assert_eq!(conn.remote_alpn(), b"alknet/test"); + assert_eq!( + conn.remote_addr(), + Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1234)) + ); + } + + #[test] + fn stream_error_maps_to_handler_error() { + assert!(matches!( + HandlerError::from(StreamError::ConnectionClosed), + HandlerError::ConnectionClosed + )); + match HandlerError::from(StreamError::StreamClosed) { + HandlerError::StreamError(e) => assert_eq!(e.kind(), io::ErrorKind::ConnectionReset), + other => panic!("expected StreamError, got {other:?}"), + } + match HandlerError::from(StreamError::Timeout) { + HandlerError::StreamError(e) => assert_eq!(e.kind(), io::ErrorKind::TimedOut), + other => panic!("expected StreamError, got {other:?}"), + } + match HandlerError::from(StreamError::Internal(io::Error::other("x"))) { + HandlerError::StreamError(e) => assert_eq!(e.kind(), io::ErrorKind::Other), + other => panic!("expected StreamError, got {other:?}"), + } + } + + #[test] + fn handler_error_auth_required_constructible() { + let e = HandlerError::AuthRequired; + assert_eq!(format!("{e}"), "authentication required"); + } + + #[test] + fn handler_error_debug_covers_all_variants() { + assert_eq!( + format!("{:?}", HandlerError::ConnectionClosed), + "HandlerError::ConnectionClosed" + ); + let io_err = io::Error::new(io::ErrorKind::BrokenPipe, "boom"); + let dbg = format!("{:?}", HandlerError::StreamError(io_err)); + assert!(dbg.contains("HandlerError::StreamError")); + assert_eq!( + format!("{:?}", HandlerError::AuthRequired), + "HandlerError::AuthRequired" + ); + let inner: Box = "oops".into(); + let dbg = format!("{:?}", HandlerError::Internal(inner)); + assert!(dbg.contains("HandlerError::Internal")); + } + + #[test] + fn handler_error_display_covers_all_variants() { + assert_eq!( + format!("{}", HandlerError::ConnectionClosed), + "connection closed" + ); + let io_err = io::Error::new(io::ErrorKind::BrokenPipe, "boom"); + let s = format!("{}", HandlerError::StreamError(io_err)); + assert!(s.starts_with("stream error: ")); + assert_eq!( + format!("{}", HandlerError::AuthRequired), + "authentication required" + ); + let inner: Box = "oops".into(); + assert_eq!( + format!("{}", HandlerError::Internal(inner)), + "internal handler error: oops" + ); + } + + #[test] + fn handler_error_source_covers_all_variants() { + use std::error::Error; + assert!(HandlerError::ConnectionClosed.source().is_none()); + assert!(HandlerError::AuthRequired.source().is_none()); + let stream_err = + HandlerError::StreamError(io::Error::new(io::ErrorKind::BrokenPipe, "boom")); + assert!( + stream_err.source().is_some(), + "StreamError must expose its io::Error as source" + ); + let internal_inner: Box = "boom".into(); + let internal_err = HandlerError::Internal(internal_inner); + assert!( + internal_err.source().is_some(), + "Internal must expose its inner error as source" + ); + } + + #[test] + fn stream_error_debug_covers_all_variants() { + assert_eq!( + format!("{:?}", StreamError::ConnectionClosed), + "StreamError::ConnectionClosed" + ); + assert_eq!( + format!("{:?}", StreamError::StreamClosed), + "StreamError::StreamClosed" + ); + assert_eq!( + format!("{:?}", StreamError::Timeout), + "StreamError::Timeout" + ); + let dbg = format!("{:?}", StreamError::Internal(io::Error::other("x"))); + assert!(dbg.contains("StreamError::Internal")); + } + + #[test] + fn stream_error_display_covers_all_variants() { + assert_eq!( + format!("{}", StreamError::ConnectionClosed), + "connection closed" + ); + assert_eq!(format!("{}", StreamError::StreamClosed), "stream closed"); + assert_eq!(format!("{}", StreamError::Timeout), "stream timed out"); + assert_eq!( + format!("{}", StreamError::Internal(io::Error::other("boom"))), + "stream error: boom" + ); + } + + #[test] + fn stream_error_source_covers_all_variants() { + use std::error::Error; + assert!(StreamError::ConnectionClosed.source().is_none()); + assert!(StreamError::StreamClosed.source().is_none()); + assert!(StreamError::Timeout.source().is_none()); + let internal = StreamError::Internal(io::Error::other("x")); + assert!( + internal.source().is_some(), + "Internal must expose its io::Error as source" + ); + } + + #[test] + fn capabilities_default_is_empty() { + let caps = Capabilities::default(); + assert!(caps.get("anything").is_none()); + } + + #[test] + fn capabilities_zeroize_clears_entries() { + let mut caps = Capabilities::new() + .with_api_key("svc-a", "k1".to_string()) + .with_http_token("svc-b", "t1".to_string()); + assert!(caps.get("svc-a").is_some()); + assert!(caps.get("svc-b").is_some()); + caps.zeroize(); + assert!(caps.get("svc-a").is_none()); + assert!(caps.get("svc-b").is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b3987d6 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,25 @@ +//! alkcall: Call + channels RPC — operations, streaming, service discovery, +//! and N-channel multiplexing over one transport stream. +//! +//! This crate unifies `alknet-call` (structured JSON RPC: operations, +//! streaming subscriptions, service discovery) and `alknet-channels` +//! (multiplexing proxy: N logical channels over one transport stream, +//! channel 0 pre-negotiated as `alknet/call`). +//! +//! ## Architecture +//! +//! - **Vendored core types** ([`core`]): `Connection`, `ProtocolHandler`, +//! `BiStream`, `BidiStreamSource`, `AuthContext`, `IdentityProvider`, +//! `Capabilities`, `OwnershipProvider` — the home for the former +//! `alknet-core` surface. +//! - **Registry** ([`registry`]): operation specs, context, dispatch, and +//! the operation registry — the call half's dispatch core. +//! - **Protocol** ([`protocol`]): wire format, streams, adapter, dispatch +//! loop, pending requests, abort cascade — the call half's wire layer. +//! - **Client** ([`client`]): `CallClient`, `from_call`, `OperationAdapter` +//! — the call half's outbound surface. + +pub mod client; +pub mod core; +pub mod protocol; +pub mod registry; diff --git a/src/protocol/abort.rs b/src/protocol/abort.rs new file mode 100644 index 0000000..c446450 --- /dev/null +++ b/src/protocol/abort.rs @@ -0,0 +1,393 @@ +//! Abort cascade logic for nested calls (ADR-016). +//! +//! When `call.aborted` arrives for a parent request, the protocol cascades +//! the abort to all non-terminal descendants in the call tree. The default +//! policy is `abort-dependents`; `continue-running` is an opt-in for +//! long-running work that should survive a parent's abort. +//! +//! The call tree is indexed by `parent_request_id` in the +//! `PendingRequestMap`. The root request has `parent_request_id: None`; +//! each composed call has `parent_request_id: Some(parent.request_id)`. +//! Composed child request IDs are internal — they appear in the map for +//! abort-cascade indexing but are not sent as `call.requested` to any +//! peer. The client only sees `call.aborted` for the root ID it sent; the +//! server cascades internally to descendants. + +use super::pending::PendingRequestMap; +use crate::registry::context::AbortPolicy; + +pub struct AbortCascade<'a> { + pending: &'a mut PendingRequestMap, +} + +impl<'a> AbortCascade<'a> { + pub fn new(pending: &'a mut PendingRequestMap) -> Self { + Self { pending } + } + + /// Cascade an abort from the given request ID to all non-terminal + /// descendants in the call tree. Returns the list of descendant + /// request IDs that were aborted (for logging/auditing), sorted for + /// determinism. The root request itself is not touched by this + /// method — the caller is responsible for aborting the root (the + /// trigger of the cascade). + /// + /// Under `AbortDependents` (default): all descendants are aborted, + /// regardless of whether they have started. + /// + /// Under `ContinueRunning`: only descendants that have not started + /// are aborted; started descendants continue to completion. No new + /// descendants start (the parent is gone). This is the conservative + /// approximation noted in ADR-016: a descendant is "started" if + /// `PendingEntry::started` is true (the handler has begun + /// executing). A `call.aborted` for an unknown request ID is + /// silently discarded — `cascade_abort` on an unknown root returns + /// an empty list and removes nothing. + pub fn cascade_abort(&mut self, root_request_id: &str, policy: AbortPolicy) -> Vec { + if !self.pending.contains(root_request_id) { + return Vec::new(); + } + + let descendants = self.find_descendants(root_request_id); + + let mut aborted = Vec::new(); + match policy { + AbortPolicy::AbortDependents => { + for id in &descendants { + if self.pending.handle_aborted(id) { + aborted.push(id.clone()); + } + } + } + AbortPolicy::ContinueRunning => { + for id in &descendants { + let started = self.pending.is_started(id).unwrap_or(false); + if !started && self.pending.handle_aborted(id) { + aborted.push(id.clone()); + } + } + } + } + + aborted.sort(); + aborted + } + + /// Find all descendants of a request ID in the call tree by walking + /// the `parent_request_id` index. Returns descendants in + /// breadth-first order with each level's children sorted for + /// determinism. The root itself is not included in the result. + fn find_descendants(&self, parent_id: &str) -> Vec { + let mut descendants = Vec::new(); + let mut frontier: Vec = vec![parent_id.to_string()]; + + while let Some(current) = frontier.pop() { + let mut children: Vec = self + .pending + .request_ids() + .into_iter() + .filter(|id| { + self.pending + .parent_of(id) + .flatten() + .is_some_and(|p| p == current) + }) + .collect(); + children.sort(); + for child in children { + descendants.push(child.clone()); + frontier.push(child); + } + } + + descendants + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::wire::CallError; + use std::time::{Duration, Instant}; + + fn register_call(map: &mut PendingRequestMap, id: &str, parent: Option<&str>) { + map.register_call( + id.to_string(), + Instant::now() + Duration::from_secs(30), + parent.map(|p| p.to_string()), + ); + } + + fn register_subscribe(map: &mut PendingRequestMap, id: &str, parent: Option<&str>) { + map.register_subscribe(id.to_string(), None, parent.map(|p| p.to_string())); + } + + #[test] + fn cascade_abort_unknown_root_returns_empty_and_is_noop() { + let mut map = PendingRequestMap::new(); + register_call(&mut map, "r1", None); + let mut cascade = AbortCascade::new(&mut map); + let aborted = cascade.cascade_abort("does-not-exist", AbortPolicy::AbortDependents); + assert!(aborted.is_empty()); + assert!(cascade.pending.contains("r1")); + } + + #[test] + fn cascade_abort_abort_dependents_aborts_all_descendants() { + let mut map = PendingRequestMap::new(); + register_call(&mut map, "r1", None); + register_call(&mut map, "r1-a", Some("r1")); + register_call(&mut map, "r1-b", Some("r1")); + register_call(&mut map, "r1-a-1", Some("r1-a")); + register_call(&mut map, "r1-a-2", Some("r1-a")); + register_call(&mut map, "r1-b-1", Some("r1-b")); + + let mut cascade = AbortCascade::new(&mut map); + let aborted = cascade.cascade_abort("r1", AbortPolicy::AbortDependents); + + assert_eq!( + aborted, + vec![ + "r1-a".to_string(), + "r1-a-1".to_string(), + "r1-a-2".to_string(), + "r1-b".to_string(), + "r1-b-1".to_string(), + ] + ); + assert!(cascade.pending.contains("r1")); + assert!(!cascade.pending.contains("r1-a")); + assert!(!cascade.pending.contains("r1-b")); + assert!(!cascade.pending.contains("r1-a-1")); + assert!(!cascade.pending.contains("r1-a-2")); + assert!(!cascade.pending.contains("r1-b-1")); + } + + #[test] + fn cascade_abort_continue_running_aborts_only_unstarted_descendants() { + let mut map = PendingRequestMap::new(); + register_call(&mut map, "r1", None); + register_call(&mut map, "r1-a", Some("r1")); + register_call(&mut map, "r1-b", Some("r1")); + register_call(&mut map, "r1-a-1", Some("r1-a")); + + map.mark_started("r1-a"); + // r1-b and r1-a-1 are unstarted + + let mut cascade = AbortCascade::new(&mut map); + let aborted = cascade.cascade_abort("r1", AbortPolicy::ContinueRunning); + + assert_eq!(aborted, vec!["r1-a-1".to_string(), "r1-b".to_string()]); + assert!(cascade.pending.contains("r1")); + assert!(cascade.pending.contains("r1-a")); + assert!(!cascade.pending.contains("r1-b")); + assert!(!cascade.pending.contains("r1-a-1")); + } + + #[test] + fn cascade_abort_continue_running_aborts_all_when_none_started() { + let mut map = PendingRequestMap::new(); + register_call(&mut map, "r1", None); + register_call(&mut map, "r1-a", Some("r1")); + register_call(&mut map, "r1-b", Some("r1")); + + let mut cascade = AbortCascade::new(&mut map); + let aborted = cascade.cascade_abort("r1", AbortPolicy::ContinueRunning); + + assert_eq!(aborted, vec!["r1-a".to_string(), "r1-b".to_string()]); + assert!(!cascade.pending.contains("r1-a")); + assert!(!cascade.pending.contains("r1-b")); + } + + #[test] + fn cascade_abort_depth_three_aborts_all_descendants() { + let mut map = PendingRequestMap::new(); + register_call(&mut map, "root", None); + register_call(&mut map, "root-a", Some("root")); + register_call(&mut map, "root-b", Some("root")); + register_call(&mut map, "root-a-1", Some("root-a")); + register_call(&mut map, "root-a-2", Some("root-a")); + register_call(&mut map, "root-a-1-x", Some("root-a-1")); + register_call(&mut map, "root-a-1-y", Some("root-a-1")); + register_call(&mut map, "root-b-1", Some("root-b")); + + let mut cascade = AbortCascade::new(&mut map); + let aborted = cascade.cascade_abort("root", AbortPolicy::AbortDependents); + + assert_eq!( + aborted, + vec![ + "root-a".to_string(), + "root-a-1".to_string(), + "root-a-1-x".to_string(), + "root-a-1-y".to_string(), + "root-a-2".to_string(), + "root-b".to_string(), + "root-b-1".to_string(), + ] + ); + assert!(cascade.pending.contains("root")); + assert_eq!(cascade.pending.len(), 1); + } + + #[test] + fn cascade_abort_root_with_no_descendants_returns_empty() { + let mut map = PendingRequestMap::new(); + register_call(&mut map, "lonely", None); + + let mut cascade = AbortCascade::new(&mut map); + let aborted = cascade.cascade_abort("lonely", AbortPolicy::AbortDependents); + assert!(aborted.is_empty()); + assert!(cascade.pending.contains("lonely")); + } + + #[test] + fn cascade_abort_only_aborts_descendants_not_siblings() { + let mut map = PendingRequestMap::new(); + register_call(&mut map, "r1", None); + register_call(&mut map, "r2", None); + register_call(&mut map, "r1-a", Some("r1")); + register_call(&mut map, "r2-a", Some("r2")); + + let mut cascade = AbortCascade::new(&mut map); + let aborted = cascade.cascade_abort("r1", AbortPolicy::AbortDependents); + + assert_eq!(aborted, vec!["r1-a".to_string()]); + assert!(cascade.pending.contains("r1")); + assert!(cascade.pending.contains("r2")); + assert!(cascade.pending.contains("r2-a")); + assert!(!cascade.pending.contains("r1-a")); + } + + #[test] + fn cascade_abort_handles_mixed_call_and_subscribe_entries() { + let mut map = PendingRequestMap::new(); + register_call(&mut map, "r1", None); + register_subscribe(&mut map, "r1-sub", Some("r1")); + register_call(&mut map, "r1-sub-child", Some("r1-sub")); + + let mut cascade = AbortCascade::new(&mut map); + let aborted = cascade.cascade_abort("r1", AbortPolicy::AbortDependents); + + assert_eq!( + aborted, + vec!["r1-sub".to_string(), "r1-sub-child".to_string(),] + ); + assert!(cascade.pending.contains("r1")); + assert_eq!(cascade.pending.len(), 1); + } + + #[test] + fn cascade_abort_continue_running_with_started_descendant_keeps_its_unstarted_children() { + let mut map = PendingRequestMap::new(); + register_call(&mut map, "r1", None); + register_call(&mut map, "r1-a", Some("r1")); + register_call(&mut map, "r1-a-1", Some("r1-a")); + + map.mark_started("r1-a"); + // r1-a is started and continues; r1-a-1 is unstarted. + // Under ContinueRunning, r1-a-1 is aborted (conservative: still pending). + + let mut cascade = AbortCascade::new(&mut map); + let aborted = cascade.cascade_abort("r1", AbortPolicy::ContinueRunning); + + assert_eq!(aborted, vec!["r1-a-1".to_string()]); + assert!(cascade.pending.contains("r1-a")); + assert!(!cascade.pending.contains("r1-a-1")); + } + + #[test] + fn cascade_abort_abort_dependents_aborts_started_descendants_too() { + let mut map = PendingRequestMap::new(); + register_call(&mut map, "r1", None); + register_call(&mut map, "r1-a", Some("r1")); + register_call(&mut map, "r1-b", Some("r1")); + + map.mark_started("r1-a"); + map.mark_started("r1-b"); + + let mut cascade = AbortCascade::new(&mut map); + let aborted = cascade.cascade_abort("r1", AbortPolicy::AbortDependents); + + assert_eq!(aborted, vec!["r1-a".to_string(), "r1-b".to_string()]); + assert!(!cascade.pending.contains("r1-a")); + assert!(!cascade.pending.contains("r1-b")); + } + + #[test] + fn find_descendants_does_not_include_root() { + let mut map = PendingRequestMap::new(); + register_call(&mut map, "r1", None); + register_call(&mut map, "r1-a", Some("r1")); + + let cascade = AbortCascade::new(&mut map); + let descendants = cascade.find_descendants("r1"); + assert_eq!(descendants, vec!["r1-a".to_string()]); + assert!(!descendants.contains(&"r1".to_string())); + } + + #[test] + fn cascade_abort_default_policy_is_abort_dependents() { + let mut map = PendingRequestMap::new(); + register_call(&mut map, "r1", None); + register_call(&mut map, "r1-a", Some("r1")); + map.mark_started("r1-a"); + + let mut cascade = AbortCascade::new(&mut map); + let aborted_default = cascade.cascade_abort("r1", AbortPolicy::default()); + assert_eq!(aborted_default, vec!["r1-a".to_string()]); + } + + #[test] + fn cascade_abort_does_not_remove_root() { + let mut map = PendingRequestMap::new(); + register_call(&mut map, "r1", None); + register_call(&mut map, "r1-a", Some("r1")); + + let mut cascade = AbortCascade::new(&mut map); + let _ = cascade.cascade_abort("r1", AbortPolicy::AbortDependents); + assert!(cascade.pending.contains("r1")); + } + + #[test] + fn cascade_abort_returns_sorted_descendants_for_determinism() { + let mut map = PendingRequestMap::new(); + register_call(&mut map, "r1", None); + register_call(&mut map, "r1-z", Some("r1")); + register_call(&mut map, "r1-a", Some("r1")); + register_call(&mut map, "r1-m", Some("r1")); + + let mut cascade = AbortCascade::new(&mut map); + let aborted = cascade.cascade_abort("r1", AbortPolicy::AbortDependents); + assert_eq!( + aborted, + vec!["r1-a".to_string(), "r1-m".to_string(), "r1-z".to_string(),] + ); + } + + #[test] + fn unknown_request_id_silently_discarded_no_panic() { + let mut map = PendingRequestMap::new(); + let mut cascade = AbortCascade::new(&mut map); + let aborted = cascade.cascade_abort("totally-unknown", AbortPolicy::AbortDependents); + assert!(aborted.is_empty()); + } + + #[test] + fn cascade_abort_continue_running_started_descendant_survives() { + let mut map = PendingRequestMap::new(); + register_call(&mut map, "r1", None); + register_call(&mut map, "r1-a", Some("r1")); + map.mark_started("r1-a"); + + let mut cascade = AbortCascade::new(&mut map); + let aborted = cascade.cascade_abort("r1", AbortPolicy::ContinueRunning); + assert!(aborted.is_empty()); + assert!(cascade.pending.contains("r1-a")); + } + + #[test] + fn cascade_abort_handles_call_error_unused() { + let _ = CallError::internal("unused"); + } +} diff --git a/src/protocol/adapter.rs b/src/protocol/adapter.rs new file mode 100644 index 0000000..fd6f661 --- /dev/null +++ b/src/protocol/adapter.rs @@ -0,0 +1,1236 @@ +//! `CallAdapter`: implements `ProtocolHandler` for ALPN `alknet/call`. +//! +//! Accepts bidirectional streams, reads `EventEnvelope` frames, and +//! dispatches `call.requested` events to the operation registry. See +//! `docs/architecture/crates/call/call-protocol.md` for the full +//! specification. +//! +//! The dispatch loop is shared with [`crate::client::CallClient`] via +//! [`super::dispatch::Dispatcher`] (ADR-017 §1): `CallAdapter` is the +//! inbound (accept) half; `CallClient` is the outbound (connect) half; both +//! produce a [`CallConnection`] and hand it to the same `Dispatcher::run_loop`. + +use std::sync::Arc; +use std::time::Duration; + +use crate::core::auth::{AuthContext, IdentityProvider}; +use crate::core::ownership::OwnershipProvider; +use crate::core::types::{Connection, HandlerError, ProtocolHandler}; +use async_trait::async_trait; + +use super::connection::CallConnection; +use super::dispatch::Dispatcher; +use crate::registry::context::OperationContext; +use crate::registry::registration::OperationRegistry; + +#[cfg(test)] +use super::wire::ResponseEnvelope; +#[cfg(test)] +use crate::core::auth::Identity; +#[cfg(test)] +use serde_json::Value; + +pub trait SessionOverlaySource: Send + Sync { + fn overlay_for( + &self, + context: &OperationContext, + ) -> Option>; +} + +pub struct CallAdapter { + dispatcher: Dispatcher, +} + +impl CallAdapter { + pub fn new( + registry: Arc, + identity_provider: Arc, + ) -> Self { + Self { + dispatcher: Dispatcher::new(registry, identity_provider), + } + } + + pub fn with_session_source( + mut self, + source: Arc, + ) -> Self { + self.dispatcher = self.dispatcher.with_session_source(source); + self + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.dispatcher = self.dispatcher.with_timeout(timeout); + self + } + + pub fn with_ownership_provider(mut self, provider: Arc) -> Self { + self.dispatcher = self.dispatcher.with_ownership_provider(provider); + self + } + + pub fn registry(&self) -> &Arc { + &self.dispatcher.registry + } + + pub fn identity_provider(&self) -> &Arc { + &self.dispatcher.identity_provider + } + + pub fn default_timeout(&self) -> Duration { + self.dispatcher.default_timeout + } + + pub fn session_source(&self) -> Option<&Arc> { + self.dispatcher.session_source.as_ref() + } + + // --- Test-facing wrappers around the shared Dispatcher ----------------- + // These exist so the adapter's existing tests keep compiling against the + // adapter type; they delegate to the Dispatcher's shared implementation. + // Gated to test builds — the production adapter delegates through + // `handle()` -> `Dispatcher::run_loop()` directly. + + #[cfg(test)] + pub(crate) fn strip_leading_slash(operation_id: &str) -> &str { + operation_id.strip_prefix('/').unwrap_or(operation_id) + } + + #[cfg(test)] + pub(crate) fn resolve_identity( + &self, + connection_identity: Option, + payload: &Value, + ) -> Option { + self.dispatcher + .resolve_identity(connection_identity, payload) + } + + #[cfg(test)] + pub(crate) fn build_root_context( + &self, + request_id: String, + operation_name: &str, + identity: Option, + forwarded_for: Option, + connection: &CallConnection, + ) -> OperationContext { + self.dispatcher.build_root_context( + request_id, + operation_name, + identity, + forwarded_for, + connection, + ) + } + + #[cfg(test)] + pub(crate) async fn dispatch_requested( + &self, + connection: &Arc, + request_id: String, + payload: Value, + ) -> ResponseEnvelope { + self.dispatcher + .dispatch_requested(connection, request_id, payload) + .await + } + + #[cfg(test)] + pub(crate) async fn handle_stream( + &self, + connection: Arc, + stream: crate::core::types::BiStream, + ) { + self.dispatcher.handle_stream(connection, stream).await; + } +} + +#[async_trait] +impl ProtocolHandler for CallAdapter { + fn alpn(&self) -> &'static [u8] { + b"alknet/call" + } + + async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError> { + if let Some(identity) = auth.identity.clone() { + let _ = connection.set_identity(identity); + } + + let call_connection = Arc::new(CallConnection::new(connection)); + self.dispatcher.clone().run_loop(call_connection).await; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::auth::AuthToken; + use crate::core::types::Capabilities; + use crate::protocol::wire::{ + CallError, EventEnvelope, EVENT_COMPLETED, EVENT_ERROR, EVENT_RESPONDED, + }; + use crate::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv}; + use crate::registry::env::OperationEnv; + use crate::registry::registration::{ + make_handler, HandlerKind, HandlerRegistration, OperationProvenance, + }; + use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility}; + use std::collections::HashMap; + use std::sync::Mutex as StdMutex; + use std::time::{Duration, Instant}; + + use crate::protocol::sink_empty_connection as stub_connection; + + struct StaticIdentityProvider { + tokens: StdMutex>, + } + + impl StaticIdentityProvider { + fn new() -> Self { + Self { + tokens: StdMutex::new(HashMap::new()), + } + } + + fn with_token(self, token: &str, identity: Identity) -> Self { + self.tokens + .lock() + .unwrap() + .insert(token.to_string(), identity); + self + } + } + + impl IdentityProvider for StaticIdentityProvider { + fn resolve_from_fingerprint(&self, _fp: &str) -> Option { + None + } + fn resolve_from_token(&self, token: &AuthToken) -> Option { + let token_str = String::from_utf8_lossy(&token.raw); + self.tokens.lock().unwrap().get(token_str.as_ref()).cloned() + } + } + + fn identity_with_scopes(id: &str, scopes: &[&str]) -> Identity { + Identity { + id: id.to_string(), + scopes: scopes.iter().map(|s| s.to_string()).collect(), + resources: HashMap::new(), + } + } + + fn external_spec(name: &str, acl: AccessControl) -> OperationSpec { + OperationSpec::new( + name, + OperationType::Query, + Visibility::External, + serde_json::json!({}), + serde_json::json!({}), + vec![], + acl, + None, + ) + } + + #[allow(dead_code)] + fn internal_spec(name: &str) -> OperationSpec { + OperationSpec::new( + name, + OperationType::Query, + Visibility::Internal, + serde_json::json!({}), + serde_json::json!({}), + vec![], + AccessControl::default(), + None, + ) + } + + fn registry_with( + name: &str, + visibility: Visibility, + acl: AccessControl, + handler: crate::registry::registration::Handler, + ) -> Arc { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + OperationSpec::new( + name, + OperationType::Query, + visibility, + serde_json::json!({}), + serde_json::json!({}), + vec![], + acl, + None, + ), + HandlerKind::Once(handler), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + Arc::new(registry) + } + + fn echo_handler() -> crate::registry::registration::Handler { + make_handler( + |input, context| async move { ResponseEnvelope::ok(context.request_id, input) }, + ) + } + + fn inspect_identity_handler() -> crate::registry::registration::Handler { + make_handler(|_input, context| async move { + let id = context.identity.as_ref().map(|i| i.id.clone()); + ResponseEnvelope::ok(context.request_id, serde_json::json!({ "identity_id": id })) + }) + } + + #[test] + fn alpn_returns_alknet_call() { + let registry = Arc::new(OperationRegistry::new()); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + assert_eq!(adapter.alpn(), b"alknet/call"); + } + + #[test] + fn constructors_set_fields() { + let registry = Arc::new(OperationRegistry::new()); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(Arc::clone(®istry), Arc::clone(&provider)) + .with_timeout(Duration::from_secs(60)); + assert_eq!(adapter.default_timeout(), Duration::from_secs(60)); + assert!(Arc::ptr_eq(adapter.registry(), ®istry)); + assert!(adapter.session_source().is_none()); + } + + #[test] + fn strip_leading_slash_removes_prefix() { + assert_eq!( + CallAdapter::strip_leading_slash("/fs/readFile"), + "fs/readFile" + ); + assert_eq!( + CallAdapter::strip_leading_slash("fs/readFile"), + "fs/readFile" + ); + assert_eq!( + CallAdapter::strip_leading_slash("/services/list"), + "services/list" + ); + } + + #[test] + fn resolve_identity_uses_connection_identity_when_no_token() { + let registry = Arc::new(OperationRegistry::new()); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn_id = identity_with_scopes("caller", &["user"]); + let payload = serde_json::json!({ "operationId": "/echo/run", "input": {} }); + let resolved = adapter.resolve_identity(Some(conn_id.clone()), &payload); + assert_eq!(resolved, Some(conn_id)); + } + + #[test] + fn resolve_identity_token_overrides_connection_identity() { + let registry = Arc::new(OperationRegistry::new()); + let token_identity = identity_with_scopes("admin", &["admin"]); + let provider: Arc = Arc::new( + StaticIdentityProvider::new().with_token("alk_secret", token_identity.clone()), + ); + let adapter = CallAdapter::new(registry, provider); + let conn_id = identity_with_scopes("caller", &["user"]); + let payload = serde_json::json!({ + "operationId": "/echo/run", + "input": {}, + "auth_token": "alk_secret", + }); + let resolved = adapter.resolve_identity(Some(conn_id), &payload); + assert_eq!(resolved.as_ref().map(|i| &i.id), Some(&"admin".to_string())); + } + + #[test] + fn resolve_identity_token_failure_falls_back_to_connection_identity() { + let registry = Arc::new(OperationRegistry::new()); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn_id = identity_with_scopes("caller", &["user"]); + let payload = serde_json::json!({ + "operationId": "/echo/run", + "input": {}, + "auth_token": "alk_unknown", + }); + let resolved = adapter.resolve_identity(Some(conn_id.clone()), &payload); + assert_eq!(resolved, Some(conn_id)); + } + + #[test] + fn resolve_identity_token_failure_with_no_connection_identity_returns_none() { + let registry = Arc::new(OperationRegistry::new()); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let payload = serde_json::json!({ + "operationId": "/echo/run", + "input": {}, + "auth_token": "alk_unknown", + }); + let resolved = adapter.resolve_identity(None, &payload); + assert!(resolved.is_none()); + } + + #[tokio::test] + async fn build_root_context_sets_internal_false_and_deadline() { + let registry = registry_with( + "echo/run", + Visibility::External, + AccessControl::default(), + echo_handler(), + ); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = CallConnection::new(stub_connection()); + + let context = + adapter.build_root_context("req-1".to_string(), "echo/run", None, None, &conn); + + assert!(!context.is_internal()); + assert!(context.parent_request_id.is_none()); + assert!(context.deadline.is_some()); + } + + #[tokio::test] + async fn build_root_context_carries_capabilities_and_scoped_env() { + let mut registry = OperationRegistry::new(); + let scoped = ScopedPeerEnv::new(["fs/readFile"]); + let caps = Capabilities::new().with_api_key("google", "k".to_string()); + registry + .register(HandlerRegistration::new( + external_spec("agent/run", AccessControl::default()), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + Some(scoped.clone()), + caps.clone(), + )) + .unwrap(); + let registry = Arc::new(registry); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = CallConnection::new(stub_connection()); + + let context = + adapter.build_root_context("req-2".to_string(), "agent/run", None, None, &conn); + + assert!(context.scoped_env.allows("fs/readFile")); + assert!(!context.scoped_env.allows("other/op")); + } + + #[tokio::test] + async fn compose_root_env_aggregates_layers() { + let registry = registry_with( + "fs/readFile", + Visibility::External, + AccessControl::default(), + echo_handler(), + ); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry.clone(), provider); + let conn = CallConnection::new(stub_connection()); + + let context = + adapter.build_root_context("req-3".to_string(), "fs/readFile", None, None, &conn); + + assert!(context.env.contains("fs/readFile")); + } + + struct StubSessionOverlay { + env: Option>, + } + + impl SessionOverlaySource for StubSessionOverlay { + fn overlay_for( + &self, + _context: &OperationContext, + ) -> Option> { + self.env.clone() + } + } + + struct StaticEnv { + name: String, + contains_set: Vec, + } + + #[async_trait::async_trait] + impl OperationEnv for StaticEnv { + async fn invoke_with_policy( + &self, + _namespace: &str, + _operation: &str, + _input: Value, + parent: &OperationContext, + _policy: AbortPolicy, + ) -> ResponseEnvelope { + ResponseEnvelope::ok(parent.request_id.clone(), Value::String(self.name.clone())) + } + + fn contains(&self, name: &str) -> bool { + self.contains_set.iter().any(|n| n == name) + } + } + + #[tokio::test] + async fn compose_root_env_uses_session_overlay_when_present() { + let registry = registry_with( + "fs/readFile", + Visibility::External, + AccessControl::default(), + echo_handler(), + ); + let session_env: Arc = Arc::new(StaticEnv { + name: "session".to_string(), + contains_set: vec!["agent/chat".to_string()], + }); + let session_source: Arc = + Arc::new(StubSessionOverlay { + env: Some(session_env), + }); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider).with_session_source(session_source); + let conn = CallConnection::new(stub_connection()); + + let context = + adapter.build_root_context("req-4".to_string(), "fs/readFile", None, None, &conn); + + assert!(context.env.contains("agent/chat")); + assert!(context.env.contains("fs/readFile")); + } + + #[tokio::test] + async fn compose_root_env_attaches_peer_when_connection_has_identity() { + let registry = registry_with( + "fs/readFile", + Visibility::External, + AccessControl::default(), + echo_handler(), + ); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = CallConnection::new(stub_connection()); + let imported = HandlerRegistration::new( + OperationSpec::new( + "worker/exec", + OperationType::Query, + Visibility::Internal, + serde_json::json!({}), + serde_json::json!({}), + vec![], + AccessControl::default(), + None, + ), + HandlerKind::Once(echo_handler()), + OperationProvenance::FromCall, + None, + None, + Capabilities::new(), + ); + conn.register_imported(imported); + let peer_identity = Identity { + id: "worker-a".to_string(), + scopes: vec![], + resources: HashMap::new(), + }; + conn.connection() + .expect("quic connection present") + .set_identity(peer_identity) + .expect("identity not yet set"); + + let context = + adapter.build_root_context("req-5".to_string(), "fs/readFile", None, None, &conn); + + let scoped = ScopedPeerEnv::new(["worker/exec"]); + let invoke_ctx = OperationContext { + request_id: "req-5".to_string(), + parent_request_id: None, + identity: None, + handler_identity: None, + forwarded_for: None, + capabilities: Capabilities::new(), + metadata: HashMap::new(), + scoped_env: scoped, + env: context.env.clone(), + abort_policy: AbortPolicy::default(), + deadline: context.deadline, + internal: false, + ownership: None, + }; + let response = context + .env + .invoke("worker", "exec", serde_json::json!({"v": 1}), &invoke_ctx) + .await; + assert!( + response.result.is_ok(), + "peer overlay dispatches the imported op when identity is attached" + ); + assert_eq!(response.result.unwrap(), serde_json::json!({"v": 1})); + } + + #[tokio::test] + async fn compose_root_env_does_not_attach_peer_when_connection_has_no_identity() { + let registry = registry_with( + "fs/readFile", + Visibility::External, + AccessControl::default(), + echo_handler(), + ); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = CallConnection::new(stub_connection()); + let imported = HandlerRegistration::new( + OperationSpec::new( + "worker/exec", + OperationType::Query, + Visibility::Internal, + serde_json::json!({}), + serde_json::json!({}), + vec![], + AccessControl::default(), + None, + ), + HandlerKind::Once(echo_handler()), + OperationProvenance::FromCall, + None, + None, + Capabilities::new(), + ); + conn.register_imported(imported); + + let context = + adapter.build_root_context("req-6".to_string(), "fs/readFile", None, None, &conn); + + let scoped = ScopedPeerEnv::new(["worker/exec"]); + let invoke_ctx = OperationContext { + request_id: "req-6".to_string(), + parent_request_id: None, + identity: None, + handler_identity: None, + forwarded_for: None, + capabilities: Capabilities::new(), + metadata: HashMap::new(), + scoped_env: scoped, + env: context.env.clone(), + abort_policy: AbortPolicy::default(), + deadline: context.deadline, + internal: false, + ownership: None, + }; + let response = context + .env + .invoke("worker", "exec", serde_json::json!({}), &invoke_ctx) + .await; + match response.result { + Err(e) => assert_eq!( + e.code, "NOT_FOUND", + "no peer overlay attached: op falls through to base registry which has no worker/exec" + ), + other => panic!("expected NOT_FOUND, got {other:?}"), + } + } + + #[tokio::test] + async fn dispatch_requested_round_trip_returns_responded() { + let registry = registry_with( + "echo/run", + Visibility::External, + AccessControl::default(), + echo_handler(), + ); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/echo/run", + "input": { "msg": "hi" }, + }); + let response = adapter + .dispatch_requested(&conn, "req-1".to_string(), payload) + .await; + + assert_eq!(response.request_id, "req-1"); + assert_eq!(response.result, Ok(serde_json::json!({ "msg": "hi" }))); + } + + #[tokio::test] + async fn dispatch_requested_internal_op_from_wire_returns_not_found() { + let registry = registry_with( + "secret/op", + Visibility::Internal, + AccessControl::default(), + echo_handler(), + ); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/secret/op", + "input": {}, + }); + let response = adapter + .dispatch_requested(&conn, "req-2".to_string(), payload) + .await; + + match response.result { + Err(e) => assert_eq!(e.code, "NOT_FOUND"), + other => panic!("expected NOT_FOUND, got {other:?}"), + } + } + + #[tokio::test] + async fn dispatch_requested_acl_denied_returns_forbidden() { + let registry = registry_with( + "admin/run", + Visibility::External, + AccessControl { + required_scopes: vec!["admin".to_string()], + ..Default::default() + }, + echo_handler(), + ); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/admin/run", + "input": {}, + }); + let response = adapter + .dispatch_requested(&conn, "req-3".to_string(), payload) + .await; + + match response.result { + Err(e) => { + assert_eq!(e.code, "FORBIDDEN"); + assert_eq!(e.message, "authentication required"); + } + other => panic!("expected FORBIDDEN, got {other:?}"), + } + } + + #[tokio::test] + async fn dispatch_requested_unknown_op_returns_not_found() { + let registry = Arc::new(OperationRegistry::new()); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/missing/op", + "input": {}, + }); + let response = adapter + .dispatch_requested(&conn, "req-4".to_string(), payload) + .await; + + match response.result { + Err(e) => assert_eq!(e.code, "NOT_FOUND"), + other => panic!("expected NOT_FOUND, got {other:?}"), + } + } + + #[tokio::test] + async fn dispatch_requested_auth_token_overrides_connection_identity() { + let registry = registry_with( + "admin/run", + Visibility::External, + AccessControl { + required_scopes: vec!["admin".to_string()], + ..Default::default() + }, + inspect_identity_handler(), + ); + let token_identity = identity_with_scopes("admin-user", &["admin"]); + let provider: Arc = + Arc::new(StaticIdentityProvider::new().with_token("alk_admin", token_identity)); + let adapter = CallAdapter::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/admin/run", + "input": {}, + "auth_token": "alk_admin", + }); + let response = adapter + .dispatch_requested(&conn, "req-5".to_string(), payload) + .await; + + let out = response.result.expect("ok"); + assert_eq!(out["identity_id"], Value::String("admin-user".into())); + } + + #[tokio::test] + async fn dispatch_requested_no_leading_slash_still_resolves() { + let registry = registry_with( + "echo/run", + Visibility::External, + AccessControl::default(), + echo_handler(), + ); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "echo/run", + "input": { "x": 1 }, + }); + let response = adapter + .dispatch_requested(&conn, "req-6".to_string(), payload) + .await; + + assert_eq!(response.result, Ok(serde_json::json!({ "x": 1 }))); + } + + #[tokio::test] + async fn response_envelope_ok_converts_to_call_responded_event() { + let response = ResponseEnvelope::ok("req-1", Value::String("hi".into())); + let event: EventEnvelope = response.into(); + assert_eq!(event.r#type, EVENT_RESPONDED); + assert_eq!(event.id, "req-1"); + assert_eq!( + event.payload.get("output"), + Some(&Value::String("hi".into())) + ); + } + + #[tokio::test] + async fn response_envelope_error_converts_to_call_error_event() { + let response = ResponseEnvelope::error("req-2", CallError::not_found("missing/op")); + let event: EventEnvelope = response.into(); + assert_eq!(event.r#type, EVENT_ERROR); + assert_eq!(event.id, "req-2"); + assert_eq!( + event.payload.get("code"), + Some(&Value::String("NOT_FOUND".into())) + ); + } + + #[tokio::test] + async fn completed_event_has_empty_payload() { + let event = EventEnvelope::completed("sub-1"); + assert_eq!(event.r#type, EVENT_COMPLETED); + assert_eq!(event.payload, serde_json::json!({})); + } + + #[tokio::test] + async fn handle_sets_connection_identity_from_auth_context() { + let registry = Arc::new(OperationRegistry::new()); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + + let conn = stub_connection(); + let auth = AuthContext { + identity: Some(identity_with_scopes("caller", &["user"])), + alpn: b"alknet/call".to_vec(), + remote_addr: None, + tls_client_fingerprint: None, + }; + + let handle_conn = conn; + let result = adapter.handle(handle_conn, &auth).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn handle_returns_ok_when_accept_bi_returns_stream_closed() { + let registry = Arc::new(OperationRegistry::new()); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = stub_connection(); + let auth = AuthContext { + identity: None, + alpn: b"alknet/call".to_vec(), + remote_addr: None, + tls_client_fingerprint: None, + }; + let result = adapter.handle(conn, &auth).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn handle_fail_all_on_close_uses_internal_error() { + let registry = Arc::new(OperationRegistry::new()); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = stub_connection(); + let auth = AuthContext { + identity: None, + alpn: b"alknet/call".to_vec(), + remote_addr: None, + tls_client_fingerprint: None, + }; + let result = adapter.handle(conn, &auth).await; + assert!(result.is_ok()); + } + + #[test] + fn session_overlay_source_trait_is_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } + + #[test] + fn call_adapter_is_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } + + #[tokio::test] + async fn build_root_context_with_unknown_op_produces_empty_scoped_env() { + let registry = Arc::new(OperationRegistry::new()); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = CallConnection::new(stub_connection()); + + let context = + adapter.build_root_context("req-7".to_string(), "missing/op", None, None, &conn); + + assert!(!context.scoped_env.allows("missing/op")); + assert!(context.handler_identity.is_none()); + } + + #[tokio::test] + async fn dispatch_requested_for_internal_spec_does_not_invoke_handler() { + let registry = registry_with( + "secret/op", + Visibility::Internal, + AccessControl::default(), + echo_handler(), + ); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/secret/op", + "input": { "should_not": "reach" }, + }); + let response = adapter + .dispatch_requested(&conn, "req-8".to_string(), payload) + .await; + match response.result { + Err(e) => { + assert_eq!(e.code, "NOT_FOUND"); + assert!(e.message.contains("secret/op")); + } + other => panic!("expected NOT_FOUND, got {other:?}"), + } + } + + fn inspect_forwarded_for_handler() -> crate::registry::registration::Handler { + make_handler(|_input, context| async move { + let identity_id = context.identity.as_ref().map(|i| i.id.clone()); + let forwarded_for_id = context.forwarded_for.as_ref().map(|i| i.id.clone()); + ResponseEnvelope::ok( + context.request_id, + serde_json::json!({ + "identity_id": identity_id, + "forwarded_for_id": forwarded_for_id, + }), + ) + }) + } + + #[test] + fn build_root_context_populates_forwarded_for_from_argument() { + let registry = registry_with( + "echo/run", + Visibility::External, + AccessControl::default(), + echo_handler(), + ); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = CallConnection::new(stub_connection()); + + let forwarded = identity_with_scopes("alice", &["fs:read"]); + let context = adapter.build_root_context( + "req-ff-1".to_string(), + "echo/run", + None, + Some(forwarded.clone()), + &conn, + ); + + assert_eq!( + context.forwarded_for.as_ref().map(|i| &i.id), + Some(&"alice".to_string()) + ); + assert_eq!( + context.forwarded_for.as_ref().map(|i| i.scopes.clone()), + Some(forwarded.scopes.clone()) + ); + } + + #[test] + fn build_root_context_missing_forwarded_for_is_none() { + let registry = registry_with( + "echo/run", + Visibility::External, + AccessControl::default(), + echo_handler(), + ); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = CallConnection::new(stub_connection()); + + let context = + adapter.build_root_context("req-ff-2".to_string(), "echo/run", None, None, &conn); + + assert!(context.forwarded_for.is_none()); + } + + #[tokio::test] + async fn dispatch_requested_populates_forwarded_for_from_payload() { + let registry = registry_with( + "inspect/run", + Visibility::External, + AccessControl::default(), + inspect_forwarded_for_handler(), + ); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/inspect/run", + "input": {}, + "forwarded_for": { + "id": "alice", + "scopes": ["fs:read", "docker:start"], + "resources": {}, + }, + }); + let response = adapter + .dispatch_requested(&conn, "req-ff-3".to_string(), payload) + .await; + + let out = response.result.expect("ok"); + assert_eq!(out["forwarded_for_id"], Value::String("alice".into())); + } + + #[tokio::test] + async fn dispatch_requested_missing_forwarded_for_yields_none() { + let registry = registry_with( + "inspect/run", + Visibility::External, + AccessControl::default(), + inspect_forwarded_for_handler(), + ); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/inspect/run", + "input": {}, + }); + let response = adapter + .dispatch_requested(&conn, "req-ff-4".to_string(), payload) + .await; + + let out = response.result.expect("ok"); + assert!(out["forwarded_for_id"].is_null()); + } + + #[tokio::test] + async fn dispatch_requested_malformed_forwarded_for_yields_none() { + let registry = registry_with( + "inspect/run", + Visibility::External, + AccessControl::default(), + inspect_forwarded_for_handler(), + ); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/inspect/run", + "input": {}, + "forwarded_for": "not-an-object", + }); + let response = adapter + .dispatch_requested(&conn, "req-ff-5".to_string(), payload) + .await; + + let out = response.result.expect("ok"); + assert!(out["forwarded_for_id"].is_null()); + } + + #[tokio::test] + async fn dispatch_requested_forwarded_for_does_not_satisfy_acl() { + let registry = registry_with( + "admin/run", + Visibility::External, + AccessControl { + required_scopes: vec!["admin".to_string()], + ..Default::default() + }, + inspect_forwarded_for_handler(), + ); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/admin/run", + "input": {}, + "forwarded_for": { + "id": "alice", + "scopes": ["admin"], + "resources": {}, + }, + }); + let response = adapter + .dispatch_requested(&conn, "req-ff-6".to_string(), payload) + .await; + + match response.result { + Err(e) => { + assert_eq!(e.code, "FORBIDDEN"); + assert_eq!(e.message, "authentication required"); + } + other => panic!("expected FORBIDDEN (forwarded_for must not authorize), got {other:?}"), + } + } + + #[tokio::test] + async fn dispatch_requested_forwarded_for_present_with_satisfied_acl() { + let registry = registry_with( + "admin/run", + Visibility::External, + AccessControl { + required_scopes: vec!["admin".to_string()], + ..Default::default() + }, + inspect_forwarded_for_handler(), + ); + let token_identity = identity_with_scopes("hub", &["admin"]); + let provider: Arc = + Arc::new(StaticIdentityProvider::new().with_token("alk_hub", token_identity)); + let adapter = CallAdapter::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/admin/run", + "input": {}, + "auth_token": "alk_hub", + "forwarded_for": { + "id": "alice", + "scopes": ["fs:read"], + "resources": {}, + }, + }); + let response = adapter + .dispatch_requested(&conn, "req-ff-7".to_string(), payload) + .await; + + let out = response.result.expect("ok"); + assert_eq!(out["identity_id"], Value::String("hub".into())); + assert_eq!(out["forwarded_for_id"], Value::String("alice".into())); + } + + fn encode_frame(envelope: &EventEnvelope) -> Vec { + let body = serde_json::to_vec(envelope).unwrap(); + let mut buf = (body.len() as u32).to_be_bytes().to_vec(); + buf.extend_from_slice(&body); + buf + } + + #[tokio::test] + async fn handle_stream_aborted_cascades_parent_and_child() { + let registry = registry_with( + "parent/run", + Visibility::External, + AccessControl::default(), + echo_handler(), + ); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + { + let mut pending = conn.pending().lock(); + pending.register_call( + "parent-1".to_string(), + Instant::now() + Duration::from_secs(30), + None, + ); + pending.register_call( + "child-1".to_string(), + Instant::now() + Duration::from_secs(30), + Some("parent-1".to_string()), + ); + } + + let frame = encode_frame(&EventEnvelope::aborted("parent-1")); + let recv = tokio::io::BufReader::new(std::io::Cursor::new(frame)); + let (send, _recv_sink) = tokio::io::duplex(64); + let stream = crate::core::types::BiStream::from_joined(recv, send); + + adapter.handle_stream(conn.clone(), stream).await; + + let pending = conn.pending().lock(); + assert!( + !pending.contains("parent-1"), + "parent entry must be removed after abort" + ); + assert!( + !pending.contains("child-1"), + "child entry must be removed by cascade" + ); + } + + #[tokio::test] + async fn handle_stream_aborted_unknown_request_id_is_noop() { + let registry = registry_with( + "parent/run", + Visibility::External, + AccessControl::default(), + echo_handler(), + ); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let adapter = CallAdapter::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + { + let mut pending = conn.pending().lock(); + pending.register_call( + "unrelated-1".to_string(), + Instant::now() + Duration::from_secs(30), + None, + ); + } + + let frame = encode_frame(&EventEnvelope::aborted("does-not-exist")); + let recv = tokio::io::BufReader::new(std::io::Cursor::new(frame)); + let (send, _recv_sink) = tokio::io::duplex(64); + let stream = crate::core::types::BiStream::from_joined(recv, send); + + adapter.handle_stream(conn.clone(), stream).await; + + let pending = conn.pending().lock(); + assert!( + pending.contains("unrelated-1"), + "unrelated entry must survive abort of unknown id" + ); + } +} diff --git a/src/protocol/connection.rs b/src/protocol/connection.rs new file mode 100644 index 0000000..c9beaba --- /dev/null +++ b/src/protocol/connection.rs @@ -0,0 +1,1024 @@ +//! `CallConnection`: an established `alknet/call` connection (either +//! direction — accepted or opened). Holds the connection's Layer 2 overlay +//! (imported ops). +//! +//! See `docs/architecture/crates/call/call-protocol.md` for the full +//! specification. + +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::{Duration, Instant}; + +use crate::core::auth::Identity; +use crate::core::types::Connection; +use futures::stream::Stream; +use parking_lot::{Mutex, RwLock}; +use serde_json::Value; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::sync::mpsc; + +use super::pending::PendingRequestMap; +use super::wire::{ + CallError, EventEnvelope, FrameFramedReader, FrameFramedWriter, EVENT_ABORTED, EVENT_COMPLETED, + EVENT_ERROR, EVENT_RESPONDED, +}; +use crate::protocol::wire::ResponseEnvelope; +use crate::registry::context::{generate_request_id, AbortPolicy, OperationContext, ScopedPeerEnv}; +use crate::registry::env::OperationEnv; +use crate::registry::registration::{HandlerKind, HandlerRegistration}; +use crate::registry::spec::AccessResult; + +const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(30); + +pub struct CallConnection { + connection: Option>, + stored_identity: Option, + imported_operations: Arc>>, + pending: Arc>, +} + +impl Clone for CallConnection { + fn clone(&self) -> Self { + Self { + connection: self.connection.clone(), + stored_identity: self.stored_identity.clone(), + imported_operations: Arc::clone(&self.imported_operations), + pending: Arc::clone(&self.pending), + } + } +} + +impl CallConnection { + pub fn new(connection: Connection) -> Self { + Self { + connection: Some(Arc::new(connection)), + stored_identity: None, + imported_operations: Arc::new(RwLock::new(HashMap::new())), + pending: Arc::new(Mutex::new(PendingRequestMap::new())), + } + } + + pub fn new_overlay_only(identity: Identity) -> Self { + Self { + connection: None, + stored_identity: Some(identity), + imported_operations: Arc::new(RwLock::new(HashMap::new())), + pending: Arc::new(Mutex::new(PendingRequestMap::new())), + } + } + + pub fn connection(&self) -> Option<&Arc> { + self.connection.as_ref() + } + + pub fn identity(&self) -> Option<&Identity> { + match &self.connection { + Some(c) => c.identity(), + None => self.stored_identity.as_ref(), + } + } + + pub fn pending(&self) -> &Arc> { + &self.pending + } + + pub fn register_imported(&self, registration: HandlerRegistration) { + let name = registration.spec.name.clone(); + self.imported_operations.write().insert(name, registration); + } + + pub fn register_imported_all(&self, registrations: Vec) { + let mut overlay = self.imported_operations.write(); + for reg in registrations { + overlay.insert(reg.spec.name.clone(), reg); + } + } + + pub fn overlay_env(&self) -> Arc { + Arc::new(OverlayOperationEnv { + overlay: Arc::clone(&self.imported_operations), + }) + } + + pub async fn call(&self, operation_id: &str, input: Value) -> ResponseEnvelope { + let payload = serde_json::json!({ + "operationId": operation_id, + "input": input, + }); + self.call_with_payload(payload).await + } + + /// Invoke a remote op with a caller-constructed `call.requested` payload. + /// The payload MUST include `operationId` and `input`; the caller may add + /// `forwarded_for` (ADR-032) and `auth_token` (ADR-017 §7) for the hub + /// forwarding path used by `from_call`. + pub async fn call_with_payload(&self, payload: Value) -> ResponseEnvelope { + let request_id = generate_request_id(); + + let connection = match &self.connection { + Some(c) => c, + None => { + return ResponseEnvelope::error( + request_id, + CallError::internal("no underlying connection (overlay-only)"), + ); + } + }; + + // `open_bi` returns a `BiStream` (ADR-092); split it into halves for + // the call protocol's separate write (request) and read (response) + // pumps. The split is the stdlib idiom; no per-handler wrapper. + let stream = match connection.open_bi().await { + Ok(s) => s, + Err(err) => { + let call_error = CallError::internal(format!("failed to open stream: {err}")); + return ResponseEnvelope::error(request_id, call_error); + } + }; + let (recv, send) = tokio::io::split(stream); + + let receiver = { + let mut pending = self.pending.lock(); + pending.register_call( + request_id.clone(), + Instant::now() + DEFAULT_CALL_TIMEOUT, + None, + ) + }; + + if let Err(err) = self.write_request(send, &request_id, payload).await { + let call_error = CallError::internal(err); + self.pending + .lock() + .handle_error(&request_id, call_error.clone()); + return ResponseEnvelope::error(request_id, call_error); + } + + let pending = Arc::clone(&self.pending); + tokio::spawn(async move { + read_stream_until_closed(recv, &pending).await; + }); + + match receiver.await { + Ok(Ok(value)) => ResponseEnvelope::ok(request_id, value), + Ok(Err(error)) => ResponseEnvelope::error(request_id, error), + Err(_) => ResponseEnvelope::error(request_id, CallError::internal("request cancelled")), + } + } + + pub async fn subscribe( + &self, + operation_id: &str, + input: Value, + ) -> impl Stream { + let payload = serde_json::json!({ + "operationId": operation_id, + "input": input, + }); + self.subscribe_with_payload(payload).await + } + + /// Subscribe to a remote op with a caller-constructed `call.requested` + /// payload. The payload MUST include `operationId` and `input`; the + /// caller may add `forwarded_for` (ADR-032) and `auth_token` (ADR-017 §7) + /// for the hub forwarding path used by `from_call`'s streaming forwarding + /// handler. Mirrors [`call_with_payload`](Self::call_with_payload) so the + /// forwarding handler can populate `forwarded_for` + `auth_token` on the + /// subscription payload (the plain [`subscribe`](Self::subscribe) builds + /// the payload internally and omits those fields). + pub async fn subscribe_with_payload( + &self, + payload: Value, + ) -> impl Stream { + let request_id = generate_request_id(); + + let connection = match &self.connection { + Some(c) => c, + None => { + let call_error = CallError::internal("no underlying connection (overlay-only)"); + return SubscriptionStream::closed(request_id, call_error); + } + }; + + // `open_bi` returns a `BiStream` (ADR-092); split for the separate + // write (request) and read (subscription events) pumps. + let stream = match connection.open_bi().await { + Ok(s) => s, + Err(err) => { + let call_error = CallError::internal(format!("failed to open stream: {err}")); + return SubscriptionStream::closed(request_id, call_error); + } + }; + let (recv, send) = tokio::io::split(stream); + + let receiver = { + let mut pending = self.pending.lock(); + pending.register_subscribe(request_id.clone(), None, None) + }; + + if let Err(err) = self.write_request(send, &request_id, payload).await { + let call_error = CallError::internal(err); + self.pending + .lock() + .handle_error(&request_id, call_error.clone()); + return SubscriptionStream::closed(request_id, call_error); + } + + let pending = Arc::clone(&self.pending); + tokio::spawn(async move { + read_stream_until_closed(recv, &pending).await; + }); + + SubscriptionStream::new(request_id, receiver) + } + + pub async fn abort(&self, request_id: &str) { + let envelope = EventEnvelope::aborted(request_id); + if let Err(err) = self.write_envelope(&envelope).await { + tracing::warn!(error = %err, request_id, "failed to send call.aborted"); + return; + } + self.pending.lock().handle_aborted(request_id); + } + + async fn write_request( + &self, + send: W, + request_id: &str, + payload: Value, + ) -> Result<(), String> + where + W: AsyncWrite + Unpin, + { + let envelope = EventEnvelope::requested(request_id, payload); + let mut writer = FrameFramedWriter::new(send); + writer + .write_frame(&envelope) + .await + .map_err(|e| format!("failed to write frame: {e}")) + } + + async fn write_envelope(&self, envelope: &EventEnvelope) -> Result<(), String> { + let connection = self + .connection + .as_ref() + .ok_or_else(|| "no underlying connection (overlay-only)".to_string())?; + // `open_bi` returns a `BiStream` (ADR-092). We only need the write + // half to send the envelope; split and drop the read half. + let stream = connection + .open_bi() + .await + .map_err(|e| format!("failed to open stream: {e}"))?; + let (_recv, send) = tokio::io::split(stream); + let mut writer = FrameFramedWriter::new(send); + writer + .write_frame(envelope) + .await + .map_err(|e| format!("failed to write frame: {e}")) + } +} + +async fn read_stream_until_closed(recv: R, pending: &Arc>) +where + R: AsyncRead + Unpin, +{ + let mut reader = FrameFramedReader::new(recv); + while let Ok(envelope) = reader.read_frame().await { + dispatch_envelope(pending, envelope); + } +} + +fn dispatch_envelope(pending: &Arc>, envelope: EventEnvelope) { + let request_id = envelope.id.clone(); + match envelope.r#type.as_str() { + EVENT_RESPONDED => { + let output = envelope + .payload + .get("output") + .cloned() + .unwrap_or(Value::Null); + pending.lock().handle_responded(&request_id, output); + } + EVENT_COMPLETED => { + pending.lock().handle_completed(&request_id); + } + EVENT_ABORTED => { + pending.lock().handle_aborted(&request_id); + } + EVENT_ERROR => { + if let Ok(error) = serde_json::from_value::(envelope.payload) { + pending.lock().handle_error(&request_id, error); + } + } + _ => {} + } +} + +struct OverlayOperationEnv { + overlay: Arc>>, +} + +#[async_trait::async_trait] +impl OperationEnv for OverlayOperationEnv { + async fn invoke_with_policy( + &self, + namespace: &str, + operation: &str, + input: Value, + parent: &OperationContext, + policy: AbortPolicy, + ) -> ResponseEnvelope { + let name = format!("{namespace}/{operation}"); + + if !parent.scoped_env.allows(&name) { + return ResponseEnvelope::not_found(parent.request_id.clone(), &name); + } + + let handler: HandlerKind; + let composition_authority; + let scoped_env; + let access_control; + let resource_id_path; + { + let overlay = self.overlay.read(); + let Some(registration) = overlay.get(&name) else { + return ResponseEnvelope::not_found(parent.request_id.clone(), &name); + }; + handler = registration.handler.clone(); + composition_authority = registration.composition_authority.clone(); + scoped_env = registration + .scoped_env + .clone() + .unwrap_or_else(ScopedPeerEnv::empty); + access_control = registration.spec.access_control.clone(); + resource_id_path = registration.spec.resource_id_path.clone(); + } + + let caller_identity = if parent.internal { + parent + .handler_identity + .as_ref() + .and_then(|ca| ca.as_identity()) + } else { + parent.identity.clone() + }; + let resource_id = resource_id_path + .as_ref() + .and_then(|path| crate::registry::registration::extract_json_pointer(&input, path)); + if let AccessResult::Forbidden(message) = access_control.check( + caller_identity.as_ref(), + resource_id.as_deref(), + parent.ownership.as_deref(), + ) { + return ResponseEnvelope::forbidden(parent.request_id.clone(), message); + } + + let context = OperationContext { + request_id: generate_request_id(), + parent_request_id: Some(parent.request_id.clone()), + identity: parent + .handler_identity + .as_ref() + .and_then(|ca| ca.as_identity()), + handler_identity: composition_authority, + forwarded_for: None, + capabilities: parent.capabilities.clone(), + metadata: HashMap::new(), + abort_policy: policy, + deadline: parent.deadline, + scoped_env, + env: parent.env.clone(), + internal: true, + ownership: parent.ownership.clone(), + }; + + match handler { + HandlerKind::Once(h) => h(input, context).await, + HandlerKind::Stream(_) => ResponseEnvelope::error( + parent.request_id.clone(), + CallError::invalid_operation_type( + "OperationEnv::invoke() called on a Subscription op; composition is request/response-only", + ), + ), + } + } + + fn contains(&self, name: &str) -> bool { + self.overlay.read().contains_key(name) + } +} + +pub struct SubscriptionStream { + request_id: String, + receiver: mpsc::Receiver>, + done: bool, +} + +impl SubscriptionStream { + fn new(request_id: String, receiver: mpsc::Receiver>) -> Self { + Self { + request_id, + receiver, + done: false, + } + } + + fn closed(request_id: String, error: CallError) -> Self { + let (tx, rx) = mpsc::channel(1); + let _ = tx.try_send(Err(error)); + Self { + request_id, + receiver: rx, + done: false, + } + } +} + +impl Stream for SubscriptionStream { + type Item = ResponseEnvelope; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.done { + return Poll::Ready(None); + } + let this = self.get_mut(); + match this.receiver.poll_recv(cx) { + Poll::Ready(None) => { + this.done = true; + Poll::Ready(None) + } + Poll::Ready(Some(Ok(value))) => { + Poll::Ready(Some(ResponseEnvelope::ok(this.request_id.clone(), value))) + } + Poll::Ready(Some(Err(error))) => { + this.done = true; + Poll::Ready(Some(ResponseEnvelope::error( + this.request_id.clone(), + error, + ))) + } + Poll::Pending => Poll::Pending, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::types::Capabilities; + use crate::registry::context::CompositionAuthority; + use crate::registry::registration::{make_handler, Handler, HandlerKind, OperationProvenance}; + use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility}; + use std::collections::HashMap; + use std::time::{Duration, Instant}; + + use crate::protocol::sink_empty_connection as stub_connection; + + fn external_spec(name: &str) -> OperationSpec { + OperationSpec::new( + name, + OperationType::Query, + Visibility::External, + serde_json::json!({}), + serde_json::json!({}), + vec![], + AccessControl::default(), + None, + ) + } + + fn echo_handler() -> Handler { + make_handler( + |input, context| async move { ResponseEnvelope::ok(context.request_id, input) }, + ) + } + + fn imported_registration(name: &str) -> HandlerRegistration { + HandlerRegistration::new( + external_spec(name), + HandlerKind::Once(echo_handler()), + OperationProvenance::FromCall, + None, + None, + Capabilities::new(), + ) + } + + fn root_context( + request_id: &str, + scoped_env: ScopedPeerEnv, + env: Arc, + ) -> OperationContext { + OperationContext { + request_id: request_id.to_string(), + parent_request_id: None, + identity: None, + handler_identity: Some(CompositionAuthority::new("agent", ["fs:read".to_string()])), + forwarded_for: None, + capabilities: Capabilities::new(), + metadata: HashMap::new(), + scoped_env, + env, + abort_policy: AbortPolicy::default(), + deadline: Some(Instant::now() + Duration::from_secs(30)), + internal: true, + ownership: None, + } + } + + #[test] + fn register_imported_adds_to_overlay_and_contains_returns_true() { + let conn = CallConnection::new(stub_connection()); + let env = conn.overlay_env(); + + assert!(!env.contains("worker/exec")); + + conn.register_imported(imported_registration("worker/exec")); + + assert!(env.contains("worker/exec")); + assert!(!env.contains("worker/missing")); + } + + #[test] + fn register_imported_all_bulk_adds_to_overlay() { + let conn = CallConnection::new(stub_connection()); + let env = conn.overlay_env(); + + conn.register_imported_all(vec![ + imported_registration("worker/exec"), + imported_registration("worker/status"), + imported_registration("fs/readFile"), + ]); + + assert!(env.contains("worker/exec")); + assert!(env.contains("worker/status")); + assert!(env.contains("fs/readFile")); + assert!(!env.contains("worker/missing")); + } + + #[tokio::test] + async fn overlay_env_dispatches_to_imported_op() { + let conn = CallConnection::new(stub_connection()); + conn.register_imported(imported_registration("worker/exec")); + let env = conn.overlay_env(); + + let scoped = ScopedPeerEnv::new(["worker/exec"]); + let ctx = root_context("root-1", scoped, env.clone()); + + let response = env + .invoke("worker", "exec", serde_json::json!({"hi": 1}), &ctx) + .await; + + assert!(response.result.is_ok()); + assert_eq!(response.result.unwrap(), serde_json::json!({"hi": 1})); + } + + #[tokio::test] + async fn overlay_env_contains_returns_false_for_non_imported_op() { + let conn = CallConnection::new(stub_connection()); + conn.register_imported(imported_registration("worker/exec")); + let env = conn.overlay_env(); + + assert!(!env.contains("worker/missing")); + + let scoped = ScopedPeerEnv::new(["worker/missing"]); + let ctx = root_context("root-2", scoped, env.clone()); + + let response = env + .invoke("worker", "missing", serde_json::json!({}), &ctx) + .await; + + match response.result { + Err(e) => assert_eq!(e.code, "NOT_FOUND"), + other => panic!("expected NOT_FOUND, got {other:?}"), + } + } + + #[tokio::test] + async fn overlay_env_reachability_check_returns_not_found_for_disallowed_op() { + let conn = CallConnection::new(stub_connection()); + conn.register_imported(imported_registration("worker/exec")); + let env = conn.overlay_env(); + + let scoped = ScopedPeerEnv::empty(); + let ctx = root_context("root-3", scoped, env.clone()); + + let response = env + .invoke("worker", "exec", serde_json::json!({}), &ctx) + .await; + + match response.result { + Err(e) => assert_eq!(e.code, "NOT_FOUND"), + other => panic!("expected NOT_FOUND, got {other:?}"), + } + } + + #[tokio::test] + async fn overlay_env_dispatched_child_has_internal_true_and_parent_set() { + let conn = CallConnection::new(stub_connection()); + let inspect_handler = make_handler(|_input, context| async move { + let internal = context.is_internal(); + let parent_set = context.parent_request_id.is_some(); + ResponseEnvelope::ok( + context.request_id, + serde_json::json!({ + "internal": internal, + "parent_set": parent_set, + }), + ) + }); + conn.register_imported(HandlerRegistration::new( + external_spec("worker/exec"), + HandlerKind::Once(inspect_handler), + OperationProvenance::FromCall, + None, + None, + Capabilities::new(), + )); + let env = conn.overlay_env(); + + let scoped = ScopedPeerEnv::new(["worker/exec"]); + let ctx = root_context("root-4", scoped, env.clone()); + + let response = env + .invoke("worker", "exec", serde_json::json!({}), &ctx) + .await; + let out = response.result.expect("ok"); + assert_eq!(out["internal"], Value::Bool(true)); + assert_eq!(out["parent_set"], Value::Bool(true)); + } + + #[test] + fn connection_accessor_returns_underlying_connection() { + let conn = CallConnection::new(stub_connection()); + assert_eq!( + conn.connection() + .expect("quic connection present") + .remote_alpn(), + b"alknet/call" + ); + } + + #[test] + fn empty_overlay_contains_nothing() { + let conn = CallConnection::new(stub_connection()); + let env = conn.overlay_env(); + assert!(!env.contains("anything")); + assert!(!env.contains("")); + } + + #[test] + fn overlay_drops_with_connection() { + let captured: Arc>> = + Arc::new(RwLock::new(HashMap::new())); + { + let conn = CallConnection::new(stub_connection()); + conn.register_imported(imported_registration("worker/exec")); + assert!(conn.overlay_env().contains("worker/exec")); + std::mem::swap( + &mut *captured.write(), + &mut *conn.imported_operations.write(), + ); + } + assert!(captured.read().contains_key("worker/exec")); + } + + // --- dispatch_envelope ------------------------------------------------- + + fn empty_pending() -> Arc> { + Arc::new(Mutex::new(PendingRequestMap::new())) + } + + #[tokio::test] + async fn dispatch_envelope_responded_resolves_call_receiver() { + let pending = empty_pending(); + let rx = pending.lock().register_call( + "req-1".to_string(), + Instant::now() + Duration::from_secs(30), + None, + ); + let envelope = EventEnvelope::responded("req-1", serde_json::json!({"v": 42})); + dispatch_envelope(&pending, envelope); + assert!(!pending.lock().contains("req-1")); + let result = tokio::time::timeout(Duration::from_millis(100), rx).await; + match result { + Ok(Ok(Ok(value))) => assert_eq!(value, serde_json::json!({"v": 42})), + other => panic!("expected Ok({{v:42}}), got {other:?}"), + } + } + + #[tokio::test] + async fn dispatch_envelope_responded_pushes_to_subscribe_channel() { + let pending = empty_pending(); + let mut rx = pending + .lock() + .register_subscribe("sub-1".to_string(), None, None); + dispatch_envelope( + &pending, + EventEnvelope::responded("sub-1", serde_json::json!("first")), + ); + dispatch_envelope( + &pending, + EventEnvelope::responded("sub-1", serde_json::json!("second")), + ); + assert!(pending.lock().contains("sub-1")); + let a = tokio::time::timeout(Duration::from_millis(100), rx.recv()).await; + let b = tokio::time::timeout(Duration::from_millis(100), rx.recv()).await; + match (a, b) { + (Ok(Some(Ok(x))), Ok(Some(Ok(y)))) => { + assert_eq!(x, serde_json::json!("first")); + assert_eq!(y, serde_json::json!("second")); + } + other => panic!("expected two Ok values, got {other:?}"), + } + } + + #[tokio::test] + async fn dispatch_envelope_completed_removes_entry() { + let pending = empty_pending(); + let _rx = pending + .lock() + .register_subscribe("sub-2".to_string(), None, None); + assert!(pending.lock().contains("sub-2")); + dispatch_envelope(&pending, EventEnvelope::completed("sub-2")); + assert!(!pending.lock().contains("sub-2")); + } + + #[tokio::test] + async fn dispatch_envelope_aborted_removes_entry() { + let pending = empty_pending(); + let _rx = pending.lock().register_call( + "req-2".to_string(), + Instant::now() + Duration::from_secs(30), + None, + ); + assert!(pending.lock().contains("req-2")); + dispatch_envelope(&pending, EventEnvelope::aborted("req-2")); + assert!(!pending.lock().contains("req-2")); + } + + #[tokio::test] + async fn dispatch_envelope_error_resolves_call_with_error() { + let pending = empty_pending(); + let rx = pending.lock().register_call( + "req-3".to_string(), + Instant::now() + Duration::from_secs(30), + None, + ); + let err = CallError::new("FILE_NOT_FOUND", "missing", false); + dispatch_envelope(&pending, EventEnvelope::error("req-3", &err)); + assert!(!pending.lock().contains("req-3")); + let result = tokio::time::timeout(Duration::from_millis(100), rx).await; + match result { + Ok(Ok(Err(e))) => { + assert_eq!(e.code, "FILE_NOT_FOUND"); + assert!(!e.retryable); + } + other => panic!("expected Err(FILE_NOT_FOUND), got {other:?}"), + } + } + + #[tokio::test] + async fn dispatch_envelope_error_pushes_error_to_subscribe_channel() { + let pending = empty_pending(); + let mut rx = pending + .lock() + .register_subscribe("sub-3".to_string(), None, None); + let err = CallError::new("RATE_LIMITED", "slow down", true); + dispatch_envelope(&pending, EventEnvelope::error("sub-3", &err)); + assert!(!pending.lock().contains("sub-3")); + let result = tokio::time::timeout(Duration::from_millis(100), rx.recv()).await; + match result { + Ok(Some(Err(e))) => { + assert_eq!(e.code, "RATE_LIMITED"); + assert!(e.retryable); + } + other => panic!("expected Err(RATE_LIMITED), got {other:?}"), + } + } + + #[tokio::test] + async fn dispatch_envelope_error_with_invalid_payload_is_no_op() { + let pending = empty_pending(); + let _rx = pending.lock().register_call( + "req-4".to_string(), + Instant::now() + Duration::from_secs(30), + None, + ); + let malformed = + EventEnvelope::new(EVENT_ERROR, "req-4", serde_json::json!("not-an-object")); + dispatch_envelope(&pending, malformed); + assert!(pending.lock().contains("req-4")); + } + + #[tokio::test] + async fn dispatch_envelope_unknown_event_type_is_no_op() { + let pending = empty_pending(); + let _rx = pending.lock().register_call( + "req-5".to_string(), + Instant::now() + Duration::from_secs(30), + None, + ); + let unknown = EventEnvelope::new("call.mystery", "req-5", serde_json::json!({})); + dispatch_envelope(&pending, unknown); + assert!(pending.lock().contains("req-5")); + } + + #[tokio::test] + async fn dispatch_envelope_unknown_request_id_is_no_op() { + let pending = empty_pending(); + dispatch_envelope( + &pending, + EventEnvelope::responded("ghost", serde_json::json!(1)), + ); + dispatch_envelope(&pending, EventEnvelope::completed("ghost")); + dispatch_envelope(&pending, EventEnvelope::aborted("ghost")); + assert!(pending.lock().is_empty()); + } + + // --- SubscriptionStream ------------------------------------------------ + + #[tokio::test] + async fn subscription_stream_closed_yields_one_error_then_ends() { + use futures::stream::StreamExt; + let err = CallError::internal("stream closed before send"); + let mut stream = SubscriptionStream::closed("req-x".to_string(), err); + let first = stream.next().await; + match first { + Some(env) => { + assert_eq!(env.request_id, "req-x"); + assert!(env.result.is_err()); + assert_eq!(env.result.unwrap_err().code, "INTERNAL"); + } + other => panic!("expected one error envelope, got {other:?}"), + } + let second = stream.next().await; + assert!(second.is_none(), "stream must terminate after the error"); + } + + #[tokio::test] + async fn subscription_stream_emits_ok_values_then_completes() { + use futures::stream::StreamExt; + let (tx, rx) = mpsc::channel(8); + let mut stream = SubscriptionStream::new("req-y".to_string(), rx); + tx.try_send(Ok(serde_json::json!(1))).unwrap(); + tx.try_send(Ok(serde_json::json!(2))).unwrap(); + drop(tx); + + let a = stream.next().await.unwrap(); + assert_eq!(a.request_id, "req-y"); + assert_eq!(a.result.unwrap(), serde_json::json!(1)); + let b = stream.next().await.unwrap(); + assert_eq!(b.result.unwrap(), serde_json::json!(2)); + assert!( + stream.next().await.is_none(), + "stream ends after channel closes" + ); + } + + #[tokio::test] + async fn subscription_stream_emits_error_then_terminates() { + use futures::stream::StreamExt; + let (tx, rx) = mpsc::channel(8); + let mut stream = SubscriptionStream::new("req-z".to_string(), rx); + tx.try_send(Ok(serde_json::json!("ok"))).unwrap(); + tx.try_send(Err(CallError::timeout("timed out"))).unwrap(); + drop(tx); + + let first = stream.next().await.unwrap(); + assert_eq!(first.result.unwrap(), serde_json::json!("ok")); + let second = stream.next().await.unwrap(); + assert_eq!(second.request_id, "req-z"); + assert_eq!(second.result.unwrap_err().code, "TIMEOUT"); + assert!( + stream.next().await.is_none(), + "stream terminates after error" + ); + } + + // --- non-QUIC (overlay-only) CallConnection --------------------------- + + fn sample_identity(id: &str) -> Identity { + Identity { + id: id.to_string(), + scopes: vec!["fs:read".to_string()], + resources: HashMap::new(), + } + } + + #[test] + fn overlay_only_constructor_has_no_quic_connection() { + let conn = CallConnection::new_overlay_only(sample_identity("ws-peer")); + assert!(conn.connection().is_none(), "no QUIC connection stored"); + } + + #[test] + fn overlay_only_identity_returns_stored_identity() { + let conn = CallConnection::new_overlay_only(sample_identity("ws-peer")); + let identity = conn.identity().expect("identity stored"); + assert_eq!(identity.id, "ws-peer"); + assert_eq!(identity.scopes, vec!["fs:read".to_string()]); + } + + #[test] + fn overlay_only_holds_independent_pending_map() { + let conn = CallConnection::new_overlay_only(sample_identity("ws-peer")); + let pending = Arc::clone(conn.pending()); + assert!(pending.lock().is_empty()); + let _rx = pending.lock().register_call( + "req-overlay-1".to_string(), + Instant::now() + Duration::from_secs(30), + None, + ); + assert!(pending.lock().contains("req-overlay-1")); + } + + #[test] + fn overlay_only_register_imported_populates_overlay() { + let conn = CallConnection::new_overlay_only(sample_identity("ws-peer")); + let env = conn.overlay_env(); + assert!(!env.contains("worker/exec")); + conn.register_imported(imported_registration("worker/exec")); + assert!(env.contains("worker/exec")); + } + + #[tokio::test] + async fn overlay_only_overlay_env_dispatches_imported_op() { + let conn = CallConnection::new_overlay_only(sample_identity("ws-peer")); + conn.register_imported(imported_registration("worker/exec")); + let env = conn.overlay_env(); + + let scoped = ScopedPeerEnv::new(["worker/exec"]); + let ctx = root_context("overlay-root-1", scoped, env.clone()); + + let response = env + .invoke("worker", "exec", serde_json::json!({"v": 1}), &ctx) + .await; + assert!(response.result.is_ok()); + assert_eq!(response.result.unwrap(), serde_json::json!({"v": 1})); + } + + #[tokio::test] + async fn overlay_only_call_without_connection_returns_error() { + let conn = CallConnection::new_overlay_only(sample_identity("ws-peer")); + let response = conn.call("fs/readFile", serde_json::json!({})).await; + let err = response.result.expect_err("no connection → error"); + assert_eq!(err.code, "INTERNAL"); + } + + #[test] + fn quic_path_identity_returns_connection_identity() { + let conn = CallConnection::new(stub_connection()); + conn.connection() + .expect("quic connection present") + .set_identity(sample_identity("quic-peer")) + .expect("identity not yet set"); + let identity = conn.identity().expect("identity from connection"); + assert_eq!(identity.id, "quic-peer"); + } + + #[test] + fn quic_path_stored_identity_is_none_when_connection_present() { + let conn = CallConnection::new(stub_connection()); + assert!(conn.connection().is_some(), "QUIC connection present"); + assert!(conn.identity().is_none(), "no identity set yet"); + } + + #[tokio::test] + async fn overlay_env_invoke_on_stream_kind_returns_invalid_operation_type() { + use crate::registry::registration::make_streaming_handler; + let conn = CallConnection::new(stub_connection()); + let streaming_handler = make_streaming_handler(|input, ctx| { + futures::stream::iter(vec![ResponseEnvelope::ok(ctx.request_id, input)]) + }); + conn.register_imported(HandlerRegistration::new( + OperationSpec::new( + "events/stream", + OperationType::Subscription, + Visibility::External, + serde_json::json!({}), + serde_json::json!({}), + vec![], + AccessControl::default(), + None, + ), + HandlerKind::Stream(streaming_handler), + OperationProvenance::FromCall, + None, + None, + Capabilities::new(), + )); + let env = conn.overlay_env(); + let scoped = ScopedPeerEnv::new(["events/stream"]); + let ctx = root_context("root-stream", scoped, env.clone()); + let response = env + .invoke("events", "stream", serde_json::json!({}), &ctx) + .await; + match response.result { + Err(e) => assert_eq!(e.code, "INVALID_OPERATION_TYPE"), + other => panic!("expected INVALID_OPERATION_TYPE, got {other:?}"), + } + } +} diff --git a/src/protocol/dispatch.rs b/src/protocol/dispatch.rs new file mode 100644 index 0000000..6974c30 --- /dev/null +++ b/src/protocol/dispatch.rs @@ -0,0 +1,1363 @@ +//! Shared dispatch loop for `alknet/call` connections. +//! +//! Both [`CallAdapter`]'s accept path and [`crate::client::CallClient`]'s +//! connect path produce a [`CallConnection`] and hand it to the same dispatch +//! loop here (ADR-017 §1): the loop reads `EventEnvelope` frames off accepted +//! bidirectional streams, dispatches `call.requested` events against the +//! operation registry, and writes the response back on the same stream. The +//! connection-establishment half differs (accept vs dial); the dispatch half +//! is shared. +//! +//! See `docs/architecture/crates/call/call-protocol.md` and +//! `docs/architecture/crates/call/client-and-adapters.md` for the spec. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crate::core::auth::{AuthToken, Identity, IdentityProvider}; +use crate::core::ownership::OwnershipProvider; +use crate::core::types::StreamError; +use futures::stream::StreamExt; +use serde_json::Value; +use tokio::task::JoinHandle; +use tracing::{debug, warn}; + +use super::abort::AbortCascade; +use super::connection::CallConnection; +use super::wire::{ + CallError, EventEnvelope, FrameFramedReader, FrameFramedWriter, ResponseEnvelope, + EVENT_ABORTED, EVENT_REQUESTED, +}; +use crate::protocol::adapter::SessionOverlaySource; +use crate::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv}; +use crate::registry::env::{LocalOperationEnv, OperationEnv, PeerCompositeEnv}; +use crate::registry::registration::{OperationRegistry, ResponseStream}; +use crate::registry::spec::OperationType; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +const SWEEPER_INTERVAL: Duration = Duration::from_secs(10); + +/// Outcome of dispatching a `call.requested` event. The dispatcher branches on +/// the registered operation's `op_type` (ADR-049 §6): `Query`/`Mutation` produce +/// a single [`ResponseEnvelope`] (`Once`), `Subscription` produces a +/// [`ResponseStream`] (`Stream`) that `handle_stream` pumps to the wire. +/// +/// This enum is the branch point the spec describes ("branches on `op_type` in +/// `handle_stream`"): `dispatch` returns it and `handle_stream` matches on it, +/// keeping the Once path (one frame, no `call.completed`) and the Stream path +/// (each envelope → frame, `call.completed` on natural end) visibly distinct. +pub enum DispatchResult { + Once(ResponseEnvelope), + Stream(ResponseStream), +} + +impl std::fmt::Debug for DispatchResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DispatchResult::Once(env) => f.debug_tuple("Once").field(env).finish(), + DispatchResult::Stream(_) => { + f.debug_tuple("Stream").field(&"").finish() + } + } + } +} + +/// Shared dispatcher for an established `CallConnection`. Constructed by +/// both `CallAdapter` (accept path) and `CallClient` (connect path) and used +/// to run the dispatch loop. Holds no per-connection state; the +/// `CallConnection` is passed into `run_loop`. +pub struct Dispatcher { + pub registry: Arc, + pub identity_provider: Arc, + pub session_source: Option>, + pub ownership_provider: Option>, + pub default_timeout: Duration, +} + +impl Dispatcher { + pub fn new( + registry: Arc, + identity_provider: Arc, + ) -> Self { + Self { + registry, + identity_provider, + session_source: None, + ownership_provider: None, + default_timeout: DEFAULT_TIMEOUT, + } + } + + pub fn with_session_source( + mut self, + source: Arc, + ) -> Self { + self.session_source = Some(source); + self + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.default_timeout = timeout; + self + } + + pub fn with_ownership_provider(mut self, provider: Arc) -> Self { + self.ownership_provider = Some(provider); + self + } + + fn strip_leading_slash(operation_id: &str) -> &str { + operation_id.strip_prefix('/').unwrap_or(operation_id) + } + + pub(crate) fn resolve_identity( + &self, + connection_identity: Option, + payload: &Value, + ) -> Option { + let auth_token = payload.get("auth_token").and_then(|v| v.as_str()); + match auth_token { + Some(token_str) => { + let token = AuthToken { + raw: token_str.as_bytes().to_vec(), + }; + match self.identity_provider.resolve_from_token(&token) { + Some(identity) => Some(identity), + None => connection_identity, + } + } + None => connection_identity, + } + } + + pub fn compose_root_env( + &self, + connection: &CallConnection, + context: &OperationContext, + ) -> Arc { + let base: Arc = + Arc::new(LocalOperationEnv::new(Arc::clone(&self.registry))); + let session = self + .session_source + .as_ref() + .and_then(|s| s.overlay_for(context)); + + let mut env = PeerCompositeEnv::new(base); + if let Some(session) = session { + env = env.with_session(session); + } + if let Some(peer_id) = connection.identity().map(|identity| identity.id.clone()) { + env.attach_peer(peer_id, connection.overlay_env()); + } + Arc::new(env) + } + + pub(crate) fn build_root_context( + &self, + request_id: String, + operation_name: &str, + identity: Option, + forwarded_for: Option, + connection: &CallConnection, + ) -> OperationContext { + let registration = self.registry.registration(operation_name); + let (composition_authority, capabilities, scoped_env) = match registration { + Some(r) => ( + r.composition_authority.clone(), + r.capabilities.clone(), + r.scoped_env.clone().unwrap_or_else(ScopedPeerEnv::empty), + ), + None => ( + None, + crate::core::types::Capabilities::new(), + ScopedPeerEnv::empty(), + ), + }; + + let stub_env: Arc = + Arc::new(LocalOperationEnv::new(Arc::clone(&self.registry))); + let mut context = OperationContext { + request_id, + parent_request_id: None, + identity: identity.clone(), + handler_identity: composition_authority, + forwarded_for, + capabilities, + metadata: HashMap::new(), + deadline: Some(Instant::now() + self.default_timeout), + scoped_env, + env: stub_env, + abort_policy: AbortPolicy::default(), + internal: false, + ownership: self.ownership_provider.clone(), + }; + context.env = self.compose_root_env(connection, &context); + context + } + + pub async fn dispatch_requested( + &self, + connection: &Arc, + request_id: String, + payload: Value, + ) -> ResponseEnvelope { + match self.dispatch(connection, request_id, payload).await { + DispatchResult::Once(envelope) => envelope, + DispatchResult::Stream(mut stream) => stream.next().await.unwrap_or_else(|| { + ResponseEnvelope::error( + String::new(), + CallError::internal( + "dispatch_requested called on a Subscription op; use the streaming path", + ), + ) + }), + } + } + + /// Dispatch a `call.requested` event, branching on the registered + /// operation's `op_type` (ADR-049 §6). `Query`/`Mutation` → `invoke()` → + /// [`DispatchResult::Once`]; `Subscription` → `invoke_streaming()` → + /// [`DispatchResult::Stream`]. Unknown ops and ACL failures resolve via + /// the registry's own envelope/error paths (Once for `invoke`, a single + /// error envelope for `invoke_streaming`). + /// + /// For the streaming branch the root context's deadline is cleared + /// (`deadline: None`): subscriptions are long-running and unbounded — the + /// 30s request/response deadline does not apply (ADR-049 §6, call-protocol + /// Timeouts). The Once branch keeps the deadline from `build_root_context`. + pub async fn dispatch( + &self, + connection: &Arc, + request_id: String, + payload: Value, + ) -> DispatchResult { + let operation_id = payload + .get("operationId") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let operation_name = Self::strip_leading_slash(operation_id).to_string(); + + let connection_identity = connection.identity().cloned(); + let identity = self.resolve_identity(connection_identity, &payload); + let forwarded_for = payload + .get("forwarded_for") + .and_then(|v| serde_json::from_value::(v.clone()).ok()); + + let input = payload.get("input").cloned().unwrap_or(Value::Null); + + let is_subscription = self + .registry + .registration(&operation_name) + .map(|r| r.spec.op_type == OperationType::Subscription) + .unwrap_or(false); + + let mut context = self.build_root_context( + request_id.clone(), + &operation_name, + identity, + forwarded_for, + connection, + ); + + if is_subscription { + context.deadline = None; + let stream = self + .registry + .invoke_streaming(&operation_name, input, context); + DispatchResult::Stream(stream) + } else { + let envelope = self.registry.invoke(&operation_name, input, context).await; + DispatchResult::Once(envelope) + } + } + + pub async fn handle_abort(&self, connection: &Arc, request_id: &str) { + let mut pending = connection.pending().lock(); + let mut cascade = AbortCascade::new(&mut pending); + let aborted = cascade.cascade_abort(request_id, AbortPolicy::AbortDependents); + pending.handle_aborted(request_id); + if !aborted.is_empty() { + debug!(count = aborted.len(), "abort cascade evicted descendants"); + } + } + + pub(crate) async fn handle_stream( + &self, + connection: Arc, + stream: crate::core::types::BiStream, + ) { + // `stream` is a `BiStream` (ADR-092) — `AsyncRead + AsyncWrite + Send + // + Unpin`. Split into the read and write halves the call protocol's + // frame reader/writer consume. The split is the stdlib idiom; no + // per-handler wrapper. + let (recv, send) = tokio::io::split(stream); + let mut reader = FrameFramedReader::new(recv); + let mut writer = FrameFramedWriter::new(send); + + loop { + let envelope = match reader.read_frame().await { + Ok(env) => env, + Err(super::wire::FrameError::ConnectionClosed) => break, + Err(err) => { + warn!(error = %err, "stream frame read error; closing stream"); + break; + } + }; + + match envelope.r#type.as_str() { + EVENT_REQUESTED => { + let request_id = envelope.id.clone(); + let payload = envelope.payload.clone(); + + match self + .dispatch(&connection, request_id.clone(), payload) + .await + { + DispatchResult::Once(response) => { + let event: EventEnvelope = response.into(); + if let Err(err) = writer.write_frame(&event).await { + warn!(error = %err, "failed to write response frame; closing stream"); + break; + } + } + DispatchResult::Stream(stream) => { + self.pump_stream(&mut writer, &request_id, stream).await; + } + } + } + EVENT_ABORTED => { + let request_id = envelope.id.clone(); + self.handle_abort(&connection, &request_id).await; + } + other => { + debug!(event_type = %other, id = %envelope.id, "ignoring non-requested/non-aborted event on inbound stream"); + } + } + } + } + + /// Pump a subscription's [`ResponseStream`] to the wire: each + /// [`ResponseEnvelope`] becomes an [`EventEnvelope`] frame (`call.responded` + /// for `Ok`, `call.error` for `Err`). On natural stream end (the stream + /// returned `None` without the last item being an `Err`), write a + /// `call.completed` frame. An `Err` envelope is terminal — the stream + /// ends after it and we do NOT write `call.completed` (ADR-049 §6). + /// + /// If a frame write fails the pump stops early; the stream is dropped on + /// return, releasing the handler's resources via `Drop` (ADR-016). The + /// pump is cancellable: it runs inside the `handle_stream` task, so a + /// `call.aborted` for this request ID (handled by `handle_abort` on + /// another stream) or connection close cancels the task and drops the + /// stream. + pub(crate) async fn pump_stream( + &self, + writer: &mut super::wire::FrameFramedWriter, + request_id: &str, + mut stream: ResponseStream, + ) { + let mut last_was_error = false; + while let Some(envelope) = stream.next().await { + last_was_error = envelope.result.is_err(); + let event: EventEnvelope = envelope.into(); + if let Err(err) = writer.write_frame(&event).await { + warn!(error = %err, "failed to write streaming frame; closing stream"); + return; + } + } + + if !last_was_error { + let completed = EventEnvelope::completed(request_id); + if let Err(err) = writer.write_frame(&completed).await { + warn!(error = %err, "failed to write call.completed"); + } + } + } + + /// Run the shared dispatch loop over an established `CallConnection`: + /// spawn the pending-entry sweeper, accept bidirectional streams until the + /// connection closes, dispatch each stream via `handle_stream`, and fail + /// outstanding pending requests on close. Returns when the connection is + /// closed (accept loop yields `ConnectionClosed`/`StreamClosed`/`Timeout`). + pub async fn run_loop(self, connection: Arc) { + let pending = Arc::clone(connection.pending()); + + let quic = match connection.connection() { + Some(c) => Arc::clone(c), + None => { + warn!("run_loop called with an overlay-only CallConnection; returning"); + return; + } + }; + + let sweeper_pending = Arc::clone(&pending); + let sweeper_handle: JoinHandle<()> = tokio::spawn(async move { + let mut interval = tokio::time::interval(SWEEPER_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + let evicted = sweeper_pending.lock().evict_expired(); + if !evicted.is_empty() { + debug!( + count = evicted.len(), + "sweeper evicted expired pending entries" + ); + } + } + }); + + loop { + match quic.accept_bi().await { + Ok(stream) => { + let conn = Arc::clone(&connection); + let dispatcher = self.clone(); + tokio::spawn(async move { + dispatcher.handle_stream(conn, stream).await; + }); + } + Err(StreamError::ConnectionClosed) => break, + Err(StreamError::StreamClosed) => break, + Err(StreamError::Timeout) => break, + Err(err) => { + warn!(error = %err, "accept_bi error; stopping accept loop"); + break; + } + } + } + + let failed = pending + .lock() + .fail_all(CallError::internal("connection closed")); + if !failed.is_empty() { + debug!( + count = failed.len(), + "failed pending requests on connection close" + ); + } + + sweeper_handle.abort(); + } +} + +impl Clone for Dispatcher { + fn clone(&self) -> Self { + Self { + registry: Arc::clone(&self.registry), + identity_provider: Arc::clone(&self.identity_provider), + session_source: self.session_source.clone(), + ownership_provider: self.ownership_provider.clone(), + default_timeout: self.default_timeout, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::auth::{AuthToken, Identity, IdentityProvider}; + use crate::core::types::Capabilities; + use crate::protocol::wire::{EVENT_COMPLETED, EVENT_ERROR, EVENT_RESPONDED}; + use crate::registry::registration::{ + make_handler, make_streaming_handler, HandlerKind, HandlerRegistration, OperationProvenance, + }; + use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility}; + use std::collections::HashMap; + use std::sync::Mutex as StdMutex; + + use crate::protocol::sink_empty_connection as stub_connection; + + struct StaticIdentityProvider { + tokens: StdMutex>, + } + + impl StaticIdentityProvider { + fn new() -> Self { + Self { + tokens: StdMutex::new(HashMap::new()), + } + } + + fn with_token(self, token: &str, identity: Identity) -> Self { + self.tokens + .lock() + .unwrap() + .insert(token.to_string(), identity); + self + } + } + + impl IdentityProvider for StaticIdentityProvider { + fn resolve_from_fingerprint(&self, _fp: &str) -> Option { + None + } + fn resolve_from_token(&self, token: &AuthToken) -> Option { + let token_str = String::from_utf8_lossy(&token.raw); + self.tokens.lock().unwrap().get(token_str.as_ref()).cloned() + } + } + + fn identity_with_scopes(id: &str, scopes: &[&str]) -> Identity { + Identity { + id: id.to_string(), + scopes: scopes.iter().map(|s| s.to_string()).collect(), + resources: HashMap::new(), + } + } + + fn external_spec(name: &str, acl: AccessControl) -> OperationSpec { + OperationSpec::new( + name, + OperationType::Query, + Visibility::External, + serde_json::json!({}), + serde_json::json!({}), + vec![], + acl, + None, + ) + } + + fn registry_with(name: &str, visibility: Visibility, acl: AccessControl) -> OperationRegistry { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + OperationSpec::new( + name, + OperationType::Query, + visibility, + serde_json::json!({}), + serde_json::json!({}), + vec![], + acl, + None, + ), + HandlerKind::Once(make_handler(|input, context| async move { + ResponseEnvelope::ok(context.request_id, input) + })), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + registry + } + + fn dispatcher() -> Dispatcher { + Dispatcher::new( + Arc::new(OperationRegistry::new()), + Arc::new(StaticIdentityProvider::new()), + ) + } + + #[tokio::test] + async fn dispatch_authorized_peer_dispatches_and_populates_capabilities() { + let caps = Capabilities::new().with_api_key("google", "k".to_string()); + let mut registry = OperationRegistry::new(); + let handler = make_handler(|_input, context| async move { + let has_google = context.capabilities.get("google").is_some(); + ResponseEnvelope::ok( + context.request_id, + serde_json::json!({ "has_google": has_google }), + ) + }); + registry + .register(HandlerRegistration::new( + external_spec("admin/run", AccessControl::default()), + HandlerKind::Once(handler), + OperationProvenance::Local, + None, + None, + caps, + )) + .unwrap(); + let registry = Arc::new(registry); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/admin/run", + "input": {}, + }); + let response = dp + .dispatch_requested(&conn, "req-1".to_string(), payload) + .await; + let out = response.result.expect("dispatch ok"); + assert_eq!(out["has_google"], Value::Bool(true)); + } + + #[tokio::test] + async fn dispatch_unauthorized_peer_returns_forbidden_capabilities_never_populated() { + let caps = Capabilities::new().with_api_key("google", "k".to_string()); + let mut registry = OperationRegistry::new(); + let handler = make_handler(|_input, context| async move { + let has_google = context.capabilities.get("google").is_some(); + ResponseEnvelope::ok( + context.request_id, + serde_json::json!({ "has_google": has_google }), + ) + }); + registry + .register(HandlerRegistration::new( + external_spec( + "admin/run", + AccessControl { + required_scopes: vec!["admin".to_string()], + ..Default::default() + }, + ), + HandlerKind::Once(handler), + OperationProvenance::Local, + None, + None, + caps, + )) + .unwrap(); + let registry = Arc::new(registry); + let provider: Arc = Arc::new( + StaticIdentityProvider::new() + .with_token("alk_user", identity_with_scopes("regular-user", &["user"])), + ); + let dp = Dispatcher::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/admin/run", + "input": {}, + "auth_token": "alk_user", + }); + let response = dp + .dispatch_requested(&conn, "req-2".to_string(), payload) + .await; + match response.result { + Err(e) => { + assert_eq!(e.code, "FORBIDDEN"); + assert!(e.message.contains("admin")); + } + other => panic!("expected FORBIDDEN, got {other:?}"), + } + } + + #[tokio::test] + async fn dispatch_internal_op_from_wire_returns_not_found_before_acl() { + let registry = Arc::new(registry_with( + "secret/op", + Visibility::Internal, + AccessControl::default(), + )); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/secret/op", + "input": {}, + }); + let response = dp + .dispatch_requested(&conn, "req-3".to_string(), payload) + .await; + match response.result { + Err(e) => { + assert_eq!(e.code, "NOT_FOUND"); + assert!(e.message.contains("secret/op")); + } + other => panic!("expected NOT_FOUND, got {other:?}"), + } + } + + #[tokio::test] + async fn dispatch_connection_with_no_identity_produces_no_peer_id_in_env() { + let registry = Arc::new(registry_with( + "fs/readFile", + Visibility::External, + AccessControl::default(), + )); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = CallConnection::new(stub_connection()); + + let context = dp.build_root_context("req-4".to_string(), "fs/readFile", None, None, &conn); + + assert!( + context.identity.is_none(), + "no connection identity → context.identity is None" + ); + assert!( + context.env.peer_ids().is_empty(), + "no peer overlay attached when connection has no identity" + ); + } + + #[tokio::test] + async fn dispatch_connection_with_identity_attaches_peer_overlay_keyed_by_identity_id() { + let registry = Arc::new(registry_with( + "fs/readFile", + Visibility::External, + AccessControl::default(), + )); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = CallConnection::new(stub_connection()); + conn.connection() + .expect("quic connection present") + .set_identity(identity_with_scopes("worker-a", &[])) + .expect("identity not yet set"); + + let context = dp.build_root_context("req-5".to_string(), "fs/readFile", None, None, &conn); + + assert_eq!( + context.env.peer_ids(), + vec!["worker-a".to_string()], + "PeerId for connection comes from connection.identity().id" + ); + } + + #[tokio::test] + async fn dispatch_extract_forwarded_for_from_payload_into_context() { + let mut registry = OperationRegistry::new(); + let handler = make_handler(|_input, context| async move { + let forwarded_id = context.forwarded_for.as_ref().map(|i| i.id.clone()); + ResponseEnvelope::ok( + context.request_id, + serde_json::json!({ "forwarded_for_id": forwarded_id }), + ) + }); + registry + .register(HandlerRegistration::new( + external_spec("fs/readFile", AccessControl::default()), + HandlerKind::Once(handler), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let registry = Arc::new(registry); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/fs/readFile", + "input": {}, + "forwarded_for": { + "id": "alice", + "scopes": ["fs:read"], + "resources": {} + }, + }); + let response = dp + .dispatch_requested(&conn, "req-6".to_string(), payload) + .await; + let out = response.result.expect("ok"); + assert_eq!(out["forwarded_for_id"], Value::String("alice".into())); + } + + #[tokio::test] + async fn dispatch_without_forwarded_for_field_is_none() { + let mut registry = OperationRegistry::new(); + let handler = make_handler(|_input, context| async move { + let present = context.forwarded_for.is_some(); + ResponseEnvelope::ok( + context.request_id, + serde_json::json!({ "present": present }), + ) + }); + registry + .register(HandlerRegistration::new( + external_spec("fs/readFile", AccessControl::default()), + HandlerKind::Once(handler), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let registry = Arc::new(registry); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/fs/readFile", + "input": {}, + }); + let response = dp + .dispatch_requested(&conn, "req-7".to_string(), payload) + .await; + let out = response.result.expect("ok"); + assert_eq!(out["present"], Value::Bool(false)); + } + + #[tokio::test] + async fn dispatch_default_access_control_dispatches_to_any_peer() { + let registry = Arc::new(registry_with( + "echo/run", + Visibility::External, + AccessControl::default(), + )); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/echo/run", + "input": { "msg": "hi" }, + }); + let response = dp + .dispatch_requested(&conn, "req-8".to_string(), payload) + .await; + assert_eq!(response.result, Ok(serde_json::json!({ "msg": "hi" }))); + } + + #[test] + fn dispatcher_helper_compiles_with_full_signature() { + let _dp = dispatcher(); + } + + // --- non-QUIC (overlay-only) dispatch path ---------------------------- + + fn overlay_only_connection(identity: Identity) -> Arc { + Arc::new(CallConnection::new_overlay_only(identity)) + } + + #[tokio::test] + async fn dispatch_requested_works_with_overlay_only_connection() { + let registry = Arc::new(registry_with( + "echo/run", + Visibility::External, + AccessControl::default(), + )); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = overlay_only_connection(identity_with_scopes("ws-peer", &[])); + + let payload = serde_json::json!({ + "operationId": "/echo/run", + "input": { "msg": "hello" }, + }); + let response = dp + .dispatch_requested(&conn, "ws-req-1".to_string(), payload) + .await; + assert_eq!(response.request_id, "ws-req-1"); + assert_eq!(response.result, Ok(serde_json::json!({ "msg": "hello" }))); + } + + #[tokio::test] + async fn dispatch_requested_overlay_only_attaches_peer_keyed_by_stored_identity() { + let mut registry = OperationRegistry::new(); + let handler = make_handler(|_input, context| async move { + let peer_ids = context.env.peer_ids(); + ResponseEnvelope::ok( + context.request_id, + serde_json::json!({ "peer_ids": peer_ids }), + ) + }); + registry + .register(HandlerRegistration::new( + external_spec("fs/readFile", AccessControl::default()), + HandlerKind::Once(handler), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let registry = Arc::new(registry); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = overlay_only_connection(identity_with_scopes("ws-peer", &[])); + + let payload = serde_json::json!({ + "operationId": "/fs/readFile", + "input": {}, + }); + let response = dp + .dispatch_requested(&conn, "ws-req-2".to_string(), payload) + .await; + let out = response.result.expect("ok"); + assert_eq!(out["peer_ids"], serde_json::json!(["ws-peer"])); + } + + #[tokio::test] + async fn dispatch_requested_overlay_only_unknown_op_returns_not_found() { + let registry = Arc::new(OperationRegistry::new()); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = overlay_only_connection(identity_with_scopes("ws-peer", &[])); + + let payload = serde_json::json!({ + "operationId": "/no/such/op", + "input": {}, + }); + let response = dp + .dispatch_requested(&conn, "ws-req-3".to_string(), payload) + .await; + match response.result { + Err(e) => assert_eq!(e.code, "NOT_FOUND"), + other => panic!("expected NOT_FOUND, got {other:?}"), + } + } + + #[tokio::test] + async fn handle_abort_works_with_overlay_only_connection() { + let registry = Arc::new(registry_with( + "echo/run", + Visibility::External, + AccessControl::default(), + )); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = overlay_only_connection(identity_with_scopes("ws-peer", &[])); + + let parent_id = "ws-abort-root".to_string(); + let child_id = "ws-abort-child".to_string(); + { + let mut pending = conn.pending().lock(); + pending.register_call( + parent_id.clone(), + Instant::now() + Duration::from_secs(30), + None, + ); + pending.register_call( + child_id.clone(), + Instant::now() + Duration::from_secs(30), + Some(parent_id.clone()), + ); + } + assert!(conn.pending().lock().contains(&parent_id)); + assert!(conn.pending().lock().contains(&child_id)); + + dp.handle_abort(&conn, &parent_id).await; + + assert!( + !conn.pending().lock().contains(&parent_id), + "parent entry removed after abort" + ); + assert!( + !conn.pending().lock().contains(&child_id), + "child aborted by cascade" + ); + } + + #[tokio::test] + async fn handle_abort_unknown_request_id_is_noop_for_overlay_only() { + let registry = Arc::new(OperationRegistry::new()); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = overlay_only_connection(identity_with_scopes("ws-peer", &[])); + + dp.handle_abort(&conn, "totally-unknown").await; + assert!(conn.pending().lock().is_empty()); + } + + #[tokio::test] + async fn overlay_only_full_dispatch_round_trip_returns_response_envelope() { + let registry = Arc::new(registry_with( + "echo/run", + Visibility::External, + AccessControl::default(), + )); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = overlay_only_connection(identity_with_scopes("ws-peer", &[])); + + let payload = serde_json::json!({ + "operationId": "/echo/run", + "input": { "v": 42 }, + }); + let request_id = "ws-roundtrip-1".to_string(); + let response = dp + .dispatch_requested(&conn, request_id.clone(), payload) + .await; + assert!(response.result.is_ok()); + let envelope: EventEnvelope = response.into(); + assert_eq!(envelope.r#type, EVENT_RESPONDED); + assert_eq!(envelope.id, "ws-roundtrip-1"); + assert_eq!( + envelope.payload.get("output"), + Some(&serde_json::json!({ "v": 42 })) + ); + } + + // --- streaming dispatch branch (ADR-049 §6) --------------------------- + + fn subscription_spec(name: &str, acl: AccessControl) -> OperationSpec { + OperationSpec::new( + name, + OperationType::Subscription, + Visibility::External, + serde_json::json!({}), + serde_json::json!({}), + vec![], + acl, + None, + ) + } + + fn encode_frame(envelope: &EventEnvelope) -> Vec { + let body = serde_json::to_vec(envelope).expect("serialize envelope"); + let mut buf = (body.len() as u32).to_be_bytes().to_vec(); + buf.extend_from_slice(&body); + buf + } + + async fn read_all_frames( + reader: &mut (impl tokio::io::AsyncRead + Unpin), + ) -> Vec { + let mut buf = Vec::new(); + use tokio::io::AsyncReadExt; + let _ = reader.read_to_end(&mut buf).await; + let mut frames = Vec::new(); + let mut cursor = std::io::Cursor::new(buf); + loop { + let mut len_buf = [0u8; 4]; + match tokio::io::AsyncReadExt::read_exact(&mut cursor, &mut len_buf).await { + Ok(_) => {} + Err(_) => break, + } + let len = u32::from_be_bytes(len_buf) as usize; + let mut body = vec![0u8; len]; + if tokio::io::AsyncReadExt::read_exact(&mut cursor, &mut body) + .await + .is_err() + { + break; + } + let envelope: EventEnvelope = + serde_json::from_slice(&body).expect("deserialize written frame"); + frames.push(envelope); + } + frames + } + + fn registry_with_subscription( + name: &str, + handler: crate::registry::registration::StreamingHandler, + ) -> Arc { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + subscription_spec(name, AccessControl::default()), + HandlerKind::Stream(handler), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + Arc::new(registry) + } + + #[tokio::test] + async fn dispatch_subscription_returns_stream_result() { + let handler = make_streaming_handler(|input, ctx| { + futures::stream::iter(vec![ + ResponseEnvelope::ok(ctx.request_id.clone(), input.clone()), + ResponseEnvelope::ok(ctx.request_id.clone(), serde_json::json!({"done": true})), + ]) + }); + let registry = registry_with_subscription("events/stream", handler); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/events/stream", + "input": { "v": 1 }, + }); + match dp.dispatch(&conn, "sub-1".to_string(), payload).await { + DispatchResult::Stream(mut stream) => { + use futures::stream::StreamExt; + let first = stream.next().await.expect("first envelope"); + assert_eq!(first.request_id, "sub-1"); + assert_eq!(first.result, Ok(serde_json::json!({ "v": 1 }))); + let second = stream.next().await.expect("second envelope"); + assert_eq!(second.result, Ok(serde_json::json!({ "done": true }))); + assert!( + stream.next().await.is_none(), + "stream ends after two values" + ); + } + other => panic!("expected Stream, got {other:?}"), + } + } + + #[tokio::test] + async fn dispatch_subscription_clears_deadline_to_none() { + let handler = make_streaming_handler(|_input, ctx| { + let deadline = ctx.deadline; + futures::stream::iter(vec![ResponseEnvelope::ok( + ctx.request_id.clone(), + serde_json::json!({ "deadline_is_none": deadline.is_none() }), + )]) + }); + let registry = registry_with_subscription("events/stream", handler); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/events/stream", + "input": {}, + }); + match dp.dispatch(&conn, "sub-dl".to_string(), payload).await { + DispatchResult::Stream(mut stream) => { + use futures::stream::StreamExt; + let env = stream.next().await.expect("one envelope"); + let out = env.result.expect("ok"); + assert_eq!(out["deadline_is_none"], Value::Bool(true)); + } + other => panic!("expected Stream, got {other:?}"), + } + } + + #[tokio::test] + async fn dispatch_query_keeps_deadline_some() { + let mut registry = OperationRegistry::new(); + let handler = make_handler(|_input, ctx| async move { + let deadline_is_some = ctx.deadline.is_some(); + ResponseEnvelope::ok( + ctx.request_id.clone(), + serde_json::json!({ "deadline_is_some": deadline_is_some }), + ) + }); + registry + .register(HandlerRegistration::new( + external_spec("echo/run", AccessControl::default()), + HandlerKind::Once(handler), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let registry = Arc::new(registry); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let payload = serde_json::json!({ + "operationId": "/echo/run", + "input": {}, + }); + match dp.dispatch(&conn, "q-1".to_string(), payload).await { + DispatchResult::Once(env) => { + let out = env.result.expect("ok"); + assert_eq!(out["deadline_is_some"], Value::Bool(true)); + } + other => panic!("expected Once, got {other:?}"), + } + } + + #[tokio::test] + async fn handle_stream_subscription_pumps_each_frame_then_completed() { + let handler = make_streaming_handler(|input, ctx| { + let first = input.clone(); + let rid = ctx.request_id.clone(); + futures::stream::iter(vec![ + ResponseEnvelope::ok(rid.clone(), first), + ResponseEnvelope::ok(rid.clone(), serde_json::json!({"n": 2})), + ResponseEnvelope::ok(rid, serde_json::json!({"n": 3})), + ]) + }); + let registry = registry_with_subscription("events/stream", handler); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let request = EventEnvelope::requested( + "sub-pump-1", + serde_json::json!({ + "operationId": "/events/stream", + "input": { "n": 1 }, + }), + ); + let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request))); + let (send, mut sink) = tokio::io::duplex(8 * 1024); + let stream = crate::core::types::BiStream::from_joined(recv, send); + + dp.handle_stream(conn, stream).await; + + let frames = read_all_frames(&mut sink).await; + assert_eq!(frames.len(), 4, "3 responded + 1 completed"); + for (i, f) in frames[..3].iter().enumerate() { + assert_eq!(f.r#type, EVENT_RESPONDED, "frame {i} is call.responded"); + assert_eq!(f.id, "sub-pump-1"); + } + assert_eq!(frames[3].r#type, EVENT_COMPLETED); + assert_eq!(frames[3].id, "sub-pump-1"); + assert_eq!(frames[3].payload, serde_json::json!({})); + } + + #[tokio::test] + async fn handle_stream_subscription_error_is_terminal_no_completed() { + let handler = make_streaming_handler(|_input, ctx| { + let rid = ctx.request_id.clone(); + futures::stream::iter(vec![ + ResponseEnvelope::ok(rid.clone(), serde_json::json!({"ok": true})), + ResponseEnvelope::error(rid.clone(), CallError::internal("boom")), + ]) + }); + let registry = registry_with_subscription("events/stream", handler); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let request = EventEnvelope::requested( + "sub-err-1", + serde_json::json!({ + "operationId": "/events/stream", + "input": {}, + }), + ); + let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request))); + let (send, mut sink) = tokio::io::duplex(8 * 1024); + let stream = crate::core::types::BiStream::from_joined(recv, send); + + dp.handle_stream(conn, stream).await; + + let frames = read_all_frames(&mut sink).await; + assert_eq!(frames.len(), 2, "1 responded + 1 error, no completed"); + assert_eq!(frames[0].r#type, EVENT_RESPONDED); + assert_eq!(frames[1].r#type, EVENT_ERROR); + assert_eq!(frames[1].id, "sub-err-1"); + assert_eq!( + frames[1].payload.get("code"), + Some(&Value::String("INTERNAL".into())) + ); + } + + #[tokio::test] + async fn handle_stream_query_dispatch_unchanged_one_frame_no_completed() { + let registry = Arc::new(registry_with( + "echo/run", + Visibility::External, + AccessControl::default(), + )); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let request = EventEnvelope::requested( + "q-pump-1", + serde_json::json!({ + "operationId": "/echo/run", + "input": { "msg": "hi" }, + }), + ); + let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request))); + let (send, mut sink) = tokio::io::duplex(8 * 1024); + let stream = crate::core::types::BiStream::from_joined(recv, send); + + dp.handle_stream(conn, stream).await; + + let frames = read_all_frames(&mut sink).await; + assert_eq!(frames.len(), 1, "query: exactly one frame, no completed"); + assert_eq!(frames[0].r#type, EVENT_RESPONDED); + assert_eq!(frames[0].id, "q-pump-1"); + assert_eq!( + frames[0].payload.get("output"), + Some(&serde_json::json!({ "msg": "hi" })) + ); + } + + #[tokio::test] + async fn handle_stream_subscription_unknown_op_yields_single_error_no_completed() { + let registry = Arc::new(OperationRegistry::new()); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let request = EventEnvelope::requested( + "sub-missing-1", + serde_json::json!({ + "operationId": "/no/such/stream", + "input": {}, + }), + ); + let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request))); + let (send, mut sink) = tokio::io::duplex(8 * 1024); + let stream = crate::core::types::BiStream::from_joined(recv, send); + + dp.handle_stream(conn, stream).await; + + let frames = read_all_frames(&mut sink).await; + assert_eq!(frames.len(), 1, "unknown op: single error, no completed"); + assert_eq!(frames[0].r#type, EVENT_ERROR); + assert_eq!(frames[0].id, "sub-missing-1"); + assert_eq!( + frames[0].payload.get("code"), + Some(&Value::String("NOT_FOUND".into())) + ); + } + + #[tokio::test] + async fn handle_stream_aborted_for_streaming_request_drops_stream() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc as StdArc; + + let dropped = StdArc::new(AtomicBool::new(false)); + let dropped_clone = StdArc::clone(&dropped); + let handler = make_streaming_handler(move |_input, ctx| { + let rid = ctx.request_id.clone(); + let flag = StdArc::clone(&dropped_clone); + struct DropGuard(StdArc); + impl Drop for DropGuard { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + let guard = DropGuard(StdArc::clone(&flag)); + futures::stream::poll_fn(move |_cx| { + if flag.load(Ordering::SeqCst) { + return std::task::Poll::Ready(None); + } + std::task::Poll::Ready(Some(ResponseEnvelope::ok( + rid.clone(), + serde_json::json!({"tick": 1}), + ))) + }) + .map(move |env| { + let _keep_guard = &guard; + env + }) + }); + let registry = registry_with_subscription("events/stream", handler); + let provider: Arc = Arc::new(StaticIdentityProvider::new()); + let dp = Dispatcher::new(registry, provider); + let conn = Arc::new(CallConnection::new(stub_connection())); + + let request = EventEnvelope::requested( + "sub-abort-1", + serde_json::json!({ + "operationId": "/events/stream", + "input": {}, + }), + ); + let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request))); + let (send, _sink) = tokio::io::duplex(8 * 1024); + let stream = crate::core::types::BiStream::from_joined(recv, send); + + let conn_clone = Arc::clone(&conn); + let dp_clone = dp.clone(); + let handle = tokio::spawn(async move { + dp_clone.handle_stream(conn_clone, stream).await; + }); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + dp.handle_abort(&conn, "sub-abort-1").await; + assert!( + !conn.pending().lock().contains("sub-abort-1"), + "abort removes the pending entry" + ); + + handle.abort(); + let _ = handle.await; + assert!( + dropped.load(Ordering::SeqCst), + "stream future dropped → Drop guard released handler resources" + ); + } +} diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs new file mode 100644 index 0000000..605ef18 --- /dev/null +++ b/src/protocol/mod.rs @@ -0,0 +1,18 @@ +//! Call protocol: wire format, streams, and the call adapter. +//! +//! Implements `ProtocolHandler` for ALPN `alknet/call` on top of the +//! operation registry. See `docs/architecture/crates/call/call-protocol.md` +//! for the full specification. + +pub mod abort; +pub mod adapter; +pub mod connection; +pub mod dispatch; +pub mod pending; +pub mod wire; + +#[cfg(test)] +mod test_support; + +#[cfg(test)] +pub(crate) use test_support::sink_empty_connection; diff --git a/src/protocol/pending.rs b/src/protocol/pending.rs new file mode 100644 index 0000000..cb2e2f9 --- /dev/null +++ b/src/protocol/pending.rs @@ -0,0 +1,584 @@ +use std::collections::HashMap; +use std::time::Instant; + +use serde_json::Value; +use tokio::sync::{mpsc, oneshot}; + +use crate::protocol::wire::CallError; + +const SUBSCRIBE_CHANNEL_CAPACITY: usize = 32; + +pub struct PendingRequestMap { + pending: HashMap, +} + +pub(crate) enum PendingEntry { + Call { + tx: oneshot::Sender>, + timeout: Instant, + parent_request_id: Option, + started: bool, + }, + Subscribe { + tx: mpsc::Sender>, + timeout: Option, + parent_request_id: Option, + started: bool, + }, +} + +impl PendingEntry { + pub(crate) fn parent_request_id(&self) -> Option<&str> { + match self { + PendingEntry::Call { + parent_request_id, .. + } => parent_request_id.as_deref(), + PendingEntry::Subscribe { + parent_request_id, .. + } => parent_request_id.as_deref(), + } + } + + pub(crate) fn started(&self) -> bool { + match self { + PendingEntry::Call { started, .. } => *started, + PendingEntry::Subscribe { started, .. } => *started, + } + } +} + +impl PendingRequestMap { + pub fn new() -> Self { + Self { + pending: HashMap::new(), + } + } + + pub fn register_call( + &mut self, + request_id: String, + timeout: Instant, + parent_request_id: Option, + ) -> oneshot::Receiver> { + let (tx, rx) = oneshot::channel(); + self.pending.insert( + request_id, + PendingEntry::Call { + tx, + timeout, + parent_request_id, + started: false, + }, + ); + rx + } + + pub fn register_subscribe( + &mut self, + request_id: String, + timeout: Option, + parent_request_id: Option, + ) -> mpsc::Receiver> { + let (tx, rx) = mpsc::channel(SUBSCRIBE_CHANNEL_CAPACITY); + self.pending.insert( + request_id, + PendingEntry::Subscribe { + tx, + timeout, + parent_request_id, + started: false, + }, + ); + rx + } + + pub fn mark_started(&mut self, request_id: &str) -> bool { + let Some(entry) = self.pending.get_mut(request_id) else { + return false; + }; + match entry { + PendingEntry::Call { started, .. } => *started = true, + PendingEntry::Subscribe { started, .. } => *started = true, + } + true + } + + pub fn handle_responded(&mut self, request_id: &str, output: Value) -> bool { + let Some(entry) = self.pending.remove(request_id) else { + return false; + }; + match entry { + PendingEntry::Call { tx, .. } => { + let _ = tx.send(Ok(output)); + true + } + PendingEntry::Subscribe { + tx, + timeout, + parent_request_id, + started, + } => { + let send_result = tx.try_send(Ok(output)); + match send_result { + Ok(()) => { + self.pending.insert( + request_id.to_string(), + PendingEntry::Subscribe { + tx, + timeout, + parent_request_id, + started, + }, + ); + true + } + Err(mpsc::error::TrySendError::Full(_)) => { + tracing::warn!( + request_id, + "subscribe channel full; dropping entry and closing subscription" + ); + true + } + Err(mpsc::error::TrySendError::Closed(_)) => true, + } + } + } + } + + pub fn handle_completed(&mut self, request_id: &str) -> bool { + self.pending.remove(request_id).is_some() + } + + pub fn handle_aborted(&mut self, request_id: &str) -> bool { + self.pending.remove(request_id).is_some() + } + + pub fn handle_error(&mut self, request_id: &str, error: CallError) -> bool { + let Some(entry) = self.pending.remove(request_id) else { + return false; + }; + match entry { + PendingEntry::Call { tx, .. } => { + let _ = tx.send(Err(error)); + true + } + PendingEntry::Subscribe { tx, .. } => { + let _ = tx.try_send(Err(error)); + true + } + } + } + + pub fn evict_expired(&mut self) -> Vec { + let now = Instant::now(); + let mut evicted = Vec::new(); + let mut to_remove: Vec = Vec::new(); + for (id, entry) in self.pending.iter() { + let expired = match entry { + PendingEntry::Call { timeout, .. } => *timeout <= now, + PendingEntry::Subscribe { + timeout: Some(t), .. + } => *t <= now, + PendingEntry::Subscribe { timeout: None, .. } => false, + }; + if expired { + to_remove.push(id.clone()); + } + } + for id in to_remove { + let Some(entry) = self.pending.remove(&id) else { + continue; + }; + let timeout_err = CallError::timeout("request timed out"); + match entry { + PendingEntry::Call { tx, .. } => { + let _ = tx.send(Err(timeout_err)); + } + PendingEntry::Subscribe { tx, .. } => { + let _ = tx.try_send(Err(timeout_err)); + } + } + evicted.push(id); + } + evicted + } + + pub fn fail_all(&mut self, error: CallError) -> Vec { + let ids: Vec = self.pending.keys().cloned().collect(); + for id in &ids { + if let Some(entry) = self.pending.remove(id) { + match entry { + PendingEntry::Call { tx, .. } => { + let _ = tx.send(Err(error.clone())); + } + PendingEntry::Subscribe { tx, .. } => { + let _ = tx.try_send(Err(error.clone())); + } + } + } + } + ids + } + + pub fn contains(&self, request_id: &str) -> bool { + self.pending.contains_key(request_id) + } + + pub(crate) fn parent_of(&self, request_id: &str) -> Option> { + self.pending + .get(request_id) + .map(|e| e.parent_request_id().map(|s| s.to_string())) + } + + pub(crate) fn is_started(&self, request_id: &str) -> Option { + self.pending.get(request_id).map(|e| e.started()) + } + + pub(crate) fn request_ids(&self) -> Vec { + self.pending.keys().cloned().collect() + } + + pub fn len(&self) -> usize { + self.pending.len() + } + + pub fn is_empty(&self) -> bool { + self.pending.is_empty() + } +} + +impl Default for PendingRequestMap { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::time::Duration; + use tokio::time::timeout; + + fn timeout_error() -> CallError { + CallError::timeout("request timed out") + } + + fn internal_error(message: &str) -> CallError { + CallError::internal(message) + } + + #[tokio::test] + async fn register_call_then_handle_responded_resolves_oneshot() { + let mut map = PendingRequestMap::new(); + let rx = map.register_call( + "req-1".to_string(), + Instant::now() + Duration::from_secs(30), + None, + ); + + assert!(map.contains("req-1")); + assert_eq!(map.len(), 1); + + assert!(map.handle_responded("req-1", json!(42))); + + let result = timeout(Duration::from_millis(100), rx).await; + match result { + Ok(Ok(Ok(value))) => assert_eq!(value, json!(42)), + other => panic!("expected Ok(42), got {other:?}"), + } + assert!(!map.contains("req-1")); + assert_eq!(map.len(), 0); + } + + #[tokio::test] + async fn register_subscribe_then_handle_responded_pushes_to_channel() { + let mut map = PendingRequestMap::new(); + let mut rx = map.register_subscribe("sub-1".to_string(), None, None); + + assert!(map.handle_responded("sub-1", json!("first"))); + assert!(map.handle_responded("sub-1", json!("second"))); + assert!(map.contains("sub-1")); + + let first = timeout(Duration::from_millis(100), rx.recv()).await; + let second = timeout(Duration::from_millis(100), rx.recv()).await; + match (first, second) { + (Ok(Some(Ok(a))), Ok(Some(Ok(b)))) => { + assert_eq!(a, json!("first")); + assert_eq!(b, json!("second")); + } + other => panic!("expected two Ok values, got {other:?}"), + } + } + + #[tokio::test] + async fn subscribe_handle_completed_closes_channel_and_deletes_entry() { + let mut map = PendingRequestMap::new(); + let mut rx = map.register_subscribe("sub-2".to_string(), None, None); + + assert!(map.handle_responded("sub-2", json!("a"))); + assert!(map.handle_completed("sub-2")); + assert!(!map.contains("sub-2")); + + let _ = timeout(Duration::from_millis(100), rx.recv()).await; + let after_close = timeout(Duration::from_millis(100), rx.recv()).await; + match after_close { + Ok(None) => {} + other => panic!("expected channel closed (None), got {other:?}"), + } + } + + #[tokio::test] + async fn expired_call_is_evicted_with_timeout_error() { + let mut map = PendingRequestMap::new(); + let rx = map.register_call( + "req-2".to_string(), + Instant::now() - Duration::from_millis(1), + None, + ); + + let evicted = map.evict_expired(); + assert_eq!(evicted, vec!["req-2".to_string()]); + assert!(!map.contains("req-2")); + + let result = timeout(Duration::from_millis(100), rx).await; + match result { + Ok(Ok(Err(e))) => { + assert_eq!(e.code, "TIMEOUT"); + assert!(e.retryable); + } + other => panic!("expected Err(TIMEOUT), got {other:?}"), + } + } + + #[tokio::test] + async fn expired_subscribe_is_evicted_with_timeout_error() { + let mut map = PendingRequestMap::new(); + let mut rx = map.register_subscribe( + "sub-3".to_string(), + Some(Instant::now() - Duration::from_millis(1)), + None, + ); + + let evicted = map.evict_expired(); + assert_eq!(evicted, vec!["sub-3".to_string()]); + + let result = timeout(Duration::from_millis(100), rx.recv()).await; + match result { + Ok(Some(Err(e))) => { + assert_eq!(e.code, "TIMEOUT"); + assert!(e.retryable); + } + other => panic!("expected Err(TIMEOUT), got {other:?}"), + } + } + + #[tokio::test] + async fn unbounded_subscribe_is_not_evicted() { + let mut map = PendingRequestMap::new(); + let _rx = map.register_subscribe("sub-4".to_string(), None, None); + + let evicted = map.evict_expired(); + assert!(evicted.is_empty()); + assert!(map.contains("sub-4")); + } + + #[tokio::test] + async fn fail_all_resolves_all_pending_with_internal_error() { + let mut map = PendingRequestMap::new(); + let rx_call = map.register_call( + "c-1".to_string(), + Instant::now() + Duration::from_secs(30), + None, + ); + let mut rx_sub = map.register_subscribe( + "s-1".to_string(), + Some(Instant::now() + Duration::from_secs(30)), + None, + ); + + let failed = map.fail_all(internal_error("connection closed")); + assert_eq!(failed.len(), 2); + assert!(failed.contains(&"c-1".to_string())); + assert!(failed.contains(&"s-1".to_string())); + assert!(map.is_empty()); + + let call_result = timeout(Duration::from_millis(100), rx_call).await; + match call_result { + Ok(Ok(Err(e))) => { + assert_eq!(e.code, "INTERNAL"); + assert_eq!(e.message, "connection closed"); + } + other => panic!("expected Err(INTERNAL), got {other:?}"), + } + + let sub_result = timeout(Duration::from_millis(100), rx_sub.recv()).await; + match sub_result { + Ok(Some(Err(e))) => { + assert_eq!(e.code, "INTERNAL"); + assert_eq!(e.message, "connection closed"); + } + other => panic!("expected Err(INTERNAL), got {other:?}"), + } + } + + #[tokio::test] + async fn handle_responded_unknown_request_id_returns_false() { + let mut map = PendingRequestMap::new(); + assert!(!map.handle_responded("nonexistent", json!(1))); + assert_eq!(map.len(), 0); + } + + #[tokio::test] + async fn handle_completed_unknown_request_id_returns_false() { + let mut map = PendingRequestMap::new(); + assert!(!map.handle_completed("nonexistent")); + } + + #[tokio::test] + async fn handle_aborted_unknown_request_id_returns_false() { + let mut map = PendingRequestMap::new(); + assert!(!map.handle_aborted("nonexistent")); + } + + #[tokio::test] + async fn handle_error_unknown_request_id_returns_false() { + let mut map = PendingRequestMap::new(); + assert!(!map.handle_error("nonexistent", internal_error("x"))); + } + + #[tokio::test] + async fn handle_aborted_cancels_pending_call() { + let mut map = PendingRequestMap::new(); + let rx = map.register_call( + "req-3".to_string(), + Instant::now() + Duration::from_secs(30), + None, + ); + + assert!(map.handle_aborted("req-3")); + assert!(!map.contains("req-3")); + + let result = timeout(Duration::from_millis(100), rx).await; + match result { + Ok(Err(_)) => {} + other => panic!("expected sender dropped (Err), got {other:?}"), + } + } + + #[tokio::test] + async fn handle_error_resolves_call_with_error() { + let mut map = PendingRequestMap::new(); + let rx = map.register_call( + "req-4".to_string(), + Instant::now() + Duration::from_secs(30), + None, + ); + + let err = CallError::new("FILE_NOT_FOUND", "missing", false); + assert!(map.handle_error("req-4", err.clone())); + assert!(!map.contains("req-4")); + + let result = timeout(Duration::from_millis(100), rx).await; + match result { + Ok(Ok(Err(e))) => { + assert_eq!(e.code, "FILE_NOT_FOUND"); + assert!(!e.retryable); + } + other => panic!("expected Err(FILE_NOT_FOUND), got {other:?}"), + } + } + + #[tokio::test] + async fn handle_error_pushes_to_subscribe_channel() { + let mut map = PendingRequestMap::new(); + let mut rx = map.register_subscribe("sub-5".to_string(), None, None); + + let err = CallError::new("RATE_LIMITED", "too fast", true); + assert!(map.handle_error("sub-5", err.clone())); + assert!(!map.contains("sub-5")); + + let result = timeout(Duration::from_millis(100), rx.recv()).await; + match result { + Ok(Some(Err(e))) => { + assert_eq!(e.code, "RATE_LIMITED"); + assert!(e.retryable); + } + other => panic!("expected Err(RATE_LIMITED), got {other:?}"), + } + } + + #[tokio::test] + async fn correlation_by_id_not_by_stream() { + let mut map = PendingRequestMap::new(); + let rx = map.register_call( + "req-stream-3".to_string(), + Instant::now() + Duration::from_secs(30), + None, + ); + + assert!(map.handle_responded("req-stream-3", json!("response-from-stream-7"))); + let result = timeout(Duration::from_millis(100), rx).await; + match result { + Ok(Ok(Ok(value))) => assert_eq!(value, json!("response-from-stream-7")), + other => panic!("expected Ok, got {other:?}"), + } + } + + #[tokio::test] + async fn register_call_overwrites_existing_entry() { + let mut map = PendingRequestMap::new(); + let _rx_old = map.register_call( + "req-5".to_string(), + Instant::now() + Duration::from_secs(30), + None, + ); + let rx_new = map.register_call( + "req-5".to_string(), + Instant::now() + Duration::from_secs(30), + None, + ); + assert_eq!(map.len(), 1); + + assert!(map.handle_responded("req-5", json!("new"))); + let result = timeout(Duration::from_millis(100), rx_new).await; + match result { + Ok(Ok(Ok(value))) => assert_eq!(value, json!("new")), + other => panic!("expected Ok from new receiver, got {other:?}"), + } + } + + #[tokio::test] + async fn evict_expired_skips_non_expired_entries() { + let mut map = PendingRequestMap::new(); + let _rx_expired = map.register_call( + "expired".to_string(), + Instant::now() - Duration::from_millis(1), + None, + ); + let _rx_alive = map.register_call( + "alive".to_string(), + Instant::now() + Duration::from_secs(60), + None, + ); + + let evicted = map.evict_expired(); + assert_eq!(evicted, vec!["expired".to_string()]); + assert!(map.contains("alive")); + assert!(!map.contains("expired")); + } + + #[tokio::test] + async fn default_is_empty_map() { + let map = PendingRequestMap::default(); + assert!(map.is_empty()); + assert_eq!(map.len(), 0); + } + + #[tokio::test] + async fn timeout_error_helper() { + let err = timeout_error(); + assert_eq!(err.code, "TIMEOUT"); + assert!(err.retryable); + } +} diff --git a/src/protocol/test_support.rs b/src/protocol/test_support.rs new file mode 100644 index 0000000..bf40262 --- /dev/null +++ b/src/protocol/test_support.rs @@ -0,0 +1,66 @@ +//! Shared test helpers for the call protocol's inline `#[cfg(test)]` +//! modules. Kept here (not in each test module) so the `stub_connection()` +//! shape is defined once — `Connection::from_stream` was removed (ADR-092) +//! and every test stub that previously called it now calls +//! `Connection::from_bidi(SinkEmpty, ...)` via `sink_empty_connection()`. + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use crate::core::types::Connection; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +/// A test-only `AsyncRead + AsyncWrite` pair equivalent to +/// `tokio::io::sink() + tokio::io::empty()`: reads yield EOF immediately +/// (zero bytes), writes discard. Exists because `Connection::from_bidi` +/// (ADR-092 — the only public stream constructor, replacing +/// `from_stream`) requires a single value that implements both traits. +/// Used only to construct a `Connection` for tests that exercise +/// `Connection`-level state (alpn, addr, identity, dispatcher run loop +/// with an immediately-closed accept stream) without ever reading or +/// writing real bytes. +pub(crate) struct SinkEmpty; + +impl AsyncRead for SinkEmpty { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _buf: &mut ReadBuf<'_>, + ) -> Poll> { + // EOF immediately — mirrors `tokio::io::empty()`. + Poll::Ready(Ok(())) + } +} + +impl AsyncWrite for SinkEmpty { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + // Discard — mirrors `tokio::io::sink()`. + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +/// Construct a `Connection` whose `accept_bi` yields a `SinkEmpty` once, +/// then `ConnectionClosed`. Used by tests that need a `Connection` for +/// `CallConnection::new(conn)` or `adapter.handle(conn, &auth)` without +/// exercising the wire protocol — `SinkEmpty` reads EOF (so the dispatch +/// loop closes immediately) and discards writes. +pub(crate) fn sink_empty_connection() -> Connection { + Connection::from_bidi( + SinkEmpty, + b"alknet/call".to_vec(), + Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321)), + ) +} diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs new file mode 100644 index 0000000..dec1ab3 --- /dev/null +++ b/src/protocol/wire.rs @@ -0,0 +1,548 @@ +//! Wire format: `EventEnvelope`, `ResponseEnvelope`, `CallError`, and +//! length-prefixed JSON framing. +//! +//! See `docs/architecture/crates/call/call-protocol.md` for the full +//! specification. + +use std::io; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +pub const EVENT_REQUESTED: &str = "call.requested"; +pub const EVENT_RESPONDED: &str = "call.responded"; +pub const EVENT_COMPLETED: &str = "call.completed"; +pub const EVENT_ABORTED: &str = "call.aborted"; +pub const EVENT_ERROR: &str = "call.error"; + +const LENGTH_PREFIX_BYTES: usize = 4; +const MAX_FRAME_SIZE: u32 = 64 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventEnvelope { + #[serde(rename = "type")] + pub r#type: String, + pub id: String, + pub payload: Value, +} + +impl EventEnvelope { + pub fn new(event_type: impl Into, id: impl Into, payload: Value) -> Self { + Self { + r#type: event_type.into(), + id: id.into(), + payload, + } + } + + pub fn requested(id: impl Into, payload: Value) -> Self { + Self::new(EVENT_REQUESTED, id, payload) + } + + pub fn responded(id: impl Into, output: Value) -> Self { + Self::new(EVENT_RESPONDED, id, serde_json::json!({ "output": output })) + } + + pub fn completed(id: impl Into) -> Self { + Self::new(EVENT_COMPLETED, id, serde_json::json!({})) + } + + pub fn aborted(id: impl Into) -> Self { + Self::new(EVENT_ABORTED, id, serde_json::json!({})) + } + + pub fn error(id: impl Into, error: &CallError) -> Self { + let payload = serde_json::to_value(error).unwrap_or(Value::Null); + Self::new(EVENT_ERROR, id, payload) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CallError { + pub code: String, + pub message: String, + pub retryable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl CallError { + pub fn new(code: impl Into, message: impl Into, retryable: bool) -> Self { + Self { + code: code.into(), + message: message.into(), + retryable, + details: None, + } + } + + pub fn with_details(mut self, details: Value) -> Self { + self.details = Some(details); + self + } + + pub fn not_found(op_name: &str) -> Self { + Self::new( + "NOT_FOUND", + format!("operation not found: {op_name}"), + false, + ) + } + + pub fn forbidden(message: impl Into) -> Self { + Self::new("FORBIDDEN", message, false) + } + + pub fn invalid_input(message: impl Into) -> Self { + Self::new("INVALID_INPUT", message, false) + } + + pub fn internal(message: impl Into) -> Self { + Self::new("INTERNAL", message, false) + } + + pub fn timeout(message: impl Into) -> Self { + Self::new("TIMEOUT", message, true) + } + + pub fn invalid_operation_type(message: impl Into) -> Self { + Self::new("INVALID_OPERATION_TYPE", message, false) + } +} + +impl Eq for CallError {} + +#[derive(Debug, Clone, PartialEq)] +pub struct ResponseEnvelope { + pub request_id: String, + pub result: Result, +} + +impl ResponseEnvelope { + pub fn ok(request_id: impl Into, output: Value) -> Self { + Self { + request_id: request_id.into(), + result: Ok(output), + } + } + + pub fn error(request_id: impl Into, error: CallError) -> Self { + Self { + request_id: request_id.into(), + result: Err(error), + } + } + + pub fn not_found(request_id: impl Into, op_name: &str) -> Self { + Self::error(request_id, CallError::not_found(op_name)) + } + + pub fn forbidden(request_id: impl Into, message: impl Into) -> Self { + Self::error(request_id, CallError::forbidden(message)) + } + + pub fn into_event(self) -> EventEnvelope { + let id = self.request_id; + match self.result { + Ok(output) => EventEnvelope::responded(id, output), + Err(ref err) => EventEnvelope::error(id, err), + } + } +} + +impl From for EventEnvelope { + fn from(envelope: ResponseEnvelope) -> EventEnvelope { + envelope.into_event() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum FrameError { + #[error("io error: {0}")] + Io(#[from] io::Error), + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + #[error("connection closed")] + ConnectionClosed, + #[error("invalid frame")] + InvalidFrame, +} + +pub struct FrameFramedReader { + reader: R, + len_buf: [u8; LENGTH_PREFIX_BYTES], +} + +impl FrameFramedReader { + pub fn new(reader: R) -> Self { + Self { + reader, + len_buf: [0u8; LENGTH_PREFIX_BYTES], + } + } + + pub fn into_inner(self) -> R { + self.reader + } + + 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(FrameError::ConnectionClosed); + } + Err(e) => return Err(FrameError::Io(e)), + } + + let length = u32::from_be_bytes(self.len_buf); + if length == 0 { + return Err(FrameError::InvalidFrame); + } + if length > MAX_FRAME_SIZE { + return Err(FrameError::InvalidFrame); + } + + let mut body = vec![0u8; length as usize]; + match self.reader.read_exact(&mut body).await { + Ok(_) => {} + Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => { + return Err(FrameError::ConnectionClosed); + } + Err(e) => return Err(FrameError::Io(e)), + } + + let envelope: EventEnvelope = serde_json::from_slice(&body)?; + Ok(envelope) + } +} + +pub struct FrameFramedWriter { + writer: W, +} + +impl FrameFramedWriter { + pub fn new(writer: W) -> Self { + Self { writer } + } + + pub fn into_inner(self) -> W { + self.writer + } + + pub async fn write_frame(&mut self, envelope: &EventEnvelope) -> Result<(), FrameError> { + let body = serde_json::to_vec(envelope)?; + let len = body.len(); + if len > MAX_FRAME_SIZE as usize { + return Err(FrameError::InvalidFrame); + } + let len_bytes = (len as u32).to_be_bytes(); + self.writer.write_all(&len_bytes).await?; + self.writer.write_all(&body).await?; + self.writer.flush().await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{duplex, AsyncReadExt}; + + fn sample_envelope() -> EventEnvelope { + EventEnvelope::new( + "call.requested", + "req-1", + serde_json::json!({ + "operationId": "/fs/readFile", + "input": { "path": "/etc/hosts" } + }), + ) + } + + #[tokio::test] + async fn round_trip_envelope() { + let (client, server) = duplex(8 * 1024); + let envelope = sample_envelope(); + + let mut writer = FrameFramedWriter::new(client); + writer.write_frame(&envelope).await.unwrap(); + drop(writer); + + let mut reader = FrameFramedReader::new(server); + let read = reader.read_frame().await.unwrap(); + assert_eq!(read, envelope); + } + + #[tokio::test] + async fn round_trip_multiple_frames() { + let (client, server) = duplex(8 * 1024); + + let envelopes = vec![ + EventEnvelope::responded("a", Value::String("hello".into())), + EventEnvelope::completed("a"), + EventEnvelope::aborted("b"), + ]; + + { + let mut writer = FrameFramedWriter::new(client); + for e in &envelopes { + writer.write_frame(e).await.unwrap(); + } + } + + let mut reader = FrameFramedReader::new(server); + for expected in envelopes { + let read = reader.read_frame().await.unwrap(); + assert_eq!(read, expected); + } + } + + #[tokio::test] + async fn read_frame_on_closed_reader_returns_connection_closed() { + let (_, server) = duplex(8 * 1024); + let mut reader = FrameFramedReader::new(server); + match reader.read_frame().await { + Err(FrameError::ConnectionClosed) => {} + other => panic!("expected ConnectionClosed, got {other:?}"), + } + } + + #[tokio::test] + async fn truncated_body_returns_connection_closed() { + let (mut client, server) = duplex(8 * 1024); + let envelope = sample_envelope(); + let body = serde_json::to_vec(&envelope).unwrap(); + let len_bytes = (body.len() as u32).to_be_bytes(); + client.write_all(&len_bytes).await.unwrap(); + client.write_all(&body[..body.len() / 2]).await.unwrap(); + drop(client); + + let mut reader = FrameFramedReader::new(server); + match reader.read_frame().await { + Err(FrameError::ConnectionClosed) => {} + other => panic!("expected ConnectionClosed, got {other:?}"), + } + } + + #[tokio::test] + async fn zero_length_frame_is_invalid() { + let (mut client, server) = duplex(8 * 1024); + client.write_all(&[0u8, 0, 0, 0]).await.unwrap(); + drop(client); + + let mut reader = FrameFramedReader::new(server); + match reader.read_frame().await { + Err(FrameError::InvalidFrame) => {} + other => panic!("expected InvalidFrame, got {other:?}"), + } + } + + #[tokio::test] + async fn oversized_frame_is_invalid() { + let (mut client, server) = duplex(8 * 1024); + let too_big = (MAX_FRAME_SIZE + 1u32).to_be_bytes(); + client.write_all(&too_big).await.unwrap(); + drop(client); + + let mut reader = FrameFramedReader::new(server); + match reader.read_frame().await { + Err(FrameError::InvalidFrame) => {} + other => panic!("expected InvalidFrame, got {other:?}"), + } + } + + #[tokio::test] + async fn framing_handles_large_payload() { + let (client, server) = duplex(1024 * 1024); + let big = "x".repeat(64 * 1024); + let envelope = EventEnvelope::responded("big", Value::String(big.clone())); + + let mut writer = FrameFramedWriter::new(client); + writer.write_frame(&envelope).await.unwrap(); + drop(writer); + + let mut reader = FrameFramedReader::new(server); + let read = reader.read_frame().await.unwrap(); + assert_eq!(read, envelope); + match read.payload { + Value::Object(map) => match map.get("output") { + Some(Value::String(s)) => assert_eq!(s, &big), + other => panic!("expected output string, got {other:?}"), + }, + other => panic!("expected object payload, got {other:?}"), + } + } + + #[test] + fn response_envelope_ok_produces_call_responded_event() { + let response = ResponseEnvelope::ok("req-1", Value::String("hi".into())); + let event: EventEnvelope = response.into(); + assert_eq!(event.r#type, EVENT_RESPONDED); + assert_eq!(event.id, "req-1"); + let map = event.payload.as_object().expect("payload is object"); + assert_eq!(map.get("output"), Some(&Value::String("hi".into()))); + } + + #[test] + fn response_envelope_error_produces_call_error_event() { + let err = CallError::new("FILE_NOT_FOUND", "file not found: /etc/x", false) + .with_details(serde_json::json!({ "path": "/etc/x" })); + let response = ResponseEnvelope::error("req-2", err); + let event: EventEnvelope = response.into(); + assert_eq!(event.r#type, EVENT_ERROR); + assert_eq!(event.id, "req-2"); + assert_eq!( + event.payload.get("code"), + Some(&Value::String("FILE_NOT_FOUND".into())) + ); + assert_eq!( + event.payload.get("message"), + Some(&Value::String("file not found: /etc/x".into())) + ); + assert_eq!(event.payload.get("retryable"), Some(&Value::Bool(false))); + assert_eq!( + event.payload.get("details"), + Some(&serde_json::json!({ "path": "/etc/x" })) + ); + } + + #[test] + fn response_envelope_not_found_helper() { + let response = ResponseEnvelope::not_found("req-3", "fs/missing"); + assert_eq!(response.request_id, "req-3"); + match &response.result { + Err(e) => { + assert_eq!(e.code, "NOT_FOUND"); + assert!(!e.retryable); + assert!(e.message.contains("fs/missing")); + } + other => panic!("expected Err, got {other:?}"), + } + let event: EventEnvelope = response.into(); + assert_eq!(event.r#type, EVENT_ERROR); + assert_eq!(event.id, "req-3"); + assert_eq!( + event.payload.get("code"), + Some(&Value::String("NOT_FOUND".into())) + ); + } + + #[test] + fn response_envelope_forbidden_helper() { + let response = ResponseEnvelope::forbidden("req-4", "authentication required"); + match &response.result { + Err(e) => { + assert_eq!(e.code, "FORBIDDEN"); + assert_eq!(e.message, "authentication required"); + } + other => panic!("expected Err, got {other:?}"), + } + let event: EventEnvelope = response.into(); + assert_eq!(event.r#type, EVENT_ERROR); + assert_eq!(event.id, "req-4"); + } + + #[test] + fn event_envelope_completed_has_empty_payload() { + let event = EventEnvelope::completed("sub-1"); + assert_eq!(event.r#type, EVENT_COMPLETED); + assert_eq!(event.id, "sub-1"); + assert_eq!(event.payload, serde_json::json!({})); + } + + #[test] + fn event_envelope_aborted_has_empty_payload() { + let event = EventEnvelope::aborted("req-9"); + assert_eq!(event.r#type, EVENT_ABORTED); + assert_eq!(event.id, "req-9"); + assert_eq!(event.payload, serde_json::json!({})); + } + + #[test] + fn event_envelope_responded_wraps_output() { + let event = EventEnvelope::responded("req-1", Value::Number(42.into())); + assert_eq!(event.r#type, EVENT_RESPONDED); + assert_eq!(event.payload.get("output"), Some(&Value::Number(42.into()))); + } + + #[test] + fn event_envelope_serializes_type_field() { + let event = sample_envelope(); + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("\"type\":\"call.requested\"")); + assert!(!json.contains("\"r#type\"")); + + let parsed: EventEnvelope = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, event); + } + + #[test] + fn call_error_skips_missing_details() { + let err = CallError::new("INTERNAL", "boom", false); + let json = serde_json::to_string(&err).unwrap(); + assert!(!json.contains("details")); + } + + #[tokio::test] + async fn read_after_eof_then_eof_returns_connection_closed() { + let mut data = Vec::new(); + let envelope = EventEnvelope::responded("one", Value::Null); + let body = serde_json::to_vec(&envelope).unwrap(); + data.extend_from_slice(&(body.len() as u32).to_be_bytes()); + data.extend_from_slice(&body); + let cursor = std::io::Cursor::new(data); + let mut reader = FrameFramedReader::new(cursor); + let first = reader.read_frame().await.unwrap(); + assert_eq!(first, envelope); + match reader.read_frame().await { + Err(FrameError::ConnectionClosed) => {} + other => panic!("expected ConnectionClosed, got {other:?}"), + } + } + + #[tokio::test] + async fn writer_into_inner_recovers_stream() { + let (client, server) = duplex(8 * 1024); + let envelope = sample_envelope(); + let mut writer = FrameFramedWriter::new(client); + writer.write_frame(&envelope).await.unwrap(); + let mut recovered = writer.into_inner(); + recovered.shutdown().await.unwrap(); + drop(recovered); + + let mut reader = FrameFramedReader::new(server); + let read = reader.read_frame().await.unwrap(); + assert_eq!(read, envelope); + let _ = reader.into_inner(); + } + + #[tokio::test] + async fn reader_handles_partial_length_prefix() { + let (mut client, server) = duplex(8 * 1024); + client.write_all(&[0u8, 0]).await.unwrap(); + drop(client); + let mut reader = FrameFramedReader::new(server); + match reader.read_frame().await { + Err(FrameError::ConnectionClosed) => {} + other => panic!("expected ConnectionClosed, got {other:?}"), + } + } + + #[tokio::test] + async fn reader_drains_remaining_after_read() { + let mut data = Vec::new(); + let envelope = sample_envelope(); + let body = serde_json::to_vec(&envelope).unwrap(); + data.extend_from_slice(&(body.len() as u32).to_be_bytes()); + data.extend_from_slice(&body); + data.extend_from_slice(&[9u8; 4]); + let mut cursor = tokio::io::BufReader::new(std::io::Cursor::new(data)); + let mut reader = FrameFramedReader::new(&mut cursor); + let read = reader.read_frame().await.unwrap(); + assert_eq!(read, envelope); + let mut leftover = Vec::new(); + let _ = cursor.read_to_end(&mut leftover).await.unwrap(); + assert_eq!(leftover, vec![9u8; 4]); + } +} diff --git a/src/registry/context.rs b/src/registry/context.rs new file mode 100644 index 0000000..b0f6745 --- /dev/null +++ b/src/registry/context.rs @@ -0,0 +1,313 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Instant; + +use crate::core::auth::Identity; +use crate::core::ownership::OwnershipProvider; +use crate::core::types::Capabilities; +use serde_json::Value; + +use super::env::{OperationEnv, PeerId, PeerRef}; + +pub struct OperationContext { + pub request_id: String, + pub parent_request_id: Option, + pub identity: Option, + pub handler_identity: Option, + /// The original caller when this call was forwarded by a `from_call` + /// handler (ADR-032). **Metadata only** — `AccessControl::check` never + /// reads it; the ACL always authorizes `identity` (the direct caller). + /// Handlers may read it for logging, auditing, per-user rate limiting, + /// or application context. Populated from + /// `call.requested.forwarded_for` by the dispatch path; set to `None` + /// for composed children (wire-ingress only, not composition-ingress). + /// The forwarder's claim, not a verified identity — a malicious hub can + /// lie (same property as HTTP `X-Forwarded-For`). See ADR-032. + pub forwarded_for: Option, + pub capabilities: Capabilities, + pub metadata: HashMap, + pub scoped_env: ScopedPeerEnv, + pub env: Arc, + pub abort_policy: AbortPolicy, + pub deadline: Option, + pub internal: bool, + /// `None` when no ownership provider is wired (backward compat — + /// `check` falls back to static `Identity.resources` path). Wired by + /// the assembly layer via `CallAdapter`/`Dispatcher` (ADR-050). + pub ownership: Option>, +} + +impl OperationContext { + pub fn is_internal(&self) -> bool { + self.internal + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AbortPolicy { + #[default] + AbortDependents, + ContinueRunning, +} + +#[derive(Debug, Clone)] +pub struct CompositionAuthority { + pub label: String, + pub scopes: Vec, + pub resources: HashMap>, +} + +impl CompositionAuthority { + pub fn none() -> Option { + None + } + + pub fn new(label: &str, scopes: impl IntoIterator) -> Self { + Self { + label: label.to_string(), + scopes: scopes.into_iter().collect(), + resources: HashMap::new(), + } + } + + pub fn as_identity(&self) -> Option { + Some(Identity { + id: self.label.clone(), + scopes: self.scopes.clone(), + resources: self.resources.clone(), + }) + } +} + +#[derive(Debug, Clone)] +pub struct ScopedPeerEnv { + /// Peer-agnostic reachability — reachable via `PeerRef::Any` or + /// `PeerRef::Specific(any)`. The common case (peer-agnostic composition). + pub allowed_ops: HashSet, + /// Peer-pinned reachability — `"peer-id/op-name"`, reachable only via + /// `PeerRef::Specific(that peer)`. Additive to `allowed_ops`; opt-in for + /// the disambiguation case (ADR-029 §4). + pub peer_pinned: HashSet, +} + +impl ScopedPeerEnv { + pub fn empty() -> Self { + Self { + allowed_ops: HashSet::new(), + peer_pinned: HashSet::new(), + } + } + + pub fn new(ops: impl IntoIterator>) -> Self { + Self { + allowed_ops: ops.into_iter().map(|s| s.into()).collect(), + peer_pinned: HashSet::new(), + } + } + + /// Peer-pinned reachability: `"peer-id/op-name"`. Reachable only via + /// `PeerRef::Specific(that peer)`. Additive to `new` — call `new` for the + /// peer-agnostic set, then `with_pinned` for the pinned set. + pub fn with_pinned(mut self, pinned: impl IntoIterator>) -> Self { + self.peer_pinned = pinned.into_iter().map(|s| s.into()).collect(); + self + } + + /// Peer-agnostic reachability — unchanged from `ScopedOperationEnv::allows`. + /// A name here is reachable via any routing path (`PeerRef::Any` or + /// `Specific`). + pub fn allows(&self, name: &str) -> bool { + self.allowed_ops.contains(name) + } + + /// Peer-pinned reachability — reachable only via `PeerRef::Specific(peer)`. + /// The entry shape is `"peer-id/op-name"` (ADR-029 §4, OQ-33). + pub fn allows_pinned(&self, peer: &PeerId, name: &str) -> bool { + self.peer_pinned.contains(&format!("{peer}/{name}")) + } + + /// Does this scoped env permit `name` via `peer`? Used by the reachability + /// gate in `invoke_peer` / `invoke_with_policy`. + /// - `PeerRef::Any` → `allows(name)` + /// - `PeerRef::Specific(peer)` → `allows(name) || allows_pinned(peer, name)` + pub fn allows_via(&self, peer: &PeerRef, name: &str) -> bool { + match peer { + PeerRef::Any => self.allows(name), + PeerRef::Specific(p) => self.allows(name) || self.allows_pinned(p, name), + } + } +} + +impl Default for ScopedPeerEnv { + fn default() -> Self { + Self::empty() + } +} + +#[allow(dead_code)] +pub(crate) fn generate_request_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scoped_env_allows_in_set() { + let env = ScopedPeerEnv::new(["fs/readFile", "agent/chat"]); + assert!(env.allows("fs/readFile")); + assert!(env.allows("agent/chat")); + } + + #[test] + fn scoped_env_disallows_not_in_set() { + let env = ScopedPeerEnv::new(["fs/readFile"]); + assert!(!env.allows("agent/chat")); + assert!(!env.allows("")); + } + + #[test] + fn scoped_env_empty_allows_nothing() { + let env = ScopedPeerEnv::empty(); + assert!(!env.allows("fs/readFile")); + } + + #[test] + fn scoped_peer_env_new_with_pinned_populates_both_fields() { + let env = ScopedPeerEnv::new(["fs/readFile"]).with_pinned(["worker-a/container/exec"]); + assert!(env.allowed_ops.contains("fs/readFile")); + assert!(env.peer_pinned.contains("worker-a/container/exec")); + assert!(!env.allowed_ops.contains("worker-a/container/exec")); + assert!(!env.peer_pinned.contains("fs/readFile")); + } + + #[test] + fn scoped_peer_env_allows_checks_allowed_ops_only() { + let env = ScopedPeerEnv::empty().with_pinned(["worker-a/container/exec"]); + assert!( + !env.allows("container/exec"), + "pinned-only op not in allowed_ops" + ); + let env2 = ScopedPeerEnv::new(["container/exec"]).with_pinned(["worker-a/container/exec"]); + assert!( + env2.allows("container/exec"), + "op in allowed_ops is allowed" + ); + } + + #[test] + fn scoped_peer_env_allows_pinned_checks_peer_pinned_shape() { + let env = ScopedPeerEnv::empty().with_pinned(["worker-a/container/exec"]); + assert!(env.allows_pinned(&"worker-a".to_string(), "container/exec")); + assert!( + !env.allows_pinned(&"worker-b".to_string(), "container/exec"), + "wrong peer" + ); + assert!( + !env.allows_pinned(&"worker-a".to_string(), "other/op"), + "wrong op" + ); + } + + #[test] + fn scoped_peer_env_allows_via_any_uses_allowed_ops_only() { + let env = ScopedPeerEnv::new(["fs/readFile"]).with_pinned(["worker-a/container/exec"]); + assert!( + env.allows_via(&PeerRef::Any, "fs/readFile"), + "allowed op via Any" + ); + assert!( + !env.allows_via(&PeerRef::Any, "container/exec"), + "pinned-only op NOT reachable via Any" + ); + } + + #[test] + fn scoped_peer_env_allows_via_specific_uses_allowed_ops_or_peer_pinned() { + let env = ScopedPeerEnv::new(["fs/readFile"]).with_pinned(["worker-a/container/exec"]); + assert!( + env.allows_via(&PeerRef::Specific("worker-a".to_string()), "container/exec"), + "pinned-only op reachable via Specific(pinned peer)" + ); + assert!( + env.allows_via(&PeerRef::Specific("worker-a".to_string()), "fs/readFile"), + "allowed op reachable via Specific(any peer)" + ); + assert!( + !env.allows_via(&PeerRef::Specific("worker-b".to_string()), "container/exec"), + "pinned-only op NOT reachable via Specific(wrong peer)" + ); + } + + #[test] + fn scoped_peer_env_op_in_both_sets_reachable_via_both_any_and_specific() { + let env = ScopedPeerEnv::new(["container/exec"]).with_pinned(["worker-a/container/exec"]); + assert!( + env.allows_via(&PeerRef::Any, "container/exec"), + "op in allowed_ops reachable via Any" + ); + assert!( + env.allows_via(&PeerRef::Specific("worker-a".to_string()), "container/exec"), + "op in both sets reachable via Specific(peer)" + ); + assert!( + env.allows_via(&PeerRef::Specific("worker-b".to_string()), "container/exec"), + "op in allowed_ops reachable via Specific(other peer) too" + ); + } + + #[test] + fn composition_authority_as_identity_correct() { + let mut resources = HashMap::new(); + resources.insert("service".to_string(), vec!["vastai".to_string()]); + let authority = CompositionAuthority { + label: "agent-chat".to_string(), + scopes: vec!["llm:call".to_string(), "fs:read".to_string()], + resources, + }; + let identity = authority.as_identity().expect("as_identity returns Some"); + assert_eq!(identity.id, "agent-chat"); + assert_eq!( + identity.scopes, + vec!["llm:call".to_string(), "fs:read".to_string()] + ); + assert_eq!( + identity.resources.get("service"), + Some(&vec!["vastai".to_string()]) + ); + } + + #[test] + fn composition_authority_new_populates_label_and_scopes() { + let authority = CompositionAuthority::new( + "agent-chat", + ["llm:call".to_string(), "fs:read".to_string()], + ); + assert_eq!(authority.label, "agent-chat"); + assert_eq!( + authority.scopes, + vec!["llm:call".to_string(), "fs:read".to_string()] + ); + assert!(authority.resources.is_empty()); + } + + #[test] + fn composition_authority_none_is_none() { + assert!(CompositionAuthority::none().is_none()); + } + + #[test] + fn abort_policy_default_is_abort_dependents() { + let policy = AbortPolicy::default(); + assert!(matches!(policy, AbortPolicy::AbortDependents)); + } + + #[test] + fn generate_request_id_is_unique_and_non_deterministic() { + let a = generate_request_id(); + let b = generate_request_id(); + assert_ne!(a, b); + assert!(!a.is_empty()); + } +} diff --git a/src/registry/discovery.rs b/src/registry/discovery.rs new file mode 100644 index 0000000..1c94cf7 --- /dev/null +++ b/src/registry/discovery.rs @@ -0,0 +1,1002 @@ +use std::sync::Arc; + +use serde_json::{json, Value}; + +use super::context::OperationContext; +use super::registration::{Handler, OperationRegistry}; +use super::spec::{AccessControl, OperationSpec, OperationType, Visibility}; +use crate::protocol::wire::{CallError, ResponseEnvelope}; + +const NAME_SERVICES_LIST: &str = "services/list"; +const NAME_SERVICES_LIST_PEERS: &str = "services/list-peers"; +const NAME_SERVICES_SCHEMA: &str = "services/schema"; + +pub fn services_list_spec() -> OperationSpec { + OperationSpec::new( + NAME_SERVICES_LIST, + OperationType::Query, + Visibility::External, + json!({}), + json!({ + "type": "object", + "properties": { + "operations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "namespace": { "type": "string" }, + "op_type": { + "type": "string", + "enum": ["query", "mutation", "subscription"] + } + } + } + } + } + }), + vec![], + AccessControl::default(), + None, + ) +} + +pub fn services_schema_spec() -> OperationSpec { + OperationSpec::new( + NAME_SERVICES_SCHEMA, + OperationType::Query, + Visibility::External, + json!({ + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + }), + operation_spec_schema(), + vec![], + AccessControl::default(), + None, + ) +} + +pub fn services_list_peers_spec() -> OperationSpec { + OperationSpec::new( + NAME_SERVICES_LIST_PEERS, + OperationType::Query, + Visibility::External, + json!({}), + json!({ + "type": "object", + "properties": { + "peers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "peer_id": { "type": "string" }, + "operations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "namespace": { "type": "string" }, + "op_type": { + "type": "string", + "enum": ["query", "mutation", "subscription"] + } + } + } + } + } + } + } + } + }), + vec![], + AccessControl::default(), + None, + ) +} + +fn operation_spec_schema() -> Value { + json!({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "namespace": { "type": "string" }, + "op_type": { + "type": "string", + "enum": ["query", "mutation", "subscription"] + }, + "visibility": { + "type": "string", + "enum": ["external", "internal"] + }, + "input_schema": {}, + "output_schema": {}, + "error_schemas": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { "type": "string" }, + "description": { "type": "string" }, + "schema": {}, + "http_status": { "type": ["integer", "null"] } + } + } + }, + "access_control": { + "type": "object", + "properties": { + "required_scopes": { + "type": "array", + "items": { "type": "string" } + }, + "required_scopes_any": { + "type": ["array", "null"], + "items": { "type": "string" } + }, + "resource_type": { "type": ["string", "null"] }, + "resource_action": { "type": ["string", "null"] } + } + } + }, + "required": [ + "name", + "namespace", + "op_type", + "visibility", + "input_schema", + "output_schema", + "error_schemas", + "access_control" + ] + }) +} + +fn op_type_str(op_type: OperationType) -> &'static str { + match op_type { + OperationType::Query => "query", + OperationType::Mutation => "mutation", + OperationType::Subscription => "subscription", + } +} + +fn visibility_str(visibility: Visibility) -> &'static str { + match visibility { + Visibility::External => "external", + Visibility::Internal => "internal", + } +} + +fn access_control_to_json(acl: &AccessControl) -> Value { + json!({ + "required_scopes": acl.required_scopes, + "required_scopes_any": acl.required_scopes_any, + "resource_type": acl.resource_type, + "resource_action": acl.resource_action, + }) +} + +fn error_definition_to_json(def: &super::spec::ErrorDefinition) -> Value { + json!({ + "code": def.code, + "description": def.description, + "schema": def.schema, + "http_status": def.http_status, + }) +} + +fn spec_to_json(spec: &OperationSpec) -> Value { + let error_schemas: Vec = spec + .error_schemas + .iter() + .map(error_definition_to_json) + .collect(); + json!({ + "name": spec.name, + "namespace": spec.namespace, + "op_type": op_type_str(spec.op_type), + "visibility": visibility_str(spec.visibility), + "input_schema": spec.input_schema, + "output_schema": spec.output_schema, + "error_schemas": error_schemas, + "access_control": access_control_to_json(&spec.access_control), + }) +} + +fn normalize_name(name: &str) -> String { + if let Some(rest) = name.strip_prefix('/') { + rest.to_string() + } else { + name.to_string() + } +} + +pub fn services_list_handler(registry: Arc) -> Handler { + Arc::new(move |input: Value, ctx: OperationContext| { + let registry = Arc::clone(®istry); + Box::pin(async move { + let _ = input; + let calling_identity = ctx.identity.as_ref(); + let ops: Vec = registry + .list_operations() + .into_iter() + .filter(|spec| { + spec.access_control + .check(calling_identity, None, None) + .is_allowed() + }) + .map(|s| { + json!({ + "name": s.name, + "namespace": s.namespace, + "op_type": op_type_str(s.op_type), + }) + }) + .collect(); + ResponseEnvelope::ok(ctx.request_id, json!({ "operations": ops })) + }) + }) +} + +pub fn services_list_peers_handler(registry: Arc) -> Handler { + Arc::new(move |input: Value, ctx: OperationContext| { + let registry = Arc::clone(®istry); + Box::pin(async move { + let _ = input; + let calling_identity = ctx.identity.as_ref(); + let local_ops: Vec = registry + .list_operations() + .into_iter() + .filter(|spec| { + spec.access_control + .check(calling_identity, None, None) + .is_allowed() + }) + .map(|s| { + json!({ + "name": s.name, + "namespace": s.namespace, + "op_type": op_type_str(s.op_type), + }) + }) + .collect(); + let mut peers: Vec = Vec::new(); + if !local_ops.is_empty() { + peers.push(json!({ "peer_id": "local", "operations": local_ops })); + } + for peer_id in ctx.env.peer_ids() { + let peer_ops: Vec = ctx + .env + .peer_operations(&peer_id) + .into_iter() + .filter(|name| { + let spec = registry.registration(name); + match spec { + Some(reg) => reg + .spec + .access_control + .check(calling_identity, None, None) + .is_allowed(), + None => true, + } + }) + .map(name_to_listing_json) + .collect(); + if !peer_ops.is_empty() { + peers.push(json!({ "peer_id": peer_id, "operations": peer_ops })); + } + } + ResponseEnvelope::ok(ctx.request_id, json!({ "peers": peers })) + }) + }) +} + +fn name_to_listing_json(name: String) -> Value { + let namespace = name + .split('/') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or("") + .to_string(); + json!({ + "name": name, + "namespace": namespace, + "op_type": "query", + }) +} + +pub fn services_schema_handler(registry: Arc) -> Handler { + Arc::new(move |input: Value, ctx: OperationContext| { + let registry = Arc::clone(®istry); + Box::pin(async move { + let name = match input.get("name").and_then(|v| v.as_str()) { + Some(n) => normalize_name(n), + None => { + return ResponseEnvelope::error( + ctx.request_id, + CallError::invalid_input("missing required field: name"), + ); + } + }; + match registry.registration(&name) { + Some(reg) => { + let spec_json = spec_to_json(®.spec); + ResponseEnvelope::ok(ctx.request_id, spec_json) + } + None => ResponseEnvelope::not_found(ctx.request_id, &name), + } + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::types::Capabilities; + use crate::registry::context::{CompositionAuthority, ScopedPeerEnv}; + use crate::registry::registration::{ + make_handler, make_streaming_handler, HandlerKind, HandlerRegistration, + OperationProvenance, StreamingHandler, + }; + use std::collections::HashMap; + use std::time::Duration; + + fn external_spec(name: &str) -> OperationSpec { + OperationSpec::new( + name, + OperationType::Query, + Visibility::External, + json!({}), + json!({}), + vec![], + AccessControl::default(), + None, + ) + } + + fn internal_spec(name: &str) -> OperationSpec { + OperationSpec::new( + name, + OperationType::Mutation, + Visibility::Internal, + json!({}), + json!({}), + vec![], + AccessControl::default(), + None, + ) + } + + fn echo_handler() -> Handler { + make_handler( + |input, context| async move { ResponseEnvelope::ok(context.request_id, input) }, + ) + } + + fn echo_streaming_handler() -> StreamingHandler { + make_streaming_handler(|input, context| { + futures::stream::iter(vec![ResponseEnvelope::ok(context.request_id, input)]) + }) + } + + fn noop_env() -> Arc { + struct NoopEnv; + #[async_trait::async_trait] + impl crate::registry::env::OperationEnv for NoopEnv { + async fn invoke_with_policy( + &self, + _ns: &str, + _op: &str, + _input: Value, + _parent: &OperationContext, + _policy: crate::registry::context::AbortPolicy, + ) -> ResponseEnvelope { + ResponseEnvelope::error("test", CallError::internal("noop env does not dispatch")) + } + fn contains(&self, _name: &str) -> bool { + false + } + } + Arc::new(NoopEnv) + } + + fn root_context(request_id: &str) -> OperationContext { + OperationContext { + request_id: request_id.to_string(), + parent_request_id: None, + identity: None, + handler_identity: None, + forwarded_for: None, + capabilities: Capabilities::new(), + metadata: HashMap::new(), + scoped_env: ScopedPeerEnv::empty(), + env: noop_env(), + abort_policy: crate::registry::context::AbortPolicy::default(), + deadline: Some(std::time::Instant::now() + Duration::from_secs(30)), + internal: false, + ownership: None, + } + } + + fn root_context_with_identity( + request_id: &str, + identity: Option, + ) -> OperationContext { + OperationContext { + request_id: request_id.to_string(), + parent_request_id: None, + identity, + handler_identity: None, + forwarded_for: None, + capabilities: Capabilities::new(), + metadata: HashMap::new(), + scoped_env: ScopedPeerEnv::empty(), + env: noop_env(), + abort_policy: crate::registry::context::AbortPolicy::default(), + deadline: Some(std::time::Instant::now() + Duration::from_secs(30)), + internal: false, + ownership: None, + } + } + + fn identity_with_scopes(id: &str, scopes: &[&str]) -> crate::core::auth::Identity { + crate::core::auth::Identity { + id: id.to_string(), + scopes: scopes.iter().map(|s| s.to_string()).collect(), + resources: HashMap::new(), + } + } + + fn external_spec_with_acl(name: &str, acl: AccessControl) -> OperationSpec { + OperationSpec::new( + name, + OperationType::Query, + Visibility::External, + json!({}), + json!({}), + vec![], + acl, + None, + ) + } + + fn registry_with_access_controlled_ops() -> Arc { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + external_spec_with_acl("public/echo", AccessControl::default()), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + registry + .register(HandlerRegistration::new( + external_spec_with_acl( + "admin/secret", + AccessControl { + required_scopes: vec!["admin".to_string()], + ..Default::default() + }, + ), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + registry + .register(HandlerRegistration::new( + internal_spec("internal/hidden"), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + Arc::new(registry) + } + + fn op_names(response: ResponseEnvelope) -> Vec { + let output = response.result.expect("ok response"); + output + .get("operations") + .and_then(|v| v.as_array()) + .expect("operations array") + .iter() + .filter_map(|o| o.get("name").and_then(|n| n.as_str()).map(String::from)) + .collect() + } + + fn registry_with_ops() -> Arc { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + external_spec("fs/readFile"), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + registry + .register(HandlerRegistration::new( + internal_spec("secret/internal"), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + registry + .register(HandlerRegistration::new( + OperationSpec::new( + "events/subscribe", + OperationType::Subscription, + Visibility::External, + json!({}), + json!({}), + vec![], + AccessControl::default(), + None, + ), + HandlerKind::Stream(echo_streaming_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + registry + .register(HandlerRegistration::new( + OperationSpec::new( + "fs/readFileErr", + OperationType::Query, + Visibility::External, + json!({}), + json!({}), + vec![super::super::spec::ErrorDefinition { + code: "FILE_NOT_FOUND".to_string(), + description: "file not found".to_string(), + schema: json!({ "type": "object" }), + http_status: None, + }], + AccessControl::default(), + None, + ), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + Arc::new(registry) + } + + #[test] + fn services_list_spec_has_correct_fields() { + let spec = services_list_spec(); + assert_eq!(spec.name, NAME_SERVICES_LIST); + assert_eq!(spec.namespace, "services"); + assert_eq!(spec.op_type, OperationType::Query); + assert_eq!(spec.visibility, Visibility::External); + assert_eq!(spec.input_schema, json!({})); + assert!(spec.output_schema.get("properties").is_some()); + assert!(spec.error_schemas.is_empty()); + assert!(!spec.access_control.has_restrictions()); + } + + #[test] + fn services_schema_spec_has_correct_fields() { + let spec = services_schema_spec(); + assert_eq!(spec.name, NAME_SERVICES_SCHEMA); + assert_eq!(spec.namespace, "services"); + assert_eq!(spec.op_type, OperationType::Query); + assert_eq!(spec.visibility, Visibility::External); + assert!(spec.input_schema.get("required").is_some()); + assert!(spec.output_schema.get("properties").is_some()); + assert!(spec.error_schemas.is_empty()); + assert!(!spec.access_control.has_restrictions()); + } + + #[tokio::test] + async fn services_list_returns_external_ops_only() { + let registry = registry_with_ops(); + let handler = services_list_handler(Arc::clone(®istry)); + let ctx = root_context("req-1"); + let response = handler(serde_json::json!({}), ctx).await; + let output = response.result.expect("ok response"); + let ops = output + .get("operations") + .and_then(|v| v.as_array()) + .expect("operations array"); + let names: Vec<&str> = ops + .iter() + .filter_map(|o| o.get("name").and_then(|n| n.as_str())) + .collect(); + assert!(names.contains(&"fs/readFile")); + assert!(names.contains(&"events/subscribe")); + assert!(names.contains(&"fs/readFileErr")); + assert!( + !names.contains(&"secret/internal"), + "internal ops must not be listed" + ); + } + + #[tokio::test] + async fn services_list_output_format_matches_spec() { + let registry = registry_with_ops(); + let handler = services_list_handler(Arc::clone(®istry)); + let ctx = root_context("req-1"); + let response = handler(serde_json::json!({}), ctx).await; + let output = response.result.expect("ok response"); + let ops = output + .get("operations") + .and_then(|v| v.as_array()) + .expect("operations array"); + let fs_op = ops + .iter() + .find(|o| o.get("name").and_then(|n| n.as_str()) == Some("fs/readFile")) + .expect("fs/readFile present"); + assert_eq!(fs_op.get("namespace"), Some(&json!("fs"))); + assert_eq!(fs_op.get("op_type"), Some(&json!("query"))); + } + + #[tokio::test] + async fn services_schema_returns_spec_for_known_op() { + let registry = registry_with_ops(); + let handler = services_schema_handler(Arc::clone(®istry)); + let ctx = root_context("req-2"); + let response = handler(serde_json::json!({ "name": "fs/readFileErr" }), ctx).await; + let spec = response.result.expect("ok response"); + assert_eq!(spec.get("name"), Some(&json!("fs/readFileErr"))); + assert_eq!(spec.get("namespace"), Some(&json!("fs"))); + assert_eq!(spec.get("op_type"), Some(&json!("query"))); + let error_schemas = spec + .get("error_schemas") + .and_then(|v| v.as_array()) + .expect("error_schemas array"); + assert_eq!(error_schemas.len(), 1); + assert_eq!(error_schemas[0].get("code"), Some(&json!("FILE_NOT_FOUND"))); + } + + #[tokio::test] + async fn services_schema_returns_not_found_for_unknown_op() { + let registry = registry_with_ops(); + let handler = services_schema_handler(Arc::clone(®istry)); + let ctx = root_context("req-3"); + let response = handler(serde_json::json!({ "name": "no/such" }), ctx).await; + match response.result { + Err(e) => assert_eq!(e.code, "NOT_FOUND"), + other => panic!("expected NOT_FOUND, got {other:?}"), + } + } + + #[tokio::test] + async fn services_schema_accepts_name_with_leading_slash() { + let registry = registry_with_ops(); + let handler = services_schema_handler(Arc::clone(®istry)); + let ctx = root_context("req-4"); + let response = handler(serde_json::json!({ "name": "/fs/readFile" }), ctx).await; + let spec = response.result.expect("ok response"); + assert_eq!(spec.get("name"), Some(&json!("fs/readFile"))); + } + + #[tokio::test] + async fn services_schema_rejects_missing_name() { + let registry = registry_with_ops(); + let handler = services_schema_handler(Arc::clone(®istry)); + let ctx = root_context("req-5"); + let response = handler(serde_json::json!({}), ctx).await; + match response.result { + Err(e) => assert_eq!(e.code, "INVALID_INPUT"), + other => panic!("expected INVALID_INPUT, got {other:?}"), + } + } + + #[tokio::test] + async fn services_list_handler_registered_and_invocable_via_registry() { + let registry = registry_with_ops(); + let list_handler = services_list_handler(Arc::clone(®istry)); + let schema_handler = services_schema_handler(Arc::clone(®istry)); + + let mut discovery_registry = OperationRegistry::new(); + discovery_registry + .register(HandlerRegistration::new( + services_list_spec(), + HandlerKind::Once(list_handler), + OperationProvenance::Local, + CompositionAuthority::none(), + ScopedPeerEnv::empty().into(), + Capabilities::new(), + )) + .unwrap(); + discovery_registry + .register(HandlerRegistration::new( + services_schema_spec(), + HandlerKind::Once(schema_handler), + OperationProvenance::Local, + CompositionAuthority::none(), + ScopedPeerEnv::empty().into(), + Capabilities::new(), + )) + .unwrap(); + let discovery = Arc::new(discovery_registry); + + let ctx = root_context("req-6"); + let response = discovery + .invoke(NAME_SERVICES_LIST, serde_json::json!({}), ctx) + .await; + let output = response.result.expect("list ok"); + assert!(output.get("operations").is_some()); + } + + #[test] + fn normalize_name_strips_leading_slash() { + assert_eq!(normalize_name("/fs/readFile"), "fs/readFile"); + assert_eq!(normalize_name("fs/readFile"), "fs/readFile"); + } + + #[test] + fn op_type_str_matches_wire_enum() { + assert_eq!(op_type_str(OperationType::Query), "query"); + assert_eq!(op_type_str(OperationType::Mutation), "mutation"); + assert_eq!(op_type_str(OperationType::Subscription), "subscription"); + } + + #[test] + fn visibility_str_matches_wire_enum() { + assert_eq!(visibility_str(Visibility::External), "external"); + assert_eq!(visibility_str(Visibility::Internal), "internal"); + } + + #[test] + fn spec_to_json_round_trips_error_schemas() { + let spec = OperationSpec::new( + "fs/readFile", + OperationType::Query, + Visibility::External, + json!({ "type": "object" }), + json!({ "type": "string" }), + vec![super::super::spec::ErrorDefinition { + code: "FILE_NOT_FOUND".to_string(), + description: "file not found".to_string(), + schema: json!({ "type": "object", "properties": { "path": { "type": "string" } } }), + http_status: Some(404), + }], + AccessControl { + required_scopes: vec!["fs:read".to_string()], + ..Default::default() + }, + None, + ); + let json_val = spec_to_json(&spec); + let error_schemas = json_val + .get("error_schemas") + .and_then(|v| v.as_array()) + .expect("error_schemas"); + assert_eq!(error_schemas.len(), 1); + assert_eq!(error_schemas[0].get("code"), Some(&json!("FILE_NOT_FOUND"))); + assert_eq!(error_schemas[0].get("http_status"), Some(&json!(404))); + let acl = json_val.get("access_control").expect("access_control"); + assert_eq!(acl.get("required_scopes"), Some(&json!(["fs:read"]))); + } + + #[tokio::test] + async fn services_list_filters_by_access_control_authorized_peer() { + let registry = registry_with_access_controlled_ops(); + let handler = services_list_handler(Arc::clone(®istry)); + let ctx = root_context_with_identity( + "req-acl-1", + Some(identity_with_scopes("admin-peer", &["admin"])), + ); + let names = op_names(handler(serde_json::json!({}), ctx).await); + assert!(names.contains(&"public/echo".to_string())); + assert!(names.contains(&"admin/secret".to_string())); + assert!(!names.contains(&"internal/hidden".to_string())); + } + + #[tokio::test] + async fn services_list_filters_by_access_control_unauthorized_peer() { + let registry = registry_with_access_controlled_ops(); + let handler = services_list_handler(Arc::clone(®istry)); + let ctx = root_context_with_identity( + "req-acl-2", + Some(identity_with_scopes("regular-peer", &["user"])), + ); + let names = op_names(handler(serde_json::json!({}), ctx).await); + assert!(names.contains(&"public/echo".to_string())); + assert!( + !names.contains(&"admin/secret".to_string()), + "unauthorized peer must not see admin/secret" + ); + assert!(!names.contains(&"internal/hidden".to_string())); + } + + #[tokio::test] + async fn services_list_op_with_default_acl_listed_to_any_peer() { + let registry = registry_with_access_controlled_ops(); + let handler = services_list_handler(Arc::clone(®istry)); + let ctx = root_context_with_identity("req-acl-3", None); + let names = op_names(handler(serde_json::json!({}), ctx).await); + assert!( + names.contains(&"public/echo".to_string()), + "default AccessControl op must be listed to unauthenticated peer" + ); + assert!(!names.contains(&"admin/secret".to_string())); + } + + #[tokio::test] + async fn services_list_peers_attributes_ops_by_peer_id() { + struct PeerEnv { + peers: HashMap>, + } + #[async_trait::async_trait] + impl crate::registry::env::OperationEnv for PeerEnv { + async fn invoke_with_policy( + &self, + _ns: &str, + _op: &str, + _input: Value, + parent: &OperationContext, + _policy: crate::registry::context::AbortPolicy, + ) -> ResponseEnvelope { + ResponseEnvelope::ok(parent.request_id.clone(), json!({})) + } + fn contains(&self, _name: &str) -> bool { + false + } + fn peer_ids(&self) -> Vec { + self.peers.keys().cloned().collect() + } + fn peer_operations(&self, peer: &crate::registry::env::PeerId) -> Vec { + self.peers.get(peer).cloned().unwrap_or_default() + } + } + + let mut peers = HashMap::new(); + peers.insert( + "worker-a".to_string(), + vec!["container/exec".to_string(), "container/logs".to_string()], + ); + peers.insert("worker-b".to_string(), vec!["container/exec".to_string()]); + let env: Arc = + Arc::new(PeerEnv { peers }); + + let registry = registry_with_access_controlled_ops(); + let handler = services_list_peers_handler(Arc::clone(®istry)); + let ctx = OperationContext { + request_id: "req-peers-1".to_string(), + parent_request_id: None, + identity: None, + handler_identity: None, + forwarded_for: None, + capabilities: Capabilities::new(), + metadata: HashMap::new(), + scoped_env: ScopedPeerEnv::empty(), + env, + abort_policy: crate::registry::context::AbortPolicy::default(), + deadline: Some(std::time::Instant::now() + Duration::from_secs(30)), + internal: false, + ownership: None, + }; + let response = handler(serde_json::json!({}), ctx).await; + let output = response.result.expect("ok response"); + let peers_arr = output + .get("peers") + .and_then(|v| v.as_array()) + .expect("peers array"); + let peer_ids: Vec<&str> = peers_arr + .iter() + .filter_map(|p| p.get("peer_id").and_then(|v| v.as_str())) + .collect(); + assert!(peer_ids.contains(&"local")); + assert!(peer_ids.contains(&"worker-a")); + assert!(peer_ids.contains(&"worker-b")); + let worker_a = peers_arr + .iter() + .find(|p| p.get("peer_id").and_then(|v| v.as_str()) == Some("worker-a")) + .expect("worker-a present"); + let worker_a_ops = worker_a + .get("operations") + .and_then(|v| v.as_array()) + .expect("worker-a operations"); + let worker_a_names: Vec<&str> = worker_a_ops + .iter() + .filter_map(|o| o.get("name").and_then(|n| n.as_str())) + .collect(); + assert!(worker_a_names.contains(&"container/exec")); + assert!(worker_a_names.contains(&"container/logs")); + } + + #[test] + fn services_list_peers_spec_has_correct_fields() { + let spec = services_list_peers_spec(); + assert_eq!(spec.name, NAME_SERVICES_LIST_PEERS); + assert_eq!(spec.namespace, "services"); + assert_eq!(spec.op_type, OperationType::Query); + assert_eq!(spec.visibility, Visibility::External); + assert!(spec.error_schemas.is_empty()); + assert!(!spec.access_control.has_restrictions()); + } + + #[tokio::test] + async fn services_list_peers_filters_by_access_control() { + struct PeerEnv; + #[async_trait::async_trait] + impl crate::registry::env::OperationEnv for PeerEnv { + async fn invoke_with_policy( + &self, + _ns: &str, + _op: &str, + _input: Value, + parent: &OperationContext, + _policy: crate::registry::context::AbortPolicy, + ) -> ResponseEnvelope { + ResponseEnvelope::ok(parent.request_id.clone(), json!({})) + } + fn contains(&self, _name: &str) -> bool { + false + } + fn peer_ids(&self) -> Vec { + vec!["restricted-peer".to_string()] + } + fn peer_operations(&self, _peer: &crate::registry::env::PeerId) -> Vec { + vec!["admin/secret".to_string(), "public/echo".to_string()] + } + } + + let registry = registry_with_access_controlled_ops(); + let handler = services_list_peers_handler(Arc::clone(®istry)); + let env: Arc = Arc::new(PeerEnv); + let ctx = OperationContext { + request_id: "req-peers-2".to_string(), + parent_request_id: None, + identity: Some(identity_with_scopes("regular-peer", &["user"])), + handler_identity: None, + forwarded_for: None, + capabilities: Capabilities::new(), + metadata: HashMap::new(), + scoped_env: ScopedPeerEnv::empty(), + env, + abort_policy: crate::registry::context::AbortPolicy::default(), + deadline: Some(std::time::Instant::now() + Duration::from_secs(30)), + internal: false, + ownership: None, + }; + let response = handler(serde_json::json!({}), ctx).await; + let output = response.result.expect("ok response"); + let peers_arr = output + .get("peers") + .and_then(|v| v.as_array()) + .expect("peers array"); + let restricted = peers_arr + .iter() + .find(|p| p.get("peer_id").and_then(|v| v.as_str()) == Some("restricted-peer")) + .expect("restricted-peer present"); + let ops = restricted + .get("operations") + .and_then(|v| v.as_array()) + .expect("operations"); + let names: Vec<&str> = ops + .iter() + .filter_map(|o| o.get("name").and_then(|n| n.as_str())) + .collect(); + assert!(names.contains(&"public/echo")); + assert!( + !names.contains(&"admin/secret"), + "unauthorized peer must not see admin op in list-peers" + ); + } +} diff --git a/src/registry/env.rs b/src/registry/env.rs new file mode 100644 index 0000000..92fd0cb --- /dev/null +++ b/src/registry/env.rs @@ -0,0 +1,1325 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::Value; + +use super::context::{generate_request_id, AbortPolicy, OperationContext, ScopedPeerEnv}; +use super::registration::OperationRegistry; +use crate::protocol::wire::ResponseEnvelope; + +/// Logical peer identifier (ADR-029 §1, ADR-030 §4). The payload is +/// `Identity.id` from `IdentityProvider` resolution (= `PeerEntry.peer_id`), +/// stable across key rotation — NOT a connection-assigned UUID and NOT the +/// peer's cryptographic material. +pub type PeerId = String; + +/// Peer-routing selector (ADR-029 §2). Selects a specific peer's sub-overlay +/// (`Specific`) or the first peer (insertion order) that serves the op +/// (`Any`). +/// +/// `PeerRef::Specific(PeerId)` routes to the named peer's overlay only — no +/// fallthrough (explicit routing must be honored or fail loudly, ADR-029 §2). +/// `PeerRef::Any` reuses `invoke_with_policy` (the insertion-order fan-out +/// built in `PeerCompositeEnv`). +pub enum PeerRef { + Specific(PeerId), + Any, +} + +#[async_trait::async_trait] +pub trait OperationEnv: Send + Sync { + async fn invoke( + &self, + namespace: &str, + operation: &str, + input: Value, + parent: &OperationContext, + ) -> ResponseEnvelope { + self.invoke_with_policy(namespace, operation, input, parent, parent.abort_policy) + .await + } + + async fn invoke_with_policy( + &self, + namespace: &str, + operation: &str, + input: Value, + parent: &OperationContext, + policy: AbortPolicy, + ) -> ResponseEnvelope; + + fn contains(&self, _name: &str) -> bool { + true + } + + fn peer_ids(&self) -> Vec { + Vec::new() + } + + fn peer_contains(&self, _peer: &PeerId, name: &str) -> bool { + self.contains(name) + } + + fn peer_operations(&self, _peer: &PeerId) -> Vec { + Vec::new() + } + + /// Peer-routing composition (ADR-029 §2). Routes to a specific peer + /// (`PeerRef::Specific`) or to the first peer that serves the op + /// (`PeerRef::Any`). The default impl ignores the peer selector and + /// delegates to `invoke_with_policy`, preserving back-compat for + /// single-layer envs that don't route by peer. `PeerCompositeEnv` + /// overrides with real peer-keyed routing. + async fn invoke_peer( + &self, + peer: &PeerRef, + namespace: &str, + operation: &str, + input: Value, + parent: &OperationContext, + policy: AbortPolicy, + ) -> ResponseEnvelope { + let _ = peer; + self.invoke_with_policy(namespace, operation, input, parent, policy) + .await + } +} + +pub struct LocalOperationEnv { + registry: Arc, +} + +impl LocalOperationEnv { + pub fn new(registry: Arc) -> Self { + Self { registry } + } + + pub fn registry(&self) -> &Arc { + &self.registry + } +} + +#[async_trait::async_trait] +impl OperationEnv for LocalOperationEnv { + async fn invoke_with_policy( + &self, + namespace: &str, + operation: &str, + input: Value, + parent: &OperationContext, + policy: AbortPolicy, + ) -> ResponseEnvelope { + let name = format!("{namespace}/{operation}"); + + if !parent.scoped_env.allows(&name) { + return ResponseEnvelope::not_found(parent.request_id.clone(), &name); + } + + let registration = match self.registry.registration(&name) { + Some(r) => r, + None => return ResponseEnvelope::not_found(parent.request_id.clone(), &name), + }; + + let context = OperationContext { + request_id: generate_request_id(), + parent_request_id: Some(parent.request_id.clone()), + identity: parent + .handler_identity + .as_ref() + .and_then(|ca| ca.as_identity()), + handler_identity: registration.composition_authority.clone(), + forwarded_for: None, + capabilities: parent.capabilities.clone(), + metadata: HashMap::new(), + abort_policy: policy, + deadline: parent.deadline, + scoped_env: registration + .scoped_env + .clone() + .unwrap_or_else(ScopedPeerEnv::empty), + env: parent.env.clone(), + internal: true, + ownership: parent.ownership.clone(), + }; + + self.registry.invoke(&name, input, context).await + } +} + +/// Per-call composite env (ADR-024 + ADR-029 §1). Built by the `Dispatcher` +/// in `compose_root_env` from the active layers. The child inherits this by +/// `Arc::clone` through `invoke()`. The Layer 2 connection overlay is +/// **peer-keyed** — a head node with N worker connections holds a +/// `HashMap`, not one overlay. The singular- +/// connection case (one peer) is the degenerate case with a single-entry map. +pub struct PeerCompositeEnv { + pub base: Arc, + pub session: Option>, + pub connections: HashMap>, + connection_order: Vec, +} + +impl PeerCompositeEnv { + pub fn new(base: Arc) -> Self { + Self { + base, + session: None, + connections: HashMap::new(), + connection_order: Vec::new(), + } + } + + pub fn with_session(mut self, session: Arc) -> Self { + self.session = Some(session); + self + } + + /// Attach a peer's connection overlay. The `peer_id` comes from + /// `connection.identity().id` (IdentityProvider resolution). A connection + /// with no resolved identity has no `PeerId` and is NOT attached + /// (ADR-030 §5) — its ops are invoked through the `CallConnection` handle + /// directly, not via peer-keyed composition. + pub fn attach_peer(&mut self, peer_id: PeerId, overlay: Arc) { + if !self.connections.contains_key(&peer_id) { + self.connection_order.push(peer_id.clone()); + } + self.connections.insert(peer_id, overlay); + } + + /// Detach a peer's overlay (on disconnect). The peer's sub-overlay drops; + /// in-flight `PeerRef::Specific(that_peer)` gets `NOT_FOUND`. + pub fn detach_peer(&mut self, peer_id: &PeerId) { + if self.connections.remove(peer_id).is_some() { + self.connection_order.retain(|p| p != peer_id); + } + } + + pub fn base(&self) -> &Arc { + &self.base + } + + pub fn session(&self) -> &Option> { + &self.session + } + + pub fn connections(&self) -> &HashMap> { + &self.connections + } + + pub fn connection_order(&self) -> &[PeerId] { + &self.connection_order + } +} + +#[async_trait::async_trait] +impl OperationEnv for PeerCompositeEnv { + async fn invoke_with_policy( + &self, + namespace: &str, + operation: &str, + input: Value, + parent: &OperationContext, + policy: AbortPolicy, + ) -> ResponseEnvelope { + let name = format!("{namespace}/{operation}"); + + if !parent.scoped_env.allows(&name) { + return ResponseEnvelope::not_found(parent.request_id.clone(), &name); + } + + if let Some(session) = &self.session { + if session.contains(&name) { + return session + .invoke_with_policy(namespace, operation, input, parent, policy) + .await; + } + } + for peer_id in &self.connection_order { + if let Some(conn_env) = self.connections.get(peer_id) { + if conn_env.contains(&name) { + return conn_env + .invoke_with_policy(namespace, operation, input, parent, policy) + .await; + } + } + } + self.base + .invoke_with_policy(namespace, operation, input, parent, policy) + .await + } + + fn contains(&self, name: &str) -> bool { + self.session.as_ref().is_some_and(|s| s.contains(name)) + || self.connections.values().any(|c| c.contains(name)) + || self.base.contains(name) + } + + async fn invoke_peer( + &self, + peer: &PeerRef, + namespace: &str, + operation: &str, + input: Value, + parent: &OperationContext, + policy: AbortPolicy, + ) -> ResponseEnvelope { + let name = format!("{namespace}/{operation}"); + match peer { + PeerRef::Specific(peer_id) => { + if !parent + .scoped_env + .allows_via(&PeerRef::Specific(peer_id.clone()), &name) + { + return ResponseEnvelope::not_found(parent.request_id.clone(), &name); + } + match self.connections.get(peer_id) { + Some(conn_env) if conn_env.contains(&name) => { + conn_env + .invoke_with_policy(namespace, operation, input, parent, policy) + .await + } + _ => ResponseEnvelope::not_found(parent.request_id.clone(), &name), + } + } + PeerRef::Any => { + if !parent.scoped_env.allows(&name) { + return ResponseEnvelope::not_found(parent.request_id.clone(), &name); + } + self.invoke_with_policy(namespace, operation, input, parent, policy) + .await + } + } + } + + fn peer_contains(&self, peer: &PeerId, name: &str) -> bool { + self.connections.get(peer).is_some_and(|c| c.contains(name)) + } + + fn peer_ids(&self) -> Vec { + self.connection_order.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::auth::Identity; + use crate::core::types::Capabilities; + use crate::registry::context::CompositionAuthority; + use crate::registry::registration::{ + make_handler, HandlerKind, HandlerRegistration, OperationProvenance, + }; + use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility}; + use std::time::{Duration, Instant}; + + struct NoopEnv { + contains_op: bool, + } + + #[async_trait::async_trait] + impl OperationEnv for NoopEnv { + async fn invoke_with_policy( + &self, + _namespace: &str, + _operation: &str, + _input: Value, + parent: &OperationContext, + _policy: AbortPolicy, + ) -> ResponseEnvelope { + ResponseEnvelope::ok(parent.request_id.clone(), Value::String("noop".into())) + } + + fn contains(&self, _name: &str) -> bool { + self.contains_op + } + } + + fn echo_handler() -> crate::registry::registration::Handler { + make_handler( + |input, context| async move { ResponseEnvelope::ok(context.request_id, input) }, + ) + } + + fn inspect_handler() -> crate::registry::registration::Handler { + make_handler(|_input, context| async move { + let internal = context.is_internal(); + let id = context.identity.as_ref().map(|i| i.id.clone()); + let forwarded_for_id = context.forwarded_for.as_ref().map(|i| i.id.clone()); + let metadata_empty = context.metadata.is_empty(); + let parent_set = context.parent_request_id.is_some(); + ResponseEnvelope::ok( + context.request_id, + serde_json::json!({ + "internal": internal, + "identity_id": id, + "forwarded_for_id": forwarded_for_id, + "metadata_empty": metadata_empty, + "parent_set": parent_set, + }), + ) + }) + } + + fn root_context( + request_id: &str, + identity: Option, + handler_identity: Option, + scoped_env: ScopedPeerEnv, + env: Arc, + ) -> OperationContext { + root_context_with_forwarded_for( + request_id, + identity, + handler_identity, + None, + scoped_env, + env, + ) + } + + fn root_context_with_forwarded_for( + request_id: &str, + identity: Option, + handler_identity: Option, + forwarded_for: Option, + scoped_env: ScopedPeerEnv, + env: Arc, + ) -> OperationContext { + OperationContext { + request_id: request_id.to_string(), + parent_request_id: None, + identity, + handler_identity, + forwarded_for, + capabilities: Capabilities::new(), + metadata: HashMap::new(), + scoped_env, + env, + abort_policy: AbortPolicy::default(), + deadline: Some(Instant::now() + Duration::from_secs(30)), + internal: false, + ownership: None, + } + } + + fn registry_with( + name: &str, + spec_visibility: Visibility, + handler: crate::registry::registration::Handler, + composition_authority: Option, + scoped_env: Option, + ) -> Arc { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + OperationSpec::new( + name, + OperationType::Query, + spec_visibility, + serde_json::json!({}), + serde_json::json!({}), + vec![], + AccessControl::default(), + None, + ), + HandlerKind::Once(handler), + OperationProvenance::Local, + composition_authority, + scoped_env, + Capabilities::new(), + )) + .unwrap(); + Arc::new(registry) + } + + #[tokio::test] + async fn local_env_invoke_allowed_op_dispatches() { + let registry = registry_with("echo/run", Visibility::External, echo_handler(), None, None); + let env = Arc::new(LocalOperationEnv::new(Arc::clone(®istry))); + let scoped = ScopedPeerEnv::new(["echo/run"]); + let ctx = root_context("root-1", None, None, scoped, env.clone()); + let response = env + .invoke("echo", "run", serde_json::json!({"hi": 1}), &ctx) + .await; + assert!(response.result.is_ok()); + assert_eq!(response.result.unwrap(), serde_json::json!({"hi": 1})); + } + + #[tokio::test] + async fn local_env_invoke_disallowed_op_returns_not_found() { + let registry = registry_with("echo/run", Visibility::External, echo_handler(), None, None); + let env = Arc::new(LocalOperationEnv::new(Arc::clone(®istry))); + let scoped = ScopedPeerEnv::new(["other/op"]); + let ctx = root_context("root-2", None, None, scoped, env.clone()); + let response = env.invoke("echo", "run", serde_json::json!({}), &ctx).await; + match response.result { + Err(e) => assert_eq!(e.code, "NOT_FOUND"), + other => panic!("expected NOT_FOUND, got {other:?}"), + } + } + + #[tokio::test] + async fn local_env_invoke_internal_op_dispatches_as_internal_call() { + let registry = registry_with( + "secret/op", + Visibility::Internal, + inspect_handler(), + None, + None, + ); + let env = Arc::new(LocalOperationEnv::new(Arc::clone(®istry))); + let scoped = ScopedPeerEnv::new(["secret/op"]); + let ctx = root_context("root-3", None, None, scoped, env.clone()); + let response = env + .invoke("secret", "op", serde_json::json!({}), &ctx) + .await; + let out = response.result.expect("ok"); + assert_eq!(out["internal"], Value::Bool(true)); + assert_eq!(out["parent_set"], Value::Bool(true)); + } + + #[tokio::test] + async fn local_env_child_identity_is_parent_handler_identity() { + let authority = CompositionAuthority::new("agent-chat", ["fs:read".to_string()]); + let registry = registry_with( + "child/run", + Visibility::External, + inspect_handler(), + None, + None, + ); + let env = Arc::new(LocalOperationEnv::new(Arc::clone(®istry))); + let scoped = ScopedPeerEnv::new(["child/run"]); + let ctx = root_context( + "root-4", + Some(Identity { + id: "wire-caller".to_string(), + scopes: vec![], + resources: HashMap::new(), + }), + Some(authority.clone()), + scoped, + env.clone(), + ); + let response = env + .invoke("child", "run", serde_json::json!({}), &ctx) + .await; + let out = response.result.expect("ok"); + assert_eq!(out["identity_id"], Value::String("agent-chat".into())); + } + + #[tokio::test] + async fn local_env_child_metadata_is_fresh_not_parent() { + let registry = registry_with( + "child/run", + Visibility::External, + inspect_handler(), + None, + None, + ); + let env = Arc::new(LocalOperationEnv::new(Arc::clone(®istry))); + let scoped = ScopedPeerEnv::new(["child/run"]); + let mut ctx = root_context("root-5", None, None, scoped, env.clone()); + ctx.metadata + .insert("secret".to_string(), Value::String("leak".into())); + let response = env + .invoke("child", "run", serde_json::json!({}), &ctx) + .await; + let out = response.result.expect("ok"); + assert_eq!(out["metadata_empty"], Value::Bool(true)); + } + + #[tokio::test] + async fn local_env_child_does_not_inherit_forwarded_for() { + let registry = registry_with( + "child/run", + Visibility::External, + inspect_handler(), + None, + None, + ); + let env = Arc::new(LocalOperationEnv::new(Arc::clone(®istry))); + let scoped = ScopedPeerEnv::new(["child/run"]); + let forwarded = Identity { + id: "alice".to_string(), + scopes: vec![], + resources: HashMap::new(), + }; + let ctx = root_context_with_forwarded_for( + "root-ff", + None, + None, + Some(forwarded), + scoped, + env.clone(), + ); + assert!(ctx.forwarded_for.is_some()); + let response = env + .invoke("child", "run", serde_json::json!({}), &ctx) + .await; + let out = response.result.expect("ok"); + assert!( + out["forwarded_for_id"].is_null(), + "composed child must NOT inherit forwarded_for (wire-ingress only, ADR-032)" + ); + } + + struct ProbeEnv { + name: String, + contains_set: Vec, + dispatched: std::sync::Mutex>, + } + + #[async_trait::async_trait] + impl OperationEnv for ProbeEnv { + async fn invoke_with_policy( + &self, + namespace: &str, + operation: &str, + _input: Value, + parent: &OperationContext, + _policy: AbortPolicy, + ) -> ResponseEnvelope { + *self.dispatched.lock().unwrap() = Some(format!("{namespace}/{operation}")); + ResponseEnvelope::ok(parent.request_id.clone(), Value::String(self.name.clone())) + } + + fn contains(&self, name: &str) -> bool { + self.contains_set.iter().any(|n| n == name) + } + } + + #[tokio::test] + async fn peer_composite_env_routes_to_session_when_it_contains_op() { + let base = Arc::new(NoopEnv { contains_op: true }); + let session = Arc::new(ProbeEnv { + name: "session".to_string(), + contains_set: vec!["agent/chat".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let composite = PeerCompositeEnv::new(base).with_session(session.clone()); + let env: Arc = Arc::new(composite); + let scoped = ScopedPeerEnv::new(["agent/chat"]); + let ctx = root_context("root-6", None, None, scoped, env.clone()); + let response = env + .invoke("agent", "chat", serde_json::json!({}), &ctx) + .await; + assert_eq!(response.result.unwrap(), Value::String("session".into())); + assert_eq!( + session.dispatched.lock().unwrap().as_deref(), + Some("agent/chat") + ); + } + + #[tokio::test] + async fn peer_composite_env_routes_to_first_peer_in_insertion_order() { + let base = Arc::new(ProbeEnv { + name: "base".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let worker_a = Arc::new(ProbeEnv { + name: "worker-a".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let worker_b = Arc::new(ProbeEnv { + name: "worker-b".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let mut composite = PeerCompositeEnv::new(base); + composite.attach_peer("worker-a".to_string(), worker_a.clone()); + composite.attach_peer("worker-b".to_string(), worker_b.clone()); + let env: Arc = Arc::new(composite); + let scoped = ScopedPeerEnv::new(["worker/exec"]); + let ctx = root_context("root-7", None, None, scoped, env.clone()); + let response = env + .invoke("worker", "exec", serde_json::json!({}), &ctx) + .await; + assert_eq!(response.result.unwrap(), Value::String("worker-a".into())); + assert_eq!( + worker_a.dispatched.lock().unwrap().as_deref(), + Some("worker/exec") + ); + assert!(worker_b.dispatched.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn peer_composite_env_falls_through_to_base_when_no_overlay_contains() { + let base = Arc::new(ProbeEnv { + name: "base".to_string(), + contains_set: vec!["fs/readFile".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let session = Arc::new(ProbeEnv { + name: "session".to_string(), + contains_set: vec!["agent/chat".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let connection = Arc::new(ProbeEnv { + name: "connection".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let mut composite = PeerCompositeEnv::new(base.clone()).with_session(session); + composite.attach_peer("worker-a".to_string(), connection); + let env: Arc = Arc::new(composite); + let scoped = ScopedPeerEnv::new(["fs/readFile"]); + let ctx = root_context("root-8", None, None, scoped, env.clone()); + let response = env + .invoke("fs", "readFile", serde_json::json!({}), &ctx) + .await; + assert_eq!(response.result.unwrap(), Value::String("base".into())); + assert_eq!( + base.dispatched.lock().unwrap().as_deref(), + Some("fs/readFile") + ); + } + + #[tokio::test] + async fn peer_composite_env_reachability_check_returns_not_found() { + let base = Arc::new(NoopEnv { contains_op: true }); + let composite = PeerCompositeEnv::new(base); + let env: Arc = Arc::new(composite); + let scoped = ScopedPeerEnv::empty(); + let ctx = root_context("root-9", None, None, scoped, env.clone()); + let response = env + .invoke("agent", "chat", serde_json::json!({}), &ctx) + .await; + match response.result { + Err(e) => assert_eq!(e.code, "NOT_FOUND"), + other => panic!("expected NOT_FOUND, got {other:?}"), + } + } + + #[test] + fn peer_composite_env_contains_aggregates_layers() { + let base = Arc::new(ProbeEnv { + name: "base".to_string(), + contains_set: vec!["fs/readFile".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let session = Arc::new(ProbeEnv { + name: "session".to_string(), + contains_set: vec!["agent/chat".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let connection = Arc::new(ProbeEnv { + name: "connection".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let mut composite = PeerCompositeEnv::new(base).with_session(session); + composite.attach_peer("worker-a".to_string(), connection); + assert!(composite.contains("fs/readFile")); + assert!(composite.contains("agent/chat")); + assert!(composite.contains("worker/exec")); + assert!(!composite.contains("unknown/op")); + } + + #[tokio::test] + async fn peer_composite_env_detach_peer_drops_overlay_and_returns_not_found() { + let base: Arc = + Arc::new(LocalOperationEnv::new(Arc::new(OperationRegistry::new()))); + let connection = Arc::new(ProbeEnv { + name: "connection".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let mut composite = PeerCompositeEnv::new(base); + composite.attach_peer("worker-a".to_string(), connection.clone()); + composite.detach_peer(&"worker-a".to_string()); + let env: Arc = Arc::new(composite); + let scoped = ScopedPeerEnv::new(["worker/exec"]); + let ctx = root_context("root-10", None, None, scoped, env.clone()); + let response = env + .invoke("worker", "exec", serde_json::json!({}), &ctx) + .await; + match response.result { + Err(e) => assert_eq!(e.code, "NOT_FOUND"), + other => panic!("expected NOT_FOUND after detach, got {other:?}"), + } + assert!(connection.dispatched.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn peer_composite_env_detach_peer_then_reattach_routes_again() { + let base: Arc = + Arc::new(LocalOperationEnv::new(Arc::new(OperationRegistry::new()))); + let connection = Arc::new(ProbeEnv { + name: "connection".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let mut composite = PeerCompositeEnv::new(base); + composite.attach_peer("worker-a".to_string(), connection.clone()); + composite.detach_peer(&"worker-a".to_string()); + composite.attach_peer("worker-a".to_string(), connection.clone()); + let env: Arc = Arc::new(composite); + let scoped = ScopedPeerEnv::new(["worker/exec"]); + let ctx = root_context("root-10b", None, None, scoped, env.clone()); + let response = env + .invoke("worker", "exec", serde_json::json!({}), &ctx) + .await; + assert_eq!(response.result.unwrap(), Value::String("connection".into())); + assert_eq!( + connection.dispatched.lock().unwrap().as_deref(), + Some("worker/exec") + ); + } + + #[test] + fn peer_composite_env_attach_peer_preserves_insertion_order_on_re_attach() { + let base: Arc = Arc::new(NoopEnv { contains_op: true }); + let overlay_a: Arc = + Arc::new(NoopEnv { contains_op: true }); + let overlay_b: Arc = + Arc::new(NoopEnv { contains_op: true }); + let mut composite = PeerCompositeEnv::new(base); + composite.attach_peer("worker-a".to_string(), overlay_a); + composite.attach_peer("worker-b".to_string(), overlay_b); + assert_eq!(composite.connection_order(), &["worker-a", "worker-b"]); + let overlay_a2: Arc = + Arc::new(NoopEnv { contains_op: true }); + composite.attach_peer("worker-a".to_string(), overlay_a2); + assert_eq!( + composite.connection_order(), + &["worker-a", "worker-b"], + "re-attach keeps original position" + ); + } + + #[tokio::test] + async fn peer_composite_env_routes_to_connection_when_session_absent_or_missing() { + let base = Arc::new(ProbeEnv { + name: "base".to_string(), + contains_set: vec![], + dispatched: std::sync::Mutex::new(None), + }); + let connection = Arc::new(ProbeEnv { + name: "connection".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let session = Arc::new(ProbeEnv { + name: "session".to_string(), + contains_set: vec!["agent/chat".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let mut composite = PeerCompositeEnv::new(base).with_session(session); + composite.attach_peer("worker-a".to_string(), connection.clone()); + let env: Arc = Arc::new(composite); + let scoped = ScopedPeerEnv::new(["worker/exec"]); + let ctx = root_context("root-11", None, None, scoped, env.clone()); + let response = env + .invoke("worker", "exec", serde_json::json!({}), &ctx) + .await; + assert_eq!(response.result.unwrap(), Value::String("connection".into())); + assert_eq!( + connection.dispatched.lock().unwrap().as_deref(), + Some("worker/exec") + ); + } + + #[tokio::test] + async fn local_env_unknown_op_after_reachability_pass_returns_not_found() { + let registry = Arc::new(OperationRegistry::new()); + let env = Arc::new(LocalOperationEnv::new(Arc::clone(®istry))); + let scoped = ScopedPeerEnv::new(["fs/readFile"]); + let ctx = root_context("root-12", None, None, scoped, env.clone()); + let response = env + .invoke("fs", "readFile", serde_json::json!({}), &ctx) + .await; + match response.result { + Err(e) => assert_eq!(e.code, "NOT_FOUND"), + other => panic!("expected NOT_FOUND, got {other:?}"), + } + } + + #[tokio::test] + async fn local_env_child_inherits_parent_deadline() { + let registry = registry_with( + "child/run", + Visibility::External, + inspect_handler(), + None, + None, + ); + let env = Arc::new(LocalOperationEnv::new(Arc::clone(®istry))); + let scoped = ScopedPeerEnv::new(["child/run"]); + let deadline = Instant::now() + Duration::from_secs(5); + let mut ctx = root_context("root-13", None, None, scoped, env.clone()); + ctx.deadline = Some(deadline); + let response = env + .invoke("child", "run", serde_json::json!({}), &ctx) + .await; + assert!(response.result.is_ok()); + } + + #[test] + fn local_env_default_contains_is_true() { + let registry = Arc::new(OperationRegistry::new()); + let env = LocalOperationEnv::new(registry); + assert!(env.contains("anything")); + assert!(env.contains("")); + } + + #[test] + fn abort_policy_is_copy() { + let p = AbortPolicy::default(); + let _ = p; + let _ = p; + } + + #[test] + fn composition_authority_none_propagates_as_none_identity() { + assert!(CompositionAuthority::none().is_none()); + } + + #[test] + fn local_env_new_exposes_registry() { + let registry = Arc::new(OperationRegistry::new()); + let env = LocalOperationEnv::new(Arc::clone(®istry)); + assert!(Arc::ptr_eq(env.registry(), ®istry)); + } + + #[test] + fn peer_composite_env_accessors_return_refs() { + let base: Arc = Arc::new(NoopEnv { contains_op: true }); + let session: Arc = Arc::new(NoopEnv { contains_op: true }); + let connection: Arc = + Arc::new(NoopEnv { contains_op: false }); + let mut composite = + PeerCompositeEnv::new(Arc::clone(&base)).with_session(Arc::clone(&session)); + composite.attach_peer("worker-a".to_string(), Arc::clone(&connection)); + assert!(Arc::ptr_eq(composite.base(), &base)); + assert!(composite.session().is_some()); + assert!(composite.connections().get("worker-a").is_some()); + assert_eq!(composite.connection_order(), &["worker-a"]); + } + + #[test] + fn peer_composite_env_singular_connection_is_degenerate_single_entry_map() { + let base: Arc = Arc::new(NoopEnv { contains_op: true }); + let connection: Arc = + Arc::new(NoopEnv { contains_op: true }); + let mut composite = PeerCompositeEnv::new(base); + composite.attach_peer("worker-a".to_string(), connection); + assert_eq!(composite.connections().len(), 1); + assert_eq!(composite.connection_order().len(), 1); + assert!(composite.connections().contains_key("worker-a")); + } + + #[tokio::test] + async fn invoke_peer_specific_routes_to_named_peer() { + let base = Arc::new(ProbeEnv { + name: "base".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let worker_a = Arc::new(ProbeEnv { + name: "worker-a".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let worker_b = Arc::new(ProbeEnv { + name: "worker-b".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let mut composite = PeerCompositeEnv::new(base); + composite.attach_peer("worker-a".to_string(), worker_a.clone()); + composite.attach_peer("worker-b".to_string(), worker_b.clone()); + let env: Arc = Arc::new(composite); + let scoped = ScopedPeerEnv::new(["worker/exec"]); + let ctx = root_context("root-pr-1", None, None, scoped, env.clone()); + let response = env + .invoke_peer( + &PeerRef::Specific("worker-b".to_string()), + "worker", + "exec", + serde_json::json!({}), + &ctx, + AbortPolicy::default(), + ) + .await; + assert_eq!(response.result.unwrap(), Value::String("worker-b".into())); + assert_eq!( + worker_b.dispatched.lock().unwrap().as_deref(), + Some("worker/exec") + ); + assert!(worker_a.dispatched.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn invoke_peer_specific_returns_not_found_when_peer_does_not_serve_op() { + let base = Arc::new(ProbeEnv { + name: "base".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let worker_a = Arc::new(ProbeEnv { + name: "worker-a".to_string(), + contains_set: vec!["other/op".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let mut composite = PeerCompositeEnv::new(base.clone()); + composite.attach_peer("worker-a".to_string(), worker_a.clone()); + let env: Arc = Arc::new(composite); + let scoped = ScopedPeerEnv::new(["worker/exec"]); + let ctx = root_context("root-pr-2", None, None, scoped, env.clone()); + let response = env + .invoke_peer( + &PeerRef::Specific("worker-a".to_string()), + "worker", + "exec", + serde_json::json!({}), + &ctx, + AbortPolicy::default(), + ) + .await; + match response.result { + Err(e) => assert_eq!(e.code, "NOT_FOUND"), + other => panic!("expected NOT_FOUND, got {other:?}"), + } + assert!(worker_a.dispatched.lock().unwrap().is_none()); + assert!( + base.dispatched.lock().unwrap().is_none(), + "no fallthrough to base" + ); + } + + #[tokio::test] + async fn invoke_peer_specific_returns_not_found_when_peer_unknown() { + let base = Arc::new(ProbeEnv { + name: "base".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let mut composite = PeerCompositeEnv::new(base.clone()); + composite.attach_peer( + "worker-a".to_string(), + Arc::new(ProbeEnv { + name: "worker-a".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }), + ); + let env: Arc = Arc::new(composite); + let scoped = ScopedPeerEnv::new(["worker/exec"]); + let ctx = root_context("root-pr-3", None, None, scoped, env.clone()); + let response = env + .invoke_peer( + &PeerRef::Specific("ghost".to_string()), + "worker", + "exec", + serde_json::json!({}), + &ctx, + AbortPolicy::default(), + ) + .await; + match response.result { + Err(e) => assert_eq!(e.code, "NOT_FOUND"), + other => panic!("expected NOT_FOUND, got {other:?}"), + } + assert!(base.dispatched.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn invoke_peer_any_routes_to_first_peer_in_insertion_order() { + let base = Arc::new(ProbeEnv { + name: "base".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let worker_a = Arc::new(ProbeEnv { + name: "worker-a".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let worker_b = Arc::new(ProbeEnv { + name: "worker-b".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let mut composite = PeerCompositeEnv::new(base); + composite.attach_peer("worker-a".to_string(), worker_a.clone()); + composite.attach_peer("worker-b".to_string(), worker_b.clone()); + let env: Arc = Arc::new(composite); + let scoped = ScopedPeerEnv::new(["worker/exec"]); + let ctx = root_context("root-pr-4", None, None, scoped, env.clone()); + let response = env + .invoke_peer( + &PeerRef::Any, + "worker", + "exec", + serde_json::json!({}), + &ctx, + AbortPolicy::default(), + ) + .await; + assert_eq!(response.result.unwrap(), Value::String("worker-a".into())); + assert_eq!( + worker_a.dispatched.lock().unwrap().as_deref(), + Some("worker/exec") + ); + assert!(worker_b.dispatched.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn invoke_peer_reachability_check_gates_before_routing() { + let base = Arc::new(NoopEnv { contains_op: true }); + let worker_a = Arc::new(ProbeEnv { + name: "worker-a".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let mut composite = PeerCompositeEnv::new(base); + composite.attach_peer("worker-a".to_string(), worker_a.clone()); + let env: Arc = Arc::new(composite); + let scoped = ScopedPeerEnv::empty(); + let ctx = root_context("root-pr-5", None, None, scoped, env.clone()); + let response = env + .invoke_peer( + &PeerRef::Specific("worker-a".to_string()), + "worker", + "exec", + serde_json::json!({}), + &ctx, + AbortPolicy::default(), + ) + .await; + match response.result { + Err(e) => assert_eq!(e.code, "NOT_FOUND"), + other => panic!("expected NOT_FOUND, got {other:?}"), + } + assert!(worker_a.dispatched.lock().unwrap().is_none()); + } + + #[test] + fn peer_contains_checks_specific_peer_overlay() { + let base = Arc::new(ProbeEnv { + name: "base".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let worker_a = Arc::new(ProbeEnv { + name: "worker-a".to_string(), + contains_set: vec!["worker/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let worker_b = Arc::new(ProbeEnv { + name: "worker-b".to_string(), + contains_set: vec!["other/op".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let mut composite = PeerCompositeEnv::new(base); + composite.attach_peer("worker-a".to_string(), worker_a); + composite.attach_peer("worker-b".to_string(), worker_b); + assert!(composite.peer_contains(&"worker-a".to_string(), "worker/exec")); + assert!(!composite.peer_contains(&"worker-b".to_string(), "worker/exec")); + assert!(!composite.peer_contains(&"ghost".to_string(), "worker/exec")); + } + + #[tokio::test] + async fn default_invoke_peer_delegates_to_invoke_with_policy() { + let registry = registry_with("echo/run", Visibility::External, echo_handler(), None, None); + let env = Arc::new(LocalOperationEnv::new(Arc::clone(®istry))); + let scoped = ScopedPeerEnv::new(["echo/run"]); + let ctx = root_context("root-pr-6", None, None, scoped, env.clone()); + let response = env + .invoke_peer( + &PeerRef::Specific("any-peer".to_string()), + "echo", + "run", + serde_json::json!({"hi": 1}), + &ctx, + AbortPolicy::default(), + ) + .await; + assert!(response.result.is_ok()); + assert_eq!(response.result.unwrap(), serde_json::json!({"hi": 1})); + } + + #[test] + fn default_peer_contains_delegates_to_contains() { + let registry = Arc::new(OperationRegistry::new()); + let env = LocalOperationEnv::new(registry); + assert!(env.peer_contains(&"any-peer".to_string(), "anything")); + } + + // --- ADR-029 §4: peer-pinned reachability gate ------------------------- + + #[tokio::test] + async fn invoke_peer_specific_pinned_only_op_reaches_pinned_peer() { + let base = Arc::new(NoopEnv { contains_op: true }); + let worker_a = Arc::new(ProbeEnv { + name: "worker-a".to_string(), + contains_set: vec!["container/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let mut composite = PeerCompositeEnv::new(base); + composite.attach_peer("worker-a".to_string(), worker_a.clone()); + let env: Arc = Arc::new(composite); + let scoped = ScopedPeerEnv::empty().with_pinned(["worker-a/container/exec"]); + let ctx = root_context("root-pin-1", None, None, scoped, env.clone()); + let response = env + .invoke_peer( + &PeerRef::Specific("worker-a".to_string()), + "container", + "exec", + serde_json::json!({}), + &ctx, + AbortPolicy::default(), + ) + .await; + assert_eq!(response.result.unwrap(), Value::String("worker-a".into())); + assert_eq!( + worker_a.dispatched.lock().unwrap().as_deref(), + Some("container/exec") + ); + } + + #[tokio::test] + async fn invoke_peer_any_pinned_only_op_returns_not_found() { + let base = Arc::new(NoopEnv { contains_op: true }); + let worker_a = Arc::new(ProbeEnv { + name: "worker-a".to_string(), + contains_set: vec!["container/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let mut composite = PeerCompositeEnv::new(base); + composite.attach_peer("worker-a".to_string(), worker_a.clone()); + let env: Arc = Arc::new(composite); + let scoped = ScopedPeerEnv::empty().with_pinned(["worker-a/container/exec"]); + let ctx = root_context("root-pin-2", None, None, scoped, env.clone()); + let response = env + .invoke_peer( + &PeerRef::Any, + "container", + "exec", + serde_json::json!({}), + &ctx, + AbortPolicy::default(), + ) + .await; + match response.result { + Err(e) => assert_eq!(e.code, "NOT_FOUND", "pinned-only op NOT reachable via Any"), + other => panic!("expected NOT_FOUND via Any, got {other:?}"), + } + assert!(worker_a.dispatched.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn invoke_with_policy_does_not_pick_up_pinned_only_ops() { + let base = Arc::new(NoopEnv { contains_op: true }); + let worker_a = Arc::new(ProbeEnv { + name: "worker-a".to_string(), + contains_set: vec!["container/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let mut composite = PeerCompositeEnv::new(base); + composite.attach_peer("worker-a".to_string(), worker_a.clone()); + let env: Arc = Arc::new(composite); + let scoped = ScopedPeerEnv::empty().with_pinned(["worker-a/container/exec"]); + let ctx = root_context("root-pin-3", None, None, scoped, env.clone()); + let response = env + .invoke("container", "exec", serde_json::json!({}), &ctx) + .await; + match response.result { + Err(e) => assert_eq!( + e.code, "NOT_FOUND", + "invoke_with_policy (Any path) must NOT pick up pinned-only ops" + ), + other => panic!("expected NOT_FOUND, got {other:?}"), + } + assert!(worker_a.dispatched.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn invoke_peer_specific_wrong_peer_for_pinned_only_op_returns_not_found() { + let base = Arc::new(NoopEnv { contains_op: true }); + let worker_a = Arc::new(ProbeEnv { + name: "worker-a".to_string(), + contains_set: vec!["container/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let worker_b = Arc::new(ProbeEnv { + name: "worker-b".to_string(), + contains_set: vec!["container/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let mut composite = PeerCompositeEnv::new(base); + composite.attach_peer("worker-a".to_string(), worker_a.clone()); + composite.attach_peer("worker-b".to_string(), worker_b.clone()); + let env: Arc = Arc::new(composite); + let scoped = ScopedPeerEnv::empty().with_pinned(["worker-a/container/exec"]); + let ctx = root_context("root-pin-4", None, None, scoped, env.clone()); + let response = env + .invoke_peer( + &PeerRef::Specific("worker-b".to_string()), + "container", + "exec", + serde_json::json!({}), + &ctx, + AbortPolicy::default(), + ) + .await; + match response.result { + Err(e) => assert_eq!( + e.code, "NOT_FOUND", + "pinned to worker-a, routed to worker-b → NOT_FOUND" + ), + other => panic!("expected NOT_FOUND, got {other:?}"), + } + assert!(worker_a.dispatched.lock().unwrap().is_none()); + assert!(worker_b.dispatched.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn invoke_peer_op_in_both_sets_reachable_via_both_any_and_specific() { + let base = Arc::new(NoopEnv { contains_op: true }); + let worker_a = Arc::new(ProbeEnv { + name: "worker-a".to_string(), + contains_set: vec!["container/exec".to_string()], + dispatched: std::sync::Mutex::new(None), + }); + let mut composite = PeerCompositeEnv::new(base); + composite.attach_peer("worker-a".to_string(), worker_a.clone()); + let env: Arc = Arc::new(composite); + let scoped = + ScopedPeerEnv::new(["container/exec"]).with_pinned(["worker-a/container/exec"]); + let ctx = root_context("root-pin-5", None, None, scoped, env.clone()); + + let response_any = env + .invoke_peer( + &PeerRef::Any, + "container", + "exec", + serde_json::json!({}), + &ctx, + AbortPolicy::default(), + ) + .await; + assert!( + response_any.result.is_ok(), + "op in allowed_ops reachable via Any" + ); + + let response_specific = env + .invoke_peer( + &PeerRef::Specific("worker-a".to_string()), + "container", + "exec", + serde_json::json!({}), + &ctx, + AbortPolicy::default(), + ) + .await; + assert!( + response_specific.result.is_ok(), + "op in both sets reachable via Specific(peer)" + ); + } +} diff --git a/src/registry/mod.rs b/src/registry/mod.rs new file mode 100644 index 0000000..9a1ee80 --- /dev/null +++ b/src/registry/mod.rs @@ -0,0 +1,12 @@ +//! Operation registry: specs, handlers, access control, service discovery. +//! +//! Maps operation names to specs and handlers, enforces access control, and +//! dispatches `call.requested` events to local handlers. The registry is +//! layered by trust boundary (ADR-024): a curated layer (immutable after +//! startup) plus dynamic session and connection overlays. + +pub mod context; +pub mod discovery; +pub mod env; +pub mod registration; +pub mod spec; diff --git a/src/registry/registration.rs b/src/registry/registration.rs new file mode 100644 index 0000000..cf575e7 --- /dev/null +++ b/src/registry/registration.rs @@ -0,0 +1,1634 @@ +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use crate::core::types::Capabilities; +use futures::stream::{self, Stream}; +use serde_json::Value; + +use super::context::{CompositionAuthority, OperationContext, ScopedPeerEnv}; +use super::spec::{AccessResult, OperationSpec, OperationType, Visibility}; +use crate::protocol::wire::{CallError, ResponseEnvelope}; + +pub type Handler = Arc< + dyn Fn(Value, OperationContext) -> Pin + Send>> + + Send + + Sync, +>; + +pub type StreamingHandler = Arc< + dyn Fn(Value, OperationContext) -> Pin + Send>> + + Send + + Sync, +>; + +pub type ResponseStream = Pin + Send>>; + +#[derive(Clone)] +pub enum HandlerKind { + Once(Handler), + Stream(StreamingHandler), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OperationProvenance { + Local, + FromOpenAPI, + FromMCP, + FromCall, + FromJsonSchema, + Session, +} + +pub struct HandlerRegistration { + pub spec: OperationSpec, + pub handler: HandlerKind, + pub provenance: OperationProvenance, + pub composition_authority: Option, + pub scoped_env: Option, + pub capabilities: Capabilities, +} + +impl HandlerRegistration { + pub fn new( + spec: OperationSpec, + handler: HandlerKind, + provenance: OperationProvenance, + composition_authority: Option, + scoped_env: Option, + capabilities: Capabilities, + ) -> Self { + Self { + spec, + handler, + provenance, + composition_authority, + scoped_env, + capabilities, + } + } +} + +pub struct OperationRegistry { + operations: HashMap, +} + +impl OperationRegistry { + pub fn new() -> Self { + Self { + operations: HashMap::new(), + } + } + + pub fn register(&mut self, registration: HandlerRegistration) -> Result<(), String> { + let expected = match registration.spec.op_type { + OperationType::Query | OperationType::Mutation => "Once", + OperationType::Subscription => "Stream", + }; + let actual = match registration.handler { + HandlerKind::Once(_) => "Once", + HandlerKind::Stream(_) => "Stream", + }; + if expected != actual { + return Err(format!( + "handler kind mismatch: {:?} requires HandlerKind::{} (got HandlerKind::{})", + registration.spec.op_type, expected, actual + )); + } + self.operations + .insert(registration.spec.name.clone(), registration); + Ok(()) + } + + pub fn registration(&self, name: &str) -> Option<&HandlerRegistration> { + self.operations.get(name) + } + + pub fn list_operations(&self) -> Vec<&OperationSpec> { + self.operations + .values() + .filter(|r| r.spec.visibility == Visibility::External) + .map(|r| &r.spec) + .collect() + } + + pub async fn invoke( + &self, + name: &str, + input: Value, + context: OperationContext, + ) -> ResponseEnvelope { + let request_id = context.request_id.clone(); + let registration = match self.operations.get(name) { + Some(r) => r, + None => return ResponseEnvelope::not_found(request_id, name), + }; + + if registration.spec.visibility == Visibility::Internal && !context.internal { + return ResponseEnvelope::not_found(request_id, name); + } + + let acl = ®istration.spec.access_control; + let identity = if context.internal { + context + .handler_identity + .as_ref() + .and_then(|ca| ca.as_identity()) + } else { + context.identity.clone() + }; + + let resource_id = registration + .spec + .resource_id_path + .as_ref() + .and_then(|path| extract_json_pointer(&input, path)); + + if let AccessResult::Forbidden(message) = acl.check( + identity.as_ref(), + resource_id.as_deref(), + context.ownership.as_deref(), + ) { + return ResponseEnvelope::forbidden(request_id, message); + } + + match ®istration.handler { + HandlerKind::Once(handler) => { + let handler = Arc::clone(handler); + (handler)(input, context).await + } + HandlerKind::Stream(_) => ResponseEnvelope::error( + request_id, + CallError::invalid_operation_type( + "invoke() called on a Subscription op; use invoke_streaming()", + ), + ), + } + } + + pub fn invoke_streaming( + &self, + name: &str, + input: Value, + context: OperationContext, + ) -> ResponseStream { + let request_id = context.request_id.clone(); + let name_owned = name.to_string(); + + let registration = match self.operations.get(name) { + Some(r) => r, + None => { + return Box::pin(stream::once(async move { + ResponseEnvelope::not_found(request_id, &name_owned) + })); + } + }; + + if registration.spec.visibility == Visibility::Internal && !context.internal { + return Box::pin(stream::once(async move { + ResponseEnvelope::not_found(request_id, &name_owned) + })); + } + + let acl = ®istration.spec.access_control; + let identity = if context.internal { + context + .handler_identity + .as_ref() + .and_then(|ca| ca.as_identity()) + } else { + context.identity.clone() + }; + + let resource_id = registration + .spec + .resource_id_path + .as_ref() + .and_then(|path| extract_json_pointer(&input, path)); + + if let AccessResult::Forbidden(message) = acl.check( + identity.as_ref(), + resource_id.as_deref(), + context.ownership.as_deref(), + ) { + return Box::pin(stream::once(async move { + ResponseEnvelope::forbidden(request_id, message) + })); + } + + let streaming_handler = match ®istration.handler { + HandlerKind::Stream(h) => Arc::clone(h), + HandlerKind::Once(_) => { + return Box::pin(stream::once(async move { + ResponseEnvelope::error( + request_id, + CallError::invalid_operation_type( + "invoke_streaming() called on a Query/Mutation op; use invoke()", + ), + ) + })); + } + }; + + streaming_handler(input, context) + } +} + +impl Default for OperationRegistry { + fn default() -> Self { + Self::new() + } +} + +pub struct OperationRegistryBuilder { + operations: HashMap, +} + +impl OperationRegistryBuilder { + pub fn new() -> Self { + Self { + operations: HashMap::new(), + } + } + + fn store(mut self, registration: HandlerRegistration) -> Result { + let name = registration.spec.name.clone(); + self.operations.insert(name, registration); + Ok(self) + } + + fn wrap_once(spec: &OperationSpec, handler: Handler) -> Result { + match spec.op_type { + OperationType::Query | OperationType::Mutation => Ok(HandlerKind::Once(handler)), + OperationType::Subscription => Err(format!( + "handler kind mismatch: {:?} requires HandlerKind::Stream (got Handler)", + spec.op_type + )), + } + } + + fn wrap_stream(spec: &OperationSpec, handler: StreamingHandler) -> Result { + match spec.op_type { + OperationType::Subscription => Ok(HandlerKind::Stream(handler)), + OperationType::Query | OperationType::Mutation => Err(format!( + "handler kind mismatch: {:?} requires HandlerKind::Once (got StreamingHandler)", + spec.op_type + )), + } + } + + pub fn with_local( + self, + spec: OperationSpec, + handler: Handler, + composition_authority: Option, + scoped_env: Option, + capabilities: Capabilities, + ) -> Result { + let kind = Self::wrap_once(&spec, handler)?; + let registration = HandlerRegistration::new( + spec, + kind, + OperationProvenance::Local, + composition_authority, + scoped_env, + capabilities, + ); + self.store(registration) + } + + pub fn with_local_streaming( + self, + spec: OperationSpec, + handler: StreamingHandler, + composition_authority: Option, + scoped_env: Option, + capabilities: Capabilities, + ) -> Result { + let kind = Self::wrap_stream(&spec, handler)?; + let registration = HandlerRegistration::new( + spec, + kind, + OperationProvenance::Local, + composition_authority, + scoped_env, + capabilities, + ); + self.store(registration) + } + + pub fn with_leaf( + self, + spec: OperationSpec, + handler: Handler, + capabilities: Capabilities, + ) -> Result { + self.with_leaf_provenance( + spec, + handler, + OperationProvenance::FromOpenAPI, + capabilities, + ) + } + + pub fn with_leaf_provenance( + self, + spec: OperationSpec, + handler: Handler, + provenance: OperationProvenance, + capabilities: Capabilities, + ) -> Result { + let kind = Self::wrap_once(&spec, handler)?; + let registration = + HandlerRegistration::new(spec, kind, provenance, None, None, capabilities); + self.store(registration) + } + + pub fn with_leaf_streaming( + self, + spec: OperationSpec, + handler: StreamingHandler, + capabilities: Capabilities, + ) -> Result { + self.with_leaf_streaming_provenance( + spec, + handler, + OperationProvenance::FromOpenAPI, + capabilities, + ) + } + + pub fn with_leaf_streaming_provenance( + self, + spec: OperationSpec, + handler: StreamingHandler, + provenance: OperationProvenance, + capabilities: Capabilities, + ) -> Result { + let kind = Self::wrap_stream(&spec, handler)?; + let registration = + HandlerRegistration::new(spec, kind, provenance, None, None, capabilities); + self.store(registration) + } + + pub fn with(self, registration: HandlerRegistration) -> Result { + self.store(registration) + } + + pub fn build(self) -> OperationRegistry { + OperationRegistry { + operations: self.operations, + } + } +} + +impl Default for OperationRegistryBuilder { + fn default() -> Self { + Self::new() + } +} + +pub fn make_handler(f: F) -> Handler +where + F: Fn(Value, OperationContext) -> Fut + Send + Sync + 'static, + Fut: Future + Send + 'static, +{ + Arc::new(move |input, context| Box::pin(f(input, context))) +} + +pub fn make_streaming_handler(f: S) -> StreamingHandler +where + S: Fn(Value, OperationContext) -> St + Send + Sync + 'static, + St: Stream + Send + 'static, +{ + Arc::new(move |input, context| Box::pin(f(input, context))) +} + +/// Extract a string value from `input` at a JSON-pointer-ish path described +/// by `$.field` or `$.field/sub` (a leading `$` followed by a slash-separated +/// pointer, the same shape `serde_json::Value::pointer` expects after the +/// leading `/` is restored). Also tolerates the dotted form `$.a.b` for +/// shallow nested lookups (translated to `/a/b`). +/// +/// Returns `None` if the path is malformed, the field is missing, or the +/// value at the path is not a string. Graceful — never panics (ADR-050 §2a): +/// a missing field simply yields `resource_id: None`, and the caller's +/// `AccessControl::check` decides whether to deny based on whether the spec +/// requires a specific resource ID. +pub(crate) fn extract_json_pointer(input: &Value, path: &str) -> Option { + let trimmed = path.strip_prefix('$')?; + if trimmed.is_empty() { + return None; + } + let pointer = if let Some(rest) = trimmed.strip_prefix('/') { + format!("/{}", rest) + } else { + let dotted = trimmed.replace('.', "/"); + if let Some(rest) = dotted.strip_prefix('/') { + format!("/{}", rest) + } else { + format!("/{}", dotted) + } + }; + input + .pointer(pointer.as_str()) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::auth::Identity; + use crate::core::ownership::OwnershipProvider; + use crate::protocol::wire::CallError; + use crate::registry::context::AbortPolicy; + use crate::registry::env::OperationEnv; + use crate::registry::spec::{AccessControl, OperationType}; + use std::collections::HashMap; + use std::time::Duration; + + struct NoopEnv; + + #[async_trait::async_trait] + impl OperationEnv for NoopEnv { + async fn invoke_with_policy( + &self, + _namespace: &str, + _operation: &str, + _input: Value, + _parent: &OperationContext, + _policy: AbortPolicy, + ) -> ResponseEnvelope { + ResponseEnvelope::error("test", CallError::internal("noop env does not dispatch")) + } + + fn contains(&self, _name: &str) -> bool { + false + } + } + + fn root_context( + request_id: &str, + identity: Option, + handler_identity: Option, + internal: bool, + scoped_env: ScopedPeerEnv, + ) -> OperationContext { + OperationContext { + request_id: request_id.to_string(), + parent_request_id: None, + identity, + handler_identity, + forwarded_for: None, + capabilities: Capabilities::new(), + metadata: HashMap::new(), + scoped_env, + env: Arc::new(NoopEnv), + abort_policy: AbortPolicy::default(), + deadline: Some(std::time::Instant::now() + Duration::from_secs(30)), + internal, + ownership: None, + } + } + + fn root_context_with_ownership( + request_id: &str, + identity: Option, + ownership: Option>, + scoped_env: ScopedPeerEnv, + ) -> OperationContext { + OperationContext { + request_id: request_id.to_string(), + parent_request_id: None, + identity, + handler_identity: None, + forwarded_for: None, + capabilities: Capabilities::new(), + metadata: HashMap::new(), + scoped_env, + env: Arc::new(NoopEnv), + abort_policy: AbortPolicy::default(), + deadline: Some(std::time::Instant::now() + Duration::from_secs(30)), + internal: false, + ownership, + } + } + + fn echo_handler() -> Handler { + make_handler( + |input, context| async move { ResponseEnvelope::ok(context.request_id, input) }, + ) + } + + fn error_handler() -> Handler { + make_handler(|_input, context| async move { + ResponseEnvelope::error(context.request_id, CallError::internal("handler failure")) + }) + } + + fn external_spec(name: &str, acl: AccessControl) -> OperationSpec { + OperationSpec::new( + name, + OperationType::Query, + Visibility::External, + serde_json::json!({}), + serde_json::json!({}), + vec![], + acl, + None, + ) + } + + fn internal_spec(name: &str, acl: AccessControl) -> OperationSpec { + OperationSpec::new( + name, + OperationType::Query, + Visibility::Internal, + serde_json::json!({}), + serde_json::json!({}), + vec![], + acl, + None, + ) + } + + fn identity_with_scopes(id: &str, scopes: &[&str]) -> Identity { + Identity { + id: id.to_string(), + scopes: scopes.iter().map(|s| s.to_string()).collect(), + resources: HashMap::new(), + } + } + + #[tokio::test] + async fn register_and_invoke_simple_operation() { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + external_spec("echo", AccessControl::default()), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let ctx = root_context("req-1", None, None, false, ScopedPeerEnv::empty()); + let response = registry + .invoke("echo", serde_json::json!({"hi": 1}), ctx) + .await; + assert_eq!(response.request_id, "req-1"); + assert_eq!(response.result, Ok(serde_json::json!({"hi": 1}))); + } + + #[tokio::test] + async fn internal_op_from_external_call_returns_not_found() { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + internal_spec("secret", AccessControl::default()), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let ctx = root_context("req-2", None, None, false, ScopedPeerEnv::empty()); + let response = registry.invoke("secret", serde_json::json!({}), ctx).await; + match response.result { + Err(e) => { + assert_eq!(e.code, "NOT_FOUND"); + assert!(e.message.contains("secret")); + } + other => panic!("expected NOT_FOUND, got {other:?}"), + } + } + + #[tokio::test] + async fn internal_op_from_internal_call_invokes_handler() { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + internal_spec("secret", AccessControl::default()), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let ctx = root_context("req-3", None, None, true, ScopedPeerEnv::empty()); + let response = registry + .invoke("secret", serde_json::json!({"x": 2}), ctx) + .await; + assert_eq!(response.request_id, "req-3"); + assert_eq!(response.result, Ok(serde_json::json!({"x": 2}))); + } + + #[tokio::test] + async fn unknown_op_returns_not_found() { + let registry = OperationRegistry::new(); + let ctx = root_context("req-4", None, None, false, ScopedPeerEnv::empty()); + let response = registry.invoke("missing", serde_json::json!({}), ctx).await; + match response.result { + Err(e) => assert_eq!(e.code, "NOT_FOUND"), + other => panic!("expected NOT_FOUND, got {other:?}"), + } + } + + #[tokio::test] + async fn acl_sufficient_scopes_allowed() { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + external_spec( + "admin", + AccessControl { + required_scopes: vec!["admin".to_string()], + ..Default::default() + }, + ), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let ctx = root_context( + "req-5", + Some(identity_with_scopes("caller", &["admin"])), + None, + false, + ScopedPeerEnv::empty(), + ); + let response = registry.invoke("admin", serde_json::json!({}), ctx).await; + assert!(response.result.is_ok()); + } + + #[tokio::test] + async fn acl_insufficient_scopes_forbidden() { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + external_spec( + "admin", + AccessControl { + required_scopes: vec!["admin".to_string()], + ..Default::default() + }, + ), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let ctx = root_context( + "req-6", + Some(identity_with_scopes("caller", &["user"])), + None, + false, + ScopedPeerEnv::empty(), + ); + let response = registry.invoke("admin", serde_json::json!({}), ctx).await; + match response.result { + Err(e) => { + assert_eq!(e.code, "FORBIDDEN"); + assert!(e.message.contains("admin")); + } + other => panic!("expected FORBIDDEN, got {other:?}"), + } + } + + #[tokio::test] + async fn acl_restricted_op_no_identity_forbidden() { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + external_spec( + "admin", + AccessControl { + required_scopes: vec!["admin".to_string()], + ..Default::default() + }, + ), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let ctx = root_context("req-7", None, None, false, ScopedPeerEnv::empty()); + let response = registry.invoke("admin", serde_json::json!({}), ctx).await; + match response.result { + Err(e) => { + assert_eq!(e.code, "FORBIDDEN"); + assert_eq!(e.message, "authentication required"); + } + other => panic!("expected FORBIDDEN, got {other:?}"), + } + } + + #[tokio::test] + async fn internal_call_acl_uses_handler_identity() { + let mut registry = OperationRegistry::new(); + let composing_authority = CompositionAuthority::new("agent-chat", ["admin".to_string()]); + registry + .register(HandlerRegistration::new( + internal_spec( + "secret", + AccessControl { + required_scopes: vec!["admin".to_string()], + ..Default::default() + }, + ), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let ctx = root_context( + "req-8", + Some(identity_with_scopes("user", &["user"])), + Some(composing_authority), + true, + ScopedPeerEnv::empty(), + ); + let response = registry.invoke("secret", serde_json::json!({}), ctx).await; + assert!( + response.result.is_ok(), + "internal call should use handler_identity (admin), not caller (user)" + ); + } + + #[tokio::test] + async fn internal_call_acl_insufficient_handler_identity_forbidden() { + let mut registry = OperationRegistry::new(); + let weak_authority = CompositionAuthority::new("weak", ["user".to_string()]); + registry + .register(HandlerRegistration::new( + internal_spec( + "secret", + AccessControl { + required_scopes: vec!["admin".to_string()], + ..Default::default() + }, + ), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let ctx = root_context( + "req-9", + Some(identity_with_scopes("user", &["admin"])), + Some(weak_authority), + true, + ScopedPeerEnv::empty(), + ); + let response = registry.invoke("secret", serde_json::json!({}), ctx).await; + match response.result { + Err(e) => { + assert_eq!(e.code, "FORBIDDEN"); + assert!(e.message.contains("admin")); + } + other => panic!("expected FORBIDDEN, got {other:?}"), + } + } + + #[tokio::test] + async fn external_call_acl_uses_caller_identity_not_handler_identity() { + let mut registry = OperationRegistry::new(); + let handler_authority = CompositionAuthority::new("agent", ["admin".to_string()]); + registry + .register(HandlerRegistration::new( + external_spec( + "gate", + AccessControl { + required_scopes: vec!["admin".to_string()], + ..Default::default() + }, + ), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + Some(handler_authority), + None, + Capabilities::new(), + )) + .unwrap(); + let ctx = root_context( + "req-10", + Some(identity_with_scopes("user", &["user"])), + Some(CompositionAuthority::new("agent", ["admin".to_string()])), + false, + ScopedPeerEnv::empty(), + ); + let response = registry.invoke("gate", serde_json::json!({}), ctx).await; + match response.result { + Err(e) => assert_eq!(e.code, "FORBIDDEN"), + other => panic!("expected FORBIDDEN, got {other:?}"), + } + } + + #[tokio::test] + async fn list_operations_returns_external_only() { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + external_spec("echo", AccessControl::default()), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + registry + .register(HandlerRegistration::new( + internal_spec("secret", AccessControl::default()), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let ops = registry.list_operations(); + assert_eq!(ops.len(), 1); + assert_eq!(ops[0].name, "echo"); + } + + #[tokio::test] + async fn handler_returned_error_passes_through() { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + external_spec("boom", AccessControl::default()), + HandlerKind::Once(error_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let ctx = root_context("req-11", None, None, false, ScopedPeerEnv::empty()); + let response = registry.invoke("boom", serde_json::json!({}), ctx).await; + match response.result { + Err(e) => assert_eq!(e.code, "INTERNAL"), + other => panic!("expected INTERNAL error, got {other:?}"), + } + } + + #[test] + fn builder_with_local_sets_provenance_local() { + let registry = OperationRegistryBuilder::new() + .with_local( + external_spec("echo", AccessControl::default()), + echo_handler(), + CompositionAuthority::none(), + ScopedPeerEnv::empty().into(), + Capabilities::new(), + ) + .unwrap() + .build(); + let reg = registry.registration("echo").expect("registered"); + assert_eq!(reg.provenance, OperationProvenance::Local); + assert!(reg.composition_authority.is_none()); + assert!(reg.scoped_env.is_some()); + } + + #[test] + fn builder_with_local_carries_authority_and_scoped_env() { + let registry = OperationRegistryBuilder::new() + .with_local( + external_spec("agent", AccessControl::default()), + echo_handler(), + Some(CompositionAuthority::new("agent", ["fs:read".to_string()])), + Some(ScopedPeerEnv::new(["fs/readFile"])), + Capabilities::new(), + ) + .unwrap() + .build(); + let reg = registry.registration("agent").expect("registered"); + assert_eq!(reg.provenance, OperationProvenance::Local); + let authority = reg.composition_authority.as_ref().expect("authority set"); + assert_eq!(authority.label, "agent"); + assert_eq!(authority.scopes, vec!["fs:read".to_string()]); + assert!(reg.scoped_env.is_some()); + assert!(reg.scoped_env.as_ref().unwrap().allows("fs/readFile")); + } + + #[test] + fn builder_with_leaf_sets_provenance_and_no_authority() { + let registry = OperationRegistryBuilder::new() + .with_leaf( + external_spec("vastai", AccessControl::default()), + echo_handler(), + Capabilities::new(), + ) + .unwrap() + .build(); + let reg = registry.registration("vastai").expect("registered"); + assert_eq!(reg.provenance, OperationProvenance::FromOpenAPI); + assert!(reg.composition_authority.is_none()); + assert!(reg.scoped_env.is_none()); + } + + #[test] + fn builder_with_leaf_provenance_overrides_provenance() { + let registry = OperationRegistryBuilder::new() + .with_leaf_provenance( + external_spec("remote", AccessControl::default()), + echo_handler(), + OperationProvenance::FromCall, + Capabilities::new(), + ) + .unwrap() + .build(); + let reg = registry.registration("remote").expect("registered"); + assert_eq!(reg.provenance, OperationProvenance::FromCall); + assert!(reg.composition_authority.is_none()); + assert!(reg.scoped_env.is_none()); + } + + #[test] + fn builder_with_takes_full_bundle() { + let registration = HandlerRegistration::new( + external_spec("agent", AccessControl::default()), + HandlerKind::Once(echo_handler()), + OperationProvenance::Session, + Some(CompositionAuthority::new("sandbox", [])), + Some(ScopedPeerEnv::new(["fs/readFile"])), + Capabilities::new(), + ); + let registry = OperationRegistryBuilder::new() + .with(registration) + .unwrap() + .build(); + let reg = registry.registration("agent").expect("registered"); + assert_eq!(reg.provenance, OperationProvenance::Session); + assert!(reg.composition_authority.is_some()); + assert!(reg.scoped_env.is_some()); + } + + #[test] + fn builder_default_is_new() { + let builder = OperationRegistryBuilder::default(); + let registry = builder.build(); + assert!(registry.list_operations().is_empty()); + } + + #[test] + fn registry_default_is_new() { + let registry = OperationRegistry::default(); + assert!(registry.list_operations().is_empty()); + assert!(registry.registration("anything").is_none()); + } + + #[test] + fn registration_lookup_returns_bundle_fields() { + let mut registry = OperationRegistry::new(); + let authority = CompositionAuthority::new("agent", ["fs:read".to_string()]); + let scoped = ScopedPeerEnv::new(["fs/readFile"]); + let caps = Capabilities::new().with_api_key("google", "k".to_string()); + registry + .register(HandlerRegistration::new( + external_spec("agent", AccessControl::default()), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + Some(authority.clone()), + Some(scoped.clone()), + caps.clone(), + )) + .unwrap(); + let reg = registry.registration("agent").expect("found"); + assert_eq!(reg.spec.name, "agent"); + assert_eq!(reg.provenance, OperationProvenance::Local); + assert_eq!(reg.composition_authority.as_ref().unwrap().label, "agent"); + assert!(reg.scoped_env.as_ref().unwrap().allows("fs/readFile")); + } + + fn subscription_spec(name: &str) -> OperationSpec { + OperationSpec::new( + name, + OperationType::Subscription, + Visibility::External, + serde_json::json!({}), + serde_json::json!({}), + vec![], + AccessControl::default(), + None, + ) + } + + fn echo_streaming_handler() -> StreamingHandler { + make_streaming_handler(|input, context| { + futures::stream::iter(vec![ResponseEnvelope::ok(context.request_id, input)]) + }) + } + + #[tokio::test] + async fn invoke_on_stream_kind_returns_invalid_operation_type() { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + subscription_spec("events/stream"), + HandlerKind::Stream(echo_streaming_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let ctx = root_context("req-iot", None, None, false, ScopedPeerEnv::empty()); + let response = registry + .invoke("events/stream", serde_json::json!({}), ctx) + .await; + match response.result { + Err(e) => assert_eq!(e.code, "INVALID_OPERATION_TYPE"), + other => panic!("expected INVALID_OPERATION_TYPE, got {other:?}"), + } + } + + #[tokio::test] + async fn invoke_on_once_kind_dispatches_normally() { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + external_spec("echo", AccessControl::default()), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let ctx = root_context("req-once", None, None, false, ScopedPeerEnv::empty()); + let response = registry + .invoke("echo", serde_json::json!({"hi": 1}), ctx) + .await; + assert_eq!(response.result, Ok(serde_json::json!({"hi": 1}))); + } + + #[test] + fn register_rejects_once_for_subscription_spec() { + let mut registry = OperationRegistry::new(); + let result = registry.register(HandlerRegistration::new( + subscription_spec("events/stream"), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )); + match result { + Err(msg) => assert!( + msg.contains("Subscription") + && msg.contains("HandlerKind::Stream") + && msg.contains("HandlerKind::Once"), + "unexpected message: {msg}" + ), + other => panic!("expected Err, got {other:?}"), + } + } + + #[test] + fn register_rejects_stream_for_query_spec() { + let mut registry = OperationRegistry::new(); + let result = registry.register(HandlerRegistration::new( + external_spec("echo", AccessControl::default()), + HandlerKind::Stream(echo_streaming_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )); + match result { + Err(msg) => assert!( + (msg.contains("Query") || msg.contains("Mutation")) + && msg.contains("HandlerKind::Once") + && msg.contains("HandlerKind::Stream"), + "unexpected message: {msg}" + ), + other => panic!("expected Err, got {other:?}"), + } + } + + #[tokio::test] + async fn make_streaming_handler_produces_working_stream() { + use futures::stream::StreamExt; + let handler = echo_streaming_handler(); + let ctx = root_context("req-st", None, None, false, ScopedPeerEnv::empty()); + let mut stream = handler(serde_json::json!({"v": 1}), ctx); + let first = stream.next().await.expect("one envelope"); + assert_eq!(first.result, Ok(serde_json::json!({"v": 1}))); + let second = stream.next().await; + assert!(second.is_none(), "stream ends after one value"); + } + + #[test] + fn call_error_invalid_operation_type_is_not_retryable() { + let err = CallError::invalid_operation_type("bad path"); + assert_eq!(err.code, "INVALID_OPERATION_TYPE"); + assert!(!err.retryable); + assert!(err.details.is_none()); + } + + async fn collect_stream(mut s: ResponseStream) -> Vec { + use futures::stream::StreamExt; + let mut out = Vec::new(); + while let Some(env) = s.next().await { + out.push(env); + } + out + } + + #[tokio::test] + async fn invoke_streaming_on_subscription_dispatches_handler_stream() { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + subscription_spec("events/stream"), + HandlerKind::Stream(echo_streaming_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let ctx = root_context("req-is-1", None, None, false, ScopedPeerEnv::empty()); + let stream = registry.invoke_streaming("events/stream", serde_json::json!({"v": 7}), ctx); + let items = collect_stream(stream).await; + assert_eq!(items.len(), 1); + assert_eq!(items[0].request_id, "req-is-1"); + assert_eq!(items[0].result, Ok(serde_json::json!({"v": 7}))); + } + + #[tokio::test] + async fn invoke_streaming_on_unknown_op_yields_single_not_found() { + let registry = OperationRegistry::new(); + let ctx = root_context("req-is-2", None, None, false, ScopedPeerEnv::empty()); + let stream = registry.invoke_streaming("missing", serde_json::json!({}), ctx); + let items = collect_stream(stream).await; + assert_eq!(items.len(), 1); + match &items[0].result { + Err(e) => { + assert_eq!(e.code, "NOT_FOUND"); + assert!(e.message.contains("missing")); + } + other => panic!("expected NOT_FOUND, got {other:?}"), + } + } + + #[tokio::test] + async fn invoke_streaming_on_query_op_yields_invalid_operation_type() { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + external_spec("echo", AccessControl::default()), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let ctx = root_context("req-is-3", None, None, false, ScopedPeerEnv::empty()); + let stream = registry.invoke_streaming("echo", serde_json::json!({}), ctx); + let items = collect_stream(stream).await; + assert_eq!(items.len(), 1); + match &items[0].result { + Err(e) => assert_eq!(e.code, "INVALID_OPERATION_TYPE"), + other => panic!("expected INVALID_OPERATION_TYPE, got {other:?}"), + } + } + + #[tokio::test] + async fn invoke_streaming_internal_op_from_external_yields_not_found() { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + internal_subscription_spec(AccessControl::default()), + HandlerKind::Stream(echo_streaming_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let ctx = root_context("req-is-4", None, None, false, ScopedPeerEnv::empty()); + let stream = registry.invoke_streaming("events/stream", serde_json::json!({}), ctx); + let items = collect_stream(stream).await; + assert_eq!(items.len(), 1); + match &items[0].result { + Err(e) => { + assert_eq!(e.code, "NOT_FOUND"); + assert!(e.message.contains("events/stream")); + } + other => panic!("expected NOT_FOUND, got {other:?}"), + } + } + + #[tokio::test] + async fn invoke_streaming_acl_denied_yields_forbidden() { + let mut registry = OperationRegistry::new(); + registry + .register(HandlerRegistration::new( + subscription_spec_with_acl(AccessControl { + required_scopes: vec!["admin".to_string()], + ..Default::default() + }), + HandlerKind::Stream(echo_streaming_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let ctx = root_context( + "req-is-5", + Some(identity_with_scopes("caller", &["user"])), + None, + false, + ScopedPeerEnv::empty(), + ); + let stream = registry.invoke_streaming("events/stream", serde_json::json!({}), ctx); + let items = collect_stream(stream).await; + assert_eq!(items.len(), 1); + match &items[0].result { + Err(e) => { + assert_eq!(e.code, "FORBIDDEN"); + assert!(e.message.contains("admin")); + } + other => panic!("expected FORBIDDEN, got {other:?}"), + } + } + + #[tokio::test] + async fn invoke_streaming_internal_call_uses_handler_identity_for_acl() { + let mut registry = OperationRegistry::new(); + let composing_authority = CompositionAuthority::new("agent-chat", ["admin".to_string()]); + registry + .register(HandlerRegistration::new( + internal_subscription_spec(AccessControl { + required_scopes: vec!["admin".to_string()], + ..Default::default() + }), + HandlerKind::Stream(echo_streaming_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let ctx = root_context( + "req-is-6", + Some(identity_with_scopes("user", &["user"])), + Some(composing_authority), + true, + ScopedPeerEnv::empty(), + ); + let stream = registry.invoke_streaming("events/stream", serde_json::json!({"ok": 1}), ctx); + let items = collect_stream(stream).await; + assert_eq!(items.len(), 1); + assert_eq!(items[0].request_id, "req-is-6"); + assert_eq!(items[0].result, Ok(serde_json::json!({"ok": 1}))); + } + + fn subscription_spec_with_acl(acl: AccessControl) -> OperationSpec { + OperationSpec::new( + "events/stream", + OperationType::Subscription, + Visibility::External, + serde_json::json!({}), + serde_json::json!({}), + vec![], + acl, + None, + ) + } + + fn internal_subscription_spec(acl: AccessControl) -> OperationSpec { + OperationSpec::new( + "events/stream", + OperationType::Subscription, + Visibility::Internal, + serde_json::json!({}), + serde_json::json!({}), + vec![], + acl, + None, + ) + } + + struct MockOwnership { + owned: Vec<(String, String)>, + } + + impl OwnershipProvider for MockOwnership { + fn owns( + &self, + _identity: &Identity, + resource_type: &str, + resource_id: &str, + _action: &str, + ) -> bool { + self.owned + .iter() + .any(|(rt, rid)| rt == resource_type && rid == resource_id) + } + + fn owned_resources(&self, _identity: &Identity, resource_type: &str) -> Vec { + self.owned + .iter() + .filter(|(rt, _)| rt == resource_type) + .map(|(_, rid)| rid.clone()) + .collect() + } + + fn owns_any(&self, _identity: &Identity, resource_type: &str) -> bool { + self.owned.iter().any(|(rt, _)| rt == resource_type) + } + } + + fn empty_identity(id: &str) -> Identity { + Identity { + id: id.to_string(), + scopes: vec![], + resources: HashMap::new(), + } + } + + fn resource_spec( + name: &str, + acl: AccessControl, + resource_id_path: Option<&str>, + ) -> OperationSpec { + OperationSpec::new( + name, + OperationType::Query, + Visibility::External, + serde_json::json!({}), + serde_json::json!({}), + vec![], + acl, + resource_id_path.map(|s| s.to_string()), + ) + } + + #[test] + fn extract_json_pointer_resolves_single_field() { + let input = serde_json::json!({"containerId": "abc123"}); + assert_eq!( + extract_json_pointer(&input, "$.containerId"), + Some("abc123".to_string()) + ); + } + + #[test] + fn extract_json_pointer_missing_field_returns_none() { + let input = serde_json::json!({"other": 1}); + assert_eq!(extract_json_pointer(&input, "$.containerId"), None); + } + + #[test] + fn extract_json_pointer_non_string_value_returns_none() { + let input = serde_json::json!({"containerId": 42}); + assert_eq!(extract_json_pointer(&input, "$.containerId"), None); + } + + #[test] + fn extract_json_pointer_no_leading_dollar_returns_none() { + let input = serde_json::json!({"containerId": "abc"}); + assert_eq!(extract_json_pointer(&input, "containerId"), None); + } + + #[test] + fn extract_json_pointer_empty_after_dollar_returns_none() { + let input = serde_json::json!({"a": "b"}); + assert_eq!(extract_json_pointer(&input, "$"), None); + } + + #[test] + fn extract_json_pointer_nested_slash_path() { + let input = serde_json::json!({"data": {"id": "xyz"}}); + assert_eq!( + extract_json_pointer(&input, "$.data/id"), + Some("xyz".to_string()) + ); + } + + #[test] + fn extract_json_pointer_nested_dotted_path() { + let input = serde_json::json!({"data": {"id": "xyz"}}); + assert_eq!( + extract_json_pointer(&input, "$.data.id"), + Some("xyz".to_string()) + ); + } + + #[tokio::test] + async fn invoke_with_ownership_provider_allows_owned_resource() { + let mut registry = OperationRegistry::new(); + let acl = AccessControl { + resource_type: Some("container".to_string()), + resource_action: Some("exec".to_string()), + ..Default::default() + }; + registry + .register(HandlerRegistration::new( + resource_spec("container/exec", acl, Some("$.containerId")), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let provider: Arc = Arc::new(MockOwnership { + owned: vec![("container".to_string(), "c1".to_string())], + }); + let ctx = root_context_with_ownership( + "req-owns", + Some(empty_identity("alice")), + Some(provider), + ScopedPeerEnv::empty(), + ); + let response = registry + .invoke( + "container/exec", + serde_json::json!({"containerId": "c1"}), + ctx, + ) + .await; + assert!(response.result.is_ok(), "owned resource should be allowed"); + } + + #[tokio::test] + async fn invoke_with_ownership_provider_forbids_unowned_resource() { + let mut registry = OperationRegistry::new(); + let acl = AccessControl { + resource_type: Some("container".to_string()), + resource_action: Some("exec".to_string()), + ..Default::default() + }; + registry + .register(HandlerRegistration::new( + resource_spec("container/exec", acl, Some("$.containerId")), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let provider: Arc = Arc::new(MockOwnership { + owned: vec![("container".to_string(), "c1".to_string())], + }); + let ctx = root_context_with_ownership( + "req-not-owns", + Some(empty_identity("alice")), + Some(provider), + ScopedPeerEnv::empty(), + ); + let response = registry + .invoke( + "container/exec", + serde_json::json!({"containerId": "c2"}), + ctx, + ) + .await; + match response.result { + Err(e) => { + assert_eq!(e.code, "FORBIDDEN"); + assert!(e.message.contains("container/c2")); + } + other => panic!("expected FORBIDDEN, got {other:?}"), + } + } + + #[tokio::test] + async fn invoke_with_ownership_provider_missing_field_falls_back_to_owns_any() { + let mut registry = OperationRegistry::new(); + let acl = AccessControl { + resource_type: Some("container".to_string()), + resource_action: Some("exec".to_string()), + ..Default::default() + }; + registry + .register(HandlerRegistration::new( + resource_spec("container/exec", acl, Some("$.containerId")), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let provider: Arc = Arc::new(MockOwnership { + owned: vec![("container".to_string(), "c1".to_string())], + }); + let ctx = root_context_with_ownership( + "req-missing", + Some(empty_identity("alice")), + Some(provider), + ScopedPeerEnv::empty(), + ); + let response = registry + .invoke("container/exec", serde_json::json!({}), ctx) + .await; + assert!( + response.result.is_ok(), + "missing field → resource_id None → owns_any path allowed" + ); + } + + #[tokio::test] + async fn invoke_with_ownership_provider_missing_field_forbids_when_not_owns_any() { + let mut registry = OperationRegistry::new(); + let acl = AccessControl { + resource_type: Some("container".to_string()), + resource_action: Some("exec".to_string()), + ..Default::default() + }; + registry + .register(HandlerRegistration::new( + resource_spec("container/exec", acl, Some("$.containerId")), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let provider: Arc = Arc::new(MockOwnership { + owned: vec![("volume".to_string(), "v1".to_string())], + }); + let ctx = root_context_with_ownership( + "req-missing-denied", + Some(empty_identity("alice")), + Some(provider), + ScopedPeerEnv::empty(), + ); + let response = registry + .invoke("container/exec", serde_json::json!({}), ctx) + .await; + match response.result { + Err(e) => { + assert_eq!(e.code, "FORBIDDEN"); + assert!(e.message.contains("container")); + } + other => panic!("expected FORBIDDEN, got {other:?}"), + } + } + + #[tokio::test] + async fn invoke_without_ownership_provider_falls_back_to_static_resources() { + let mut registry = OperationRegistry::new(); + let acl = AccessControl { + resource_type: Some("container".to_string()), + resource_action: Some("exec".to_string()), + ..Default::default() + }; + registry + .register(HandlerRegistration::new( + resource_spec("container/exec", acl, Some("$.containerId")), + HandlerKind::Once(echo_handler()), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )) + .unwrap(); + let mut resources = HashMap::new(); + resources.insert("container".to_string(), vec!["exec".to_string()]); + let identity = Identity { + id: "alice".to_string(), + scopes: vec![], + resources, + }; + let ctx = + root_context_with_ownership("req-static", Some(identity), None, ScopedPeerEnv::empty()); + let response = registry + .invoke( + "container/exec", + serde_json::json!({"containerId": "c1"}), + ctx, + ) + .await; + assert!( + response.result.is_ok(), + "no provider wired → static Identity.resources fallback allows" + ); + } +} diff --git a/src/registry/spec.rs b/src/registry/spec.rs new file mode 100644 index 0000000..7f4d955 --- /dev/null +++ b/src/registry/spec.rs @@ -0,0 +1,530 @@ +//! Operation specifications: `OperationSpec`, `OperationType`, `Visibility`, +//! `ErrorDefinition`, and `AccessControl`. +//! +//! See `docs/architecture/crates/call/operation-registry.md` for the full +//! specification. + +use crate::core::auth::Identity; +use crate::core::ownership::OwnershipProvider; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OperationType { + Query, + Mutation, + Subscription, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Visibility { + External, + Internal, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ErrorDefinition { + pub code: String, + pub description: String, + pub schema: Value, + pub http_status: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AccessControl { + pub required_scopes: Vec, + pub required_scopes_any: Option>, + pub resource_type: Option, + pub resource_action: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AccessResult { + Allowed, + Forbidden(String), +} + +impl AccessResult { + pub fn is_allowed(&self) -> bool { + matches!(self, AccessResult::Allowed) + } +} + +impl AccessControl { + pub fn has_restrictions(&self) -> bool { + !self.required_scopes.is_empty() + || self.required_scopes_any.is_some() + || self.resource_type.is_some() + || self.resource_action.is_some() + } + + pub fn check( + &self, + identity: Option<&Identity>, + resource_id: Option<&str>, + ownership: Option<&dyn OwnershipProvider>, + ) -> AccessResult { + if !self.has_restrictions() { + return AccessResult::Allowed; + } + let identity = match identity { + Some(id) => id, + None => return AccessResult::Forbidden("authentication required".to_string()), + }; + + for scope in &self.required_scopes { + if !identity.scopes.iter().any(|s| s == scope) { + return AccessResult::Forbidden(format!("missing required scope: {scope}")); + } + } + + if let Some(any) = &self.required_scopes_any { + let has_one = any.iter().any(|s| identity.scopes.iter().any(|i| i == s)); + if !has_one { + return AccessResult::Forbidden( + "missing required scope (any of: ".to_string() + &any.join(", ") + ")", + ); + } + } + + if let Some(p) = ownership { + if let Some(rt) = &self.resource_type { + match resource_id { + Some(rid) => { + let action = self.resource_action.as_deref().unwrap_or(""); + if !p.owns(identity, rt, rid, action) { + return AccessResult::Forbidden(format!( + "not owner of resource: {rt}/{rid}" + )); + } + } + None => { + if !p.owns_any(identity, rt) { + return AccessResult::Forbidden(format!( + "no owned resources of type: {rt}" + )); + } + } + } + return AccessResult::Allowed; + } + } + + if let Some(rt) = &self.resource_type { + let allowed = identity.resources.get(rt); + match &self.resource_action { + Some(action) => match allowed { + Some(actions) if actions.iter().any(|a| a == action) => {} + _ => { + return AccessResult::Forbidden(format!("missing resource: {rt}/{action}")) + } + }, + None => match allowed { + Some(actions) if !actions.is_empty() => {} + _ => return AccessResult::Forbidden(format!("missing resource: {rt}")), + }, + } + } else if let Some(action) = &self.resource_action { + let found = identity + .resources + .values() + .any(|actions| actions.iter().any(|a| a == action)); + if !found { + return AccessResult::Forbidden(format!("missing resource action: {action}")); + } + } + + AccessResult::Allowed + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct OperationSpec { + pub name: String, + pub namespace: String, + pub op_type: OperationType, + pub visibility: Visibility, + pub input_schema: Value, + pub output_schema: Value, + pub error_schemas: Vec, + pub access_control: AccessControl, + /// JSON pointer into the input for the resource ID, when + /// `access_control.resource_type` is set and the operation targets a + /// specific runtime-spawned resource (ADR-050). e.g. `"$.containerId"` + /// for `docker/container/exec`. Absent for no-specific-resource + /// operations (the `list` case). `None` for operations with no + /// `resource_type` or with static resource sets. + pub resource_id_path: Option, +} + +impl OperationSpec { + #[allow(clippy::too_many_arguments)] + pub fn new( + name: impl Into, + op_type: OperationType, + visibility: Visibility, + input_schema: Value, + output_schema: Value, + error_schemas: Vec, + access_control: AccessControl, + resource_id_path: Option, + ) -> Self { + let name = name.into(); + let namespace = name + .split('/') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or("") + .to_string(); + Self { + name, + namespace, + op_type, + visibility, + input_schema, + output_schema, + error_schemas, + access_control, + resource_id_path, + } + } + + pub fn path(&self) -> String { + format!("/{}", self.name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn identity(scopes: &[&str], resources: &[(&str, &[&str])]) -> Identity { + let mut res = HashMap::new(); + for (k, v) in resources { + res.insert( + (*k).to_string(), + v.iter().map(|s| (*s).to_string()).collect(), + ); + } + Identity { + id: "caller".to_string(), + scopes: scopes.iter().map(|s| (*s).to_string()).collect(), + resources: res, + } + } + + #[test] + fn path_has_leading_slash() { + let spec = OperationSpec::new( + "fs/readFile", + OperationType::Query, + Visibility::External, + serde_json::json!({}), + serde_json::json!({}), + vec![], + AccessControl::default(), + None, + ); + assert_eq!(spec.path(), "/fs/readFile"); + } + + #[test] + fn namespace_derived_from_name() { + let spec = OperationSpec::new( + "agent/chat", + OperationType::Subscription, + Visibility::External, + serde_json::json!({}), + serde_json::json!({}), + vec![], + AccessControl::default(), + None, + ); + assert_eq!(spec.namespace, "agent"); + assert_eq!(spec.name, "agent/chat"); + } + + #[test] + fn namespace_for_single_segment() { + let spec = OperationSpec::new( + "list", + OperationType::Query, + Visibility::Internal, + serde_json::json!({}), + serde_json::json!({}), + vec![], + AccessControl::default(), + None, + ); + assert_eq!(spec.namespace, "list"); + } + + #[test] + fn resource_id_path_defaults_to_none() { + let spec = OperationSpec::new( + "fs/readFile", + OperationType::Query, + Visibility::External, + serde_json::json!({}), + serde_json::json!({}), + vec![], + AccessControl::default(), + None, + ); + assert_eq!(spec.resource_id_path, None); + } + + #[test] + fn empty_access_control_allowed_for_all() { + let acl = AccessControl::default(); + assert_eq!(acl.check(None, None, None), AccessResult::Allowed); + let id = identity(&[], &[]); + assert_eq!(acl.check(Some(&id), None, None), AccessResult::Allowed); + } + + #[test] + fn none_identity_with_restrictions_forbidden() { + let acl = AccessControl { + required_scopes: vec!["read".to_string()], + ..Default::default() + }; + assert_eq!( + acl.check(None, None, None), + AccessResult::Forbidden("authentication required".to_string()) + ); + + let acl2 = AccessControl { + required_scopes_any: Some(vec!["read".to_string()]), + ..Default::default() + }; + assert_eq!( + acl2.check(None, None, None), + AccessResult::Forbidden("authentication required".to_string()) + ); + + let acl3 = AccessControl { + resource_type: Some("service".to_string()), + ..Default::default() + }; + assert_eq!( + acl3.check(None, None, None), + AccessResult::Forbidden("authentication required".to_string()) + ); + } + + #[test] + fn required_scopes_and_checked() { + let acl = AccessControl { + required_scopes: vec!["a".to_string(), "b".to_string()], + ..Default::default() + }; + let id_missing = identity(&["a"], &[]); + assert!(matches!( + acl.check(Some(&id_missing), None, None), + AccessResult::Forbidden(_) + )); + let id_ok = identity(&["a", "b", "c"], &[]); + assert_eq!(acl.check(Some(&id_ok), None, None), AccessResult::Allowed); + } + + #[test] + fn required_scopes_any_or_checked() { + let acl = AccessControl { + required_scopes_any: Some(vec!["x".to_string(), "y".to_string()]), + ..Default::default() + }; + let id_x = identity(&["x"], &[]); + assert_eq!(acl.check(Some(&id_x), None, None), AccessResult::Allowed); + let id_y = identity(&["y"], &[]); + assert_eq!(acl.check(Some(&id_y), None, None), AccessResult::Allowed); + let id_none = identity(&["z"], &[]); + assert!(matches!( + acl.check(Some(&id_none), None, None), + AccessResult::Forbidden(_) + )); + } + + #[test] + fn resource_check_with_type_and_action() { + let acl = AccessControl { + resource_type: Some("service".to_string()), + resource_action: Some("read".to_string()), + ..Default::default() + }; + let id_ok = identity(&[], &[("service", &["read"])]); + assert_eq!(acl.check(Some(&id_ok), None, None), AccessResult::Allowed); + let id_missing_action = identity(&[], &[("service", &["write"])]); + assert!(matches!( + acl.check(Some(&id_missing_action), None, None), + AccessResult::Forbidden(_) + )); + let id_missing_type = identity(&[], &[("other", &["read"])]); + assert!(matches!( + acl.check(Some(&id_missing_type), None, None), + AccessResult::Forbidden(_) + )); + } + + #[test] + fn combined_scopes_and_resources() { + let acl = AccessControl { + required_scopes: vec!["admin".to_string()], + resource_type: Some("service".to_string()), + resource_action: Some("read".to_string()), + ..Default::default() + }; + let id_ok = identity(&["admin"], &[("service", &["read"])]); + assert_eq!(acl.check(Some(&id_ok), None, None), AccessResult::Allowed); + let id_missing_scope = identity(&["user"], &[("service", &["read"])]); + assert!(matches!( + acl.check(Some(&id_missing_scope), None, None), + AccessResult::Forbidden(_) + )); + } + + struct MockOwnership { + owned: Vec<(String, String)>, + } + + impl OwnershipProvider for MockOwnership { + fn owns( + &self, + _identity: &Identity, + resource_type: &str, + resource_id: &str, + _action: &str, + ) -> bool { + self.owned + .iter() + .any(|(rt, rid)| rt == resource_type && rid == resource_id) + } + + fn owned_resources(&self, _identity: &Identity, resource_type: &str) -> Vec { + self.owned + .iter() + .filter(|(rt, _)| rt == resource_type) + .map(|(_, rid)| rid.clone()) + .collect() + } + + fn owns_any(&self, _identity: &Identity, resource_type: &str) -> bool { + self.owned.iter().any(|(rt, _)| rt == resource_type) + } + } + + fn empty_identity(id: &str) -> Identity { + Identity { + id: id.to_string(), + scopes: vec![], + resources: HashMap::new(), + } + } + + #[test] + fn ownership_provider_allows_owned_resource() { + let acl = AccessControl { + resource_type: Some("container".to_string()), + resource_action: Some("exec".to_string()), + ..Default::default() + }; + let id = empty_identity("alice"); + let provider = MockOwnership { + owned: vec![("container".to_string(), "c1".to_string())], + }; + assert_eq!( + acl.check( + Some(&id), + Some("c1"), + Some(&provider as &dyn OwnershipProvider) + ), + AccessResult::Allowed + ); + } + + #[test] + fn ownership_provider_forbids_unowned_resource() { + let acl = AccessControl { + resource_type: Some("container".to_string()), + resource_action: Some("exec".to_string()), + ..Default::default() + }; + let id = empty_identity("alice"); + let provider = MockOwnership { + owned: vec![("container".to_string(), "c1".to_string())], + }; + assert!(matches!( + acl.check( + Some(&id), + Some("c2"), + Some(&provider as &dyn OwnershipProvider) + ), + AccessResult::Forbidden(_) + )); + } + + #[test] + fn ownership_provider_forbids_none_identity() { + let acl = AccessControl { + resource_type: Some("container".to_string()), + resource_action: Some("exec".to_string()), + ..Default::default() + }; + let provider = MockOwnership { + owned: vec![("container".to_string(), "c1".to_string())], + }; + assert!(matches!( + acl.check(None, Some("c1"), Some(&provider as &dyn OwnershipProvider)), + AccessResult::Forbidden(_) + )); + } + + #[test] + fn ownership_provider_list_allowed_when_owns_any() { + let acl = AccessControl { + resource_type: Some("container".to_string()), + resource_action: Some("exec".to_string()), + ..Default::default() + }; + let id = empty_identity("alice"); + let provider = MockOwnership { + owned: vec![("container".to_string(), "c1".to_string())], + }; + assert_eq!( + acl.check(Some(&id), None, Some(&provider as &dyn OwnershipProvider)), + AccessResult::Allowed + ); + } + + #[test] + fn ownership_provider_list_forbidden_when_not_owns_any() { + let acl = AccessControl { + resource_type: Some("container".to_string()), + resource_action: Some("exec".to_string()), + ..Default::default() + }; + let id = empty_identity("alice"); + let provider = MockOwnership { + owned: vec![("volume".to_string(), "v1".to_string())], + }; + assert!(matches!( + acl.check(Some(&id), None, Some(&provider as &dyn OwnershipProvider)), + AccessResult::Forbidden(_) + )); + } + + #[test] + fn ownership_none_falls_back_to_static() { + let acl = AccessControl { + resource_type: Some("service".to_string()), + resource_action: Some("read".to_string()), + ..Default::default() + }; + let id_ok = identity(&[], &[("service", &["read"])]); + assert_eq!(acl.check(Some(&id_ok), None, None), AccessResult::Allowed); + let id_missing = identity(&[], &[("service", &["write"])]); + assert!(matches!( + acl.check(Some(&id_missing), None, None), + AccessResult::Forbidden(_) + )); + } +}