From 72c74b74a2ef49398ea8c840243d61e00627a3b2 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 11 Aug 2026 05:50:35 +0700 Subject: [PATCH 1/4] Add bounded portable MMS file transfer core --- CMakeLists.txt | 8 + include/ariec61850/mms/file_service.hpp | 312 ++++++ include/ariec61850/mms/pdu.hpp | 4 + src/mms/file_service.cpp | 1296 +++++++++++++++++++++++ tests/test_mms_file_service.cpp | 606 +++++++++++ 5 files changed, 2226 insertions(+) create mode 100644 include/ariec61850/mms/file_service.hpp create mode 100644 src/mms/file_service.cpp create mode 100644 tests/test_mms_file_service.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index cdebc85c6..ebcd42ac3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,6 +25,7 @@ add_library(ariec61850_core src/mms/data_codec.cpp src/mms/pdu.cpp src/mms/services.cpp + src/mms/file_service.cpp src/mms/dynamic_data_set.cpp src/mms/dynamic_report_planner.cpp src/mms/invoke_router.cpp @@ -209,6 +210,13 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_sanitizers(ariec61850_mms_services_tests) add_test(NAME ariec61850_mms_services_tests COMMAND ariec61850_mms_services_tests) + add_executable(ariec61850_mms_file_service_tests tests/test_mms_file_service.cpp) + target_link_libraries(ariec61850_mms_file_service_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_mms_file_service_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_mms_file_service_tests) + ariec61850_apply_sanitizers(ariec61850_mms_file_service_tests) + add_test(NAME ariec61850_mms_file_service_tests COMMAND ariec61850_mms_file_service_tests) + add_executable(ariec61850_dynamic_data_set_tests tests/test_dynamic_data_set.cpp) target_link_libraries(ariec61850_dynamic_data_set_tests PRIVATE ARIEC61850::core) target_compile_features(ariec61850_dynamic_data_set_tests PRIVATE cxx_std_20) diff --git a/include/ariec61850/mms/file_service.hpp b/include/ariec61850/mms/file_service.hpp new file mode 100644 index 000000000..cbfe0c62f --- /dev/null +++ b/include/ariec61850/mms/file_service.hpp @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/association_runtime.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsFileFailureKind : std::uint8_t { + none, + invalid_argument, + malformed_response, + invoke_id_mismatch, + unexpected_service, + confirmed_error, + reject_or_abort, + transport, + cancelled, + timed_out, + protocol, + limit_exceeded, + sink, + cleanup, +}; + +struct MmsFileDiagnostic final { + std::string stage; + MmsFileFailureKind failure_kind{MmsFileFailureKind::none}; + bool success{}; + std::string message; + std::optional invoke_id; + std::optional frsm_id; + std::optional error_class; + std::optional error_value; + std::string request_hex; + std::string response_hex; + + [[nodiscard]] bool file_non_existent() const noexcept { + return failure_kind == MmsFileFailureKind::confirmed_error && + error_class == 11 && error_value == 7U; + } +}; + +class MmsFileServiceError final : public std::runtime_error { +public: + explicit MmsFileServiceError(MmsFileDiagnostic diagnostic); + + [[nodiscard]] const MmsFileDiagnostic& diagnostic() const noexcept { + return diagnostic_; + } + +private: + MmsFileDiagnostic diagnostic_; +}; + +struct MmsFileDirectoryEntry final { + std::string name; + std::string path; + std::optional size_bytes; + std::vector last_modified; + + [[nodiscard]] bool likely_directory() const; + friend bool operator==(const MmsFileDirectoryEntry&, + const MmsFileDirectoryEntry&) = default; +}; + +struct MmsFileDirectoryRequest final { + std::uint32_t invoke_id{}; + std::string directory_name; + std::string continue_after; +}; + +struct MmsFileDirectoryResponse final { + std::uint32_t invoke_id{}; + std::vector entries; + bool more_follows{}; +}; + +struct MmsFileOpenRequest final { + std::uint32_t invoke_id{}; + std::string remote_path; + std::uint32_t initial_position{}; + bool rooted_backslash{}; +}; + +struct MmsFileOpenResponse final { + std::uint32_t invoke_id{}; + std::int32_t frsm_id{}; + std::optional file_size_bytes; + std::vector last_modified; +}; + +struct MmsFileReadRequest final { + std::uint32_t invoke_id{}; + std::int32_t frsm_id{}; +}; + +struct MmsFileReadResponse final { + std::uint32_t invoke_id{}; + std::vector data; + bool more_follows{true}; +}; + +struct MmsFileCloseRequest final { + std::uint32_t invoke_id{}; + std::int32_t frsm_id{}; +}; + +struct MmsFileCloseResponse final { + std::uint32_t invoke_id{}; +}; + +class MmsFileServiceCodec final { +public: + static constexpr std::int32_t file_open_service_tag = 72; + static constexpr std::int32_t file_read_service_tag = 73; + static constexpr std::int32_t file_close_service_tag = 74; + static constexpr std::int32_t file_directory_service_tag = 77; + static constexpr std::size_t maximum_path_bytes = 1'024U; + static constexpr std::size_t maximum_directory_entries = 65'536U; + static constexpr std::size_t maximum_directory_depth = 8U; + + [[nodiscard]] static std::string normalize_remote_path( + std::string path, + bool allow_root = false); + [[nodiscard]] static std::string rooted_backslash_path(const std::string& path); + + [[nodiscard]] static std::vector encode_file_directory_request_pdu( + const MmsFileDirectoryRequest& request); + [[nodiscard]] static std::vector encode_file_directory_request_p_data( + const MmsFileDirectoryRequest& request, + std::uint32_t presentation_context_id = 3U); + [[nodiscard]] static MmsFileDirectoryResponse decode_file_directory_response( + std::span presentation_or_mms_payload, + std::uint32_t expected_invoke_id, + const std::string& directory_name = {}); + + [[nodiscard]] static std::vector encode_file_open_request_pdu( + const MmsFileOpenRequest& request); + [[nodiscard]] static std::vector encode_file_open_request_p_data( + const MmsFileOpenRequest& request, + std::uint32_t presentation_context_id = 3U); + [[nodiscard]] static MmsFileOpenResponse decode_file_open_response( + std::span presentation_or_mms_payload, + std::uint32_t expected_invoke_id); + + [[nodiscard]] static std::vector encode_file_read_request_pdu( + const MmsFileReadRequest& request); + [[nodiscard]] static std::vector encode_file_read_request_p_data( + const MmsFileReadRequest& request, + std::uint32_t presentation_context_id = 3U); + [[nodiscard]] static MmsFileReadResponse decode_file_read_response( + std::span presentation_or_mms_payload, + std::uint32_t expected_invoke_id); + + [[nodiscard]] static std::vector encode_file_close_request_pdu( + const MmsFileCloseRequest& request); + [[nodiscard]] static std::vector encode_file_close_request_p_data( + const MmsFileCloseRequest& request, + std::uint32_t presentation_context_id = 3U); + [[nodiscard]] static MmsFileCloseResponse decode_file_close_response( + std::span presentation_or_mms_payload, + std::uint32_t expected_invoke_id); +}; + +struct MmsFileDirectoryOptions final { + std::size_t maximum_pages{64U}; + std::size_t maximum_entries{65'536U}; + std::size_t maximum_diagnostics{64U}; + std::size_t maximum_diagnostic_bytes{512U}; +}; + +struct MmsFileDirectoryPage final { + std::size_t page_number{}; + std::string continue_after; + std::size_t response_entry_count{}; + bool more_follows{}; +}; + +struct MmsFileDirectoryResult final { + bool success{}; + std::string directory_name; + std::vector entries; + std::vector pages; + std::vector diagnostics; + MmsFileFailureKind failure_kind{MmsFileFailureKind::none}; + std::string message; +}; + +struct MmsFileTransferOptions final { + std::uint64_t maximum_bytes{512ULL * 1024ULL * 1024ULL}; + std::size_t maximum_read_operations{100'000U}; + std::size_t maximum_block_bytes{1U * 1024U * 1024U}; + std::size_t maximum_diagnostics{64U}; + std::size_t maximum_diagnostic_bytes{512U}; + bool require_declared_size_match{}; +}; + +struct MmsFileTransferProgress final { + std::string remote_path; + std::uint64_t bytes_transferred{}; + std::optional expected_bytes; + std::size_t read_operations{}; + bool complete{}; +}; + +class MmsFileSink { +public: + virtual ~MmsFileSink() = default; + + [[nodiscard]] virtual bool write( + std::span data, + std::string& error) noexcept = 0; + [[nodiscard]] virtual bool flush(std::string& error) noexcept = 0; + [[nodiscard]] virtual bool reset(std::string& error) noexcept = 0; +}; + +class MmsFileProgressSink { +public: + virtual ~MmsFileProgressSink() = default; + virtual void report(const MmsFileTransferProgress& progress) noexcept = 0; +}; + +struct MmsFileTransferResult final { + bool success{}; + std::string remote_path; + std::uint64_t bytes_transferred{}; + std::optional expected_bytes; + std::size_t read_operations{}; + bool remote_file_closed{}; + bool adaptive_fallback_attempted{}; + bool adaptive_fallback_succeeded{}; + MmsFileFailureKind failure_kind{MmsFileFailureKind::none}; + std::string message; + std::vector diagnostics; + std::vector primary_attempt_diagnostics; + std::vector fallback_attempt_diagnostics; +}; + +class MmsFileServiceChannel { +public: + virtual ~MmsFileServiceChannel() = default; + [[nodiscard]] virtual std::uint32_t next_invoke_id() = 0; + [[nodiscard]] virtual std::vector exchange_confirmed( + std::span request, + std::uint32_t expected_invoke_id, + std::stop_token stop_token) = 0; + [[nodiscard]] virtual bool associated() const noexcept = 0; + [[nodiscard]] virtual std::uint32_t presentation_context_id() const noexcept = 0; +}; + +class MmsAssociationFileServiceChannel final : public MmsFileServiceChannel { +public: + explicit MmsAssociationFileServiceChannel(MmsAssociationRuntime& association) + : association_(association) {} + + [[nodiscard]] std::uint32_t next_invoke_id() override; + [[nodiscard]] std::vector exchange_confirmed( + std::span request, + std::uint32_t expected_invoke_id, + std::stop_token stop_token) override; + [[nodiscard]] bool associated() const noexcept override; + [[nodiscard]] std::uint32_t presentation_context_id() const noexcept override; + +private: + MmsAssociationRuntime& association_; +}; + +class MmsFileTransferRuntime final { +public: + explicit MmsFileTransferRuntime(MmsFileServiceChannel& channel) + : channel_(channel) {} + + [[nodiscard]] MmsFileDirectoryResult list_directory( + const std::string& directory_name = {}, + MmsFileDirectoryOptions options = {}, + std::stop_token stop_token = {}); + + [[nodiscard]] MmsFileTransferResult download( + const std::string& remote_path, + MmsFileSink& sink, + MmsFileTransferOptions options = {}, + MmsFileProgressSink* progress = nullptr, + std::stop_token stop_token = {}); + + [[nodiscard]] MmsFileTransferResult download_adaptive( + const std::string& remote_path, + MmsFileSink& sink, + MmsFileTransferOptions options = {}, + MmsFileProgressSink* progress = nullptr, + std::stop_token stop_token = {}); + +private: + [[nodiscard]] MmsFileTransferResult download_attempt( + const std::string& normalized_path, + bool rooted_backslash, + MmsFileSink& sink, + const MmsFileTransferOptions& options, + MmsFileProgressSink* progress, + std::stop_token stop_token); + + MmsFileServiceChannel& channel_; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/pdu.hpp b/include/ariec61850/mms/pdu.hpp index 920c0bf17..2caf4e68b 100644 --- a/include/ariec61850/mms/pdu.hpp +++ b/include/ariec61850/mms/pdu.hpp @@ -42,6 +42,10 @@ enum class MmsConfirmedService : std::int32_t { write = 5, get_variable_access_attributes = 6, get_named_variable_list_attributes = 12, + file_open = 72, + file_read = 73, + file_close = 74, + file_directory = 77, }; struct MmsInitiateDetail final { diff --git a/src/mms/file_service.cpp b/src/mms/file_service.cpp new file mode 100644 index 000000000..42d98318d --- /dev/null +++ b/src/mms/file_service.cpp @@ -0,0 +1,1296 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/file_service.hpp" + +#include "ariec61850/asn1/ber.hpp" +#include "ariec61850/mms/pdu.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +using asn1::BerClass; +using asn1::BerReader; +using asn1::BerTlv; +using asn1::BerWriter; + +constexpr std::size_t codec_hex_preview_bytes = 512U; + +[[nodiscard]] std::vector concat( + const std::initializer_list> parts) { + std::size_t size = 0U; + for (const auto part : parts) { + if (part.size() > MmsPduCodec::maximum_pdu_bytes - size) { + throw std::length_error("MMS file-service encoding exceeds the PDU bound."); + } + size += part.size(); + } + std::vector result; + result.reserve(size); + for (const auto part : parts) { + result.insert(result.end(), part.begin(), part.end()); + } + return result; +} + +[[nodiscard]] std::string hex_preview( + const std::span bytes, + const std::size_t maximum_bytes = codec_hex_preview_bytes) { + static constexpr char digits[] = "0123456789ABCDEF"; + const auto retained = std::min(bytes.size(), maximum_bytes); + std::string result; + result.reserve((retained * 3U) + 32U); + for (std::size_t index = 0U; index < retained; ++index) { + if (index != 0U) { + result.push_back(' '); + } + const auto value = bytes[index]; + result.push_back(digits[(value >> 4U) & 0x0FU]); + result.push_back(digits[value & 0x0FU]); + } + if (retained < bytes.size()) { + result += " ... (" + std::to_string(bytes.size()) + " bytes total)"; + } + return result; +} + +[[nodiscard]] std::vector positive_u32(const std::uint32_t value) { + auto result = BerWriter::encode_unsigned_integer(value); + if (result.empty()) { + result.push_back(0U); + } + if ((result.front() & 0x80U) != 0U) { + result.insert(result.begin(), 0U); + } + return result; +} + +[[nodiscard]] std::vector graphic_string(const std::string& value) { + return BerWriter::encode_tlv(0x19U, BerWriter::encode_ascii(value)); +} + +[[nodiscard]] std::vector encode_segmented_file_name( + const std::string& path) { + BerWriter body; + std::size_t begin = 0U; + while (begin < path.size()) { + const auto end = path.find('/', begin); + const auto segment = path.substr( + begin, end == std::string::npos ? std::string::npos : end - begin); + const auto encoded = graphic_string(segment); + body.write_raw(encoded); + if (end == std::string::npos) { + break; + } + begin = end + 1U; + } + return body.to_vector(); +} + +[[nodiscard]] std::string lowercase_ascii(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const char character) { + return static_cast(std::tolower(static_cast(character))); + }); + return value; +} + +[[nodiscard]] bool ends_with_separator(const std::string& value) noexcept { + return !value.empty() && (value.back() == '/' || value.back() == '\\'); +} + +[[nodiscard]] bool has_extension(const std::string& name) { + const auto separator = name.find_last_of("/\\"); + const auto dot = name.find_last_of('.'); + return dot != std::string::npos && + (separator == std::string::npos || dot > separator) && + dot + 1U < name.size(); +} + +[[noreturn]] void throw_file_error(MmsFileDiagnostic diagnostic) { + throw MmsFileServiceError(std::move(diagnostic)); +} + +[[nodiscard]] MmsConfirmedResponse decode_service_response( + const std::span payload, + const std::uint32_t expected_invoke_id, + const std::int32_t expected_service_tag, + const std::string& operation) { + MmsPduEnvelope envelope; + try { + envelope = MmsPduCodec::decode_envelope(payload); + } catch (const std::exception& exception) { + throw_file_error({ + operation, + MmsFileFailureKind::malformed_response, + false, + operation + " response decode failed: " + exception.what(), + expected_invoke_id, + std::nullopt, + std::nullopt, + std::nullopt, + {}, + hex_preview(payload)}); + } + + if (envelope.kind == MmsPduKind::confirmed_error) { + try { + const auto error = MmsPduCodec::decode_confirmed_error(envelope.mms_payload); + std::ostringstream message; + message << "MMS Confirmed-Error PDU during " << operation + << ": errorClass=[" << error.error_class_tag + << "], value=" << error.error_value << "."; + throw_file_error({ + operation, + MmsFileFailureKind::confirmed_error, + false, + message.str(), + error.invoke_id, + std::nullopt, + error.error_class_tag, + error.error_value, + {}, + hex_preview(envelope.mms_payload)}); + } catch (const MmsFileServiceError&) { + throw; + } catch (const std::exception& exception) { + throw_file_error({ + operation, + MmsFileFailureKind::malformed_response, + false, + operation + " Confirmed-Error decode failed: " + exception.what(), + expected_invoke_id, + std::nullopt, + std::nullopt, + std::nullopt, + {}, + hex_preview(envelope.mms_payload)}); + } + } + + if (envelope.kind == MmsPduKind::reject || + envelope.kind == MmsPduKind::unconfirmed) { + throw_file_error({ + operation, + MmsFileFailureKind::reject_or_abort, + false, + "MMS Reject/Abort PDU during " + operation + ".", + envelope.invoke_id, + std::nullopt, + std::nullopt, + std::nullopt, + {}, + hex_preview(envelope.mms_payload)}); + } + if (envelope.kind != MmsPduKind::confirmed_response) { + throw_file_error({ + operation, + MmsFileFailureKind::unexpected_service, + false, + operation + " expected an MMS Confirmed-Response PDU.", + envelope.invoke_id, + std::nullopt, + std::nullopt, + std::nullopt, + {}, + hex_preview(envelope.mms_payload)}); + } + + MmsConfirmedResponse response; + try { + response = MmsPduCodec::decode_confirmed_response(envelope.mms_payload); + } catch (const std::exception& exception) { + throw_file_error({ + operation, + MmsFileFailureKind::malformed_response, + false, + operation + " Confirmed-Response decode failed: " + exception.what(), + envelope.invoke_id, + std::nullopt, + std::nullopt, + std::nullopt, + {}, + hex_preview(envelope.mms_payload)}); + } + if (response.invoke_id != expected_invoke_id) { + throw_file_error({ + operation, + MmsFileFailureKind::invoke_id_mismatch, + false, + operation + " invoke ID mismatch. Expected " + + std::to_string(expected_invoke_id) + ", received " + + std::to_string(response.invoke_id) + ".", + response.invoke_id, + std::nullopt, + std::nullopt, + std::nullopt, + {}, + hex_preview(envelope.mms_payload)}); + } + if (response.service_tag != expected_service_tag) { + throw_file_error({ + operation, + MmsFileFailureKind::unexpected_service, + false, + operation + " response service mismatch. Expected [" + + std::to_string(expected_service_tag) + "], received [" + + std::to_string(response.service_tag) + "].", + response.invoke_id, + std::nullopt, + std::nullopt, + std::nullopt, + {}, + hex_preview(envelope.mms_payload)}); + } + return response; +} + +[[nodiscard]] std::optional read_u32(const BerTlv& field) noexcept { + const auto value = BerReader::read_unsigned_integer(field); + if (!value || *value > std::numeric_limits::max()) { + return std::nullopt; + } + return static_cast(*value); +} + +void collect_graphic_strings( + const BerTlv& field, + std::vector& parts, + const std::size_t depth) { + if (!field.constructed || depth > MmsFileServiceCodec::maximum_directory_depth) { + return; + } + for (const auto& child : BerReader::read_children(field.value)) { + if (child.encoded_tag == 0x19U || child.encoded_tag == 0x1AU || + child.encoded_tag == 0x16U) { + parts.push_back(BerReader::read_ascii_string(child)); + } else if (child.constructed) { + collect_graphic_strings(child, parts, depth + 1U); + } + } +} + +[[nodiscard]] bool looks_like_directory_entry(const BerTlv& candidate) { + if (!candidate.constructed) { + return false; + } + for (const auto& field : BerReader::read_children(candidate.value)) { + if (field.tag_class == BerClass::context_specific && + field.tag_number == 0 && field.constructed) { + return true; + } + } + return false; +} + +[[nodiscard]] MmsFileDirectoryEntry decode_directory_entry( + const BerTlv& encoded, + const std::string& directory_name) { + std::vector parts; + std::optional size; + std::vector modified; + for (const auto& field : BerReader::read_children(encoded.value)) { + if (field.tag_class != BerClass::context_specific) { + continue; + } + if (field.tag_number == 0 && field.constructed) { + collect_graphic_strings(field, parts, 0U); + } else if (field.tag_number == 1 && field.constructed) { + for (const auto& attribute : BerReader::read_children(field.value)) { + if (attribute.tag_class != BerClass::context_specific) { + continue; + } + if (attribute.tag_number == 0) { + size = read_u32(attribute); + if (!size) { + throw MmsFormatError("FileDirectory size is not an unsigned Integer32."); + } + } else if (attribute.tag_number == 1) { + modified = attribute.value; + } + } + } + } + if (parts.empty()) { + throw MmsFormatError("FileDirectory entry has no FileName GraphicString."); + } + std::string joined; + for (const auto& part : parts) { + const auto normalized = MmsFileServiceCodec::normalize_remote_path(part); + if (!joined.empty()) { + joined.push_back('/'); + } + joined += normalized; + } + const auto directory = MmsFileServiceCodec::normalize_remote_path(directory_name, true); + std::string path = joined; + if (!directory.empty() && joined != directory && + joined.rfind(directory + '/', 0U) != 0U) { + path = directory + '/' + joined; + } + return {joined, path, size, std::move(modified)}; +} + +void collect_directory_entries( + const BerTlv& container, + const std::string& directory_name, + std::vector& entries, + const std::size_t depth) { + if (!container.constructed || depth > MmsFileServiceCodec::maximum_directory_depth) { + return; + } + for (const auto& child : BerReader::read_children(container.value)) { + if (looks_like_directory_entry(child)) { + if (entries.size() >= MmsFileServiceCodec::maximum_directory_entries) { + throw MmsFormatError("FileDirectory response exceeds the entry bound."); + } + entries.push_back(decode_directory_entry(child, directory_name)); + } else if (child.constructed) { + collect_directory_entries(child, directory_name, entries, depth + 1U); + } + } +} + +void validate_invoke_id(const std::uint32_t invoke_id) { + if (invoke_id == 0U || invoke_id > MmsPduCodec::maximum_invoke_id) { + throw std::invalid_argument("MMS file-service invoke ID is outside the supported range."); + } +} + +[[nodiscard]] std::vector encode_frsm_request( + const std::uint32_t invoke_id, + const std::int32_t frsm_id, + const std::int32_t service_tag) { + validate_invoke_id(invoke_id); + return MmsPduCodec::encode_confirmed_request( + {invoke_id, service_tag, false, BerWriter::encode_signed_integer(frsm_id)}); +} + +void bound_text(std::string& value, const std::size_t maximum_bytes) { + if (value.size() <= maximum_bytes) { + return; + } + const auto original = value.size(); + value.resize(maximum_bytes); + value += "...(" + std::to_string(original) + " bytes)"; +} + +void append_diagnostic( + std::vector& diagnostics, + MmsFileDiagnostic diagnostic, + const std::size_t maximum_entries, + const std::size_t maximum_bytes) { + bound_text(diagnostic.message, maximum_bytes); + bound_text(diagnostic.request_hex, maximum_bytes); + bound_text(diagnostic.response_hex, maximum_bytes); + if (maximum_entries == 0U) { + return; + } + if (diagnostics.size() < maximum_entries) { + diagnostics.push_back(std::move(diagnostic)); + } else if (!diagnostic.success) { + diagnostics.back() = std::move(diagnostic); + } +} + +[[nodiscard]] MmsFileDiagnostic exception_diagnostic( + const std::string& stage, + const std::uint32_t invoke_id, + const std::string& request_hex, + const std::exception& exception, + const MmsFileFailureKind kind = MmsFileFailureKind::transport) { + return { + stage, + kind, + false, + stage + " exchange failed: " + exception.what(), + invoke_id, + std::nullopt, + std::nullopt, + std::nullopt, + request_hex, + {}}; +} + +[[nodiscard]] bool has_file_nonexistent_open( + const MmsFileTransferResult& result) noexcept { + if (result.success || result.bytes_transferred != 0U || result.read_operations != 0U) { + return false; + } + return std::any_of( + result.diagnostics.begin(), result.diagnostics.end(), + [](const MmsFileDiagnostic& diagnostic) { + return diagnostic.stage == "FileOpen" && diagnostic.file_non_existent(); + }); +} + +} // namespace + +MmsFileServiceError::MmsFileServiceError(MmsFileDiagnostic diagnostic) + : std::runtime_error(diagnostic.message), diagnostic_(std::move(diagnostic)) {} + +bool MmsFileDirectoryEntry::likely_directory() const { + if (ends_with_separator(name) || ends_with_separator(path)) { + return true; + } + return !has_extension(name) && (!size_bytes || *size_bytes == 0U); +} + +std::string MmsFileServiceCodec::normalize_remote_path( + std::string path, + const bool allow_root) { + if (path.find('\0') != std::string::npos) { + throw std::invalid_argument("MMS remote path contains a null character."); + } + const auto first = path.find_first_not_of(" \t\r\n"); + const auto last = path.find_last_not_of(" \t\r\n"); + if (first == std::string::npos) { + if (allow_root) { + return {}; + } + throw std::invalid_argument("MMS remote path is empty."); + } + path = path.substr(first, last - first + 1U); + std::replace(path.begin(), path.end(), '\\', '/'); + if (allow_root && (path == "/" || path == "*")) { + return {}; + } + + std::string normalized; + std::size_t begin = 0U; + while (begin <= path.size()) { + const auto end = path.find('/', begin); + auto segment = path.substr( + begin, end == std::string::npos ? std::string::npos : end - begin); + const auto segment_first = segment.find_first_not_of(" \t\r\n"); + const auto segment_last = segment.find_last_not_of(" \t\r\n"); + if (segment_first != std::string::npos) { + segment = segment.substr(segment_first, segment_last - segment_first + 1U); + if (segment == "." || segment == "..") { + throw std::invalid_argument("MMS remote path contains a traversal segment."); + } + if (segment.find(':') != std::string::npos) { + throw std::invalid_argument("MMS remote path contains an ambiguous drive/scheme segment."); + } + for (const char character : segment) { + if (static_cast(character) < 0x20U) { + throw std::invalid_argument("MMS remote path contains a control character."); + } + } + if (!normalized.empty()) { + normalized.push_back('/'); + } + normalized += segment; + } + if (end == std::string::npos) { + break; + } + begin = end + 1U; + } + if (normalized.empty()) { + if (allow_root) { + return {}; + } + throw std::invalid_argument("MMS remote path has no usable segment."); + } + if (normalized.size() > maximum_path_bytes) { + throw std::length_error("MMS remote path exceeds the configured byte bound."); + } + return normalized; +} + +std::string MmsFileServiceCodec::rooted_backslash_path(const std::string& path) { + auto normalized = normalize_remote_path(path); + std::replace(normalized.begin(), normalized.end(), '/', '\\'); + return "\\" + normalized; +} + +std::vector MmsFileServiceCodec::encode_file_directory_request_pdu( + const MmsFileDirectoryRequest& request) { + validate_invoke_id(request.invoke_id); + BerWriter body; + const auto directory = normalize_remote_path(request.directory_name, true); + const auto continuation = normalize_remote_path(request.continue_after, true); + if (!directory.empty()) { + const auto name = graphic_string(directory); + body.write_tlv(BerClass::context_specific, true, 0, name); + } + if (!continuation.empty()) { + const auto name = graphic_string(continuation); + body.write_tlv(BerClass::context_specific, true, 1, name); + } + return MmsPduCodec::encode_confirmed_request( + {request.invoke_id, file_directory_service_tag, true, body.to_vector()}); +} + +std::vector MmsFileServiceCodec::encode_file_directory_request_p_data( + const MmsFileDirectoryRequest& request, + const std::uint32_t presentation_context_id) { + return MmsPduCodec::wrap_p_data( + encode_file_directory_request_pdu(request), presentation_context_id); +} + +MmsFileDirectoryResponse MmsFileServiceCodec::decode_file_directory_response( + const std::span payload, + const std::uint32_t expected_invoke_id, + const std::string& directory_name) { + const auto service = decode_service_response( + payload, expected_invoke_id, file_directory_service_tag, "FileDirectory"); + MmsFileDirectoryResponse result; + result.invoke_id = service.invoke_id; + try { + for (const auto& field : BerReader::read_children(service.service_value)) { + if (field.tag_class == BerClass::context_specific && + field.tag_number == 0 && field.constructed) { + collect_directory_entries(field, directory_name, result.entries, 0U); + } else if (field.tag_class == BerClass::context_specific && + field.tag_number == 1 && !field.value.empty()) { + result.more_follows = BerReader::read_boolean(field) + .value_or(field.value.front() != 0U); + } + } + std::set> seen; + result.entries.erase( + std::remove_if(result.entries.begin(), result.entries.end(), + [&seen](const MmsFileDirectoryEntry& entry) { + return !seen.insert(lowercase_ascii(entry.path)).second; + }), + result.entries.end()); + return result; + } catch (const std::exception& exception) { + throw_file_error({ + "FileDirectory", + MmsFileFailureKind::malformed_response, + false, + std::string{"FileDirectory service decode failed: "} + exception.what(), + service.invoke_id, + std::nullopt, + std::nullopt, + std::nullopt, + {}, + hex_preview(payload)}); + } +} + +std::vector MmsFileServiceCodec::encode_file_open_request_pdu( + const MmsFileOpenRequest& request) { + validate_invoke_id(request.invoke_id); + const auto normalized = normalize_remote_path(request.remote_path); + const auto name_content = request.rooted_backslash + ? graphic_string(rooted_backslash_path(normalized)) + : encode_segmented_file_name(normalized); + const auto file_name = BerWriter::encode_tlv( + BerClass::context_specific, true, 0, name_content); + const auto position = BerWriter::encode_tlv( + BerClass::context_specific, false, 1, positive_u32(request.initial_position)); + return MmsPduCodec::encode_confirmed_request( + {request.invoke_id, file_open_service_tag, true, concat({file_name, position})}); +} + +std::vector MmsFileServiceCodec::encode_file_open_request_p_data( + const MmsFileOpenRequest& request, + const std::uint32_t presentation_context_id) { + return MmsPduCodec::wrap_p_data( + encode_file_open_request_pdu(request), presentation_context_id); +} + +MmsFileOpenResponse MmsFileServiceCodec::decode_file_open_response( + const std::span payload, + const std::uint32_t expected_invoke_id) { + const auto service = decode_service_response( + payload, expected_invoke_id, file_open_service_tag, "FileOpen"); + MmsFileOpenResponse result; + result.invoke_id = service.invoke_id; + bool have_frsm = false; + try { + for (const auto& field : BerReader::read_children(service.service_value)) { + if (field.tag_class != BerClass::context_specific) { + continue; + } + if (field.tag_number == 0 && !field.constructed) { + const auto value = BerReader::read_signed_integer(field); + if (!value || *value < std::numeric_limits::min() || + *value > std::numeric_limits::max()) { + throw MmsFormatError("FileOpen FRSM is not a signed Integer32."); + } + result.frsm_id = static_cast(*value); + have_frsm = true; + } else if (field.tag_number == 1 && field.constructed) { + for (const auto& attribute : BerReader::read_children(field.value)) { + if (attribute.tag_class != BerClass::context_specific) { + continue; + } + if (attribute.tag_number == 0) { + result.file_size_bytes = read_u32(attribute); + if (!result.file_size_bytes) { + throw MmsFormatError("FileOpen size is not an unsigned Integer32."); + } + } else if (attribute.tag_number == 1) { + result.last_modified = attribute.value; + } + } + } + } + if (!have_frsm) { + throw MmsFormatError("FileOpen response has no signed Integer32 FRSM identifier."); + } + return result; + } catch (const std::exception& exception) { + throw_file_error({ + "FileOpen", + MmsFileFailureKind::malformed_response, + false, + std::string{"FileOpen service decode failed: "} + exception.what(), + service.invoke_id, + std::nullopt, + std::nullopt, + std::nullopt, + {}, + hex_preview(payload)}); + } +} + +std::vector MmsFileServiceCodec::encode_file_read_request_pdu( + const MmsFileReadRequest& request) { + return encode_frsm_request(request.invoke_id, request.frsm_id, file_read_service_tag); +} + +std::vector MmsFileServiceCodec::encode_file_read_request_p_data( + const MmsFileReadRequest& request, + const std::uint32_t presentation_context_id) { + return MmsPduCodec::wrap_p_data( + encode_file_read_request_pdu(request), presentation_context_id); +} + +MmsFileReadResponse MmsFileServiceCodec::decode_file_read_response( + const std::span payload, + const std::uint32_t expected_invoke_id) { + const auto service = decode_service_response( + payload, expected_invoke_id, file_read_service_tag, "FileRead"); + MmsFileReadResponse result; + result.invoke_id = service.invoke_id; + bool have_data = false; + try { + for (const auto& field : BerReader::read_children(service.service_value)) { + if (field.tag_class != BerClass::context_specific) { + continue; + } + if (field.tag_number == 0 && !field.constructed) { + if (have_data) { + throw MmsFormatError("FileRead response contains duplicate data blocks."); + } + result.data = field.value; + have_data = true; + } else if (field.tag_number == 1 && !field.value.empty()) { + result.more_follows = BerReader::read_boolean(field) + .value_or(field.value.front() != 0U); + } + } + if (!have_data) { + throw MmsFormatError("FileRead response has no data block."); + } + return result; + } catch (const std::exception& exception) { + throw_file_error({ + "FileRead", + MmsFileFailureKind::malformed_response, + false, + std::string{"FileRead service decode failed: "} + exception.what(), + service.invoke_id, + std::nullopt, + std::nullopt, + std::nullopt, + {}, + hex_preview(payload)}); + } +} + +std::vector MmsFileServiceCodec::encode_file_close_request_pdu( + const MmsFileCloseRequest& request) { + return encode_frsm_request(request.invoke_id, request.frsm_id, file_close_service_tag); +} + +std::vector MmsFileServiceCodec::encode_file_close_request_p_data( + const MmsFileCloseRequest& request, + const std::uint32_t presentation_context_id) { + return MmsPduCodec::wrap_p_data( + encode_file_close_request_pdu(request), presentation_context_id); +} + +MmsFileCloseResponse MmsFileServiceCodec::decode_file_close_response( + const std::span payload, + const std::uint32_t expected_invoke_id) { + const auto service = decode_service_response( + payload, expected_invoke_id, file_close_service_tag, "FileClose"); + if (!service.service_value.empty()) { + throw_file_error({ + "FileClose", + MmsFileFailureKind::malformed_response, + false, + "FileClose successful response must be empty.", + service.invoke_id, + std::nullopt, + std::nullopt, + std::nullopt, + {}, + hex_preview(payload)}); + } + return {service.invoke_id}; +} + +std::uint32_t MmsAssociationFileServiceChannel::next_invoke_id() { + return association_.next_invoke_id(); +} + +std::vector MmsAssociationFileServiceChannel::exchange_confirmed( + const std::span request, + const std::uint32_t expected_invoke_id, + const std::stop_token stop_token) { + auto exchange = association_.exchange_confirmed( + request, expected_invoke_id, stop_token); + if (!exchange.presentation_payload.empty()) { + return exchange.presentation_payload; + } + return exchange.envelope.mms_payload; +} + +bool MmsAssociationFileServiceChannel::associated() const noexcept { + return association_.associated(); +} + +std::uint32_t MmsAssociationFileServiceChannel::presentation_context_id() const noexcept { + return association_.negotiated().presentation_context_id; +} + +MmsFileDirectoryResult MmsFileTransferRuntime::list_directory( + const std::string& directory_name, + MmsFileDirectoryOptions options, + const std::stop_token stop_token) { + MmsFileDirectoryResult result; + try { + result.directory_name = MmsFileServiceCodec::normalize_remote_path( + directory_name, true); + } catch (const std::exception& exception) { + result.failure_kind = MmsFileFailureKind::invalid_argument; + result.message = exception.what(); + return result; + } + if (options.maximum_pages == 0U || options.maximum_entries == 0U || + options.maximum_diagnostic_bytes == 0U) { + result.failure_kind = MmsFileFailureKind::invalid_argument; + result.message = "FileDirectory bounds must be greater than zero."; + return result; + } + + std::set> seen_paths; + std::set> seen_continuations; + std::string continuation; + for (std::size_t page = 0U; page < options.maximum_pages; ++page) { + if (stop_token.stop_requested()) { + result.failure_kind = MmsFileFailureKind::cancelled; + result.message = "FileDirectory cancelled before the next page."; + break; + } + const auto invoke_id = channel_.next_invoke_id(); + const MmsFileDirectoryRequest request{ + invoke_id, result.directory_name, continuation}; + std::vector encoded; + try { + encoded = MmsFileServiceCodec::encode_file_directory_request_p_data( + request, channel_.presentation_context_id()); + const auto response_payload = channel_.exchange_confirmed( + encoded, invoke_id, stop_token); + const auto response = MmsFileServiceCodec::decode_file_directory_response( + response_payload, invoke_id, result.directory_name); + + append_diagnostic(result.diagnostics, { + "FileDirectory page " + std::to_string(page + 1U), + MmsFileFailureKind::none, + true, + "Decoded " + std::to_string(response.entries.size()) + + " directory entries; moreFollows=" + + (response.more_follows ? "true." : "false."), + invoke_id, + std::nullopt, + std::nullopt, + std::nullopt, + hex_preview(encoded, options.maximum_diagnostic_bytes), + hex_preview(response_payload, options.maximum_diagnostic_bytes)}, + options.maximum_diagnostics, options.maximum_diagnostic_bytes); + + result.pages.push_back({ + page + 1U, continuation, response.entries.size(), response.more_follows}); + const auto before = result.entries.size(); + for (const auto& entry : response.entries) { + if (!seen_paths.insert(lowercase_ascii(entry.path)).second) { + continue; + } + if (result.entries.size() >= options.maximum_entries) { + result.failure_kind = MmsFileFailureKind::limit_exceeded; + result.message = "FileDirectory exceeded the configured entry limit."; + break; + } + result.entries.push_back(entry); + } + if (result.failure_kind != MmsFileFailureKind::none) { + break; + } + if (!response.more_follows) { + result.success = true; + result.message = "FileDirectory completed in " + + std::to_string(result.pages.size()) + " page(s) with " + + std::to_string(result.entries.size()) + " unique entry/entries."; + return result; + } + if (response.entries.empty() || result.entries.size() == before) { + result.failure_kind = MmsFileFailureKind::protocol; + result.message = "FileDirectory reported moreFollows without forward progress."; + break; + } + const auto next = response.entries.back().name; + if (next.empty() || next == continuation || + !seen_continuations.insert(lowercase_ascii(next)).second) { + result.failure_kind = MmsFileFailureKind::protocol; + result.message = "FileDirectory continuation repeated without progress."; + break; + } + continuation = next; + if (page + 1U == options.maximum_pages) { + result.failure_kind = MmsFileFailureKind::limit_exceeded; + result.message = "FileDirectory exceeded the configured page limit."; + } + } catch (const MmsFileServiceError& exception) { + auto diagnostic = exception.diagnostic(); + diagnostic.request_hex = hex_preview(encoded, options.maximum_diagnostic_bytes); + append_diagnostic(result.diagnostics, std::move(diagnostic), + options.maximum_diagnostics, options.maximum_diagnostic_bytes); + result.failure_kind = exception.diagnostic().failure_kind; + result.message = exception.what(); + break; + } catch (const MmsTransportCancelledError& exception) { + auto diagnostic = exception_diagnostic( + "FileDirectory", invoke_id, + hex_preview(encoded, options.maximum_diagnostic_bytes), exception, + MmsFileFailureKind::cancelled); + result.failure_kind = diagnostic.failure_kind; + result.message = diagnostic.message; + append_diagnostic(result.diagnostics, std::move(diagnostic), + options.maximum_diagnostics, options.maximum_diagnostic_bytes); + break; + } catch (const MmsTransportTimeoutError& exception) { + auto diagnostic = exception_diagnostic( + "FileDirectory", invoke_id, + hex_preview(encoded, options.maximum_diagnostic_bytes), exception, + MmsFileFailureKind::timed_out); + result.failure_kind = diagnostic.failure_kind; + result.message = diagnostic.message; + append_diagnostic(result.diagnostics, std::move(diagnostic), + options.maximum_diagnostics, options.maximum_diagnostic_bytes); + break; + } catch (const std::exception& exception) { + auto diagnostic = exception_diagnostic( + "FileDirectory", invoke_id, + hex_preview(encoded, options.maximum_diagnostic_bytes), exception); + result.failure_kind = diagnostic.failure_kind; + result.message = diagnostic.message; + append_diagnostic(result.diagnostics, std::move(diagnostic), + options.maximum_diagnostics, options.maximum_diagnostic_bytes); + break; + } + } + return result; +} + +MmsFileTransferResult MmsFileTransferRuntime::download( + const std::string& remote_path, + MmsFileSink& sink, + MmsFileTransferOptions options, + MmsFileProgressSink* progress, + const std::stop_token stop_token) { + std::string normalized; + try { + normalized = MmsFileServiceCodec::normalize_remote_path(remote_path); + } catch (const std::exception& exception) { + MmsFileTransferResult result; + result.failure_kind = MmsFileFailureKind::invalid_argument; + result.message = exception.what(); + return result; + } + return download_attempt(normalized, false, sink, options, progress, stop_token); +} + +MmsFileTransferResult MmsFileTransferRuntime::download_adaptive( + const std::string& remote_path, + MmsFileSink& sink, + MmsFileTransferOptions options, + MmsFileProgressSink* progress, + const std::stop_token stop_token) { + auto primary = download(remote_path, sink, options, progress, stop_token); + primary.primary_attempt_diagnostics = primary.diagnostics; + if (!has_file_nonexistent_open(primary)) { + return primary; + } + + std::string reset_error; + if (!sink.reset(reset_error)) { + primary.failure_kind = MmsFileFailureKind::sink; + primary.message += " Rooted-backslash retry was skipped because the sink could not reset: " + + reset_error; + append_diagnostic(primary.diagnostics, { + "Adaptive sink reset", + MmsFileFailureKind::sink, + false, + reset_error, + std::nullopt, + std::nullopt, + std::nullopt, + std::nullopt, + {}, + {}}, options.maximum_diagnostics, options.maximum_diagnostic_bytes); + return primary; + } + + auto fallback = download_attempt( + MmsFileServiceCodec::normalize_remote_path(remote_path), true, + sink, options, progress, stop_token); + fallback.adaptive_fallback_attempted = true; + fallback.adaptive_fallback_succeeded = fallback.success; + fallback.primary_attempt_diagnostics = primary.diagnostics; + fallback.fallback_attempt_diagnostics = fallback.diagnostics; + auto combined = primary.diagnostics; + for (const auto& diagnostic : fallback.diagnostics) { + append_diagnostic(combined, diagnostic, + options.maximum_diagnostics, options.maximum_diagnostic_bytes); + } + fallback.diagnostics = std::move(combined); + fallback.message += fallback.success + ? " Adaptive rooted-backslash FileOpen fallback succeeded." + : " Adaptive rooted-backslash FileOpen fallback also failed."; + return fallback; +} + +MmsFileTransferResult MmsFileTransferRuntime::download_attempt( + const std::string& normalized_path, + const bool rooted_backslash, + MmsFileSink& sink, + const MmsFileTransferOptions& options, + MmsFileProgressSink* progress, + const std::stop_token stop_token) { + MmsFileTransferResult result; + result.remote_path = rooted_backslash + ? MmsFileServiceCodec::rooted_backslash_path(normalized_path) + : normalized_path; + if (options.maximum_bytes == 0U || options.maximum_read_operations == 0U || + options.maximum_block_bytes == 0U || options.maximum_diagnostic_bytes == 0U) { + result.failure_kind = MmsFileFailureKind::invalid_argument; + result.message = "File transfer bounds must be greater than zero."; + return result; + } + + std::optional frsm; + auto fail = [&result](const MmsFileFailureKind kind, std::string message) { + if (result.failure_kind == MmsFileFailureKind::none) { + result.failure_kind = kind; + result.message = std::move(message); + } + }; + + const auto exchange = [this, &result, &options, stop_token]( + const std::string& stage, + const std::uint32_t invoke_id, + const std::vector& request) -> std::vector { + try { + return channel_.exchange_confirmed(request, invoke_id, stop_token); + } catch (const MmsTransportCancelledError& exception) { + auto diagnostic = exception_diagnostic( + stage, invoke_id, + hex_preview(request, options.maximum_diagnostic_bytes), exception, + MmsFileFailureKind::cancelled); + append_diagnostic(result.diagnostics, diagnostic, + options.maximum_diagnostics, options.maximum_diagnostic_bytes); + throw MmsFileServiceError(std::move(diagnostic)); + } catch (const MmsTransportTimeoutError& exception) { + auto diagnostic = exception_diagnostic( + stage, invoke_id, + hex_preview(request, options.maximum_diagnostic_bytes), exception, + MmsFileFailureKind::timed_out); + append_diagnostic(result.diagnostics, diagnostic, + options.maximum_diagnostics, options.maximum_diagnostic_bytes); + throw MmsFileServiceError(std::move(diagnostic)); + } catch (const std::exception& exception) { + auto diagnostic = exception_diagnostic( + stage, invoke_id, + hex_preview(request, options.maximum_diagnostic_bytes), exception); + append_diagnostic(result.diagnostics, diagnostic, + options.maximum_diagnostics, options.maximum_diagnostic_bytes); + throw MmsFileServiceError(std::move(diagnostic)); + } + }; + + try { + if (stop_token.stop_requested()) { + fail(MmsFileFailureKind::cancelled, "File transfer cancelled before FileOpen."); + } else { + const auto invoke_id = channel_.next_invoke_id(); + const auto request = MmsFileServiceCodec::encode_file_open_request_p_data( + {invoke_id, normalized_path, 0U, rooted_backslash}, + channel_.presentation_context_id()); + try { + const auto response_payload = exchange("FileOpen", invoke_id, request); + const auto opened = MmsFileServiceCodec::decode_file_open_response( + response_payload, invoke_id); + frsm = opened.frsm_id; + result.expected_bytes = opened.file_size_bytes; + append_diagnostic(result.diagnostics, { + "FileOpen", + MmsFileFailureKind::none, + true, + "FileOpen succeeded; FRSM=" + std::to_string(opened.frsm_id) + + (opened.file_size_bytes + ? ", declaredSize=" + std::to_string(*opened.file_size_bytes) + "." + : ", declared size unavailable."), + invoke_id, + opened.frsm_id, + std::nullopt, + std::nullopt, + hex_preview(request, options.maximum_diagnostic_bytes), + hex_preview(response_payload, options.maximum_diagnostic_bytes)}, + options.maximum_diagnostics, options.maximum_diagnostic_bytes); + if (result.expected_bytes && *result.expected_bytes > options.maximum_bytes) { + fail(MmsFileFailureKind::limit_exceeded, + "Remote file declared size exceeds the configured maximum byte limit."); + } + } catch (const MmsFileServiceError& exception) { + if (result.diagnostics.empty() || + result.diagnostics.back().message != exception.diagnostic().message) { + auto diagnostic = exception.diagnostic(); + diagnostic.stage = "FileOpen"; + diagnostic.request_hex = hex_preview( + request, options.maximum_diagnostic_bytes); + append_diagnostic(result.diagnostics, std::move(diagnostic), + options.maximum_diagnostics, options.maximum_diagnostic_bytes); + } + fail(exception.diagnostic().failure_kind, exception.what()); + } + } + + bool more_follows = result.failure_kind == MmsFileFailureKind::none; + while (more_follows) { + if (stop_token.stop_requested()) { + fail(MmsFileFailureKind::cancelled, "File transfer cancelled before FileRead."); + break; + } + if (result.read_operations >= options.maximum_read_operations) { + fail(MmsFileFailureKind::limit_exceeded, + "FileRead exceeded the configured operation limit."); + break; + } + ++result.read_operations; + const auto invoke_id = channel_.next_invoke_id(); + const auto request = MmsFileServiceCodec::encode_file_read_request_p_data( + {invoke_id, *frsm}, channel_.presentation_context_id()); + try { + const auto response_payload = exchange("FileRead", invoke_id, request); + const auto block = MmsFileServiceCodec::decode_file_read_response( + response_payload, invoke_id); + if (block.data.empty() && block.more_follows) { + fail(MmsFileFailureKind::protocol, + "FileRead returned an empty block while moreFollows remained true."); + } else if (block.data.size() > options.maximum_block_bytes) { + fail(MmsFileFailureKind::limit_exceeded, + "FileRead block exceeds the configured maximum block size."); + } else if (static_cast(block.data.size()) > + options.maximum_bytes - result.bytes_transferred) { + fail(MmsFileFailureKind::limit_exceeded, + "File transfer exceeds the configured maximum byte limit."); + } else if (result.expected_bytes && + static_cast(block.data.size()) > + *result.expected_bytes - + std::min(*result.expected_bytes, result.bytes_transferred)) { + fail(MmsFileFailureKind::protocol, + "FileRead bytes exceed the advertised remote file size."); + } + + append_diagnostic(result.diagnostics, { + "FileRead #" + std::to_string(result.read_operations), + result.failure_kind, + result.failure_kind == MmsFileFailureKind::none, + "FileRead decoded " + std::to_string(block.data.size()) + + " byte(s); moreFollows=" + + (block.more_follows ? "true." : "false."), + invoke_id, + *frsm, + std::nullopt, + std::nullopt, + hex_preview(request, options.maximum_diagnostic_bytes), + hex_preview(response_payload, options.maximum_diagnostic_bytes)}, + options.maximum_diagnostics, options.maximum_diagnostic_bytes); + + if (result.failure_kind != MmsFileFailureKind::none) { + break; + } + if (!block.data.empty()) { + std::string sink_error; + if (!sink.write(block.data, sink_error)) { + append_diagnostic(result.diagnostics, { + "Sink write after FileRead #" + + std::to_string(result.read_operations), + MmsFileFailureKind::sink, + false, + sink_error, + invoke_id, + *frsm, + std::nullopt, + std::nullopt, + {}, + {}}, options.maximum_diagnostics, + options.maximum_diagnostic_bytes); + fail(MmsFileFailureKind::sink, + "Local sink write failed: " + sink_error); + break; + } + result.bytes_transferred += + static_cast(block.data.size()); + } + more_follows = block.more_follows; + if (progress != nullptr) { + progress->report({ + result.remote_path, + result.bytes_transferred, + result.expected_bytes, + result.read_operations, + !more_follows}); + } + } catch (const MmsFileServiceError& exception) { + if (result.diagnostics.empty() || + result.diagnostics.back().message != exception.diagnostic().message) { + auto diagnostic = exception.diagnostic(); + diagnostic.stage = "FileRead #" + + std::to_string(result.read_operations); + diagnostic.frsm_id = frsm; + diagnostic.request_hex = hex_preview( + request, options.maximum_diagnostic_bytes); + append_diagnostic(result.diagnostics, std::move(diagnostic), + options.maximum_diagnostics, options.maximum_diagnostic_bytes); + } + fail(exception.diagnostic().failure_kind, exception.what()); + break; + } + } + + if (result.failure_kind == MmsFileFailureKind::none && + options.require_declared_size_match && result.expected_bytes && + result.bytes_transferred != *result.expected_bytes) { + fail(MmsFileFailureKind::protocol, + "Transferred byte count does not match the advertised file size."); + } + if (result.failure_kind == MmsFileFailureKind::none) { + std::string flush_error; + if (!sink.flush(flush_error)) { + fail(MmsFileFailureKind::sink, + "Local sink flush failed: " + flush_error); + } + } + } catch (const std::exception& exception) { + fail(MmsFileFailureKind::transport, + std::string{"File transfer pipeline failed: "} + exception.what()); + } + + if (frsm) { + if (channel_.associated()) { + const auto invoke_id = channel_.next_invoke_id(); + std::vector request; + try { + request = MmsFileServiceCodec::encode_file_close_request_p_data( + {invoke_id, *frsm}, channel_.presentation_context_id()); + const auto response_payload = channel_.exchange_confirmed( + request, invoke_id, {}); + static_cast(MmsFileServiceCodec::decode_file_close_response( + response_payload, invoke_id)); + result.remote_file_closed = true; + append_diagnostic(result.diagnostics, { + "FileClose", + MmsFileFailureKind::none, + true, + "FileClose succeeded for FRSM=" + std::to_string(*frsm) + ".", + invoke_id, + *frsm, + std::nullopt, + std::nullopt, + hex_preview(request, options.maximum_diagnostic_bytes), + hex_preview(response_payload, options.maximum_diagnostic_bytes)}, + options.maximum_diagnostics, options.maximum_diagnostic_bytes); + } catch (const MmsFileServiceError& exception) { + auto diagnostic = exception.diagnostic(); + diagnostic.stage = "FileClose"; + diagnostic.frsm_id = frsm; + diagnostic.request_hex = hex_preview( + request, options.maximum_diagnostic_bytes); + append_diagnostic(result.diagnostics, std::move(diagnostic), + options.maximum_diagnostics, options.maximum_diagnostic_bytes); + if (result.failure_kind == MmsFileFailureKind::none) { + result.failure_kind = MmsFileFailureKind::cleanup; + result.message = exception.what(); + } + } catch (const std::exception& exception) { + append_diagnostic(result.diagnostics, { + "FileClose", + MmsFileFailureKind::cleanup, + false, + std::string{"FileClose exchange failed: "} + exception.what(), + invoke_id, + *frsm, + std::nullopt, + std::nullopt, + hex_preview(request, options.maximum_diagnostic_bytes), + {}}, options.maximum_diagnostics, options.maximum_diagnostic_bytes); + if (result.failure_kind == MmsFileFailureKind::none) { + result.failure_kind = MmsFileFailureKind::cleanup; + result.message = std::string{"FileClose failed: "} + exception.what(); + } + } + } else { + append_diagnostic(result.diagnostics, { + "FileClose", + MmsFileFailureKind::cleanup, + false, + "FileClose skipped because the MMS association is no longer active.", + std::nullopt, + *frsm, + std::nullopt, + std::nullopt, + {}, + {}}, options.maximum_diagnostics, options.maximum_diagnostic_bytes); + if (result.failure_kind == MmsFileFailureKind::none) { + result.failure_kind = MmsFileFailureKind::cleanup; + result.message = "FileClose skipped because the association is inactive."; + } + } + } + + result.success = result.failure_kind == MmsFileFailureKind::none && + (!frsm || result.remote_file_closed); + if (result.success) { + result.message = "Downloaded " + std::to_string(result.bytes_transferred) + + " byte(s) from '" + result.remote_path + "' in " + + std::to_string(result.read_operations) + " FileRead operation(s)."; + if (progress != nullptr) { + progress->report({ + result.remote_path, + result.bytes_transferred, + result.expected_bytes, + result.read_operations, + true}); + } + } else if (result.message.empty()) { + result.message = "MMS file transfer failed."; + } + return result; +} + +} // namespace ar::iec61850::mms diff --git a/tests/test_mms_file_service.cpp b/tests/test_mms_file_service.cpp new file mode 100644 index 000000000..88d45fbb7 --- /dev/null +++ b/tests/test_mms_file_service.cpp @@ -0,0 +1,606 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber.hpp" +#include "ariec61850/mms/file_service.hpp" +#include "ariec61850/mms/pdu.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; +using ByteVector = std::vector; + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + throw std::runtime_error( \ + std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ + } while (false) + +template +void check_throws(Callable&& callable) { + try { + std::invoke(std::forward(callable)); + } catch (const Exception&) { + return; + } + throw std::runtime_error("Expected exception was not thrown."); +} + +ByteVector concat(const std::initializer_list> parts) { + ByteVector result; + for (const auto part : parts) { + result.insert(result.end(), part.begin(), part.end()); + } + return result; +} + +ByteVector confirmed_response( + const std::uint32_t invoke_id, + const std::int32_t service_tag, + const bool constructed, + const std::span value) { + return mms::MmsPduCodec::wrap_p_data( + mms::MmsPduCodec::encode_confirmed_response( + {invoke_id, service_tag, constructed, + ByteVector{value.begin(), value.end()}})); +} + +ByteVector confirmed_error( + const std::uint32_t invoke_id, + const std::int32_t error_class, + const std::uint32_t error_value) { + return mms::MmsPduCodec::wrap_p_data( + mms::MmsPduCodec::encode_confirmed_error( + {invoke_id, error_class, error_value})); +} + +ByteVector directory_entry( + const std::string& name, + const std::uint32_t size, + const std::string& modified = "20260811010101Z") { + using asn1::BerClass; + using asn1::BerWriter; + const auto graphic = BerWriter::encode_tlv(0x19U, BerWriter::encode_ascii(name)); + const auto file_name = BerWriter::encode_tlv( + BerClass::context_specific, true, 0, graphic); + const auto encoded_size = BerWriter::encode_tlv( + BerClass::context_specific, false, 0, + BerWriter::encode_unsigned_integer(size)); + const auto encoded_modified = BerWriter::encode_tlv( + BerClass::context_specific, false, 1, + BerWriter::encode_ascii(modified)); + const auto attributes = BerWriter::encode_tlv( + BerClass::context_specific, true, 1, + concat({encoded_size, encoded_modified})); + return BerWriter::encode_tlv(0x30U, concat({file_name, attributes})); +} + +ByteVector directory_response( + const std::uint32_t invoke_id, + const std::vector& entries, + const bool more_follows) { + using asn1::BerClass; + using asn1::BerWriter; + ByteVector encoded_entries; + for (const auto& entry : entries) { + encoded_entries.insert(encoded_entries.end(), entry.begin(), entry.end()); + } + const auto sequence = BerWriter::encode_tlv(0x30U, encoded_entries); + const auto list = BerWriter::encode_tlv( + BerClass::context_specific, true, 0, sequence); + const auto more = BerWriter::encode_tlv( + BerClass::context_specific, false, 1, + BerWriter::encode_boolean(more_follows)); + return confirmed_response( + invoke_id, mms::MmsFileServiceCodec::file_directory_service_tag, + true, concat({list, more})); +} + +ByteVector open_response( + const std::uint32_t invoke_id, + const std::int32_t frsm, + const std::optional size = std::nullopt) { + using asn1::BerClass; + using asn1::BerWriter; + const auto id = BerWriter::encode_tlv( + BerClass::context_specific, false, 0, + BerWriter::encode_signed_integer(frsm)); + ByteVector value = id; + if (size) { + const auto encoded_size = BerWriter::encode_tlv( + BerClass::context_specific, false, 0, + BerWriter::encode_unsigned_integer(*size)); + const auto modified = BerWriter::encode_tlv( + BerClass::context_specific, false, 1, + BerWriter::encode_ascii("20260811010101Z")); + const auto attributes = BerWriter::encode_tlv( + BerClass::context_specific, true, 1, + concat({encoded_size, modified})); + value.insert(value.end(), attributes.begin(), attributes.end()); + } + return confirmed_response( + invoke_id, mms::MmsFileServiceCodec::file_open_service_tag, true, value); +} + +ByteVector read_response( + const std::uint32_t invoke_id, + const std::span data, + const bool more_follows) { + using asn1::BerClass; + using asn1::BerWriter; + const auto block = BerWriter::encode_tlv( + BerClass::context_specific, false, 0, data); + const auto more = BerWriter::encode_tlv( + BerClass::context_specific, false, 1, + BerWriter::encode_boolean(more_follows)); + return confirmed_response( + invoke_id, mms::MmsFileServiceCodec::file_read_service_tag, + true, concat({block, more})); +} + +ByteVector close_response(const std::uint32_t invoke_id) { + return confirmed_response( + invoke_id, mms::MmsFileServiceCodec::file_close_service_tag, false, {}); +} + +class FakeChannel final : public mms::MmsFileServiceChannel { +public: + using Step = std::function, std::uint32_t, std::stop_token)>; + + [[nodiscard]] std::uint32_t next_invoke_id() override { return next_++; } + + [[nodiscard]] ByteVector exchange_confirmed( + const std::span request, + const std::uint32_t expected_invoke_id, + const std::stop_token stop_token) override { + if (steps.empty()) { + throw std::runtime_error("FakeChannel script exhausted."); + } + auto step = std::move(steps.front()); + steps.pop_front(); + sent.emplace_back(request.begin(), request.end()); + return step(request, expected_invoke_id, stop_token); + } + + [[nodiscard]] bool associated() const noexcept override { return connected; } + [[nodiscard]] std::uint32_t presentation_context_id() const noexcept override { + return 3U; + } + + void respond(ByteVector response) { + steps.push_back([response = std::move(response)]( + std::span, std::uint32_t, + std::stop_token) { return response; }); + } + + std::uint32_t next_{1U}; + bool connected{true}; + std::deque steps; + std::vector sent; +}; + +class MemorySink final : public mms::MmsFileSink { +public: + [[nodiscard]] bool write( + const std::span data, + std::string& error) noexcept override { + if (fail_write) { + error = "scripted sink write failure"; + return false; + } + bytes.insert(bytes.end(), data.begin(), data.end()); + return true; + } + + [[nodiscard]] bool flush(std::string& error) noexcept override { + if (fail_flush) { + error = "scripted sink flush failure"; + return false; + } + flushed = true; + return true; + } + + [[nodiscard]] bool reset(std::string& error) noexcept override { + ++reset_count; + if (fail_reset) { + error = "scripted sink reset failure"; + return false; + } + bytes.clear(); + flushed = false; + return true; + } + + ByteVector bytes; + bool fail_write{}; + bool fail_flush{}; + bool fail_reset{}; + bool flushed{}; + std::size_t reset_count{}; +}; + +std::int32_t sent_service_tag(const ByteVector& request) { + return mms::MmsPduCodec::decode_confirmed_request( + mms::MmsPduCodec::extract_mms_payload(request)).service_tag; +} + +void codec_encodes_file_directory_high_tag_and_continuation() { + const auto request = mms::MmsFileServiceCodec::encode_file_directory_request_pdu( + {7U, "COMTRADE", "FRA00019.cfg"}); + const auto decoded = mms::MmsPduCodec::decode_confirmed_request(request); + CHECK(decoded.invoke_id == 7U); + CHECK(decoded.service_tag == 77); + CHECK(decoded.service_constructed); + const auto fields = asn1::BerReader::read_children(decoded.service_value); + CHECK(fields.size() == 2U); + CHECK(fields[0].tag_number == 0); + CHECK(fields[1].tag_number == 1); + CHECK(asn1::BerReader::read_ascii_string( + asn1::BerReader::read_children(fields[0].value).front()) == "COMTRADE"); + CHECK(asn1::BerReader::read_ascii_string( + asn1::BerReader::read_children(fields[1].value).front()) == "FRA00019.cfg"); + + const auto root = mms::MmsFileServiceCodec::encode_file_directory_request_pdu( + {8U, "/", {}}); + CHECK(mms::MmsPduCodec::decode_confirmed_request(root).service_value.empty()); +} + +void codec_decodes_directory_attributes_and_preserves_order() { + const auto response = directory_response(9U, { + directory_entry("COMTRADE/FRA00019.cfg", 2'048U, "A"), + directory_entry("FRA00019.dat", 119'000U, "B")}, true); + const auto decoded = mms::MmsFileServiceCodec::decode_file_directory_response( + response, 9U, "COMTRADE"); + CHECK(decoded.more_follows); + CHECK(decoded.entries.size() == 2U); + CHECK(decoded.entries[0].path == "COMTRADE/FRA00019.cfg"); + CHECK(decoded.entries[0].size_bytes == 2'048U); + CHECK(decoded.entries[0].last_modified == ByteVector{'A'}); + CHECK(decoded.entries[1].path == "COMTRADE/FRA00019.dat"); + CHECK(decoded.entries[1].size_bytes == 119'000U); +} + +void codec_normalizes_paths_and_round_trips_signed_frsm() { + const auto open = mms::MmsFileServiceCodec::encode_file_open_request_pdu( + {17U, "COMTRADE\\TRIP_001.cfg", 0U, false}); + const auto decoded_open = mms::MmsPduCodec::decode_confirmed_request(open); + CHECK(decoded_open.service_tag == 72); + const auto fields = asn1::BerReader::read_children(decoded_open.service_value); + const auto segments = asn1::BerReader::read_children(fields.front().value); + CHECK(segments.size() == 2U); + CHECK(asn1::BerReader::read_ascii_string(segments[0]) == "COMTRADE"); + CHECK(asn1::BerReader::read_ascii_string(segments[1]) == "TRIP_001.cfg"); + CHECK(!mms::MmsFileServiceCodec::encode_file_open_request_pdu( + {18U, "FRA00019", 0U, false}).empty()); + CHECK(mms::MmsFileServiceCodec::rooted_backslash_path( + "COMTRADE/FRA00028.dat") == "\\COMTRADE\\FRA00028.dat"); + check_throws([] { + static_cast(mms::MmsFileServiceCodec::encode_file_open_request_pdu( + {1U, "../secret.cfg", 0U, false})); + }); + + for (const auto frsm : { + std::numeric_limits::min(), -17, -1, 23, + std::numeric_limits::max()}) { + const auto read = mms::MmsFileServiceCodec::encode_file_read_request_pdu( + {19U, frsm}); + const auto decoded_read = mms::MmsPduCodec::decode_confirmed_request(read); + CHECK(decoded_read.service_tag == 73); + asn1::BerTlv integer; + std::size_t offset = 0U; + CHECK(asn1::BerReader::try_read_tlv( + asn1::BerWriter::encode_tlv( + asn1::BerClass::context_specific, false, 73, + decoded_read.service_value), offset, integer)); + CHECK(asn1::BerReader::read_signed_integer(integer) == frsm); + + const auto close = mms::MmsFileServiceCodec::encode_file_close_request_pdu( + {20U, frsm}); + const auto decoded_close = mms::MmsPduCodec::decode_confirmed_request(close); + CHECK(decoded_close.service_tag == 74); + CHECK(asn1::BerReader::read_signed_integer(asn1::BerTlv{ + 0U, asn1::BerClass::context_specific, false, 74, + decoded_close.service_value}) == frsm); + } + + const auto opened = mms::MmsFileServiceCodec::decode_file_open_response( + open_response(21U, std::numeric_limits::min(), 4'096U), 21U); + CHECK(opened.frsm_id == std::numeric_limits::min()); + CHECK(opened.file_size_bytes == 4'096U); +} + +void codec_rejects_invoke_mismatch_malformed_and_trailing_data() { + const auto valid = read_response(5U, ByteVector{1U, 2U}, false); + try { + static_cast(mms::MmsFileServiceCodec::decode_file_read_response(valid, 6U)); + CHECK(false); + } catch (const mms::MmsFileServiceError& error) { + CHECK(error.diagnostic().failure_kind == + mms::MmsFileFailureKind::invoke_id_mismatch); + } + auto trailing = valid; + trailing.push_back(0U); + try { + static_cast(mms::MmsFileServiceCodec::decode_file_read_response(trailing, 5U)); + CHECK(false); + } catch (const mms::MmsFileServiceError& error) { + CHECK(error.diagnostic().failure_kind == + mms::MmsFileFailureKind::malformed_response); + } + auto truncated = valid; + truncated.pop_back(); + check_throws([&] { + static_cast(mms::MmsFileServiceCodec::decode_file_read_response( + truncated, 5U)); + }); + CHECK(mms::MmsFileServiceCodec::decode_file_close_response( + close_response(8U), 8U).invoke_id == 8U); +} + +void directory_runtime_paginates_deduplicates_and_detects_no_progress() { + FakeChannel channel; + channel.respond(directory_response(1U, { + directory_entry("B", 1U), directory_entry("A", 2U)}, true)); + channel.respond(directory_response(2U, { + directory_entry("A", 2U), directory_entry("C", 3U)}, false)); + mms::MmsFileTransferRuntime runtime{channel}; + const auto result = runtime.list_directory(); + CHECK(result.success); + CHECK(result.pages.size() == 2U); + CHECK(result.pages[1].continue_after == "A"); + CHECK(result.entries.size() == 3U); + CHECK(result.entries[0].name == "B"); + CHECK(result.entries[1].name == "A"); + CHECK(result.entries[2].name == "C"); + + FakeChannel repeated; + repeated.respond(directory_response(1U, {directory_entry("A", 1U)}, true)); + repeated.respond(directory_response(2U, {directory_entry("A", 1U)}, true)); + mms::MmsFileTransferRuntime repeated_runtime{repeated}; + const auto failed = repeated_runtime.list_directory(); + CHECK(!failed.success); + CHECK(failed.failure_kind == mms::MmsFileFailureKind::protocol); +} + +void runtime_streams_multiple_blocks_and_closes_negative_frsm() { + FakeChannel channel; + channel.respond(open_response(1U, -17, 5U)); + channel.respond(read_response(2U, ByteVector{1U, 2U, 3U}, true)); + channel.respond(read_response(3U, ByteVector{4U, 5U}, false)); + channel.respond(close_response(4U)); + MemorySink sink; + mms::MmsFileTransferRuntime runtime{channel}; + mms::MmsFileTransferOptions options; + options.require_declared_size_match = true; + const auto result = runtime.download("FRA00019", sink, options); + CHECK(result.success); + CHECK(result.bytes_transferred == 5U); + CHECK(result.read_operations == 2U); + CHECK(result.remote_file_closed); + CHECK(sink.bytes == (ByteVector{1U, 2U, 3U, 4U, 5U})); + CHECK(sink.flushed); + CHECK(channel.sent.size() == 4U); + CHECK(sent_service_tag(channel.sent[0]) == 72); + CHECK(sent_service_tag(channel.sent[1]) == 73); + CHECK(sent_service_tag(channel.sent[3]) == 74); +} + +void runtime_bounds_empty_blocks_sizes_operations_and_sink_failures() { + { + FakeChannel channel; + channel.respond(open_response(1U, 9, 10U)); + channel.respond(read_response(2U, {}, true)); + channel.respond(close_response(3U)); + MemorySink sink; + mms::MmsFileTransferRuntime runtime{channel}; + const auto result = runtime.download("empty.cfg", sink); + CHECK(!result.success); + CHECK(result.failure_kind == mms::MmsFileFailureKind::protocol); + CHECK(result.remote_file_closed); + } + { + FakeChannel channel; + channel.respond(open_response(1U, 10, 2U)); + channel.respond(read_response(2U, ByteVector{1U, 2U, 3U}, false)); + channel.respond(close_response(3U)); + MemorySink sink; + mms::MmsFileTransferRuntime runtime{channel}; + const auto result = runtime.download("oversize.dat", sink); + CHECK(!result.success); + CHECK(result.bytes_transferred == 0U); + CHECK(result.remote_file_closed); + } + { + FakeChannel channel; + channel.respond(open_response(1U, 11)); + channel.respond(read_response(2U, ByteVector{1U}, true)); + channel.respond(close_response(3U)); + MemorySink sink; + mms::MmsFileTransferRuntime runtime{channel}; + mms::MmsFileTransferOptions options; + options.maximum_read_operations = 1U; + const auto result = runtime.download("bounded.dat", sink, options); + CHECK(!result.success); + CHECK(result.failure_kind == mms::MmsFileFailureKind::limit_exceeded); + CHECK(result.remote_file_closed); + } + { + FakeChannel channel; + channel.respond(open_response(1U, 12)); + channel.respond(read_response(2U, ByteVector{1U}, false)); + channel.respond(close_response(3U)); + MemorySink sink; + sink.fail_write = true; + mms::MmsFileTransferRuntime runtime{channel}; + const auto result = runtime.download("sink.dat", sink); + CHECK(!result.success); + CHECK(result.failure_kind == mms::MmsFileFailureKind::sink); + CHECK(result.remote_file_closed); + } +} + +void runtime_preserves_primary_failure_when_close_also_fails() { + FakeChannel channel; + channel.respond(open_response(1U, 21)); + channel.respond(confirmed_error(2U, 11, 3U)); + channel.respond(confirmed_error(3U, 11, 4U)); + MemorySink sink; + mms::MmsFileTransferRuntime runtime{channel}; + const auto result = runtime.download("failure.dat", sink); + CHECK(!result.success); + CHECK(result.failure_kind == mms::MmsFileFailureKind::confirmed_error); + CHECK(result.message.find("value=3") != std::string::npos); + CHECK(!result.remote_file_closed); + CHECK(std::any_of(result.diagnostics.begin(), result.diagnostics.end(), + [](const auto& diagnostic) { + return diagnostic.stage == "FileClose" && !diagnostic.success; + })); +} + +void adaptive_fallback_is_precise_and_resets_sink() { + FakeChannel channel; + channel.respond(confirmed_error(1U, 11, 7U)); + channel.steps.push_back([]( + const std::span request, + const std::uint32_t invoke_id, + std::stop_token) { + const auto decoded = mms::MmsPduCodec::decode_confirmed_request( + mms::MmsPduCodec::extract_mms_payload(request)); + const auto fields = asn1::BerReader::read_children(decoded.service_value); + const auto names = asn1::BerReader::read_children(fields.front().value); + CHECK(names.size() == 1U); + CHECK(asn1::BerReader::read_ascii_string(names.front()) == + "\\COMTRADE\\FRA00028.dat"); + return open_response(invoke_id, -1, 2U); + }); + channel.respond(read_response(3U, ByteVector{4U, 2U}, false)); + channel.respond(close_response(4U)); + MemorySink sink; + mms::MmsFileTransferRuntime runtime{channel}; + const auto result = runtime.download_adaptive( + "COMTRADE/FRA00028.dat", sink); + CHECK(result.success); + CHECK(result.adaptive_fallback_attempted); + CHECK(result.adaptive_fallback_succeeded); + CHECK(sink.reset_count == 1U); + CHECK(result.primary_attempt_diagnostics.size() == 1U); + CHECK(!result.fallback_attempt_diagnostics.empty()); + + FakeChannel denied; + denied.respond(confirmed_error(1U, 11, 3U)); + MemorySink denied_sink; + mms::MmsFileTransferRuntime denied_runtime{denied}; + const auto denied_result = denied_runtime.download_adaptive( + "denied.dat", denied_sink); + CHECK(!denied_result.adaptive_fallback_attempted); + CHECK(denied_sink.reset_count == 0U); + CHECK(denied.steps.empty()); +} + +void adaptive_fallback_is_forbidden_after_any_read() { + FakeChannel channel; + channel.respond(open_response(1U, 22)); + channel.respond(read_response(2U, ByteVector{1U}, true)); + channel.respond(confirmed_error(3U, 11, 7U)); + channel.respond(close_response(4U)); + MemorySink sink; + mms::MmsFileTransferRuntime runtime{channel}; + const auto result = runtime.download_adaptive("partial.dat", sink); + CHECK(!result.success); + CHECK(result.bytes_transferred == 1U); + CHECK(result.read_operations == 2U); + CHECK(!result.adaptive_fallback_attempted); + CHECK(sink.reset_count == 0U); +} + +void cancellation_after_open_still_closes() { + FakeChannel channel; + std::stop_source source; + channel.steps.push_back([&source]( + std::span, const std::uint32_t invoke_id, + std::stop_token) { + source.request_stop(); + return open_response(invoke_id, 31, 1U); + }); + channel.respond(close_response(2U)); + MemorySink sink; + mms::MmsFileTransferRuntime runtime{channel}; + const auto result = runtime.download( + "cancel.dat", sink, {}, nullptr, source.get_token()); + CHECK(!result.success); + CHECK(result.failure_kind == mms::MmsFileFailureKind::cancelled); + CHECK(result.remote_file_closed); + CHECK(channel.sent.size() == 2U); + CHECK(sent_service_tag(channel.sent.back()) == 74); +} + +void timeout_preserves_evidence_when_association_drops() { + FakeChannel channel; + channel.respond(open_response(1U, 41)); + channel.steps.push_back([&channel]( + std::span, std::uint32_t, std::stop_token) -> ByteVector { + channel.connected = false; + throw mms::MmsTransportTimeoutError("scripted timeout"); + }); + MemorySink sink; + mms::MmsFileTransferRuntime runtime{channel}; + const auto result = runtime.download("timeout.dat", sink); + CHECK(!result.success); + CHECK(result.failure_kind == mms::MmsFileFailureKind::timed_out); + CHECK(!result.remote_file_closed); + CHECK(std::any_of(result.diagnostics.begin(), result.diagnostics.end(), + [](const auto& diagnostic) { + return diagnostic.failure_kind == mms::MmsFileFailureKind::timed_out; + })); +} + +} // namespace + +int main() { + const std::vector>> tests{ + {"codec file-directory request", codec_encodes_file_directory_high_tag_and_continuation}, + {"codec file-directory response", codec_decodes_directory_attributes_and_preserves_order}, + {"codec path and signed FRSM", codec_normalizes_paths_and_round_trips_signed_frsm}, + {"codec malformed evidence", codec_rejects_invoke_mismatch_malformed_and_trailing_data}, + {"directory bounded pagination", directory_runtime_paginates_deduplicates_and_detects_no_progress}, + {"runtime streaming and close", runtime_streams_multiple_blocks_and_closes_negative_frsm}, + {"runtime bounds and sink", runtime_bounds_empty_blocks_sizes_operations_and_sink_failures}, + {"runtime primary error preservation", runtime_preserves_primary_failure_when_close_also_fails}, + {"adaptive precise fallback", adaptive_fallback_is_precise_and_resets_sink}, + {"adaptive forbids retry after read", adaptive_fallback_is_forbidden_after_any_read}, + {"cancellation cleanup", cancellation_after_open_still_closes}, + {"timeout evidence", timeout_preserves_evidence_when_association_drops}, + }; + + std::size_t passed = 0U; + for (const auto& [name, test] : tests) { + try { + test(); + ++passed; + std::cout << "[PASS] " << name << '\n'; + } catch (const std::exception& exception) { + std::cerr << "[FAIL] " << name << ": " << exception.what() << '\n'; + return 1; + } + } + std::cout << "MMS file-service tests passed: " << passed << '/' << tests.size() << ".\n"; + return 0; +} From ce37b6ba6cc19f2519f92064d488d97de47904f0 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 11 Aug 2026 05:57:25 +0700 Subject: [PATCH 2/4] Add MMS file transfer CLI and fuzz coverage --- CMakeLists.txt | 10 + .../mms_file_service/file_close_response.hex | 1 + .../file_directory_response.hex | 1 + .../mms_file_service/file_open_response.hex | 1 + .../mms_file_service/file_read_response.hex | 1 + fuzz/fuzz_mms_file_service.cpp | 15 + fuzz/fuzz_targets.hpp | 32 ++ src/mms/file_service.cpp | 14 +- tests/test_fuzz_smoke.cpp | 9 +- tests/test_mms_file_service.cpp | 17 + tools/file_transfer.cpp | 406 ++++++++++++++++++ 11 files changed, 496 insertions(+), 11 deletions(-) create mode 100644 fuzz/corpus/mms_file_service/file_close_response.hex create mode 100644 fuzz/corpus/mms_file_service/file_directory_response.hex create mode 100644 fuzz/corpus/mms_file_service/file_open_response.hex create mode 100644 fuzz/corpus/mms_file_service/file_read_response.hex create mode 100644 fuzz/fuzz_mms_file_service.cpp create mode 100644 tools/file_transfer.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ebcd42ac3..88ac2ff8f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -133,6 +133,12 @@ if(ARIEC61850_BUILD_TOOLS) target_compile_features(ariec61850_pagination_probe PRIVATE cxx_std_20) ariec61850_apply_warnings(ariec61850_pagination_probe) ariec61850_apply_sanitizers(ariec61850_pagination_probe) + + add_executable(ariec61850_file_transfer tools/file_transfer.cpp) + target_link_libraries(ariec61850_file_transfer PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_file_transfer PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_file_transfer) + ariec61850_apply_sanitizers(ariec61850_file_transfer) endif() if(ARIEC61850_BUILD_TESTS) @@ -291,6 +297,9 @@ if(ARIEC61850_BUILD_TESTS) add_test( NAME ariec61850_pagination_probe_help COMMAND ariec61850_pagination_probe --help) + add_test( + NAME ariec61850_file_transfer_help + COMMAND ariec61850_file_transfer --help) endif() endif() @@ -320,5 +329,6 @@ if(ARIEC61850_BUILD_FUZZERS) ariec61850_add_fuzzer(ariec61850_fuzz_transport fuzz/fuzz_transport.cpp) ariec61850_add_fuzzer(ariec61850_fuzz_association fuzz/fuzz_association.cpp) ariec61850_add_fuzzer(ariec61850_fuzz_mms_services fuzz/fuzz_mms_services.cpp) + ariec61850_add_fuzzer(ariec61850_fuzz_mms_file_service fuzz/fuzz_mms_file_service.cpp) ariec61850_add_fuzzer(ariec61850_fuzz_reporting fuzz/fuzz_reporting.cpp) endif() diff --git a/fuzz/corpus/mms_file_service/file_close_response.hex b/fuzz/corpus/mms_file_service/file_close_response.hex new file mode 100644 index 000000000..eef06caa1 --- /dev/null +++ b/fuzz/corpus/mms_file_service/file_close_response.hex @@ -0,0 +1 @@ +A1060201019F4A00 diff --git a/fuzz/corpus/mms_file_service/file_directory_response.hex b/fuzz/corpus/mms_file_service/file_directory_response.hex new file mode 100644 index 000000000..3ee629786 --- /dev/null +++ b/fuzz/corpus/mms_file_service/file_directory_response.hex @@ -0,0 +1 @@ +A105020101BF4D00810100 diff --git a/fuzz/corpus/mms_file_service/file_open_response.hex b/fuzz/corpus/mms_file_service/file_open_response.hex new file mode 100644 index 000000000..c6ccb5c02 --- /dev/null +++ b/fuzz/corpus/mms_file_service/file_open_response.hex @@ -0,0 +1 @@ +A108020101BF4803800101 diff --git a/fuzz/corpus/mms_file_service/file_read_response.hex b/fuzz/corpus/mms_file_service/file_read_response.hex new file mode 100644 index 000000000..440040e8d --- /dev/null +++ b/fuzz/corpus/mms_file_service/file_read_response.hex @@ -0,0 +1 @@ +A10B020101BF4906800100810100 diff --git a/fuzz/fuzz_mms_file_service.cpp b/fuzz/fuzz_mms_file_service.cpp new file mode 100644 index 000000000..a85374ffc --- /dev/null +++ b/fuzz/fuzz_mms_file_service.cpp @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "fuzz_targets.hpp" + +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput( + const std::uint8_t* data, + const std::size_t size) { + ar::iec61850::fuzzing::exercise_mms_file_service( + std::span{data, size}); + return 0; +} diff --git a/fuzz/fuzz_targets.hpp b/fuzz/fuzz_targets.hpp index f98597bcb..037468e73 100644 --- a/fuzz/fuzz_targets.hpp +++ b/fuzz/fuzz_targets.hpp @@ -8,6 +8,7 @@ #include "ariec61850/evidence/pcap_equivalence.hpp" #include "ariec61850/goose/frame_codec.hpp" #include "ariec61850/goose/pdu_codec.hpp" +#include "ariec61850/mms/file_service.hpp" #include "ariec61850/mms/invoke_router.hpp" #include "ariec61850/mms/pdu.hpp" #include "ariec61850/mms/reporting.hpp" @@ -348,6 +349,37 @@ inline void exercise_mms_services(const std::span bytes) { } } +inline void exercise_mms_file_service(const std::span bytes) { + std::uint32_t invoke_id = 1U; + try { + const auto envelope = mms::MmsPduCodec::decode_envelope(bytes); + if (envelope.invoke_id && *envelope.invoke_id != 0U) { + invoke_id = *envelope.invoke_id; + } + } catch (...) { + } + try { + static_cast(mms::MmsFileServiceCodec::decode_file_directory_response( + bytes, invoke_id)); + } catch (...) { + } + try { + static_cast(mms::MmsFileServiceCodec::decode_file_open_response( + bytes, invoke_id)); + } catch (...) { + } + try { + static_cast(mms::MmsFileServiceCodec::decode_file_read_response( + bytes, invoke_id)); + } catch (...) { + } + try { + static_cast(mms::MmsFileServiceCodec::decode_file_close_response( + bytes, invoke_id)); + } catch (...) { + } +} + inline void exercise_reporting(const std::span bytes) { try { diff --git a/src/mms/file_service.cpp b/src/mms/file_service.cpp index 42d98318d..79d26985f 100644 --- a/src/mms/file_service.cpp +++ b/src/mms/file_service.cpp @@ -1045,13 +1045,15 @@ MmsFileTransferResult MmsFileTransferRuntime::download_attempt( const auto opened = MmsFileServiceCodec::decode_file_open_response( response_payload, invoke_id); frsm = opened.frsm_id; - result.expected_bytes = opened.file_size_bytes; + result.expected_bytes = opened.file_size_bytes && *opened.file_size_bytes > 0U + ? std::optional{*opened.file_size_bytes} + : std::nullopt; append_diagnostic(result.diagnostics, { "FileOpen", MmsFileFailureKind::none, true, "FileOpen succeeded; FRSM=" + std::to_string(opened.frsm_id) + - (opened.file_size_bytes + (opened.file_size_bytes && *opened.file_size_bytes > 0U ? ", declaredSize=" + std::to_string(*opened.file_size_bytes) + "." : ", declared size unavailable."), invoke_id, @@ -1279,14 +1281,6 @@ MmsFileTransferResult MmsFileTransferRuntime::download_attempt( result.message = "Downloaded " + std::to_string(result.bytes_transferred) + " byte(s) from '" + result.remote_path + "' in " + std::to_string(result.read_operations) + " FileRead operation(s)."; - if (progress != nullptr) { - progress->report({ - result.remote_path, - result.bytes_transferred, - result.expected_bytes, - result.read_operations, - true}); - } } else if (result.message.empty()) { result.message = "MMS file transfer failed."; } diff --git a/tests/test_fuzz_smoke.cpp b/tests/test_fuzz_smoke.cpp index 21e9ad03d..f995eb318 100644 --- a/tests/test_fuzz_smoke.cpp +++ b/tests/test_fuzz_smoke.cpp @@ -106,6 +106,9 @@ void deterministic_decoder_mutation_smoke() { const auto mms_services = read_file( std::filesystem::path{ARIEC61850_SOURCE_DIR} / "fuzz/corpus/mms_services/initiate_request"); + const auto mms_file_service = read_file( + std::filesystem::path{ARIEC61850_SOURCE_DIR} / + "fuzz/corpus/mms_file_service/file_read_response.hex"); const auto reporting = read_file( std::filesystem::path{ARIEC61850_SOURCE_DIR} / "fuzz/corpus/reporting/realistic_information_report"); @@ -120,6 +123,7 @@ void deterministic_decoder_mutation_smoke() { mutation_case_count(transport) + mutation_case_count(association) + mutation_case_count(mms_services) + + mutation_case_count(mms_file_service) + mutation_case_count(reporting) + 1U; @@ -164,6 +168,9 @@ void deterministic_decoder_mutation_smoke() { cases += exercise_mutations(mms_services, [](const ByteVector& bytes) { ar::iec61850::fuzzing::exercise_mms_services(bytes); }); + cases += exercise_mutations(mms_file_service, [](const ByteVector& bytes) { + ar::iec61850::fuzzing::exercise_mms_file_service(bytes); + }); cases += exercise_mutations(reporting, [](const ByteVector& bytes) { ar::iec61850::fuzzing::exercise_reporting(bytes); }); @@ -189,4 +196,4 @@ int main() { << error.what() << '\n'; return 1; } -} \ No newline at end of file +} diff --git a/tests/test_mms_file_service.cpp b/tests/test_mms_file_service.cpp index 88d45fbb7..5d3ea1bac 100644 --- a/tests/test_mms_file_service.cpp +++ b/tests/test_mms_file_service.cpp @@ -403,6 +403,22 @@ void runtime_streams_multiple_blocks_and_closes_negative_frsm() { CHECK(sent_service_tag(channel.sent[3]) == 74); } +void runtime_treats_zero_fileopen_size_as_unavailable() { + FakeChannel channel; + channel.respond(open_response(1U, 7, 0U)); + channel.respond(read_response(2U, ByteVector{8U, 8U, 2U}, false)); + channel.respond(close_response(3U)); + MemorySink sink; + mms::MmsFileTransferRuntime runtime{channel}; + mms::MmsFileTransferOptions options; + options.require_declared_size_match = true; + const auto result = runtime.download("relay.inf", sink, options); + CHECK(result.success); + CHECK(!result.expected_bytes); + CHECK(result.bytes_transferred == 3U); + CHECK(result.remote_file_closed); +} + void runtime_bounds_empty_blocks_sizes_operations_and_sink_failures() { { FakeChannel channel; @@ -582,6 +598,7 @@ int main() { {"codec malformed evidence", codec_rejects_invoke_mismatch_malformed_and_trailing_data}, {"directory bounded pagination", directory_runtime_paginates_deduplicates_and_detects_no_progress}, {"runtime streaming and close", runtime_streams_multiple_blocks_and_closes_negative_frsm}, + {"runtime zero declared size", runtime_treats_zero_fileopen_size_as_unavailable}, {"runtime bounds and sink", runtime_bounds_empty_blocks_sizes_operations_and_sink_failures}, {"runtime primary error preservation", runtime_preserves_primary_failure_when_close_also_fails}, {"adaptive precise fallback", adaptive_fallback_is_precise_and_resets_sink}, diff --git a/tools/file_transfer.cpp b/tools/file_transfer.cpp new file mode 100644 index 000000000..e41fa2552 --- /dev/null +++ b/tools/file_transfer.cpp @@ -0,0 +1,406 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/association_runtime.hpp" +#include "ariec61850/mms/file_service.hpp" +#include "ariec61850/mms/tcp_transport.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +enum class Command : std::uint8_t { list, download }; + +struct Options final { + mms::MmsEndpoint endpoint; + Command command{Command::list}; + std::string directory; + std::string remote_path; + std::string output_path; + std::string json_output_path; + std::size_t timeout_ms{30'000U}; + std::size_t maximum_pages{64U}; + std::size_t maximum_entries{65'536U}; + std::uint64_t maximum_bytes{512ULL * 1024ULL * 1024ULL}; + std::size_t maximum_reads{100'000U}; + std::size_t maximum_block_bytes{1U * 1024U * 1024U}; + bool json{}; +}; + +[[nodiscard]] std::string json_escape(const std::string_view value) { + std::ostringstream output; + for (const char character : value) { + switch (character) { + case '\\': output << "\\\\"; break; + case '"': output << "\\\""; break; + case '\b': output << "\\b"; break; + case '\f': output << "\\f"; break; + case '\n': output << "\\n"; break; + case '\r': output << "\\r"; break; + case '\t': output << "\\t"; break; + default: + if (static_cast(character) < 0x20U) { + output << '?'; + } else { + output << character; + } + break; + } + } + return output.str(); +} + +[[nodiscard]] std::string hex(const std::vector& bytes) { + static constexpr char digits[] = "0123456789ABCDEF"; + std::string result; + result.reserve(bytes.size() * 2U); + for (const auto value : bytes) { + result.push_back(digits[(value >> 4U) & 0x0FU]); + result.push_back(digits[value & 0x0FU]); + } + return result; +} + +[[nodiscard]] std::size_t parse_size( + const std::string& option, + const std::string& value) { + std::size_t consumed = 0U; + const auto parsed = std::stoull(value, &consumed, 10); + if (consumed != value.size() || parsed == 0ULL || + parsed > static_cast( + std::numeric_limits::max())) { + throw std::invalid_argument(option + " requires a positive bounded integer."); + } + return static_cast(parsed); +} + +[[nodiscard]] std::uint64_t parse_u64( + const std::string& option, + const std::string& value) { + std::size_t consumed = 0U; + const auto parsed = std::stoull(value, &consumed, 10); + if (consumed != value.size() || parsed == 0ULL) { + throw std::invalid_argument(option + " requires a positive integer."); + } + return parsed; +} + +[[nodiscard]] std::uint16_t parse_port(const std::string& value) { + const auto parsed = parse_size("port", value); + if (parsed > 65'535U) { + throw std::invalid_argument("port must be in the range 1..65535."); + } + return static_cast(parsed); +} + +void usage() { + std::cout + << "Usage:\n" + << " ariec61850_file_transfer [port] [list] [options]\n" + << " ariec61850_file_transfer [port] download --remote PATH --output FILE [options]\n\n" + << "List options:\n" + << " --directory PATH Root is used by default.\n" + << " --max-pages N Default 64.\n" + << " --max-entries N Default 65536.\n\n" + << "Download options:\n" + << " --remote PATH Explicit remote file to read.\n" + << " --output FILE Explicit local destination.\n" + << " --max-bytes N Default 536870912.\n" + << " --max-reads N Default 100000.\n" + << " --max-block-bytes N Default 1048576.\n\n" + << "Common options:\n" + << " --timeout-ms N Connect/request timeout; default 30000.\n" + << " --json Print machine-readable evidence.\n" + << " --json-output FILE Also write JSON evidence to a local file.\n" + << " --help Show this help.\n\n" + << "The default command is read-only FileDirectory. Download must be explicit;\n" + << "this tool never uploads, deletes, renames, or mutates remote files.\n"; +} + +[[nodiscard]] Options parse_options(const int argc, char** argv) { + if (argc < 2) { + throw std::invalid_argument("A host is required."); + } + Options options; + options.endpoint.host = argv[1]; + options.endpoint.port = 102U; + int index = 2; + if (index < argc && std::string_view{argv[index]} != "list" && + std::string_view{argv[index]} != "download" && + std::string_view{argv[index]}.rfind("--", 0U) != 0U) { + options.endpoint.port = parse_port(argv[index++]); + } + if (index < argc && std::string_view{argv[index]} == "list") { + ++index; + } else if (index < argc && std::string_view{argv[index]} == "download") { + options.command = Command::download; + ++index; + } + + while (index < argc) { + const std::string option = argv[index++]; + if (option == "--json") { + options.json = true; + continue; + } + if (option == "--help" || option == "-h") { + usage(); + std::exit(0); + } + if (index >= argc) { + throw std::invalid_argument(option + " requires a value."); + } + const std::string value = argv[index++]; + if (option == "--directory") options.directory = value; + else if (option == "--remote") options.remote_path = value; + else if (option == "--output") options.output_path = value; + else if (option == "--json-output") options.json_output_path = value; + else if (option == "--timeout-ms") options.timeout_ms = parse_size(option, value); + else if (option == "--max-pages") options.maximum_pages = parse_size(option, value); + else if (option == "--max-entries") options.maximum_entries = parse_size(option, value); + else if (option == "--max-bytes") options.maximum_bytes = parse_u64(option, value); + else if (option == "--max-reads") options.maximum_reads = parse_size(option, value); + else if (option == "--max-block-bytes") { + options.maximum_block_bytes = parse_size(option, value); + } else { + throw std::invalid_argument("Unknown option: " + option); + } + } + if (options.command == Command::download && + (options.remote_path.empty() || options.output_path.empty())) { + throw std::invalid_argument( + "download requires both --remote PATH and --output FILE."); + } + return options; +} + +class FileSink final : public mms::MmsFileSink { +public: + explicit FileSink(std::string path) : path_(std::move(path)) { + open(); + if (!stream_) { + throw std::runtime_error("Unable to create local output file: " + path_); + } + } + + [[nodiscard]] bool write( + const std::span data, + std::string& error) noexcept override { + stream_.write( + reinterpret_cast(data.data()), + static_cast(data.size())); + if (!stream_) { + error = "write failed for local output file"; + return false; + } + return true; + } + + [[nodiscard]] bool flush(std::string& error) noexcept override { + stream_.flush(); + if (!stream_) { + error = "flush failed for local output file"; + return false; + } + return true; + } + + [[nodiscard]] bool reset(std::string& error) noexcept override { + stream_.close(); + stream_.clear(); + open(); + if (!stream_) { + error = "truncate/reset failed for local output file"; + return false; + } + return true; + } + +private: + void open() { + stream_.open(path_, std::ios::binary | std::ios::out | std::ios::trunc); + } + + std::string path_; + std::ofstream stream_; +}; + +class ConsoleProgress final : public mms::MmsFileProgressSink { +public: + void report(const mms::MmsFileTransferProgress& progress) noexcept override { + std::cerr << "Transferred " << progress.bytes_transferred; + if (progress.expected_bytes) { + std::cerr << '/' << *progress.expected_bytes; + } + std::cerr << " bytes in " << progress.read_operations << " read(s)" + << (progress.complete ? " [complete]" : "") << ".\n"; + } +}; + +[[nodiscard]] std::string directory_json( + const mms::MmsFileDirectoryResult& result) { + std::ostringstream output; + output << "{\"schemaVersion\":\"ariec61850-mms-file-directory-v1\"," + << "\"readOnly\":true,\"success\":" + << (result.success ? "true" : "false") << ',' + << "\"directory\":\"" << json_escape(result.directory_name) << "\"," + << "\"message\":\"" << json_escape(result.message) << "\"," + << "\"pageCount\":" << result.pages.size() << ',' + << "\"entryCount\":" << result.entries.size() << ",\"pages\":["; + for (std::size_t index = 0U; index < result.pages.size(); ++index) { + if (index != 0U) output << ','; + const auto& page = result.pages[index]; + output << "{\"page\":" << page.page_number + << ",\"continueAfter\":\"" << json_escape(page.continue_after) + << "\",\"entryCount\":" << page.response_entry_count + << ",\"moreFollows\":" << (page.more_follows ? "true" : "false") + << '}'; + } + output << "],\"entries\":["; + for (std::size_t index = 0U; index < result.entries.size(); ++index) { + if (index != 0U) output << ','; + const auto& entry = result.entries[index]; + output << "{\"name\":\"" << json_escape(entry.name) + << "\",\"path\":\"" << json_escape(entry.path) << "\","; + if (entry.size_bytes) output << "\"sizeBytes\":" << *entry.size_bytes << ','; + else output << "\"sizeBytes\":null,"; + output << "\"lastModifiedHex\":\"" << hex(entry.last_modified) + << "\",\"likelyDirectory\":" + << (entry.likely_directory() ? "true" : "false") << '}'; + } + output << "]}"; + return output.str(); +} + +[[nodiscard]] std::string transfer_json( + const mms::MmsFileTransferResult& result) { + std::ostringstream output; + output << "{\"schemaVersion\":\"ariec61850-mms-file-transfer-v1\"," + << "\"readOnly\":true,\"success\":" + << (result.success ? "true" : "false") << ',' + << "\"remotePath\":\"" << json_escape(result.remote_path) << "\"," + << "\"message\":\"" << json_escape(result.message) << "\"," + << "\"bytesTransferred\":" << result.bytes_transferred << ','; + if (result.expected_bytes) output << "\"expectedBytes\":" << *result.expected_bytes << ','; + else output << "\"expectedBytes\":null,"; + output << "\"readOperations\":" << result.read_operations << ',' + << "\"remoteFileClosed\":" + << (result.remote_file_closed ? "true" : "false") << ',' + << "\"adaptiveFallbackAttempted\":" + << (result.adaptive_fallback_attempted ? "true" : "false") << ',' + << "\"adaptiveFallbackSucceeded\":" + << (result.adaptive_fallback_succeeded ? "true" : "false") + << ",\"diagnostics\":["; + for (std::size_t index = 0U; index < result.diagnostics.size(); ++index) { + if (index != 0U) output << ','; + const auto& diagnostic = result.diagnostics[index]; + output << "{\"stage\":\"" << json_escape(diagnostic.stage) + << "\",\"success\":" << (diagnostic.success ? "true" : "false") + << ",\"message\":\"" << json_escape(diagnostic.message) << "\"}"; + } + output << "]}"; + return output.str(); +} + +void emit_json(const std::string& json, const Options& options) { + if (options.json) { + std::cout << json << '\n'; + } + if (!options.json_output_path.empty()) { + std::ofstream output{ + options.json_output_path, std::ios::binary | std::ios::out | std::ios::trunc}; + if (!output) { + throw std::runtime_error( + "Unable to create JSON evidence file: " + options.json_output_path); + } + output << json << '\n'; + } +} + +[[nodiscard]] int exit_code(const mms::MmsFileFailureKind kind) noexcept { + if (kind == mms::MmsFileFailureKind::sink) return 4; + if (kind == mms::MmsFileFailureKind::cleanup) return 5; + return 3; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc == 2 && + (std::string_view{argv[1]} == "--help" || + std::string_view{argv[1]} == "-h")) { + usage(); + return 0; + } + try { + const auto options = parse_options(argc, argv); + if (options.timeout_ms > static_cast( + std::numeric_limits::max())) { + throw std::invalid_argument("--timeout-ms is too large."); + } + + mms::MmsAssociationOptions association_options; + association_options.connect_timeout = std::chrono::milliseconds{ + static_cast(options.timeout_ms)}; + association_options.request_timeout = association_options.connect_timeout; + mms::TcpMmsByteTransport transport; + mms::MmsAssociationRuntime association{transport, association_options}; + association.connect(options.endpoint); + mms::MmsAssociationFileServiceChannel channel{association}; + mms::MmsFileTransferRuntime runtime{channel}; + + int result_code = 0; + if (options.command == Command::list) { + mms::MmsFileDirectoryOptions directory_options; + directory_options.maximum_pages = options.maximum_pages; + directory_options.maximum_entries = options.maximum_entries; + const auto result = runtime.list_directory( + options.directory, directory_options); + const auto json = directory_json(result); + emit_json(json, options); + if (!options.json) { + std::cout << result.message << '\n'; + for (const auto& entry : result.entries) { + std::cout << (entry.likely_directory() ? "[DIR] " : "[FILE] ") + << entry.path; + if (entry.size_bytes) std::cout << " " << *entry.size_bytes << " bytes"; + std::cout << '\n'; + } + } + if (!result.success) result_code = exit_code(result.failure_kind); + } else { + FileSink sink{options.output_path}; + ConsoleProgress progress; + mms::MmsFileTransferOptions transfer_options; + transfer_options.maximum_bytes = options.maximum_bytes; + transfer_options.maximum_read_operations = options.maximum_reads; + transfer_options.maximum_block_bytes = options.maximum_block_bytes; + transfer_options.require_declared_size_match = true; + const auto result = runtime.download_adaptive( + options.remote_path, sink, transfer_options, &progress); + const auto json = transfer_json(result); + emit_json(json, options); + if (!options.json) std::cout << result.message << '\n'; + if (!result.success) result_code = exit_code(result.failure_kind); + } + association.disconnect(); + return result_code; + } catch (const std::invalid_argument& exception) { + std::cerr << "Argument error: " << exception.what() << '\n'; + usage(); + return 2; + } catch (const std::exception& exception) { + std::cerr << "MMS file transfer tool failed: " << exception.what() << '\n'; + return 3; + } +} From 228761a9dbc1ee95739a774524fe45331449fc0f Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 11 Aug 2026 06:03:56 +0700 Subject: [PATCH 3/4] Document and gate portable MMS file transfer --- CMakeLists.txt | 2 + MIGRATION_CHECKLIST.md | 20 ++++- README.md | 21 ++++- docs/FEATURE_MATRIX.md | 5 +- docs/MMS_FILE_TRANSFER.md | 135 ++++++++++++++++++++++++++++++++ embedded/CMakeLists.txt | 5 +- embedded/README.md | 6 ++ tests/test_mms_file_service.cpp | 22 ++++++ 8 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 docs/MMS_FILE_TRANSFER.md diff --git a/CMakeLists.txt b/CMakeLists.txt index 3ff610959..559815fc2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -219,6 +219,8 @@ if(ARIEC61850_BUILD_TESTS) add_executable(ariec61850_mms_file_service_tests tests/test_mms_file_service.cpp) target_link_libraries(ariec61850_mms_file_service_tests PRIVATE ARIEC61850::core) + target_compile_definitions(ariec61850_mms_file_service_tests PRIVATE + ARIEC61850_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") target_compile_features(ariec61850_mms_file_service_tests PRIVATE cxx_std_20) ariec61850_apply_warnings(ariec61850_mms_file_service_tests) ariec61850_apply_sanitizers(ariec61850_mms_file_service_tests) diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index d2bc4d573..483f585ee 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -150,7 +150,17 @@ behavioral oracle until laboratory interoperability is complete. - [ ] Direct and SBO control state machines. - [ ] Enhanced-security termination/error handling. -- [ ] MMS file and fault-record transfer. +- [x] MMS FileDirectory `[77]` codec with root/nested paths, attributes, `moreFollows`, and `continueAfter`. +- [x] Bounded FileDirectory pagination with deterministic order-preserving deduplication and no-progress detection. +- [x] MMS FileOpen/FileRead/FileClose `[72-74]` codecs with signed Integer32 FRSM parity. +- [x] Caller-owned streaming sink runtime with byte/read/block/diagnostic bounds and advertised-size validation. +- [x] Best-effort FileClose after read, validation, sink, and observed cancellation failures without replacing the primary error. +- [x] Precise file/file-non-existent rooted-backslash fallback before the first FileRead only. +- [x] Host list/download CLI, JSON evidence, dedicated regression suite, mutation smoke, and libFuzzer corpus. +- [x] Controlled live FileDirectory and small-file download with successful FileClose and local hash evidence. +- [x] Controlled live forced block-limit failure with zero local bytes and successful FileClose cleanup. +- [ ] Multi-page physical FileDirectory continuation evidence. +- [ ] Multi-vendor file-service acceptance and ESP32 device validation. ## Phase 5 — simulator and applications @@ -160,6 +170,8 @@ behavioral oracle until laboratory interoperability is complete. ## Safety gate -SCL, COMTRADE, PCAP, and the Phase 4C/4C.1 live discovery surface are read-only. Active RCB -mutation, control, dynamic DataSet mutation, file transfer, Npcap transmission, and real-time SV -scheduling require separate explicit APIs, authorization, and physical-laboratory acceptance. +SCL, COMTRADE, PCAP, and the Phase 4C/4C.1 live discovery surface are read-only. MMS file +download is also remote-content read-only but has an active FileOpen/FileRead/FileClose resource +lifecycle, so the host CLI requires an explicit remote path/destination and controlled-lab +authorization. RCB mutation, control, dynamic DataSet mutation, remote file mutation, Npcap +transmission, and real-time SV scheduling retain separate explicit safety gates. diff --git a/README.md b/README.md index 23854aeb7..3ff4ef26e 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ MCU-safe protocol boundary. - SCL read-only parsing and COMTRADE CFG/DAT support for host engineering tools. - RFC 1006 TPKT, COTP, ISO Session, Presentation, ACSE, and MMS Initiate. - Confirmed MMS GetNameList, GetVariableAccessAttributes, Read, and Write codecs. +- Read-only MMS FileDirectory/FileOpen/FileRead/FileClose codecs and bounded streaming runtime. - DataSet directory, InformationReport, RCB state, and report-subscription foundations. ### Phase 1A–1E — deterministic process-bus core @@ -126,6 +127,22 @@ GetVariableAccessAttributes, GetNamedVariableListAttributes and Read. It does **not** authorize or perform Write, control, GI, RCB reservation/enable, dynamic DataSet mutation, or file-service mutation. +### Phase 4E - MMS file transfer client + +`ariec61850_file_transfer` adds an explicit read-only file-service surface: + +- root/nested FileDirectory with bounded continuation; +- streaming FileOpen/FileRead/FileClose download to a caller-owned sink; +- full signed Integer32 FRSM support; +- exact Confirmed-Error evidence and bounded diagnostics; +- best-effort FileClose that preserves the primary failure; and +- a single oracle-matched rooted-backslash fallback for file-non-existent. + +The default CLI operation is directory listing. Download requires both +`--remote` and `--output`; upload/delete/rename are not implemented. See +`docs/MMS_FILE_TRANSFER.md` for API, safety, CLI, exit-code, and acceptance +details. + #### Public Alpha A1 model work The live model now projects C#-style: @@ -312,7 +329,9 @@ roles are documented in `docs/EMBEDDED_TARGETS.md`: - STM32H7/NXP class: later portability/industrialization target. The embedded profile already excludes host TCP/live-discovery/PCAP/COMTRADE/SCL -parser tooling and provides a no-RTTI host-simulation gate. Sampled Values also +parser tooling and provides a no-RTTI host-simulation gate. The portable MMS +file-service runtime is included through abstract transport/sink boundaries; +its filesystem-backed CLI remains host-only. Sampled Values also has a caller-owned `encode_into(span)` path so the eventual publisher does not need to allocate a fresh Ethernet frame buffer per sample. diff --git a/docs/FEATURE_MATRIX.md b/docs/FEATURE_MATRIX.md index 3c9eda556..a13b76d2d 100644 --- a/docs/FEATURE_MATRIX.md +++ b/docs/FEATURE_MATRIX.md @@ -57,9 +57,10 @@ Status terms are intentionally conservative: | Embedded | ESP32-S3 8DI/8DO IED application | Planned | Waveshare board application reference after bounded MMS server foundation exists. | | MMS server | Association acceptor + static browse/read model | Planned | Required for the ESP32 I/O IED; not yet a public server feature. | | MMS mutation | Write/control | Codec/offline foundation only | Live Phase 4C discovery intentionally does not send these operations. | -| File services | File directory/read/write | Planned for later parity | No file-service mutation/request is part of current live acceptance. | +| File services | FileDirectory / FileOpen / FileRead / FileClose client | Live-proven / read-only | Bounded portable streaming runtime, signed FRSM, exact error evidence, rooted-backslash fallback, success cleanup, and forced-failure cleanup were exercised on the controlled lab target. Current directory fit one page; multi-page live continuation remains pending. | +| File services | Upload / delete / rename | Not implemented | Remote file mutation is deliberately outside the current milestone. | | Security | ASan/UBSan | CI-gated | Security workflow; latest tranche must be green before acceptance claim. | -| Security | libFuzzer corpora | CI-gated | BER, GOOSE, SV, PCAP, SCL, COMTRADE, OSI, MMS services/reporting. | +| Security | libFuzzer corpora | CI-gated | BER, GOOSE, SV, PCAP, SCL, COMTRADE, OSI, MMS services/reporting/file-service. | | Portability | GCC / Clang / MSVC | CI-gated | Required for host Public Alpha. | | Acceptance | Same-IED repeated structural stability | In progress | Acceptance runner uses `structuralFingerprint`, not mutable legacy canonical fingerprint. Multi-cycle live evidence is the next A1 acceptance tranche. | | Acceptance | Timeout/reconnect evidence | In progress | Dedicated controlled failure/reconnect runs remain required; successful association cycles alone are not sufficient evidence. | diff --git a/docs/MMS_FILE_TRANSFER.md b/docs/MMS_FILE_TRANSFER.md new file mode 100644 index 000000000..29596e9ae --- /dev/null +++ b/docs/MMS_FILE_TRANSFER.md @@ -0,0 +1,135 @@ +# MMS file transfer client + +## Status and scope + +The C++20 core implements the read/download side of the ISO 9506 MMS file +service used by IEC 61850 engineering clients: + +- `FileDirectory` confirmed service `[77]`; +- `FileOpen` confirmed service `[72]`; +- `FileRead` confirmed service `[73]`; +- `FileClose` confirmed service `[74]`; +- bounded pagination with `continueAfter`; +- the full signed `Integer32` FRSM identifier range; +- one precise rooted-backslash interoperability fallback; and +- caller-owned streaming sinks with best-effort remote cleanup. + +Upload, FileDelete, FileRename, and every other remote file mutation are out of +scope. The implementation is not a claim of full IEC 61850 conformance or +multi-vendor interoperability. + +## Architecture + +`include/ariec61850/mms/file_service.hpp` and +`src/mms/file_service.cpp` contain the portable layer. They reuse the existing +BER, MMS PDU, Presentation, invoke-routing, and association runtime. They do not +open sockets, create threads, or access a filesystem. + +The portable boundaries are: + +- `MmsFileServiceCodec` for request/response wire models; +- `MmsFileServiceChannel` for a confirmed-exchange provider; +- `MmsAssociationFileServiceChannel` for the existing association runtime; +- `MmsFileSink` for application-owned flash, SD, RAM, or other storage; and +- `MmsFileTransferRuntime` for directory and download lifecycle policy. + +The host-only `ariec61850_file_transfer` executable supplies the native TCP +transport and a filesystem-backed sink. It is not part of `embedded_core`. + +## Wire and interoperability behavior + +Normal `FileOpen` encodes a normalized path as a sequence of GraphicString path +segments. Backslashes received from the caller are normalized to `/`; empty, +traversal (`.` or `..`), control-character, null-character, drive/scheme-like, +and overlong paths are rejected before an exchange. + +Some deployed relays list a root file normally but require FileOpen to use one +GraphicString containing a leading backslash. Adaptive download first sends the +normal segmented request. It retries exactly once with a rooted path only when: + +1. FileOpen returned Confirmed-Error `errorClass [11]`, value `7` + (`file/file-non-existent`); +2. no FileRead operation ran and no byte was accepted; and +3. the caller-provided sink resets successfully. + +Access denied, malformed responses, transport faults, cancellation, any error +after FileRead, or any other service-error value never triggers the fallback. +Primary and fallback diagnostics are retained separately. + +FRSM identifiers are signed 32-bit values. Negative values and both Integer32 +endpoints are accepted from FileOpen and echoed unchanged by FileRead/FileClose. +A FileOpen size attribute of zero is treated as unavailable, matching the C# +oracle and deployed relay behavior; a positive advertised size remains an +enforced upper bound. + +## Bounded lifecycle and failure rules + +Callers configure explicit bounds for directory pages, entries, read +operations, total bytes, block bytes, diagnostic entries, and diagnostic text. +The runtime also rejects: + +- repeated/no-progress directory pages; +- an empty FileRead block with `moreFollows=true`; +- arithmetic or configured byte-limit overflow; +- bytes beyond a positive advertised file size; and +- declared-size mismatch when strict matching is requested. + +After FileOpen succeeds, FileClose is attempted even after read validation, +sink failure, cancellation observed between operations, or a normal pipeline +failure. Cleanup uses a fresh stop token so caller cancellation does not skip it. +If the underlying association has already faulted or timed out, the result +records that cleanup could not be sent. A FileClose failure never replaces an +earlier primary failure. `remote_file_closed` makes the cleanup outcome explicit. + +## Host CLI + +Build with `ARIEC61850_BUILD_TOOLS=ON`, then list the root directory: + +```text +ariec61850_file_transfer [port] list --json-output evidence.json +``` + +Download requires both an explicit remote path and destination: + +```text +ariec61850_file_transfer [port] download \ + --remote REMOTE_PATH --output LOCAL_FILE \ + --max-bytes 1048576 --json-output evidence.json +``` + +Exit codes are stable: + +- `0`: requested operation completed; +- `2`: argument/configuration error; +- `3`: association, transport, protocol, decode, or limit failure; +- `4`: local sink failure; and +- `5`: cleanup was the terminal failure. + +The default command is FileDirectory. The CLI never uploads, deletes, or +renames a remote object. JSON evidence may contain endpoint/file metadata and +should remain in an ignored local evidence directory unless deliberately +sanitized. + +## Validation evidence + +Offline regression coverage includes golden request/response shapes, nested and +root directory paths, pagination/continuation, attributes, malformed/truncated/ +trailing BER, invoke mismatch, all signed FRSM boundaries, multi-block reads, +empty-progress blocks, configured and advertised size bounds, sink failures, +cancellation/timeout evidence, cleanup precedence, and adaptive fallback policy. +A dedicated mutation target and corpus exercise all four response decoders. + +The current controlled lab acceptance proved: + +- one bounded root FileDirectory operation; +- a small read-only file downloaded in one FileRead; +- the deployed rooted-backslash FileOpen fallback; +- zero-size FileOpen attribute interoperability; +- successful FileClose after both success and forced block-limit failure; and +- exact local byte count plus SHA-256 evidence. + +Exact endpoint, remote filename, file content/hash, and JSON traces are retained +locally in the ignored evidence directory and are intentionally not published. +Pagination was not observed on the current one-page directory and remains a +multi-page live acceptance item. ESP32 compilation/device execution and +multi-vendor physical acceptance also remain pending. diff --git a/embedded/CMakeLists.txt b/embedded/CMakeLists.txt index ee0dddb9e..7eaba0dc5 100644 --- a/embedded/CMakeLists.txt +++ b/embedded/CMakeLists.txt @@ -38,7 +38,10 @@ set(ARIEC61850_EMBEDDED_SOURCES ${ARIEC61850_ROOT}/src/mms/data_value.cpp ${ARIEC61850_ROOT}/src/mms/data_codec.cpp ${ARIEC61850_ROOT}/src/mms/pdu.cpp - ${ARIEC61850_ROOT}/src/mms/services.cpp) + ${ARIEC61850_ROOT}/src/mms/services.cpp + ${ARIEC61850_ROOT}/src/mms/invoke_router.cpp + ${ARIEC61850_ROOT}/src/mms/association_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/file_service.cpp) add_library(ariec61850_embedded_core STATIC ${ARIEC61850_EMBEDDED_SOURCES}) add_library(ARIEC61850::embedded_core ALIAS ariec61850_embedded_core) diff --git a/embedded/README.md b/embedded/README.md index 32187d20b..5d3b97134 100644 --- a/embedded/README.md +++ b/embedded/README.md @@ -43,6 +43,12 @@ Host-only facilities remain outside this build profile: - filesystem access; - desktop diagnostics/UI. +The portable MMS file-service codec and bounded transfer runtime are included in +`embedded_core`. They use the abstract, caller-supplied `MmsByteTransport` and +`MmsFileSink`; the native TCP transport and filesystem-backed host CLI remain +excluded. This is a host-simulation portability result, not yet an ESP32 device +validation or deterministic no-heap claim. + ## Current compiler contract The embedded profile currently enforces: diff --git a/tests/test_mms_file_service.cpp b/tests/test_mms_file_service.cpp index 5d3ea1bac..aa4609ce2 100644 --- a/tests/test_mms_file_service.cpp +++ b/tests/test_mms_file_service.cpp @@ -9,7 +9,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -588,6 +590,25 @@ void timeout_preserves_evidence_when_association_drops() { })); } +void portable_core_has_no_host_file_or_socket_dependencies() { + const std::vector files{ + std::string{ARIEC61850_SOURCE_DIR} + "/include/ariec61850/mms/file_service.hpp", + std::string{ARIEC61850_SOURCE_DIR} + "/src/mms/file_service.cpp"}; + const std::vector forbidden{ + "", "", "std::filesystem", "std::ifstream", + "std::ofstream", "winsock", "sys/socket", "TcpMmsByteTransport", + "std::thread"}; + for (const auto& path : files) { + std::ifstream input{path, std::ios::binary}; + CHECK(input.good()); + const std::string source{ + std::istreambuf_iterator{input}, std::istreambuf_iterator{}}; + for (const auto& token : forbidden) { + CHECK(source.find(token) == std::string::npos); + } + } +} + } // namespace int main() { @@ -605,6 +626,7 @@ int main() { {"adaptive forbids retry after read", adaptive_fallback_is_forbidden_after_any_read}, {"cancellation cleanup", cancellation_after_open_still_closes}, {"timeout evidence", timeout_preserves_evidence_when_association_drops}, + {"portable dependency boundary", portable_core_has_no_host_file_or_socket_dependencies}, }; std::size_t passed = 0U; From 9cb26309bd2dc8d94b97ffbd08383430ec1c9aff Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 11 Aug 2026 06:07:19 +0700 Subject: [PATCH 4/4] Clean file transfer CLI JSON formatting --- tools/file_transfer.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/file_transfer.cpp b/tools/file_transfer.cpp index e41fa2552..638a4e7e7 100644 --- a/tools/file_transfer.cpp +++ b/tools/file_transfer.cpp @@ -253,8 +253,8 @@ class ConsoleProgress final : public mms::MmsFileProgressSink { output << "{\"schemaVersion\":\"ariec61850-mms-file-directory-v1\"," << "\"readOnly\":true,\"success\":" << (result.success ? "true" : "false") << ',' - << "\"directory\":\"" << json_escape(result.directory_name) << "\"," - << "\"message\":\"" << json_escape(result.message) << "\"," + << "\"directory\":\"" << json_escape(result.directory_name) << "\"," + << "\"message\":\"" << json_escape(result.message) << "\"," << "\"pageCount\":" << result.pages.size() << ',' << "\"entryCount\":" << result.entries.size() << ",\"pages\":["; for (std::size_t index = 0U; index < result.pages.size(); ++index) { @@ -285,11 +285,11 @@ class ConsoleProgress final : public mms::MmsFileProgressSink { [[nodiscard]] std::string transfer_json( const mms::MmsFileTransferResult& result) { std::ostringstream output; - output << "{\"schemaVersion\":\"ariec61850-mms-file-transfer-v1\"," + output << "{\"schemaVersion\":\"ariec61850-mms-file-transfer-v1\"," << "\"readOnly\":true,\"success\":" << (result.success ? "true" : "false") << ',' - << "\"remotePath\":\"" << json_escape(result.remote_path) << "\"," - << "\"message\":\"" << json_escape(result.message) << "\"," + << "\"remotePath\":\"" << json_escape(result.remote_path) << "\"," + << "\"message\":\"" << json_escape(result.message) << "\"," << "\"bytesTransferred\":" << result.bytes_transferred << ','; if (result.expected_bytes) output << "\"expectedBytes\":" << *result.expected_bytes << ','; else output << "\"expectedBytes\":null,";