fe2a6db786
rust / build / test / clippy / fmt (push) Has been cancelled
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>
193 lines
6.2 KiB
C#
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;
|
|
}
|
|
}
|