Files
mxaccess/rust/crates/mxaccess-codec/src/item_control.rs
T
Joseph Doherty e79e289743 [F42] cargo doc --workspace --no-deps clean (0 warnings)
Fix all 33 rustdoc warnings across the workspace:

- Unresolved intra-doc links: rewrite [`name`] → either backtick text
  (when not actually a link) or fully-qualified `[Type::method]` /
  `[crate::module::name]` form. Affected: mxaccess-codec
  (asb_variant, item_control, metadata_query, observed_write_template,
  reference_handle, write_message), mxaccess-rpc (pdu), mxaccess-nmx
  (client), mxaccess-asb-nettcp (nmf), mxaccess-callback (exporter),
  mxaccess (asb_session, session, lib).
- Bracket-text being interpreted as link refs (e.g. `body[17]` →
  `` `body[17]` ``).
- Private-item references in public docs (CALLBACK_BROADCAST_CAPACITY,
  recover_connection_core, mxvalue_to_writevalue) reduced to
  backtick-text since they aren't part of the public API.

`RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` now
exits clean. Workspace 759 tests pass; clippy clean.

Defers `#![warn(missing_docs)]` lint to a future pass — the cleanup
target is the broken-link warnings, which are signal; missing-docs
would surface hundreds of low-priority public-item gaps that are out
of scope for this F-number.

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

551 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `NmxItemControlMessage` — NMX item-control body (advise / unadvise).
//!
//! Direct port of `src/MxNativeCodec/NmxItemControlMessage.cs`. The body
//! carries an advise-supervisory or unadvise command together with a 16-byte
//! item correlation GUID and a 14-byte projection of an [`crate::reference_handle::MxReferenceHandle`]
//! (handle bytes 6..20 — `object_id` through `attribute_index`).
//!
//! ## Wire layout
//!
//! Per `NmxItemControlMessage.cs:24-36, 63-81, 121-142`:
//!
//! ```text
//! offset size field notes
//! 0 1 command (u8) 0x1f AdviseSupervisory, 0x21 UnAdvise
//! 1 2 version (u16 LE) must be 1
//! 3 16 item_correlation_id (GUID) .NET layout (mixed-endian)
//! 19 2 advise extra (u16 LE) ONLY when command == AdviseSupervisory
//! [+2 if advise]
//! 19/21 2 object_id (u16 LE)
//! +2 2 object_signature (u16 LE)
//! +4 2 primitive_id (i16 LE)
//! +6 2 attribute_id (i16 LE)
//! +8 2 property_id (i16 LE)
//! +10 2 attribute_signature (u16 LE)
//! +12 2 attribute_index (i16 LE)
//! +14 4 tail (u32 LE) default 3 per cs:88
//! ```
//!
//! Total: 39 bytes for AdviseSupervisory, 37 bytes for UnAdvise.
//!
//! ## Opcode invariant
//!
//! `Advise` and `AdviseSupervisory` share opcode `0x1f` in the .NET enum
//! (`NmxItemControlMessage.cs:7-8`). The parser explicitly rejects anything
//! that is not `AdviseSupervisory` or `UnAdvise` (`cs:46-49`); there is no
//! 37-byte plain-Advise wire shape. The Rust port mirrors this: the public
//! command type only exposes `AdviseSupervisory` and `UnAdvise`.
// Direct byte indexing — see reference_handle.rs for rationale. Every read or
// write is preceded by an explicit length check that mirrors the .NET source's
// `ReadOnlySpan` slicing, so the resulting code reads as a 1:1 mirror of
// `BinaryPrimitives` calls.
#![allow(clippy::indexing_slicing)]
use crate::error::CodecError;
/// NMX item-control command opcode.
///
/// In the .NET reference this is a `byte` enum where `Advise` and
/// `AdviseSupervisory` are aliases for `0x1f` (`NmxItemControlMessage.cs:5-10`).
/// Only `AdviseSupervisory` and `UnAdvise` are accepted on the wire
/// (`cs:46-49`), so the Rust enum collapses the alias and exposes just those
/// two variants.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum NmxItemControlCommand {
/// `0x1f`. Per `NmxItemControlMessage.cs:8`.
AdviseSupervisory = 0x1f,
/// `0x21`. Per `NmxItemControlMessage.cs:9`.
UnAdvise = 0x21,
}
impl NmxItemControlCommand {
/// Map a wire byte to a command, mirroring the parser check at
/// `NmxItemControlMessage.cs:45-49`.
fn from_u8(value: u8) -> Result<Self, CodecError> {
match value {
0x1f => Ok(Self::AdviseSupervisory),
0x21 => Ok(Self::UnAdvise),
other => Err(CodecError::UnexpectedOpcode(other)),
}
}
fn to_u8(self) -> u8 {
self as u8
}
}
/// Wire-format constants from `NmxItemControlMessage.cs:24-28`.
const VERSION: u16 = 1;
const HEADER_LENGTH: usize = 3; // cmd(1) + version u16(2)
const GUID_LENGTH: usize = 16; // cs:26
const ADVISE_EXTRA_LENGTH: usize = 2; // cs:27
const PAYLOAD_LENGTH: usize = 18; // cs:28 — 7×u16 + u32 tail = 18 bytes
/// Default tail value used by `FromReferenceHandle` (`NmxItemControlMessage.cs:88`).
pub const DEFAULT_TAIL: u32 = 3;
/// Decoded NMX item-control body. The fields after `item_correlation_id`
/// project bytes 6..20 of an [`crate::reference_handle::MxReferenceHandle`] — see
/// `NmxItemControlMessage.cs:71-81, 134-141`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NmxItemControlMessage {
pub command: NmxItemControlCommand,
/// 16-byte GUID. Stored as raw bytes in .NET-`Guid` layout (the layout
/// produced by `Guid.TryWriteBytes` and consumed by `new Guid(span)` —
/// mixed-endian: little-endian `Data1`/`Data2`/`Data3`, big-endian
/// `Data4`/`Data4Tail`). The Rust port stays at the byte level so the
/// .NET-shape round-trips exactly. See `NmxItemControlMessage.cs:64, 127`.
pub item_correlation_id: [u8; GUID_LENGTH],
pub object_id: u16,
pub object_signature: u16,
pub primitive_id: i16,
pub attribute_id: i16,
pub property_id: i16,
pub attribute_signature: u16,
pub attribute_index: i16,
/// Trailing u32. Default `3` per `NmxItemControlMessage.cs:88`.
pub tail: u32,
}
impl NmxItemControlMessage {
/// Encoded length for a given command. Matches
/// `NmxItemControlMessage.GetEncodedLength` (`cs:30-36`):
/// 39 bytes for AdviseSupervisory, 37 bytes for UnAdvise.
#[must_use]
pub fn encoded_length(command: NmxItemControlCommand) -> usize {
HEADER_LENGTH
+ GUID_LENGTH
+ match command {
NmxItemControlCommand::AdviseSupervisory => ADVISE_EXTRA_LENGTH,
NmxItemControlCommand::UnAdvise => 0,
}
+ PAYLOAD_LENGTH
}
/// Construct a message from the bytes 6..20 of a reference handle.
/// Mirrors `NmxItemControlMessage.FromReferenceHandle` (`cs:84-101`).
///
/// `tail` defaults to [`DEFAULT_TAIL`] (`3`) per `cs:88`.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn from_reference_handle_fields(
command: NmxItemControlCommand,
item_correlation_id: [u8; GUID_LENGTH],
object_id: u16,
object_signature: u16,
primitive_id: i16,
attribute_id: i16,
property_id: i16,
attribute_signature: u16,
attribute_index: i16,
tail: u32,
) -> Self {
Self {
command,
item_correlation_id,
object_id,
object_signature,
primitive_id,
attribute_id,
property_id,
attribute_signature,
attribute_index,
tail,
}
}
/// Return a copy with `command = UnAdvise`. Mirrors `ToUnAdvise`
/// (`NmxItemControlMessage.cs:145-148`).
#[must_use]
pub fn to_un_advise(self) -> Self {
Self {
command: NmxItemControlCommand::UnAdvise,
..self
}
}
/// Return a copy with `command = AdviseSupervisory`. Mirrors
/// `ToAdviseSupervisory` (`NmxItemControlMessage.cs:150-153`).
#[must_use]
pub fn to_advise_supervisory(self) -> Self {
Self {
command: NmxItemControlCommand::AdviseSupervisory,
..self
}
}
/// Parse an item-control body. Mirrors `NmxItemControlMessage.Parse`
/// (`cs:38-82`).
///
/// # Errors
///
/// - [`CodecError::ShortRead`] if the buffer is shorter than the
/// minimum 37-byte UnAdvise body (`cs:40-43`).
/// - [`CodecError::UnexpectedOpcode`] if the leading command byte is
/// neither `0x1f` (AdviseSupervisory) nor `0x21` (UnAdvise) (`cs:45-49`).
/// This is also what blocks the alias plain-`Advise` from being
/// accepted on the wire.
/// - [`CodecError::UnsupportedVersion`] if the version word is not 1
/// (`cs:51-55`).
/// - [`CodecError::Decode`] if the buffer length does not match the
/// per-command expected length (`cs:57-61`).
pub fn parse(body: &[u8]) -> Result<Self, CodecError> {
// Minimum length is the UnAdvise body (no advise-extra). Mirrors cs:40.
let min_len = HEADER_LENGTH + GUID_LENGTH + PAYLOAD_LENGTH;
if body.len() < min_len {
return Err(CodecError::ShortRead {
expected: min_len,
actual: body.len(),
});
}
let command = NmxItemControlCommand::from_u8(body[0])?;
let version = read_u16_le(body, 1);
if version != VERSION {
return Err(CodecError::UnsupportedVersion {
expected: VERSION,
actual: version,
});
}
let expected_length = Self::encoded_length(command);
if body.len() != expected_length {
return Err(CodecError::Decode {
offset: 0,
reason: "unexpected item-control body length",
buffer_len: body.len(),
});
}
let mut offset = HEADER_LENGTH;
let mut item_correlation_id = [0u8; GUID_LENGTH];
item_correlation_id.copy_from_slice(&body[offset..offset + GUID_LENGTH]);
offset += GUID_LENGTH;
if command == NmxItemControlCommand::AdviseSupervisory {
// Skip the 2-byte advise-extra word (cs:66-69). The .NET parser
// does not retain it; the Rust port mirrors that drop on parse
// and writes zeros on encode.
offset += ADVISE_EXTRA_LENGTH;
}
Ok(Self {
command,
item_correlation_id,
object_id: read_u16_le(body, offset),
object_signature: read_u16_le(body, offset + 2),
primitive_id: read_i16_le(body, offset + 4),
attribute_id: read_i16_le(body, offset + 6),
property_id: read_i16_le(body, offset + 8),
attribute_signature: read_u16_le(body, offset + 10),
attribute_index: read_i16_le(body, offset + 12),
tail: read_u32_le(body, offset + 14),
})
}
/// Encode to a freshly allocated `Vec<u8>`. Mirrors
/// `NmxItemControlMessage.Encode` (`cs:121-143`).
#[must_use]
pub fn encode(&self) -> Vec<u8> {
let mut body = vec![0u8; Self::encoded_length(self.command)];
body[0] = self.command.to_u8();
write_u16_le(&mut body, 1, VERSION);
let mut offset = HEADER_LENGTH;
body[offset..offset + GUID_LENGTH].copy_from_slice(&self.item_correlation_id);
offset += GUID_LENGTH;
if self.command == NmxItemControlCommand::AdviseSupervisory {
// Two zero bytes per cs:129-132 — the .NET source advances the
// offset over already-zeroed buffer space.
offset += ADVISE_EXTRA_LENGTH;
}
write_u16_le(&mut body, offset, self.object_id);
write_u16_le(&mut body, offset + 2, self.object_signature);
write_i16_le(&mut body, offset + 4, self.primitive_id);
write_i16_le(&mut body, offset + 6, self.attribute_id);
write_i16_le(&mut body, offset + 8, self.property_id);
write_u16_le(&mut body, offset + 10, self.attribute_signature);
write_i16_le(&mut body, offset + 12, self.attribute_index);
write_u32_le(&mut body, offset + 14, self.tail);
body
}
}
#[inline]
fn read_u16_le(bytes: &[u8], offset: usize) -> u16 {
u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
}
#[inline]
fn read_i16_le(bytes: &[u8], offset: usize) -> i16 {
i16::from_le_bytes([bytes[offset], bytes[offset + 1]])
}
#[inline]
fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
])
}
#[inline]
fn write_u16_le(bytes: &mut [u8], offset: usize, value: u16) {
let le = value.to_le_bytes();
bytes[offset..offset + 2].copy_from_slice(&le);
}
#[inline]
fn write_i16_le(bytes: &mut [u8], offset: usize, value: i16) {
let le = value.to_le_bytes();
bytes[offset..offset + 2].copy_from_slice(&le);
}
#[inline]
fn write_u32_le(bytes: &mut [u8], offset: usize, value: u32) {
let le = value.to_le_bytes();
bytes[offset..offset + 4].copy_from_slice(&le);
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
mod tests {
use super::*;
fn sample(command: NmxItemControlCommand) -> NmxItemControlMessage {
NmxItemControlMessage {
command,
item_correlation_id: [0x11; GUID_LENGTH],
object_id: 0x1234,
object_signature: 0xABCD,
primitive_id: -1,
attribute_id: 7,
property_id: 0,
attribute_signature: 0xBEEF,
attribute_index: -1,
tail: DEFAULT_TAIL,
}
}
#[test]
fn encoded_length_matches_dotnet() {
// cs:30-36: AdviseSupervisory = 3+16+2+18 = 39, UnAdvise = 3+16+18 = 37.
assert_eq!(
NmxItemControlMessage::encoded_length(NmxItemControlCommand::AdviseSupervisory),
39
);
assert_eq!(
NmxItemControlMessage::encoded_length(NmxItemControlCommand::UnAdvise),
37
);
}
#[test]
fn round_trip_advise_supervisory() {
let msg = sample(NmxItemControlCommand::AdviseSupervisory);
let encoded = msg.encode();
assert_eq!(encoded.len(), 39);
let decoded = NmxItemControlMessage::parse(&encoded).unwrap();
assert_eq!(msg, decoded);
}
#[test]
fn round_trip_un_advise() {
let msg = sample(NmxItemControlCommand::UnAdvise);
let encoded = msg.encode();
assert_eq!(encoded.len(), 37);
let decoded = NmxItemControlMessage::parse(&encoded).unwrap();
assert_eq!(msg, decoded);
}
#[test]
fn parse_rejects_plain_advise_alias() {
// The .NET enum aliases `Advise = 0x1f = AdviseSupervisory`, so on
// the wire the leading byte 0x1f always means AdviseSupervisory.
// There is *no* 37-byte plain-Advise shape; a 37-byte body that
// starts with 0x1f must be rejected because the per-command length
// check (cs:57-61) demands 39 bytes for 0x1f.
let mut bogus = vec![0u8; 37];
bogus[0] = 0x1f;
write_u16_le(&mut bogus, 1, VERSION);
let err = NmxItemControlMessage::parse(&bogus).unwrap_err();
assert!(
matches!(err, CodecError::Decode { .. }),
"expected length-mismatch decode error, got {err:?}"
);
}
#[test]
fn parse_rejects_unknown_command_opcode() {
// Leading byte that is neither 0x1f nor 0x21 — cs:46-49.
let mut bogus = vec![0u8; 37];
bogus[0] = 0x42;
write_u16_le(&mut bogus, 1, VERSION);
let err = NmxItemControlMessage::parse(&bogus).unwrap_err();
assert!(matches!(err, CodecError::UnexpectedOpcode(0x42)));
}
#[test]
fn parse_rejects_wrong_length_buffer_advise() {
// 39 bytes is right for AdviseSupervisory; 38 is wrong.
let msg = sample(NmxItemControlCommand::AdviseSupervisory);
let mut encoded = msg.encode();
encoded.pop();
let err = NmxItemControlMessage::parse(&encoded).unwrap_err();
// 38 bytes still passes the 37-byte minimum, so we hit the
// per-command length mismatch (cs:57-61) — Decode.
assert!(matches!(err, CodecError::Decode { .. }));
}
#[test]
fn parse_rejects_wrong_length_buffer_un_advise() {
// 36 bytes is below the 37-byte UnAdvise minimum (cs:40).
let err = NmxItemControlMessage::parse(&[0u8; 36]).unwrap_err();
assert!(matches!(err, CodecError::ShortRead { .. }));
}
#[test]
fn parse_rejects_oversized_un_advise() {
// 38 bytes with leading 0x21 — UnAdvise demands exactly 37.
let mut bogus = vec![0u8; 38];
bogus[0] = 0x21;
write_u16_le(&mut bogus, 1, VERSION);
let err = NmxItemControlMessage::parse(&bogus).unwrap_err();
assert!(matches!(err, CodecError::Decode { .. }));
}
#[test]
fn parse_rejects_wrong_version() {
let msg = sample(NmxItemControlCommand::UnAdvise);
let mut encoded = msg.encode();
write_u16_le(&mut encoded, 1, 2);
let err = NmxItemControlMessage::parse(&encoded).unwrap_err();
assert!(matches!(err, CodecError::UnsupportedVersion { .. }));
}
#[test]
fn guid_round_trips_byte_identical() {
// Use a distinctive byte pattern so a re-shuffle would be obvious.
let guid: [u8; 16] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
0xee, 0xff,
];
let msg = NmxItemControlMessage {
item_correlation_id: guid,
..sample(NmxItemControlCommand::AdviseSupervisory)
};
let encoded = msg.encode();
// GUID lives at offset 3..19 (after cmd+version) per cs:63-65.
assert_eq!(&encoded[3..19], &guid);
let decoded = NmxItemControlMessage::parse(&encoded).unwrap();
assert_eq!(decoded.item_correlation_id, guid);
}
#[test]
fn guid_round_trips_known_pattern() {
// The brief calls out `[0x11; 16]` as a sanity vector.
let guid = [0x11u8; 16];
let msg = NmxItemControlMessage {
item_correlation_id: guid,
..sample(NmxItemControlCommand::UnAdvise)
};
let encoded = msg.encode();
assert_eq!(&encoded[3..19], &guid);
let decoded = NmxItemControlMessage::parse(&encoded).unwrap();
assert_eq!(decoded.item_correlation_id, [0x11; 16]);
}
#[test]
fn default_tail_is_three() {
// cs:88 — `uint tail = 3`.
assert_eq!(DEFAULT_TAIL, 3);
let msg = NmxItemControlMessage::from_reference_handle_fields(
NmxItemControlCommand::AdviseSupervisory,
[0x11; 16],
1,
2,
3,
4,
5,
6,
7,
DEFAULT_TAIL,
);
let encoded = msg.encode();
// Tail u32 lives at the last 4 bytes of the body.
let n = encoded.len();
assert_eq!(&encoded[n - 4..], &3u32.to_le_bytes());
}
#[test]
fn advise_supervisory_extra_bytes_are_zero_on_encode() {
// cs:129-132: the 2-byte advise-extra word at offset 19..21 is
// skipped (left as zero in the freshly-allocated buffer).
let msg = sample(NmxItemControlCommand::AdviseSupervisory);
let encoded = msg.encode();
assert_eq!(&encoded[19..21], &[0x00, 0x00]);
}
#[test]
fn handle_projection_offsets_advise_supervisory() {
// For AdviseSupervisory the handle projection starts at offset 21
// (3 + 16 + 2). Verify object_id 0x1234 lands as 34 12 there.
let msg = NmxItemControlMessage {
object_id: 0x1234,
..sample(NmxItemControlCommand::AdviseSupervisory)
};
let encoded = msg.encode();
assert_eq!(encoded[21], 0x34);
assert_eq!(encoded[22], 0x12);
}
#[test]
fn handle_projection_offsets_un_advise() {
// For UnAdvise the handle projection starts at offset 19 (3 + 16).
let msg = NmxItemControlMessage {
object_id: 0x1234,
..sample(NmxItemControlCommand::UnAdvise)
};
let encoded = msg.encode();
assert_eq!(encoded[19], 0x34);
assert_eq!(encoded[20], 0x12);
}
#[test]
fn version_word_is_one_le() {
let encoded = sample(NmxItemControlCommand::UnAdvise).encode();
assert_eq!(encoded[1], 0x01);
assert_eq!(encoded[2], 0x00);
}
#[test]
fn command_byte_round_trips() {
let advise = sample(NmxItemControlCommand::AdviseSupervisory).encode();
let unadvise = sample(NmxItemControlCommand::UnAdvise).encode();
assert_eq!(advise[0], 0x1f);
assert_eq!(unadvise[0], 0x21);
}
#[test]
fn to_un_advise_and_back() {
// Mirrors cs:145-153 — `with` updates only the command.
let advise = sample(NmxItemControlCommand::AdviseSupervisory);
let unadvise = advise.to_un_advise();
assert_eq!(unadvise.command, NmxItemControlCommand::UnAdvise);
// All other fields preserved.
assert_eq!(advise.item_correlation_id, unadvise.item_correlation_id);
assert_eq!(advise.object_id, unadvise.object_id);
assert_eq!(advise.tail, unadvise.tail);
let again = unadvise.to_advise_supervisory();
assert_eq!(again.command, NmxItemControlCommand::AdviseSupervisory);
assert_eq!(again, advise);
}
}