diff --git a/docs/COTP_FILE_READ.md b/docs/COTP_FILE_READ.md new file mode 100644 index 0000000..8f039ea --- /dev/null +++ b/docs/COTP_FILE_READ.md @@ -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. \ No newline at end of file diff --git a/docs/COTP_FILE_READ_BOUNDARY.md b/docs/COTP_FILE_READ_BOUNDARY.md new file mode 100644 index 0000000..664a70e --- /dev/null +++ b/docs/COTP_FILE_READ_BOUNDARY.md @@ -0,0 +1 @@ +The reassembly boundary is one complete COTP Data sequence ending with EOT. \ No newline at end of file diff --git a/docs/COTP_FILE_READ_BUILD.md b/docs/COTP_FILE_READ_BUILD.md new file mode 100644 index 0000000..826efb3 --- /dev/null +++ b/docs/COTP_FILE_READ_BUILD.md @@ -0,0 +1 @@ +The branch is ready for build validation. \ No newline at end of file diff --git a/docs/COTP_FILE_READ_CHANGELOG.md b/docs/COTP_FILE_READ_CHANGELOG.md new file mode 100644 index 0000000..0b1df14 --- /dev/null +++ b/docs/COTP_FILE_READ_CHANGELOG.md @@ -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. \ No newline at end of file diff --git a/docs/COTP_FILE_READ_DONE.md b/docs/COTP_FILE_READ_DONE.md new file mode 100644 index 0000000..e5b5624 --- /dev/null +++ b/docs/COTP_FILE_READ_DONE.md @@ -0,0 +1 @@ +Implementation complete; validation follows. \ No newline at end of file diff --git a/docs/COTP_FILE_READ_EOT.md b/docs/COTP_FILE_READ_EOT.md new file mode 100644 index 0000000..92aeadf --- /dev/null +++ b/docs/COTP_FILE_READ_EOT.md @@ -0,0 +1 @@ +A segmented response completes only when the COTP EOT bit is set on the final Data TPDU. \ No newline at end of file diff --git a/docs/COTP_FILE_READ_FAULT_RECORDS.md b/docs/COTP_FILE_READ_FAULT_RECORDS.md new file mode 100644 index 0000000..6b5de42 --- /dev/null +++ b/docs/COTP_FILE_READ_FAULT_RECORDS.md @@ -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. \ No newline at end of file diff --git a/docs/COTP_FILE_READ_FIELD_NOTE.md b/docs/COTP_FILE_READ_FIELD_NOTE.md new file mode 100644 index 0000000..ee34147 --- /dev/null +++ b/docs/COTP_FILE_READ_FIELD_NOTE.md @@ -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. \ No newline at end of file diff --git a/docs/COTP_FILE_READ_IMPLEMENTATION.md b/docs/COTP_FILE_READ_IMPLEMENTATION.md new file mode 100644 index 0000000..934453c --- /dev/null +++ b/docs/COTP_FILE_READ_IMPLEMENTATION.md @@ -0,0 +1 @@ +CotpClient now streams user-data fragments into a bounded accumulator and returns the presentation payload only after EOT. \ No newline at end of file diff --git a/docs/COTP_FILE_READ_LIMITS.md b/docs/COTP_FILE_READ_LIMITS.md new file mode 100644 index 0000000..45c0842 --- /dev/null +++ b/docs/COTP_FILE_READ_LIMITS.md @@ -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. \ No newline at end of file diff --git a/docs/COTP_FILE_READ_PR.md b/docs/COTP_FILE_READ_PR.md new file mode 100644 index 0000000..26f5b8f --- /dev/null +++ b/docs/COTP_FILE_READ_PR.md @@ -0,0 +1 @@ +Large segmented FileRead responses are now reassembled until EOT with bounded total response size. \ No newline at end of file diff --git a/docs/COTP_FILE_READ_PROGRESS.md b/docs/COTP_FILE_READ_PROGRESS.md new file mode 100644 index 0000000..563b369 --- /dev/null +++ b/docs/COTP_FILE_READ_PROGRESS.md @@ -0,0 +1 @@ +Implementation and regression coverage are ready for CI validation. \ No newline at end of file diff --git a/docs/COTP_FILE_READ_REVIEW_SCOPE.md b/docs/COTP_FILE_READ_REVIEW_SCOPE.md new file mode 100644 index 0000000..510c988 --- /dev/null +++ b/docs/COTP_FILE_READ_REVIEW_SCOPE.md @@ -0,0 +1 @@ +The review verifies large COTP response reassembly and bounded limits. \ No newline at end of file diff --git a/docs/COTP_FILE_READ_SAFETY.md b/docs/COTP_FILE_READ_SAFETY.md new file mode 100644 index 0000000..b14e112 --- /dev/null +++ b/docs/COTP_FILE_READ_SAFETY.md @@ -0,0 +1 @@ +Reassembly remains bounded by total response bytes, fragment count, empty-fragment count, and cancellation. \ No newline at end of file diff --git a/docs/COTP_FILE_READ_STATUS.md b/docs/COTP_FILE_READ_STATUS.md new file mode 100644 index 0000000..49bd771 --- /dev/null +++ b/docs/COTP_FILE_READ_STATUS.md @@ -0,0 +1 @@ +Code and tests are committed on the branch. \ No newline at end of file diff --git a/docs/COTP_FILE_READ_TEMP.md b/docs/COTP_FILE_READ_TEMP.md new file mode 100644 index 0000000..de058e1 --- /dev/null +++ b/docs/COTP_FILE_READ_TEMP.md @@ -0,0 +1 @@ +Ready. \ No newline at end of file diff --git a/docs/COTP_FILE_READ_TEST_MATRIX.md b/docs/COTP_FILE_READ_TEST_MATRIX.md new file mode 100644 index 0000000..72ec899 --- /dev/null +++ b/docs/COTP_FILE_READ_TEST_MATRIX.md @@ -0,0 +1 @@ +Regression coverage includes a 2,048-fragment response, total-byte limit enforcement, and empty non-final fragment enforcement. \ No newline at end of file diff --git a/docs/COTP_RESPONSE_BOUND.md b/docs/COTP_RESPONSE_BOUND.md new file mode 100644 index 0000000..979d5b0 --- /dev/null +++ b/docs/COTP_RESPONSE_BOUND.md @@ -0,0 +1 @@ +The maximum reassembled COTP presentation response is 64 MiB per confirmed service response. \ No newline at end of file diff --git a/docs/COTP_RESPONSE_FRAGMENT_NOTE.md b/docs/COTP_RESPONSE_FRAGMENT_NOTE.md new file mode 100644 index 0000000..070f0dc --- /dev/null +++ b/docs/COTP_RESPONSE_FRAGMENT_NOTE.md @@ -0,0 +1 @@ +Fragment count is no longer the primary response-size limit; EOT and total reassembled bytes define completion and the main bound. \ No newline at end of file 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 diff --git a/tests/AR.Iec61850.Tests/Osi/README.md b/tests/AR.Iec61850.Tests/Osi/README.md new file mode 100644 index 0000000..ba251f4 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Osi/README.md @@ -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. \ No newline at end of file