diff --git a/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordInteroperableDownloader.cs b/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordInteroperableDownloader.cs new file mode 100644 index 0000000..6f61cec --- /dev/null +++ b/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordInteroperableDownloader.cs @@ -0,0 +1,192 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.FaultRecords; + +/// +/// Downloads every file in a discovered fault-record bundle while preserving the +/// complete signed Integer32 FRSM returned by the MMS server. +/// +public static class Iec61850FaultRecordInteroperableDownloader +{ + public static async Task DownloadAsync( + MmsClientSession session, + Iec61850FaultRecordSet record, + string destinationRoot, + Iec61850FaultRecordDownloadOptions? options = null, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(record); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationRoot); + options ??= new Iec61850FaultRecordDownloadOptions(); + ValidateOptions(options); + + if (record.Files.Count == 0) + return Fail(record, "The selected fault record contains no downloadable file."); + if (options.RequireCompleteRecord && !record.IsComplete) + return Fail(record, $"The selected fault record is incomplete: {record.Completeness}."); + if (!record.HasUnknownSize && record.KnownSizeBytes > options.MaximumTotalBytes) + return Fail(record, $"The selected fault record declares {record.KnownSizeBytes} byte(s), exceeding the configured bundle limit of {options.MaximumTotalBytes}."); + + var destinationFullPath = Path.GetFullPath(destinationRoot); + Directory.CreateDirectory(destinationFullPath); + var temporaryDirectory = Path.Combine( + destinationFullPath, + $".fault-record-{Guid.NewGuid():N}.partial"); + Directory.CreateDirectory(temporaryDirectory); + + var downloadedFiles = new List(); + long totalBytes = 0; + long? expectedBytes = record.HasUnknownSize ? null : record.KnownSizeBytes; + string? failure = null; + + try + { + for (var index = 0; index < record.Files.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var file = record.Files[index]; + var safeFileName = Iec61850LocalPath.SanitizeFileName(file.Name); + var localPath = Iec61850LocalPath.CombineUnderRoot(temporaryDirectory, safeFileName); + var remainingBundleBytes = options.MaximumTotalBytes - totalBytes; + if (remainingBundleBytes <= 0) + throw new InvalidDataException($"Bundle transfer exceeded the configured limit of {options.MaximumTotalBytes} byte(s)."); + + var maximumFileBytes = Math.Min(options.MaximumFileBytes, remainingBundleBytes); + var baseBytes = totalBytes; + var fileProgress = new InlineProgress(item => + { + progress?.Report(new Iec61850FaultRecordDownloadProgress + { + RecordId = record.RecordId, + CurrentFileName = file.Name, + CompletedFiles = index, + TotalFiles = record.Files.Count, + BytesTransferred = baseBytes + item.BytesTransferred, + ExpectedBytes = expectedBytes, + IsComplete = false + }); + }); + + await using (var output = new FileStream( + localPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 64 * 1024, + useAsync: true)) + { + var transfer = await session.DownloadFileInteroperableAsync( + file.RemotePath, + output, + new MmsFileTransferOptions + { + MaximumBytes = maximumFileBytes, + MaximumReadOperations = options.MaximumReadOperationsPerFile, + RequireDeclaredSizeMatch = options.RequireDeclaredSizeMatch, + FlushDestinationOnSuccess = true + }, + fileProgress, + cancellationToken).ConfigureAwait(false); + + if (!transfer.IsSuccess) + { + throw new InvalidDataException( + $"{file.RemotePath}: {transfer.Message} " + + $"Request={ValueOrDash(session.LastDiscoveryRequestHex)}; " + + $"Response={ValueOrDash(session.LastDiscoveryResponseHex)}"); + } + + totalBytes += transfer.BytesTransferred; + downloadedFiles.Add(new Iec61850FaultRecordDownloadedFile + { + RemotePath = file.RemotePath, + LocalPath = localPath, + BytesTransferred = transfer.BytesTransferred + }); + } + + progress?.Report(new Iec61850FaultRecordDownloadProgress + { + RecordId = record.RecordId, + CurrentFileName = file.Name, + CompletedFiles = index + 1, + TotalFiles = record.Files.Count, + BytesTransferred = totalBytes, + ExpectedBytes = expectedBytes, + IsComplete = index + 1 == record.Files.Count + }); + } + + var finalDirectory = Iec61850LocalPath.ResolveAvailableDirectory( + destinationFullPath, + Iec61850LocalPath.SanitizeFileName(record.BaseName)); + Directory.Move(temporaryDirectory, finalDirectory); + + var finalFiles = downloadedFiles + .Select(file => new Iec61850FaultRecordDownloadedFile + { + RemotePath = file.RemotePath, + LocalPath = Path.Combine(finalDirectory, Path.GetFileName(file.LocalPath)), + BytesTransferred = file.BytesTransferred + }) + .ToArray(); + + return new Iec61850FaultRecordDownloadResult + { + IsSuccess = true, + RecordId = record.RecordId, + DestinationDirectory = finalDirectory, + Files = finalFiles, + BytesTransferred = totalBytes, + Message = $"Downloaded {finalFiles.Length} fault-record file(s), {totalBytes} byte(s), to '{finalDirectory}'." + }; + } + catch (OperationCanceledException) + { + Iec61850LocalPath.TryDeleteDirectory(temporaryDirectory); + throw; + } + catch (Exception ex) when ( + ex is IOException or + InvalidDataException or + UnauthorizedAccessException or + ArgumentException or + InvalidOperationException) + { + failure = ex.Message; + } + + Iec61850LocalPath.TryDeleteDirectory(temporaryDirectory); + return new Iec61850FaultRecordDownloadResult + { + IsSuccess = false, + RecordId = record.RecordId, + Files = downloadedFiles, + BytesTransferred = totalBytes, + Message = failure ?? "Fault-record download failed." + }; + } + + private static void ValidateOptions(Iec61850FaultRecordDownloadOptions options) + { + if (options.MaximumTotalBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(options), "MaximumTotalBytes must be greater than zero."); + if (options.MaximumFileBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(options), "MaximumFileBytes must be greater than zero."); + if (options.MaximumReadOperationsPerFile <= 0) + throw new ArgumentOutOfRangeException(nameof(options), "MaximumReadOperationsPerFile must be greater than zero."); + } + + private static Iec61850FaultRecordDownloadResult Fail(Iec61850FaultRecordSet record, string message) + => new() + { + IsSuccess = false, + RecordId = record.RecordId, + Message = message + }; + + private static string ValueOrDash(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); +} diff --git a/src/AR.Iec61850/Mms/MmsInteroperableFileTransfer.cs b/src/AR.Iec61850/Mms/MmsInteroperableFileTransfer.cs new file mode 100644 index 0000000..79e3a81 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsInteroperableFileTransfer.cs @@ -0,0 +1,345 @@ +using AR.Iec61850.Asn1; +using AR.Iec61850.Diagnostics; + +namespace AR.Iec61850.Mms; + +/// +/// Compatibility file-transfer path for MMS servers that allocate a negative +/// FileReadStateMachine identifier. ISO 9506 defines frsmID as Integer32, so the +/// full signed range is valid and must be echoed unchanged in FileRead/FileClose. +/// +public static class MmsInteroperableFileOpenResponseDecoder +{ + public static MmsFileOpenResult Decode(ReadOnlyMemory presentationPayload, int expectedInvokeId) + { + var hex = HexDump.ToCompactString(presentationPayload.Span); + if (!MmsFileResponseEnvelope.TryDecode( + presentationPayload, + expectedInvokeId, + serviceTagNumber: 72, + operationName: "FileOpen", + out var service, + out var error)) + { + return Fail(error, hex); + } + + try + { + int? stateMachineId = null; + uint? fileSize = null; + byte[] lastModified = Array.Empty(); + + foreach (var field in BerReader.ReadChildren(service.Value)) + { + if (field.Class == BerClass.ContextSpecific && field.TagNumber == 0) + { + var value = BerReader.ReadSignedInteger(field); + if (value is >= int.MinValue and <= int.MaxValue) + stateMachineId = (int)value.Value; + } + else if (field.Class == BerClass.ContextSpecific && field.TagNumber == 1 && field.Constructed) + { + MmsFileResponseEnvelope.DecodeFileAttributes(field, ref fileSize, ref lastModified); + } + } + + if (!stateMachineId.HasValue) + return Fail("FileOpen response did not contain a valid signed Integer32 file read state machine identifier.", hex); + + return new MmsFileOpenResult + { + IsSuccess = true, + FileReadStateMachineId = stateMachineId.Value, + FileSizeBytes = fileSize, + LastModifiedRaw = lastModified, + Message = fileSize.HasValue + ? $"MMS FileOpen succeeded. FRSM={stateMachineId.Value}, declaredSize={fileSize.Value}." + : $"MMS FileOpen succeeded. FRSM={stateMachineId.Value}, declared size unavailable.", + ResponseHexPreview = hex + }; + } + catch (Exception ex) when (ex is BerFormatException or ArgumentException or InvalidOperationException) + { + return Fail($"FileOpen response decode failed: {ex.GetType().Name}: {ex.Message}", hex); + } + } + + private static MmsFileOpenResult Fail(string message, string hex) + => new() + { + IsSuccess = false, + Message = message, + ResponseHexPreview = hex + }; +} + +public static class MmsInteroperableFileReadRequest +{ + public static byte[] Build(int invokeId, int fileReadStateMachineId) + => BuildFrsmRequest(invokeId, fileReadStateMachineId, serviceTagNumber: 73); + + internal static byte[] BuildFrsmRequest(int invokeId, int fileReadStateMachineId, int serviceTagNumber) + { + if (invokeId < 0) + throw new ArgumentOutOfRangeException(nameof(invokeId)); + + var service = BerWriter.EncodeTlv( + BerClass.ContextSpecific, + constructed: false, + tagNumber: serviceTagNumber, + BerWriter.EncodeSignedInteger(fileReadStateMachineId)); + var confirmedRequest = BerWriter.EncodeTlv( + 0xA0, + MmsPresentation.Concat(MmsPresentation.Integer(invokeId), service)); + return MmsPresentation.WrapIsoPresentationPData(confirmedRequest); + } +} + +public static class MmsInteroperableFileCloseRequest +{ + public static byte[] Build(int invokeId, int fileReadStateMachineId) + => MmsInteroperableFileReadRequest.BuildFrsmRequest(invokeId, fileReadStateMachineId, serviceTagNumber: 74); +} + +public sealed partial class MmsClientSession +{ + public async Task DownloadFileInteroperableAsync( + string remotePath, + Stream destination, + MmsFileTransferOptions? options = null, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + EnsureMmsReady(); + ArgumentNullException.ThrowIfNull(destination); + if (!destination.CanWrite) + throw new ArgumentException("Destination stream must be writable.", nameof(destination)); + + options ??= new MmsFileTransferOptions(); + if (options.MaximumBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(options), "MaximumBytes must be greater than zero."); + if (options.MaximumReadOperations <= 0) + throw new ArgumentOutOfRangeException(nameof(options), "MaximumReadOperations must be greater than zero."); + + var normalizedPath = MmsFileNameEncoding.Normalize(remotePath); + int? stateMachineId = null; + long bytesTransferred = 0; + long? expectedBytes = null; + var readOperations = 0; + var remoteFileClosed = false; + string? failure = null; + + try + { + var opened = await FileOpenInteroperableAsync(normalizedPath, cancellationToken).ConfigureAwait(false); + if (!opened.IsSuccess) + { + failure = opened.Message; + } + else + { + stateMachineId = opened.FileReadStateMachineId; + expectedBytes = opened.FileSizeBytes is > 0 ? (long)opened.FileSizeBytes.Value : null; + if (expectedBytes > options.MaximumBytes) + { + failure = $"Remote file declares {expectedBytes.Value} byte(s), exceeding the configured limit of {options.MaximumBytes}."; + } + else + { + var moreFollows = true; + while (moreFollows && failure == null) + { + cancellationToken.ThrowIfCancellationRequested(); + if (++readOperations > options.MaximumReadOperations) + { + failure = $"FileRead exceeded the configured operation limit of {options.MaximumReadOperations}."; + break; + } + + var chunk = await FileReadInteroperableAsync(stateMachineId.Value, cancellationToken).ConfigureAwait(false); + if (!chunk.IsSuccess) + { + failure = chunk.Message; + break; + } + + if (chunk.Data.Length == 0 && chunk.MoreFollows) + { + failure = "FileRead returned an empty block while moreFollows remained true."; + break; + } + + if (bytesTransferred + chunk.Data.LongLength > options.MaximumBytes) + { + failure = $"File transfer exceeded the configured limit of {options.MaximumBytes} byte(s)."; + break; + } + + if (chunk.Data.Length > 0) + { + await destination.WriteAsync(chunk.Data.AsMemory(), cancellationToken).ConfigureAwait(false); + bytesTransferred += chunk.Data.LongLength; + } + + moreFollows = chunk.MoreFollows; + progress?.Report(new MmsFileTransferProgress + { + RemotePath = normalizedPath, + BytesTransferred = bytesTransferred, + ExpectedBytes = expectedBytes, + ReadOperations = readOperations, + IsComplete = !moreFollows + }); + } + + if (failure == null && + options.RequireDeclaredSizeMatch && + expectedBytes.HasValue && + expectedBytes.Value != bytesTransferred) + { + failure = $"Transferred size {bytesTransferred} does not match declared size {expectedBytes.Value}."; + } + + if (failure == null && options.FlushDestinationOnSuccess) + await destination.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + } + catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) + { + failure = $"File transfer failed: {ex.GetType().Name}: {ex.Message}"; + } + finally + { + if (stateMachineId.HasValue && IsMmsInitiated) + { + try + { + using var closeCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var close = await FileCloseInteroperableAsync(stateMachineId.Value, closeCancellation.Token).ConfigureAwait(false); + remoteFileClosed = close.IsSuccess; + if (!close.IsSuccess && failure == null) + failure = close.Message; + } + catch (OperationCanceledException) + { + if (failure == null) + failure = "FileClose did not complete within the bounded close timeout."; + } + } + } + + var success = failure == null; + if (success) + { + progress?.Report(new MmsFileTransferProgress + { + RemotePath = normalizedPath, + BytesTransferred = bytesTransferred, + ExpectedBytes = expectedBytes, + ReadOperations = readOperations, + IsComplete = true + }); + } + + return new MmsFileTransferResult + { + IsSuccess = success, + RemotePath = normalizedPath, + BytesTransferred = bytesTransferred, + ExpectedBytes = expectedBytes, + ReadOperations = readOperations, + RemoteFileClosed = remoteFileClosed, + Message = success + ? $"Downloaded {bytesTransferred} byte(s) from '{normalizedPath}' in {readOperations} FileRead operation(s); FRSM={stateMachineId}." + : $"{failure ?? "MMS file transfer failed."} RemotePath='{normalizedPath}', FRSM={stateMachineId?.ToString() ?? "not-opened"}." + }; + } + + private async Task FileOpenInteroperableAsync( + string remotePath, + CancellationToken cancellationToken) + { + var invokeId = NextInvokeId(); + var request = MmsFileOpenRequest.Build(invokeId, remotePath, initialPosition: 0); + LastDiscoveryRequestHex = HexDump.ToCompactString(request); + + try + { + var response = await SendConfirmedPresentationPayloadAsync(request, invokeId, cancellationToken).ConfigureAwait(false); + var result = MmsInteroperableFileOpenResponseDecoder.Decode(response, invokeId); + LastDiscoveryResponseHex = result.ResponseHexPreview; + LastDiscoveryAttemptSummary = result.Message; + return result; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) + { + await MarkProtocolFaultAsync().ConfigureAwait(false); + return new MmsFileOpenResult + { + IsSuccess = false, + Message = $"FileOpen transport fault: {ex.GetType().Name}: {ex.Message}", + ResponseHexPreview = LastDiscoveryResponseHex + }; + } + } + + private async Task FileReadInteroperableAsync( + int fileReadStateMachineId, + CancellationToken cancellationToken) + { + var invokeId = NextInvokeId(); + var request = MmsInteroperableFileReadRequest.Build(invokeId, fileReadStateMachineId); + LastDiscoveryRequestHex = HexDump.ToCompactString(request); + + try + { + var response = await SendConfirmedPresentationPayloadAsync(request, invokeId, cancellationToken).ConfigureAwait(false); + var result = MmsFileReadResponseDecoder.Decode(response, invokeId, fileReadStateMachineId); + LastDiscoveryResponseHex = result.ResponseHexPreview; + LastDiscoveryAttemptSummary = result.Message; + return result; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) + { + await MarkProtocolFaultAsync().ConfigureAwait(false); + return new MmsFileReadResult + { + IsSuccess = false, + FileReadStateMachineId = fileReadStateMachineId, + Message = $"FileRead transport fault: {ex.GetType().Name}: {ex.Message}", + ResponseHexPreview = LastDiscoveryResponseHex + }; + } + } + + private async Task FileCloseInteroperableAsync( + int fileReadStateMachineId, + CancellationToken cancellationToken) + { + var invokeId = NextInvokeId(); + var request = MmsInteroperableFileCloseRequest.Build(invokeId, fileReadStateMachineId); + LastDiscoveryRequestHex = HexDump.ToCompactString(request); + + try + { + var response = await SendConfirmedPresentationPayloadAsync(request, invokeId, cancellationToken).ConfigureAwait(false); + var result = MmsFileCloseResponseDecoder.Decode(response, invokeId, fileReadStateMachineId); + LastDiscoveryResponseHex = result.ResponseHexPreview; + LastDiscoveryAttemptSummary = result.Message; + return result; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) + { + await MarkProtocolFaultAsync().ConfigureAwait(false); + return new MmsFileCloseResult + { + IsSuccess = false, + FileReadStateMachineId = fileReadStateMachineId, + Message = $"FileClose transport fault: {ex.GetType().Name}: {ex.Message}", + ResponseHexPreview = LastDiscoveryResponseHex + }; + } + } +} diff --git a/tests/AR.Iec61850.Tests/MmsSignedFrsmFileTransferTests.cs b/tests/AR.Iec61850.Tests/MmsSignedFrsmFileTransferTests.cs new file mode 100644 index 0000000..b45313d --- /dev/null +++ b/tests/AR.Iec61850.Tests/MmsSignedFrsmFileTransferTests.cs @@ -0,0 +1,81 @@ +using AR.Iec61850.Asn1; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests; + +public sealed class MmsSignedFrsmFileTransferTests +{ + [Fact] + public void FileOpenResponse_AcceptsNegativeSignedFrsmIdentifier() + { + var response = BuildConfirmedResponse( + invokeId: 31, + serviceTag: 72, + constructed: true, + serviceValue: BerWriter.EncodeTlv( + BerClass.ContextSpecific, + constructed: false, + tagNumber: 0, + BerWriter.EncodeSignedInteger(-17))); + + var result = MmsInteroperableFileOpenResponseDecoder.Decode(response, expectedInvokeId: 31); + + Assert.True(result.IsSuccess, result.Message); + Assert.Equal(-17, result.FileReadStateMachineId); + } + + [Theory] + [InlineData(-1)] + [InlineData(-17)] + [InlineData(int.MinValue)] + [InlineData(23)] + public void FileReadRequest_EchoesCompleteSignedInteger32Range(int frsmId) + { + var request = MmsInteroperableFileReadRequest.Build(32, frsmId); + var service = ReadService(request, expectedTag: 73); + + Assert.Equal((long)frsmId, BerReader.ReadSignedInteger(service)); + } + + [Theory] + [InlineData(-1)] + [InlineData(-17)] + [InlineData(int.MinValue)] + [InlineData(23)] + public void FileCloseRequest_EchoesCompleteSignedInteger32Range(int frsmId) + { + var request = MmsInteroperableFileCloseRequest.Build(33, frsmId); + var service = ReadService(request, expectedTag: 74); + + Assert.Equal((long)frsmId, BerReader.ReadSignedInteger(service)); + } + + private static BerTlv ReadService(byte[] request, int expectedTag) + { + var mms = MmsPresentation.StripPresentationPrefix(request); + var offset = 0; + Assert.True(BerReader.TryReadTlv(mms, ref offset, out var outer)); + var children = BerReader.ReadChildren(outer.Value); + var service = Assert.Single(children.Skip(1)); + Assert.Equal(BerClass.ContextSpecific, service.Class); + Assert.Equal(expectedTag, service.TagNumber); + return service; + } + + private static byte[] BuildConfirmedResponse( + int invokeId, + int serviceTag, + bool constructed, + byte[] serviceValue) + { + var service = BerWriter.EncodeTlv( + BerClass.ContextSpecific, + constructed, + serviceTag, + serviceValue); + var confirmedResponse = BerWriter.EncodeTlv( + 0xA1, + MmsPresentation.Concat(MmsPresentation.Integer(invokeId), service)); + return MmsPresentation.WrapIsoPresentationPData(confirmedResponse); + } +}