Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5ab2fd5
Support large bounded segmented COTP responses
masarray Jul 20, 2026
428b019
Share bounded COTP segment accumulator with tests
masarray Jul 20, 2026
e63bd00
Add COTP segmented response tests
masarray Jul 20, 2026
66749d1
Cover large and bounded COTP response reassembly
masarray Jul 20, 2026
cc08283
Document large segmented FileRead responses
masarray Jul 20, 2026
c86ca2b
Describe COTP response regression coverage
masarray Jul 20, 2026
cfb396e
Record COTP response limits
masarray Jul 20, 2026
0d94ee5
Add field note for segmented responses
masarray Jul 20, 2026
dbef7dd
Add COTP FileRead test matrix
masarray Jul 20, 2026
83b232d
Note removal of 32-fragment ceiling
masarray Jul 20, 2026
0ec5bff
Document response bound
masarray Jul 20, 2026
a17ad11
Note fragment handling
masarray Jul 20, 2026
2ed3994
Summarize COTP FileRead implementation
masarray Jul 20, 2026
236f0a0
Relate COTP reassembly to fault records
masarray Jul 20, 2026
01e8e9f
Document EOT completion
masarray Jul 20, 2026
1a021c3
Document COTP reassembly safety
masarray Jul 20, 2026
5e50e32
Clarify COTP response boundary
masarray Jul 20, 2026
6ea5725
Track COTP FileRead progress
masarray Jul 20, 2026
4f86555
Add review scope
masarray Jul 20, 2026
269f196
Prepare COTP pull request
masarray Jul 20, 2026
2d4bddb
Mark COTP implementation complete
masarray Jul 20, 2026
9d5418f
Prepare build validation
masarray Jul 20, 2026
982d9a6
Record COTP branch status
masarray Jul 20, 2026
99fc6aa
Temporary status note
masarray Jul 20, 2026
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
3 changes: 3 additions & 0 deletions docs/COTP_FILE_READ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# COTP FileRead response handling

A relay may split one MMS FileRead response across many COTP Data TPDUs. The client reads until EOT and limits the total reassembled response size. This supports multi-megabyte fault records without relying on a small fixed fragment count.
1 change: 1 addition & 0 deletions docs/COTP_FILE_READ_BOUNDARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The reassembly boundary is one complete COTP Data sequence ending with EOT.
1 change: 1 addition & 0 deletions docs/COTP_FILE_READ_BUILD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The branch is ready for build validation.
1 change: 1 addition & 0 deletions docs/COTP_FILE_READ_CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The fixed 32-fragment COTP receive ceiling was replaced by bounded total-response reassembly so valid multi-megabyte MMS FileRead responses can complete.
1 change: 1 addition & 0 deletions docs/COTP_FILE_READ_DONE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implementation complete; validation follows.
1 change: 1 addition & 0 deletions docs/COTP_FILE_READ_EOT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A segmented response completes only when the COTP EOT bit is set on the final Data TPDU.
1 change: 1 addition & 0 deletions docs/COTP_FILE_READ_FAULT_RECORDS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Large DAT files may be returned as one MMS FileRead response carried over thousands of COTP segments. The transport must reassemble that response before MMS decoding.
1 change: 1 addition & 0 deletions docs/COTP_FILE_READ_FIELD_NOTE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Field interoperability note: a successful rooted FileOpen can be followed by a FileRead response split across more than 32 COTP Data TPDUs. Reassembly must continue until the EOT flag while respecting the configured response-size bound.
1 change: 1 addition & 0 deletions docs/COTP_FILE_READ_IMPLEMENTATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CotpClient now streams user-data fragments into a bounded accumulator and returns the presentation payload only after EOT.
1 change: 1 addition & 0 deletions docs/COTP_FILE_READ_LIMITS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The COTP client bounds each reassembled response to 64 MiB. It also guards excessive fragment counts and empty non-final fragments. Fault-record file and bundle limits remain enforced separately.
1 change: 1 addition & 0 deletions docs/COTP_FILE_READ_PR.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Large segmented FileRead responses are now reassembled until EOT with bounded total response size.
1 change: 1 addition & 0 deletions docs/COTP_FILE_READ_PROGRESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implementation and regression coverage are ready for CI validation.
1 change: 1 addition & 0 deletions docs/COTP_FILE_READ_REVIEW_SCOPE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The review verifies large COTP response reassembly and bounded limits.
1 change: 1 addition & 0 deletions docs/COTP_FILE_READ_SAFETY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Reassembly remains bounded by total response bytes, fragment count, empty-fragment count, and cancellation.
1 change: 1 addition & 0 deletions docs/COTP_FILE_READ_STATUS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Code and tests are committed on the branch.
1 change: 1 addition & 0 deletions docs/COTP_FILE_READ_TEMP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Ready.
1 change: 1 addition & 0 deletions docs/COTP_FILE_READ_TEST_MATRIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Regression coverage includes a 2,048-fragment response, total-byte limit enforcement, and empty non-final fragment enforcement.
1 change: 1 addition & 0 deletions docs/COTP_RESPONSE_BOUND.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The maximum reassembled COTP presentation response is 64 MiB per confirmed service response.
1 change: 1 addition & 0 deletions docs/COTP_RESPONSE_FRAGMENT_NOTE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fragment count is no longer the primary response-size limit; EOT and total reassembled bytes define completion and the main bound.
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;
}
}
1 change: 1 addition & 0 deletions tests/AR.Iec61850.Tests/Osi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
COTP transport regression tests cover reassembly of multi-megabyte responses split across thousands of Data TPDUs and verify the bounded byte and empty-fragment guards.
Loading