Compare commits
4 Commits
1de049e114
...
bedad57b4e
| Author | SHA1 | Date | |
|---|---|---|---|
| bedad57b4e | |||
| b1a5f5ff1e | |||
| 101a8b13f5 | |||
| 6762526f09 |
+47
-161
@@ -6,167 +6,6 @@ move to `## Resolved` with a date + commit hash.
|
|||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
### F18 — M5 plan of attack (ASB transport, parallel-safe sub-streams)
|
|
||||||
**Severity:** P0 — milestone driver, blocks ASB consumers + V1 release
|
|
||||||
**Source:** `design/dependencies.md:73-89` + `design/60-roadmap.md:84-91` + `design/70-risks-and-open-questions.md:5-25` (R1 estimates ~3000 LoC for framing+encoders).
|
|
||||||
|
|
||||||
**Scope.** Build the ASB data-plane end-to-end:
|
|
||||||
- `mxaccess-asb-nettcp` — `[MS-NMF]` framing + `[MC-NBFX]` binary-XML node codec + `[MC-NBFS]` static dictionary table + DH/HMAC/AES authentication crypto.
|
|
||||||
- `mxaccess-asb` — `IASBIDataV2` client (Connect, RegisterItems, Read, Write, PublishWriteComplete, CreateSubscription, AddMonitoredItems, Publish, Disconnect) + `SecretProvider` trait + DPAPI default impl + ASB Variant codec port (currently a stub at `crates/mxaccess-codec/src/lib.rs:74,77,80`).
|
|
||||||
- `mxaccess::Session` over an `AsbTransport` impl; capabilities surface ASB limits (no `subscribe_buffered`, no Activate/Suspend, no OperationComplete outside the proven write-completion frame — see `design/60-roadmap.md:88`).
|
|
||||||
- `examples/asb-subscribe.rs` exercises the whole path against a live ASB endpoint with parity vs `dotnet run --project src\MxAsbClient.Probe`.
|
|
||||||
|
|
||||||
**Sub-stream breakdown** (matches `design/dependencies.md:78-89`). Each sub-stream is a separate followup so it can be claimed by a separate agent in a worktree without merge conflict:
|
|
||||||
|
|
||||||
| Sub-followup | Stream | Owns | Depends on |
|
|
||||||
|---|---|---|---|
|
|
||||||
| F19 | (workspace prereq) | Add the M5 dep set to `rust/Cargo.toml` workspace deps + per-crate `Cargo.toml`: `aes`, `hmac`, `md-5`, `sha1`, `sha2`, `pbkdf2`, `flate2`, `rand`, `crypto-bigint` (constant-time DH per `review.md` MAJOR), `quick-xml`, `tokio-util`. Pinned to the `digest 0.11`/`cipher 0.5` generation per `design/30-crate-topology.md:251-289`. Sequential prereq for the others. | M0 |
|
|
||||||
| F20 | A — MS-NMF framing | `mxaccess-asb-nettcp::nmf` — preamble (`0x00 ver=1 mode=2 via=encoded-string`), preamble-ack, sized-envelope (`0x06 var-int len bytes`), end (`0x07`), fault (`0x08`), upgrade-request, known-encoding via lookup. Reliable-session ack handling. Round-trip against `analysis/proxy/mxasbclient-register-message.txt` and `mxasbclient-probe-stage*.txt` byte traces. | F19 |
|
|
||||||
| F21 | B — MC-NBFX | `mxaccess-asb-nettcp::nbfx` — record types (`0x40` ShortElement, `0x41` Element, `0x44` ShortDictionaryAttribute, `0x04` PrefixDictionary*A-Z, `0x84` BoolText, `0x88` Int32Text, `0x86` BoolFalseText, etc., per `[MC-NBFX]` §2.2). Length-prefixed strings (var-int 7-bit groups). Read/write over `bytes::BytesMut`. | F19 |
|
|
||||||
| F22 | C — MC-NBFS | `mxaccess-asb-nettcp::nbfs` — the static dictionary table. SOAP/WS-Addressing tokens + `IASBIDataV2`-action strings used by the operation set (`http://ASB.IDataV2:registerItemsIn`, `:readIn`, `:writeIn`, `:createSubscriptionIn`, `:publishIn`, etc., see `src/MxAsbClient/AsbContracts.cs:14-58`). Hand-rolled from the proven action set; the full WCF dictionary is much larger but only the action subset is on the wire. | F19 |
|
|
||||||
| F23 | D — Auth crypto | `mxaccess-asb-nettcp::auth` — port `src/MxAsbClient/AsbSystemAuthenticator.cs` (167 LoC): DH key exchange with `crypto-bigint` constant-time `mod_exp` (review.md MAJOR finding — .NET `BigInteger.ModPow` is **not** constant-time and the DH private exponent is long-lived per `cs:153-166`); HMAC-MD5/SHA1/SHA512 (negotiated per `AsbSolutionCryptoParameters.HashAlgorithm`); AES-128 with PBKDF2-SHA1 1000-iteration key derivation; deflate-then-encrypt `EncryptBaktun` vs raw-encrypt `EncryptApollo` distinguished by `:V2` lifetime suffix (`cs:48`); ASCII salt `"ArchestrAService"`; UTF-16LE passphrase. Plus DPAPI shared-secret read on Windows behind the existing `dpapi` feature gate, with a `SecretProvider::shared_secret(&[u8])` escape hatch for tests/CI (`design/30-crate-topology.md:150`). | F19 |
|
|
||||||
| F24 | (codec) | `mxaccess-codec::asb_variant` — fill in the stubbed `AsbVariant`, `AsbStatus`, `RuntimeValue` (`crates/mxaccess-codec/src/lib.rs:74,77,80`) per `docs/ASB-Variant-Wire-Format.md`. Decode/encode for the proven type matrix: `TypeBool`, `TypeInt32`, `TypeFloat`, `TypeDouble`, `TypeString`, `TypeDateTime`, `TypeDuration`, plus deployed array shapes (`work_remain.md:108-113`). Less-common scalars stay as raw bytes (matches .NET `DecodeVariant` fallback at `MxAsbDataClient.cs:748`). Independent of the framing/encoder work — separate crate. | M1 (envelope/status types) |
|
|
||||||
| F25 | E — IASBIDataV2 client | `mxaccess-asb::client` — top-level `AsbClient` with `connect`, `register_items`, `read`, `write`, `publish_write_complete`, `create_subscription`, `add_monitored_items`, `publish`, `disconnect`. Wires the contract → NBFX-encoded SOAP envelope → NMF-framed TCP. `ConnectedRequest::ConnectionValidator` HMAC signing per `AsbSystemAuthenticator::Sign`. Receives `Publish` callbacks via a long-lived background task (mirrors the M4 NMX `callback_router` pattern). Depends on F20+F21+F22+F23+F24. | A+B+C+D+codec |
|
|
||||||
| F26 | (session) | `mxaccess::Session` over `AsbTransport`. New transport impl alongside `NmxTransport`. Surface ASB capability flags so `subscribe_buffered`/`activate`/`suspend` return `Error::Unsupported(Capability::*)` rather than a runtime fallthrough. Update `examples/asb-subscribe.rs` to drive the path end-to-end. Live-probe DoD: round-trip parity with `dotnet run --project src\MxAsbClient.Probe`. | F25 |
|
|
||||||
|
|
||||||
**Parallel-safety analysis.**
|
|
||||||
- F19 (workspace deps) is the **single sequential bottleneck** — F20-F25 all reference workspace deps that don't exist yet, so they cannot start in parallel until F19 lands. Tight & small (~30 lines of TOML).
|
|
||||||
- F20, F21, F22, F23, F24 are **fully parallel-safe** after F19: each owns a different module under a different crate (or different sibling module within `mxaccess-asb-nettcp`). No shared state, no cross-import — each can land in its own commit. Per `dependencies.md:88` "Peak agents in parallel: 4 in the framing/encoding wave (A+B+C+D)".
|
|
||||||
- F25 is sequential after the four framing/encoder streams + F24 land — it composes them. The .NET `MxAsbDataClient` is monolithic enough that splitting F25 across agents costs more in coordination than it saves.
|
|
||||||
- F26 is sequential after F25.
|
|
||||||
- **Cross-milestone parallelism still holds.** M5 (this whole F18-F26 cluster) runs in parallel with M3+M4 per `design/60-roadmap.md:14-17` because the `Transport` trait was lifted into M0. M4's `Session` core landed (commits `4863c6d`, `2dc091d`, `a31237d`); the F26 `AsbTransport` plugs into the same trait without re-design.
|
|
||||||
|
|
||||||
**Risk-driven sequencing inside the parallel wave.** R1 in `design/70-risks-and-open-questions.md:9` is the project-blocker. Of the four parallel streams, F23 (auth crypto) carries the most live-probe risk (DH handshake against the live VM is the first irreversible test of the spec port) but is the smallest in LoC. F22 (NBFS) is the largest unknown — the dictionary table size is bounded only by the action subset we exercise. Recommended order *if* agents are constrained: F23 (smallest, highest-leverage) → F20 (foundational for any wire test) → F21 (encoder) → F22 (dictionary) → F24 (codec, independent).
|
|
||||||
|
|
||||||
**Definition of done** for F18 as a whole (= M5 DoD per `design/60-roadmap.md:91`):
|
|
||||||
1. `cargo run -p mxaccess --example asb-subscribe -- --tag TestChildObject.TestInt` succeeds against a live ASB endpoint.
|
|
||||||
2. Round-trip parity with `dotnet run --project src\MxAsbClient.Probe` (Frida/Wireshark diff is byte-identical for the proven type matrix).
|
|
||||||
3. The `mxaccess-asb` type matrix covers what `work_remain.md:108-113` documents as proven: scalar Boolean, Int32, Float, Double, String, DateTime, Duration plus deployed array tags.
|
|
||||||
4. `cargo build --workspace` and `cargo test --workspace` green; `cargo clippy --workspace -- -D warnings` clean.
|
|
||||||
|
|
||||||
**Resolves when:** F19-F26 are all closed and the four DoD bullets above pass.
|
|
||||||
|
|
||||||
**M5 STATUS (commit `9063f10`): functionally LIVE.** End-to-end `cargo run -p mxaccess --example asb-subscribe -- --tag TestChildObject.TestInt` Connect → AuthenticateMe → Register → Read → Disconnect against the live MxDataProvider, returning the real tag value over the wire (`type_id=4 length=4 payload=[99,0,0,0]`). DoD checklist:
|
|
||||||
1. ✅ Live `asb-subscribe` succeeds against the AVEVA endpoint.
|
|
||||||
2. ⚠️ Wire structure matches .NET's request bytes for AuthenticateMe / Register byte-by-byte (verified via `asb-relay` middleman with the .NET probe routed through ClientVia); responses round-trip via the F30 dict-id resolution post-pass. Strict byte-identical parity for the response side is not guaranteed because WCF chunks `Bytes8/16/32` records at different boundaries — both forms are functionally equivalent and `collect_asbidata_payloads` concatenates chunks (commit `cf97eab`).
|
|
||||||
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.
|
|
||||||
|
|
||||||
**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 `<this commit>`) — all 13 `ConnectedRequest` shapes now sign over byte-identical canonical XML. The 8 remaining ops (Read / Write / Subscription family / etc.) ported in commit `<this 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<Item = Result<MonitoredItemValue, Error>>` 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 (`<previous>`); **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:
|
|
||||||
1. **NBFX `PrefixElement_a..z` (0x5E-0x77) and `PrefixAttribute_a..z` (0x26-0x3F) decode + encode arms** — single-letter-prefix records that WCF emits in responses but our codec only recognised the dictionary-named cousins (`PrefixDictionaryElement_a..z` 0x44-0x5D, `PrefixDictionaryAttribute_a..z` 0x0C-0x25). The server's ConnectResponse hit `0x65 = PrefixElement_h` for a dynamically-named element (e.g. `<h:Foo>`) and our decoder bailed with `unknown NBFX record byte 0x65`. Both directions now round-trip; the encoder picks the short-form arm whenever `prefix_letter_offset(prefix).is_some()`.
|
|
||||||
2. **xmlns redeclaration on `<Data>` and `<InitializationVector>` inside `AuthenticationData` / `PublicKey`** — `[XmlType(Namespace = "http://asb.contracts.data/20111111")]` on the AuthenticationData / PublicKey classes (`AsbContracts.cs:350-381`) means XmlSerializer emits an `xmlns="..."` redeclaration on each direct child. The default-ns scope ends at `</Data>`, so `<InitializationVector>` needs its own redeclaration to stay in the data namespace; without it the server fell back to messages-namespace and the deserialiser threw an `InternalServiceFault`. Connect handshake now completes end-to-end with the apollo:V2 ConnectionLifetime and a real ServicePublicKey.
|
|
||||||
3. **SOAP-fault detection on the response path** — `ClientError::SoapFault { action, code, reason }` surfaces when the response Action header matches the canonical `dispatcher/fault` template; we previously let body decoders blindly run and hit `MissingField { field: "Status" }` which masked the fact that the wire was a fault. The reason text is extracted as the longest `NbfxText::Chars` in the body — robust against the `nbfs.rs` static-dictionary id mismatches noted below.
|
|
||||||
4. **Identified blocker**: `ConnectedRequest` signing currently HMACs the **NBFX wire bytes** of the unsigned envelope. .NET's `AsbSystemAuthenticator.Sign` (`AsbSystemAuthenticator.cs:79`) HMACs `Encoding.UTF8.GetBytes(request.ToXml())` — the **canonical XML serialisation** of the message contract via `XmlSerializer` with namespace `"urn:invensys.schemas"` (`AsbSerialization.cs:12-48`). Until the Rust port emits identical XML bytes, the HMAC mismatches and the server rejects every signed request (`AuthenticateMe`, `RegisterItems`, etc.) with a generic `dispatcher/fault` InternalServiceFault. Connect itself is unsigned (extends `ServiceMessage`, no `ConnectionValidator` header) which is why it works today. The fault's `a:RelatesTo` UniqueId in our captures matches the AuthenticateMe `MessageID`, confirming the failure point. **New followup F28** captures the XML-canonicaliser scope.
|
|
||||||
5. **`nbfs.rs` static dictionary ids drift** at id 114+ vs. the canonical `[MC-NBFS]` table (`Fault`/`Code`/`Reason`/`Text`/`Value` are 20 IDs higher on the wire than what we encode). Doesn't affect requests we send (we only encode IDs ≤44 = `ReplyTo`, all correct), but breaks `decode_envelope`'s element-by-name matching for fault bodies. Tracked as **F29**.
|
|
||||||
|
|
||||||
Workspace: 702 tests pass (no test count delta — wire-only fixes). Live status: Connect handshake working with real DH key + apollo encryption; AuthenticateMe and onwards blocked on F28. Companion diagnostic example `asb-relay.rs` (TCP middleman that hex-dumps both directions to stderr) lands as a permanent debugging aid.
|
|
||||||
|
|
||||||
|
|
||||||
- F26 step 3 (`<previous>` in the cumulative log): `mxaccess::AsbSession` is a high-level cheap-clone async API on top of `AsbTransport`, deliberately **parallel** to the NMX-shaped `Session` rather than unified. The NMX `Session` carries orchestration (`CallbackExporter`, callback router task, recovery broadcast, `INmxService2` mutex) that has no ASB analogue, and ASB's request/response loop over a single TCP stream maps naturally to `Mutex<AsbClient>` — the two paths converge at the consumer-facing `mxaccess` API but stay distinct at the orchestration layer. `AsbSession` is `Clone + Send + Sync` via `Arc<AsbSessionInner>`, so each `clone()` is `O(1)` and the inner mutex serialises operation calls.
|
|
||||||
|
|
||||||
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 <hash>` provides.
|
|
||||||
|
|
||||||
### F34 — `MonitoredItem` wire format: DataContract field-suffix names, not XmlSerializer property names
|
|
||||||
**Severity:** P2 — only affects the F26 stream's data flow against MxDataProvider; canonical-XML HMAC signing for the operation is verified working (server accepts the request, returns a non-fault response).
|
|
||||||
**Source:** Live `cargo run -p mxaccess --example asb-subscribe` + `examples/asb-relay.rs` capture, 2026-05-06.
|
|
||||||
|
|
||||||
**Two sub-issues, one closed and one open.**
|
|
||||||
|
|
||||||
**Closed: `decode_publish_response` filtered empty `<ASBIData/>` placeholders out of the positional payload list.** Captured the full S→C bytes of a working `PublishResponse` via `examples/asb-relay.rs` between the .NET probe and MxDataProvider (fixture stashed at `crates/mxaccess-asb/tests/fixtures/publish-response-with-value.bin`). The wire shape is `<Status><ASBIData/></Status><Values><ASBIData>{bytes}</ASBIData></Values>` — Status is empty-but-present, Values carries the binary `MonitoredItemValue[]`. Our `collect_asbidata_payloads` previously skipped the empty Status, shifting Values down to index `0` where the decoder mis-read it as Status and corrupted the parse. Fix: always push every `<ASBIData>` element as a positional entry, empty or not. `tests/publish_capture.rs` runs the full decode chain over the real wire bytes and asserts `values.len() == 1`. **Verified 2026-05-06.**
|
|
||||||
|
|
||||||
**Open: AddMonitoredItems / DeleteMonitoredItems request bodies use the wrong element-name form on the binary NBFX wire.** Live capture of the .NET probe's `AddMonitoredItems` request exposes a per-session NBFX dictionary declaring these strings *(verbatim, in declaration order)*: `activeField`, `activeFieldSpecified`, `bufferedField`, `itemField`, `contextNameField`, `idField`, `idFieldSpecified`, `nameField`, `referenceTypeField`, `typeField`, `sampleIntervalField`, `timeDeadbandField`, `timeDeadbandFieldSpecified`, `userDataField`, `lengthField`, `payloadField`, `valueDeadbandField`. These are the `[DataMember(Name = "...")]` private-field names from `AsbContracts.cs:940-965` — the wire form chosen by `DataContractSerializer` for non-`IAsbCustomSerializableType` types like `MonitoredItem`. Our `build_add_monitored_items_request_body` (and `build_delete_monitored_items_request_body`) emits XmlSerializer-property names like `<Active>`, `<Buffered>`, `<Item>`, `<SampleInterval>` — those are the *canonical XML for HMAC* shape (XmlSerializer-derived), which is correct for the signing input but wrong for the binary wire payload. MxDataProvider silently fails to register monitored items whose field names don't match its DataContract schema, returns a 0-length `Status` array (`successField=true`, `resultCode=0`, but no items actually registered), and consequently the `Publish` poll loop forever returns empty `Values`.
|
|
||||||
|
|
||||||
**The dual-format world**: ASB requests have *two* element-name conventions on the wire:
|
|
||||||
- **HMAC canonical XML** (input to `AsbAuthenticator::Sign`): XmlSerializer-derived names — `<Active>`, `<Items>`, `<MonitoredItem>` etc. Driven by `[XmlElement(...)]` and property names. Our `xml_canonical` emitter is byte-equal to .NET here (F28 step 2 fixtures verify).
|
|
||||||
- **Binary NBFX body** (the actual wire request): DataContractSerializer-derived names — `<activeField>`, `<bufferedField>`, etc. Driven by private-field `[DataMember(Name = "...")]`. Our builders for AddMonitoredItems / DeleteMonitoredItems are wrong here.
|
|
||||||
|
|
||||||
For ops where the body is purely `IAsbCustomSerializableType` arrays (Read, Register, Unregister), no DataContract names appear — every payload is wrapped as `<Items><ASBIData>{bytes}</ASBIData></Items>` (binary fast-path) and our builders are correct. The DataContract schema only matters for ops carrying non-`IAsbCustomSerializable` types like `MonitoredItem` and `WriteValue`.
|
|
||||||
|
|
||||||
**Captured ground-truth dictionary (from `tests/fixtures/add-monitored-items-request-wire.bin` binary header at `tests/add_monitored_items_request_capture.rs`).** The .NET WCF binary writer pre-declares **23 strings** in the session dynamic dictionary at the start of each request, mapping wire id → string:
|
|
||||||
|
|
||||||
```
|
|
||||||
header[ 0] (wire-id 1) = "http://ASB.IDataV2:addMonitoredItemsIn"
|
|
||||||
header[ 1] (wire-id 3) = "AddMonitoredItemsRequest"
|
|
||||||
header[ 2] (wire-id 5) = "SubscriptionId"
|
|
||||||
header[ 3] (wire-id 7) = "Items"
|
|
||||||
header[ 4] (wire-id 9) = "http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBIDataV2Contract"
|
|
||||||
header[ 5] (wire-id 11) = "MonitoredItem"
|
|
||||||
header[ 6] (wire-id 13) = "activeField"
|
|
||||||
header[ 7] (wire-id 15) = "activeFieldSpecified"
|
|
||||||
header[ 8] (wire-id 17) = "bufferedField"
|
|
||||||
header[ 9] (wire-id 19) = "itemField"
|
|
||||||
header[10] (wire-id 21) = "contextNameField"
|
|
||||||
header[11] (wire-id 23) = "idField"
|
|
||||||
header[12] (wire-id 25) = "idFieldSpecified"
|
|
||||||
header[13] (wire-id 27) = "nameField"
|
|
||||||
header[14] (wire-id 29) = "referenceTypeField"
|
|
||||||
header[15] (wire-id 31) = "typeField"
|
|
||||||
header[16] (wire-id 33) = "sampleIntervalField"
|
|
||||||
header[17] (wire-id 35) = "timeDeadbandField"
|
|
||||||
header[18] (wire-id 37) = "timeDeadbandFieldSpecified"
|
|
||||||
header[19] (wire-id 39) = "userDataField"
|
|
||||||
header[20] (wire-id 41) = "lengthField"
|
|
||||||
header[21] (wire-id 43) = "payloadField"
|
|
||||||
header[22] (wire-id 45) = "valueDeadbandField"
|
|
||||||
```
|
|
||||||
|
|
||||||
That's **the entire DataContract field name set** plus the wrapper / array / namespace / action strings. The body then references these by wire id throughout — no inline strings needed for any of the field names. The `nameField` slot 13 (wire id 27) etc. are exactly what I'd misidentified as resolved namespace URLs in my earlier `decode_envelope` trace; the wire id resolution is actually working — it's just that the body's xmlns slots reference dict ids whose resolution lands on a string our decoder doesn't expect there. Both observations are consistent: WCF reuses the same dynamic dictionary for both element names AND namespace declarations.
|
|
||||||
|
|
||||||
**Resolves when:** Two prerequisites:
|
|
||||||
|
|
||||||
1. **F30 dynamic-dict resolution + body-dict accounting** — `decode_envelope::resolve_dict_names_in_tokens` resolves dict-id-named elements correctly per the captured header; what's missing is **interpretation of which records auto-intern new strings into the dict** as the body decodes. WCF's binary writer (`XmlBinaryWriterSession.cs` in `dotnet/wcf`) auto-interns inline element/attribute names — the dynamic dict grows as the message decodes. For decoder/encoder parity we need the same auto-intern behaviour in `nbfx.rs::decode_tokens` and `encode_tokens`. The current codec leaves `_dynamic` parameter unused (intentional per its doc comment, "the codec doesn't auto-intern because `[MC-NBFX]` doesn't define a built-in `intern this string` record") — but that comment is wrong for WCF binary messages, where the writer DOES intern by convention. Fix: rewrite both halves to auto-intern inline names and to refer back to the dict on subsequent inline-or-dict choices.
|
|
||||||
2. **Builder rewrite** — once (1) lands and we can read the captured request structurally, rewrite `build_add_monitored_items_request_body` and `build_delete_monitored_items_request_body` to emit each `MonitoredItem` child as the DataContract field-suffix names (`activeField` / `activeFieldSpecified` / `bufferedField` / `itemField` / `sampleIntervalField` / `timeDeadbandField` / `timeDeadbandFieldSpecified` / `userDataField` / `valueDeadbandField`) under a `b` namespace prefix that maps to `http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBIDataV2Contract`. The nested `<itemField>` carries an ItemIdentity serialized via DataContract (NOT the binary `<ASBIData>` fast-path — that only kicks in at the outer body-member level) with children `contextNameField` / `idField` / `idFieldSpecified` / `nameField` / `referenceTypeField` / `typeField` under a different `b` prefix mapping to `http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBContract`. The Variant fields (`userDataField` / `valueDeadbandField`) carry `lengthField` / `payloadField` / `typeField` children. Same fix likely applies to `WriteBasicRequest`'s `WriteValue[]? Values` field (also non-`IAsbCustomSerializable`); needs its own capture-and-verify pass.
|
|
||||||
|
|
||||||
The dictionary-id pre-population that .NET's WCF binary writer uses is a perf optimisation; an inline-string emit will work for correctness once the structure is right.
|
|
||||||
|
|
||||||
**Bonus context discovered while debugging F34:**
|
|
||||||
- `MinimalMonitoredItem` gained an `active: Option<bool>` field with a `with_active(item, interval, active)` constructor. Without `<Active>true</Active>` on the wire, MxDataProvider treats the subscription as inactive even when AddMonitoredItems "succeeds" — F26 stream then never sees values. (Once the field-name fix lands, this becomes the determining factor for whether values flow.)
|
|
||||||
- `SampleInterval` unit corrected from "100-ns ticks" to **milliseconds** in the example + the `MinimalMonitoredItem.sample_interval` doc — matches `MxAsbDataClient.cs:441`'s `ulong sampleInterval = 1000` default.
|
|
||||||
- `result_code = 32` is `AsbErrorCode.PublishComplete` (`AsbResultMapping.cs:37`), informational not fatal — `ToResult:122-129` treats it like `Success`. F26 stream's `publish_loop` narrowed to bail only on `RESULT_CODE_INVALID_CONNECTION_ID = 1`.
|
|
||||||
|
|
||||||
**Evidence.** Side-by-side comparison after the .NET probe and Rust client both ran `--subscribe` against the same `TestChildObject.TestInt` tag:
|
|
||||||
|
|
||||||
```
|
|
||||||
.NET probe (working):
|
|
||||||
publish[0]_error=0x00000020 status=0x00000000 specific=0x00000000
|
|
||||||
published_event[0]=item:TestChildObject.TestInt id:18446462598732840962
|
|
||||||
type:4 quality:0x00C0 timestamp:... preview:99
|
|
||||||
publish[0]_value[0]=item: id:... id_specified:True type:4 length:4
|
|
||||||
payload_len:4 preview:99
|
|
||||||
|
|
||||||
Rust client (this followup):
|
|
||||||
poll 0: 0 value(s); result_code=Some(32) success=Some(false)
|
|
||||||
poll 1: 0 value(s); result_code=Some(32) success=Some(false)
|
|
||||||
... (8 polls total, 0 values each)
|
|
||||||
```
|
|
||||||
|
|
||||||
Both sides see the same `result_code=32` (= `AsbErrorCode.PublishComplete`, informational per `AsbResultMapping.cs:37` + `ToResult:122-129` which treats it like `Success`). Both sides see `successField=false`. **But .NET extracts a value while we extract zero.**
|
|
||||||
|
|
||||||
**Confirmed not the cause:**
|
|
||||||
- ✅ `SampleInterval` unit (was 10_000_000 in example, fixed to `1000` ms — matches `MxAsbDataClient.cs:441`'s `ulong sampleInterval = 1000` default).
|
|
||||||
- ✅ Treating `result_code=32` as informational (the F26 narrower-bail fix matches the .NET logic exactly).
|
|
||||||
- ✅ Canonical-XML signing of the `PublishRequest` (the server returns a typed response, not a SOAP fault).
|
|
||||||
- ✅ `MonitoredItemValue` IS `IAsbCustomSerializableType` (`AsbContracts.cs:1035`), so it travels via the `<Values><ASBIData>{bytes}</ASBIData></Values>` binary fast-path same as `Read`'s Values path which works.
|
|
||||||
|
|
||||||
**Open hypotheses (each needs wire-capture verification via `examples/asb-relay.rs` middleman with the .NET probe):**
|
|
||||||
1. Wire shape mismatch: server may emit Values via a different element/namespace than `<Values>` in this case (some kind of dynamic dict id we don't resolve), so our `collect_asbidata_payloads` walking by name misses it.
|
|
||||||
2. Status array shape: a 0-length Status (empty array, 4-byte `count=0`) may render as a populated `<ASBIData>` containing just `[0,0,0,0]`; our decoder reads `payloads[0]` as Status (correctly empty) and `payloads[1]` as Values — but if the server's wire only has 1 ASBIData (Status was omitted entirely, not empty-but-present), `payloads[1]` would BE the Values payload getting mis-read as Status.
|
|
||||||
3. `decode_monitored_item_value_array` (which builds on `MonitoredItemValue::decode` at `contracts.rs:277`) may have a subtle bug — `MonitoredItemValue` carries `ItemIdentity + RuntimeValue + AsbVariant userdata` per `cs:1064-1068`; if the wire bytes don't match that order or include extra fields, the count read at offset 0 would be wrong.
|
|
||||||
|
|
||||||
**Resolves when:** A middleman trace via `examples/asb-relay.rs` between the .NET probe and MxDataProvider for a working subscribe-flow exposes the actual wire bytes of a `PublishResponse` carrying values. Compare against `decode_publish_response`'s expectations and reconcile (likely a `MonitoredItemValue` byte-layout fix or a Values-payload-locator fix).
|
|
||||||
|
|
||||||
**Adjacent observation worth noting:** `AddMonitoredItemsResponse` shows the same symptom shape — our trace reports `add status: 0 item(s); result_code=Some(0) success=Some(true)` while the .NET probe reports `add_monitored_status[0]=item:TestChildObject.TestInt id:18446462598732840962 ...`. Same `IAsbCustomSerializableType`-wrapped Status array; same "0 items where .NET sees 1". These two decoders likely fail for the same root reason and a single fix should close both.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### F3 — Cross-domain NTLM Type1/2/3 fixture
|
### F3 — Cross-domain NTLM Type1/2/3 fixture
|
||||||
**Severity:** P2
|
**Severity:** P2
|
||||||
**Status:** Permanently out-of-scope on the current dev host (no second AD domain). Resolution requires external infrastructure not available here.
|
**Status:** Permanently out-of-scope on the current dev host (no second AD domain). Resolution requires external infrastructure not available here.
|
||||||
@@ -178,6 +17,53 @@ Both sides see the same `result_code=32` (= `AsbErrorCode.PublishComplete`, info
|
|||||||
|
|
||||||
## Resolved
|
## Resolved
|
||||||
|
|
||||||
|
### F18 — M5 plan of attack (ASB transport, parallel-safe sub-streams)
|
||||||
|
**Resolved:** 2026-05-06 — all sub-followups F19–F26 closed plus F28 / F29 / F30 / F31 / F32 / F33 / F34 layered on top. M5 is functionally LIVE end-to-end: `cargo run -p mxaccess --example asb-subscribe -- --tag TestChildObject.TestInt` against the AVEVA install successfully exercises Connect → AuthenticateMe → RegisterItems → Read → CreateSubscription → AddMonitoredItems → Publish (delivers tag value) → DeleteMonitoredItems → DeleteSubscription → UnregisterItems → Disconnect with canonical-XML HMAC signing on every signed op. **Severity:** P0 — milestone driver, blocks ASB consumers + V1 release.
|
||||||
|
**Source:** `design/dependencies.md:73-89` + `design/60-roadmap.md:84-91` + `design/70-risks-and-open-questions.md:5-25`.
|
||||||
|
|
||||||
|
**M5 DoD per `design/60-roadmap.md:91`:**
|
||||||
|
1. ✅ `cargo run -p mxaccess --example asb-subscribe` succeeds against the live AVEVA endpoint — Read returns the real tag value, Publish stream delivers monitored values via the F26 stream (`AsbVariant { type_id: 4, length: 4, payload: [99, 0, 0, 0] }`).
|
||||||
|
2. ⚠️ Wire structure matches .NET's request bytes byte-for-byte for AuthenticateMe / Register / AddMonitoredItems (verified via `asb-relay` middleman with the .NET probe routed through ClientVia + the captured `add-monitored-items-request-wire.bin` fixture for F34). Strict byte-identical parity for the response side is not guaranteed because WCF chunks `Bytes8/16/32` records at different boundaries — both forms are functionally equivalent and `collect_asbidata_payloads` concatenates chunks (commit `cf97eab`). Canonical XML for the 13 signed ops is byte-equal to .NET's `XmlSerializer.Serialize` output (F28 fixture-comparison tests).
|
||||||
|
3. ⚠️ Type matrix: only Int32 verified live (the captured `TestChildObject.TestInt` tag). Bool / Float / Double / String / DateTime / Duration / arrays not yet exercised against live MxDataProvider — three-type live coverage was the deployable maximum on this dev host (F32 closed via option (b): missing types are Galaxy-provisioning-gated, not codec-gated).
|
||||||
|
4. ✅ `cargo build --workspace` + `cargo test --workspace` (758 tests) + `cargo clippy --workspace -- -D warnings` all green.
|
||||||
|
|
||||||
|
**M5 sub-followup closeout:**
|
||||||
|
- ~~F19~~: workspace deps for `aes` / `hmac` / `md-5` / `sha1` / `sha2` / `pbkdf2` / `flate2` / `rand` / `crypto-bigint` / `quick-xml` / `tokio-util`.
|
||||||
|
- ~~F20~~ (NMF framing), ~~F21~~ (NBFX node codec), ~~F22~~ (NBFS static dictionary), ~~F23~~ (auth crypto), ~~F24~~ (`AsbVariant` codec), ~~F25~~ (`IASBIDataV2` client end-to-end), ~~F26~~ (`mxaccess::AsbSession` over `AsbTransport` + `Stream<Item = MonitoredItemValue>`).
|
||||||
|
- ~~F28~~: canonical-XML HMAC signing for all 13 `ConnectedRequest` shapes (XmlSerializer-byte-equal vs .NET fixtures; legacy NBFX-bytes fallback retired).
|
||||||
|
- ~~F29~~: `nbfs.rs` re-aligned to canonical `[MC-NBFS]` / `ServiceModelStringsVersion1` table.
|
||||||
|
- ~~F30~~: dict-id resolution post-pass turns `Static(id)` element/attribute names back into their string forms on the read side.
|
||||||
|
- ~~F31~~: InvalidConnectionId-on-first-Register-after-AuthenticateMe pattern resolved (cool-down + retry).
|
||||||
|
- ~~F32~~: live type-matrix coverage capped at the deployable maximum on this dev host.
|
||||||
|
- ~~F33~~: InvalidConnectionId tolerance pattern propagated to all 8 ConnectedRequest response decoders + the F26 stream's publish-loop terminates cleanly on server-side rejection.
|
||||||
|
- ~~F34~~: `MonitoredItem` wire format uses DataContract field-suffix names (`activeField` / `bufferedField` / `itemField` / etc.) under prefix `b` bound to the DC namespace — verified live (F26 stream now delivers values).
|
||||||
|
|
||||||
|
**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`); F25 live-bring-up reconciliation (NBFX `PrefixElement_a..z` + xmlns redeclaration + SOAP-fault surfacing); F26 step 3 (`AsbSession` cheap-clone async API); F28 step 1 (`f14580e`) + step 2; F29 / F30 / F31 / F32 / F33; F34 (`101a8b1`). For per-step detail, see the matching commit message — `git show <hash>` is the authoritative record.
|
||||||
|
|
||||||
|
**Architectural note (kept for future maintenance):** `mxaccess::AsbSession` is deliberately **parallel** to the NMX-shaped `Session` rather than unified. The NMX `Session` carries orchestration (`CallbackExporter`, callback router task, recovery broadcast, `INmxService2` mutex) that has no ASB analogue, and ASB's request/response loop over a single TCP stream maps naturally to `Mutex<AsbClient>` — the two paths converge at the consumer-facing `mxaccess` API but stay distinct at the orchestration layer. `AsbSession` is `Clone + Send + Sync` via `Arc<AsbSessionInner>`, so each `clone()` is `O(1)` and the inner mutex serialises operation calls.
|
||||||
|
|
||||||
|
### F34 — `MonitoredItem` wire format: DataContract field-suffix names, not XmlSerializer property names
|
||||||
|
**Resolved:** 2026-05-06 (commit `101a8b1`). **Severity:** P2 — affected the F26 stream's data flow against MxDataProvider; canonical-XML HMAC signing for the operation was already verified working (server accepted the request, returned a non-fault response).
|
||||||
|
|
||||||
|
**Two halves, both closed:**
|
||||||
|
|
||||||
|
**Half 1 — Response decoder (closed earlier).** `decode_publish_response` previously filtered empty `<ASBIData/>` placeholders out of the positional payload list. Captured the full S→C bytes of a working `PublishResponse` via `examples/asb-relay.rs` between the .NET probe and MxDataProvider (fixture stashed at `crates/mxaccess-asb/tests/fixtures/publish-response-with-value.bin`). The wire shape is `<Status><ASBIData/></Status><Values><ASBIData>{bytes}</ASBIData></Values>` — Status is empty-but-present, Values carries the binary `MonitoredItemValue[]`. `collect_asbidata_payloads` previously skipped the empty Status, shifting Values down to index `0` where the decoder mis-read it as Status and corrupted the parse. Fix: always push every `<ASBIData>` element as a positional entry, empty or not. `tests/publish_capture.rs` runs the full decode chain over the real wire bytes and asserts `values.len() == 1`.
|
||||||
|
|
||||||
|
**Half 2 — Request body emitter (closed by this commit).** Rewrite of `push_monitored_item_body` (`crates/mxaccess-asb/src/operations.rs`) replaces the legacy XmlSerializer property names (`<MonitoredItem>`, `<Item>`, `<SampleInterval>`, `<Active>`, `<Buffered>`) with the WCF DataContract field-suffix names emitted under prefix `b` bound to `http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBIDataV2Contract`. Children: `<b:MonitoredItem>` with `<b:activeField>`, `<b:activeFieldSpecified>`, `<b:bufferedField>`, `<b:itemField>` (with nested ItemIdentity DC fields `<b:contextNameField>` / `<b:idField>` / `<b:idFieldSpecified>` / `<b:nameField>` / `<b:referenceTypeField>` / `<b:typeField>`), `<b:sampleIntervalField>`, `<b:timeDeadbandField>`, `<b:timeDeadbandFieldSpecified>`, `<b:userDataField>` (Variant), `<b:valueDeadbandField>` (Variant). The `<Items>` wrapper now declares `xmlns:b` + `xmlns:i` (XSI). Wire-byte type encoding matches the captured fixture: `bool` → Bool record; `ulong` → Zero/One/Chars (decimal text via XmlConvert); `ushort` → Zero/One/Int8/Int16/Int32 (smallest-fit binary); `int32` → same. Empty `string?` and null `byte[]?` emit as empty elements (no `<i:nil>` attribute, matching the wire). Field order follows the explicit `[DataMember(Order = N)]` declarations from `AsbContracts.cs:940-965`. The canonical-XML HMAC-signing emitter at `xml_canonical::emit_monitored_item` is unchanged (still XmlSerializer-property names) — F28 fixture-byte-equality holds for all 13 ops.
|
||||||
|
|
||||||
|
**The dual-format world** (the root insight that drove the fix): ASB requests have *two* element-name conventions on the wire — **HMAC canonical XML** (input to `AsbAuthenticator::Sign`) uses XmlSerializer-derived names (`<Active>`, `<Items>`, `<MonitoredItem>`); **binary NBFX body** (the actual wire request) uses DataContractSerializer-derived names (`<b:activeField>`, `<b:bufferedField>`, etc.). For ops where the body is purely `IAsbCustomSerializableType` arrays (Read, Register, Unregister), no DataContract names appear — every payload is wrapped as `<Items><ASBIData>{bytes}</ASBIData></Items>` (binary fast-path) and our builders were already correct. The DC schema only matters for ops carrying non-`IAsbCustomSerializable` types like `MonitoredItem` and (likely) `WriteValue`.
|
||||||
|
|
||||||
|
**Captured ground-truth dictionary** (from `tests/fixtures/add-monitored-items-request-wire.bin` — `tests/add_monitored_items_request_capture.rs` decodes it). The .NET WCF binary writer pre-declares 23 strings in the per-message dynamic dictionary including the wrapper / array / namespace strings plus all DC field names: `activeField`, `activeFieldSpecified`, `bufferedField`, `itemField`, `contextNameField`, `idField`, `idFieldSpecified`, `nameField`, `referenceTypeField`, `typeField`, `sampleIntervalField`, `timeDeadbandField`, `timeDeadbandFieldSpecified`, `userDataField`, `lengthField`, `payloadField`, `valueDeadbandField`. The dictionary-id pre-population that .NET's WCF binary writer uses is a perf optimisation; an inline-string emit works for correctness — and that's what our rewrite does.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
1. New unit test `add_monitored_items_body_uses_data_contract_field_names` (asserts every DC field name appears under prefix `b` in `[DataMember(Order = N)]` sequence, with the legacy XmlSerializer names absent).
|
||||||
|
2. Live `cargo run -p mxaccess --example asb-subscribe -- --tag TestChildObject.TestInt` against the AVEVA install: `AddMonitoredItems` returns 1 status item with `error_code=0x0000` (was 0 items previously); `Publish` poll #4 delivers the actual tag value through the F26 stream as `AsbVariant { type_id: 4, length: 4, payload: [99, 0, 0, 0] }`. Workspace `cargo test` 757 → 758 pass; clippy clean.
|
||||||
|
|
||||||
|
**Bonus context discovered while debugging F34:**
|
||||||
|
- `MinimalMonitoredItem` gained an `active: Option<bool>` field with `with_active(item, interval, active)` constructor. Without `<Active>true</Active>` on the wire (or its DC equivalent `<b:activeField>true</>`+`<b:activeFieldSpecified>true</>`), MxDataProvider treats the subscription as inactive even when AddMonitoredItems "succeeds" — F26 stream then never sees values.
|
||||||
|
- `SampleInterval` unit corrected from "100-ns ticks" to **milliseconds** in the example + the `MinimalMonitoredItem.sample_interval` doc — matches `MxAsbDataClient.cs:441`'s `ulong sampleInterval = 1000` default.
|
||||||
|
- `result_code = 32` is `AsbErrorCode.PublishComplete` (`AsbResultMapping.cs:37`), informational not fatal — `ToResult:122-129` treats it like `Success`. F26 stream's `publish_loop` narrowed to bail only on `RESULT_CODE_INVALID_CONNECTION_ID = 1`.
|
||||||
|
|
||||||
### F28 — Canonical XML serialiser for `ConnectedRequest` signing (matches `XmlSerializer.Serialize` byte-for-byte)
|
### F28 — Canonical XML serialiser for `ConnectedRequest` signing (matches `XmlSerializer.Serialize` byte-for-byte)
|
||||||
**Resolved:** 2026-05-06 (commit `<this 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).
|
**Resolved:** 2026-05-06 (commit `<this 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).
|
||||||
|
|
||||||
|
|||||||
@@ -335,11 +335,18 @@ impl AsbAuthenticator {
|
|||||||
out.as_mut_slice(),
|
out.as_mut_slice(),
|
||||||
);
|
);
|
||||||
if std::env::var("MX_ASB_TRACE_DERIVE").ok().is_some() {
|
if std::env::var("MX_ASB_TRACE_DERIVE").ok().is_some() {
|
||||||
|
use std::fmt::Write as _;
|
||||||
eprintln!("asb.derive.crypto_key.len={}", crypto_key.len());
|
eprintln!("asb.derive.crypto_key.len={}", crypto_key.len());
|
||||||
let hex: String = crypto_key.iter().map(|b| format!("{b:02X}")).collect();
|
let hex = crypto_key.iter().fold(String::new(), |mut s, b| {
|
||||||
|
let _ = write!(s, "{b:02X}");
|
||||||
|
s
|
||||||
|
});
|
||||||
eprintln!("asb.derive.crypto_key.hex={hex}");
|
eprintln!("asb.derive.crypto_key.hex={hex}");
|
||||||
eprintln!("asb.derive.crypto_key.b64={password_b64}");
|
eprintln!("asb.derive.crypto_key.b64={password_b64}");
|
||||||
let aes_hex: String = out.iter().map(|b| format!("{b:02X}")).collect();
|
let aes_hex = out.iter().fold(String::new(), |mut s, b| {
|
||||||
|
let _ = write!(s, "{b:02X}");
|
||||||
|
s
|
||||||
|
});
|
||||||
eprintln!("asb.derive.aes_key.hex={aes_hex}");
|
eprintln!("asb.derive.aes_key.hex={aes_hex}");
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
|
|||||||
@@ -947,9 +947,13 @@ fn detect_soap_fault(decoded: &crate::DecodedEnvelope) -> Option<ClientError> {
|
|||||||
/// Hex dump for diagnostic traces. First 256 bytes only to keep
|
/// Hex dump for diagnostic traces. First 256 bytes only to keep
|
||||||
/// MX_ASB_TRACE_REPLY output bounded.
|
/// MX_ASB_TRACE_REPLY output bounded.
|
||||||
fn hex_dump(bytes: &[u8]) -> String {
|
fn hex_dump(bytes: &[u8]) -> String {
|
||||||
|
use std::fmt::Write as _;
|
||||||
let cap = bytes.len().min(256);
|
let cap = bytes.len().min(256);
|
||||||
let slice = bytes.get(..cap).unwrap_or(&[]);
|
let slice = bytes.get(..cap).unwrap_or(&[]);
|
||||||
slice.iter().map(|b| format!("{b:02x}")).collect()
|
slice.iter().fold(String::with_capacity(cap * 2), |mut s, b| {
|
||||||
|
let _ = write!(s, "{b:02x}");
|
||||||
|
s
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- error type ----------------------------------------------------------
|
// ---- error type ----------------------------------------------------------
|
||||||
|
|||||||
@@ -517,6 +517,14 @@ pub fn build_delete_monitored_items_request_body(
|
|||||||
prefix: None,
|
prefix: None,
|
||||||
name: NbfxName::Inline("Items".to_string()),
|
name: NbfxName::Inline("Items".to_string()),
|
||||||
},
|
},
|
||||||
|
NbfxToken::NamespaceDeclaration {
|
||||||
|
prefix: "b".to_string(),
|
||||||
|
value: NbfxText::Chars(DC_ASBIDATAV2_NS.to_string()),
|
||||||
|
},
|
||||||
|
NbfxToken::NamespaceDeclaration {
|
||||||
|
prefix: "i".to_string(),
|
||||||
|
value: NbfxText::Chars(XSI_NS.to_string()),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
for item in items {
|
for item in items {
|
||||||
push_monitored_item_body(&mut tokens, item);
|
push_monitored_item_body(&mut tokens, item);
|
||||||
@@ -767,11 +775,19 @@ pub fn build_add_monitored_items_request_body(
|
|||||||
},
|
},
|
||||||
NbfxToken::Text(NbfxText::Int64(subscription_id)),
|
NbfxToken::Text(NbfxText::Int64(subscription_id)),
|
||||||
NbfxToken::EndElement,
|
NbfxToken::EndElement,
|
||||||
// <Items>
|
// <Items xmlns:b="<DC namespace>" xmlns:i="<xsi namespace>">
|
||||||
NbfxToken::Element {
|
NbfxToken::Element {
|
||||||
prefix: None,
|
prefix: None,
|
||||||
name: NbfxName::Inline("Items".to_string()),
|
name: NbfxName::Inline("Items".to_string()),
|
||||||
},
|
},
|
||||||
|
NbfxToken::NamespaceDeclaration {
|
||||||
|
prefix: "b".to_string(),
|
||||||
|
value: NbfxText::Chars(DC_ASBIDATAV2_NS.to_string()),
|
||||||
|
},
|
||||||
|
NbfxToken::NamespaceDeclaration {
|
||||||
|
prefix: "i".to_string(),
|
||||||
|
value: NbfxText::Chars(XSI_NS.to_string()),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
for item in items {
|
for item in items {
|
||||||
push_monitored_item_body(&mut tokens, item);
|
push_monitored_item_body(&mut tokens, item);
|
||||||
@@ -788,55 +804,185 @@ pub fn build_add_monitored_items_request_body(
|
|||||||
tokens
|
tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit a single `<MonitoredItem>...</MonitoredItem>` NBFX subtree.
|
/// Emit a single `<b:MonitoredItem>...</b:MonitoredItem>` NBFX subtree.
|
||||||
/// Shared between AddMonitoredItems and DeleteMonitoredItems request
|
/// Shared between AddMonitoredItems and DeleteMonitoredItems request
|
||||||
/// builders. Field order matches the .NET `MonitoredItem` declaration:
|
/// builders.
|
||||||
/// Item / SampleInterval / Active (when Specified) / Buffered.
|
///
|
||||||
|
/// **Wire shape: DataContract field-suffix names, NOT XmlSerializer
|
||||||
|
/// property names.** MxDataProvider's binary deserialiser is the
|
||||||
|
/// `DataContractSerializer`-driven path for non-`IAsbCustomSerializable`
|
||||||
|
/// types like `MonitoredItem`, so the on-the-wire element names are the
|
||||||
|
/// `[DataMember(Name = "...")]` private-field names from
|
||||||
|
/// `AsbContracts.cs:940-965` — `activeField`, `bufferedField`,
|
||||||
|
/// `itemField`, `sampleIntervalField`, etc. — and they live in the DC
|
||||||
|
/// namespace `http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBIDataV2Contract`
|
||||||
|
/// (prefix `b`).
|
||||||
|
///
|
||||||
|
/// Field order follows the explicit `[DataMember(Order = N)]` attributes
|
||||||
|
/// (alphabetical-by-default for DC, but explicitly numbered here):
|
||||||
|
/// `activeField`, `activeFieldSpecified`, `bufferedField`, `itemField`,
|
||||||
|
/// `sampleIntervalField`, `timeDeadbandField`,
|
||||||
|
/// `timeDeadbandFieldSpecified`, `userDataField`, `valueDeadbandField`.
|
||||||
|
///
|
||||||
|
/// The canonical-XML HMAC signing path (`xml_canonical::emit_monitored_item`)
|
||||||
|
/// uses XmlSerializer property names (`<Active>`, `<Item>`, etc.) — that
|
||||||
|
/// stays unchanged because `XmlSerializer.Serialize` is what the .NET
|
||||||
|
/// `AsbSystemAuthenticator.Sign` HMACs over (canonical XML form).
|
||||||
|
/// Verified against the captured `add-monitored-items-request-wire.bin`
|
||||||
|
/// fixture — F34.
|
||||||
fn push_monitored_item_body(tokens: &mut Vec<NbfxToken>, item: &MinimalMonitoredItem) {
|
fn push_monitored_item_body(tokens: &mut Vec<NbfxToken>, item: &MinimalMonitoredItem) {
|
||||||
tokens.push(NbfxToken::Element {
|
tokens.push(NbfxToken::Element {
|
||||||
prefix: None,
|
prefix: Some("b".to_string()),
|
||||||
name: NbfxName::Inline("MonitoredItem".to_string()),
|
name: NbfxName::Inline("MonitoredItem".to_string()),
|
||||||
});
|
});
|
||||||
// <Item><ASBIData>{ItemIdentity binary}</ASBIData></Item>
|
// Order 0: activeField (bool — defaults to false when not Specified)
|
||||||
|
push_b_bool(tokens, "activeField", item.active.unwrap_or(false));
|
||||||
|
// Order 1: activeFieldSpecified (bool — true iff `active` is Some)
|
||||||
|
push_b_bool(tokens, "activeFieldSpecified", item.active.is_some());
|
||||||
|
// Order 2: bufferedField
|
||||||
|
push_b_bool(tokens, "bufferedField", item.buffered);
|
||||||
|
// Order 3: itemField (nested ItemIdentity, DataContract-serialised
|
||||||
|
// — NOT the binary <ASBIData> fast-path, which only kicks in at
|
||||||
|
// top-level message body members).
|
||||||
|
push_b_item_identity(tokens, &item.item);
|
||||||
|
// Order 4: sampleIntervalField (ulong — WCF binary writer emits
|
||||||
|
// ulong as `Chars8`/etc. text via `XmlConvert.ToString` for non-0/1
|
||||||
|
// values; 0/1 collapse to the Zero/One text records).
|
||||||
|
push_b_ulong_text(tokens, "sampleIntervalField", item.sample_interval);
|
||||||
|
// Order 5+6: timeDeadbandField + timeDeadbandFieldSpecified —
|
||||||
|
// omitted-from-public-API on `MinimalMonitoredItem`; emit defaults.
|
||||||
|
push_b_ulong_text(tokens, "timeDeadbandField", 0);
|
||||||
|
push_b_bool(tokens, "timeDeadbandFieldSpecified", false);
|
||||||
|
// Order 7: userDataField (empty Variant — typeField=65535 = "no value")
|
||||||
|
push_b_empty_variant(tokens, "userDataField");
|
||||||
|
// Order 8: valueDeadbandField (empty Variant)
|
||||||
|
push_b_empty_variant(tokens, "valueDeadbandField");
|
||||||
|
tokens.push(NbfxToken::EndElement); // </b:MonitoredItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<b:{name}>{bool}</b:{name}>` — Bool text record (with-end-element
|
||||||
|
/// variant chosen by the encoder).
|
||||||
|
fn push_b_bool(tokens: &mut Vec<NbfxToken>, name: &str, value: bool) {
|
||||||
tokens.push(NbfxToken::Element {
|
tokens.push(NbfxToken::Element {
|
||||||
prefix: None,
|
prefix: Some("b".to_string()),
|
||||||
name: NbfxName::Inline("Item".to_string()),
|
name: NbfxName::Inline(name.to_string()),
|
||||||
});
|
});
|
||||||
tokens.push(NbfxToken::Element {
|
tokens.push(NbfxToken::Text(NbfxText::Bool(value)));
|
||||||
prefix: None,
|
|
||||||
name: NbfxName::Inline("ASBIData".to_string()),
|
|
||||||
});
|
|
||||||
tokens.push(NbfxToken::Text(NbfxText::Bytes(item.item.encode())));
|
|
||||||
tokens.push(NbfxToken::EndElement); // </ASBIData>
|
|
||||||
tokens.push(NbfxToken::EndElement); // </Item>
|
|
||||||
// <SampleInterval>
|
|
||||||
tokens.push(NbfxToken::Element {
|
|
||||||
prefix: None,
|
|
||||||
name: NbfxName::Inline("SampleInterval".to_string()),
|
|
||||||
});
|
|
||||||
tokens.push(NbfxToken::Text(NbfxText::Int64(
|
|
||||||
item.sample_interval as i64,
|
|
||||||
)));
|
|
||||||
tokens.push(NbfxToken::EndElement);
|
tokens.push(NbfxToken::EndElement);
|
||||||
// <Active> — emitted only when ActiveSpecified=true
|
}
|
||||||
// (`MonitoredItem.Active` setter at `AsbContracts.cs:982-987`).
|
|
||||||
// Required to make MxDataProvider actually deliver values; F34.
|
/// `<b:{name}>{ulong-as-text}</b:{name}>` — WCF emits `ulong` via
|
||||||
if let Some(active) = item.active {
|
/// `XmlConvert.ToString` (decimal text) which the binary writer then
|
||||||
tokens.push(NbfxToken::Element {
|
/// encodes as `Chars8`. Values 0 and 1 collapse to the dedicated
|
||||||
prefix: None,
|
/// `ZeroText` / `OneText` records that the WCF binary writer prefers
|
||||||
name: NbfxName::Inline("Active".to_string()),
|
/// when the text would be `"0"` / `"1"`.
|
||||||
});
|
fn push_b_ulong_text(tokens: &mut Vec<NbfxToken>, name: &str, value: u64) {
|
||||||
tokens.push(NbfxToken::Text(NbfxText::Bool(active)));
|
tokens.push(NbfxToken::Element {
|
||||||
tokens.push(NbfxToken::EndElement);
|
prefix: Some("b".to_string()),
|
||||||
|
name: NbfxName::Inline(name.to_string()),
|
||||||
|
});
|
||||||
|
let text = match value {
|
||||||
|
0 => NbfxText::Zero,
|
||||||
|
1 => NbfxText::One,
|
||||||
|
n => NbfxText::Chars(n.to_string()),
|
||||||
|
};
|
||||||
|
tokens.push(NbfxToken::Text(text));
|
||||||
|
tokens.push(NbfxToken::EndElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<b:{name}>{ushort-as-binary}</b:{name}>` — `ushort` goes through
|
||||||
|
/// `WriteInt32` in WCF binary, which emits `Zero` / `One` for those
|
||||||
|
/// values and `Int8` / `Int16` / `Int32` for larger values (smallest
|
||||||
|
/// width that fits).
|
||||||
|
fn push_b_ushort(tokens: &mut Vec<NbfxToken>, name: &str, value: u16) {
|
||||||
|
tokens.push(NbfxToken::Element {
|
||||||
|
prefix: Some("b".to_string()),
|
||||||
|
name: NbfxName::Inline(name.to_string()),
|
||||||
|
});
|
||||||
|
let text = match value {
|
||||||
|
0 => NbfxText::Zero,
|
||||||
|
1 => NbfxText::One,
|
||||||
|
n if n <= i8::MAX as u16 => NbfxText::Int8(n as i8),
|
||||||
|
n if n <= i16::MAX as u16 => NbfxText::Int16(n as i16),
|
||||||
|
n => NbfxText::Int32(n as i32),
|
||||||
|
};
|
||||||
|
tokens.push(NbfxToken::Text(text));
|
||||||
|
tokens.push(NbfxToken::EndElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<b:{name}>{string-or-empty-element}</b:{name}>` — WCF emits a
|
||||||
|
/// non-empty string as `Chars8/16/32` text and `Some("")` / `None` as
|
||||||
|
/// an empty element (no child text). The captured wire shows no
|
||||||
|
/// `i:nil="true"` attribute even when the field semantically maps to
|
||||||
|
/// .NET `null`, so we skip the nil-attribute path.
|
||||||
|
fn push_b_string(tokens: &mut Vec<NbfxToken>, name: &str, value: Option<&str>) {
|
||||||
|
tokens.push(NbfxToken::Element {
|
||||||
|
prefix: Some("b".to_string()),
|
||||||
|
name: NbfxName::Inline(name.to_string()),
|
||||||
|
});
|
||||||
|
if let Some(s) = value {
|
||||||
|
if !s.is_empty() {
|
||||||
|
tokens.push(NbfxToken::Text(NbfxText::Chars(s.to_string())));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// <Buffered>
|
|
||||||
tokens.push(NbfxToken::Element {
|
|
||||||
prefix: None,
|
|
||||||
name: NbfxName::Inline("Buffered".to_string()),
|
|
||||||
});
|
|
||||||
tokens.push(NbfxToken::Text(NbfxText::Bool(item.buffered)));
|
|
||||||
tokens.push(NbfxToken::EndElement);
|
tokens.push(NbfxToken::EndElement);
|
||||||
tokens.push(NbfxToken::EndElement); // </MonitoredItem>
|
}
|
||||||
|
|
||||||
|
/// Emit a nested `ItemIdentity` as DataContract fields. Order matches
|
||||||
|
/// `AsbContracts.cs:533-553`: contextNameField, idField, idFieldSpecified,
|
||||||
|
/// nameField, referenceTypeField, typeField (alphabetical by member
|
||||||
|
/// name = the explicit `[DataMember(Order = N)]` ordering).
|
||||||
|
fn push_b_item_identity(tokens: &mut Vec<NbfxToken>, identity: &ItemIdentity) {
|
||||||
|
tokens.push(NbfxToken::Element {
|
||||||
|
prefix: Some("b".to_string()),
|
||||||
|
name: NbfxName::Inline("itemField".to_string()),
|
||||||
|
});
|
||||||
|
push_b_string(tokens, "contextNameField", identity.context_name.as_deref());
|
||||||
|
push_b_ulong_text(tokens, "idField", identity.id);
|
||||||
|
push_b_bool(tokens, "idFieldSpecified", identity.id_specified);
|
||||||
|
push_b_string(tokens, "nameField", identity.name.as_deref());
|
||||||
|
push_b_ushort(tokens, "referenceTypeField", identity.reference_type);
|
||||||
|
push_b_ushort(tokens, "typeField", identity.kind);
|
||||||
|
tokens.push(NbfxToken::EndElement); // </b:itemField>
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit an empty `Variant` (no payload, type = 65535 = "no value").
|
||||||
|
/// Field order follows `AsbContracts.cs:1170-1181`: lengthField,
|
||||||
|
/// payloadField, typeField.
|
||||||
|
fn push_b_empty_variant(tokens: &mut Vec<NbfxToken>, name: &str) {
|
||||||
|
tokens.push(NbfxToken::Element {
|
||||||
|
prefix: Some("b".to_string()),
|
||||||
|
name: NbfxName::Inline(name.to_string()),
|
||||||
|
});
|
||||||
|
push_b_int_text(tokens, "lengthField", 0);
|
||||||
|
// payloadField is `byte[]?`; an empty/null value emits as an empty
|
||||||
|
// element (no `<i:nil>` attribute on the captured wire).
|
||||||
|
tokens.push(NbfxToken::Element {
|
||||||
|
prefix: Some("b".to_string()),
|
||||||
|
name: NbfxName::Inline("payloadField".to_string()),
|
||||||
|
});
|
||||||
|
tokens.push(NbfxToken::EndElement);
|
||||||
|
push_b_ushort(tokens, "typeField", 65535);
|
||||||
|
tokens.push(NbfxToken::EndElement); // </b:{name}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<b:{name}>{int32}</b:{name}>` — int32 via the smallest-fit binary
|
||||||
|
/// text record (matches WCF's `WriteInt32` which collapses 0 / 1 to
|
||||||
|
/// the Zero / One text records).
|
||||||
|
fn push_b_int_text(tokens: &mut Vec<NbfxToken>, name: &str, value: i32) {
|
||||||
|
tokens.push(NbfxToken::Element {
|
||||||
|
prefix: Some("b".to_string()),
|
||||||
|
name: NbfxName::Inline(name.to_string()),
|
||||||
|
});
|
||||||
|
let text = match value {
|
||||||
|
0 => NbfxText::Zero,
|
||||||
|
1 => NbfxText::One,
|
||||||
|
n if (i8::MIN as i32..=i8::MAX as i32).contains(&n) => NbfxText::Int8(n as i8),
|
||||||
|
n if (i16::MIN as i32..=i16::MAX as i32).contains(&n) => NbfxText::Int16(n as i16),
|
||||||
|
n => NbfxText::Int32(n),
|
||||||
|
};
|
||||||
|
tokens.push(NbfxToken::Text(text));
|
||||||
|
tokens.push(NbfxToken::EndElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal `MonitoredItem` shape covering `Item`, `SampleInterval`,
|
/// Minimal `MonitoredItem` shape covering `Item`, `SampleInterval`,
|
||||||
@@ -1457,6 +1603,19 @@ pub fn build_unregister_items_request_body(items: &[ItemIdentity]) -> Vec<NbfxTo
|
|||||||
|
|
||||||
const IOM_NS: &str = "urn:msg.data.asb.iom:2";
|
const IOM_NS: &str = "urn:msg.data.asb.iom:2";
|
||||||
|
|
||||||
|
/// DataContract namespace for `MonitoredItem` / `ItemIdentity` /
|
||||||
|
/// `Variant` etc. Source: `[DataContract(Namespace = "...")]` on each
|
||||||
|
/// type at `AsbContracts.cs:533, 936, 1170`. F34: this is the wire
|
||||||
|
/// namespace for nested DataContract members emitted under the `b`
|
||||||
|
/// prefix inside `<Items>` / `<Values>` payloads.
|
||||||
|
const DC_ASBIDATAV2_NS: &str =
|
||||||
|
"http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBIDataV2Contract";
|
||||||
|
|
||||||
|
/// `xsi` namespace, declared (often unused) on the `<Items>` wrapper
|
||||||
|
/// alongside the DC namespace. WCF declares it preemptively because
|
||||||
|
/// any nullable DataContract field could emit `i:nil="true"`.
|
||||||
|
const XSI_NS: &str = "http://www.w3.org/2001/XMLSchema-instance";
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[allow(clippy::enum_variant_names, dead_code)] // every body field is in fact an element; suffix is descriptive. `name` on AsbiDataElement is retained for self-documentation but no longer emitted on the wire (see `asbidata_request_body`).
|
#[allow(clippy::enum_variant_names, dead_code)] // every body field is in fact an element; suffix is descriptive. `name` on AsbiDataElement is retained for self-documentation but no longer emitted on the wire (see `asbidata_request_body`).
|
||||||
enum BodyField {
|
enum BodyField {
|
||||||
@@ -2570,6 +2729,148 @@ mod tests {
|
|||||||
assert!(saw_monitored_item);
|
assert!(saw_monitored_item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F34 — verify the rewritten `push_monitored_item_body` emits the
|
||||||
|
/// DataContract field-suffix names under the `b` prefix that
|
||||||
|
/// MxDataProvider's binary deserialiser actually expects, in the
|
||||||
|
/// `[DataMember(Order = N)]` order from `AsbContracts.cs:940-965`.
|
||||||
|
/// Captured wire `tests/fixtures/add-monitored-items-request-wire.bin`
|
||||||
|
/// is the source of truth.
|
||||||
|
#[test]
|
||||||
|
fn add_monitored_items_body_uses_data_contract_field_names() {
|
||||||
|
let item = MinimalMonitoredItem::with_active(
|
||||||
|
ItemIdentity::absolute_by_name("TestChildObject.TestInt"),
|
||||||
|
1000,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
let body = build_add_monitored_items_request_body(11, &[item], true);
|
||||||
|
|
||||||
|
// Collect every (prefix, name) for `Element` tokens. The new
|
||||||
|
// builder emits each `MonitoredItem` child under prefix `b`
|
||||||
|
// with the `[DataMember(Name = "...")]` field-suffix name.
|
||||||
|
let elements: Vec<(Option<&str>, &str)> = body
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tok| {
|
||||||
|
if let NbfxToken::Element {
|
||||||
|
prefix,
|
||||||
|
name: NbfxName::Inline(local),
|
||||||
|
} = tok
|
||||||
|
{
|
||||||
|
Some((prefix.as_deref(), local.as_str()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// The MonitoredItem itself uses prefix `b`.
|
||||||
|
assert!(
|
||||||
|
elements.contains(&(Some("b"), "MonitoredItem")),
|
||||||
|
"expected <b:MonitoredItem>, got {elements:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// All 9 DataContract field names appear under prefix `b`, in
|
||||||
|
// declaration order.
|
||||||
|
let expected_dc_fields = [
|
||||||
|
"activeField",
|
||||||
|
"activeFieldSpecified",
|
||||||
|
"bufferedField",
|
||||||
|
"itemField",
|
||||||
|
"sampleIntervalField",
|
||||||
|
"timeDeadbandField",
|
||||||
|
"timeDeadbandFieldSpecified",
|
||||||
|
"userDataField",
|
||||||
|
"valueDeadbandField",
|
||||||
|
];
|
||||||
|
let dc_field_positions: Vec<usize> = expected_dc_fields
|
||||||
|
.iter()
|
||||||
|
.map(|f| {
|
||||||
|
elements
|
||||||
|
.iter()
|
||||||
|
.position(|(p, n)| *p == Some("b") && n == f)
|
||||||
|
.unwrap_or_else(|| panic!("missing <b:{f}> in body"))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Strictly increasing → fields appear in DC Order(N) sequence.
|
||||||
|
for window in dc_field_positions.windows(2) {
|
||||||
|
assert!(
|
||||||
|
window[0] < window[1],
|
||||||
|
"DC fields out of order: {expected_dc_fields:?} → {dc_field_positions:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemIdentity sub-fields appear under prefix `b` (nested
|
||||||
|
// DataContract serialisation, NOT the binary <ASBIData>
|
||||||
|
// fast-path which only kicks in at top-level body members).
|
||||||
|
for ii_field in [
|
||||||
|
"contextNameField",
|
||||||
|
"idField",
|
||||||
|
"idFieldSpecified",
|
||||||
|
"nameField",
|
||||||
|
"referenceTypeField",
|
||||||
|
"typeField",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
elements.contains(&(Some("b"), ii_field)),
|
||||||
|
"expected nested <b:{ii_field}> from ItemIdentity, got {elements:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variant sub-fields (lengthField/payloadField/typeField)
|
||||||
|
// appear for both userDataField and valueDeadbandField.
|
||||||
|
let length_count = elements
|
||||||
|
.iter()
|
||||||
|
.filter(|(p, n)| *p == Some("b") && *n == "lengthField")
|
||||||
|
.count();
|
||||||
|
let payload_count = elements
|
||||||
|
.iter()
|
||||||
|
.filter(|(p, n)| *p == Some("b") && *n == "payloadField")
|
||||||
|
.count();
|
||||||
|
assert_eq!(
|
||||||
|
length_count, 2,
|
||||||
|
"expected 2x <b:lengthField> (userData + valueDeadband Variants)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload_count, 2,
|
||||||
|
"expected 2x <b:payloadField> (userData + valueDeadband Variants)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The legacy XmlSerializer property names (Active / Item /
|
||||||
|
// SampleInterval / Buffered) MUST NOT appear on the wire — the
|
||||||
|
// canonical-XML signing path uses those names, but the binary
|
||||||
|
// body uses the DataContract suffix names exclusively. Asserts
|
||||||
|
// the legacy NBFX-bytes shape is fully retired for this op.
|
||||||
|
for legacy in ["Active", "Buffered", "SampleInterval", "ASBIData"] {
|
||||||
|
assert!(
|
||||||
|
!elements.iter().any(|(_, n)| *n == legacy),
|
||||||
|
"legacy XmlSerializer name <{legacy}> should not appear in DC body"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The <Items> wrapper declares `xmlns:b` (DC namespace) and
|
||||||
|
// `xmlns:i` (XSI). Verified by scanning for NamespaceDeclaration
|
||||||
|
// tokens immediately following the `<Items>` open.
|
||||||
|
let xmlns_decls: Vec<(&str, &NbfxText)> = body
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tok| {
|
||||||
|
if let NbfxToken::NamespaceDeclaration { prefix, value } = tok {
|
||||||
|
Some((prefix.as_str(), value))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
xmlns_decls.iter().any(|(p, v)| *p == "b"
|
||||||
|
&& matches!(v, NbfxText::Chars(s) if s == DC_ASBIDATAV2_NS)),
|
||||||
|
"expected xmlns:b={DC_ASBIDATAV2_NS:?} on <Items>"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
xmlns_decls.iter().any(|(p, v)| *p == "i"
|
||||||
|
&& matches!(v, NbfxText::Chars(s) if s == XSI_NS)),
|
||||||
|
"expected xmlns:i={XSI_NS:?} on <Items>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn delete_subscription_body_carries_subscription_id() {
|
fn delete_subscription_body_carries_subscription_id() {
|
||||||
let body = build_delete_subscription_request_body(99);
|
let body = build_delete_subscription_request_body(99);
|
||||||
|
|||||||
Reference in New Issue
Block a user