[M5] mxaccess-asb: F28 canonical-XML signing wired + registry-driven DH params
Adds `xml_canonical` module that emits XmlSerializer-compatible canonical XML for the five primary `ConnectedRequest` shapes (AuthenticateMe, Disconnect, KeepAlive, RegisterItemsRequest, UnregisterItemsRequest). Six fixture-comparison tests verify byte-exact match against captured .NET output, including the empty-MAC-IV variant that the live signing flow uses (`authenticate-me-empty-mac-iv.xml`, 896 bytes; new `emit_data_ns_byte_array` helper picks self-closing form for empty byte[]). Plumbing: `AsbAuthenticator::peek_next_message_number` exposes the pre-allocated message number; `AsbClient::send_signed_envelope[_one_way]` gain an `xml_for_signing: Option<&[u8]>` parameter. `connect`, `disconnect`, `keep_alive`, `register_items`, `unregister_items` now build a pre-signing `ConnectionValidator` (empty MAC + IV) + emit the canonical XML + pass the bytes through to HMAC. Other ops (Read, Write, Subscription) keep the legacy NBFX-bytes path until F28 expands to cover their request shapes. Live-bring-up wiring: - `tools/Get-AsbPassphrase.ps1` now exports `MX_ASB_DH_PRIME`, `MX_ASB_DH_GENERATOR`, `MX_ASB_DH_HASH_ALGORITHM` (always — even when empty, so the example can distinguish "no env var" from "registry says empty"), and `MX_ASB_DH_KEY_SIZE`. - `examples/asb-subscribe.rs` honours those env vars to override `CryptoParameters::defaults()`. Each AVEVA install picks its own DH group at provisioning time (768-bit prime is typical, vs the .NET reference's 1024-bit fallback that we previously hardcoded). Empty hashAlgorithm in the registry maps to `HashAlgorithm::Unrecognised`, matching `AsbSystemAuthenticator.CreateHmac:84-93` semantics where empty + forceHmac=true → HMAC-SHA1. - `MxAsbClient.Probe --dump-signed-xml` flag (added in earlier commit) now traces the live HMAC inputs (`asb.sign.xml-utf8-len`, `asb.sign.xml-b64`, `asb.sign.hmac-b64`, etc.) so the Rust port can diff its canonical XML against .NET's byte-for-byte for any live scenario (env-driven via `Action<string>? sharedTrace`). Wire-format alignment for `XmlSerializer` parity: - `ItemIdentity::default()` and `absolute_by_name` now use `Some(String::new())` for null-able strings (matches .NET's `CreateAbsoluteItem` setting `ContextName = string.Empty` not null). - `read_unicode_string` returns `Some(String::new())` for length-0 rather than `None` — mirrors .NET's `AsbBinary.ReadUnicodeString: return string.Empty for byteLength == 0`. Wire format genuinely cannot distinguish null from empty (both encode as 4 bytes of zero); callers that need to preserve the distinction MUST track it in their domain types before encoding. Live status (post-fix): Connect handshake completes end-to-end. The canonical XML our emitter produces matches .NET's structure byte-for- byte (verified by fixture comparison). DH prime/generator/hash now match the live registry values. Despite all this, AuthenticateMe still produces a generic dispatcher fault on the server — there's at least one more subtle wire-byte or crypto mismatch that needs isolation. F28 stays open with that note. Workspace: 709 unit tests pass (was 702 + 7 new xml_canonical tests). Clippy: clean (`-D warnings`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
//! Canonical XML emitter for `ConnectedRequest` HMAC signing.
|
||||
//!
|
||||
//! .NET's `AsbSystemAuthenticator.Sign` (`AsbSystemAuthenticator.cs:79`)
|
||||
//! HMACs `Encoding.UTF8.GetBytes(request.ToXml())` — the textual XML
|
||||
//! produced by `XmlSerializer.Serialize(...)` with default namespace
|
||||
//! `"urn:invensys.schemas"` (`AsbSerialization.cs:12-48`). For the
|
||||
//! server's recomputation of the MAC to match ours, this module must
|
||||
//! emit byte-identical UTF-8 bytes.
|
||||
//!
|
||||
//! ## Inferred XmlSerializer rules
|
||||
//!
|
||||
//! Captured from `MxAsbClient.Probe --dump-signed-xml` against
|
||||
//! deterministic field values; fixtures saved at
|
||||
//! `crates/mxaccess-asb/tests/fixtures/signed-xml/*.xml` (also see
|
||||
//! `tests/fixtures/signed-xml/README.md`):
|
||||
//!
|
||||
//! 1. Element name = class name (NOT `[MessageContract.WrapperName]`).
|
||||
//! 2. Field order = C# declaration order (inherited fields first; NOT
|
||||
//! `[MessageBodyMember.Order]`).
|
||||
//! 3. `[XmlType(Namespace = ...)]` on a field's TYPE causes per-child
|
||||
//! `xmlns="..."` redeclaration on the children, NOT on the wrapper.
|
||||
//! 4. `byte[]` → base64 text content. `Guid` → lowercase D-format.
|
||||
//! `ulong` → decimal. `bool` → `"true"`/`"false"`.
|
||||
//! 5. Null reference field with `[XmlElement(IsNullable = true)]` →
|
||||
//! `<Name xsi:nil="true" xmlns="..." />`. Empty string → self-closing
|
||||
//! `<Name xmlns="..." />`.
|
||||
//! 6. `*Specified` pattern: `XxxSpecified = true` triggers `<Xxx>` to be
|
||||
//! emitted with the int value; the `*Specified` field itself is
|
||||
//! `[XmlIgnore]`.
|
||||
//! 7. Self-closing elements use ` />` (space before `/>`).
|
||||
//! 8. CRLF line endings, 2-space indent, no trailing newline.
|
||||
//! 9. XML declaration: `<?xml version="1.0" encoding="utf-16"?>` (the
|
||||
//! `utf-16` literal is a .NET StringWriter default — actual byte
|
||||
//! encoding fed to HMAC is UTF-8).
|
||||
|
||||
use crate::ConnectionValidator;
|
||||
use crate::contracts::ItemIdentity;
|
||||
use crate::envelope::format_uuid;
|
||||
|
||||
const INVENSYS_NS: &str = "urn:invensys.schemas";
|
||||
const DATA_NS: &str = "http://asb.contracts.data/20111111";
|
||||
const IOM_DATA_NS: &str = "urn:data.data.asb.iom:2";
|
||||
const XSI_NS: &str = "http://www.w3.org/2001/XMLSchema-instance";
|
||||
const XSD_NS: &str = "http://www.w3.org/2001/XMLSchema";
|
||||
|
||||
const HEADER: &str = "<?xml version=\"1.0\" encoding=\"utf-16\"?>\r\n";
|
||||
|
||||
// ---- public emitters -----------------------------------------------------
|
||||
|
||||
/// `<AuthenticateMe>` per `AsbContracts.cs:102-107`.
|
||||
pub fn emit_authenticate_me_xml(
|
||||
validator: &ConnectionValidator,
|
||||
consumer_data_b64: &str,
|
||||
consumer_iv_b64: &str,
|
||||
) -> Vec<u8> {
|
||||
emit_top("AuthenticateMe", |s| {
|
||||
emit_validator(s, validator);
|
||||
emit_authentication_data_field(s, "ConsumerAuthenticationData", consumer_data_b64, consumer_iv_b64);
|
||||
})
|
||||
}
|
||||
|
||||
/// `<Disconnect>` per `AsbContracts.cs:109-114`. Same shape as
|
||||
/// AuthenticateMe — both have a single `ConsumerAuthenticationData`
|
||||
/// body field plus the inherited `ConnectionValidator` header.
|
||||
pub fn emit_disconnect_xml(
|
||||
validator: &ConnectionValidator,
|
||||
consumer_data_b64: &str,
|
||||
consumer_iv_b64: &str,
|
||||
) -> Vec<u8> {
|
||||
emit_top("Disconnect", |s| {
|
||||
emit_validator(s, validator);
|
||||
emit_authentication_data_field(s, "ConsumerAuthenticationData", consumer_data_b64, consumer_iv_b64);
|
||||
})
|
||||
}
|
||||
|
||||
/// `<KeepAlive>` per `AsbContracts.cs:116-117`. Empty body — only the
|
||||
/// inherited `ConnectionValidator` header.
|
||||
pub fn emit_keep_alive_xml(validator: &ConnectionValidator) -> Vec<u8> {
|
||||
emit_top("KeepAlive", |s| {
|
||||
emit_validator(s, validator);
|
||||
})
|
||||
}
|
||||
|
||||
/// `<RegisterItemsRequest>` per `AsbContracts.cs:119-131`. Body
|
||||
/// fields in declaration order: `Items`, `RequireId`, `RegisterOnly`.
|
||||
/// Each `Items` entry is a single `ItemIdentity` (XmlElement attribute
|
||||
/// renames the field to "Items").
|
||||
pub fn emit_register_items_request_xml(
|
||||
validator: &ConnectionValidator,
|
||||
items: &[ItemIdentity],
|
||||
require_id: bool,
|
||||
register_only: bool,
|
||||
) -> Vec<u8> {
|
||||
emit_top("RegisterItemsRequest", |s| {
|
||||
emit_validator(s, validator);
|
||||
for item in items {
|
||||
emit_item_identity(s, item);
|
||||
}
|
||||
emit_invensys_bool(s, " ", "RequireId", require_id);
|
||||
emit_invensys_bool(s, " ", "RegisterOnly", register_only);
|
||||
})
|
||||
}
|
||||
|
||||
/// `<UnregisterItemsRequest>` per `AsbContracts.cs:145-150`. Body
|
||||
/// has just the `Items` array (no `RequireId`/`RegisterOnly`).
|
||||
pub fn emit_unregister_items_request_xml(
|
||||
validator: &ConnectionValidator,
|
||||
items: &[ItemIdentity],
|
||||
) -> Vec<u8> {
|
||||
emit_top("UnregisterItemsRequest", |s| {
|
||||
emit_validator(s, validator);
|
||||
for item in items {
|
||||
emit_item_identity(s, item);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- internal helpers ----------------------------------------------------
|
||||
|
||||
fn emit_top<F: FnOnce(&mut String)>(class_name: &str, body: F) -> Vec<u8> {
|
||||
let mut s = String::with_capacity(1024);
|
||||
s.push_str(HEADER);
|
||||
s.push('<');
|
||||
s.push_str(class_name);
|
||||
s.push_str(" xmlns:xsi=\"");
|
||||
s.push_str(XSI_NS);
|
||||
s.push_str("\" xmlns:xsd=\"");
|
||||
s.push_str(XSD_NS);
|
||||
s.push_str("\" xmlns=\"");
|
||||
s.push_str(INVENSYS_NS);
|
||||
s.push_str("\">\r\n");
|
||||
body(&mut s);
|
||||
s.push_str("</");
|
||||
s.push_str(class_name);
|
||||
s.push('>');
|
||||
s.into_bytes()
|
||||
}
|
||||
|
||||
/// `ConnectionValidator` element. The wrapper element itself stays in
|
||||
/// the parent (urn:invensys.schemas) namespace because XmlSerializer
|
||||
/// only redeclares xmlns when it changes; the inherited
|
||||
/// `[XmlType(Namespace = "http://asb.contracts.data/20111111")]` (or
|
||||
/// equivalent inferred default) on the inner type causes EACH direct
|
||||
/// child to carry the data-ns redeclaration.
|
||||
///
|
||||
/// `MessageAuthenticationCode` and `SignatureInitializationVector` are
|
||||
/// `byte[]` fields. When the validator is being signed (NOT yet on the
|
||||
/// wire), they're empty `byte[]` and XmlSerializer emits self-closing
|
||||
/// `<MessageAuthenticationCode xmlns="..." />`. After signing they
|
||||
/// carry base64 content. Both forms must round-trip.
|
||||
fn emit_validator(s: &mut String, v: &ConnectionValidator) {
|
||||
s.push_str(" <ConnectionValidator>\r\n");
|
||||
emit_data_ns_text(s, " ", "ConnectionId", &format_uuid(&v.connection_id));
|
||||
emit_data_ns_text(s, " ", "MessageNumber", &v.message_number.to_string());
|
||||
emit_data_ns_byte_array(s, " ", "MessageAuthenticationCode", &v.mac_base64);
|
||||
emit_data_ns_byte_array(s, " ", "SignatureInitializationVector", &v.iv_base64);
|
||||
s.push_str(" </ConnectionValidator>\r\n");
|
||||
}
|
||||
|
||||
/// `AuthenticationData`-typed field (e.g. `ConsumerAuthenticationData`).
|
||||
/// The wrapper stays in `urn:invensys.schemas`; children Data + IV are
|
||||
/// in the data namespace per `[XmlType]` on `AuthenticationData`.
|
||||
fn emit_authentication_data_field(
|
||||
s: &mut String,
|
||||
field_name: &str,
|
||||
data_b64: &str,
|
||||
iv_b64: &str,
|
||||
) {
|
||||
s.push_str(" <");
|
||||
s.push_str(field_name);
|
||||
s.push_str(">\r\n");
|
||||
emit_data_ns_text(s, " ", "Data", data_b64);
|
||||
emit_data_ns_text(s, " ", "InitializationVector", iv_b64);
|
||||
s.push_str(" </");
|
||||
s.push_str(field_name);
|
||||
s.push_str(">\r\n");
|
||||
}
|
||||
|
||||
/// `<Items>` element holding one ItemIdentity. The wrapper is in
|
||||
/// urn:invensys.schemas; children get `xmlns="urn:data.data.asb.iom:2"`
|
||||
/// per `[XmlType(Namespace = "urn:data.data.asb.iom:2")]` on
|
||||
/// `ItemIdentity` (`AsbContracts.cs:534`).
|
||||
///
|
||||
/// Field order matches C# declaration: contextNameField, idField,
|
||||
/// idFieldSpecified, nameField, referenceTypeField, typeField — but
|
||||
/// XmlSerializer uses the public *property* declaration order which
|
||||
/// yields Type → ReferenceType → Name → ContextName → (Id) per the
|
||||
/// captured fixtures. `IdSpecified` is `[XmlIgnore]` so it never
|
||||
/// appears; when `IdSpecified == true` the `<Id>` element is emitted.
|
||||
///
|
||||
/// Null Name/ContextName → `<Name xsi:nil="true" xmlns="..." />`;
|
||||
/// empty-string ContextName → self-closing `<ContextName xmlns="..." />`.
|
||||
fn emit_item_identity(s: &mut String, item: &ItemIdentity) {
|
||||
s.push_str(" <Items>\r\n");
|
||||
emit_iom_text(s, " ", "Type", &item.kind.to_string());
|
||||
emit_iom_text(s, " ", "ReferenceType", &item.reference_type.to_string());
|
||||
emit_iom_optional_string(s, " ", "Name", item.name.as_deref());
|
||||
emit_iom_optional_string(s, " ", "ContextName", item.context_name.as_deref());
|
||||
if item.id_specified {
|
||||
emit_iom_text(s, " ", "Id", &item.id.to_string());
|
||||
}
|
||||
s.push_str(" </Items>\r\n");
|
||||
}
|
||||
|
||||
/// Emit a `byte[]` field in the data namespace. Empty bytes (empty
|
||||
/// base64 string) → self-closing `<Tag xmlns="..." />`; non-empty →
|
||||
/// `<Tag xmlns="...">b64</Tag>`. Mirrors XmlSerializer's behaviour
|
||||
/// for empty `byte[]` (verified via `--dump-signed-xml` with empty
|
||||
/// MAC/IV).
|
||||
fn emit_data_ns_byte_array(s: &mut String, indent: &str, tag: &str, value: &str) {
|
||||
if value.is_empty() {
|
||||
s.push_str(indent);
|
||||
s.push('<');
|
||||
s.push_str(tag);
|
||||
s.push_str(" xmlns=\"");
|
||||
s.push_str(DATA_NS);
|
||||
s.push_str("\" />\r\n");
|
||||
} else {
|
||||
emit_data_ns_text(s, indent, tag, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit `<Tag xmlns="DATA_NS">value</Tag>\r\n` with the given indent.
|
||||
fn emit_data_ns_text(s: &mut String, indent: &str, tag: &str, value: &str) {
|
||||
s.push_str(indent);
|
||||
s.push('<');
|
||||
s.push_str(tag);
|
||||
s.push_str(" xmlns=\"");
|
||||
s.push_str(DATA_NS);
|
||||
s.push_str("\">");
|
||||
write_xml_escaped_text(s, value);
|
||||
s.push_str("</");
|
||||
s.push_str(tag);
|
||||
s.push_str(">\r\n");
|
||||
}
|
||||
|
||||
/// Emit `<Tag xmlns="IOM_DATA_NS">value</Tag>\r\n`.
|
||||
fn emit_iom_text(s: &mut String, indent: &str, tag: &str, value: &str) {
|
||||
s.push_str(indent);
|
||||
s.push('<');
|
||||
s.push_str(tag);
|
||||
s.push_str(" xmlns=\"");
|
||||
s.push_str(IOM_DATA_NS);
|
||||
s.push_str("\">");
|
||||
write_xml_escaped_text(s, value);
|
||||
s.push_str("</");
|
||||
s.push_str(tag);
|
||||
s.push_str(">\r\n");
|
||||
}
|
||||
|
||||
/// Emit a string-typed `[XmlElement(IsNullable = true)]` field. Three
|
||||
/// cases per the captured fixtures:
|
||||
/// * `None` → `<Tag xsi:nil="true" xmlns="IOM_DATA_NS" />\r\n`
|
||||
/// * `Some("")` → `<Tag xmlns="IOM_DATA_NS" />\r\n`
|
||||
/// * `Some(s)` → `<Tag xmlns="IOM_DATA_NS">s</Tag>\r\n`
|
||||
fn emit_iom_optional_string(s: &mut String, indent: &str, tag: &str, value: Option<&str>) {
|
||||
s.push_str(indent);
|
||||
s.push('<');
|
||||
s.push_str(tag);
|
||||
match value {
|
||||
None => {
|
||||
// Note: xsi:nil first, THEN xmlns, per fixtures.
|
||||
s.push_str(" xsi:nil=\"true\" xmlns=\"");
|
||||
s.push_str(IOM_DATA_NS);
|
||||
s.push_str("\" />\r\n");
|
||||
}
|
||||
Some("") => {
|
||||
s.push_str(" xmlns=\"");
|
||||
s.push_str(IOM_DATA_NS);
|
||||
s.push_str("\" />\r\n");
|
||||
}
|
||||
Some(text) => {
|
||||
s.push_str(" xmlns=\"");
|
||||
s.push_str(IOM_DATA_NS);
|
||||
s.push_str("\">");
|
||||
write_xml_escaped_text(s, text);
|
||||
s.push_str("</");
|
||||
s.push_str(tag);
|
||||
s.push_str(">\r\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a `bool` field in the default invensys namespace (no xmlns
|
||||
/// redeclaration).
|
||||
fn emit_invensys_bool(s: &mut String, indent: &str, tag: &str, value: bool) {
|
||||
s.push_str(indent);
|
||||
s.push('<');
|
||||
s.push_str(tag);
|
||||
s.push('>');
|
||||
s.push_str(if value { "true" } else { "false" });
|
||||
s.push_str("</");
|
||||
s.push_str(tag);
|
||||
s.push_str(">\r\n");
|
||||
}
|
||||
|
||||
/// XML-escape characters that XmlSerializer escapes in text nodes.
|
||||
/// Only `<`, `>`, and `&` are emitted as entities by the .NET writer;
|
||||
/// quotes appear inside attribute values which we control directly,
|
||||
/// not in text content. (Verified via `XmlTextWriter.WriteString` —
|
||||
/// CRLF/TAB are passed through verbatim.)
|
||||
fn write_xml_escaped_text(out: &mut String, text: &str) {
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'&' => out.push_str("&"),
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode raw bytes as base64 in the form `XmlSerializer` emits for
|
||||
/// `byte[]` fields. Mirrors the inline encoder in
|
||||
/// `envelope::base64_encode` (kept private there); duplicated here to
|
||||
/// keep the xml_canonical module standalone.
|
||||
pub fn base64_encode(input: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let lookup = |idx: u32| ALPHABET.get((idx & 0x3F) as usize).copied().unwrap_or(b'=');
|
||||
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
|
||||
for chunk in input.chunks(3) {
|
||||
let b0 = u32::from(chunk.first().copied().unwrap_or(0));
|
||||
let b1 = u32::from(chunk.get(1).copied().unwrap_or(0));
|
||||
let b2 = u32::from(chunk.get(2).copied().unwrap_or(0));
|
||||
let triple = (b0 << 16) | (b1 << 8) | b2;
|
||||
out.push(lookup(triple >> 18) as char);
|
||||
out.push(lookup(triple >> 12) as char);
|
||||
out.push(if chunk.len() > 1 {
|
||||
lookup(triple >> 6) as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
out.push(if chunk.len() > 2 {
|
||||
lookup(triple) as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ConnectionValidator;
|
||||
|
||||
fn fixture(name: &str) -> Vec<u8> {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures/signed-xml")
|
||||
.join(name);
|
||||
std::fs::read(&path).unwrap_or_else(|e| {
|
||||
panic!("could not read fixture {}: {e}", path.display())
|
||||
})
|
||||
}
|
||||
|
||||
fn pinned_validator() -> ConnectionValidator {
|
||||
let mac: Vec<u8> = (0u8..16).collect();
|
||||
let iv: Vec<u8> = (16u8..32).collect();
|
||||
ConnectionValidator {
|
||||
connection_id: parse_pinned_guid(),
|
||||
message_number: 42,
|
||||
mac_base64: base64_encode(&mac),
|
||||
iv_base64: base64_encode(&iv),
|
||||
}
|
||||
}
|
||||
|
||||
/// `8cba964a-74c1-ef74-f6aa-761b3540191b` in .NET mixed-endian
|
||||
/// byte order — same value the .NET probe pins.
|
||||
fn parse_pinned_guid() -> [u8; 16] {
|
||||
// d1 = 0x8cba964a (LE) → bytes [4a, 96, ba, 8c]
|
||||
// d2 = 0x74c1 (LE) → bytes [c1, 74]
|
||||
// d3 = 0xef74 (LE) → bytes [74, ef]
|
||||
// d4 (BE) = f6 aa
|
||||
// d5 (BE) = 76 1b 35 40 19 1b
|
||||
[
|
||||
0x4a, 0x96, 0xba, 0x8c, 0xc1, 0x74, 0x74, 0xef, 0xf6, 0xaa, 0x76, 0x1b, 0x35, 0x40,
|
||||
0x19, 0x1b,
|
||||
]
|
||||
}
|
||||
|
||||
fn pinned_consumer_data_b64() -> String {
|
||||
// "deterministic-ciphertext-bytes" base64-encoded
|
||||
base64_encode(b"deterministic-ciphertext-bytes".as_slice())
|
||||
}
|
||||
|
||||
fn pinned_consumer_iv_b64() -> String {
|
||||
// "0123456789abcdef" base64-encoded
|
||||
base64_encode(b"0123456789abcdef".as_slice())
|
||||
}
|
||||
|
||||
fn pinned_disconnect_data_b64() -> String {
|
||||
base64_encode(b"disconnect-ciphertext".as_slice())
|
||||
}
|
||||
|
||||
/// The actual signing input has empty MAC + IV (the MAC is filled
|
||||
/// AFTER `request.ToXml()` produces the bytes that get HMAC'd). This
|
||||
/// fixture pins XmlSerializer's empty-byte-array behaviour:
|
||||
/// `<MessageAuthenticationCode xmlns="..." />` (self-closing) when
|
||||
/// `byte[] = []`. Without this round-trip, the live HMAC will not
|
||||
/// match the server's recomputation.
|
||||
#[test]
|
||||
fn authenticate_me_with_empty_mac_iv_matches_dotnet_fixture() {
|
||||
let validator = ConnectionValidator {
|
||||
connection_id: parse_pinned_guid(),
|
||||
message_number: 42,
|
||||
mac_base64: String::new(),
|
||||
iv_base64: String::new(),
|
||||
};
|
||||
let data = pinned_consumer_data_b64();
|
||||
let iv = pinned_consumer_iv_b64();
|
||||
let actual = emit_authenticate_me_xml(&validator, &data, &iv);
|
||||
let expected = fixture("authenticate-me-empty-mac-iv.xml");
|
||||
assert_eq_bytes("authenticate-me-empty-mac-iv", &actual, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticate_me_matches_dotnet_fixture() {
|
||||
let validator = pinned_validator();
|
||||
let data = pinned_consumer_data_b64();
|
||||
let iv = pinned_consumer_iv_b64();
|
||||
let actual = emit_authenticate_me_xml(&validator, &data, &iv);
|
||||
let expected = fixture("authenticate-me.xml");
|
||||
assert_eq_bytes("authenticate-me", &actual, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_matches_dotnet_fixture() {
|
||||
let validator = pinned_validator();
|
||||
let data = pinned_disconnect_data_b64();
|
||||
let iv = pinned_consumer_iv_b64();
|
||||
let actual = emit_disconnect_xml(&validator, &data, &iv);
|
||||
let expected = fixture("disconnect.xml");
|
||||
assert_eq_bytes("disconnect", &actual, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_alive_matches_dotnet_fixture() {
|
||||
let validator = pinned_validator();
|
||||
let actual = emit_keep_alive_xml(&validator);
|
||||
let expected = fixture("keep-alive.xml");
|
||||
assert_eq_bytes("keep-alive", &actual, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_items_matches_dotnet_fixture() {
|
||||
let validator = pinned_validator();
|
||||
let item = ItemIdentity {
|
||||
kind: 0,
|
||||
reference_type: 1,
|
||||
name: Some("TestChildObject.TestInt".to_string()),
|
||||
context_name: Some(String::new()),
|
||||
id: 0,
|
||||
id_specified: false,
|
||||
};
|
||||
let actual = emit_register_items_request_xml(&validator, &[item], true, false);
|
||||
let expected = fixture("register-items.xml");
|
||||
assert_eq_bytes("register-items", &actual, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_items_matches_dotnet_fixture() {
|
||||
let validator = pinned_validator();
|
||||
let item = ItemIdentity {
|
||||
kind: 1,
|
||||
reference_type: 1,
|
||||
name: None,
|
||||
context_name: None,
|
||||
id: 0xCAFE_BABE_DEAD_BEEFu64,
|
||||
id_specified: true,
|
||||
};
|
||||
let actual = emit_unregister_items_request_xml(&validator, &[item]);
|
||||
let expected = fixture("unregister-items.xml");
|
||||
assert_eq_bytes("unregister-items", &actual, &expected);
|
||||
}
|
||||
|
||||
/// XML escaping: feed a name with `<` and `&` and confirm the
|
||||
/// emitter produces `<` and `&`. Real wire never carries
|
||||
/// these characters in tag names, but this protects against future
|
||||
/// users-supplied-tag-name regressions.
|
||||
#[test]
|
||||
fn xml_escapes_text_content() {
|
||||
let mut s = String::new();
|
||||
write_xml_escaped_text(&mut s, "a < b & c > d");
|
||||
assert_eq!(s, "a < b & c > d");
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_eq_bytes(label: &str, actual: &[u8], expected: &[u8]) {
|
||||
if actual == expected {
|
||||
return;
|
||||
}
|
||||
let actual_str = String::from_utf8_lossy(actual);
|
||||
let expected_str = String::from_utf8_lossy(expected);
|
||||
let diverge = actual
|
||||
.iter()
|
||||
.zip(expected.iter())
|
||||
.take_while(|(a, e)| a == e)
|
||||
.count();
|
||||
let context_start = diverge.saturating_sub(40);
|
||||
let context_end_act = (diverge + 40).min(actual.len());
|
||||
let context_end_exp = (diverge + 40).min(expected.len());
|
||||
let actual_ctx = actual.get(context_start..context_end_act).unwrap_or(&[]);
|
||||
let expected_ctx = expected.get(context_start..context_end_exp).unwrap_or(&[]);
|
||||
panic!(
|
||||
"{label}: bytes differ at offset {diverge}\n actual len={} bytes\n expected len={} bytes\n actual context: {:?}\n expected ctx: {:?}\n full actual:\n{}\n full expected:\n{}",
|
||||
actual.len(),
|
||||
expected.len(),
|
||||
String::from_utf8_lossy(actual_ctx),
|
||||
String::from_utf8_lossy(expected_ctx),
|
||||
actual_str,
|
||||
expected_str,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user