From 14bb5297a85e47f6b7ccac8208bad5b25949a1b2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 5 May 2026 12:14:16 -0400 Subject: [PATCH] =?UTF-8?q?[M5]=20mxaccess:=20F26=20step=202=20=E2=80=94?= =?UTF-8?q?=20AsbTransport::connect=20TCP+preamble+handshake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `tokio::net::TcpStream`-specialised async constructor that owns the full transport-bring-up sequence: TCP connect → NMF preamble → DH Connect → AuthenticateMe (one-way) Signature: ``` async fn connect( endpoint: SocketAddr, passphrase: &str, crypto_parameters: &CryptoParameters, via_uri: impl Into, connection_id: [u8; 16], ) -> Result<(AsbTransport, ConnectResponse), Error> ``` Returns the `ConnectResponse` alongside the transport so callers can inspect the negotiated `connection_lifetime` (the `:V2` suffix toggles Apollo vs Baktun encryption — see F23). New error variant: `ConnectionError::TransportFailure { detail }` covers all transport-bring-up failure modes (NMF / NBFX / auth / peer Fault). The underlying error type is intentionally erased to keep the public taxonomy small; `detail` carries the Display representation. Errors are mapped at the AsbClient / AuthError boundary via private `map_client_error` / `map_auth_error` helpers. 1 new test: * `connect_to_unreachable_endpoint_surfaces_connection_error` — TCP connect to 127.0.0.1:1 (TCPMUX-reserved) cleanly errors without panicking. Smoke test for the constructor signature + error path. Stubbed for F26 step 3: * `Session::connect_asb` constructor — the SessionInner refactor to host both NMX + ASB transports under one struct is heavier than this iteration's scope. * Operation-routing layer that maps ASB result types (ItemStatus, RuntimeValue) back to mxaccess types (MxStatus, DataChange, MxValue). Co-Authored-By: Claude Opus 4.7 (1M context) --- design/followups.md | 6 +- rust/crates/mxaccess/src/lib.rs | 7 ++ rust/crates/mxaccess/src/transport_asb.rs | 94 ++++++++++++++++++++++- 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/design/followups.md b/design/followups.md index 1066b93..694f76e 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 (`1b1ee1e`); F26 step 1 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 (`8a0f92b`); F26 step 2 landed in this commit: +- F26 step 2: `AsbTransport::connect(endpoint, passphrase, crypto_parameters, via_uri, connection_id)` — `tokio::net::TcpStream`-specialised async constructor that owns the full transport-bring-up sequence: TCP connect → NMF preamble exchange → DH Connect handshake → AuthenticateMe one-way (signed). Returns `(AsbTransport, ConnectResponse)` so callers can inspect the negotiated lifetime / Apollo-vs-Baktun flag from the response. New `ConnectionError::TransportFailure { detail }` variant carries the underlying error message (NMF / NBFX / auth / I/O) without exploding the public taxonomy. Errors are mapped at the AsbClient/Auth boundary via `map_client_error` / `map_auth_error` helpers. 1 new test confirms a connect to an unreachable endpoint (127.0.0.1:1, TCPMUX-reserved) surfaces an `Err` cleanly without panicking. **Stubbed for F26 step 3:** `Session::connect_asb` constructor (the SessionInner refactor needed to host both NMX + ASB transports under one struct is heavier than this iteration's scope), plus the operation-routing layer that maps ASB result types (`ItemStatus`, `RuntimeValue`) back to `mxaccess` types (`MxStatus`, `DataChange`, `MxValue`). + +**Earlier slices:** +- F26 step 1 (commit `8a0f92b`): - 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:** diff --git a/rust/crates/mxaccess/src/lib.rs b/rust/crates/mxaccess/src/lib.rs index 66f1b64..263bbd7 100644 --- a/rust/crates/mxaccess/src/lib.rs +++ b/rust/crates/mxaccess/src/lib.rs @@ -301,6 +301,13 @@ pub enum ConnectionError { CallbackProxyMissing, #[error("engine not registered (UninitializedObject / ERROR_INVALID_STATE)")] EngineNotRegistered, + /// Transport bring-up failed during preamble exchange or + /// authentication handshake. `detail` is the underlying error + /// message — the original error type is intentionally erased to + /// keep the public taxonomy small. ASB-specific (F26 step 2); + /// `EngineNotRegistered` covers the analogous NMX failure mode. + #[error("transport bring-up failed: {detail}")] + TransportFailure { detail: String }, } #[derive(Debug, thiserror::Error)] diff --git a/rust/crates/mxaccess/src/transport_asb.rs b/rust/crates/mxaccess/src/transport_asb.rs index 50e033e..8426a6b 100644 --- a/rust/crates/mxaccess/src/transport_asb.rs +++ b/rust/crates/mxaccess/src/transport_asb.rs @@ -31,10 +31,14 @@ //! `CreateSubscription` + `AddMonitoredItems` + `Publish`-callback //! pipeline; the F25 subscription operations are not yet wired up. -use mxaccess_asb::AsbClient; -use tokio::io::{AsyncRead, AsyncWrite}; +use std::net::SocketAddr; -use crate::{Transport, TransportCapabilities, TransportKind}; +use mxaccess_asb::{AsbClient, ClientError, ConnectResponse}; +use mxaccess_asb_nettcp::auth::{AsbAuthenticator, CryptoParameters}; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::net::TcpStream; + +use crate::{Error, Transport, TransportCapabilities, TransportKind}; /// `Transport` implementation for the ASB (`net.tcp` + binary-message- /// encoder) data plane. Owns the underlying [`AsbClient`]. @@ -66,6 +70,68 @@ impl AsbTransport { } } +impl AsbTransport { + /// `tokio::net::TcpStream`-specialised constructor: opens the TCP + /// connection, runs the F20 preamble exchange, and runs the F25 + /// step-6 DH `Connect` + `AuthenticateMe` handshake. Returns a + /// transport ready for operation calls. + /// + /// The `via_uri` is the `net.tcp://host:port/path` URL the peer + /// expects in the [MS-NMF] `ViaRecord`. `passphrase` is the + /// solution-shared secret (typically read from DPAPI on a real + /// install — see F23's `dpapi` feature gate; tests / CI pass it + /// directly via `AsbCredentials::shared_secret(...)` once that + /// type lands). + /// + /// `crypto_parameters` controls the DH prime / generator / hash + /// algorithm; pass [`CryptoParameters::defaults`] for a stock + /// AVEVA install. + /// + /// `connection_id` should typically be a freshly-generated UUID + /// (e.g. `Uuid::new_v4().into_bytes()`). Tests pin it for + /// determinism. + /// + /// # Errors + /// + /// Surfaces all transport-bring-up failure modes as + /// [`Error::Connection`]: + /// * TCP connect fails. + /// * NMF preamble exchange fails (peer responded with `Fault` or + /// an unexpected record). + /// * DH `Connect` operation fails. + /// * Encrypted authentication-data assembly fails. + /// * `AuthenticateMe` write fails. + pub async fn connect( + endpoint: SocketAddr, + passphrase: &str, + crypto_parameters: &CryptoParameters, + via_uri: impl Into, + connection_id: [u8; 16], + ) -> Result<(Self, ConnectResponse), Error> { + let stream = TcpStream::connect(endpoint).await.map_err(Error::Io)?; + let authenticator = AsbAuthenticator::new(passphrase, crypto_parameters, connection_id) + .map_err(map_auth_error)?; + let mut client = AsbClient::new(stream, authenticator, via_uri); + client.send_preamble().await.map_err(map_client_error)?; + let response = client.connect().await.map_err(map_client_error)?; + Ok((Self::new(client), response)) + } +} + +fn map_client_error(err: ClientError) -> Error { + use crate::ConnectionError; + Error::Connection(ConnectionError::TransportFailure { + detail: err.to_string(), + }) +} + +fn map_auth_error(err: mxaccess_asb_nettcp::auth::AuthError) -> Error { + use crate::ConnectionError; + Error::Connection(ConnectionError::TransportFailure { + detail: err.to_string(), + }) +} + /// 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 @@ -116,6 +182,28 @@ mod tests { assert_eq!(transport.kind(), TransportKind::Asb); } + #[tokio::test] + async fn connect_to_unreachable_endpoint_surfaces_connection_error() { + // Bind to a port that won't accept connections. Address + // 127.0.0.1:1 is reserved (TCPMUX) and almost always closed, + // so connect() should fail immediately. Whether it surfaces + // as Io or Connection depends on the platform; we just assert + // that it errors cleanly without panicking. + let endpoint = "127.0.0.1:1".parse::().unwrap(); + let result = AsbTransport::::connect( + endpoint, + "test-passphrase", + &CryptoParameters::defaults(), + "net.tcp://127.0.0.1:1/asb", + [0u8; 16], + ) + .await; + assert!( + result.is_err(), + "expected connect to unreachable endpoint to fail" + ); + } + #[test] fn asb_transport_capabilities_disable_buffered_and_activate_suspend() { let (client_end, _peer) = tokio::io::duplex(64);