From 5ab2fd5faa156d89908a44887a556b9c76de591e Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 04:07:45 +0700 Subject: [PATCH 1/4] Support large bounded segmented COTP responses --- src/AR.Iec61850/Osi/CotpClient.cs | 69 ++++++++++++++++++------------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/src/AR.Iec61850/Osi/CotpClient.cs b/src/AR.Iec61850/Osi/CotpClient.cs index 094bee2..898cac5 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,9 +57,9 @@ 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 reassembled = new MemoryStream(); + var fragmentCount = 0; + var emptyNonFinalFragments = 0; while (true) { @@ -63,40 +67,49 @@ public async Task ReceiveDataAsync(CancellationToken cancellationToken) var tpktPayload = await _tpkt.ReceiveTpktAsync(cancellationToken).ConfigureAwait(false); if (tpktPayload.Length < 3) - throw new InvalidDataException($"COTP data response is too short ({tpktPayload.Length} byte)."); + 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}."); + 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}."); + throw new InvalidDataException($"Expected COTP Data TPDU 0xF0, received 0x{tpktPayload[1]:X2}." ); + + fragmentCount++; + if (fragmentCount > MaximumResponseFragments) + { + throw new InvalidDataException( + $"COTP segmented response exceeded the bounded limit of {MaximumResponseFragments:N0} TPDU fragments. " + + $"Reassembled {reassembled.Length:N0} byte(s) before EOT." ); + } var endOfTransmission = (tpktPayload[2] & 0x80) != 0; var userDataOffset = headerLength + 1; - var userData = tpktPayload[userDataOffset..]; - parts.Add(userData); - total += userData.Length; + var userDataLength = tpktPayload.Length - userDataOffset; + + if (userDataLength == 0 && !endOfTransmission) + { + emptyNonFinalFragments++; + if (emptyNonFinalFragments > MaximumEmptyNonFinalFragments) + { + throw new InvalidDataException( + $"COTP segmented response contained more than {MaximumEmptyNonFinalFragments:N0} empty non-final TPDU fragments." ); + } + } + + if (reassembled.Length + userDataLength > MaximumReassembledResponseBytes) + { + throw new InvalidDataException( + $"COTP segmented response exceeded the bounded reassembly limit of {MaximumReassembledResponseBytes:N0} byte(s). " + + $"Fragments={fragmentCount:N0}, receivedBeforeFragment={reassembled.Length:N0}, incoming={userDataLength:N0}." ); + } + + if (userDataLength > 0) + reassembled.Write(tpktPayload, userDataOffset, userDataLength); if (endOfTransmission) - break; - - guard++; - if (guard > 32) - throw new InvalidDataException("COTP segmented response exceeded 32 TPDU fragments."); - } - - if (parts.Count == 1) - return parts[0]; - - var result = new byte[total]; - var offset = 0; - foreach (var part in parts) - { - Buffer.BlockCopy(part, 0, result, offset, part.Length); - offset += part.Length; + return reassembled.ToArray(); } - - return result; } -} +} \ No newline at end of file From 428b0198545ac6f7f4087fa42d10037ebe7ab00c Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 04:08:20 +0700 Subject: [PATCH 2/4] Share bounded COTP segment accumulator with tests --- src/AR.Iec61850/Osi/CotpClient.cs | 132 +++++++++++++++++++++--------- 1 file changed, 93 insertions(+), 39 deletions(-) diff --git a/src/AR.Iec61850/Osi/CotpClient.cs b/src/AR.Iec61850/Osi/CotpClient.cs index 898cac5..491f379 100644 --- a/src/AR.Iec61850/Osi/CotpClient.cs +++ b/src/AR.Iec61850/Osi/CotpClient.cs @@ -57,59 +57,113 @@ public async Task ReceiveDataAsync(CancellationToken cancellationToken) if (!IsConnected) throw new InvalidOperationException("COTP session is not connected."); - using var reassembled = new MemoryStream(); - var fragmentCount = 0; - var emptyNonFinalFragments = 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)." ); + accumulator.AppendTpktPayload(tpktPayload); + } + + return accumulator.Complete(); + } +} + +/// +/// 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; + } + + public bool IsComplete { get; private set; } + public int FragmentCount => _fragmentCount; + public long ReassembledBytes => _buffer.Length; - 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}." ); + 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 (tpktPayload[1] != 0xF0) - throw new InvalidDataException($"Expected COTP Data TPDU 0xF0, received 0x{tpktPayload[1]:X2}." ); + var endOfTransmission = (tpktPayload[2] & 0x80) != 0; + var userDataOffset = headerLength + 1; + var userDataLength = tpktPayload.Length - userDataOffset; - fragmentCount++; - if (fragmentCount > MaximumResponseFragments) + if (userDataLength == 0 && !endOfTransmission) + { + _emptyNonFinalFragments++; + if (_emptyNonFinalFragments > _maximumEmptyNonFinalFragments) { throw new InvalidDataException( - $"COTP segmented response exceeded the bounded limit of {MaximumResponseFragments:N0} TPDU fragments. " + - $"Reassembled {reassembled.Length:N0} byte(s) before EOT." ); + $"COTP segmented response contained more than {_maximumEmptyNonFinalFragments:N0} empty non-final TPDU fragments." ); } + } - var endOfTransmission = (tpktPayload[2] & 0x80) != 0; - var userDataOffset = headerLength + 1; - var userDataLength = tpktPayload.Length - userDataOffset; + 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 && !endOfTransmission) - { - emptyNonFinalFragments++; - if (emptyNonFinalFragments > MaximumEmptyNonFinalFragments) - { - throw new InvalidDataException( - $"COTP segmented response contained more than {MaximumEmptyNonFinalFragments:N0} empty non-final TPDU fragments." ); - } - } + if (userDataLength > 0) + _buffer.Write(tpktPayload[userDataOffset..]); - if (reassembled.Length + userDataLength > MaximumReassembledResponseBytes) - { - throw new InvalidDataException( - $"COTP segmented response exceeded the bounded reassembly limit of {MaximumReassembledResponseBytes:N0} byte(s). " + - $"Fragments={fragmentCount:N0}, receivedBeforeFragment={reassembled.Length:N0}, incoming={userDataLength:N0}." ); - } + IsComplete = endOfTransmission; + } - if (userDataLength > 0) - reassembled.Write(tpktPayload, userDataOffset, userDataLength); + public byte[] Complete() + { + if (!IsComplete) + throw new InvalidOperationException("The COTP Data TPDU sequence ended before EOT."); - if (endOfTransmission) - return reassembled.ToArray(); - } + return _buffer.ToArray(); } + + public void Dispose() => _buffer.Dispose(); } \ No newline at end of file From e63bd006db9de5afcdf6ee78769f9e9916b49a02 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 04:09:38 +0700 Subject: [PATCH 3/4] Add COTP segmented response tests --- .../Osi/CotpLargeSegmentedResponseTests.cs | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Osi/CotpLargeSegmentedResponseTests.cs diff --git a/tests/AR.Iec61850.Tests/Osi/CotpLargeSegmentedResponseTests.cs b/tests/AR.Iec61850.Tests/Osi/CotpLargeSegmentedResponseTests.cs new file mode 100644 index 0000000..a5edbc4 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Osi/CotpLargeSegmentedResponseTests.cs @@ -0,0 +1,7 @@ +using AR.Iec61850.Osi; + +namespace AR.Iec61850.Tests.Osi; + +public sealed class CotpLargeSegmentedResponseTests +{ +} From 66749d17a99411d58c8bc8b80d4c3cdc7b8e6709 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 04:09:57 +0700 Subject: [PATCH 4/4] Cover large and bounded COTP response reassembly --- .../Osi/CotpLargeSegmentedResponseTests.cs | 77 ++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/tests/AR.Iec61850.Tests/Osi/CotpLargeSegmentedResponseTests.cs b/tests/AR.Iec61850.Tests/Osi/CotpLargeSegmentedResponseTests.cs index a5edbc4..dbe498d 100644 --- a/tests/AR.Iec61850.Tests/Osi/CotpLargeSegmentedResponseTests.cs +++ b/tests/AR.Iec61850.Tests/Osi/CotpLargeSegmentedResponseTests.cs @@ -4,4 +4,79 @@ 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