diff --git a/src/AR.Iec61850/Osi/CotpClient.cs b/src/AR.Iec61850/Osi/CotpClient.cs index 094bee2..491f379 100644 --- a/src/AR.Iec61850/Osi/CotpClient.cs +++ b/src/AR.Iec61850/Osi/CotpClient.cs @@ -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) @@ -53,50 +57,113 @@ public async Task ReceiveDataAsync(CancellationToken cancellationToken) if (!IsConnected) throw new InvalidOperationException("COTP session is not connected."); - var parts = new List(); - 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; +/// +/// 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. +/// +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 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(); +} \ No newline at end of file diff --git a/tests/AR.Iec61850.Tests/Osi/CotpLargeSegmentedResponseTests.cs b/tests/AR.Iec61850.Tests/Osi/CotpLargeSegmentedResponseTests.cs new file mode 100644 index 0000000..dbe498d --- /dev/null +++ b/tests/AR.Iec61850.Tests/Osi/CotpLargeSegmentedResponseTests.cs @@ -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(() => + 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(), endOfTransmission: false)); + accumulator.AppendTpktPayload(BuildDataTpdu(Array.Empty(), endOfTransmission: false)); + + var exception = Assert.Throws(() => + accumulator.AppendTpktPayload(BuildDataTpdu(Array.Empty(), 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; + } +} \ No newline at end of file