Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 99 additions & 32 deletions src/AR.Iec61850/Osi/CotpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ namespace AR.Iec61850.Osi;

public sealed class CotpClient
{
internal const int MaximumReassembledResponseBytes = 64 * 1024 * 1024;
internal const int MaximumResponseFragments = 1_048_576;
internal const int MaximumEmptyNonFinalFragments = 1_024;

private readonly TpktClient _tpkt;

public CotpClient(TpktClient tpkt)
Expand Down Expand Up @@ -53,50 +57,113 @@ public async Task<byte[]> ReceiveDataAsync(CancellationToken cancellationToken)
if (!IsConnected)
throw new InvalidOperationException("COTP session is not connected.");

var parts = new List<byte[]>();
var total = 0;
var guard = 0;
using var accumulator = new CotpDataSequenceAccumulator(
MaximumReassembledResponseBytes,
MaximumResponseFragments,
MaximumEmptyNonFinalFragments);

while (true)
while (!accumulator.IsComplete)
{
cancellationToken.ThrowIfCancellationRequested();

var tpktPayload = await _tpkt.ReceiveTpktAsync(cancellationToken).ConfigureAwait(false);
if (tpktPayload.Length < 3)
throw new InvalidDataException($"COTP data response is too short ({tpktPayload.Length} byte).");

var headerLength = tpktPayload[0];
if (headerLength < 2 || tpktPayload.Length < headerLength + 1)
throw new InvalidDataException($"Invalid COTP data header length {headerLength} for payload size {tpktPayload.Length}.");
accumulator.AppendTpktPayload(tpktPayload);
}

if (tpktPayload[1] != 0xF0)
throw new InvalidDataException($"Expected COTP Data TPDU 0xF0, received 0x{tpktPayload[1]:X2}.");
return accumulator.Complete();
}
}

var endOfTransmission = (tpktPayload[2] & 0x80) != 0;
var userDataOffset = headerLength + 1;
var userData = tpktPayload[userDataOffset..];
parts.Add(userData);
total += userData.Length;
/// <summary>
/// Reassembles one COTP Data TPDU sequence. Large MMS FileRead responses can
/// legitimately span thousands of TPKT/COTP frames, so safety is enforced by
/// bounded total bytes plus very high fragment and empty-fragment guards rather
/// than the previous interoperability-breaking 32-fragment ceiling.
/// </summary>
internal sealed class CotpDataSequenceAccumulator : IDisposable
{
private readonly long _maximumBytes;
private readonly int _maximumFragments;
private readonly int _maximumEmptyNonFinalFragments;
private readonly MemoryStream _buffer = new();
private int _fragmentCount;
private int _emptyNonFinalFragments;

public CotpDataSequenceAccumulator(
long maximumBytes,
int maximumFragments,
int maximumEmptyNonFinalFragments)
{
if (maximumBytes <= 0)
throw new ArgumentOutOfRangeException(nameof(maximumBytes));
if (maximumFragments <= 0)
throw new ArgumentOutOfRangeException(nameof(maximumFragments));
if (maximumEmptyNonFinalFragments <= 0)
throw new ArgumentOutOfRangeException(nameof(maximumEmptyNonFinalFragments));

_maximumBytes = maximumBytes;
_maximumFragments = maximumFragments;
_maximumEmptyNonFinalFragments = maximumEmptyNonFinalFragments;
}

if (endOfTransmission)
break;
public bool IsComplete { get; private set; }
public int FragmentCount => _fragmentCount;
public long ReassembledBytes => _buffer.Length;

guard++;
if (guard > 32)
throw new InvalidDataException("COTP segmented response exceeded 32 TPDU fragments.");
public void AppendTpktPayload(ReadOnlySpan<byte> tpktPayload)
{
if (IsComplete)
throw new InvalidOperationException("The COTP Data TPDU sequence is already complete.");
if (tpktPayload.Length < 3)
throw new InvalidDataException($"COTP data response is too short ({tpktPayload.Length} byte)." );

var headerLength = tpktPayload[0];
if (headerLength < 2 || tpktPayload.Length < headerLength + 1)
throw new InvalidDataException($"Invalid COTP data header length {headerLength} for payload size {tpktPayload.Length}." );
if (tpktPayload[1] != 0xF0)
throw new InvalidDataException($"Expected COTP Data TPDU 0xF0, received 0x{tpktPayload[1]:X2}." );

_fragmentCount++;
if (_fragmentCount > _maximumFragments)
{
throw new InvalidDataException(
$"COTP segmented response exceeded the bounded limit of {_maximumFragments:N0} TPDU fragments. " +
$"Reassembled {_buffer.Length:N0} byte(s) before EOT." );
}

if (parts.Count == 1)
return parts[0];
var endOfTransmission = (tpktPayload[2] & 0x80) != 0;
var userDataOffset = headerLength + 1;
var userDataLength = tpktPayload.Length - userDataOffset;

var result = new byte[total];
var offset = 0;
foreach (var part in parts)
if (userDataLength == 0 && !endOfTransmission)
{
Buffer.BlockCopy(part, 0, result, offset, part.Length);
offset += part.Length;
_emptyNonFinalFragments++;
if (_emptyNonFinalFragments > _maximumEmptyNonFinalFragments)
{
throw new InvalidDataException(
$"COTP segmented response contained more than {_maximumEmptyNonFinalFragments:N0} empty non-final TPDU fragments." );
}
}

return result;
if (_buffer.Length + userDataLength > _maximumBytes)
{
throw new InvalidDataException(
$"COTP segmented response exceeded the bounded reassembly limit of {_maximumBytes:N0} byte(s). " +
$"Fragments={_fragmentCount:N0}, receivedBeforeFragment={_buffer.Length:N0}, incoming={userDataLength:N0}." );
}

if (userDataLength > 0)
_buffer.Write(tpktPayload[userDataOffset..]);

IsComplete = endOfTransmission;
}
}

public byte[] Complete()
{
if (!IsComplete)
throw new InvalidOperationException("The COTP Data TPDU sequence ended before EOT.");

return _buffer.ToArray();
}

public void Dispose() => _buffer.Dispose();
}
82 changes: 82 additions & 0 deletions tests/AR.Iec61850.Tests/Osi/CotpLargeSegmentedResponseTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using AR.Iec61850.Osi;

namespace AR.Iec61850.Tests.Osi;

public sealed class CotpLargeSegmentedResponseTests
{
[Fact]
public void Reassembles_More_Than_ThirtyTwo_Data_Tpdu_Fragments()
{
const int fragmentCount = 2_048;
const int bytesPerFragment = 997;
using var accumulator = new CotpDataSequenceAccumulator(
maximumBytes: 4 * 1024 * 1024,
maximumFragments: 4_096,
maximumEmptyNonFinalFragments: 32);

var expected = new byte[fragmentCount * bytesPerFragment];
for (var fragment = 0; fragment < fragmentCount; fragment++)
{
var data = new byte[bytesPerFragment];
for (var index = 0; index < data.Length; index++)
{
var value = (byte)((fragment + index) % 251);
data[index] = value;
expected[(fragment * bytesPerFragment) + index] = value;
}

accumulator.AppendTpktPayload(BuildDataTpdu(
data,
endOfTransmission: fragment == fragmentCount - 1));
}

var actual = accumulator.Complete();

Assert.Equal(fragmentCount, accumulator.FragmentCount);
Assert.Equal(expected.LongLength, accumulator.ReassembledBytes);
Assert.Equal(expected, actual);
}

[Fact]
public void Rejects_Response_When_Bounded_Byte_Limit_Is_Exceeded()
{
using var accumulator = new CotpDataSequenceAccumulator(
maximumBytes: 10,
maximumFragments: 100,
maximumEmptyNonFinalFragments: 10);

accumulator.AppendTpktPayload(BuildDataTpdu(new byte[8], endOfTransmission: false));

var exception = Assert.Throws<InvalidDataException>(() =>
accumulator.AppendTpktPayload(BuildDataTpdu(new byte[3], endOfTransmission: true)));

Assert.Contains("reassembly limit", exception.Message, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void Rejects_Too_Many_Empty_NonFinal_Fragments()
{
using var accumulator = new CotpDataSequenceAccumulator(
maximumBytes: 1024,
maximumFragments: 100,
maximumEmptyNonFinalFragments: 2);

accumulator.AppendTpktPayload(BuildDataTpdu(Array.Empty<byte>(), endOfTransmission: false));
accumulator.AppendTpktPayload(BuildDataTpdu(Array.Empty<byte>(), endOfTransmission: false));

var exception = Assert.Throws<InvalidDataException>(() =>
accumulator.AppendTpktPayload(BuildDataTpdu(Array.Empty<byte>(), endOfTransmission: false)));

Assert.Contains("empty non-final", exception.Message, StringComparison.OrdinalIgnoreCase);
}

private static byte[] BuildDataTpdu(byte[] userData, bool endOfTransmission)
{
var result = new byte[userData.Length + 3];
result[0] = 0x02;
result[1] = 0xF0;
result[2] = endOfTransmission ? (byte)0x80 : (byte)0x00;
userData.CopyTo(result, 3);
return result;
}
}
Loading