ecbf282f6d
Lands the codec-only prerequisites for M2 wave 3 (callback exporter). The TCP server itself (port of ManagedCallbackExporter.cs's TcpListener + accept loop) follows next iteration in the mxaccess-callback crate. New modules - nmx_metadata.rs (9 tests) — port of NmxProcedureMetadata.cs. INmxService2 + INmxSvcCallback IIDs, NdrProcedureDescriptor with per-opnum metadata for the 9 INmxService2 procedures (opnums 3..11) and 2 INmxSvcCallback procedures (opnums 3, 4). - nmx_callback_messages.rs (8 tests) — port of NmxSvcCallbackMessages.cs. parse_callback_request decodes OrpcThis + i32 size + i32 max_count + body bytes; encode_callback_response produces the 12-byte OrpcThat + HRESULT response. objref.rs additions - ComObjRefBuilder::create_standard_objref (8 tests) — port of the second class in ManagedCallbackExporter.cs:337-393. Pure-Rust OBJREF emitter that builds 68-byte header + dual-string array. Note this is *not* the Win32 CoMarshalInterface-based ComObjRefProvider.cs (still open as F6); it's the higher-level emitter the callback exporter uses to build OBJREF bytes from primitives. - CALLBACK_OBJREF_AUTH_SERVICES const exposes the 7-entry auth-service tower-id table (NTLM SSP through Kerberos extension) the .NET reference advertises in every callback OBJREF. Test count delta: 319 -> 344 (+25; mxaccess-rpc 102 -> 127, codec unchanged at 215, parity unchanged at 2). All four DoD gates green. Open followups touched: none new; F6 advances toward resolution but the windows-rs Win32 wrapper part stays open. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
240 lines
7.9 KiB
Rust
240 lines
7.9 KiB
Rust
//! `INmxSvcCallback` request body parser + response body encoder.
|
|
//!
|
|
//! Direct port of `src/MxNativeClient/NmxSvcCallbackMessages.cs`. Decodes the
|
|
//! single `byte[] buffer` parameter the AVEVA service marshals through
|
|
//! `INmxSvcCallback::DataReceived` (opnum 3) and `StatusReceived` (opnum 4),
|
|
//! and produces the matching `HRESULT`-bearing response body the callback
|
|
//! exporter writes back.
|
|
//!
|
|
//! Per `NmxSvcCallbackMessages.cs:14-36`, the inbound body is:
|
|
//!
|
|
//! ```text
|
|
//! offset size field
|
|
//! 0 32 OrpcThis (encoded length without extensions)
|
|
//! 32 4 size i32 LE byte-array logical length
|
|
//! 36 4 max_count i32 LE conformant-array max count
|
|
//! 40 size body raw bytes carried inside the callback
|
|
//! ```
|
|
//!
|
|
//! `size` and `max_count` are NDR-marshalled `int` values; .NET asserts both
|
|
//! are non-negative and `max_count >= size` (`cs:24`).
|
|
|
|
#![allow(clippy::indexing_slicing)]
|
|
|
|
use crate::error::RpcError;
|
|
use crate::guid::Guid;
|
|
use crate::nmx_metadata::INMX_SVC_CALLBACK_IID;
|
|
use crate::orpc::{OrpcThat, OrpcThis};
|
|
|
|
/// Convenience re-export so callers can match the .NET `InterfaceId` static
|
|
/// (`NmxSvcCallbackMessages.cs:9`).
|
|
pub const INTERFACE_ID: Guid = INMX_SVC_CALLBACK_IID;
|
|
|
|
/// Opnum for `INmxSvcCallback::DataReceived` (`cs:11`). Same value as
|
|
/// [`crate::nmx_metadata::DATA_RECEIVED.opnum`].
|
|
pub const DATA_RECEIVED_OPNUM: u16 = 3;
|
|
|
|
/// Opnum for `INmxSvcCallback::StatusReceived` (`cs:12`). Same value as
|
|
/// [`crate::nmx_metadata::STATUS_RECEIVED.opnum`].
|
|
pub const STATUS_RECEIVED_OPNUM: u16 = 4;
|
|
|
|
/// Decoded callback request — mirrors `NmxCallbackRequest`
|
|
/// (`NmxSvcCallbackMessages.cs:5`).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct NmxCallbackRequest {
|
|
pub orpc_this: OrpcThis,
|
|
pub body: Vec<u8>,
|
|
}
|
|
|
|
/// Header overhead before the `body` bytes: `OrpcThis(32) + size(4) +
|
|
/// max_count(4) = 40` (`NmxSvcCallbackMessages.cs:16,29`).
|
|
pub const CALLBACK_REQUEST_HEADER_LEN: usize = OrpcThis::ENCODED_LEN + 8;
|
|
|
|
/// Parse an inbound callback request body. Mirrors `ParseCallbackRequest`
|
|
/// (`NmxSvcCallbackMessages.cs:14-36`).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// - [`RpcError::ShortRead`] when `buffer.len() < 40` (`cs:16-19`).
|
|
/// - [`RpcError::Decode`] when `size < 0` or `max_count < size`
|
|
/// (`cs:24-27`), or when the declared `size` runs past the buffer
|
|
/// (`cs:30-33`).
|
|
pub fn parse_callback_request(buffer: &[u8]) -> Result<NmxCallbackRequest, RpcError> {
|
|
if buffer.len() < CALLBACK_REQUEST_HEADER_LEN {
|
|
return Err(RpcError::ShortRead {
|
|
expected: CALLBACK_REQUEST_HEADER_LEN,
|
|
actual: buffer.len(),
|
|
});
|
|
}
|
|
|
|
let orpc_this = OrpcThis::parse(&buffer[..OrpcThis::ENCODED_LEN])?;
|
|
|
|
// size and max_count are .NET `int` (i32 LE). Negative values are
|
|
// explicitly rejected by the .NET reference (`cs:24`).
|
|
let size_i32 = i32::from_le_bytes([
|
|
buffer[OrpcThis::ENCODED_LEN],
|
|
buffer[OrpcThis::ENCODED_LEN + 1],
|
|
buffer[OrpcThis::ENCODED_LEN + 2],
|
|
buffer[OrpcThis::ENCODED_LEN + 3],
|
|
]);
|
|
let max_count_i32 = i32::from_le_bytes([
|
|
buffer[OrpcThis::ENCODED_LEN + 4],
|
|
buffer[OrpcThis::ENCODED_LEN + 5],
|
|
buffer[OrpcThis::ENCODED_LEN + 6],
|
|
buffer[OrpcThis::ENCODED_LEN + 7],
|
|
]);
|
|
|
|
if size_i32 < 0 || max_count_i32 < size_i32 {
|
|
return Err(RpcError::Decode {
|
|
offset: OrpcThis::ENCODED_LEN,
|
|
reason: "callback request has invalid array size metadata",
|
|
buffer_len: buffer.len(),
|
|
});
|
|
}
|
|
|
|
let size = size_i32 as usize;
|
|
let body_offset = CALLBACK_REQUEST_HEADER_LEN;
|
|
if body_offset + size > buffer.len() {
|
|
return Err(RpcError::Decode {
|
|
offset: body_offset,
|
|
reason: "callback request byte array is truncated",
|
|
buffer_len: buffer.len(),
|
|
});
|
|
}
|
|
|
|
Ok(NmxCallbackRequest {
|
|
orpc_this,
|
|
body: buffer[body_offset..body_offset + size].to_vec(),
|
|
})
|
|
}
|
|
|
|
/// Encode the callback response body — `OrpcThat(8) + hresult(4) = 12`
|
|
/// bytes. Mirrors `EncodeCallbackResponse` (`NmxSvcCallbackMessages.cs:38-44`).
|
|
#[must_use]
|
|
pub fn encode_callback_response(hresult: i32) -> [u8; 12] {
|
|
let mut buf = [0u8; 12];
|
|
let orpc_that = OrpcThat {
|
|
flags: 0,
|
|
extensions_referent_id: 0,
|
|
}
|
|
.encode();
|
|
buf[..OrpcThat::ENCODED_LEN].copy_from_slice(&orpc_that);
|
|
buf[OrpcThat::ENCODED_LEN..].copy_from_slice(&hresult.to_le_bytes());
|
|
buf
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(
|
|
clippy::unwrap_used,
|
|
clippy::expect_used,
|
|
clippy::indexing_slicing,
|
|
clippy::panic
|
|
)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_request(
|
|
body: &[u8],
|
|
size_override: Option<i32>,
|
|
max_count_override: Option<i32>,
|
|
) -> Vec<u8> {
|
|
let mut buf = Vec::with_capacity(CALLBACK_REQUEST_HEADER_LEN + body.len());
|
|
let orpc_this = OrpcThis::create(Guid::new([0x10; 16]), None).encode();
|
|
buf.extend_from_slice(&orpc_this);
|
|
let size = size_override.unwrap_or(body.len() as i32);
|
|
let max_count = max_count_override.unwrap_or(body.len() as i32);
|
|
buf.extend_from_slice(&size.to_le_bytes());
|
|
buf.extend_from_slice(&max_count.to_le_bytes());
|
|
buf.extend_from_slice(body);
|
|
buf
|
|
}
|
|
|
|
#[test]
|
|
fn opnums_match_dotnet() {
|
|
assert_eq!(DATA_RECEIVED_OPNUM, 3);
|
|
assert_eq!(STATUS_RECEIVED_OPNUM, 4);
|
|
}
|
|
|
|
#[test]
|
|
fn interface_id_matches_callback_iid() {
|
|
assert_eq!(INTERFACE_ID, INMX_SVC_CALLBACK_IID);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_round_trip_empty_body() {
|
|
let bytes = make_request(&[], None, None);
|
|
let parsed = parse_callback_request(&bytes).unwrap();
|
|
assert!(parsed.body.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_round_trip_carries_payload() {
|
|
let body: &[u8] = &[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02];
|
|
let bytes = make_request(body, None, None);
|
|
let parsed = parse_callback_request(&bytes).unwrap();
|
|
assert_eq!(parsed.body, body);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_short_buffer_errors() {
|
|
let err = parse_callback_request(&[0u8; 39]).unwrap_err();
|
|
assert!(matches!(
|
|
err,
|
|
RpcError::ShortRead {
|
|
expected: 40,
|
|
actual: 39
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_negative_size_rejected() {
|
|
let bytes = make_request(&[], Some(-1), Some(0));
|
|
assert!(matches!(
|
|
parse_callback_request(&bytes),
|
|
Err(RpcError::Decode { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_max_count_less_than_size_rejected() {
|
|
let bytes = make_request(&[0xAA; 8], Some(8), Some(4));
|
|
assert!(matches!(
|
|
parse_callback_request(&bytes),
|
|
Err(RpcError::Decode { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_truncated_body_rejected() {
|
|
// Declare 16 bytes but supply only 4.
|
|
let mut bytes = make_request(&[0xAA; 4], Some(16), Some(16));
|
|
// Trim trailing bytes so the buffer is shorter than declared size.
|
|
bytes.truncate(CALLBACK_REQUEST_HEADER_LEN + 4);
|
|
assert!(matches!(
|
|
parse_callback_request(&bytes),
|
|
Err(RpcError::Decode { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn encode_response_layout() {
|
|
// Success response — OrpcThat zeros + hresult=0.
|
|
let r = encode_callback_response(0);
|
|
assert_eq!(r.len(), 12);
|
|
assert_eq!(&r[..8], &[0u8; 8]);
|
|
assert_eq!(&r[8..], &0i32.to_le_bytes());
|
|
|
|
// Negative hresult round-trip.
|
|
let r = encode_callback_response(unchecked_negative());
|
|
assert_eq!(&r[8..], &unchecked_negative().to_le_bytes());
|
|
}
|
|
|
|
fn unchecked_negative() -> i32 {
|
|
// 0x80004005 = E_FAIL, the canonical generic failure HRESULT.
|
|
// .NET would write `unchecked((int)0x80004005)`; Rust expresses
|
|
// the same bit pattern as `i32::MIN`-aligned negative.
|
|
0x80004005u32 as i32
|
|
}
|
|
}
|