diff --git a/design/followups.md b/design/followups.md index 6abccba..1066b93 100644 --- a/design/followups.md +++ b/design/followups.md @@ -46,7 +46,11 @@ move to `## Resolved` with a date + commit hash. **Resolves when:** F19-F26 are all closed and the four DoD bullets above pass. -**Cumulative execution log.** F19 + F23 (`ed17c07`); F24 (`7611d9e`); F20 (`9dfd193`); F22 (`43c10a1`); F21 (`5f98558`); F25 step 1 (`25dbd8d`); F25 step 2 (`a2b8989`); F25 step 3 (`c4bf0a0`); F25 step 4 (`1e59249`); F25 step 5 (`9b8133f`); F25 step 6 (`321b796`); F25 step 7 landed in this commit: +**Cumulative execution log.** F19 + F23 (`ed17c07`); F24 (`7611d9e`); F20 (`9dfd193`); F22 (`43c10a1`); F21 (`5f98558`); F25 step 1 (`25dbd8d`); F25 step 2 (`a2b8989`); F25 step 3 (`c4bf0a0`); F25 step 4 (`1e59249`); F25 step 5 (`9b8133f`); F25 step 6 (`321b796`); F25 step 7 (`1b1ee1e`); F26 step 1 landed in this commit: +- F26 step 1: `mxaccess::AsbTransport` — bridges F25's `AsbClient` into the M0 `Transport` trait. Generic over `T: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static` (the same bounds AsbClient takes). `Transport::capabilities()` returns the ASB-specific flags per `design/60-roadmap.md` M5: `buffered_subscribe = false`, `activate_suspend = false`, `operation_complete_frame = false`. `Transport::kind()` returns `TransportKind::Asb`. `AsbTransport::new(client)` / `into_client()` / `client_mut()` for transport↔client conversion. New deps: `mxaccess` now path-deps `mxaccess-asb` + `mxaccess-asb-nettcp`. Compile-time `Send + Sync + 'static` assertion guards the trait-bound contract. 2 new tests: kind == Asb; capabilities all false. **Stubbed for F26 step 2:** `Session::connect_asb` constructor that owns the full TCP-open + preamble + DH handshake orchestration, plus operation routing that maps ASB types (`ItemStatus`, `RuntimeValue`) back to `mxaccess` types (`MxStatus`, `DataChange`, `MxValue`). Stubbed for F26 step 3: subscription routing — `Session::subscribe` on ASB maps to a `CreateSubscription` + `AddMonitoredItems` + `Publish`-callback pipeline; F25 subscription operations themselves are not yet implemented. + +**Earlier slices:** +- F25 step 7 (commit `1b1ee1e`): - F25 step 7: Disconnect operation (closes the connection lifecycle: Connect → ops → Disconnect → End → close). New `build_disconnect_request_body(data, iv)` mirrors `AsbContracts.cs:109-114` (``) — same payload shape as AuthenticateMe but under a different wrapper element. New `client::disconnect()` builds a fresh encrypted authentication-data blob via F23's `create_authentication_data` (encrypts `local_pub || remote_pub` under the derived AES key with a fresh IV), wraps it, and sends one-way + signed (regular HMAC, no force). 2 new tests: `disconnect_request_carries_data_and_iv_under_correct_wrapper` (checks wrapper element name + Data/IV byte ordering), and end-to-end `disconnect_writes_signed_one_way_envelope` via `tokio::io::duplex` peer that verifies the encoded SizedEnvelope contains the disconnectIn action string. With Disconnect landed, `AsbClient` now covers the full session lifecycle: `send_preamble().await? → connect().await? → register_items()/read()/keep_alive()/unregister_items() → disconnect().await? → send_end().await?`. **Earlier slices:** diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 650505a..4197118 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -339,6 +339,8 @@ version = "0.0.0" dependencies = [ "async-trait", "futures-util", + "mxaccess-asb", + "mxaccess-asb-nettcp", "mxaccess-callback", "mxaccess-codec", "mxaccess-galaxy", diff --git a/rust/crates/mxaccess/Cargo.toml b/rust/crates/mxaccess/Cargo.toml index 3aeefba..98515ed 100644 --- a/rust/crates/mxaccess/Cargo.toml +++ b/rust/crates/mxaccess/Cargo.toml @@ -14,6 +14,8 @@ mxaccess-callback = { path = "../mxaccess-callback" } mxaccess-galaxy = { path = "../mxaccess-galaxy" } mxaccess-nmx = { path = "../mxaccess-nmx" } mxaccess-rpc = { path = "../mxaccess-rpc" } +mxaccess-asb = { path = "../mxaccess-asb" } +mxaccess-asb-nettcp = { path = "../mxaccess-asb-nettcp" } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } diff --git a/rust/crates/mxaccess/src/lib.rs b/rust/crates/mxaccess/src/lib.rs index 3326742..66f1b64 100644 --- a/rust/crates/mxaccess/src/lib.rs +++ b/rust/crates/mxaccess/src/lib.rs @@ -30,6 +30,9 @@ pub use mxaccess_codec::{ // ---- Public types -------------------------------------------------------- pub mod session; +pub mod transport_asb; + +pub use transport_asb::AsbTransport; pub use mxaccess_galaxy::{GalaxyTagMetadata, Resolver, ResolverError}; pub use mxaccess_nmx::WriteValue; diff --git a/rust/crates/mxaccess/src/transport_asb.rs b/rust/crates/mxaccess/src/transport_asb.rs new file mode 100644 index 0000000..50e033e --- /dev/null +++ b/rust/crates/mxaccess/src/transport_asb.rs @@ -0,0 +1,129 @@ +//! `AsbTransport` — bridges the F25 `mxaccess_asb::AsbClient` into the +//! `mxaccess::Transport` trait + `Session` API. +//! +//! Per `design/60-roadmap.md` M5, the ASB transport surfaces: +//! +//! * **No `subscribe_buffered`** — ASB has no proven equivalent of NMX's +//! buffered-batch DataUpdate frame; consumers calling +//! `Session::subscribe_buffered` over ASB get +//! `Error::Unsupported(Capability::BufferedSubscribe)`. +//! * **No `Activate` / `Suspend`** — these are NMX `INmxService2` +//! primitives without an ASB analogue. +//! * **No `OperationComplete` outside the proven write-completion frame** +//! — ASB doesn't surface a generic completion-frame channel. +//! +//! ## Scope of this iteration (F26 step 1) +//! +//! Implements: +//! * [`AsbTransport`] struct that owns an [`AsbClient`] over an +//! `AsyncRead + AsyncWrite + Unpin + Send` transport. +//! * [`Transport`] trait impl returning the capability flags above. +//! * [`AsbTransport::new`] constructor. +//! +//! Stubbed for next F26 iteration: +//! * `Session::connect_asb` constructor — wires `AsbTransport` into a +//! `Session`. Needs a thin shim that owns the AsbClient + delegates +//! `register_items`/`read`/`write`/`subscribe` to the corresponding +//! client method, mapping ASB result types (`ItemStatus`, +//! `RuntimeValue`) back to `mxaccess` types (`MxStatus`, +//! `DataChange`, `MxValue`). +//! * Subscription routing — `Session::subscribe` on ASB maps to a +//! `CreateSubscription` + `AddMonitoredItems` + `Publish`-callback +//! pipeline; the F25 subscription operations are not yet wired up. + +use mxaccess_asb::AsbClient; +use tokio::io::{AsyncRead, AsyncWrite}; + +use crate::{Transport, TransportCapabilities, TransportKind}; + +/// `Transport` implementation for the ASB (`net.tcp` + binary-message- +/// encoder) data plane. Owns the underlying [`AsbClient`]. +pub struct AsbTransport { + client: AsbClient, +} + +impl AsbTransport { + /// Build a transport from an already-constructed [`AsbClient`]. + /// The client should typically have completed + /// `send_preamble().await? -> connect().await?` before being + /// wrapped — the F26 next-step `Session::connect_asb` will own that + /// orchestration. + pub fn new(client: AsbClient) -> Self { + Self { client } + } + + /// Surface the inner client. M5 / F26 step 2 wires concrete + /// operations through here. + pub fn client_mut(&mut self) -> &mut AsbClient { + &mut self.client + } + + /// Consume the transport and return the inner client. Useful when + /// the caller wants to issue raw IASBIDataV2 operations directly + /// before / after the Session-level orchestration kicks in. + pub fn into_client(self) -> AsbClient { + self.client + } +} + +/// Compile-time only: `AsbTransport` must be `Send + Sync + 'static` +/// (the `Transport` trait bound). Sync is provided by `AsbClient`'s +/// internal lack of interior mutability over non-Sync types — the +/// `AsyncRead + AsyncWrite + Unpin + Send` transport is the only +/// non-trivial constraint, and Tokio's `TcpStream` satisfies it. +const _: fn() = || { + fn assert_send_sync() {} + assert_send_sync::>(); +}; + +impl Transport for AsbTransport { + fn capabilities(&self) -> TransportCapabilities { + TransportCapabilities { + // ASB has no proven buffered-batch DataUpdate equivalent. + buffered_subscribe: false, + // Activate/Suspend are NMX `INmxService2` primitives. + activate_suspend: false, + // No generic completion-frame channel on ASB. + operation_complete_frame: false, + } + } + + fn kind(&self) -> TransportKind { + TransportKind::Asb + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] +mod tests { + use super::*; + use mxaccess_asb_nettcp::auth::{AsbAuthenticator, CryptoParameters}; + + fn make_authenticator() -> AsbAuthenticator { + AsbAuthenticator::new("test-passphrase", &CryptoParameters::defaults(), [0u8; 16]).unwrap() + } + + #[test] + fn asb_transport_kind_is_asb() { + let (client_end, _peer) = tokio::io::duplex(64); + let client = AsbClient::new(client_end, make_authenticator(), "test://x/y"); + let transport = AsbTransport::new(client); + assert_eq!(transport.kind(), TransportKind::Asb); + } + + #[test] + fn asb_transport_capabilities_disable_buffered_and_activate_suspend() { + let (client_end, _peer) = tokio::io::duplex(64); + let client = AsbClient::new(client_end, make_authenticator(), "test://x/y"); + let transport = AsbTransport::new(client); + let caps = transport.capabilities(); + assert!(!caps.buffered_subscribe); + assert!(!caps.activate_suspend); + assert!(!caps.operation_complete_frame); + } +}