//! 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)]` → //! ``. Empty string → self-closing //! ``. //! 6. `*Specified` pattern: `XxxSpecified = true` triggers `` 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: `` (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; use crate::operations::{MinimalMonitoredItem, MinimalWriteValue}; 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"; /// Variant's per-type namespace (`[XmlType(Namespace = ...)]` on /// `Variant` per `AsbContracts.cs`). Children of a Variant — Type, /// Length, Payload — get this xmlns redeclaration. const IDATA_DATA_NS: &str = "http://asb.contracts.idata.data/20111111"; const XSI_NS: &str = "http://www.w3.org/2001/XMLSchema-instance"; const XSD_NS: &str = "http://www.w3.org/2001/XMLSchema"; const HEADER: &str = "\r\n"; // ---- public emitters ----------------------------------------------------- /// `` per `AsbContracts.cs:102-107`. pub fn emit_authenticate_me_xml( validator: &ConnectionValidator, consumer_data_b64: &str, consumer_iv_b64: &str, ) -> Vec { emit_top("AuthenticateMe", |s| { emit_validator(s, validator); emit_authentication_data_field(s, "ConsumerAuthenticationData", consumer_data_b64, consumer_iv_b64); }) } /// `` 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 { emit_top("Disconnect", |s| { emit_validator(s, validator); emit_authentication_data_field(s, "ConsumerAuthenticationData", consumer_data_b64, consumer_iv_b64); }) } /// `` per `AsbContracts.cs:116-117`. Empty body — only the /// inherited `ConnectionValidator` header. pub fn emit_keep_alive_xml(validator: &ConnectionValidator) -> Vec { emit_top("KeepAlive", |s| { emit_validator(s, validator); }) } /// `` 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 { 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); }) } /// `` 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 { emit_top("UnregisterItemsRequest", |s| { emit_validator(s, validator); for item in items { emit_item_identity(s, item); } }) } /// `` per `AsbContracts.cs:161-167`. Same shape as /// `RegisterItemsRequest` but without `RequireId` / `RegisterOnly`. pub fn emit_read_request_xml( validator: &ConnectionValidator, items: &[ItemIdentity], ) -> Vec { emit_top("ReadRequest", |s| { emit_validator(s, validator); for item in items { emit_item_identity(s, item); } }) } /// `` per `AsbContracts.cs:204-205`. /// Empty body — same shape as `KeepAlive`, just a different wrapper /// element. pub fn emit_publish_write_complete_request_xml(validator: &ConnectionValidator) -> Vec { emit_top("PublishWriteCompleteRequest", |s| { emit_validator(s, validator); }) } /// `` per `AsbContracts.cs:215-223`. /// `MaxQueueSize` (`long`) + `SampleInterval` (`ulong`) — both stay /// in the parent `urn:invensys.schemas` namespace (no per-element /// xmlns redeclaration; the type doesn't carry `[XmlType(Namespace)]` /// because both fields are primitives). pub fn emit_create_subscription_request_xml( validator: &ConnectionValidator, max_queue_size: i64, sample_interval: u64, ) -> Vec { emit_top("CreateSubscriptionRequest", |s| { emit_validator(s, validator); emit_invensys_text(s, " ", "MaxQueueSize", &max_queue_size.to_string()); emit_invensys_text(s, " ", "SampleInterval", &sample_interval.to_string()); }) } /// `` per `AsbContracts.cs:232-237`. /// Single primitive `` long. pub fn emit_delete_subscription_request_xml( validator: &ConnectionValidator, subscription_id: i64, ) -> Vec { emit_top("DeleteSubscriptionRequest", |s| { emit_validator(s, validator); emit_invensys_text(s, " ", "SubscriptionId", &subscription_id.to_string()); }) } /// `` per `AsbContracts.cs:287-292`. Same shape as /// `DeleteSubscriptionRequest` (single primitive ``). pub fn emit_publish_request_xml( validator: &ConnectionValidator, subscription_id: i64, ) -> Vec { emit_top("PublishRequest", |s| { emit_validator(s, validator); emit_invensys_text(s, " ", "SubscriptionId", &subscription_id.to_string()); }) } /// `` per `AsbContracts.cs:181-194`. `Items[]` + /// `Values[]` (each [`MinimalWriteValue`] inlined as a `` /// element with Value/Status/Comment children) + `WriteHandle` /// (`uint`). /// /// `MinimalWriteValue` only carries the inner `Variant`; the optional /// ArrayElementIndex / HasQT / Timestamp fields are `*Specified`-gated /// and never emit when the consumer-visible value is the default. /// `Status` is fixed at the empty-AsbStatus shape (`0`). /// `Comment` is fixed at `` (None — matches /// the captured fixture and the .NET default for `string? Comment;`). pub fn emit_write_basic_request_xml( validator: &ConnectionValidator, items: &[ItemIdentity], values: &[MinimalWriteValue], write_handle: u32, ) -> Vec { emit_top("WriteBasicRequest", |s| { emit_validator(s, validator); for item in items { emit_item_identity(s, item); } for value in values { emit_write_value(s, value); } emit_invensys_text(s, " ", "WriteHandle", &write_handle.to_string()); }) } /// `` per `AsbContracts.cs:242-254`. /// `SubscriptionId` + `Items[]` (each [`MinimalMonitoredItem`] inlined /// as an `` element with Item/SampleInterval/ValueDeadband/ /// UserData/Buffered children) + `RequireId`. pub fn emit_add_monitored_items_request_xml( validator: &ConnectionValidator, subscription_id: i64, items: &[MinimalMonitoredItem], require_id: bool, ) -> Vec { emit_top("AddMonitoredItemsRequest", |s| { emit_validator(s, validator); emit_invensys_text(s, " ", "SubscriptionId", &subscription_id.to_string()); for item in items { emit_monitored_item(s, item); } emit_invensys_bool(s, " ", "RequireId", require_id); }) } /// `` per `AsbContracts.cs:268-277`. /// Same as `AddMonitoredItemsRequest` minus the trailing `RequireId`. pub fn emit_delete_monitored_items_request_xml( validator: &ConnectionValidator, subscription_id: i64, items: &[MinimalMonitoredItem], ) -> Vec { emit_top("DeleteMonitoredItemsRequest", |s| { emit_validator(s, validator); emit_invensys_text(s, " ", "SubscriptionId", &subscription_id.to_string()); for item in items { emit_monitored_item(s, item); } }) } // ---- internal helpers ---------------------------------------------------- fn emit_top(class_name: &str, body: F) -> Vec { 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.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 /// ``. After signing they /// carry base64 content. Both forms must round-trip. fn emit_validator(s: &mut String, v: &ConnectionValidator) { s.push_str(" \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(" \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(" \r\n"); } /// `` 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 `` element is emitted. /// /// Null Name/ContextName → ``; /// empty-string ContextName → self-closing ``. fn emit_item_identity(s: &mut String, item: &ItemIdentity) { s.push_str(" \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(" \r\n"); } /// Emit a `byte[]` field in the data namespace. Empty bytes (empty /// base64 string) → self-closing ``; non-empty → /// `b64`. 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 `value\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("\r\n"); } /// Emit `value\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("\r\n"); } /// Emit a string-typed `[XmlElement(IsNullable = true)]` field. Three /// cases per the captured fixtures: /// * `None` → `\r\n` /// * `Some("")` → `\r\n` /// * `Some(s)` → `s\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("\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("\r\n"); } /// Emit a text-bearing element in the default invensys namespace /// (no xmlns redeclaration). Used for primitive int / long / uint /// fields (`MaxQueueSize`, `SampleInterval`, `SubscriptionId`, /// `WriteHandle`). fn emit_invensys_text(s: &mut String, indent: &str, tag: &str, value: &str) { s.push_str(indent); s.push('<'); s.push_str(tag); s.push('>'); write_xml_escaped_text(s, value); s.push_str("\r\n"); } /// Emit a `MinimalWriteValue` as a `` element with inlined /// Value (Variant) + Status + Comment children. Mirrors the captured /// `--dump-signed-xml WriteBasicRequest` shape: /// /// ```xml /// /// ... variant ... /// 0 /// /// /// ``` /// /// XmlSerializer flattens each `WriteValue` array element into a /// `` wrapper (per `[XmlElement("Values")]` on /// `WriteValue[]?`), then emits each child field with the /// `WriteValue.[XmlType(Namespace = "urn:data.data.asb.iom:2")]` /// redeclaration on the outermost child of each. fn emit_write_value(s: &mut String, value: &MinimalWriteValue) { s.push_str(" \r\n"); // — wraps the inner Variant. The Value element itself // gets the iom:2 redeclaration; its children (Type/Length/Payload) // get the idata namespace. s.push_str(" \r\n"); emit_idata_variant( s, " ", value.value.type_id, value.value.length, &value.value.payload, ); s.push_str(" \r\n"); // 0. AsbStatus's // field doesn't carry [XmlType(Namespace)], so Count inherits // the parent iom:2 redeclaration that the wrapper added. s.push_str(" \r\n 0\r\n \r\n"); // — Comment is // [XmlElement(IsNullable = true)] string?; default-null serialises // as the xsi:nil + xmlns redeclaration form. s.push_str(" \r\n"); s.push_str(" \r\n"); } /// Emit a `MinimalMonitoredItem` as an `` element with inlined /// Item / SampleInterval / ValueDeadband / UserData / Buffered /// children. Mirrors the `--dump-signed-xml AddMonitoredItemsRequest` /// fixture. /// /// Fields not on `MinimalMonitoredItem` (Active / TimeDeadband) are /// `*Specified`-gated and never emit when unset. `ValueDeadband` and /// `UserData` always emit because they are non-nullable `Variant` /// structs — XmlSerializer always serialises them with their default /// `Type=0 Length=0 Payload=nil` shape. fn emit_monitored_item(s: &mut String, item: &MinimalMonitoredItem) { s.push_str(" \r\n"); // ... ItemIdentity children, no per-child // xmlns redeclaration since they're already in iom:2. emit_inline_item_identity(s, " ", "Item", &item.item); emit_iom_text(s, " ", "SampleInterval", &item.sample_interval.to_string()); // is *Specified-gated; emit only when the consumer // opted in (None → not on the wire). MxDataProvider's // Publish path requires Active=true to actually deliver values // — F34, verified live 2026-05-06. if let Some(active) = item.active { emit_iom_text(s, " ", "Active", if active { "true" } else { "false" }); } // ValueDeadband + UserData: default-Variant shape (type=0, // length=0, payload nil). emit_iom_default_variant(s, " ", "ValueDeadband"); emit_iom_default_variant(s, " ", "UserData"); emit_iom_text(s, " ", "Buffered", if item.buffered { "true" } else { "false" }); s.push_str(" \r\n"); } /// Emit an `ItemIdentity` *as a child element of a MonitoredItem* — /// the wrapper carries the iom:2 namespace redeclaration once, and /// children (Type/ReferenceType/Name/ContextName/Id) inherit. Differs /// from [`emit_item_identity`] which is the top-level form where each /// child gets its own redeclaration. fn emit_inline_item_identity(s: &mut String, indent: &str, wrapper: &str, item: &ItemIdentity) { s.push_str(indent); s.push('<'); s.push_str(wrapper); s.push_str(" xmlns=\""); s.push_str(IOM_DATA_NS); s.push_str("\">\r\n"); let inner_indent = format!("{indent} "); emit_inline_text(s, &inner_indent, "Type", &item.kind.to_string()); emit_inline_text(s, &inner_indent, "ReferenceType", &item.reference_type.to_string()); emit_inline_optional_string(s, &inner_indent, "Name", item.name.as_deref()); emit_inline_optional_string(s, &inner_indent, "ContextName", item.context_name.as_deref()); if item.id_specified { emit_inline_text(s, &inner_indent, "Id", &item.id.to_string()); } s.push_str(indent); s.push_str("\r\n"); } /// Inline text element — no xmlns redeclaration (consumer is already /// inside an xmlns-scoped wrapper). fn emit_inline_text(s: &mut String, indent: &str, tag: &str, value: &str) { s.push_str(indent); s.push('<'); s.push_str(tag); s.push('>'); write_xml_escaped_text(s, value); s.push_str("\r\n"); } /// Inline `[XmlElement(IsNullable = true)]` string. None → /// ``; Some("") → ``; Some(s) → /// `s`. Differs from [`emit_iom_optional_string`] in /// omitting the xmlns redeclaration. fn emit_inline_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 => s.push_str(" xsi:nil=\"true\" />\r\n"), Some("") => s.push_str(" />\r\n"), Some(text) => { s.push('>'); write_xml_escaped_text(s, text); s.push_str("\r\n"); } } } /// Emit a Variant's children — ``, ``, `` — /// each carrying the `IDATA_DATA_NS` redeclaration (since /// `Variant.[XmlType(Namespace)]` is `http://asb.contracts.idata.data/20111111`). /// `length == 0` collapses Payload to `` /// matching the captured shape for default-Variant fields. fn emit_idata_variant(s: &mut String, indent: &str, type_id: u16, length: i32, payload: &[u8]) { s.push_str(indent); s.push_str(""); s.push_str(&type_id.to_string()); s.push_str("\r\n"); s.push_str(indent); s.push_str(""); s.push_str(&length.to_string()); s.push_str("\r\n"); if length == 0 { s.push_str(indent); s.push_str("\r\n"); } else { s.push_str(indent); s.push_str(""); s.push_str(&base64_encode(payload)); s.push_str("\r\n"); } } /// Emit a default-shape Variant wrapper (`` with /// Type=0 Length=0 Payload-nil children). Used for `ValueDeadband` / /// `UserData` inside MonitoredItem. fn emit_iom_default_variant(s: &mut String, indent: &str, tag: &str) { s.push_str(indent); s.push('<'); s.push_str(tag); s.push_str(" xmlns=\""); s.push_str(IOM_DATA_NS); s.push_str("\">\r\n"); let inner_indent = format!("{indent} "); emit_idata_variant(s, &inner_indent, 0, 0, &[]); s.push_str(indent); 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 { 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 = (0u8..16).collect(); let iv: Vec = (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: /// `` (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); } fn pinned_sample_item() -> ItemIdentity { ItemIdentity { kind: 0, reference_type: 1, name: Some("TestChildObject.TestInt".to_string()), context_name: Some(String::new()), id: 0, id_specified: false, } } fn pinned_sample_item_by_id() -> ItemIdentity { ItemIdentity { kind: 1, reference_type: 1, name: None, context_name: None, id: 0xCAFE_BABE_DEAD_BEEFu64, id_specified: true, } } /// `0x1234_5678_9abc_def0` — same `SampleSubscriptionId` the .NET /// probe pins. Decimal 1311768467463790320. const PINNED_SUB_ID: i64 = 0x1234_5678_9abc_def0; #[test] fn read_request_matches_dotnet_fixture() { let validator = pinned_validator(); let item = pinned_sample_item(); let actual = emit_read_request_xml(&validator, &[item]); let expected = fixture("read-request.xml"); assert_eq_bytes("read-request", &actual, &expected); } #[test] fn publish_write_complete_request_matches_dotnet_fixture() { let validator = pinned_validator(); let actual = emit_publish_write_complete_request_xml(&validator); let expected = fixture("publish-write-complete-request.xml"); assert_eq_bytes("publish-write-complete-request", &actual, &expected); } #[test] fn create_subscription_request_matches_dotnet_fixture() { let validator = pinned_validator(); let actual = emit_create_subscription_request_xml(&validator, 100, 1000); let expected = fixture("create-subscription-request.xml"); assert_eq_bytes("create-subscription-request", &actual, &expected); } #[test] fn delete_subscription_request_matches_dotnet_fixture() { let validator = pinned_validator(); let actual = emit_delete_subscription_request_xml(&validator, PINNED_SUB_ID); let expected = fixture("delete-subscription-request.xml"); assert_eq_bytes("delete-subscription-request", &actual, &expected); } #[test] fn publish_request_matches_dotnet_fixture() { let validator = pinned_validator(); let actual = emit_publish_request_xml(&validator, PINNED_SUB_ID); let expected = fixture("publish-request.xml"); assert_eq_bytes("publish-request", &actual, &expected); } #[test] fn write_basic_request_matches_dotnet_fixture() { use mxaccess_codec::AsbVariant; let validator = pinned_validator(); let item = pinned_sample_item(); let value = MinimalWriteValue::new(AsbVariant::from_i32(42)); let actual = emit_write_basic_request_xml(&validator, &[item], &[value], 0xDEAD_BEEFu32); let expected = fixture("write-basic-request.xml"); assert_eq_bytes("write-basic-request", &actual, &expected); } #[test] fn add_monitored_items_request_matches_dotnet_fixture() { let validator = pinned_validator(); let item = MinimalMonitoredItem::new(pinned_sample_item(), 1000); let actual = emit_add_monitored_items_request_xml(&validator, PINNED_SUB_ID, &[item], true); let expected = fixture("add-monitored-items-request.xml"); assert_eq_bytes("add-monitored-items-request", &actual, &expected); } #[test] fn delete_monitored_items_request_matches_dotnet_fixture() { let validator = pinned_validator(); let item = MinimalMonitoredItem::new(pinned_sample_item_by_id(), 1000); let actual = emit_delete_monitored_items_request_xml(&validator, PINNED_SUB_ID, &[item]); let expected = fixture("delete-monitored-items-request.xml"); assert_eq_bytes("delete-monitored-items-request", &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, ); } }