Files
mxaccess/src/MxNativeCodec/NmxObservedFrame.cs
T
Joseph Doherty fe2a6db786
rust / build / test / clippy / fmt (push) Has been cancelled
Initial project state: .NET reference, design, Rust port (M0+M1), evidence
Layout:
- src/                    .NET 10 x64 reference: MxNativeCodec, MxNativeClient,
                          MxAsbClient, probes, tests, harnesses. Executable spec.
- design/                 Architectural plan for the Rust port (M0–M6), error
                          model, protocol invariants, risks (R1–R16), adversarial
                          review log (review.md).
- rust/                   Rust workspace. M0 skeleton + M1 codec parity.
                          mxaccess-codec: 215 unit tests + 2 cross-implementation
                          parity tests (byte-identical against .NET reference).
                          Other crates are M0 stubs awaiting M2+.
- captures/               Frida + netsh + pcap evidence per CLAUDE.md
                          ("captures are evidence, not throwaway logs").
- analysis/               Decompiled C# (frida/proxy/decompiled-*),
                          Ghidra exports for native DLLs (`exports/` only —
                          working state at `projects/` and AVEVA's input
                          binaries at `input/` are gitignored).
- docs/                   Reverse-engineering reference docs.
- tools/                  Setup-LiveProbeEnv.ps1 (Infisical credential fetcher),
                          Compute-Crc.ps1 (.NET parity helper).
- .github/workflows/      Rust CI: fmt + build + test + clippy on Windows.
- LICENSE                 MIT (Joseph Doherty, 2026).

Verified:
- cargo test --workspace → 217 passed (215 unit + 2 .NET parity), 0 failed
- cargo clippy --workspace -- -D warnings → clean
- cargo fmt --all -- --check → clean
- cargo publish --dry-run -p mxaccess-codec → packages cleanly

Excluded from history (see .gitignore):
- **/bin, **/obj, **/target — build artifacts
- analysis/ghidra/projects/ — Ghidra working state (regenerable)
- analysis/ghidra/input/ — AVEVA proprietary DLLs (vendor IP)

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

193 lines
6.2 KiB
C#

using System.Buffers.Binary;
using System.Text;
namespace MxNativeCodec;
public sealed record NmxObservedEnvelope(
bool HasLengthPrefix,
int? TotalLengthPrefix,
int DeclaredInnerLength,
int ActualInnerLength,
ReadOnlyMemory<byte> Header,
ReadOnlyMemory<byte> InnerBody)
{
public const int HeaderLength = 46;
public const int InnerLengthOffset = 2;
public static NmxObservedEnvelope ParseTransferDataBody(ReadOnlyMemory<byte> body)
{
if (body.Length < HeaderLength)
{
throw new ArgumentException("NMX TransferData body is too short.", nameof(body));
}
int declaredInnerLength = BinaryPrimitives.ReadInt32LittleEndian(body.Span.Slice(InnerLengthOffset, sizeof(int)));
int actualInnerLength = body.Length - HeaderLength;
if (declaredInnerLength != actualInnerLength)
{
throw new ArgumentException("NMX TransferData inner length does not match body size.", nameof(body));
}
return new NmxObservedEnvelope(
false,
null,
declaredInnerLength,
actualInnerLength,
body[..HeaderLength],
body.Slice(HeaderLength, actualInnerLength));
}
public static NmxObservedEnvelope ParseProcessDataReceivedBody(ReadOnlyMemory<byte> body)
{
if (body.Length < sizeof(int) + HeaderLength)
{
throw new ArgumentException("NMX ProcessDataReceived body is too short.", nameof(body));
}
int totalLengthPrefix = BinaryPrimitives.ReadInt32LittleEndian(body.Span[..sizeof(int)]);
if (totalLengthPrefix != body.Length)
{
throw new ArgumentException("NMX ProcessDataReceived length prefix does not match body size.", nameof(body));
}
int headerOffset = sizeof(int);
int declaredInnerLength = BinaryPrimitives.ReadInt32LittleEndian(
body.Span.Slice(headerOffset + InnerLengthOffset, sizeof(int)));
int actualInnerLength = declaredInnerLength - sizeof(int);
if (actualInnerLength < 0 || headerOffset + HeaderLength + actualInnerLength != body.Length)
{
throw new ArgumentException("NMX ProcessDataReceived inner length does not match body size.", nameof(body));
}
return new NmxObservedEnvelope(
true,
totalLengthPrefix,
declaredInnerLength,
actualInnerLength,
body.Slice(headerOffset, HeaderLength),
body.Slice(headerOffset + HeaderLength, actualInnerLength));
}
public static NmxObservedEnvelope ParseProcessDataReceivedBodyFlexible(ReadOnlyMemory<byte> body)
{
if (body.Length >= sizeof(int) + HeaderLength)
{
int totalLengthPrefix = BinaryPrimitives.ReadInt32LittleEndian(body.Span[..sizeof(int)]);
if (totalLengthPrefix == body.Length)
{
return ParseProcessDataReceivedBody(body);
}
}
if (body.Length < HeaderLength)
{
throw new ArgumentException("NMX ProcessDataReceived body is too short.", nameof(body));
}
int declaredInnerLength = BinaryPrimitives.ReadInt32LittleEndian(body.Span.Slice(InnerLengthOffset, sizeof(int)));
int actualInnerLength = body.Length - HeaderLength;
if (declaredInnerLength != actualInnerLength)
{
throw new ArgumentException("NMX ProcessDataReceived header inner length does not match body size.", nameof(body));
}
return new NmxObservedEnvelope(
false,
null,
declaredInnerLength,
actualInnerLength,
body[..HeaderLength],
body.Slice(HeaderLength, actualInnerLength));
}
}
public sealed record NmxObservedString(int Offset, string Value);
public sealed record NmxObservedMessage(
byte Command,
string CommandName,
byte VersionMajor,
byte VersionMinor,
Guid? ItemCorrelationId,
IReadOnlyList<NmxObservedString> Strings)
{
public static NmxObservedMessage Parse(ReadOnlySpan<byte> body)
{
if (body.Length < 3)
{
throw new ArgumentException("NMX message body is too short.", nameof(body));
}
byte command = body[0];
Guid? itemCorrelationId = null;
if (command is 0x1f or 0x21 && body.Length >= 19)
{
itemCorrelationId = new Guid(body.Slice(3, 16));
}
return new NmxObservedMessage(
command,
GetCommandName(command),
body[1],
body[2],
itemCorrelationId,
ExtractUtf16Strings(body));
}
private static string GetCommandName(byte command)
{
return command switch
{
0x17 => "MetadataQuery",
0x1f => "AdviseSupervisory",
0x21 => "UnAdvise",
0x32 => "SubscriptionStatus",
0x33 => "DataUpdate",
0x37 => "Write",
0x40 => "MetadataResponse",
_ => $"Unknown0x{command:X2}",
};
}
private static IReadOnlyList<NmxObservedString> ExtractUtf16Strings(ReadOnlySpan<byte> body)
{
List<NmxObservedString> strings = [];
int offset = 0;
while (offset + 8 <= body.Length)
{
int start = offset;
int chars = 0;
while (offset + 1 < body.Length)
{
byte lo = body[offset];
byte hi = body[offset + 1];
if (lo == 0 && hi == 0)
{
break;
}
if (hi != 0 || lo < 0x20 || lo > 0x7e)
{
chars = 0;
break;
}
chars++;
offset += 2;
}
if (chars >= 3 && offset + 1 < body.Length && body[offset] == 0 && body[offset + 1] == 0)
{
string value = Encoding.Unicode.GetString(body.Slice(start, chars * 2));
strings.Add(new NmxObservedString(start, value));
offset += 2;
continue;
}
offset = start + 1;
}
return strings;
}
}