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(() => 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 = BitConverter.ToUInt32(captured, 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(() => 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(() => 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(() => 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(() => EncapsulationCodec.ParseReply(frame, expectConnected: false)) .Status.ShouldBe(AbStatus.BadReply); } }