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>
429 lines
16 KiB
C#
429 lines
16 KiB
C#
using System.Buffers.Binary;
|
|
using System.Text;
|
|
|
|
namespace MxNativeCodec;
|
|
|
|
public sealed record NmxCallbackValue(
|
|
byte WireKind,
|
|
MxValueKind? ValueKind,
|
|
object? Value,
|
|
int EncodedLength);
|
|
|
|
public sealed record NmxSubscriptionRecord(
|
|
int Status,
|
|
int? DetailStatus,
|
|
ushort Quality,
|
|
DateTime TimestampUtc,
|
|
byte WireKind,
|
|
object? Value,
|
|
int Offset,
|
|
int Length)
|
|
{
|
|
public MxStatus ToDataChangeStatus()
|
|
{
|
|
return MxStatus.DataChangeOk;
|
|
}
|
|
}
|
|
|
|
public sealed record NmxSubscriptionMessage(
|
|
byte Command,
|
|
ushort Version,
|
|
int RecordCount,
|
|
Guid OperationId,
|
|
Guid? ItemCorrelationId,
|
|
IReadOnlyList<NmxSubscriptionRecord> Records)
|
|
{
|
|
public const byte SubscriptionStatusCommand = 0x32;
|
|
public const byte DataUpdateCommand = 0x33;
|
|
|
|
public static NmxSubscriptionMessage ParseProcessDataReceivedBody(ReadOnlyMemory<byte> body)
|
|
{
|
|
var envelope = NmxObservedEnvelope.ParseProcessDataReceivedBodyFlexible(body);
|
|
return ParseInner(envelope.InnerBody.Span);
|
|
}
|
|
|
|
public static NmxSubscriptionMessage ParseInner(ReadOnlySpan<byte> inner)
|
|
{
|
|
if (inner.Length < 23)
|
|
{
|
|
throw new ArgumentException("NMX subscription callback body is too short.", nameof(inner));
|
|
}
|
|
|
|
byte command = inner[0];
|
|
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(inner.Slice(1, sizeof(ushort)));
|
|
int recordCount = BinaryPrimitives.ReadInt32LittleEndian(inner.Slice(3, sizeof(int)));
|
|
var operationId = new Guid(inner.Slice(7, 16));
|
|
|
|
return command switch
|
|
{
|
|
SubscriptionStatusCommand => ParseSubscriptionStatus(inner, version, recordCount, operationId),
|
|
DataUpdateCommand => ParseDataUpdate(inner, version, recordCount, operationId),
|
|
_ => throw new ArgumentException($"Unsupported NMX subscription callback command 0x{command:X2}.", nameof(inner)),
|
|
};
|
|
}
|
|
|
|
private static NmxSubscriptionMessage ParseDataUpdate(
|
|
ReadOnlySpan<byte> inner,
|
|
ushort version,
|
|
int recordCount,
|
|
Guid operationId)
|
|
{
|
|
if (recordCount != 1)
|
|
{
|
|
throw new ArgumentException("Observed NMX DataUpdate callback parser currently supports one record per body.", nameof(inner));
|
|
}
|
|
|
|
const int recordOffset = 23;
|
|
var record = ParseRecord(inner, recordOffset, hasDetailStatus: false);
|
|
return new NmxSubscriptionMessage(
|
|
DataUpdateCommand,
|
|
version,
|
|
recordCount,
|
|
operationId,
|
|
null,
|
|
[record]);
|
|
}
|
|
|
|
private static NmxSubscriptionMessage ParseSubscriptionStatus(
|
|
ReadOnlySpan<byte> inner,
|
|
ushort version,
|
|
int recordCount,
|
|
Guid operationId)
|
|
{
|
|
if (inner.Length < 39)
|
|
{
|
|
throw new ArgumentException("NMX SubscriptionStatus callback body is too short.", nameof(inner));
|
|
}
|
|
|
|
var itemCorrelationId = new Guid(inner.Slice(23, 16));
|
|
int offset = 39;
|
|
List<NmxSubscriptionRecord> records = [];
|
|
for (int i = 0; i < recordCount; i++)
|
|
{
|
|
var record = ParseRecord(inner, offset, hasDetailStatus: true);
|
|
records.Add(record);
|
|
offset += record.Length;
|
|
}
|
|
|
|
return new NmxSubscriptionMessage(
|
|
SubscriptionStatusCommand,
|
|
version,
|
|
recordCount,
|
|
operationId,
|
|
itemCorrelationId,
|
|
records);
|
|
}
|
|
|
|
private static NmxSubscriptionRecord ParseRecord(ReadOnlySpan<byte> body, int offset, bool hasDetailStatus)
|
|
{
|
|
int minimumLength = hasDetailStatus ? 19 : 15;
|
|
if (offset < 0 || offset + minimumLength > body.Length)
|
|
{
|
|
throw new ArgumentException("NMX subscription record is too short.", nameof(body));
|
|
}
|
|
|
|
int start = offset;
|
|
int status = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(offset, sizeof(int)));
|
|
offset += sizeof(int);
|
|
|
|
int? detailStatus = null;
|
|
if (hasDetailStatus)
|
|
{
|
|
detailStatus = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(offset, sizeof(int)));
|
|
offset += sizeof(int);
|
|
}
|
|
|
|
ushort quality = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(offset, sizeof(ushort)));
|
|
offset += sizeof(ushort);
|
|
|
|
long fileTime = BinaryPrimitives.ReadInt64LittleEndian(body.Slice(offset, sizeof(long)));
|
|
offset += sizeof(long);
|
|
|
|
byte wireKind = body[offset++];
|
|
var value = DecodeValue(wireKind, body[offset..]);
|
|
offset += value.EncodedLength;
|
|
|
|
return new NmxSubscriptionRecord(
|
|
status,
|
|
detailStatus,
|
|
quality,
|
|
DateTime.FromFileTimeUtc(fileTime),
|
|
wireKind,
|
|
value.Value,
|
|
start,
|
|
offset - start);
|
|
}
|
|
|
|
private static NmxCallbackValue DecodeValue(byte wireKind, ReadOnlySpan<byte> body)
|
|
{
|
|
if (body.Length == 0)
|
|
{
|
|
return new NmxCallbackValue(wireKind, ToValueKindOrNull(wireKind), null, 0);
|
|
}
|
|
|
|
return wireKind switch
|
|
{
|
|
0x01 when body.Length >= 1 => new NmxCallbackValue(wireKind, MxValueKind.Boolean, body[0] != 0, 1),
|
|
0x02 when body.Length >= sizeof(int) => new NmxCallbackValue(wireKind, MxValueKind.Int32, BinaryPrimitives.ReadInt32LittleEndian(body[..sizeof(int)]), sizeof(int)),
|
|
0x03 when body.Length >= sizeof(float) => new NmxCallbackValue(wireKind, MxValueKind.Float32, BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(body[..sizeof(int)])), sizeof(float)),
|
|
0x04 when body.Length >= sizeof(double) => new NmxCallbackValue(wireKind, MxValueKind.Float64, BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64LittleEndian(body[..sizeof(long)])), sizeof(double)),
|
|
0x05 => DecodeStringValue(wireKind, body),
|
|
0x06 => DecodeDateTimeValue(wireKind, body),
|
|
0x07 => DecodeElapsedTimeValue(wireKind, body),
|
|
0x41 or 0x42 or 0x43 or 0x44 or 0x45 or 0x46 => DecodeArrayValue(wireKind, body),
|
|
_ => new NmxCallbackValue(wireKind, ToValueKindOrNull(wireKind), null, 0),
|
|
};
|
|
}
|
|
|
|
private static NmxCallbackValue DecodeStringValue(byte wireKind, ReadOnlySpan<byte> body)
|
|
{
|
|
if (body.Length < sizeof(int))
|
|
{
|
|
return new NmxCallbackValue(wireKind, MxValueKind.String, null, 0);
|
|
}
|
|
|
|
int recordLength = BinaryPrimitives.ReadInt32LittleEndian(body[..sizeof(int)]);
|
|
if (recordLength == sizeof(int))
|
|
{
|
|
return new NmxCallbackValue(wireKind, MxValueKind.String, string.Empty, sizeof(int));
|
|
}
|
|
|
|
if (body.Length < 8)
|
|
{
|
|
return new NmxCallbackValue(wireKind, MxValueKind.String, null, 0);
|
|
}
|
|
|
|
int textByteLength = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(4, sizeof(int)));
|
|
if (recordLength < 8 || textByteLength < 0 || recordLength != textByteLength + 4 || body.Length < 8 + textByteLength)
|
|
{
|
|
return new NmxCallbackValue(wireKind, MxValueKind.String, null, 0);
|
|
}
|
|
|
|
ReadOnlySpan<byte> textBytes = body.Slice(8, textByteLength);
|
|
if (textBytes.Length >= 2 && textBytes[^2] == 0 && textBytes[^1] == 0)
|
|
{
|
|
textBytes = textBytes[..^2];
|
|
}
|
|
|
|
string value = Encoding.Unicode.GetString(textBytes);
|
|
return new NmxCallbackValue(wireKind, MxValueKind.String, value, 8 + textByteLength);
|
|
}
|
|
|
|
private static NmxCallbackValue DecodeDateTimeValue(byte wireKind, ReadOnlySpan<byte> body)
|
|
{
|
|
if (body.Length >= 14)
|
|
{
|
|
int recordLength = BinaryPrimitives.ReadInt32LittleEndian(body[..sizeof(int)]);
|
|
if (recordLength >= 10 && body.Length >= sizeof(int) + recordLength)
|
|
{
|
|
long fileTime = BinaryPrimitives.ReadInt64LittleEndian(body.Slice(sizeof(int), sizeof(long)));
|
|
if (TryFromFileTimeUtc(fileTime, out DateTime timestamp))
|
|
{
|
|
return new NmxCallbackValue(
|
|
wireKind,
|
|
MxValueKind.DateTime,
|
|
timestamp,
|
|
sizeof(int) + recordLength);
|
|
}
|
|
|
|
return new NmxCallbackValue(wireKind, MxValueKind.DateTime, null, sizeof(int) + recordLength);
|
|
}
|
|
}
|
|
|
|
if (body.Length >= sizeof(long))
|
|
{
|
|
long fileTime = BinaryPrimitives.ReadInt64LittleEndian(body[..sizeof(long)]);
|
|
if (TryFromFileTimeUtc(fileTime, out DateTime timestamp))
|
|
{
|
|
return new NmxCallbackValue(wireKind, MxValueKind.DateTime, timestamp, sizeof(long));
|
|
}
|
|
}
|
|
|
|
return new NmxCallbackValue(wireKind, MxValueKind.DateTime, null, 0);
|
|
}
|
|
|
|
private static NmxCallbackValue DecodeElapsedTimeValue(byte wireKind, ReadOnlySpan<byte> body)
|
|
{
|
|
if (body.Length < sizeof(int))
|
|
{
|
|
return new NmxCallbackValue(wireKind, MxValueKind.ElapsedTime, null, 0);
|
|
}
|
|
|
|
int milliseconds = BinaryPrimitives.ReadInt32LittleEndian(body[..sizeof(int)]);
|
|
return new NmxCallbackValue(wireKind, MxValueKind.ElapsedTime, TimeSpan.FromMilliseconds(milliseconds), sizeof(int));
|
|
}
|
|
|
|
private static NmxCallbackValue DecodeArrayValue(byte wireKind, ReadOnlySpan<byte> body)
|
|
{
|
|
const int arrayHeaderLength = 10;
|
|
if (body.Length < arrayHeaderLength)
|
|
{
|
|
return new NmxCallbackValue(wireKind, ToValueKindOrNull(wireKind), null, 0);
|
|
}
|
|
|
|
ushort count = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(4, sizeof(ushort)));
|
|
int elementWidth = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(6, sizeof(int)));
|
|
ReadOnlySpan<byte> values = body[arrayHeaderLength..];
|
|
|
|
return wireKind switch
|
|
{
|
|
0x41 => DecodeBooleanArray(wireKind, count, elementWidth, body.Length, values),
|
|
0x42 => DecodeInt32Array(wireKind, count, elementWidth, body.Length, values),
|
|
0x43 => DecodeFloat32Array(wireKind, count, elementWidth, body.Length, values),
|
|
0x44 => DecodeFloat64Array(wireKind, count, elementWidth, body.Length, values),
|
|
0x45 => DecodeStringArray(wireKind, count, values),
|
|
0x46 => DecodeDateTimeArray(wireKind, count, elementWidth, body.Length, values),
|
|
_ => new NmxCallbackValue(wireKind, ToValueKindOrNull(wireKind), null, 0),
|
|
};
|
|
}
|
|
|
|
private static NmxCallbackValue DecodeBooleanArray(byte wireKind, ushort count, int elementWidth, int bodyLength, ReadOnlySpan<byte> values)
|
|
{
|
|
if (elementWidth != sizeof(short) || values.Length < count * elementWidth)
|
|
{
|
|
return new NmxCallbackValue(wireKind, MxValueKind.BooleanArray, null, 0);
|
|
}
|
|
|
|
bool[] decoded = new bool[count];
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
decoded[i] = BinaryPrimitives.ReadInt16LittleEndian(values.Slice(i * elementWidth, sizeof(short))) != 0;
|
|
}
|
|
|
|
return new NmxCallbackValue(wireKind, MxValueKind.BooleanArray, decoded, 10 + count * elementWidth);
|
|
}
|
|
|
|
private static NmxCallbackValue DecodeInt32Array(byte wireKind, ushort count, int elementWidth, int bodyLength, ReadOnlySpan<byte> values)
|
|
{
|
|
if (elementWidth != sizeof(int) || values.Length < count * elementWidth)
|
|
{
|
|
return new NmxCallbackValue(wireKind, MxValueKind.Int32Array, null, 0);
|
|
}
|
|
|
|
int[] decoded = new int[count];
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
decoded[i] = BinaryPrimitives.ReadInt32LittleEndian(values.Slice(i * elementWidth, sizeof(int)));
|
|
}
|
|
|
|
return new NmxCallbackValue(wireKind, MxValueKind.Int32Array, decoded, 10 + count * elementWidth);
|
|
}
|
|
|
|
private static NmxCallbackValue DecodeFloat32Array(byte wireKind, ushort count, int elementWidth, int bodyLength, ReadOnlySpan<byte> values)
|
|
{
|
|
if (elementWidth != sizeof(float) || values.Length < count * elementWidth)
|
|
{
|
|
return new NmxCallbackValue(wireKind, MxValueKind.Float32Array, null, 0);
|
|
}
|
|
|
|
float[] decoded = new float[count];
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
decoded[i] = BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(values.Slice(i * elementWidth, sizeof(int))));
|
|
}
|
|
|
|
return new NmxCallbackValue(wireKind, MxValueKind.Float32Array, decoded, 10 + count * elementWidth);
|
|
}
|
|
|
|
private static NmxCallbackValue DecodeFloat64Array(byte wireKind, ushort count, int elementWidth, int bodyLength, ReadOnlySpan<byte> values)
|
|
{
|
|
if (elementWidth != sizeof(double) || values.Length < count * elementWidth)
|
|
{
|
|
return new NmxCallbackValue(wireKind, MxValueKind.Float64Array, null, 0);
|
|
}
|
|
|
|
double[] decoded = new double[count];
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
decoded[i] = BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64LittleEndian(values.Slice(i * elementWidth, sizeof(long))));
|
|
}
|
|
|
|
return new NmxCallbackValue(wireKind, MxValueKind.Float64Array, decoded, 10 + count * elementWidth);
|
|
}
|
|
|
|
private static NmxCallbackValue DecodeDateTimeArray(byte wireKind, ushort count, int elementWidth, int bodyLength, ReadOnlySpan<byte> values)
|
|
{
|
|
if (elementWidth != 12 || values.Length < count * elementWidth)
|
|
{
|
|
return new NmxCallbackValue(wireKind, MxValueKind.DateTimeArray, null, 0);
|
|
}
|
|
|
|
DateTime[] decoded = new DateTime[count];
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
long fileTime = BinaryPrimitives.ReadInt64LittleEndian(values.Slice(i * elementWidth, sizeof(long)));
|
|
decoded[i] = DateTime.FromFileTimeUtc(fileTime);
|
|
}
|
|
|
|
return new NmxCallbackValue(wireKind, MxValueKind.DateTimeArray, decoded, 10 + count * elementWidth);
|
|
}
|
|
|
|
private static NmxCallbackValue DecodeStringArray(byte wireKind, ushort count, ReadOnlySpan<byte> values)
|
|
{
|
|
string[] decoded = new string[count];
|
|
int offset = 0;
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
if (offset + 13 > values.Length)
|
|
{
|
|
return new NmxCallbackValue(wireKind, MxValueKind.StringArray, null, 0);
|
|
}
|
|
|
|
int recordLength = BinaryPrimitives.ReadInt32LittleEndian(values.Slice(offset, sizeof(int)));
|
|
byte elementKind = values[offset + 4];
|
|
int textRecordLength = BinaryPrimitives.ReadInt32LittleEndian(values.Slice(offset + 5, sizeof(int)));
|
|
int textByteLength = BinaryPrimitives.ReadInt32LittleEndian(values.Slice(offset + 9, sizeof(int)));
|
|
if (recordLength < 9 || elementKind != 0x05 || textRecordLength != textByteLength + sizeof(int) || recordLength != 1 + sizeof(int) + sizeof(int) + textByteLength || offset + 13 + textByteLength > values.Length)
|
|
{
|
|
return new NmxCallbackValue(wireKind, MxValueKind.StringArray, null, 0);
|
|
}
|
|
|
|
ReadOnlySpan<byte> textBytes = values.Slice(offset + 13, textByteLength);
|
|
if (textBytes.Length >= 2 && textBytes[^2] == 0 && textBytes[^1] == 0)
|
|
{
|
|
textBytes = textBytes[..^2];
|
|
}
|
|
|
|
decoded[i] = Encoding.Unicode.GetString(textBytes);
|
|
offset += 13 + textByteLength;
|
|
}
|
|
|
|
return new NmxCallbackValue(wireKind, MxValueKind.StringArray, decoded, 10 + offset);
|
|
}
|
|
|
|
private static MxValueKind? ToValueKindOrNull(byte wireKind)
|
|
{
|
|
return wireKind switch
|
|
{
|
|
0x01 => MxValueKind.Boolean,
|
|
0x02 => MxValueKind.Int32,
|
|
0x03 => MxValueKind.Float32,
|
|
0x04 => MxValueKind.Float64,
|
|
0x05 => MxValueKind.String,
|
|
0x06 => MxValueKind.DateTime,
|
|
0x07 => MxValueKind.ElapsedTime,
|
|
0x41 => MxValueKind.BooleanArray,
|
|
0x42 => MxValueKind.Int32Array,
|
|
0x43 => MxValueKind.Float32Array,
|
|
0x44 => MxValueKind.Float64Array,
|
|
0x45 => MxValueKind.StringArray,
|
|
0x46 => MxValueKind.DateTimeArray,
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
private static bool TryFromFileTimeUtc(long fileTime, out DateTime timestamp)
|
|
{
|
|
try
|
|
{
|
|
timestamp = DateTime.FromFileTimeUtc(fileTime);
|
|
return true;
|
|
}
|
|
catch (ArgumentOutOfRangeException)
|
|
{
|
|
timestamp = default;
|
|
return false;
|
|
}
|
|
}
|
|
}
|