[M5] mxaccess-asb-nettcp/asb: Connect handshake live + SOAP fault detection
Live-bring-up reconciliation against AVEVA's MxDataProvider on Windows.
Connect now completes end-to-end (real DH key exchange, apollo:V2
encryption, ServicePublicKey/ServiceAuthenticationData populated). Five
fixes land:
1. NBFX `PrefixElement_a..z` (0x5E-0x77) and `PrefixAttribute_a..z`
(0x26-0x3F) decode + encode arms. The server's ConnectResponse hit
`0x65 = PrefixElement_h` for a dynamically-named element and our
decoder bailed with `unknown NBFX record byte 0x65`. Both directions
now round-trip; encoder picks short-form when prefix is a single
lowercase ASCII letter.
2. xmlns redeclaration on `<Data>` AND `<InitializationVector>` inside
`AuthenticationData` / `PublicKey`. `[XmlType(Namespace = ...)]` on
AuthenticationData / PublicKey (`AsbContracts.cs:350-381`) means
XmlSerializer emits `xmlns="..."` on each direct child. The default-
ns scope ends at `</Data>`, so `<InitializationVector>` needs its own
redeclaration to stay in the data namespace; without it the server
fell back to messages-namespace and the deserialiser threw an
`InternalServiceFault`.
3. SOAP-fault detection in `AsbClient::send_envelope`. New
`ClientError::SoapFault { action, code, reason }` surfaces when the
response Action header matches the canonical `dispatcher/fault`
template; previously body decoders blindly ran and surfaced
`MissingField { field: "Status" }` masking the actual fault. Reason
text is extracted as the longest `NbfxText::Chars` in the body —
robust against the `nbfs.rs` static-dictionary id mismatches.
4. Identified blocker (filed as F28): signed-request HMAC currently
covers the NBFX wire bytes, but .NET's `AsbSystemAuthenticator.Sign`
HMACs `Encoding.UTF8.GetBytes(request.ToXml())` — the canonical XML
serialisation via `XmlSerializer` with namespace
`urn:invensys.schemas` (`AsbSerialization.cs:12-48`). Until the Rust
port emits identical XML bytes for `ConnectedRequest` subclasses,
AuthenticateMe / RegisterItems / every signed RPC fault on the
server. Connect itself is unsigned (`ServiceMessage` not
`ConnectedRequest`) which is why it works today.
5. Identified `nbfs.rs` static-dictionary id drift (filed as F29): wire
uses Fault=134/Code=142/Reason=144/Text=146/Value=154/Subcode=156
but our table has them at 114/122/124/126/134/136. Off by 20 from
id 114+ — 10 missing entries between `s` (id 112) and `Fault`. No
request-side impact (we only encode IDs ≤44, all correct); the SOAP
fault decode walks text records directly so it sidesteps the issue.
Workspace: 702 tests pass (no test count delta — wire-only fixes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -432,6 +432,13 @@ fn encode_element(
|
||||
out.push(0x44 + off);
|
||||
encode_multibyte_int31_to_nbfx(out, *id)
|
||||
}
|
||||
// Short-form: single-letter prefix + inline name. Records
|
||||
// 0x5E..0x77 (PrefixElement_a..z).
|
||||
(Some(prefix), NbfxName::Inline(s)) if prefix_letter_offset(prefix).is_some() => {
|
||||
let off = prefix_letter_offset(prefix).unwrap_or(0);
|
||||
out.push(0x5E + off);
|
||||
encode_string(s.as_bytes(), out)
|
||||
}
|
||||
(Some(prefix), NbfxName::Inline(s)) => {
|
||||
out.push(REC_ELEMENT);
|
||||
encode_string(prefix.as_bytes(), out)?;
|
||||
@@ -470,6 +477,13 @@ fn encode_attribute(
|
||||
out.push(0x0C + off);
|
||||
encode_multibyte_int31_to_nbfx(out, *id)?;
|
||||
}
|
||||
// Short-form: single-letter prefix + inline name. Records
|
||||
// 0x26..0x3F (PrefixAttribute_a..z).
|
||||
(Some(prefix), NbfxName::Inline(s)) if prefix_letter_offset(prefix).is_some() => {
|
||||
let off = prefix_letter_offset(prefix).unwrap_or(0);
|
||||
out.push(0x26 + off);
|
||||
encode_string(s.as_bytes(), out)?;
|
||||
}
|
||||
(Some(prefix), NbfxName::Inline(s)) => {
|
||||
out.push(REC_ATTRIBUTE);
|
||||
encode_string(prefix.as_bytes(), out)?;
|
||||
@@ -647,6 +661,18 @@ pub fn decode_tokens(
|
||||
name: NbfxName::Static(id),
|
||||
});
|
||||
}
|
||||
// PrefixElement_a..z: 0x5E..0x77 — single-letter prefix +
|
||||
// inline element name. WCF emits these on the response side
|
||||
// when the element name is not in either dictionary (e.g.
|
||||
// dynamically-named DataContract members).
|
||||
byte if (0x5E..=0x77).contains(&byte) => {
|
||||
let prefix_letter = char::from(b'a' + (byte - 0x5E));
|
||||
let name = decode_string(input, &mut cursor, "prefix-element-name")?;
|
||||
tokens.push(NbfxToken::Element {
|
||||
prefix: Some(prefix_letter.to_string()),
|
||||
name: NbfxName::Inline(name),
|
||||
});
|
||||
}
|
||||
REC_SHORT_ATTRIBUTE => {
|
||||
let name = decode_string(input, &mut cursor, "short-attribute")?;
|
||||
let value = decode_text_record(input, &mut cursor)?;
|
||||
@@ -697,6 +723,18 @@ pub fn decode_tokens(
|
||||
value,
|
||||
});
|
||||
}
|
||||
// PrefixAttribute_a..z: 0x26..0x3F — single-letter prefix +
|
||||
// inline attribute name + text-record value.
|
||||
byte if (0x26..=0x3F).contains(&byte) => {
|
||||
let prefix_letter = char::from(b'a' + (byte - 0x26));
|
||||
let name = decode_string(input, &mut cursor, "prefix-attribute-name")?;
|
||||
let value = decode_text_record(input, &mut cursor)?;
|
||||
tokens.push(NbfxToken::Attribute {
|
||||
prefix: Some(prefix_letter.to_string()),
|
||||
name: NbfxName::Inline(name),
|
||||
value,
|
||||
});
|
||||
}
|
||||
REC_SHORT_XMLNS_ATTRIBUTE => {
|
||||
let value_str = decode_string(input, &mut cursor, "default-xmlns-value")?;
|
||||
tokens.push(NbfxToken::DefaultNamespace {
|
||||
|
||||
@@ -171,6 +171,9 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
match record {
|
||||
NmfRecord::SizedEnvelope(reply_bytes) => {
|
||||
let decoded = decode_envelope(&reply_bytes, &mut self.read_dictionary)?;
|
||||
if let Some(fault) = detect_soap_fault(&decoded) {
|
||||
return Err(fault);
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
NmfRecord::Fault(message) => Err(ClientError::Fault(message)),
|
||||
@@ -604,6 +607,59 @@ async fn read_multibyte_int31_async<T: AsyncRead + Unpin>(
|
||||
usize::try_from(value).map_err(|_| ClientError::Nmf(NmfError::NegativeLength(value)))
|
||||
}
|
||||
|
||||
/// Inspect a `DecodedEnvelope` for a SOAP-1.2 `<s:Fault>` body and
|
||||
/// return a typed `ClientError::SoapFault` if found. Returns `None`
|
||||
/// for non-fault responses so the normal decode path runs.
|
||||
///
|
||||
/// WCF surfaces server-side exceptions as a `dispatcher/fault` action
|
||||
/// envelope wrapping `<s:Fault>`. The fault structure uses static dict
|
||||
/// ids (Reason=144, Text=146, Value=154 per `[MC-NBFS]`) which our
|
||||
/// `nbfs.rs` static table partially mismatches; rather than relying
|
||||
/// on element-name lookup, we accept any envelope whose Action header
|
||||
/// matches the canonical fault action template AND extract the
|
||||
/// human-readable reason as the longest `Chars` text in the body.
|
||||
/// The fault code is the first short `Chars` value (typically
|
||||
/// `s:Receiver` or `s:Sender`).
|
||||
fn detect_soap_fault(decoded: &crate::DecodedEnvelope) -> Option<ClientError> {
|
||||
use mxaccess_asb_nettcp::nbfx::{NbfxText, NbfxToken};
|
||||
|
||||
let action_is_fault = decoded
|
||||
.action
|
||||
.as_deref()
|
||||
.is_some_and(|a| a.contains("/fault") || a.ends_with(":fault"));
|
||||
if !action_is_fault {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Walk the body's text records. The fault Reason text is by far
|
||||
// the longest free-form Chars in a fault body; the Code/Subcode
|
||||
// values are shorter qname-style strings ("s:Receiver", "...:.
|
||||
// InternalServiceFault"). Sort accordingly.
|
||||
let mut all_chars: Vec<&str> = Vec::new();
|
||||
for tok in &decoded.body_tokens {
|
||||
if let NbfxToken::Text(NbfxText::Chars(s)) = tok {
|
||||
all_chars.push(s);
|
||||
}
|
||||
}
|
||||
let reason = all_chars
|
||||
.iter()
|
||||
.max_by_key(|s| s.len())
|
||||
.map(|s| (*s).to_string())
|
||||
.unwrap_or_else(|| "(no reason text)".to_string());
|
||||
// First Chars that looks like a SOAP fault code qname (contains a
|
||||
// colon or ends with "Fault").
|
||||
let code = all_chars
|
||||
.iter()
|
||||
.find(|s| s.contains(':') || s.ends_with("Fault"))
|
||||
.map(|s| (*s).to_string());
|
||||
let action = decoded.action.clone().unwrap_or_default();
|
||||
Some(ClientError::SoapFault {
|
||||
action,
|
||||
code,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- error type ----------------------------------------------------------
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -627,6 +683,19 @@ pub enum ClientError {
|
||||
AlreadyClosed,
|
||||
#[error("peer reported NMF fault: {0}")]
|
||||
Fault(String),
|
||||
/// SOAP-level fault inside a SizedEnvelope. WCF's
|
||||
/// `dispatcher/fault` action wraps a SOAP 1.2 `<s:Fault>` body
|
||||
/// when the service throws an unhandled exception. The action is
|
||||
/// preserved so callers can correlate (e.g.
|
||||
/// `.../dispatcher/fault` is the generic catch-all;
|
||||
/// `.../addressing/fault` indicates AddressFilterMismatch). The
|
||||
/// `reason` is the human-readable `<s:Reason><s:Text>` text.
|
||||
#[error("SOAP fault from peer (action={action}): {reason}")]
|
||||
SoapFault {
|
||||
action: String,
|
||||
code: Option<String>,
|
||||
reason: String,
|
||||
},
|
||||
#[error("peer closed the channel before sending a response")]
|
||||
PeerClosed,
|
||||
#[error("unexpected NMF record on response path: {0}")]
|
||||
|
||||
@@ -229,23 +229,64 @@ fn public_key_data_field(data: &[u8]) -> Vec<NbfxToken> {
|
||||
prefix: None,
|
||||
name: NbfxName::Inline("Data".to_string()),
|
||||
},
|
||||
// .NET's `PublicKey` class has
|
||||
// `[XmlType(Namespace = "http://asb.contracts.data/20111111")]`
|
||||
// (`AsbContracts.cs:350-362`). XmlSerializer emits an
|
||||
// `xmlns="..."` redeclaration on `<Data>` to switch from the
|
||||
// outer messages namespace into the data namespace. Without
|
||||
// this, the server's deserialiser fails and dispatches a
|
||||
// generic InternalServiceFault. Verified against .NET probe
|
||||
// wire capture.
|
||||
NbfxToken::DefaultNamespace {
|
||||
value: NbfxText::Chars("http://asb.contracts.data/20111111".to_string()),
|
||||
},
|
||||
NbfxToken::Text(NbfxText::Bytes(data.to_vec())),
|
||||
NbfxToken::EndElement,
|
||||
]
|
||||
}
|
||||
|
||||
/// `AuthenticationData` per `AsbContracts.cs:364-381`:
|
||||
///
|
||||
/// ```csharp
|
||||
/// [XmlType(Namespace = "http://asb.contracts.data/20111111")]
|
||||
/// public sealed class AuthenticationData {
|
||||
/// public byte[]? Data { get; set; }
|
||||
/// public byte[]? InitializationVector { get; set; }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Same data-namespace switch as `<PublicKey><Data>` — the `<Data>`
|
||||
/// element gets the `xmlns="...data/20111111"` redeclaration. The
|
||||
/// `<InitializationVector>` element is in the same data namespace
|
||||
/// (already-in-scope because of the prior `<Data>` redeclaration's
|
||||
/// `xmlns` lasts until end of `<AuthenticationData>`).
|
||||
fn authentication_data_fields(data: &[u8], iv: &[u8]) -> Vec<NbfxToken> {
|
||||
// The default-namespace declaration on `<Data>` only stays in
|
||||
// scope until `</Data>` closes. `<InitializationVector>` opens
|
||||
// afterwards and therefore needs its OWN xmlns redeclaration to
|
||||
// stay in the `http://asb.contracts.data/20111111` namespace
|
||||
// (matching `[XmlType]` on the `AuthenticationData` class). Without
|
||||
// the second redeclaration the IV element falls back to the parent
|
||||
// (messages) namespace and the server's XmlSerializer rejects the
|
||||
// request with a generic InternalServiceFault.
|
||||
let data_ns = "http://asb.contracts.data/20111111".to_string();
|
||||
vec![
|
||||
NbfxToken::Element {
|
||||
prefix: None,
|
||||
name: NbfxName::Inline("Data".to_string()),
|
||||
},
|
||||
NbfxToken::DefaultNamespace {
|
||||
value: NbfxText::Chars(data_ns.clone()),
|
||||
},
|
||||
NbfxToken::Text(NbfxText::Bytes(data.to_vec())),
|
||||
NbfxToken::EndElement,
|
||||
NbfxToken::Element {
|
||||
prefix: None,
|
||||
name: NbfxName::Inline("InitializationVector".to_string()),
|
||||
},
|
||||
NbfxToken::DefaultNamespace {
|
||||
value: NbfxText::Chars(data_ns),
|
||||
},
|
||||
NbfxToken::Text(NbfxText::Bytes(iv.to_vec())),
|
||||
NbfxToken::EndElement,
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user