[M5] mxaccess: F26 step 2 — AsbTransport::connect TCP+preamble+handshake
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<String>,
connection_id: [u8; 16],
) -> Result<(AsbTransport<TcpStream>, 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) <noreply@anthropic.com>
This commit is contained in:
+5
-1
@@ -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<TcpStream>, 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:**
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<T: AsyncRead + AsyncWrite + Unpin + Send + 'static> AsbTransport<T> {
|
||||
}
|
||||
}
|
||||
|
||||
impl AsbTransport<TcpStream> {
|
||||
/// `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<String>,
|
||||
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::<std::net::SocketAddr>().unwrap();
|
||||
let result = AsbTransport::<TcpStream>::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);
|
||||
|
||||
Reference in New Issue
Block a user