Files
mxaccess/rust/crates/mxaccess-asb/src/xml_canonical.rs
T
Joseph Doherty fb40e4c20b
rust / build / test / clippy / fmt (push) Has been cancelled
[F34 partial] mxaccess-asb: fix collect_asbidata_payloads + add Active flag
Investigation via examples/asb-relay.rs middleman captured the full
S→C bytes of a working PublishResponse from the .NET probe against
MxDataProvider. Decoder fix verified by regression test against the
captured fixture; one further wire-format gap surfaced and is filed.

Closed in this commit:

1. collect_asbidata_payloads filtered out empty <ASBIData/> elements
   so positional payload[N] indexing collapsed when Status was
   empty-but-present. The wire form for PublishResponse is:
     <Status><ASBIData/></Status>          ← empty placeholder
     <Values><ASBIData>{bytes}</ASBIData></Values>
   Our decoder lost the positional info and read Values as Status,
   then panicked on the malformed parse. Fix: always push every
   <ASBIData> element (empty or not) so payloads[0]=Status and
   payloads[1]=Values stay aligned. New regression test
   tests/publish_capture.rs runs the full decode chain over the
   captured wire bytes (305-byte frame at
   tests/fixtures/publish-response-with-value.bin) and asserts
   values.len() == 1.

2. MinimalMonitoredItem.active: Option<bool> + new with_active()
   constructor. The .NET reference's MxAsbDataClient.AddMonitoredItems
   defaults to active: true (cs:441). Without <Active>true</Active>
   on the wire, MxDataProvider treats the subscription as inactive
   and Publish polls return empty Values. Both binary build and
   canonical XML emitters now conditionally emit <Active> when
   active.is_some(). Shared push_monitored_item_body helper
   eliminates the duplicate MonitoredItem encoder between
   AddMonitoredItems and DeleteMonitoredItems builders.

3. SampleInterval unit: clarified as **milliseconds** in
   MinimalMonitoredItem.sample_interval doc + the example
   (sample_interval_ticks → sample_interval_ms = 1000). Matches the
   .NET reference's `ulong sampleInterval = 1000` default.

Open: F34's deeper finding — `MonitoredItem`'s wire schema is
DataContract field-suffix names (`activeField`, `bufferedField`,
`itemField`, `sampleIntervalField`, etc., per the per-session NBFX
dictionary the .NET probe declares), NOT XmlSerializer property
names (`Active`, `Buffered`, `Item`, `SampleInterval`). Our binary
NBFX builder still uses the property names, so MxDataProvider
silently fails to register monitored items — successField=true with
a 0-length Status array. The fix needs a complete rebuild of
build_add_monitored_items_request_body and
build_delete_monitored_items_request_body to use the field-suffix
names plus emit the *Specified siblings (activeFieldSpecified,
idFieldSpecified, etc.) as their own elements. The HMAC canonical
XML side is unaffected (XmlSerializer naming is correct there;
verified byte-equal to .NET via the F28 fixtures). Detailed in
design/followups.md F34's "Open" section.

Live verification of the F34-partial bonus context:
  - Read still returns 99 end-to-end via canonical XML signing.
  - AddMonitoredItems still returns Status[0] = 0 items
    (server doesn't recognize our DataContract-misnamed payload).
  - Publish still returns 0 values (the F34-open consequence).
  - All other 13 canonical-XML signed ops succeed at the request
    level (no SOAP faults, no HMAC rejections).

Workspace: mxaccess-asb 95 → 96 (+1 capture-driven decoder test);
default-feature clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:49:11 -04:00

953 lines
36 KiB
Rust

//! 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;
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 = "<?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);
}
})
}
/// `<ReadRequest>` per `AsbContracts.cs:161-167`. Same shape as
/// `RegisterItemsRequest` but without `RequireId` / `RegisterOnly`.
pub fn emit_read_request_xml(
validator: &ConnectionValidator,
items: &[ItemIdentity],
) -> Vec<u8> {
emit_top("ReadRequest", |s| {
emit_validator(s, validator);
for item in items {
emit_item_identity(s, item);
}
})
}
/// `<PublishWriteCompleteRequest>` 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<u8> {
emit_top("PublishWriteCompleteRequest", |s| {
emit_validator(s, validator);
})
}
/// `<CreateSubscriptionRequest>` 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<u8> {
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());
})
}
/// `<DeleteSubscriptionRequest>` per `AsbContracts.cs:232-237`.
/// Single primitive `<SubscriptionId>` long.
pub fn emit_delete_subscription_request_xml(
validator: &ConnectionValidator,
subscription_id: i64,
) -> Vec<u8> {
emit_top("DeleteSubscriptionRequest", |s| {
emit_validator(s, validator);
emit_invensys_text(s, " ", "SubscriptionId", &subscription_id.to_string());
})
}
/// `<PublishRequest>` per `AsbContracts.cs:287-292`. Same shape as
/// `DeleteSubscriptionRequest` (single primitive `<SubscriptionId>`).
pub fn emit_publish_request_xml(
validator: &ConnectionValidator,
subscription_id: i64,
) -> Vec<u8> {
emit_top("PublishRequest", |s| {
emit_validator(s, validator);
emit_invensys_text(s, " ", "SubscriptionId", &subscription_id.to_string());
})
}
/// `<WriteBasicRequest>` per `AsbContracts.cs:181-194`. `Items[]` +
/// `Values[]` (each [`MinimalWriteValue`] inlined as a `<Values>`
/// 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 (`<Count>0</Count>`).
/// `Comment` is fixed at `<Comment xsi:nil="true">` (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<u8> {
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());
})
}
/// `<AddMonitoredItemsRequest>` per `AsbContracts.cs:242-254`.
/// `SubscriptionId` + `Items[]` (each [`MinimalMonitoredItem`] inlined
/// as an `<Items>` 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<u8> {
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);
})
}
/// `<DeleteMonitoredItemsRequest>` 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<u8> {
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<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");
}
/// 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("</");
s.push_str(tag);
s.push_str(">\r\n");
}
/// Emit a `MinimalWriteValue` as a `<Values>` element with inlined
/// Value (Variant) + Status + Comment children. Mirrors the captured
/// `--dump-signed-xml WriteBasicRequest` shape:
///
/// ```xml
/// <Values>
/// <Value xmlns="urn:data.data.asb.iom:2"> ... variant ... </Value>
/// <Status xmlns="urn:data.data.asb.iom:2"><Count>0</Count></Status>
/// <Comment xsi:nil="true" xmlns="urn:data.data.asb.iom:2" />
/// </Values>
/// ```
///
/// XmlSerializer flattens each `WriteValue` array element into a
/// `<Values>` 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(" <Values>\r\n");
// <Value> — 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(" <Value xmlns=\"");
s.push_str(IOM_DATA_NS);
s.push_str("\">\r\n");
emit_idata_variant(
s,
" ",
value.value.type_id,
value.value.length,
&value.value.payload,
);
s.push_str(" </Value>\r\n");
// <Status xmlns="iom:2"><Count>0</Count></Status>. AsbStatus's
// field doesn't carry [XmlType(Namespace)], so Count inherits
// the parent iom:2 redeclaration that the wrapper added.
s.push_str(" <Status xmlns=\"");
s.push_str(IOM_DATA_NS);
s.push_str("\">\r\n <Count>0</Count>\r\n </Status>\r\n");
// <Comment xsi:nil="true" xmlns="iom:2" /> — Comment is
// [XmlElement(IsNullable = true)] string?; default-null serialises
// as the xsi:nil + xmlns redeclaration form.
s.push_str(" <Comment xsi:nil=\"true\" xmlns=\"");
s.push_str(IOM_DATA_NS);
s.push_str("\" />\r\n");
s.push_str(" </Values>\r\n");
}
/// Emit a `MinimalMonitoredItem` as an `<Items>` 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(" <Items>\r\n");
// <Item xmlns="iom:2"> ... 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());
// <Active> 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(" </Items>\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("</");
s.push_str(wrapper);
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("</");
s.push_str(tag);
s.push_str(">\r\n");
}
/// Inline `[XmlElement(IsNullable = true)]` string. None →
/// `<Tag xsi:nil="true" />`; Some("") → `<Tag />`; Some(s) →
/// `<Tag>s</Tag>`. 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("</");
s.push_str(tag);
s.push_str(">\r\n");
}
}
}
/// Emit a Variant's children — `<Type>`, `<Length>`, `<Payload>` —
/// each carrying the `IDATA_DATA_NS` redeclaration (since
/// `Variant.[XmlType(Namespace)]` is `http://asb.contracts.idata.data/20111111`).
/// `length == 0` collapses Payload to `<Payload xsi:nil="true" xmlns="..." />`
/// 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("<Type xmlns=\"");
s.push_str(IDATA_DATA_NS);
s.push_str("\">");
s.push_str(&type_id.to_string());
s.push_str("</Type>\r\n");
s.push_str(indent);
s.push_str("<Length xmlns=\"");
s.push_str(IDATA_DATA_NS);
s.push_str("\">");
s.push_str(&length.to_string());
s.push_str("</Length>\r\n");
if length == 0 {
s.push_str(indent);
s.push_str("<Payload xsi:nil=\"true\" xmlns=\"");
s.push_str(IDATA_DATA_NS);
s.push_str("\" />\r\n");
} else {
s.push_str(indent);
s.push_str("<Payload xmlns=\"");
s.push_str(IDATA_DATA_NS);
s.push_str("\">");
s.push_str(&base64_encode(payload));
s.push_str("</Payload>\r\n");
}
}
/// Emit a default-shape Variant wrapper (`<Tag xmlns="iom:2">` 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("</");
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("&lt;"),
'>' => out.push_str("&gt;"),
'&' => out.push_str("&amp;"),
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);
}
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 `&lt;` and `&amp;`. 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 &lt; b &amp; c &gt; 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,
);
}
}