Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationCodecTests.cs
T
Joseph Doherty 7892eb13a8 fix(abcip): harden Ab.Transport CIP codec and EncapsulationClient (#564, #565)
CIP codec (#564): PatchU16 bounds guard; CipReply content equality; CipPath
port/length/ASCII/odd/oversize validation with ArgumentException for malformed
tag names and routes; CipPath.MessageRouter/ConnectionManager are now
ReadOnlySpan<byte>; refused Forward Open surfaces ForwardOpenRefusedException
with structured general/extended status; test CaptureFile uses BinaryPrimitives.

EncapsulationClient (#565): frame write bounded by the exchange timeout, which
faults (never cancels) the connection; finite positive timeouts enforced at
every entry point; gate wait bounded by the timeout; deterministic reorder of
the 300 ms test; new dispose-during-drain, gate-wait and RegisterSession
zero-context tests.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:54:32 -04:00

152 lines
7.3 KiB
C#

using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Eip;
public sealed class EncapsulationCodecTests
{
private const ulong Ctx = 0x0807060504030201;
[Fact]
public void RegisterSession_frame_is_byte_exact() =>
Convert.ToHexString(EncapsulationCodec.RegisterSession()).ShouldBe(
"6500" + "0400" + "00000000" + "00000000" + "0000000000000000" + "00000000" + "0100" + "0000");
[Fact]
public void UnRegisterSession_frame_is_header_only() =>
Convert.ToHexString(EncapsulationCodec.UnRegisterSession(0x11223344, Ctx)).ShouldBe(
"6600" + "0000" + "44332211" + "00000000" + "0102030405060708" + "00000000");
[Fact]
public void SendRRData_wraps_cip_in_null_address_and_unconnected_data_items()
{
var frame = EncapsulationCodec.SendRRData(0x11223344, Ctx, new byte[] { 0x4C, 0x02 }, timeoutSeconds: 10);
Convert.ToHexString(frame).ShouldBe(
"6F00" + "1200" + "44332211" + "00000000" + "0102030405060708" + "00000000" +
"00000000" + "0A00" + "0200" + "00000000" + "B2000200" + "4C02");
}
[Fact]
public void SendUnitData_wraps_cip_with_connection_id_and_sequence()
{
var frame = EncapsulationCodec.SendUnitData(0x11223344, Ctx, connectionId: 0xAABBCCDD, sequence: 7, new byte[] { 0x4C, 0x02 });
Convert.ToHexString(frame).ShouldBe(
"7000" + "1800" + "44332211" + "00000000" + "0102030405060708" + "00000000" +
"00000000" + "0000" + "0200" + "A1000400" + "DDCCBBAA" + "B1000400" + "0700" + "4C02");
}
[Fact]
public void Oversized_cip_payload_is_refused_not_truncated() =>
Should.Throw<AbTransportException>(() =>
EncapsulationCodec.SendRRData(1, Ctx, new byte[EncapsulationCodec.MaxCipPayload + 1], 1))
.Status.ShouldBe(AbStatus.TooLarge);
[Fact]
public void RegisterSession_matches_libplctag_capture_byte_for_byte()
{
// Including the sender context: ab_server rejects a non-zero RegisterSession context with
// encapsulation status 1 (found by Task 17 controllogix — every native session failed).
var captured = CaptureFile.Load("logix-register-session.hex").First(f => f.FromClient).Bytes;
Convert.ToHexString(EncapsulationCodec.RegisterSession()).ShouldBe(Convert.ToHexString(captured));
}
[Fact]
public void SendRRData_matches_libplctag_forward_open_capture()
{
// libplctag put a 1-second timeout in the SendRRData timeout field; the session handle and
// context are copied from the capture so the whole frame must match byte for byte.
var captured = CaptureFile.Load("logix-forward-open.hex").First(f => f.FromClient).Bytes;
var h = EncapsulationCodec.ReadHeader(captured);
var ours = EncapsulationCodec.SendRRData(h.SessionHandle, h.SenderContext,
CaptureFile.CipPayload(new CapturedFrame(true, captured)), timeoutSeconds: 1);
Convert.ToHexString(ours).ShouldBe(Convert.ToHexString(captured));
}
[Fact]
public void SendUnitData_matches_libplctag_read_capture_except_timeout_field()
{
// libplctag writes 1 into the SendUnitData interface-timeout field; the CIP spec says the field
// is unused for SendUnitData and the brief pins ours to 0 — so bytes 28-29 are excluded and
// asserted separately. Everything else (connection id, sequence, CIP) must match exactly.
var captured = CaptureFile.Load("logix-read-dint.hex").First(f => f.FromClient && f.Bytes[0] == 0x70).Bytes;
var h = EncapsulationCodec.ReadHeader(captured);
var parsed = EncapsulationCodec.ParseReply(captured, expectConnected: true);
var connId = BinaryPrimitives.ReadUInt32LittleEndian(captured.AsSpan(24 + 8 + 4)); // header, iface+timeout+count, A1 type+len
var ours = EncapsulationCodec.SendUnitData(h.SessionHandle, h.SenderContext, connId, parsed.Sequence, parsed.Cip.Span);
ours.Length.ShouldBe(captured.Length);
ours.AsSpan(0, 28).ToArray().ShouldBe(captured.AsSpan(0, 28).ToArray());
ours.AsSpan(30).ToArray().ShouldBe(captured.AsSpan(30).ToArray());
ours.AsSpan(28, 2).ToArray().ShouldBe(new byte[] { 0, 0 });
connId.ShouldBe(0x2CDF633Fu);
parsed.Sequence.ShouldBe((ushort)1);
}
[Fact]
public void ParseReply_of_captured_connected_read_strips_the_sequence_count()
{
var reply = CaptureFile.Load("logix-read-dint.hex").First(f => !f.FromClient && f.Bytes[0] == 0x70).Bytes;
var (h, cip, seq) = EncapsulationCodec.ParseReply(reply, expectConnected: true);
h.Command.ShouldBe(EncapCommand.SendUnitData);
h.SessionHandle.ShouldBe(0x1A08A5F3u);
seq.ShouldBe((ushort)1);
Convert.ToHexString(cip.ToArray()).ShouldBe("D2000000C40040E20100"); // Read Tag reply, DINT 123456
}
[Fact]
public void ParseReply_of_captured_unconnected_forward_open_returns_the_cip_reply()
{
var reply = CaptureFile.Load("logix-forward-open.hex").First(f => !f.FromClient).Bytes;
var (h, cip, seq) = EncapsulationCodec.ParseReply(reply, expectConnected: false);
h.Command.ShouldBe(EncapCommand.SendRRData);
seq.ShouldBe((ushort)0);
cip.Length.ShouldBe(0x1E);
cip.Span[0].ShouldBe((byte)0xDB); // Large Forward Open reply
}
[Fact]
public void ParseReply_of_register_session_returns_header_and_empty_payload()
{
var reply = CaptureFile.Load("logix-register-session.hex").First(f => !f.FromClient).Bytes;
var (h, cip, _) = EncapsulationCodec.ParseReply(reply, expectConnected: false);
h.SessionHandle.ShouldBe(0x1A08A5F3u);
cip.IsEmpty.ShouldBeTrue();
}
[Fact]
public void Nonzero_encapsulation_status_is_BadReply()
{
var reply = Convert.FromHexString("6F00" + "0000" + "44332211" + "01000000" + "0102030405060708" + "00000000");
Should.Throw<AbTransportException>(() => EncapsulationCodec.ParseReply(reply, expectConnected: false))
.Status.ShouldBe(AbStatus.BadReply);
}
[Fact]
public void Length_mismatch_is_BadReply()
{
var frame = EncapsulationCodec.SendRRData(1, Ctx, new byte[] { 0xCC, 0 }, 0);
Should.Throw<AbTransportException>(() => EncapsulationCodec.ParseReply(frame.AsMemory(0, frame.Length - 1), false))
.Status.ShouldBe(AbStatus.BadReply);
}
[Fact]
public void Missing_expected_data_item_is_BadReply()
{
// An unconnected (0xB2) reply parsed as connected has no 0xB1 item.
var frame = EncapsulationCodec.SendRRData(1, Ctx, new byte[] { 0xCC, 0 }, 0);
Should.Throw<AbTransportException>(() => EncapsulationCodec.ParseReply(frame, expectConnected: true))
.Status.ShouldBe(AbStatus.BadReply);
}
[Fact]
public void Item_length_running_past_the_frame_is_BadReply()
{
var frame = EncapsulationCodec.SendRRData(1, Ctx, new byte[] { 0xCC, 0 }, 0);
frame[24 + 8 + 4 + 2] = 0x40; // B2 item length 2 -> 64
Should.Throw<AbTransportException>(() => EncapsulationCodec.ParseReply(frame, expectConnected: false))
.Status.ShouldBe(AbStatus.BadReply);
}
}