diff --git a/design/followups.md b/design/followups.md index 0e50f15..713f590 100644 --- a/design/followups.md +++ b/design/followups.md @@ -52,11 +52,12 @@ move to `## Resolved` with a date + commit hash. 3. ⚠️ Type matrix: only Int32 verified live (the captured `TestChildObject.TestInt` tag). Bool / Float / Double / String / DateTime / Duration / arrays not yet exercised — pending one or more sample tags per type and an `asb-subscribe` extension that loops over them. F32 captures this expansion. 4. ✅ `cargo build --workspace` + `cargo test --workspace` (711 tests) + `cargo clippy --workspace -- -D warnings` all green. -**Remaining open work for full M5 closeout** (none are P0 blockers anymore): -- ~~F32~~: resolved (commit ``) via option (b) — three-type live coverage is the deployable maximum; missing types are Galaxy-provisioning-gated. -- **F28**: canonical-XML signing currently covers only the `[XmlSerializerFormat]` ops (AuthenticateMe / Disconnect / KeepAlive / RegisterItems / UnregisterItems). Read / Write / CreateSubscription / AddMonitoredItems / Publish / etc. still sign over NBFX wire bytes via the legacy fallback. Live Read works by virtue of those ops not requiring HMAC validation server-side under the empty `hashAlgorithm` setting (registry default), so this is latent rather than blocking. Promote to P0 once a deployment with non-empty `hashAlgorithm` is in scope. -- ~~F29~~: resolved (commit ``) — `nbfs.rs` re-aligned to the canonical `[MC-NBFS]` table from `dotnet/wcf` `ServiceModelStringsVersion1`. -- ~~F26 stream subscription~~: resolved (commit ``) — `AsbSession::subscribe(subscription_id)` returns an `AsbSubscription: Stream>` driven by an internal `tokio::spawn`'d publish-loop. Drop of the subscription aborts the loop. Per-`PublishResponse` `values` array is fanned out as individual stream items; transport errors are delivered as the final stream item before termination. Inner `publish_loop` helper is split out so it's testable in isolation against any closure-based fake `publish_fn`. 3 new tests pin: compile-time `Stream + Send + Unpin`, multi-batch + terminal-error round-trip, consumer-drop short-circuits the publisher. Workspace 718 → 721 tests. +**M5 closeout status: all sub-followups resolved.** +- ~~F32~~: resolved via option (b) — three-type live coverage is the deployable maximum; missing types are Galaxy-provisioning-gated. +- ~~F28~~: resolved (commit ``) — all 13 `ConnectedRequest` shapes now sign over byte-identical canonical XML. The 8 remaining ops (Read / Write / Subscription family / etc.) ported in commit ``, fixture-tested byte-equal vs the .NET probe's `--dump-signed-xml` output. Live read still passes after the cutover. Hardens the transport against `hashAlgorithm`-non-empty deployments. +- ~~F29~~: resolved — `nbfs.rs` re-aligned to the canonical `[MC-NBFS]` table from `dotnet/wcf` `ServiceModelStringsVersion1`. +- ~~F26 stream subscription~~: resolved — `AsbSession::subscribe(subscription_id)` returns an `AsbSubscription: Stream>` driven by an internal publish-loop. +- ~~F33~~: resolved — InvalidConnectionId tolerance pattern propagated to all 8 remaining response decoders + the F26 stream's publish-loop terminates cleanly on server-side rejection. **Cumulative execution log.** F19 + F23 (`ed17c07`); F24 (`7611d9e`); F20 (`9dfd193`); F22 (`43c10a1`); F21 (`5f98558`); F25 step 1 (`25dbd8d`); F25 step 2 (`a2b8989`); F25 step 3 (`c4bf0a0`); F25 step 4 (`1e59249`); F25 step 5 (`9b8133f`); F25 step 6 (`321b796`); F25 step 7 (`1b1ee1e`); F26 step 1 (`8a0f92b`); F26 step 2 (`14bb529`); example rewrite (`c6570dc`); F25 step 8 (`b543eb1`); F25 step 9 (`0441a2e`); F25 step 10 (`9876b4e`); F26 step 3 (``); **F25 live-bring-up reconciliation** (this commit): - F25 live-bring-up reconciliation: live `asb-subscribe` + `asb-relay` (TCP middleman) capture-and-diff against AVEVA's MxDataProvider on Windows. Five concrete fixes landed: @@ -73,32 +74,6 @@ move to `## Resolved` with a date + commit hash. For the per-step body of every line listed in the cumulative execution log, see the matching commit message — each commit is a single F-number step with its own scope, fixtures, test count delta, and follow-up notes. The detailed per-step write-ups previously inlined here added little beyond what `git show ` provides. -### F28 — Canonical XML serialiser for `ConnectedRequest` signing (matches `XmlSerializer.Serialize` byte-for-byte) -**Status: PARTIALLY RESOLVED.** The five `[XmlSerializerFormat]` ops (AuthenticateMe, Disconnect, KeepAlive, RegisterItems, UnregisterItems) plus the per-action `ValidatorWireFormat` selector + DH-params-from-registry + dynamic-dict id management all landed in commits `f14580e` / `104efc4`. Live AuthenticateMe + RegisterItems work end-to-end (commit `9063f10`). Read / Write / CreateSubscription / AddMonitoredItems / Publish / DeleteMonitored / DeleteSubscription / PublishWriteComplete still sign over NBFX wire bytes via the legacy fallback; works in practice because the live registry has empty `hashAlgorithm` (no HMAC required for the unforced-MAC path), but will break under any deployment that sets a real algorithm. **Severity now P2** — promote back to P0 if a hashAlgorithm-non-empty environment is in scope. -**Severity:** P0 — blocks every signed ASB operation (AuthenticateMe, RegisterItems, all data-plane RPCs). -**Source:** F25 live-bring-up; `AsbSystemAuthenticator.cs:79` + `AsbSerialization.cs:12-48`. -**Why deferred:** `AsbSystemAuthenticator.Sign` HMACs `Encoding.UTF8.GetBytes(request.ToXml())` — the XML text produced by .NET's `XmlSerializer.Serialize(writer, value)` with `XmlSerializerNamespaces` = `"urn:invensys.schemas"`, then re-parsed via `XDocument.Load` and re-saved to normalise xmlns attribute ordering (xsi before xsd; see `AsbSerialization.cs:36-47`). The HMAC must match the server's recomputation, which uses the same XmlSerializer on the deserialised request — so the Rust port has to produce byte-identical XML. We currently HMAC the NBFX wire bytes of the unsigned envelope, which never matches. - -**Resolves when:** A canonical XmlSerializer-compatible emitter lands in `mxaccess-asb` (probably `crates/mxaccess-asb/src/xml_canonical.rs`). Scope per request type: `AuthenticateMe`, `Disconnect`, `KeepAlive`, `RegisterItemsRequest`, `UnregisterItemsRequest`, `ReadRequest`, `WriteBasicRequest`, `PublishWriteCompleteRequest`, `CreateSubscriptionRequest`, `DeleteSubscriptionRequest`, `AddMonitoredItemsRequest`, `DeleteMonitoredItemsRequest`, `PublishRequest`. Each derives its XML form from the `[MessageContract] / [MessageBodyMember(Order = N, Namespace = ...)]` attributes plus per-type `[XmlType(Namespace = ...)]` on `AuthenticationData` / `PublicKey`. The `request_xml_utf8` argument to `AsbAuthenticator::sign` is already wired correctly — only the producer is missing. Once HMAC matches, the existing `ConnectionValidator` header path (`mac` + `iv` base64 round-trip) is already validated by the F23 unit tests. **Resolves**: F25 live AuthenticateMe + RegisterItems + every signed operation; M5 DoD bullets 1+2 unblocked. - -**Captured fixtures (commit `dbb580b`).** `MxAsbClient.Probe --dump-signed-xml` (new flag, 2026-05-05) produces canonical `request.ToXml()` output for the five primary ConnectedRequest shapes; fixtures saved under `rust/crates/mxaccess-asb/tests/fixtures/signed-xml/{authenticate-me,disconnect,keep-alive,register-items,unregister-items}.xml`. Byte sizes pinned: 1000/980/705/1068/1072. Plus `authenticate-me-empty-mac-iv.xml` (896 bytes) for the actual signing input shape (validator's MAC + IV are empty during `request.ToXml()`; .NET's `AsbSystemAuthenticator.Sign:79` mutates them only AFTER HMAC computation). The companion `README.md` documents 10 inferred XmlSerializer rules — most importantly: (1) element name = class name (NOT MessageContract.WrapperName), (2) field order = C# declaration order (NOT [MessageBodyMember.Order]), (3) `[XmlType(Namespace=...)]` on a field's type causes per-child xmlns redeclaration on the children, NOT the wrapper element, (4) the `*Specified` pattern controls whether `` is emitted, (5) CRLF line endings + 2-space indent + UTF-8-bytes-of-utf-16-declaration, (6) empty `byte[]` → self-closing `` (NOT ``). - -**Emitter landed (commit `f14580e`).** `mxaccess-asb::xml_canonical` exposes `emit_authenticate_me_xml`, `emit_disconnect_xml`, `emit_keep_alive_xml`, `emit_register_items_request_xml`, `emit_unregister_items_request_xml`. Seven fixture-comparison tests pass (byte-equal vs. .NET output for both filled-MAC + empty-MAC variants of AuthenticateMe, plus the four other shapes). Plumbing: `AsbAuthenticator::peek_next_message_number` exposes the pre-allocated message number; `AsbClient::send_signed_envelope[_one_way]` gain `xml_for_signing: Option<&[u8]>`. `connect`, `disconnect`, `keep_alive`, `register_items`, `unregister_items` now build a pre-signing `ConnectionValidator` (empty MAC + IV) → emit canonical XML → pass to HMAC. Other ops (Read, Write, Subscription) still use the legacy NBFX-bytes path. - -**Registry-driven DH params (commit `f14580e`).** `tools/Get-AsbPassphrase.ps1` exports `MX_ASB_DH_PRIME`, `MX_ASB_DH_GENERATOR`, `MX_ASB_DH_HASH_ALGORITHM`, `MX_ASB_DH_KEY_SIZE`. The `asb-subscribe` example honours those env vars to override `CryptoParameters::defaults()` (which is the .NET reference's 1024-bit fallback). Each AVEVA install picks its own DH group at provisioning time — typically a 768-bit prime, NOT the default 1024-bit. With the wrong prime, `Connect` succeeds at the byte level but the shared-secret derivation diverges, breaking AuthenticateMe's encrypted ConsumerData verification. Empty registry `hashAlgorithm` maps to `HashAlgorithm::Unrecognised` to match `AsbSystemAuthenticator.CreateHmac:84-93` semantics where empty + `forceHmac=true` falls through to HMAC-SHA1. - -**Remaining live blocker (commit `fd38189`).** With canonical XML byte-equal to .NET's AND DH params from the registry, AuthenticateMe still produces `dispatcher/fault` InternalServiceFault. `MX_ASB_TRACE_DERIVE`-gated diagnostic traces in both the Rust authenticator and the .NET reference confirm: crypto_key length matches (176 bytes = 96-byte shared secret + 80-byte passphrase); passphrase bytes [96..176] of the crypto_key are identical between Rust and .NET (same registry source, same UTF-8 encoding). The shared-secret prefix [0..96] differs per session (random DH), but should round-trip correctly with the server. - -**Crypto stack ruled out** (commit ``). Deterministic-HMAC fixture test (`auth.rs::tests::deterministic_hmac_matches_dotnet_fixture`) takes pinned inputs (passphrase, prime, generator, private-key bytes, remote-pub bytes, message number, connection ID, AES IV, consumer-data + IV) and asserts byte-equality of each step: -1. `shared = remote_pub^private_key mod prime` — ✅ matches .NET -2. `crypto_key = shared || passphrase_utf8` — ✅ matches .NET -3. `hmac = HMAC-SHA1(crypto_key, xml_utf8)` — ✅ matches .NET (HMACSHA1) -4. `aes_key = PBKDF2-SHA1(base64(crypto_key), "ArchestrAService", 1000, 16)` — ✅ matches .NET (Rfc2898DeriveBytes.Pbkdf2) -5. `encrypted_mac = AES-CBC(aes_key, iv=zeros, hmac, PKCS7)` — ✅ matches .NET (System.Security.Cryptography.Aes) - -The fixture is captured by `MxAsbClient.Probe --dump-deterministic-hmac` (`src/MxAsbClient.Probe/Program.cs:166-296`), saved at `crates/mxaccess-asb-nettcp/tests/fixtures/deterministic-hmac/authenticate-me.kv`. With all 5 crypto steps proven byte-equal to .NET, the live AuthenticateMe fault must come from one of: (a) the wire-level ConnectionValidator NBFX shape (DataContract field-name namespace, mustUnderstand attr, etc.), (b) the WCF binary message header (action+to dict pre-pop), (c) a subtle XmlSerializer quirk for live values that the hardcoded fixtures don't exercise (e.g., Guid format edge case, base64 line wrapping for specific lengths, ulong text rendering). Next iteration's hunt: add a deterministic *wire-level* fixture (the entire NBFX byte stream of an AuthenticateMe envelope, not just the canonical-XML payload) and diff against a .NET probe capture for the same inputs. - - ### F27 — Constant-time DH `mod_exp` (swap `num-bigint` → `crypto-bigint::BoxedUint`) **Severity:** P2 (security regression vs the long-term Rust target — but at parity with the .NET reference today, so not a release-blocker) **Source:** F23 (`crates/mxaccess-asb-nettcp/src/auth.rs:179,303`); originally flagged in `design/30-crate-topology.md:269-274` and the project's `review.md` MAJOR finding. @@ -134,6 +109,19 @@ The fixture is captured by `MxAsbClient.Probe --dump-deterministic-hmac` (`src/M ## Resolved +### F28 — Canonical XML serialiser for `ConnectedRequest` signing (matches `XmlSerializer.Serialize` byte-for-byte) +**Resolved:** 2026-05-06 (commit ``). All 13 `ConnectedRequest` shapes now sign over byte-identical canonical XML; the legacy NBFX-bytes fallback is gone from every `client::*` op. Hardens the ASB transport against deployments with a non-empty `hashAlgorithm` registry value (where the server's HMAC validation actually runs). + +**Two-step closure**: +1. **Step 1 (commit `f14580e`, 2026-05-05)** — landed the 5 `[XmlSerializerFormat]` ops (AuthenticateMe, Disconnect, KeepAlive, RegisterItems, UnregisterItems) plus the per-action `ValidatorWireFormat` selector + DH-params-from-registry + dynamic-dict id management. Live AuthenticateMe + RegisterItems verified end-to-end (commit `9063f10`). +2. **Step 2 (this commit)** — extended `MxAsbClient.Probe --dump-signed-xml` to emit the 8 remaining shapes (ReadRequest, WriteBasicRequest, PublishWriteCompleteRequest, CreateSubscriptionRequest, DeleteSubscriptionRequest, AddMonitoredItemsRequest, DeleteMonitoredItemsRequest, PublishRequest) against deterministic field values. Saved fixtures at `rust/crates/mxaccess-asb/tests/fixtures/signed-xml/{read,write-basic,publish-write-complete,create-subscription,delete-subscription,add-monitored-items,delete-monitored-items,publish}-request.xml`. Pinned byte sizes 981 / 1497 / 741 / 814 / 793 / 1768 / 1782 / 771. Ported 8 emitters in `mxaccess-asb::xml_canonical`: `emit_read_request_xml`, `emit_write_basic_request_xml`, `emit_publish_write_complete_request_xml`, `emit_create_subscription_request_xml`, `emit_delete_subscription_request_xml`, `emit_add_monitored_items_request_xml`, `emit_delete_monitored_items_request_xml`, `emit_publish_request_xml`. New helpers: `emit_invensys_text` (primitives in the parent ns), `emit_write_value` (`` wrapper inlining `Value`/`Status`/`Comment`), `emit_monitored_item` (`` wrapper with `Item`/`SampleInterval`/`ValueDeadband`/`UserData`/`Buffered`), `emit_inline_item_identity` (ItemIdentity as a child of MonitoredItem with shared parent xmlns), `emit_inline_text` / `emit_inline_optional_string` (no-xmlns-redeclaration variants), `emit_idata_variant` (Variant's `Type`/`Length`/`Payload` in the `idata.data` namespace), `emit_iom_default_variant` (default-shape Variant for `ValueDeadband` / `UserData`). New private helper `AsbClient::pre_signing_validator()` consolidates the 8 call-site repetitions of `(connection_id, peek_next_message_number, "", "")`. + +**Wired into `client::*`**: every `send_signed_envelope[_one_way]` call now passes `Some(&xml)` for `xml_for_signing` — the legacy NBFX-bytes fallback path inside `send_signed_envelope` is unreachable from the standard client. (The path itself stays in place to allow lower-level callers and tests to exercise the fallback.) The 8 ops affected: `read`, `write`, `publish_write_complete`, `delete_monitored_items`, `create_subscription`, `add_monitored_items`, `publish`, `delete_subscription` (plus their `_once` retry-loop variants for the ops that retry on `InvalidConnectionId`). + +**Verification**: 8 new fixture-comparison tests (each emitter byte-equal vs the .NET fixture on the first try, no iteration). Workspace `mxaccess-asb` 87 → 95 tests; default-feature clippy clean. Live `cargo run -p mxaccess --example asb-subscribe` returns `TestChildObject.TestInt = 99` against AVEVA — proving `Read` (now signed via canonical XML) round-trips end-to-end where it previously used the legacy NBFX-bytes path. The other 7 ops are wire-tested only at fixture-byte-equality so far; live exercise is gated on the F33 follow-on capture for subscribe-flow ops, but the canonical XML produces byte-identical bytes to the .NET reference, so the HMAC will match by construction. + +**Closes**: M5 DoD bullets 1+2 fully resolved across all 13 `ConnectedRequest` shapes. The `hashAlgorithm`-non-empty deployment shape is no longer latent — any future deployment with a real algorithm should sign correctly without further work. + ### F16 — Real `Session::recover_connection` reconnect loop (re-bind + re-advise) **Resolved:** 2026-05-06 (commit ``). Replaces the wave-2 no-op `recover_connection` with the full .NET-equivalent shape (`MxNativeSession.cs:399-474`). diff --git a/rust/crates/mxaccess-asb/src/client.rs b/rust/crates/mxaccess-asb/src/client.rs index a0eeae6..4acc863 100644 --- a/rust/crates/mxaccess-asb/src/client.rs +++ b/rust/crates/mxaccess-asb/src/client.rs @@ -107,6 +107,23 @@ impl AsbClient { &mut self.authenticator } + /// Build the pre-signing [`ConnectionValidator`] for an upcoming + /// signed op: `connection_id` + the next-allocated `message_number`, + /// with empty `mac_base64` / `iv_base64` (the canonical-XML emitter + /// produces self-closing `` and + /// `` for empty `byte[]`, matching + /// .NET's `XmlSerializer` shape at HMAC time). The MAC + IV get + /// filled in by `AsbAuthenticator::sign` after the XML bytes have + /// been hashed. + fn pre_signing_validator(&mut self) -> ConnectionValidator { + ConnectionValidator { + connection_id: self.authenticator.connection_id(), + message_number: self.authenticator.peek_next_message_number(), + mac_base64: String::new(), + iv_base64: String::new(), + } + } + /// Write the canonical NMF preamble (`Version 1.0` → `Duplex` → /// `Via` → `BinaryWithDictionary` → `PreambleEnd`) and read the /// peer's `PreambleAck` reply. Records other than `PreambleAck` — @@ -462,9 +479,11 @@ impl AsbClient { } async fn read_once(&mut self, items: &[ItemIdentity]) -> Result { + let pre_signing = self.pre_signing_validator(); + let xml = crate::xml_canonical::emit_read_request_xml(&pre_signing, items); let body = build_read_request_body(items); let response = self - .send_signed_envelope(actions::READ, body, None, false) + .send_signed_envelope(actions::READ, body, Some(&xml), false) .await?; Ok(decode_read_response(&response.body_tokens)?) } @@ -478,9 +497,11 @@ impl AsbClient { pub async fn publish_write_complete( &mut self, ) -> Result { + let pre_signing = self.pre_signing_validator(); + let xml = crate::xml_canonical::emit_publish_write_complete_request_xml(&pre_signing); let body = build_publish_write_complete_request_body(); let response = self - .send_signed_envelope(actions::PUBLISH_WRITE_COMPLETE, body, None, false) + .send_signed_envelope(actions::PUBLISH_WRITE_COMPLETE, body, Some(&xml), false) .await?; Ok(decode_publish_write_complete_response( &response.body_tokens, @@ -494,9 +515,15 @@ impl AsbClient { subscription_id: i64, items: &[MinimalMonitoredItem], ) -> Result { + let pre_signing = self.pre_signing_validator(); + let xml = crate::xml_canonical::emit_delete_monitored_items_request_xml( + &pre_signing, + subscription_id, + items, + ); let body = build_delete_monitored_items_request_body(subscription_id, items); let response = self - .send_signed_envelope(actions::DELETE_MONITORED_ITEMS, body, None, false) + .send_signed_envelope(actions::DELETE_MONITORED_ITEMS, body, Some(&xml), false) .await?; Ok(decode_delete_monitored_items_response( &response.body_tokens, @@ -517,9 +544,16 @@ impl AsbClient { values: &[MinimalWriteValue], write_handle: u32, ) -> Result { + let pre_signing = self.pre_signing_validator(); + let xml = crate::xml_canonical::emit_write_basic_request_xml( + &pre_signing, + items, + values, + write_handle, + ); let body = build_write_request_body(items, values, write_handle); let response = self - .send_signed_envelope(actions::WRITE, body, None, false) + .send_signed_envelope(actions::WRITE, body, Some(&xml), false) .await?; Ok(decode_write_response(&response.body_tokens)?) } @@ -564,9 +598,15 @@ impl AsbClient { max_queue_size: i64, sample_interval: u64, ) -> Result { + let pre_signing = self.pre_signing_validator(); + let xml = crate::xml_canonical::emit_create_subscription_request_xml( + &pre_signing, + max_queue_size, + sample_interval, + ); let body = build_create_subscription_request_body(max_queue_size, sample_interval); let response = self - .send_signed_envelope(actions::CREATE_SUBSCRIPTION, body, None, false) + .send_signed_envelope(actions::CREATE_SUBSCRIPTION, body, Some(&xml), false) .await?; Ok(decode_create_subscription_response( &response.body_tokens, @@ -616,9 +656,16 @@ impl AsbClient { items: &[MinimalMonitoredItem], require_id: bool, ) -> Result { + let pre_signing = self.pre_signing_validator(); + let xml = crate::xml_canonical::emit_add_monitored_items_request_xml( + &pre_signing, + subscription_id, + items, + require_id, + ); let body = build_add_monitored_items_request_body(subscription_id, items, require_id); let response = self - .send_signed_envelope(actions::ADD_MONITORED_ITEMS, body, None, false) + .send_signed_envelope(actions::ADD_MONITORED_ITEMS, body, Some(&xml), false) .await?; Ok(decode_add_monitored_items_response(&response.body_tokens)?) } @@ -627,9 +674,11 @@ impl AsbClient { /// available samples. Typical pattern is to call this in a loop /// with a small `tokio::time::timeout` per call. pub async fn publish(&mut self, subscription_id: i64) -> Result { + let pre_signing = self.pre_signing_validator(); + let xml = crate::xml_canonical::emit_publish_request_xml(&pre_signing, subscription_id); let body = build_publish_request_body(subscription_id); let response = self - .send_signed_envelope(actions::PUBLISH, body, None, false) + .send_signed_envelope(actions::PUBLISH, body, Some(&xml), false) .await?; Ok(decode_publish_response(&response.body_tokens)?) } @@ -641,9 +690,14 @@ impl AsbClient { &mut self, subscription_id: i64, ) -> Result { + let pre_signing = self.pre_signing_validator(); + let xml = crate::xml_canonical::emit_delete_subscription_request_xml( + &pre_signing, + subscription_id, + ); let body = build_delete_subscription_request_body(subscription_id); let _ = self - .send_signed_envelope(actions::DELETE_SUBSCRIPTION, body, None, false) + .send_signed_envelope(actions::DELETE_SUBSCRIPTION, body, Some(&xml), false) .await?; Ok(DeleteSubscriptionResponse) } diff --git a/rust/crates/mxaccess-asb/src/xml_canonical.rs b/rust/crates/mxaccess-asb/src/xml_canonical.rs index 7bbeff7..0d7ca8d 100644 --- a/rust/crates/mxaccess-asb/src/xml_canonical.rs +++ b/rust/crates/mxaccess-asb/src/xml_canonical.rs @@ -36,10 +36,15 @@ 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"; @@ -115,6 +120,135 @@ pub fn emit_unregister_items_request_xml( }) } +/// `` 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 { @@ -294,6 +428,204 @@ fn emit_invensys_bool(s: &mut String, indent: &str, tag: &str, value: bool) { 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()); + // 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, @@ -474,6 +806,104 @@ mod tests { 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 diff --git a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/README.md b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/README.md index 604f6de..a5663e3 100644 --- a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/README.md +++ b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/README.md @@ -89,11 +89,40 @@ source for `XmlSerializer`: ## Files - `authenticate-me.xml` — `AuthenticateMe` +- `authenticate-me-empty-mac-iv.xml` — `AuthenticateMe` with the + pre-signing validator (empty MAC + IV) — the actual HMAC input shape. - `disconnect.xml` — `Disconnect` - `keep-alive.xml` — `KeepAlive` - `register-items.xml` — `RegisterItemsRequest` - `unregister-items.xml` — `UnregisterItemsRequest` +The eight remaining `ConnectedRequest` shapes added 2026-05-06 (F28 +step 2) cover the data-plane + subscription ops: + +- `read-request.xml` — `ReadRequest` +- `write-basic-request.xml` — `WriteBasicRequest` +- `publish-write-complete-request.xml` — `PublishWriteCompleteRequest` +- `create-subscription-request.xml` — `CreateSubscriptionRequest` +- `delete-subscription-request.xml` — `DeleteSubscriptionRequest` +- `add-monitored-items-request.xml` — `AddMonitoredItemsRequest` +- `delete-monitored-items-request.xml` — `DeleteMonitoredItemsRequest` +- `publish-request.xml` — `PublishRequest` + +Pinned values for the new shapes (in addition to the +`ConnectionValidator` above): + +- `SubscriptionId = 0x1234_5678_9abc_def0` (decimal `1311768467463790320`) +- `MaxQueueSize = 100`, `SampleInterval = 1000` +- `WriteHandle = 0xDEAD_BEEF` (decimal `3735928559`) +- `WriteBasicRequest` uses one `WriteValue` whose `Value` is + `Variant.FromInt32(42)` (`Type=4`, `Length=4`, `Payload=[42, 0, 0, 0]`) +- `AddMonitoredItemsRequest` uses one `MonitoredItem` with + `Item = "TestChildObject.TestInt"` by name + `SampleInterval=1000` + + `Buffered=false` (other fields default) +- `DeleteMonitoredItemsRequest` uses one `MonitoredItem` with + `Item.Id = 0xCAFE_BABE_DEAD_BEEF` (the same `IdSpecified` shape as + `unregister-items.xml`) + Each file is the verbatim UTF-8 representation of `request.ToXml()`, with literal `\r\n` line endings preserved. Treat as binary (don't let your editor reformat). diff --git a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/add-monitored-items-request.xml b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/add-monitored-items-request.xml new file mode 100644 index 0000000..d05895a --- /dev/null +++ b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/add-monitored-items-request.xml @@ -0,0 +1,31 @@ + + + + 8cba964a-74c1-ef74-f6aa-761b3540191b + 42 + AAECAwQFBgcICQoLDA0ODw== + EBESExQVFhcYGRobHB0eHw== + + 1311768467463790320 + + + 0 + 1 + TestChildObject.TestInt + + + 1000 + + 0 + 0 + + + + 0 + 0 + + + false + + true + \ No newline at end of file diff --git a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/create-subscription-request.xml b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/create-subscription-request.xml new file mode 100644 index 0000000..ccd9d22 --- /dev/null +++ b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/create-subscription-request.xml @@ -0,0 +1,11 @@ + + + + 8cba964a-74c1-ef74-f6aa-761b3540191b + 42 + AAECAwQFBgcICQoLDA0ODw== + EBESExQVFhcYGRobHB0eHw== + + 100 + 1000 + \ No newline at end of file diff --git a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/delete-monitored-items-request.xml b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/delete-monitored-items-request.xml new file mode 100644 index 0000000..60082ba --- /dev/null +++ b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/delete-monitored-items-request.xml @@ -0,0 +1,31 @@ + + + + 8cba964a-74c1-ef74-f6aa-761b3540191b + 42 + AAECAwQFBgcICQoLDA0ODw== + EBESExQVFhcYGRobHB0eHw== + + 1311768467463790320 + + + 1 + 1 + + + 14627333968688430831 + + 1000 + + 0 + 0 + + + + 0 + 0 + + + false + + \ No newline at end of file diff --git a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/delete-subscription-request.xml b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/delete-subscription-request.xml new file mode 100644 index 0000000..a6a7975 --- /dev/null +++ b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/delete-subscription-request.xml @@ -0,0 +1,10 @@ + + + + 8cba964a-74c1-ef74-f6aa-761b3540191b + 42 + AAECAwQFBgcICQoLDA0ODw== + EBESExQVFhcYGRobHB0eHw== + + 1311768467463790320 + \ No newline at end of file diff --git a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/publish-request.xml b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/publish-request.xml new file mode 100644 index 0000000..23bd120 --- /dev/null +++ b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/publish-request.xml @@ -0,0 +1,10 @@ + + + + 8cba964a-74c1-ef74-f6aa-761b3540191b + 42 + AAECAwQFBgcICQoLDA0ODw== + EBESExQVFhcYGRobHB0eHw== + + 1311768467463790320 + \ No newline at end of file diff --git a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/publish-write-complete-request.xml b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/publish-write-complete-request.xml new file mode 100644 index 0000000..a5de0fa --- /dev/null +++ b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/publish-write-complete-request.xml @@ -0,0 +1,9 @@ + + + + 8cba964a-74c1-ef74-f6aa-761b3540191b + 42 + AAECAwQFBgcICQoLDA0ODw== + EBESExQVFhcYGRobHB0eHw== + + \ No newline at end of file diff --git a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/read-request.xml b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/read-request.xml new file mode 100644 index 0000000..5ccec61 --- /dev/null +++ b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/read-request.xml @@ -0,0 +1,15 @@ + + + + 8cba964a-74c1-ef74-f6aa-761b3540191b + 42 + AAECAwQFBgcICQoLDA0ODw== + EBESExQVFhcYGRobHB0eHw== + + + 0 + 1 + TestChildObject.TestInt + + + \ No newline at end of file diff --git a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/write-basic-request.xml b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/write-basic-request.xml new file mode 100644 index 0000000..4066cf3 --- /dev/null +++ b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/write-basic-request.xml @@ -0,0 +1,27 @@ + + + + 8cba964a-74c1-ef74-f6aa-761b3540191b + 42 + AAECAwQFBgcICQoLDA0ODw== + EBESExQVFhcYGRobHB0eHw== + + + 0 + 1 + TestChildObject.TestInt + + + + + 4 + 4 + KgAAAA== + + + 0 + + + + 3735928559 + \ No newline at end of file diff --git a/src/MxAsbClient.Probe/Program.cs b/src/MxAsbClient.Probe/Program.cs index f3b1632..a25233d 100644 --- a/src/MxAsbClient.Probe/Program.cs +++ b/src/MxAsbClient.Probe/Program.cs @@ -168,6 +168,96 @@ if (args.Any(arg => arg.Equals("--dump-signed-xml", StringComparison.OrdinalIgno }; Dump("UnregisterItemsRequest", unregisterDump); + // F28 step 2 (2026-05-06): the 8 remaining ConnectedRequest shapes + // that still sign over NBFX wire bytes via the legacy fallback. + // Same `validator` instance throughout so consumers diff per-op + // body semantics rather than validator-shape variation. + + ItemIdentity sampleItem = new() + { + Type = (ushort)ItemIdentityType.Name, + ReferenceType = (ushort)ItemReferenceType.Absolute, + Name = "TestChildObject.TestInt", + ContextName = string.Empty, + }; + ItemIdentity sampleItemById = new() + { + Type = (ushort)ItemIdentityType.Id, + ReferenceType = (ushort)ItemReferenceType.Absolute, + Id = 0xCAFE_BABE_DEAD_BEEFul, + IdSpecified = true, + }; + const long SampleSubscriptionId = 0x1234_5678_9abc_def0L; + + ReadRequest readDump = new() + { + ConnectionValidator = validator, + Items = [sampleItem], + }; + Dump("ReadRequest", readDump); + + WriteBasicRequest writeDump = new() + { + ConnectionValidator = validator, + Items = [sampleItem], + Values = [new WriteValue { Value = AsbVariantFactory.FromInt32(42) }], + WriteHandle = 0xDEAD_BEEFu, + }; + Dump("WriteBasicRequest", writeDump); + + PublishWriteCompleteRequest pwcDump = new() + { + ConnectionValidator = validator, + }; + Dump("PublishWriteCompleteRequest", pwcDump); + + CreateSubscriptionRequest createSubDump = new() + { + ConnectionValidator = validator, + MaxQueueSize = 100L, + SampleInterval = 1000ul, + }; + Dump("CreateSubscriptionRequest", createSubDump); + + DeleteSubscriptionRequest deleteSubDump = new() + { + ConnectionValidator = validator, + SubscriptionId = SampleSubscriptionId, + }; + Dump("DeleteSubscriptionRequest", deleteSubDump); + + AddMonitoredItemsRequest addMonDump = new() + { + ConnectionValidator = validator, + SubscriptionId = SampleSubscriptionId, + Items = [new MonitoredItem + { + Item = sampleItem, + SampleInterval = 1000ul, + }], + RequireId = true, + }; + Dump("AddMonitoredItemsRequest", addMonDump); + + DeleteMonitoredItemsRequest deleteMonDump = new() + { + ConnectionValidator = validator, + SubscriptionId = SampleSubscriptionId, + Items = [new MonitoredItem + { + Item = sampleItemById, + SampleInterval = 1000ul, + }], + }; + Dump("DeleteMonitoredItemsRequest", deleteMonDump); + + PublishRequest publishDump = new() + { + ConnectionValidator = validator, + SubscriptionId = SampleSubscriptionId, + }; + Dump("PublishRequest", publishDump); + return; }