//! `NmxTransferEnvelopeTemplate` — buffer-preserving alternative to //! [`crate::NmxTransferEnvelope`]. //! //! Direct port of `src/MxNativeCodec/NmxTransferEnvelopeTemplate.cs`. //! //! Where [`crate::NmxTransferEnvelope`] decodes the 46-byte header into typed //! fields and re-encodes them, the template path **takes a captured 46-byte //! header verbatim and only patches the field(s) the caller asks to patch**. //! Every other byte in the header — including any reserved/unknown bytes the //! typed codec ignores — is preserved bit-for-bit. //! //! This is the path used for high-fidelity replay against the AVEVA stack //! when the captured envelope contains bytes whose meaning is unproven and //! the round-trip must remain byte-identical to the capture. //! //! # Differences from the .NET reference //! //! - Setters return a new value (`with_inner_length`, `with_message_kind`) //! rather than mutating in place. The underlying header buffer is owned //! by the template, so `with_*` methods clone the buffer before patching. //! The .NET reference does not expose setters — it re-encodes only the //! `inner_length` field on every `Encode` call. The Rust port adds //! targeted setters for forward use cases without breaking the //! "patch only what the caller patches" contract. //! - `decode_inner` returns a borrow (`&[u8]`) rather than a `ReadOnlyMemory`. // Direct byte indexing — see reference_handle.rs for rationale. #![allow(clippy::indexing_slicing)] use crate::NmxTransferMessageKind; use crate::error::CodecError; /// Header length in bytes (`NmxTransferEnvelopeTemplate.cs:7`). pub const HEADER_LENGTH: usize = 46; /// Offset of the `inner_length` i32 LE field /// (`NmxTransferEnvelopeTemplate.cs:8`). pub const INNER_LENGTH_OFFSET: usize = 2; /// Offset of the `message_kind` i32 LE field. Mirrors the constant of the /// same name in [`crate::envelope`]. const MESSAGE_KIND_OFFSET: usize = 10; /// Round-trip preserver for an observed 46-byte transfer envelope. /// /// Internally stores the captured 46-byte header verbatim. Setters /// (`with_inner_length`, `with_message_kind`) return a clone with only the /// targeted bytes patched. [`Self::encode`] writes the header followed by the /// supplied inner body, patching `inner_length` to match. #[derive(Debug, Clone, PartialEq, Eq)] pub struct NmxTransferEnvelopeTemplate { /// The captured 46-byte header, byte-for-byte (`NmxTransferEnvelopeTemplate.cs:10`). header: [u8; HEADER_LENGTH], } impl NmxTransferEnvelopeTemplate { /// Header length in bytes (matches `NmxTransferEnvelopeTemplate.HeaderLength`). pub const HEADER_LEN: usize = HEADER_LENGTH; /// Construct a template from an observed `TransferData` body. /// /// The body must be at least 46 bytes long, and the `inner_length` field /// at offset 2 must declare exactly `body.len() - 46` bytes. Mirrors /// `FromObserved` (`NmxTransferEnvelopeTemplate.cs:17-31`). /// /// Only the leading 46 bytes are retained — the inner body is dropped. /// /// # Errors /// /// - [`CodecError::ShortRead`] if `observed_transfer_body.len() < 46` /// (`NmxTransferEnvelopeTemplate.cs:19-22`). /// - [`CodecError::InnerLengthMismatch`] if the declared `inner_length` /// field does not match the actual inner length /// (`NmxTransferEnvelopeTemplate.cs:24-28`). pub fn from_observed(observed_transfer_body: &[u8]) -> Result { if observed_transfer_body.len() < HEADER_LENGTH { return Err(CodecError::ShortRead { expected: HEADER_LENGTH, actual: observed_transfer_body.len(), }); } let inner_length = read_i32_le(observed_transfer_body, INNER_LENGTH_OFFSET); let actual_inner = observed_transfer_body.len() - HEADER_LENGTH; if inner_length != actual_inner as i32 { return Err(CodecError::InnerLengthMismatch { declared: inner_length, actual: actual_inner, }); } let mut header = [0u8; HEADER_LENGTH]; header.copy_from_slice(&observed_transfer_body[..HEADER_LENGTH]); Ok(Self { header }) } /// Borrow the captured 46-byte header. Useful for round-trip identity /// asserts and for callers that need to inspect reserved/unknown bytes /// without going through the typed [`crate::NmxTransferEnvelope`] codec. pub fn header(&self) -> &[u8; HEADER_LENGTH] { &self.header } /// Return a new template with `inner_length` (offset 2, i32 LE) patched /// to `inner_length`. Every other byte is preserved. /// /// Note: [`Self::encode`] also patches `inner_length` to match the supplied /// inner body. This setter exists for callers that need to manipulate the /// template separately from encoding. #[must_use] pub fn with_inner_length(mut self, inner_length: i32) -> Self { write_i32_le(&mut self.header, INNER_LENGTH_OFFSET, inner_length); self } /// Return a new template with `message_kind` (offset 10, i32 LE) patched /// to `kind`. Every other byte is preserved. /// /// `NmxTransferMessageKind::Unknown` encodes as 0 — same as the typed /// codec ([`crate::envelope`]). #[must_use] pub fn with_message_kind(mut self, kind: NmxTransferMessageKind) -> Self { let value: i32 = match kind { NmxTransferMessageKind::Unknown => 0, NmxTransferMessageKind::Metadata => 1, NmxTransferMessageKind::ItemControl => 2, NmxTransferMessageKind::Write => 3, }; write_i32_le(&mut self.header, MESSAGE_KIND_OFFSET, value); self } /// Encode the captured header followed by `inner_put_request_body`, /// patching the `inner_length` field at offset 2 to match the supplied /// inner body length. /// /// Mirrors `Encode` (`NmxTransferEnvelopeTemplate.cs:33-40`). Allocates a /// fresh `Vec` of length `46 + inner_put_request_body.len()`. pub fn encode(&self, inner_put_request_body: &[u8]) -> Vec { let inner_len = inner_put_request_body.len(); let mut body = vec![0u8; HEADER_LENGTH + inner_len]; body[..HEADER_LENGTH].copy_from_slice(&self.header); // Patch the inner_length field — `NmxTransferEnvelopeTemplate.cs:37`. // `inner_len as i32` matches the .NET `int` cast. write_i32_le(&mut body, INNER_LENGTH_OFFSET, inner_len as i32); body[HEADER_LENGTH..].copy_from_slice(inner_put_request_body); body } /// Strip the 46-byte header off `transfer_body` and return a borrow of /// the inner bytes. /// /// Mirrors `DecodeInner` (`NmxTransferEnvelopeTemplate.cs:42-56`). Validates /// that the declared `inner_length` matches the actual inner body length. /// Does **not** verify that the captured 46-byte prefix matches the /// template's stored header — by design, the template path is a /// permissive round-trip; if the caller wants strict validation they /// should use [`crate::NmxTransferEnvelope::parse`]. /// /// # Errors /// /// - [`CodecError::ShortRead`] if `transfer_body.len() < 46` /// (`NmxTransferEnvelopeTemplate.cs:44-47`). /// - [`CodecError::InnerLengthMismatch`] if the declared `inner_length` /// does not match the actual inner length /// (`NmxTransferEnvelopeTemplate.cs:49-53`). pub fn decode_inner<'a>(&self, transfer_body: &'a [u8]) -> Result<&'a [u8], CodecError> { if transfer_body.len() < HEADER_LENGTH { return Err(CodecError::ShortRead { expected: HEADER_LENGTH, actual: transfer_body.len(), }); } let inner_length = read_i32_le(transfer_body, INNER_LENGTH_OFFSET); let actual_inner = transfer_body.len() - HEADER_LENGTH; if inner_length != actual_inner as i32 { return Err(CodecError::InnerLengthMismatch { declared: inner_length, actual: actual_inner, }); } Ok(&transfer_body[HEADER_LENGTH..]) } } #[inline] fn read_i32_le(bytes: &[u8], offset: usize) -> i32 { i32::from_le_bytes([ bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3], ]) } #[inline] fn write_i32_le(bytes: &mut [u8], offset: usize, value: i32) { 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::*; /// Build a synthetic 46-byte header with each byte tagged so we can /// verify which bytes the round-trip preserves vs. patches. /// Offsets 2..6 are written explicitly so `inner_length` validates; /// every other byte is `0xA0 + offset & 0xff` so reserved-byte /// preservation is observable. fn synthetic_header_with_inner_len(inner_len: i32) -> [u8; HEADER_LENGTH] { let mut header = [0u8; HEADER_LENGTH]; for (i, b) in header.iter_mut().enumerate() { *b = 0xA0u8.wrapping_add(i as u8); } write_i32_le(&mut header, INNER_LENGTH_OFFSET, inner_len); header } #[test] fn header_length_constant() { assert_eq!(HEADER_LENGTH, 46); assert_eq!(NmxTransferEnvelopeTemplate::HEADER_LEN, 46); } #[test] fn round_trip_zero_inner() { // No inner body — header preserved exactly. let header = synthetic_header_with_inner_len(0); let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap(); let encoded = template.encode(&[]); assert_eq!(encoded.len(), HEADER_LENGTH); assert_eq!(&encoded[..], &header[..]); } #[test] fn from_observed_rejects_short_buffer() { let err = NmxTransferEnvelopeTemplate::from_observed(&[0u8; 45]).unwrap_err(); assert!(matches!(err, CodecError::ShortRead { .. })); } #[test] fn from_observed_rejects_inner_length_mismatch() { let mut buf = [0u8; HEADER_LENGTH + 8]; // Claim 100 inner bytes when only 8 follow. write_i32_le(&mut buf, INNER_LENGTH_OFFSET, 100); let err = NmxTransferEnvelopeTemplate::from_observed(&buf).unwrap_err(); assert!(matches!(err, CodecError::InnerLengthMismatch { .. })); } #[test] fn encode_patches_inner_length() { // Template has inner_length = 0 baked in. Encoding with an 8-byte // inner body should patch inner_length to 8. let header = synthetic_header_with_inner_len(0); let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap(); let inner = [0xDEu8, 0xAD, 0xBE, 0xEF, 0x12, 0x34, 0x56, 0x78]; let encoded = template.encode(&inner); assert_eq!(encoded.len(), HEADER_LENGTH + 8); assert_eq!(read_i32_le(&encoded, INNER_LENGTH_OFFSET), 8); // Inner body must follow. assert_eq!(&encoded[HEADER_LENGTH..], &inner); } #[test] fn encode_preserves_every_byte_outside_inner_length_field() { // Build a template from a header packed with non-trivial bytes. // After encoding with arbitrary inner data, every header byte // outside offset 2..6 must match the original header. let header = synthetic_header_with_inner_len(0); let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap(); let inner = vec![0u8; 32]; let encoded = template.encode(&inner); for i in 0..HEADER_LENGTH { if (INNER_LENGTH_OFFSET..INNER_LENGTH_OFFSET + 4).contains(&i) { continue; } assert_eq!( encoded[i], header[i], "byte at offset {i} must be preserved verbatim" ); } } #[test] fn with_inner_length_patches_only_inner_length_field() { let header = synthetic_header_with_inner_len(0); let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap(); let patched = template.with_inner_length(0x12345678); let patched_header = patched.header(); // Inner length field reflects the patch. assert_eq!(read_i32_le(patched_header, INNER_LENGTH_OFFSET), 0x12345678); // Every other byte unchanged. for i in 0..HEADER_LENGTH { if (INNER_LENGTH_OFFSET..INNER_LENGTH_OFFSET + 4).contains(&i) { continue; } assert_eq!(patched_header[i], header[i]); } } #[test] fn with_message_kind_patches_only_message_kind_field() { let header = synthetic_header_with_inner_len(0); let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap(); let patched = template.with_message_kind(NmxTransferMessageKind::Write); let patched_header = patched.header(); // Message-kind field reflects the patch (Write = 3). assert_eq!(read_i32_le(patched_header, MESSAGE_KIND_OFFSET), 3); // Every other byte unchanged. for i in 0..HEADER_LENGTH { if (MESSAGE_KIND_OFFSET..MESSAGE_KIND_OFFSET + 4).contains(&i) { continue; } assert_eq!(patched_header[i], header[i]); } } #[test] fn with_message_kind_round_trips_all_variants() { let header = synthetic_header_with_inner_len(0); let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap(); for (kind, expected) in [ (NmxTransferMessageKind::Unknown, 0), (NmxTransferMessageKind::Metadata, 1), (NmxTransferMessageKind::ItemControl, 2), (NmxTransferMessageKind::Write, 3), ] { let patched = template.clone().with_message_kind(kind); assert_eq!( read_i32_le(patched.header(), MESSAGE_KIND_OFFSET), expected, "kind {kind:?} must encode as {expected}" ); } } #[test] fn decode_inner_returns_inner_body() { // Build the full 50-byte buffer first; `from_observed` validates that // the buffer length matches the declared inner_length, so we must // pass header+inner together. let header = synthetic_header_with_inner_len(4); let mut full = vec![0u8; HEADER_LENGTH + 4]; full[..HEADER_LENGTH].copy_from_slice(&header); full[HEADER_LENGTH..].copy_from_slice(&[0x11, 0x22, 0x33, 0x44]); let template = NmxTransferEnvelopeTemplate::from_observed(&full).unwrap(); let inner = template.decode_inner(&full).unwrap(); assert_eq!(inner, &[0x11, 0x22, 0x33, 0x44]); } #[test] fn decode_inner_rejects_short_buffer() { let header = synthetic_header_with_inner_len(0); let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap(); let err = template.decode_inner(&[0u8; 45]).unwrap_err(); assert!(matches!(err, CodecError::ShortRead { .. })); } #[test] fn decode_inner_rejects_inner_length_mismatch() { let header = synthetic_header_with_inner_len(0); let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap(); // Build a 46+8 byte body but with inner_length declared as 0. let mut full = vec![0u8; HEADER_LENGTH + 8]; full[..HEADER_LENGTH].copy_from_slice(&header); // header has inner_length = 0; actual inner is 8 → mismatch. let err = template.decode_inner(&full).unwrap_err(); assert!(matches!(err, CodecError::InnerLengthMismatch { .. })); } #[test] fn header_accessor_returns_captured_bytes() { let header = synthetic_header_with_inner_len(0); let template = NmxTransferEnvelopeTemplate::from_observed(&header).unwrap(); assert_eq!(template.header(), &header); } #[test] fn captured_observed_body_is_preserved_byte_for_byte() { // Simulates a captured envelope where bytes outside the four typed // fields carry non-zero "reserved" data. The template path must // round-trip every byte. The typed `NmxTransferEnvelope` codec // would normally strip / synthesise these bytes; the template // sidesteps that. let mut captured = [0u8; HEADER_LENGTH + 12]; // Pack the header with a recognisable pattern. for (i, b) in captured[..HEADER_LENGTH].iter_mut().enumerate() { *b = 0xA5u8.wrapping_add(i as u8); } // Set inner_length = 12 so from_observed accepts it. write_i32_le(&mut captured, INNER_LENGTH_OFFSET, 12); // Inner body bytes. for (i, b) in captured[HEADER_LENGTH..].iter_mut().enumerate() { *b = 0xC0u8.wrapping_add(i as u8); } let template = NmxTransferEnvelopeTemplate::from_observed(&captured).unwrap(); let encoded = template.encode(&captured[HEADER_LENGTH..]); assert_eq!( encoded, captured, "round-trip via template must be byte-identical" ); } }