7892eb13a8
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
146 lines
7.2 KiB
C#
146 lines
7.2 KiB
C#
using System.Buffers.Binary;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip;
|
|
|
|
// CAPTURE DECISION: the brief gave timeout ticks 0x0E. Every libplctag capture (logix-forward-open,
|
|
// logix-forward-close) carries priority/tick 0x0A, timeout ticks 0x05, so ConnectionManagerServices
|
|
// uses 0x05 (brief: 0x0E -> capture: 0x05). The golden-byte tests below use 0x05 accordingly.
|
|
public sealed class ConnectionManagerServicesTests
|
|
{
|
|
private static byte[] CapturedRequest(string file) =>
|
|
CaptureFile.CipPayload(CaptureFile.Load(file).First(f => f.FromClient));
|
|
|
|
private static CipReply CapturedReply(string file) =>
|
|
CipReply.Parse(CaptureFile.CipPayload(CaptureFile.Load(file).First(f => !f.FromClient)));
|
|
|
|
[Fact]
|
|
public void Forward_close_frame()
|
|
{
|
|
var bytes = ConnectionManagerServices.ForwardClose(0x1234, 0x1337, 0xCAFEBABE, [0x01, 0x00]);
|
|
Convert.ToHexString(bytes).ShouldBe("4E02200624010A05" + "3412" + "3713" + "BEBAFECA" + "03" + "00" + "0100" + "20022401");
|
|
}
|
|
|
|
[Fact]
|
|
public void Forward_close_matches_capture()
|
|
{
|
|
var cap = CapturedRequest("logix-forward-close.hex");
|
|
var serial = BinaryPrimitives.ReadUInt16LittleEndian(cap.AsSpan(8));
|
|
var vendor = BinaryPrimitives.ReadUInt16LittleEndian(cap.AsSpan(10));
|
|
var orig = BinaryPrimitives.ReadUInt32LittleEndian(cap.AsSpan(12));
|
|
vendor.ShouldBe((ushort)0xF33D);
|
|
ConnectionManagerServices.ForwardClose(serial, vendor, orig, [0x01, 0x00]).ShouldBe(cap);
|
|
}
|
|
|
|
[Fact]
|
|
public void Large_forward_open_matches_capture()
|
|
{
|
|
var cap = CapturedRequest("logix-forward-open.hex");
|
|
// Values read out of the capture: T->O id @12, serial @16, vendor @18, originator serial @20,
|
|
// timeout multiplier @24, RPI @28, network params @32 (0x42000FA2 -> size 4002).
|
|
var p = new ForwardOpenParams(
|
|
Large: true, TOConnectionId: BinaryPrimitives.ReadUInt32LittleEndian(cap.AsSpan(12)), ConnectionSerial: BinaryPrimitives.ReadUInt16LittleEndian(cap.AsSpan(16)),
|
|
VendorId: BinaryPrimitives.ReadUInt16LittleEndian(cap.AsSpan(18)), OriginatorSerial: BinaryPrimitives.ReadUInt32LittleEndian(cap.AsSpan(20)),
|
|
TimeoutMultiplier: cap[24], RpiMicros: BinaryPrimitives.ReadUInt32LittleEndian(cap.AsSpan(28)), ConnectionSize: 4002, Route: [0x01, 0x00]);
|
|
p.RpiMicros.ShouldBe(1_000_000u);
|
|
BinaryPrimitives.ReadUInt32LittleEndian(cap.AsSpan(32)).ShouldBe(0x42000FA2u);
|
|
ConnectionManagerServices.ForwardOpen(p).ShouldBe(cap);
|
|
}
|
|
|
|
[Fact]
|
|
public void Large_forward_open_reply_parses_from_capture()
|
|
{
|
|
var reply = ConnectionManagerServices.ParseForwardOpenReply(CapturedReply("logix-forward-open.hex"));
|
|
reply.OTConnectionId.ShouldBe(0x2CDF633Fu);
|
|
reply.TOConnectionId.ShouldBe(0x073F9E60u);
|
|
reply.OTApiMicros.ShouldBe(1_000_000u);
|
|
reply.TOApiMicros.ShouldBe(1_000_000u);
|
|
}
|
|
|
|
// No small Forward Open 0x54 was captured (the Large FO succeeded), so this layout is checked against
|
|
// the CIP spec / the brief rather than a capture.
|
|
[Fact]
|
|
public void Regular_forward_open_layout_per_spec()
|
|
{
|
|
var bytes = ConnectionManagerServices.ForwardOpen(new ForwardOpenParams(
|
|
false, 0x11223344, 0x5566, 0x7788, 0x99AABBCC, 0x01, 1_000_000, 504, [0x01, 0x00]));
|
|
Convert.ToHexString(bytes).ShouldBe(
|
|
"540220062401" + "0A05" + "00000000" + "44332211" + "6655" + "8877" + "CCBBAA99" + "01000000"
|
|
+ "40420F00" + "F843" + "40420F00" + "F843" + "A3" + "03" + "0100" + "20022401");
|
|
}
|
|
|
|
[Fact]
|
|
public void Regular_forward_open_network_params_encoding()
|
|
{
|
|
var bytes = ConnectionManagerServices.ForwardOpen(new ForwardOpenParams(false, 1, 2, 3, 4, 1, 1_000_000, 504, []));
|
|
// 504 = 0x1F8; 0x4200 | 0x1F8 = 0x43F8 -> LE F8 43
|
|
Convert.ToHexString(bytes).ShouldContain("40420F00" + "F843");
|
|
bytes[0].ShouldBe((byte)0x54);
|
|
}
|
|
|
|
[Fact]
|
|
public void Unconnected_send_even_embedded()
|
|
{
|
|
byte[] embedded = [0x4C, 0x02, 0x91, 0x01, 0x41, 0x00, 0x01, 0x00]; // 8 bytes
|
|
Convert.ToHexString(ConnectionManagerServices.UnconnectedSend(embedded, [0x01, 0x00])).ShouldBe(
|
|
"520220062401" + "0A05" + "0800" + Convert.ToHexString(embedded) + "01" + "00" + "0100");
|
|
}
|
|
|
|
[Fact]
|
|
public void Unconnected_send_pads_odd_embedded()
|
|
{
|
|
byte[] embedded = [0x01, 0x02, 0x03];
|
|
Convert.ToHexString(ConnectionManagerServices.UnconnectedSend(embedded, [0x01, 0x00])).ShouldBe(
|
|
"520220062401" + "0A05" + "0300" + "010203" + "00" + "01" + "00" + "0100");
|
|
}
|
|
|
|
[Fact]
|
|
public void Refused_forward_open_surfaces_statuses()
|
|
{
|
|
var reply = CipReply.Parse(Convert.FromHexString("DB000101" + "0901"));
|
|
var ex = Should.Throw<AbTransportException>(() => ConnectionManagerServices.ParseForwardOpenReply(reply));
|
|
ex.Message.ShouldContain("0x01");
|
|
ex.Message.ShouldContain("0x0109");
|
|
}
|
|
|
|
[Fact]
|
|
public void Refused_forward_open_carries_the_statuses_as_structured_data()
|
|
{
|
|
var reply = CipReply.Parse(Convert.FromHexString("DB000102" + "09010203"));
|
|
var ex = Should.Throw<ForwardOpenRefusedException>(() => ConnectionManagerServices.ParseForwardOpenReply(reply));
|
|
ex.GeneralStatus.ShouldBe((byte)0x01);
|
|
ex.ExtendedStatus.ShouldBe(new ushort[] { 0x0109, 0x0302 });
|
|
ex.Status.ShouldBe(AbStatus.BadConnection); // general 0x01 has no specific mapping
|
|
}
|
|
|
|
[Fact]
|
|
public void Refused_forward_open_exception_is_not_aliased_to_the_reply_array()
|
|
{
|
|
var reply = CipReply.Parse(Convert.FromHexString("DB000101" + "0901"));
|
|
var ex = Should.Throw<ForwardOpenRefusedException>(() => ConnectionManagerServices.ParseForwardOpenReply(reply));
|
|
reply.ExtendedStatus[0] = 0xFFFF;
|
|
ex.ExtendedStatus[0].ShouldBe((ushort)0x0109);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("DB000800", true)] // Service Not Supported
|
|
[InlineData("DB0001010901", true)] // general 1 / extended 0x0109
|
|
[InlineData("DB0001010001", false)] // general 1 / extended 0x0100 (connection in use): no fallback
|
|
[InlineData("DB000000", false)]
|
|
public void Fallback_rules(string hex, bool expected) =>
|
|
ConnectionManagerServices.ShouldFallBackToRegularOpen(CipReply.Parse(Convert.FromHexString(hex))).ShouldBe(expected);
|
|
|
|
[Theory]
|
|
[InlineData(true, 0)] [InlineData(true, 0x10000)] [InlineData(false, 0)] [InlineData(false, 0x200)]
|
|
public void Connection_size_out_of_range_throws(bool large, int size) =>
|
|
Should.Throw<ArgumentOutOfRangeException>(() => ConnectionManagerServices.ForwardOpen(
|
|
new ForwardOpenParams(large, 1, 2, 3, 4, 1, 1_000_000, size, [])));
|
|
|
|
[Theory]
|
|
[InlineData(true, 1)] [InlineData(true, 0xFFFF)] [InlineData(false, 1)] [InlineData(false, 0x1FF)]
|
|
public void Connection_size_bounds_accepted(bool large, int size) =>
|
|
ConnectionManagerServices.ForwardOpen(new ForwardOpenParams(large, 1, 2, 3, 4, 1, 1_000_000, size, [])).ShouldNotBeEmpty();
|
|
}
|