From 72c74b74a2ef49398ea8c840243d61e00627a3b2 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 11 Aug 2026 05:50:35 +0700 Subject: [PATCH 01/66] 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 02/66] 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 03/66] 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 04/66] 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,"; From 23c9be6852f9ceb3c16663bdf3ae4041268a3683 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:25:52 +0700 Subject: [PATCH 05/66] Add portable guarded IEC 61850 control model core --- .../ariec61850/control/guarded_control.hpp | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 include/ariec61850/control/guarded_control.hpp diff --git a/include/ariec61850/control/guarded_control.hpp b/include/ariec61850/control/guarded_control.hpp new file mode 100644 index 000000000..6a3bfc51b --- /dev/null +++ b/include/ariec61850/control/guarded_control.hpp @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include +#include + +namespace ar::iec61850::control { + +enum class ControlModel : std::uint8_t { + status_only = 0U, + direct_normal = 1U, + select_before_operate_normal = 2U, + direct_enhanced = 3U, + select_before_operate_enhanced = 4U, + unknown = 0xFFU, +}; + +enum class ControlAction : std::uint8_t { + select, + select_with_value, + operate, + cancel, +}; + +enum class OriginCategory : std::uint8_t { + not_supported = 0U, + bay_control = 1U, + station_control = 2U, + remote_control = 3U, + automatic_bay = 4U, + automatic_station = 5U, + automatic_remote = 6U, + maintenance = 7U, + process = 8U, +}; + +enum class ControlError : std::uint8_t { + no_error = 0U, + unknown = 1U, + timeout_test = 2U, + operator_test = 3U, +}; + +enum class AddCause : std::uint8_t { + unknown = 0U, + not_supported = 1U, + blocked_by_switching_hierarchy = 2U, + select_failed = 3U, + invalid_position = 4U, + position_reached = 5U, + parameter_change_in_execution = 6U, + step_limit = 7U, + blocked_by_mode = 8U, + blocked_by_process = 9U, + blocked_by_interlocking = 10U, + blocked_by_synchrocheck = 11U, + command_already_in_execution = 12U, + blocked_by_health = 13U, + one_of_n_control = 14U, + abortion_by_cancel = 15U, + time_limit_over = 16U, + abortion_by_trip = 17U, + object_not_selected = 18U, + object_already_selected = 19U, + no_access_authority = 20U, + ended_with_overshoot = 21U, + abortion_due_to_deviation = 22U, + abortion_by_communication_loss = 23U, + abortion_by_command = 24U, + none = 25U, + inconsistent_parameters = 26U, + locked_by_other_client = 27U, +}; + +enum class GuardedControlStatus : std::uint8_t { + ok, + accepted_waiting_termination, + positive_termination, + negative_termination, + unsupported, + invalid_identity, + invalid_sequence, + access_denied, + object_not_selected, + object_already_selected, + locked_by_other_client, + selection_expired, + sequence_mismatch, + command_already_in_execution, + no_active_selection, + no_pending_termination, +}; + +struct ControlObjectReference final { + std::string_view domain{}; + std::string_view logical_node{}; + std::string_view data_object_path{}; + + [[nodiscard]] constexpr bool valid() const noexcept { + return !domain.empty() && !logical_node.empty() && !data_object_path.empty(); + } +}; + +// Parse the public control-object root used by the C# oracle: LD/LN.DO[.subDO]. +// Service leaves (Oper/SBO/SBOw/Cancel/ctlModel/etc.) are intentionally rejected. +[[nodiscard]] bool try_parse_control_object_reference( + std::string_view reference, + ControlObjectReference& output) noexcept; + +// Build an MMS variable item such as LN$CF$DO$ctlModel or LN$CO$DO$Oper. +// The domain is returned separately by ControlObjectReference::domain. +[[nodiscard]] bool build_control_item( + const ControlObjectReference& object, + std::string_view functional_constraint, + std::string_view service_leaf, + std::span destination, + std::size_t& bytes_written) noexcept; + +struct ControlClientIdentity final { + std::uint64_t association_id{}; + + [[nodiscard]] constexpr bool valid() const noexcept { + return association_id != 0U; + } +}; + +// `control_value` is one exact encoded MMS Data value (not an MMS Write PDU). +// The planner fingerprints it at selection/operate boundaries rather than +// allocating/copying an unbounded vendor-specific value. +struct ControlSequenceView final { + std::span control_value{}; + OriginCategory origin_category{OriginCategory::station_control}; + std::span origin_identifier{}; + std::uint8_t control_number{}; // 0 => association-local auto allocation. + std::uint64_t timestamp_token{}; // normalized T identity supplied by caller. + std::uint64_t operate_at_token{}; // 0 => immediate, otherwise normalized operTm. + bool test{}; + bool interlock_check{}; + bool synchro_check{}; + + [[nodiscard]] constexpr bool valid() const noexcept { + return !control_value.empty() && origin_identifier.size() <= 64U && + static_cast(origin_category) <= + static_cast(OriginCategory::process); + } +}; + +struct GuardedControlDecision final { + GuardedControlStatus status{GuardedControlStatus::unsupported}; + ControlAction action{ControlAction::operate}; + std::uint8_t control_number{}; + ControlError control_error{ControlError::no_error}; + AddCause add_cause{AddCause::none}; + + [[nodiscard]] constexpr bool accepted() const noexcept { + return status == GuardedControlStatus::ok || + status == GuardedControlStatus::accepted_waiting_termination || + status == GuardedControlStatus::positive_termination; + } +}; + +using ControlAuthorizationCallback = bool (*)( + void* context, + ControlAction action, + const ControlObjectReference& object, + const ControlClientIdentity& client, + const ControlSequenceView& sequence) noexcept; + +struct GuardedControlPolicy final { + std::uint64_t sbo_timeout_ms{10'000U}; + void* authorization_context{}; + ControlAuthorizationCallback authorize{}; // nullptr => fail closed. +}; + +struct GuardedControlStateView final { + ControlModel model{ControlModel::unknown}; + bool selected{}; + bool waiting_for_termination{}; + std::uint64_t owner_association_id{}; + std::uint64_t selection_expires_at_ms{}; + std::uint8_t active_control_number{}; +}; + +// Allocation-free safety planner for one controllable Data Object. It models +// the immutable client sequence used by ARIEC61850 and deliberately does not +// perform network I/O. A transport layer executes the returned service action. +class GuardedControlPlanner final { +public: + GuardedControlPlanner( + ControlObjectReference object, + ControlModel model, + GuardedControlPolicy policy = {}) noexcept; + + [[nodiscard]] bool valid() const noexcept; + [[nodiscard]] GuardedControlDecision select( + const ControlClientIdentity& client, + const ControlSequenceView& sequence, + std::uint64_t now_ms) noexcept; + [[nodiscard]] GuardedControlDecision select_with_value( + const ControlClientIdentity& client, + const ControlSequenceView& sequence, + std::uint64_t now_ms) noexcept; + [[nodiscard]] GuardedControlDecision operate( + const ControlClientIdentity& client, + const ControlSequenceView& sequence, + std::uint64_t now_ms) noexcept; + [[nodiscard]] GuardedControlDecision cancel( + const ControlClientIdentity& client, + std::uint64_t now_ms) noexcept; + + // Enhanced-security completion boundary. MMS Write acceptance alone leaves + // the planner waiting; only matching CommandTermination completes it. + [[nodiscard]] GuardedControlDecision command_termination( + ControlError error, + AddCause add_cause) noexcept; + + void on_association_closed(std::uint64_t association_id) noexcept; + void tick(std::uint64_t now_ms) noexcept; + [[nodiscard]] GuardedControlStateView state(std::uint64_t now_ms) noexcept; + +private: + struct SequenceFingerprint final { + std::uint64_t value_hash{}; + std::uint64_t origin_hash{}; + std::size_t value_bytes{}; + std::size_t origin_bytes{}; + OriginCategory origin_category{OriginCategory::station_control}; + std::uint8_t control_number{}; + std::uint64_t timestamp_token{}; + std::uint64_t operate_at_token{}; + bool test{}; + bool interlock_check{}; + bool synchro_check{}; + }; + + [[nodiscard]] GuardedControlDecision reject( + ControlAction action, + GuardedControlStatus status, + AddCause add_cause) const noexcept; + [[nodiscard]] bool authorized( + ControlAction action, + const ControlClientIdentity& client, + const ControlSequenceView& sequence) const noexcept; + [[nodiscard]] SequenceFingerprint fingerprint( + const ControlSequenceView& sequence, + std::uint8_t assigned_control_number) const noexcept; + [[nodiscard]] bool sequence_matches( + const ControlSequenceView& sequence, + const SequenceFingerprint& selected) const noexcept; + [[nodiscard]] std::uint8_t assign_control_number( + const ControlSequenceView& sequence) noexcept; + void expire_selection(std::uint64_t now_ms) noexcept; + void clear_active() noexcept; + + ControlObjectReference object_{}; + ControlModel model_{ControlModel::unknown}; + GuardedControlPolicy policy_{}; + SequenceFingerprint active_sequence_{}; + std::uint64_t owner_association_id_{}; + std::uint64_t selection_expires_at_ms_{}; + std::uint8_t next_control_number_{}; + bool selected_{}; + bool waiting_for_termination_{}; +}; + +[[nodiscard]] constexpr bool model_requires_select(const ControlModel model) noexcept { + return model == ControlModel::select_before_operate_normal || + model == ControlModel::select_before_operate_enhanced; +} + +[[nodiscard]] constexpr bool model_is_enhanced(const ControlModel model) noexcept { + return model == ControlModel::direct_enhanced || + model == ControlModel::select_before_operate_enhanced; +} + +} // namespace ar::iec61850::control From 563120ce56b1f03b2c2817528b18421cdd986820 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:26:56 +0700 Subject: [PATCH 06/66] Implement guarded Direct and SBO control planner --- src/control/guarded_control.cpp | 516 ++++++++++++++++++++++++++++++++ 1 file changed, 516 insertions(+) create mode 100644 src/control/guarded_control.cpp diff --git a/src/control/guarded_control.cpp b/src/control/guarded_control.cpp new file mode 100644 index 000000000..3fbb36d35 --- /dev/null +++ b/src/control/guarded_control.cpp @@ -0,0 +1,516 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/guarded_control.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::control { +namespace { + +constexpr std::uint64_t kFnvOffset = 14695981039346656037ULL; +constexpr std::uint64_t kFnvPrime = 1099511628211ULL; + +[[nodiscard]] constexpr char ascii_lower(const char value) noexcept { + return value >= 'A' && value <= 'Z' + ? static_cast(value - 'A' + 'a') + : value; +} + +[[nodiscard]] bool ascii_equal( + const std::string_view left, + const std::string_view right) noexcept { + if (left.size() != right.size()) { + return false; + } + for (std::size_t index = 0U; index < left.size(); ++index) { + if (ascii_lower(left[index]) != ascii_lower(right[index])) { + return false; + } + } + return true; +} + +[[nodiscard]] bool service_leaf(const std::string_view segment) noexcept { + constexpr std::array leaves{ + "ctlModel", "ctlVal", "ctlNum", "stSeld", "Oper", "SBO", "SBOw", + "Cancel", "origin", "orCat", "orIdent", "T", "Test", "Check", "operTm"}; + return std::any_of(leaves.begin(), leaves.end(), [&](const auto leaf) { + return ascii_equal(segment, leaf); + }); +} + +[[nodiscard]] std::string_view trim(const std::string_view input) noexcept { + std::size_t begin = 0U; + while (begin < input.size() && + (input[begin] == ' ' || input[begin] == '\t' || + input[begin] == '\r' || input[begin] == '\n')) { + ++begin; + } + std::size_t end = input.size(); + while (end > begin && + (input[end - 1U] == ' ' || input[end - 1U] == '\t' || + input[end - 1U] == '\r' || input[end - 1U] == '\n')) { + --end; + } + return input.substr(begin, end - begin); +} + +[[nodiscard]] bool path_separator(const char value) noexcept { + return value == '.' || value == '$'; +} + +[[nodiscard]] std::uint64_t hash_bytes( + const std::span bytes) noexcept { + std::uint64_t hash = kFnvOffset; + for (const auto byte : bytes) { + hash ^= byte; + hash *= kFnvPrime; + } + return hash; +} + +[[nodiscard]] bool same_fingerprint( + const GuardedControlPlanner::SequenceFingerprint& left, + const GuardedControlPlanner::SequenceFingerprint& right) noexcept; + +} // namespace + +bool try_parse_control_object_reference( + const std::string_view reference, + ControlObjectReference& output) noexcept { + output = {}; + const auto normalized = trim(reference); + const auto slash = normalized.find('/'); + if (slash == std::string_view::npos || slash == 0U || + slash + 1U >= normalized.size() || + normalized.find('/', slash + 1U) != std::string_view::npos) { + return false; + } + + const auto domain = trim(normalized.substr(0U, slash)); + const auto path = trim(normalized.substr(slash + 1U)); + if (domain.empty() || path.empty()) { + return false; + } + + std::size_t separator = std::string_view::npos; + for (std::size_t index = 0U; index < path.size(); ++index) { + if (path_separator(path[index])) { + separator = index; + break; + } + } + if (separator == std::string_view::npos || separator == 0U || + separator + 1U >= path.size()) { + return false; + } + + const auto logical_node = path.substr(0U, separator); + const auto data_object_path = path.substr(separator + 1U); + if (logical_node.empty() || data_object_path.empty()) { + return false; + } + + std::size_t segment_begin = 0U; + while (segment_begin < data_object_path.size()) { + auto segment_end = segment_begin; + while (segment_end < data_object_path.size() && + !path_separator(data_object_path[segment_end])) { + if (data_object_path[segment_end] == '/') { + return false; + } + ++segment_end; + } + if (segment_end == segment_begin || + service_leaf(data_object_path.substr(segment_begin, segment_end - segment_begin))) { + return false; + } + if (segment_end == data_object_path.size()) { + break; + } + segment_begin = segment_end + 1U; + if (segment_begin == data_object_path.size()) { + return false; + } + } + + output = {domain, logical_node, data_object_path}; + return output.valid(); +} + +bool build_control_item( + const ControlObjectReference& object, + const std::string_view functional_constraint, + const std::string_view service_leaf_name, + const std::span destination, + std::size_t& bytes_written) noexcept { + bytes_written = 0U; + if (!object.valid() || functional_constraint.empty() || service_leaf_name.empty() || + functional_constraint.find('$') != std::string_view::npos || + service_leaf_name.find('$') != std::string_view::npos || + service_leaf_name.find('.') != std::string_view::npos) { + return false; + } + + const auto required = object.logical_node.size() + 1U + + functional_constraint.size() + 1U + object.data_object_path.size() + + 1U + service_leaf_name.size(); + if (destination.size() < required) { + bytes_written = required; + return false; + } + + std::size_t offset = 0U; + const auto append = [&](const std::string_view text) { + for (const auto character : text) { + destination[offset++] = path_separator(character) ? '$' : character; + } + }; + append(object.logical_node); + destination[offset++] = '$'; + append(functional_constraint); + destination[offset++] = '$'; + append(object.data_object_path); + destination[offset++] = '$'; + append(service_leaf_name); + bytes_written = offset; + return true; +} + +GuardedControlPlanner::GuardedControlPlanner( + const ControlObjectReference object, + const ControlModel model, + const GuardedControlPolicy policy) noexcept + : object_{object}, model_{model}, policy_{policy} {} + +bool GuardedControlPlanner::valid() const noexcept { + return object_.valid() && + model_ != ControlModel::unknown && + static_cast(model_) <= + static_cast(ControlModel::select_before_operate_enhanced); +} + +GuardedControlDecision GuardedControlPlanner::reject( + const ControlAction action, + const GuardedControlStatus status, + const AddCause add_cause) const noexcept { + return {status, action, active_sequence_.control_number, ControlError::no_error, add_cause}; +} + +bool GuardedControlPlanner::authorized( + const ControlAction action, + const ControlClientIdentity& client, + const ControlSequenceView& sequence) const noexcept { + return policy_.authorize != nullptr && + policy_.authorize(policy_.authorization_context, action, object_, client, sequence); +} + +GuardedControlPlanner::SequenceFingerprint GuardedControlPlanner::fingerprint( + const ControlSequenceView& sequence, + const std::uint8_t assigned_control_number) const noexcept { + SequenceFingerprint result; + result.value_hash = hash_bytes(sequence.control_value); + result.origin_hash = hash_bytes(sequence.origin_identifier); + result.value_bytes = sequence.control_value.size(); + result.origin_bytes = sequence.origin_identifier.size(); + result.origin_category = sequence.origin_category; + result.control_number = assigned_control_number; + result.timestamp_token = sequence.timestamp_token; + result.operate_at_token = sequence.operate_at_token; + result.test = sequence.test; + result.interlock_check = sequence.interlock_check; + result.synchro_check = sequence.synchro_check; + return result; +} + +bool GuardedControlPlanner::sequence_matches( + const ControlSequenceView& sequence, + const SequenceFingerprint& selected) const noexcept { + const auto candidate = fingerprint( + sequence, + sequence.control_number == 0U ? selected.control_number : sequence.control_number); + return candidate.value_hash == selected.value_hash && + candidate.origin_hash == selected.origin_hash && + candidate.value_bytes == selected.value_bytes && + candidate.origin_bytes == selected.origin_bytes && + candidate.origin_category == selected.origin_category && + candidate.control_number == selected.control_number && + candidate.timestamp_token == selected.timestamp_token && + candidate.operate_at_token == selected.operate_at_token && + candidate.test == selected.test && + candidate.interlock_check == selected.interlock_check && + candidate.synchro_check == selected.synchro_check; +} + +std::uint8_t GuardedControlPlanner::assign_control_number( + const ControlSequenceView& sequence) noexcept { + if (sequence.control_number != 0U) { + return sequence.control_number; + } + next_control_number_ = next_control_number_ >= 255U + ? 1U + : static_cast(next_control_number_ + 1U); + return next_control_number_; +} + +void GuardedControlPlanner::clear_active() noexcept { + active_sequence_ = {}; + owner_association_id_ = 0U; + selection_expires_at_ms_ = 0U; + selected_ = false; + waiting_for_termination_ = false; +} + +void GuardedControlPlanner::expire_selection(const std::uint64_t now_ms) noexcept { + if (selected_ && selection_expires_at_ms_ != 0U && now_ms >= selection_expires_at_ms_) { + clear_active(); + } +} + +GuardedControlDecision GuardedControlPlanner::select( + const ControlClientIdentity& client, + const ControlSequenceView& sequence, + const std::uint64_t now_ms) noexcept { + if (model_ != ControlModel::select_before_operate_normal) { + return reject(ControlAction::select, GuardedControlStatus::unsupported, AddCause::not_supported); + } + if (!client.valid()) { + return reject(ControlAction::select, GuardedControlStatus::invalid_identity, AddCause::no_access_authority); + } + if (!sequence.valid()) { + return reject(ControlAction::select, GuardedControlStatus::invalid_sequence, AddCause::inconsistent_parameters); + } + expire_selection(now_ms); + if (waiting_for_termination_) { + return reject(ControlAction::select, GuardedControlStatus::command_already_in_execution, + AddCause::command_already_in_execution); + } + if (selected_) { + return reject(ControlAction::select, + owner_association_id_ == client.association_id + ? GuardedControlStatus::object_already_selected + : GuardedControlStatus::locked_by_other_client, + owner_association_id_ == client.association_id + ? AddCause::object_already_selected + : AddCause::locked_by_other_client); + } + if (!authorized(ControlAction::select, client, sequence)) { + return reject(ControlAction::select, GuardedControlStatus::access_denied, + AddCause::no_access_authority); + } + + const auto control_number = assign_control_number(sequence); + active_sequence_ = fingerprint(sequence, control_number); + owner_association_id_ = client.association_id; + selection_expires_at_ms_ = policy_.sbo_timeout_ms == 0U + ? 0U + : (now_ms > std::numeric_limits::max() - policy_.sbo_timeout_ms + ? std::numeric_limits::max() + : now_ms + policy_.sbo_timeout_ms); + selected_ = true; + return {GuardedControlStatus::ok, ControlAction::select, control_number, + ControlError::no_error, AddCause::none}; +} + +GuardedControlDecision GuardedControlPlanner::select_with_value( + const ControlClientIdentity& client, + const ControlSequenceView& sequence, + const std::uint64_t now_ms) noexcept { + if (model_ != ControlModel::select_before_operate_enhanced) { + return reject(ControlAction::select_with_value, GuardedControlStatus::unsupported, + AddCause::not_supported); + } + if (!client.valid()) { + return reject(ControlAction::select_with_value, GuardedControlStatus::invalid_identity, + AddCause::no_access_authority); + } + if (!sequence.valid()) { + return reject(ControlAction::select_with_value, GuardedControlStatus::invalid_sequence, + AddCause::inconsistent_parameters); + } + expire_selection(now_ms); + if (selected_) { + return reject(ControlAction::select_with_value, + owner_association_id_ == client.association_id + ? GuardedControlStatus::object_already_selected + : GuardedControlStatus::locked_by_other_client, + owner_association_id_ == client.association_id + ? AddCause::object_already_selected + : AddCause::locked_by_other_client); + } + if (!authorized(ControlAction::select_with_value, client, sequence)) { + return reject(ControlAction::select_with_value, GuardedControlStatus::access_denied, + AddCause::no_access_authority); + } + + const auto control_number = assign_control_number(sequence); + active_sequence_ = fingerprint(sequence, control_number); + owner_association_id_ = client.association_id; + selection_expires_at_ms_ = policy_.sbo_timeout_ms == 0U + ? 0U + : (now_ms > std::numeric_limits::max() - policy_.sbo_timeout_ms + ? std::numeric_limits::max() + : now_ms + policy_.sbo_timeout_ms); + selected_ = true; + return {GuardedControlStatus::ok, ControlAction::select_with_value, control_number, + ControlError::no_error, AddCause::none}; +} + +GuardedControlDecision GuardedControlPlanner::operate( + const ControlClientIdentity& client, + const ControlSequenceView& sequence, + const std::uint64_t now_ms) noexcept { + if (!valid() || model_ == ControlModel::status_only) { + return reject(ControlAction::operate, GuardedControlStatus::unsupported, + AddCause::not_supported); + } + if (!client.valid()) { + return reject(ControlAction::operate, GuardedControlStatus::invalid_identity, + AddCause::no_access_authority); + } + if (!sequence.valid()) { + return reject(ControlAction::operate, GuardedControlStatus::invalid_sequence, + AddCause::inconsistent_parameters); + } + if (waiting_for_termination_) { + return reject(ControlAction::operate, GuardedControlStatus::command_already_in_execution, + AddCause::command_already_in_execution); + } + + if (model_requires_select(model_)) { + if (selected_ && selection_expires_at_ms_ != 0U && now_ms >= selection_expires_at_ms_) { + clear_active(); + return reject(ControlAction::operate, GuardedControlStatus::selection_expired, + AddCause::time_limit_over); + } + if (!selected_) { + return reject(ControlAction::operate, GuardedControlStatus::object_not_selected, + AddCause::object_not_selected); + } + if (owner_association_id_ != client.association_id) { + return reject(ControlAction::operate, GuardedControlStatus::locked_by_other_client, + AddCause::locked_by_other_client); + } + if (!sequence_matches(sequence, active_sequence_)) { + clear_active(); + return reject(ControlAction::operate, GuardedControlStatus::sequence_mismatch, + AddCause::inconsistent_parameters); + } + } + + if (!authorized(ControlAction::operate, client, sequence)) { + if (model_requires_select(model_)) { + clear_active(); + } + return reject(ControlAction::operate, GuardedControlStatus::access_denied, + AddCause::no_access_authority); + } + + std::uint8_t control_number{}; + if (model_requires_select(model_)) { + control_number = active_sequence_.control_number; + selected_ = false; + selection_expires_at_ms_ = 0U; + } else { + control_number = assign_control_number(sequence); + active_sequence_ = fingerprint(sequence, control_number); + owner_association_id_ = client.association_id; + } + + if (model_is_enhanced(model_)) { + waiting_for_termination_ = true; + return {GuardedControlStatus::accepted_waiting_termination, ControlAction::operate, + control_number, ControlError::no_error, AddCause::none}; + } + + const GuardedControlDecision result{ + GuardedControlStatus::ok, ControlAction::operate, control_number, + ControlError::no_error, AddCause::none}; + clear_active(); + return result; +} + +GuardedControlDecision GuardedControlPlanner::cancel( + const ControlClientIdentity& client, + const std::uint64_t now_ms) noexcept { + if (!model_requires_select(model_)) { + return reject(ControlAction::cancel, GuardedControlStatus::unsupported, + AddCause::not_supported); + } + if (!client.valid()) { + return reject(ControlAction::cancel, GuardedControlStatus::invalid_identity, + AddCause::no_access_authority); + } + if (selected_ && selection_expires_at_ms_ != 0U && now_ms >= selection_expires_at_ms_) { + clear_active(); + return reject(ControlAction::cancel, GuardedControlStatus::selection_expired, + AddCause::time_limit_over); + } + if (!selected_) { + return reject(ControlAction::cancel, GuardedControlStatus::no_active_selection, + AddCause::object_not_selected); + } + if (owner_association_id_ != client.association_id) { + return reject(ControlAction::cancel, GuardedControlStatus::locked_by_other_client, + AddCause::locked_by_other_client); + } + + // Cancel authorization is evaluated with a minimal view because the exact + // selected sequence is already locked inside the planner. + const ControlSequenceView authorization_sequence{ + std::span{}, OriginCategory::station_control, {}}; + if (policy_.authorize == nullptr || + !policy_.authorize(policy_.authorization_context, ControlAction::cancel, + object_, client, authorization_sequence)) { + return reject(ControlAction::cancel, GuardedControlStatus::access_denied, + AddCause::no_access_authority); + } + + const auto control_number = active_sequence_.control_number; + clear_active(); + return {GuardedControlStatus::ok, ControlAction::cancel, control_number, + ControlError::no_error, AddCause::abortion_by_cancel}; +} + +GuardedControlDecision GuardedControlPlanner::command_termination( + const ControlError error, + const AddCause add_cause) noexcept { + if (!waiting_for_termination_) { + return reject(ControlAction::operate, GuardedControlStatus::no_pending_termination, + AddCause::unknown); + } + const auto control_number = active_sequence_.control_number; + const bool positive = error == ControlError::no_error && + (add_cause == AddCause::unknown || add_cause == AddCause::none); + const GuardedControlDecision result{ + positive ? GuardedControlStatus::positive_termination + : GuardedControlStatus::negative_termination, + ControlAction::operate, + control_number, + error, + add_cause}; + clear_active(); + return result; +} + +void GuardedControlPlanner::on_association_closed( + const std::uint64_t association_id) noexcept { + if (association_id != 0U && owner_association_id_ == association_id) { + clear_active(); + } +} + +void GuardedControlPlanner::tick(const std::uint64_t now_ms) noexcept { + expire_selection(now_ms); +} + +GuardedControlStateView GuardedControlPlanner::state(const std::uint64_t now_ms) noexcept { + expire_selection(now_ms); + return {model_, selected_, waiting_for_termination_, owner_association_id_, + selection_expires_at_ms_, active_sequence_.control_number}; +} + +} // namespace ar::iec61850::control From 456ad8b5b3b17ea4c0d1505ee67663476b510f25 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:29:16 +0700 Subject: [PATCH 07/66] Harden guarded control pending and expiry semantics --- src/control/guarded_control.cpp | 39 ++++++++++++++++----------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/src/control/guarded_control.cpp b/src/control/guarded_control.cpp index 3fbb36d35..662e072d9 100644 --- a/src/control/guarded_control.cpp +++ b/src/control/guarded_control.cpp @@ -4,7 +4,6 @@ #include #include -#include #include namespace ar::iec61850::control { @@ -72,10 +71,6 @@ constexpr std::uint64_t kFnvPrime = 1099511628211ULL; return hash; } -[[nodiscard]] bool same_fingerprint( - const GuardedControlPlanner::SequenceFingerprint& left, - const GuardedControlPlanner::SequenceFingerprint& right) noexcept; - } // namespace bool try_parse_control_object_reference( @@ -332,6 +327,11 @@ GuardedControlDecision GuardedControlPlanner::select_with_value( AddCause::inconsistent_parameters); } expire_selection(now_ms); + if (waiting_for_termination_) { + return reject(ControlAction::select_with_value, + GuardedControlStatus::command_already_in_execution, + AddCause::command_already_in_execution); + } if (selected_) { return reject(ControlAction::select_with_value, owner_association_id_ == client.association_id @@ -382,9 +382,10 @@ GuardedControlDecision GuardedControlPlanner::operate( if (model_requires_select(model_)) { if (selected_ && selection_expires_at_ms_ != 0U && now_ms >= selection_expires_at_ms_) { + const auto control_number = active_sequence_.control_number; clear_active(); - return reject(ControlAction::operate, GuardedControlStatus::selection_expired, - AddCause::time_limit_over); + return {GuardedControlStatus::selection_expired, ControlAction::operate, + control_number, ControlError::no_error, AddCause::time_limit_over}; } if (!selected_) { return reject(ControlAction::operate, GuardedControlStatus::object_not_selected, @@ -395,18 +396,20 @@ GuardedControlDecision GuardedControlPlanner::operate( AddCause::locked_by_other_client); } if (!sequence_matches(sequence, active_sequence_)) { + const auto control_number = active_sequence_.control_number; clear_active(); - return reject(ControlAction::operate, GuardedControlStatus::sequence_mismatch, - AddCause::inconsistent_parameters); + return {GuardedControlStatus::sequence_mismatch, ControlAction::operate, + control_number, ControlError::no_error, AddCause::inconsistent_parameters}; } } if (!authorized(ControlAction::operate, client, sequence)) { + const auto control_number = active_sequence_.control_number; if (model_requires_select(model_)) { clear_active(); } - return reject(ControlAction::operate, GuardedControlStatus::access_denied, - AddCause::no_access_authority); + return {GuardedControlStatus::access_denied, ControlAction::operate, + control_number, ControlError::no_error, AddCause::no_access_authority}; } std::uint8_t control_number{}; @@ -445,9 +448,10 @@ GuardedControlDecision GuardedControlPlanner::cancel( AddCause::no_access_authority); } if (selected_ && selection_expires_at_ms_ != 0U && now_ms >= selection_expires_at_ms_) { + const auto control_number = active_sequence_.control_number; clear_active(); - return reject(ControlAction::cancel, GuardedControlStatus::selection_expired, - AddCause::time_limit_over); + return {GuardedControlStatus::selection_expired, ControlAction::cancel, + control_number, ControlError::no_error, AddCause::time_limit_over}; } if (!selected_) { return reject(ControlAction::cancel, GuardedControlStatus::no_active_selection, @@ -458,13 +462,8 @@ GuardedControlDecision GuardedControlPlanner::cancel( AddCause::locked_by_other_client); } - // Cancel authorization is evaluated with a minimal view because the exact - // selected sequence is already locked inside the planner. - const ControlSequenceView authorization_sequence{ - std::span{}, OriginCategory::station_control, {}}; - if (policy_.authorize == nullptr || - !policy_.authorize(policy_.authorization_context, ControlAction::cancel, - object_, client, authorization_sequence)) { + const ControlSequenceView authorization_sequence{}; + if (!authorized(ControlAction::cancel, client, authorization_sequence)) { return reject(ControlAction::cancel, GuardedControlStatus::access_denied, AddCause::no_access_authority); } From 1d9ad4c71a362a4c49b47885e53ad1cfa6bf84c5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:30:02 +0700 Subject: [PATCH 08/66] Add guarded control hard-profile oracle --- .../control_guarded_hard_profile_smoke.cpp | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 embedded/control_guarded_hard_profile_smoke.cpp diff --git a/embedded/control_guarded_hard_profile_smoke.cpp b/embedded/control_guarded_hard_profile_smoke.cpp new file mode 100644 index 000000000..f7272cb95 --- /dev/null +++ b/embedded/control_guarded_hard_profile_smoke.cpp @@ -0,0 +1,295 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/guarded_control.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850::control; + +bool allow_all( + void*, + ControlAction, + const ControlObjectReference&, + const ControlClientIdentity&, + const ControlSequenceView&) noexcept { + return true; +} + +struct AuthState final { + bool allow{true}; + std::uint32_t calls{}; +}; + +bool controlled_authorize( + void* context, + ControlAction, + const ControlObjectReference&, + const ControlClientIdentity&, + const ControlSequenceView&) noexcept { + auto* state = static_cast(context); + if (state == nullptr) { + return false; + } + ++state->calls; + return state->allow; +} + +ControlSequenceView sequence( + const std::span value, + const std::span origin, + const std::uint8_t control_number = 0U, + const bool synchro = true, + const bool interlock = true) noexcept { + ControlSequenceView result; + result.control_value = value; + result.origin_category = OriginCategory::station_control; + result.origin_identifier = origin; + result.control_number = control_number; + result.timestamp_token = 0x1122334455667788ULL; + result.operate_at_token = 0U; + result.test = false; + result.interlock_check = interlock; + result.synchro_check = synchro; + return result; +} + +} // namespace + +int main() { + ControlObjectReference object; + if (!try_parse_control_object_reference(" LD0/CSWI1.Pos ", object) || + object.domain != "LD0" || object.logical_node != "CSWI1" || + object.data_object_path != "Pos") { + return 1; + } + ControlObjectReference rejected; + if (try_parse_control_object_reference("LD0/CSWI1.Pos.Oper", rejected) || + try_parse_control_object_reference("LD0/CSWI1.Pos.Cancel", rejected) || + try_parse_control_object_reference("LD0/CSWI1", rejected)) { + return 2; + } + + std::array item{}; + std::size_t item_bytes{}; + if (!build_control_item(object, "CF", "ctlModel", item, item_bytes) || + std::string_view{item.data(), item_bytes} != "CSWI1$CF$Pos$ctlModel" || + !build_control_item(object, "CO", "Oper", item, item_bytes) || + std::string_view{item.data(), item_bytes} != "CSWI1$CO$Pos$Oper") { + return 3; + } + std::array tiny{}; + if (build_control_item(object, "CO", "Oper", tiny, item_bytes) || + item_bytes <= tiny.size()) { + return 4; + } + + constexpr std::array on_value{0x83U, 0x01U, 0xFFU}; + constexpr std::array off_value{0x83U, 0x01U, 0x00U}; + constexpr std::array origin_a{'H', 'M', 'I'}; + constexpr std::array origin_b{'E', 'N', 'G'}; + constexpr ControlClientIdentity client_a{0xA1U}; + constexpr ControlClientIdentity client_b{0xB2U}; + + // Default-deny is intentional: a usable control object still refuses an + // Oper unless an application supplies an explicit authorization callback. + GuardedControlPlanner denied{object, ControlModel::direct_normal}; + auto result = denied.operate(client_a, sequence(on_value, origin_a), 100U); + if (result.status != GuardedControlStatus::access_denied || + result.add_cause != AddCause::no_access_authority) { + return 5; + } + + GuardedControlPolicy allow_policy; + allow_policy.sbo_timeout_ms = 1'000U; + allow_policy.authorize = allow_all; + + // Direct-normal completion boundary is MMS service acceptance. ctlNum is + // association-local monotonic 1..255 and wraps without using zero. + GuardedControlPlanner direct{object, ControlModel::direct_normal, allow_policy}; + result = direct.operate(client_a, sequence(on_value, origin_a), 100U); + if (result.status != GuardedControlStatus::ok || result.control_number != 1U || + direct.state(100U).owner_association_id != 0U) { + return 6; + } + result = direct.operate(client_a, sequence(off_value, origin_a), 110U); + if (result.status != GuardedControlStatus::ok || result.control_number != 2U) { + return 7; + } + std::uint8_t last_ctl_num = result.control_number; + for (std::uint16_t index = 0U; index < 254U; ++index) { + result = direct.operate(client_a, sequence(on_value, origin_a), 120U + index); + if (!result.accepted()) { + return 8; + } + last_ctl_num = result.control_number; + } + if (last_ctl_num != 1U) { + return 9; + } + + // Direct-enhanced stays busy after Write acceptance until an explicit + // matching CommandTermination boundary is supplied by the upper layer. + GuardedControlPlanner direct_enhanced{ + object, ControlModel::direct_enhanced, allow_policy}; + result = direct_enhanced.operate(client_a, sequence(on_value, origin_a), 1'000U); + if (result.status != GuardedControlStatus::accepted_waiting_termination || + !direct_enhanced.state(1'000U).waiting_for_termination) { + return 10; + } + const auto busy = direct_enhanced.operate( + client_a, sequence(off_value, origin_a), 1'001U); + if (busy.status != GuardedControlStatus::command_already_in_execution || + busy.add_cause != AddCause::command_already_in_execution) { + return 11; + } + result = direct_enhanced.command_termination(ControlError::no_error, AddCause::none); + if (result.status != GuardedControlStatus::positive_termination || + direct_enhanced.state(1'002U).waiting_for_termination) { + return 12; + } + result = direct_enhanced.operate(client_a, sequence(on_value, origin_a), 1'003U); + if (result.status != GuardedControlStatus::accepted_waiting_termination) { + return 13; + } + result = direct_enhanced.command_termination( + ControlError::no_error, AddCause::blocked_by_interlocking); + if (result.status != GuardedControlStatus::negative_termination || + result.add_cause != AddCause::blocked_by_interlocking) { + return 14; + } + + // SBO normal: selection is per-object/per-association and the exact + // ctlVal/origin/ctlNum/T/Test/Check sequence is immutable until Oper/Cancel. + GuardedControlPlanner sbo{ + object, ControlModel::select_before_operate_normal, allow_policy}; + result = sbo.select(client_a, sequence(on_value, origin_a), 2'000U); + if (result.status != GuardedControlStatus::ok || result.control_number != 1U || + !sbo.state(2'000U).selected) { + return 15; + } + const auto selected_ctl_num = result.control_number; + result = sbo.select(client_b, sequence(on_value, origin_b), 2'001U); + if (result.status != GuardedControlStatus::locked_by_other_client || + result.add_cause != AddCause::locked_by_other_client) { + return 16; + } + result = sbo.operate(client_b, sequence(on_value, origin_a), 2'002U); + if (result.status != GuardedControlStatus::locked_by_other_client) { + return 17; + } + result = sbo.operate( + client_a, sequence(off_value, origin_a, selected_ctl_num), 2'003U); + if (result.status != GuardedControlStatus::sequence_mismatch || + result.control_number != selected_ctl_num || sbo.state(2'003U).selected) { + return 18; + } + + result = sbo.select(client_a, sequence(on_value, origin_a), 2'100U); + if (!result.accepted()) { + return 19; + } + result = sbo.operate(client_a, sequence(on_value, origin_a), 2'101U); + if (result.status != GuardedControlStatus::ok || result.control_number == 0U || + sbo.state(2'101U).selected) { + return 20; + } + + // Check bits are sequence identity. Changing synchro/interlock after select + // is rejected instead of silently operating with a stale selection. + result = sbo.select(client_a, sequence(on_value, origin_a), 2'200U); + if (!result.accepted()) { + return 21; + } + result = sbo.operate(client_a, sequence(on_value, origin_a, 0U, false, true), 2'201U); + if (result.status != GuardedControlStatus::sequence_mismatch) { + return 22; + } + + // Selection timeout is a hard boundary and maps to the C# oracle's + // time-limit-over semantics. + result = sbo.select(client_a, sequence(on_value, origin_a), 3'000U); + if (!result.accepted()) { + return 23; + } + result = sbo.operate(client_a, sequence(on_value, origin_a), 4'000U); + if (result.status != GuardedControlStatus::selection_expired || + result.add_cause != AddCause::time_limit_over) { + return 24; + } + + // Cancel is owner-only and releases the selection locally after service + // acceptance; the transport layer will encode the retained selected values. + result = sbo.select(client_a, sequence(on_value, origin_a), 5'000U); + if (!result.accepted()) { + return 25; + } + result = sbo.cancel(client_b, 5'001U); + if (result.status != GuardedControlStatus::locked_by_other_client) { + return 26; + } + result = sbo.cancel(client_a, 5'002U); + if (result.status != GuardedControlStatus::ok || + result.add_cause != AddCause::abortion_by_cancel || + sbo.state(5'002U).selected) { + return 27; + } + + // SBO enhanced uses SelectWithValue, then Oper, then CommandTermination. + GuardedControlPlanner sbo_enhanced{ + object, ControlModel::select_before_operate_enhanced, allow_policy}; + result = sbo_enhanced.select_with_value( + client_a, sequence(on_value, origin_a), 6'000U); + if (result.status != GuardedControlStatus::ok) { + return 28; + } + result = sbo_enhanced.operate(client_a, sequence(on_value, origin_a), 6'001U); + if (result.status != GuardedControlStatus::accepted_waiting_termination || + !sbo_enhanced.state(6'001U).waiting_for_termination) { + return 29; + } + result = sbo_enhanced.select_with_value( + client_a, sequence(off_value, origin_a), 6'002U); + if (result.status != GuardedControlStatus::command_already_in_execution) { + return 30; + } + sbo_enhanced.on_association_closed(client_a.association_id); + if (sbo_enhanced.state(6'003U).waiting_for_termination || + sbo_enhanced.state(6'003U).owner_association_id != 0U) { + return 31; + } + + // Authorization is rechecked at Oper. Losing permission between Select and + // Oper cancels local ownership rather than executing a previously approved command. + AuthState auth{}; + GuardedControlPolicy changing_policy; + changing_policy.sbo_timeout_ms = 1'000U; + changing_policy.authorization_context = &auth; + changing_policy.authorize = controlled_authorize; + GuardedControlPlanner guarded{ + object, ControlModel::select_before_operate_normal, changing_policy}; + result = guarded.select(client_a, sequence(on_value, origin_a), 7'000U); + if (!result.accepted() || auth.calls != 1U) { + return 32; + } + auth.allow = false; + result = guarded.operate(client_a, sequence(on_value, origin_a), 7'001U); + if (result.status != GuardedControlStatus::access_denied || + guarded.state(7'001U).selected || auth.calls != 2U) { + return 33; + } + + GuardedControlPlanner status_only{object, ControlModel::status_only, allow_policy}; + result = status_only.operate(client_a, sequence(on_value, origin_a), 8'000U); + if (result.status != GuardedControlStatus::unsupported || + result.add_cause != AddCause::not_supported) { + return 34; + } + + return 0; +} From 8ec399d81cd8f6b94bd78a61576f9836d14d1285 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:30:19 +0700 Subject: [PATCH 09/66] Add no-exception control hard profile --- embedded/control_hard_profile/CMakeLists.txt | 45 ++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 embedded/control_hard_profile/CMakeLists.txt diff --git a/embedded/control_hard_profile/CMakeLists.txt b/embedded/control_hard_profile/CMakeLists.txt new file mode 100644 index 000000000..d31ca61c1 --- /dev/null +++ b/embedded/control_hard_profile/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850ControlHardProfile + VERSION 0.1.0 + DESCRIPTION "No-exception guarded IEC 61850 control model profile" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +if(MSVC) + message(FATAL_ERROR "Control hard profile is validated with GCC/Clang -fno-exceptions.") +endif() + +add_executable(ariec61850_hard_guarded_control_smoke + ${CMAKE_CURRENT_LIST_DIR}/../control_guarded_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/control/guarded_control.cpp) + +target_compile_features(ariec61850_hard_guarded_control_smoke PRIVATE cxx_std_20) +target_include_directories(ariec61850_hard_guarded_control_smoke PRIVATE + ${ARIEC61850_ROOT}/include) +target_compile_definitions(ariec61850_hard_guarded_control_smoke PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0 + ARIEC61850_NO_EXCEPTIONS=1) +target_compile_options(ariec61850_hard_guarded_control_smoke PRIVATE + -Wall + -Wextra + -Wpedantic + -Wconversion + -Wsign-conversion + -Werror + -fno-exceptions + -fno-rtti) + +enable_testing() +add_test( + NAME ariec61850_hard_guarded_control_smoke + COMMAND ariec61850_hard_guarded_control_smoke) + +message(STATUS "arstack61850 Control hard profile: ctlModel Direct/SBO semantics=ON") +message(STATUS "arstack61850 Control hard profile: immutable selection sequence=ON") +message(STATUS "arstack61850 Control hard profile: enhanced CommandTermination boundary=ON") +message(STATUS "arstack61850 Control hard profile: authorization default-deny=ON") +message(STATUS "arstack61850 Control hard profile: automatic command retry=OFF") +message(STATUS "arstack61850 Control hard profile: exceptions/RTTI disabled=ON") From 0a4b4b5ae1d63c86d2797743cd19bb153b52d6a5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:30:42 +0700 Subject: [PATCH 10/66] Gate guarded control semantics in GCC and Clang --- .github/workflows/control-hard-profile-ci.yml | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 .github/workflows/control-hard-profile-ci.yml diff --git a/.github/workflows/control-hard-profile-ci.yml b/.github/workflows/control-hard-profile-ci.yml new file mode 100644 index 000000000..b3894e862 --- /dev/null +++ b/.github/workflows/control-hard-profile-ci.yml @@ -0,0 +1,81 @@ +name: Control Hard Profile CI + +on: + push: + branches: + - main + pull_request: + paths: + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'embedded/control_guarded_hard_profile_smoke.cpp' + - 'embedded/control_hard_profile/**' + - '.github/workflows/control-hard-profile-ci.yml' + +permissions: + contents: read + +jobs: + control-hard-profile: + name: Control Hard / ${{ matrix.compiler }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + compiler: [gcc, clang] + + steps: + - uses: actions/checkout@v4 + + - name: Select compiler + shell: bash + run: | + if [ "${{ matrix.compiler }}" = "clang" ]; then + echo "CC=clang" >> "$GITHUB_ENV" + echo "CXX=clang++" >> "$GITHUB_ENV" + else + echo "CC=gcc" >> "$GITHUB_ENV" + echo "CXX=g++" >> "$GITHUB_ENV" + fi + + - name: Configure control hard profile + run: cmake -S embedded/control_hard_profile -B build-control-hard + + - name: Build control hard profile + run: cmake --build build-control-hard --parallel + + - name: Run control smoke + run: >- + ctest --test-dir build-control-hard + --output-on-failure + --output-junit control-ctest.xml + + - name: Prove exception runtime is absent + shell: bash + run: | + set -euo pipefail + binary=build-control-hard/ariec61850_hard_guarded_control_smoke + if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then + echo "exception runtime symbol found in $binary" >&2 + exit 1 + fi + echo 'NO_EXCEPTION_RUNTIME=PASS' + + - name: Report footprint + shell: bash + run: | + set -euo pipefail + { + echo "compiler=${{ matrix.compiler }}" + size build-control-hard/ariec61850_hard_guarded_control_smoke + } | tee build-control-hard/control-footprint.txt + + - name: Upload control evidence + uses: actions/upload-artifact@v4 + with: + name: control-hard-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: | + build-control-hard/control-ctest.xml + build-control-hard/control-footprint.txt From 3f5766725b1bbf69f79b5e618a0a51f429da1381 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:34:09 +0700 Subject: [PATCH 11/66] Add live-type MMS control structure builder API --- .../control/mms_control_structure.hpp | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 include/ariec61850/control/mms_control_structure.hpp diff --git a/include/ariec61850/control/mms_control_structure.hpp b/include/ariec61850/control/mms_control_structure.hpp new file mode 100644 index 000000000..b667a7bbf --- /dev/null +++ b/include/ariec61850/control/mms_control_structure.hpp @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/control/guarded_control.hpp" +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::control { + +enum class DoublePointValue : std::uint8_t { + intermediate = 0U, + off = 1U, + on = 2U, + bad = 3U, +}; + +struct StepPosition final { + std::int64_t position{}; + bool transient{}; +}; + +enum class ControlValueKind : std::uint8_t { + boolean, + double_point, + integer, + unsigned_integer, + floating_point, + step_position, + raw_mms, +}; + +class ControlValue final { +public: + using Value = std::variant< + bool, + DoublePointValue, + std::int64_t, + std::uint64_t, + double, + StepPosition, + mms::MmsDataValue>; + + [[nodiscard]] static ControlValue boolean(bool value); + [[nodiscard]] static ControlValue double_point(DoublePointValue value); + [[nodiscard]] static ControlValue integer(std::int64_t value); + [[nodiscard]] static ControlValue unsigned_integer(std::uint64_t value); + [[nodiscard]] static ControlValue floating_point(double value); + [[nodiscard]] static ControlValue step_position(StepPosition value); + [[nodiscard]] static ControlValue raw_mms(mms::MmsDataValue value); + + [[nodiscard]] ControlValueKind kind() const noexcept { return kind_; } + [[nodiscard]] const Value& value() const noexcept { return value_; } + +private: + ControlValue(ControlValueKind kind, Value value) + : kind_{kind}, value_{std::move(value)} {} + + ControlValueKind kind_; + Value value_; +}; + +enum class MmsControlService : std::uint8_t { + operate, + select_with_value, + cancel, +}; + +enum class MmsControlBuildStatus : std::uint8_t { + ok, + invalid_service_specification, + unsupported_component, + missing_required_component, + type_mismatch, + shape_mismatch, + value_out_of_range, + timestamp_missing, + unsupported_control_value, +}; + +struct MmsControlBuildResult final { + MmsControlBuildStatus status{MmsControlBuildStatus::invalid_service_specification}; + std::optional value; + std::string path; + + [[nodiscard]] bool success() const noexcept { + return status == MmsControlBuildStatus::ok && value.has_value(); + } +}; + +struct MmsControlSequenceContext final { + ControlValue control_value{ControlValue::boolean(false)}; + OriginCategory origin_category{OriginCategory::station_control}; + std::vector origin_identifier; + std::uint8_t control_number{}; + std::optional timestamp; + std::optional operate_at; + bool test{}; + bool interlock_check{}; + bool synchro_check{}; +}; + +// Hosted/live-control builder. It mirrors the C# oracle's conservative policy: +// consume the live MMS TypeSpecification, preserve its component order, and +// reject unknown/vendor-specific command fields rather than guessing them. +class MmsControlStructureBuilder final { +public: + [[nodiscard]] static MmsControlBuildResult bind_control_value( + const ControlValue& value, + const mms::MmsTypeSpecification& specification); + + [[nodiscard]] static MmsControlBuildResult validate_value( + const mms::MmsDataValue& value, + const mms::MmsTypeSpecification& specification, + std::string path = "ctlVal"); + + [[nodiscard]] static MmsControlBuildResult build_operate( + const MmsControlSequenceContext& context, + const mms::MmsTypeSpecification& specification, + bool require_exact_named_fields = true); + + [[nodiscard]] static MmsControlBuildResult build_select_with_value( + const MmsControlSequenceContext& context, + const mms::MmsTypeSpecification& specification, + bool require_exact_named_fields = true); + + [[nodiscard]] static MmsControlBuildResult build_cancel( + const MmsControlSequenceContext& context, + const mms::MmsTypeSpecification& specification, + bool require_exact_named_fields = true); + +private: + [[nodiscard]] static MmsControlBuildResult build_service( + MmsControlService service, + const MmsControlSequenceContext& context, + const mms::MmsTypeSpecification& specification, + bool require_exact_named_fields); +}; + +} // namespace ar::iec61850::control From b5bba0e12f3a718e273a0a8af3f306399fe7712b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:35:37 +0700 Subject: [PATCH 12/66] Implement live-type Oper SBOw and Cancel builder --- src/control/mms_control_structure.cpp | 742 ++++++++++++++++++++++++++ 1 file changed, 742 insertions(+) create mode 100644 src/control/mms_control_structure.cpp diff --git a/src/control/mms_control_structure.cpp b/src/control/mms_control_structure.cpp new file mode 100644 index 000000000..128dd7d4c --- /dev/null +++ b/src/control/mms_control_structure.cpp @@ -0,0 +1,742 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/mms_control_structure.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::control { +namespace { + +using mms::MmsDataKind; +using mms::MmsDataValue; +using mms::MmsTypeKind; +using mms::MmsTypeSpecification; + +[[nodiscard]] MmsControlBuildResult failure( + const MmsControlBuildStatus status, + std::string path) { + return {status, std::nullopt, std::move(path)}; +} + +[[nodiscard]] MmsControlBuildResult success(MmsDataValue value) { + return {MmsControlBuildStatus::ok, std::move(value), {}}; +} + +[[nodiscard]] std::string normalize_name(const std::string& value) { + std::string result; + result.reserve(value.size()); + for (const auto character : value) { + const auto byte = static_cast(character); + if (std::isalnum(byte) != 0) { + result.push_back(static_cast(std::tolower(byte))); + } + } + return result; +} + +[[nodiscard]] bool numeric_kind(const MmsTypeKind kind) noexcept { + return kind == MmsTypeKind::integer || + kind == MmsTypeKind::unsigned_integer || + kind == MmsTypeKind::floating_point || + kind == MmsTypeKind::bcd; +} + +[[nodiscard]] bool compatible_kind( + const MmsDataKind actual, + const MmsTypeKind expected) noexcept { + switch (expected) { + case MmsTypeKind::array: return actual == MmsDataKind::array; + case MmsTypeKind::structure: return actual == MmsDataKind::structure; + case MmsTypeKind::boolean: return actual == MmsDataKind::boolean; + case MmsTypeKind::bit_string: return actual == MmsDataKind::bit_string; + case MmsTypeKind::integer: return actual == MmsDataKind::integer; + case MmsTypeKind::unsigned_integer: return actual == MmsDataKind::unsigned_integer; + case MmsTypeKind::floating_point: return actual == MmsDataKind::floating_point; + case MmsTypeKind::octet_string: return actual == MmsDataKind::octet_string; + case MmsTypeKind::visible_string: return actual == MmsDataKind::visible_string; + case MmsTypeKind::binary_time: return actual == MmsDataKind::binary_time; + case MmsTypeKind::bcd: return actual == MmsDataKind::integer; + case MmsTypeKind::boolean_array: return actual == MmsDataKind::boolean_array; + case MmsTypeKind::object_id: return actual == MmsDataKind::object_id; + case MmsTypeKind::mms_string: return actual == MmsDataKind::mms_string; + case MmsTypeKind::utc_time: return actual == MmsDataKind::utc_time; + case MmsTypeKind::unknown: return false; + } + return false; +} + +[[nodiscard]] MmsControlBuildResult validate_recursive( + const MmsDataValue& value, + const MmsTypeSpecification& specification, + const std::string& path) { + if (!compatible_kind(value.kind(), specification.kind)) { + return failure(MmsControlBuildStatus::type_mismatch, path); + } + + if (specification.kind == MmsTypeKind::bit_string) { + const auto& raw = value.raw_value(); + if (raw.size() <= 1U || raw.front() > 7U) { + return failure(MmsControlBuildStatus::shape_mismatch, path); + } + const auto total_bits = (raw.size() - 1U) * 8U; + const auto unused_bits = static_cast(raw.front()); + if (unused_bits > total_bits) { + return failure(MmsControlBuildStatus::shape_mismatch, path); + } + const auto actual_bits = total_bits - unused_bits; + if (specification.size.has_value() && specification.size.value() != 0U && + actual_bits != static_cast(specification.size.value())) { + return failure(MmsControlBuildStatus::shape_mismatch, path); + } + } + + if (specification.kind == MmsTypeKind::octet_string && + specification.size.has_value() && specification.size.value() != 0U && + value.raw_value().size() > static_cast(specification.size.value())) { + return failure(MmsControlBuildStatus::shape_mismatch, path); + } + + if ((specification.kind == MmsTypeKind::visible_string || + specification.kind == MmsTypeKind::mms_string) && + specification.size.has_value() && specification.size.value() != 0U) { + const auto* text = std::get_if(&value.value()); + if (text == nullptr || + text->size() > static_cast(specification.size.value())) { + return failure(MmsControlBuildStatus::shape_mismatch, path); + } + } + + if (specification.kind == MmsTypeKind::structure || + specification.kind == MmsTypeKind::array) { + const auto& children = value.children(); + if (!specification.children.empty() && children.size() != specification.children.size()) { + return failure(MmsControlBuildStatus::shape_mismatch, path); + } + const auto count = std::min(children.size(), specification.children.size()); + for (std::size_t index = 0U; index < count; ++index) { + const auto child_name = specification.children[index].name.empty() + ? "[" + std::to_string(index) + "]" + : specification.children[index].name; + auto child = validate_recursive( + children[index], specification.children[index], path + "." + child_name); + if (!child.success()) { + return child; + } + } + } + + return success(value); +} + +[[nodiscard]] MmsControlBuildResult default_value( + const MmsTypeSpecification& specification, + const std::string& path); + +[[nodiscard]] MmsControlBuildResult bind_value( + const ControlValue& value, + const MmsTypeSpecification& specification, + const std::string& path); + +[[nodiscard]] MmsControlBuildResult zero_bit_string( + const MmsTypeSpecification& specification, + const std::string& path) { + auto bit_count = specification.size.value_or(1U); + if (bit_count == 0U || bit_count > 1024U) { + bit_count = 1U; + } + const auto byte_count = static_cast((bit_count + 7U) / 8U); + std::vector bytes(byte_count, 0U); + const auto unused = static_cast(byte_count * 8U - bit_count); + auto result = MmsDataValue::bit_string(unused, bytes); + return validate_recursive(result, specification, path); +} + +[[nodiscard]] MmsControlBuildResult default_value( + const MmsTypeSpecification& specification, + const std::string& path) { + switch (specification.kind) { + case MmsTypeKind::boolean: + return success(MmsDataValue::boolean(false)); + case MmsTypeKind::bit_string: + return zero_bit_string(specification, path); + case MmsTypeKind::integer: + case MmsTypeKind::bcd: + return success(MmsDataValue::integer(0)); + case MmsTypeKind::unsigned_integer: + return success(MmsDataValue::unsigned_integer(0U)); + case MmsTypeKind::floating_point: + return success(MmsDataValue::floating_point(0.0)); + case MmsTypeKind::octet_string: + return success(MmsDataValue::octet_string({})); + case MmsTypeKind::visible_string: + return success(MmsDataValue::visible_string({})); + case MmsTypeKind::mms_string: + return success(MmsDataValue::mms_string({})); + case MmsTypeKind::utc_time: + return success(MmsDataValue::utc_time(mms::Iec61850UtcTime{})); + case MmsTypeKind::binary_time: { + const auto size = specification.size.value_or(6U) == 4U ? 4U : 6U; + std::array zero{}; + return success(MmsDataValue::binary_time( + std::span{zero}.first(size))); + } + case MmsTypeKind::structure: { + std::vector children; + children.reserve(specification.children.size()); + for (const auto& child_specification : specification.children) { + auto child = default_value( + child_specification, + path + "." + child_specification.name); + if (!child.success()) { + return child; + } + children.push_back(std::move(child.value.value())); + } + return success(MmsDataValue::structure(std::move(children))); + } + default: + return failure(MmsControlBuildStatus::unsupported_component, path); + } +} + +[[nodiscard]] MmsControlBuildResult bind_boolean( + const ControlValue& value, + const MmsTypeSpecification& specification, + const std::string& path) { + bool result{}; + switch (value.kind()) { + case ControlValueKind::boolean: + result = std::get(value.value()); + break; + case ControlValueKind::integer: + result = std::get(value.value()) != 0; + break; + case ControlValueKind::unsigned_integer: + result = std::get(value.value()) != 0U; + break; + default: + return failure(MmsControlBuildStatus::type_mismatch, path); + } + auto encoded = MmsDataValue::boolean(result); + return validate_recursive(encoded, specification, path); +} + +[[nodiscard]] bool try_signed( + const ControlValue& value, + std::int64_t& result) noexcept { + result = 0; + switch (value.kind()) { + case ControlValueKind::integer: + result = std::get(value.value()); + return true; + case ControlValueKind::unsigned_integer: { + const auto input = std::get(value.value()); + if (input > static_cast(std::numeric_limits::max())) { + return false; + } + result = static_cast(input); + return true; + } + case ControlValueKind::double_point: + result = static_cast(std::get(value.value())); + return true; + case ControlValueKind::floating_point: { + const auto input = std::get(value.value()); + if (!std::isfinite(input) || + input < static_cast(std::numeric_limits::min()) || + input > static_cast(std::numeric_limits::max())) { + return false; + } + result = static_cast(input); + return true; + } + default: + return false; + } +} + +[[nodiscard]] bool try_unsigned( + const ControlValue& value, + std::uint64_t& result) noexcept { + result = 0U; + switch (value.kind()) { + case ControlValueKind::unsigned_integer: + result = std::get(value.value()); + return true; + case ControlValueKind::integer: { + const auto input = std::get(value.value()); + if (input < 0) { + return false; + } + result = static_cast(input); + return true; + } + case ControlValueKind::double_point: + result = static_cast(std::get(value.value())); + return true; + default: + return false; + } +} + +[[nodiscard]] MmsControlBuildResult bind_bit_string( + const ControlValue& value, + const MmsTypeSpecification& specification, + const std::string& path) { + std::uint64_t numeric{}; + if (!try_unsigned(value, numeric)) { + return failure(MmsControlBuildStatus::type_mismatch, path); + } + + auto bit_count = specification.size.value_or(2U); + if (bit_count == 0U || bit_count > 32U) { + bit_count = 2U; + } + const auto limit = std::uint64_t{1U} << bit_count; + if (numeric >= limit) { + return failure(MmsControlBuildStatus::value_out_of_range, path); + } + + const auto byte_count = static_cast((bit_count + 7U) / 8U); + std::vector bytes(byte_count, 0U); + for (std::uint32_t encoded_bit = 0U; encoded_bit < bit_count; ++encoded_bit) { + const auto numeric_bit = bit_count - 1U - encoded_bit; + if ((numeric & (std::uint64_t{1U} << numeric_bit)) != 0U) { + const auto byte_index = static_cast(encoded_bit / 8U); + const auto bit_in_byte = encoded_bit % 8U; + bytes[byte_index] = static_cast( + bytes[byte_index] | static_cast(0x80U >> bit_in_byte)); + } + } + const auto unused = static_cast(byte_count * 8U - bit_count); + auto encoded = MmsDataValue::bit_string(unused, bytes); + return validate_recursive(encoded, specification, path); +} + +[[nodiscard]] MmsControlBuildResult bind_structure( + const ControlValue& value, + const MmsTypeSpecification& specification, + const std::string& path) { + if (specification.children.empty()) { + return failure(MmsControlBuildStatus::shape_mismatch, path); + } + + if (value.kind() == ControlValueKind::step_position) { + const auto step = std::get(value.value()); + std::vector children; + children.reserve(specification.children.size()); + for (const auto& child_specification : specification.children) { + const auto name = normalize_name(child_specification.name); + MmsControlBuildResult child; + if (name == "posval" || name == "position" || name == "i") { + child = bind_value( + ControlValue::integer(step.position), child_specification, + path + "." + child_specification.name); + } else if (name == "transind" || name == "transient") { + child = bind_value( + ControlValue::boolean(step.transient), child_specification, + path + "." + child_specification.name); + } else { + return failure( + MmsControlBuildStatus::unsupported_component, + path + "." + child_specification.name); + } + if (!child.success()) { + return child; + } + children.push_back(std::move(child.value.value())); + } + auto encoded = MmsDataValue::structure(std::move(children)); + return validate_recursive(encoded, specification, path); + } + + const bool scalar_numeric = value.kind() == ControlValueKind::floating_point || + value.kind() == ControlValueKind::integer || + value.kind() == ControlValueKind::unsigned_integer; + if (scalar_numeric) { + std::vector numeric_indices; + for (std::size_t index = 0U; index < specification.children.size(); ++index) { + if (numeric_kind(specification.children[index].kind)) { + numeric_indices.push_back(index); + } + } + + std::optional selected_index; + if (numeric_indices.size() == 1U) { + selected_index = numeric_indices.front(); + } else { + const auto desired_name = value.kind() == ControlValueKind::floating_point ? "f" : "i"; + for (std::size_t index = 0U; index < specification.children.size(); ++index) { + if (normalize_name(specification.children[index].name) == desired_name) { + selected_index = index; + break; + } + } + } + + if (selected_index.has_value()) { + std::vector children; + children.reserve(specification.children.size()); + for (std::size_t index = 0U; index < specification.children.size(); ++index) { + MmsControlBuildResult child = index == selected_index.value() + ? bind_value(value, specification.children[index], + path + "." + specification.children[index].name) + : default_value(specification.children[index], + path + "." + specification.children[index].name); + if (!child.success()) { + return child; + } + children.push_back(std::move(child.value.value())); + } + auto encoded = MmsDataValue::structure(std::move(children)); + return validate_recursive(encoded, specification, path); + } + } + + if (specification.children.size() == 1U) { + auto child = bind_value( + value, specification.children.front(), + path + "." + specification.children.front().name); + if (!child.success()) { + return child; + } + std::vector children; + children.push_back(std::move(child.value.value())); + auto encoded = MmsDataValue::structure(std::move(children)); + return validate_recursive(encoded, specification, path); + } + + return failure(MmsControlBuildStatus::unsupported_control_value, path); +} + +[[nodiscard]] MmsControlBuildResult bind_value( + const ControlValue& value, + const MmsTypeSpecification& specification, + const std::string& path) { + if (value.kind() == ControlValueKind::raw_mms) { + return validate_recursive( + std::get(value.value()), specification, path); + } + + switch (specification.kind) { + case MmsTypeKind::boolean: + return bind_boolean(value, specification, path); + case MmsTypeKind::bit_string: + return bind_bit_string(value, specification, path); + case MmsTypeKind::integer: + case MmsTypeKind::bcd: { + std::int64_t numeric{}; + if (!try_signed(value, numeric)) { + return failure(MmsControlBuildStatus::value_out_of_range, path); + } + auto encoded = MmsDataValue::integer(numeric); + return validate_recursive(encoded, specification, path); + } + case MmsTypeKind::unsigned_integer: { + std::uint64_t numeric{}; + if (!try_unsigned(value, numeric)) { + return failure(MmsControlBuildStatus::value_out_of_range, path); + } + auto encoded = MmsDataValue::unsigned_integer(numeric); + return validate_recursive(encoded, specification, path); + } + case MmsTypeKind::floating_point: { + double numeric{}; + switch (value.kind()) { + case ControlValueKind::floating_point: + numeric = std::get(value.value()); + break; + case ControlValueKind::integer: + numeric = static_cast(std::get(value.value())); + break; + case ControlValueKind::unsigned_integer: + numeric = static_cast(std::get(value.value())); + break; + default: + return failure(MmsControlBuildStatus::type_mismatch, path); + } + auto encoded = MmsDataValue::floating_point(numeric); + return validate_recursive(encoded, specification, path); + } + case MmsTypeKind::structure: + return bind_structure(value, specification, path); + default: + return failure(MmsControlBuildStatus::unsupported_control_value, path); + } +} + +[[nodiscard]] MmsControlBuildResult build_integer_like( + const std::int64_t value, + const MmsTypeSpecification& specification, + const std::string& path) { + if (specification.kind == MmsTypeKind::integer || specification.kind == MmsTypeKind::bcd) { + auto encoded = MmsDataValue::integer(value); + return validate_recursive(encoded, specification, path); + } + if (specification.kind == MmsTypeKind::unsigned_integer) { + if (value < 0) { + return failure(MmsControlBuildStatus::value_out_of_range, path); + } + auto encoded = MmsDataValue::unsigned_integer(static_cast(value)); + return validate_recursive(encoded, specification, path); + } + return failure(MmsControlBuildStatus::type_mismatch, path); +} + +[[nodiscard]] MmsControlBuildResult build_origin( + const MmsControlSequenceContext& context, + const MmsTypeSpecification& specification) { + if (specification.kind != MmsTypeKind::structure || specification.children.empty()) { + return failure(MmsControlBuildStatus::type_mismatch, "origin"); + } + + std::vector children; + children.reserve(specification.children.size()); + for (const auto& child_specification : specification.children) { + const auto name = normalize_name(child_specification.name); + MmsControlBuildResult child; + if (name == "orcat") { + child = build_integer_like( + static_cast(context.origin_category), + child_specification, + "origin.orCat"); + } else if (name == "orident") { + if (child_specification.kind == MmsTypeKind::octet_string) { + auto encoded = MmsDataValue::octet_string(context.origin_identifier); + child = validate_recursive(encoded, child_specification, "origin.orIdent"); + } else if (child_specification.kind == MmsTypeKind::visible_string) { + std::string text; + text.reserve(context.origin_identifier.size()); + for (const auto byte : context.origin_identifier) { + text.push_back(byte <= 0x7FU ? static_cast(byte) : '?'); + } + auto encoded = MmsDataValue::visible_string(std::move(text)); + child = validate_recursive(encoded, child_specification, "origin.orIdent"); + } else { + return failure(MmsControlBuildStatus::type_mismatch, "origin.orIdent"); + } + } else { + return failure( + MmsControlBuildStatus::unsupported_component, + "origin." + child_specification.name); + } + if (!child.success()) { + return child; + } + children.push_back(std::move(child.value.value())); + } + + auto encoded = MmsDataValue::structure(std::move(children)); + return validate_recursive(encoded, specification, "origin"); +} + +[[nodiscard]] MmsControlBuildResult build_boolean_field( + const bool value, + const MmsTypeSpecification& specification, + const std::string& path) { + if (specification.kind != MmsTypeKind::boolean) { + return failure(MmsControlBuildStatus::type_mismatch, path); + } + auto encoded = MmsDataValue::boolean(value); + return validate_recursive(encoded, specification, path); +} + +[[nodiscard]] MmsControlBuildResult build_check( + const MmsControlSequenceContext& context, + const MmsTypeSpecification& specification) { + if (specification.kind != MmsTypeKind::bit_string) { + return failure(MmsControlBuildStatus::type_mismatch, "Check"); + } + std::uint8_t bits = 0U; + if (context.synchro_check) { + bits = static_cast(bits | 0x80U); + } + if (context.interlock_check) { + bits = static_cast(bits | 0x40U); + } + const std::array data{bits}; + auto encoded = MmsDataValue::bit_string(6U, data); + return validate_recursive(encoded, specification, "Check"); +} + +[[nodiscard]] MmsControlBuildResult build_default_timestamp( + const MmsTypeSpecification& specification, + const std::string& path) { + if (specification.kind == MmsTypeKind::utc_time) { + auto encoded = MmsDataValue::utc_time(mms::Iec61850UtcTime{}); + return validate_recursive(encoded, specification, path); + } + if (specification.kind == MmsTypeKind::binary_time) { + const auto size = specification.size.value_or(6U) == 4U ? 4U : 6U; + std::array zero{}; + auto encoded = MmsDataValue::binary_time( + std::span{zero}.first(size)); + return validate_recursive(encoded, specification, path); + } + return failure(MmsControlBuildStatus::type_mismatch, path); +} + +[[nodiscard]] bool required_field( + const MmsControlService service, + const std::string& name) noexcept { + if (name == "ctlval" || name == "origin" || name == "ctlnum" || + name == "t" || name == "test") { + return true; + } + return service != MmsControlService::cancel && name == "check"; +} + +} // namespace + +ControlValue ControlValue::boolean(const bool value) { + return {ControlValueKind::boolean, value}; +} + +ControlValue ControlValue::double_point(const DoublePointValue value) { + return {ControlValueKind::double_point, value}; +} + +ControlValue ControlValue::integer(const std::int64_t value) { + return {ControlValueKind::integer, value}; +} + +ControlValue ControlValue::unsigned_integer(const std::uint64_t value) { + return {ControlValueKind::unsigned_integer, value}; +} + +ControlValue ControlValue::floating_point(const double value) { + return {ControlValueKind::floating_point, value}; +} + +ControlValue ControlValue::step_position(const StepPosition value) { + return {ControlValueKind::step_position, value}; +} + +ControlValue ControlValue::raw_mms(MmsDataValue value) { + return {ControlValueKind::raw_mms, std::move(value)}; +} + +MmsControlBuildResult MmsControlStructureBuilder::bind_control_value( + const ControlValue& value, + const MmsTypeSpecification& specification) { + return bind_value(value, specification, "ctlVal"); +} + +MmsControlBuildResult MmsControlStructureBuilder::validate_value( + const MmsDataValue& value, + const MmsTypeSpecification& specification, + std::string path) { + return validate_recursive(value, specification, path); +} + +MmsControlBuildResult MmsControlStructureBuilder::build_operate( + const MmsControlSequenceContext& context, + const MmsTypeSpecification& specification, + const bool require_exact_named_fields) { + return build_service( + MmsControlService::operate, context, specification, require_exact_named_fields); +} + +MmsControlBuildResult MmsControlStructureBuilder::build_select_with_value( + const MmsControlSequenceContext& context, + const MmsTypeSpecification& specification, + const bool require_exact_named_fields) { + return build_service( + MmsControlService::select_with_value, context, specification, + require_exact_named_fields); +} + +MmsControlBuildResult MmsControlStructureBuilder::build_cancel( + const MmsControlSequenceContext& context, + const MmsTypeSpecification& specification, + const bool require_exact_named_fields) { + return build_service( + MmsControlService::cancel, context, specification, require_exact_named_fields); +} + +MmsControlBuildResult MmsControlStructureBuilder::build_service( + const MmsControlService service, + const MmsControlSequenceContext& context, + const MmsTypeSpecification& specification, + const bool require_exact_named_fields) { + if (specification.kind != MmsTypeKind::structure || specification.children.empty()) { + return failure(MmsControlBuildStatus::invalid_service_specification, "service"); + } + + std::array found{}; + const auto mark = [&](const std::string& name) { + if (name == "ctlval") found[0] = true; + else if (name == "origin") found[1] = true; + else if (name == "ctlnum") found[2] = true; + else if (name == "t") found[3] = true; + else if (name == "test") found[4] = true; + else if (name == "check") found[5] = true; + }; + + std::vector children; + children.reserve(specification.children.size()); + for (const auto& child_specification : specification.children) { + const auto name = normalize_name(child_specification.name); + MmsControlBuildResult child; + if (name == "ctlval") { + child = bind_value(context.control_value, child_specification, "ctlVal"); + } else if (name == "opertm" && service != MmsControlService::cancel) { + child = context.operate_at.has_value() + ? validate_recursive(context.operate_at.value(), child_specification, "operTm") + : build_default_timestamp(child_specification, "operTm"); + } else if (name == "origin") { + child = build_origin(context, child_specification); + } else if (name == "ctlnum") { + child = build_integer_like( + static_cast(context.control_number), + child_specification, + "ctlNum"); + } else if (name == "t") { + if (!context.timestamp.has_value()) { + return failure(MmsControlBuildStatus::timestamp_missing, "T"); + } + child = validate_recursive(context.timestamp.value(), child_specification, "T"); + } else if (name == "test") { + child = build_boolean_field(context.test, child_specification, "Test"); + } else if (name == "check") { + child = build_check(context, child_specification); + } else { + return failure( + MmsControlBuildStatus::unsupported_component, + child_specification.name); + } + + if (!child.success()) { + return child; + } + mark(name); + children.push_back(std::move(child.value.value())); + } + + if (require_exact_named_fields) { + constexpr std::array names{ + "ctlval", "origin", "ctlnum", "t", "test", "check"}; + for (std::size_t index = 0U; index < names.size(); ++index) { + if (required_field(service, std::string{names[index]}) && !found[index]) { + return failure( + MmsControlBuildStatus::missing_required_component, + std::string{names[index]}); + } + } + } + + auto encoded = MmsDataValue::structure(std::move(children)); + const auto service_name = service == MmsControlService::operate + ? "Oper" + : (service == MmsControlService::select_with_value ? "SBOw" : "Cancel"); + return validate_recursive(encoded, specification, service_name); +} + +} // namespace ar::iec61850::control From 8e05c1b42437fee5c69856bd031fead1d544184d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:36:17 +0700 Subject: [PATCH 13/66] Fix control structure header utility dependency --- include/ariec61850/control/mms_control_structure.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/include/ariec61850/control/mms_control_structure.hpp b/include/ariec61850/control/mms_control_structure.hpp index b667a7bbf..dc3a892e7 100644 --- a/include/ariec61850/control/mms_control_structure.hpp +++ b/include/ariec61850/control/mms_control_structure.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include From ca0fcdaf4cb3a20a3db88c06914d0a150712d384 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:37:59 +0700 Subject: [PATCH 14/66] Add MMS control structure golden-wire tests --- tests/test_control_mms_structure.cpp | 267 +++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 tests/test_control_mms_structure.cpp diff --git a/tests/test_control_mms_structure.cpp b/tests/test_control_mms_structure.cpp new file mode 100644 index 000000000..17e157950 --- /dev/null +++ b/tests/test_control_mms_structure.cpp @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/mms_control_structure.hpp" +#include "ariec61850/mms/data_codec.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; +using namespace ar::iec61850::control; +using namespace ar::iec61850::mms; + +#define CHECK(condition) do { \ + if (!(condition)) { \ + throw std::runtime_error(std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ +} while (false) + +std::string to_hex(const std::vector& bytes) { + static constexpr char digits[] = "0123456789ABCDEF"; + std::string result; + result.reserve(bytes.size() * 2U); + for (const auto byte : bytes) { + result.push_back(digits[(byte >> 4U) & 0x0FU]); + result.push_back(digits[byte & 0x0FU]); + } + return result; +} + +MmsTypeSpecification scalar( + const MmsTypeKind kind, + std::string name, + const std::optional size = std::nullopt) { + MmsTypeSpecification result; + result.kind = kind; + result.name = std::move(name); + result.size = size; + return result; +} + +MmsTypeSpecification structure( + std::string name, + std::vector children) { + MmsTypeSpecification result; + result.kind = MmsTypeKind::structure; + result.name = std::move(name); + result.children = std::move(children); + return result; +} + +MmsTypeSpecification origin_spec() { + return structure("origin", { + scalar(MmsTypeKind::unsigned_integer, "orCat"), + scalar(MmsTypeKind::octet_string, "orIdent", 64U), + }); +} + +MmsTypeSpecification oper_spec() { + // Deliberately put operTm before origin to prove that live component order, + // not a locally invented fixed order, drives the resulting MMS structure. + return structure("Oper", { + scalar(MmsTypeKind::bit_string, "ctlVal", 2U), + scalar(MmsTypeKind::utc_time, "operTm"), + origin_spec(), + scalar(MmsTypeKind::unsigned_integer, "ctlNum"), + scalar(MmsTypeKind::utc_time, "T"), + scalar(MmsTypeKind::boolean, "Test"), + scalar(MmsTypeKind::bit_string, "Check", 2U), + }); +} + +MmsTypeSpecification cancel_spec(const bool include_check) { + std::vector children{ + scalar(MmsTypeKind::bit_string, "ctlVal", 2U), + origin_spec(), + scalar(MmsTypeKind::unsigned_integer, "ctlNum"), + scalar(MmsTypeKind::utc_time, "T"), + scalar(MmsTypeKind::boolean, "Test"), + }; + if (include_check) { + children.push_back(scalar(MmsTypeKind::bit_string, "Check", 2U)); + } + return structure("Cancel", std::move(children)); +} + +MmsControlSequenceContext context() { + MmsControlSequenceContext result; + result.control_value = ControlValue::double_point(DoublePointValue::on); + result.origin_category = OriginCategory::station_control; + result.origin_identifier = {'H', 'M', 'I'}; + result.control_number = 7U; + result.timestamp = MmsDataValue::utc_time(Iec61850UtcTime{}); + result.test = false; + result.synchro_check = true; + result.interlock_check = true; + return result; +} + +void dpc_binding_matches_network_bit_order() { + const auto specification = scalar(MmsTypeKind::bit_string, "ctlVal", 2U); + auto result = MmsControlStructureBuilder::bind_control_value( + ControlValue::double_point(DoublePointValue::on), specification); + CHECK(result.success()); + CHECK(result.value->kind() == MmsDataKind::bit_string); + CHECK(result.value->raw_value().size() == 2U); + CHECK(result.value->raw_value()[0] == 6U); + CHECK(result.value->raw_value()[1] == 0x80U); // DPC On == binary 10. + + result = MmsControlStructureBuilder::bind_control_value( + ControlValue::double_point(DoublePointValue::off), specification); + CHECK(result.success()); + CHECK(result.value->raw_value()[1] == 0x40U); // DPC Off == binary 01. +} + +void operate_matches_csharp_structure_contract_and_golden_wire() { + const auto specification = oper_spec(); + auto build = MmsControlStructureBuilder::build_operate(context(), specification); + CHECK(build.success()); + CHECK(build.value->kind() == MmsDataKind::structure); + CHECK(build.value->children().size() == 7U); + + const auto& children = build.value->children(); + CHECK(children[0].kind() == MmsDataKind::bit_string); + CHECK(children[0].raw_value() == std::vector({0x06U, 0x80U})); + CHECK(children[1].kind() == MmsDataKind::utc_time); // default operTm epoch. + CHECK(children[2].kind() == MmsDataKind::structure); + CHECK(std::get(children[2].children()[0].value()) == 2U); + CHECK(children[2].children()[1].raw_value() == + std::vector({'H', 'M', 'I'})); + CHECK(std::get(children[3].value()) == 7U); + CHECK(children[4].kind() == MmsDataKind::utc_time); + CHECK(!std::get(children[5].value())); + CHECK(children[6].raw_value() == std::vector({0x06U, 0xC0U})); + + const auto encoded = MmsDataCodec::encode(build.value.value()); + CHECK(to_hex(encoded) == + "A22C8402068091080000000000000000A2088601028903484D4986010791080000000000000000830100840206C0"); + + const auto decoded = MmsDataCodec::decode_all(encoded); + CHECK(decoded.size() == 1U); + CHECK(decoded.front().kind() == MmsDataKind::structure); + CHECK(decoded.front().children().size() == 7U); + CHECK(decoded.front().children()[6].raw_value() == + std::vector({0x06U, 0xC0U})); +} + +void sbow_uses_same_exact_sequence_contract() { + auto specification = oper_spec(); + specification.name = "SBOw"; + auto input = context(); + input.operate_at = MmsDataValue::utc_time(Iec61850UtcTime{ + std::chrono::system_clock::time_point{std::chrono::seconds{10}}, 0U}); + const auto build = MmsControlStructureBuilder::build_select_with_value( + input, specification); + CHECK(build.success()); + CHECK(build.value->children().size() == 7U); + CHECK(build.value->children()[1].kind() == MmsDataKind::utc_time); +} + +void cancel_allows_optional_check_but_not_vendor_guessing() { + auto build = MmsControlStructureBuilder::build_cancel(context(), cancel_spec(false)); + CHECK(build.success()); + CHECK(build.value->children().size() == 5U); + + build = MmsControlStructureBuilder::build_cancel(context(), cancel_spec(true)); + CHECK(build.success()); + CHECK(build.value->children().size() == 6U); + CHECK(build.value->children().back().raw_value() == + std::vector({0x06U, 0xC0U})); + + auto vendor = cancel_spec(false); + vendor.children.push_back(scalar(MmsTypeKind::boolean, "vendorMagic")); + build = MmsControlStructureBuilder::build_cancel(context(), vendor); + CHECK(build.status == MmsControlBuildStatus::unsupported_component); + CHECK(build.path == "vendorMagic"); +} + +void exact_required_fields_and_check_shape_fail_closed() { + auto missing_check = oper_spec(); + missing_check.children.pop_back(); + auto build = MmsControlStructureBuilder::build_operate(context(), missing_check); + CHECK(build.status == MmsControlBuildStatus::missing_required_component); + CHECK(build.path == "check"); + + auto wrong_check = oper_spec(); + wrong_check.children.back().size = 3U; + build = MmsControlStructureBuilder::build_operate(context(), wrong_check); + CHECK(build.status == MmsControlBuildStatus::shape_mismatch); + CHECK(build.path == "Check"); + + auto missing_timestamp = context(); + missing_timestamp.timestamp.reset(); + build = MmsControlStructureBuilder::build_operate(missing_timestamp, oper_spec()); + CHECK(build.status == MmsControlBuildStatus::timestamp_missing); +} + +void origin_ctl_num_and_raw_values_are_live_type_checked() { + auto bad_origin = oper_spec(); + bad_origin.children[2].children[0].kind = MmsTypeKind::boolean; + auto build = MmsControlStructureBuilder::build_operate(context(), bad_origin); + CHECK(build.status == MmsControlBuildStatus::type_mismatch); + CHECK(build.path == "origin.orCat"); + + auto bad_ctl_num = oper_spec(); + bad_ctl_num.children[3].kind = MmsTypeKind::visible_string; + build = MmsControlStructureBuilder::build_operate(context(), bad_ctl_num); + CHECK(build.status == MmsControlBuildStatus::type_mismatch); + CHECK(build.path == "ctlNum"); + + const auto raw_good = ControlValue::raw_mms(MmsDataValue::boolean(true)); + auto raw_result = MmsControlStructureBuilder::bind_control_value( + raw_good, scalar(MmsTypeKind::boolean, "ctlVal")); + CHECK(raw_result.success()); + raw_result = MmsControlStructureBuilder::bind_control_value( + raw_good, scalar(MmsTypeKind::integer, "ctlVal")); + CHECK(raw_result.status == MmsControlBuildStatus::type_mismatch); +} + +void step_position_and_analogue_structures_follow_live_shape() { + const auto step_specification = structure("ctlVal", { + scalar(MmsTypeKind::integer, "posVal"), + scalar(MmsTypeKind::boolean, "transInd"), + }); + auto result = MmsControlStructureBuilder::bind_control_value( + ControlValue::step_position({-3, true}), step_specification); + CHECK(result.success()); + CHECK(std::get(result.value->children()[0].value()) == -3); + CHECK(std::get(result.value->children()[1].value())); + + const auto analogue_specification = structure("ctlVal", { + scalar(MmsTypeKind::floating_point, "f"), + scalar(MmsTypeKind::boolean, "qualityHint"), + }); + result = MmsControlStructureBuilder::bind_control_value( + ControlValue::floating_point(12.5), analogue_specification); + CHECK(result.success()); + CHECK(std::get(result.value->children()[0].value()) == 12.5); + CHECK(!std::get(result.value->children()[1].value())); +} + +} // namespace + +int main() { + try { + dpc_binding_matches_network_bit_order(); + operate_matches_csharp_structure_contract_and_golden_wire(); + sbow_uses_same_exact_sequence_contract(); + cancel_allows_optional_check_but_not_vendor_guessing(); + exact_required_fields_and_check_shape_fail_closed(); + origin_ctl_num_and_raw_values_are_live_type_checked(); + step_position_and_analogue_structures_follow_live_shape(); + std::cout << "Control MMS structure tests passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} From 67f0764c7cdfeb119c5a3fafc1209528e27a57ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:38:21 +0700 Subject: [PATCH 15/66] Add strict control MMS structure validation profile --- tests/control_mms_profile/CMakeLists.txt | 40 ++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/control_mms_profile/CMakeLists.txt diff --git a/tests/control_mms_profile/CMakeLists.txt b/tests/control_mms_profile/CMakeLists.txt new file mode 100644 index 000000000..827736de3 --- /dev/null +++ b/tests/control_mms_profile/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850ControlMmsProfile + VERSION 0.1.0 + DESCRIPTION "Live-type IEC 61850 Oper/SBOw/Cancel MMS structure validation" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +add_executable(ariec61850_control_mms_structure_tests + ${ARIEC61850_ROOT}/tests/test_control_mms_structure.cpp + ${ARIEC61850_ROOT}/src/asn1/ber.cpp + ${ARIEC61850_ROOT}/src/mms/utc_time.cpp + ${ARIEC61850_ROOT}/src/mms/data_value.cpp + ${ARIEC61850_ROOT}/src/mms/data_codec.cpp + ${ARIEC61850_ROOT}/src/control/guarded_control.cpp + ${ARIEC61850_ROOT}/src/control/mms_control_structure.cpp) + +target_compile_features(ariec61850_control_mms_structure_tests PRIVATE cxx_std_20) +target_include_directories(ariec61850_control_mms_structure_tests PRIVATE + ${ARIEC61850_ROOT}/include) + +if(MSVC) + target_compile_options(ariec61850_control_mms_structure_tests PRIVATE + /W4 /WX /permissive-) +else() + target_compile_options(ariec61850_control_mms_structure_tests PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) +endif() + +enable_testing() +add_test( + NAME ariec61850_control_mms_structure_tests + COMMAND ariec61850_control_mms_structure_tests) + +message(STATUS "arstack61850 Control MMS profile: live TypeSpecification binding=ON") +message(STATUS "arstack61850 Control MMS profile: Oper/SBOw/Cancel exact fields=ON") +message(STATUS "arstack61850 Control MMS profile: DPC network-bit-order oracle=ON") +message(STATUS "arstack61850 Control MMS profile: Check 2-bit oracle=ON") +message(STATUS "arstack61850 Control MMS profile: vendor-specific field guessing=OFF") From e53aa25175c33685f0107756d782d1842276f9b6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:38:35 +0700 Subject: [PATCH 16/66] Gate control MMS structures on GCC and Clang --- .github/workflows/control-mms-profile-ci.yml | 64 ++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .github/workflows/control-mms-profile-ci.yml diff --git a/.github/workflows/control-mms-profile-ci.yml b/.github/workflows/control-mms-profile-ci.yml new file mode 100644 index 000000000..86ef42d47 --- /dev/null +++ b/.github/workflows/control-mms-profile-ci.yml @@ -0,0 +1,64 @@ +name: Control MMS Profile CI + +on: + push: + branches: + - main + pull_request: + paths: + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'tests/test_control_mms_structure.cpp' + - 'tests/control_mms_profile/**' + - 'include/ariec61850/mms/data_value.hpp' + - 'include/ariec61850/mms/services.hpp' + - 'src/mms/data_value.cpp' + - 'src/mms/data_codec.cpp' + - 'src/mms/utc_time.cpp' + - '.github/workflows/control-mms-profile-ci.yml' + +permissions: + contents: read + +jobs: + control-mms-profile: + name: Control MMS / ${{ matrix.compiler }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + compiler: [gcc, clang] + + steps: + - uses: actions/checkout@v4 + + - name: Select compiler + shell: bash + run: | + if [ "${{ matrix.compiler }}" = "clang" ]; then + echo "CC=clang" >> "$GITHUB_ENV" + echo "CXX=clang++" >> "$GITHUB_ENV" + else + echo "CC=gcc" >> "$GITHUB_ENV" + echo "CXX=g++" >> "$GITHUB_ENV" + fi + + - name: Configure control MMS profile + run: cmake -S tests/control_mms_profile -B build-control-mms + + - name: Build control MMS profile + run: cmake --build build-control-mms --parallel + + - name: Run control MMS golden-wire tests + run: >- + ctest --test-dir build-control-mms + --output-on-failure + --output-junit control-mms-ctest.xml + + - name: Upload control MMS evidence + uses: actions/upload-artifact@v4 + with: + name: control-mms-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: build-control-mms/control-mms-ctest.xml From e26c73226a5ce3fbe8d2bc7836aa8016af508050 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:40:25 +0700 Subject: [PATCH 17/66] Add CommandTermination and LastApplError decoder API --- .../control/command_termination.hpp | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 include/ariec61850/control/command_termination.hpp diff --git a/include/ariec61850/control/command_termination.hpp b/include/ariec61850/control/command_termination.hpp new file mode 100644 index 000000000..9e493d2b6 --- /dev/null +++ b/include/ariec61850/control/command_termination.hpp @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/control/guarded_control.hpp" +#include "ariec61850/mms/reporting.hpp" + +#include +#include +#include + +namespace ar::iec61850::control { + +struct LastApplError final { + std::string control_object; + std::int64_t raw_control_error{}; + std::int64_t raw_add_cause{25}; + ControlError control_error{ControlError::no_error}; + AddCause add_cause{AddCause::none}; + std::string control_error_name; + std::string add_cause_name; +}; + +struct CommandTermination final { + bool is_for_control_object{}; + bool is_termination{}; + bool positive{}; + ControlError control_error{ControlError::no_error}; + AddCause add_cause{AddCause::none}; + std::int64_t raw_control_error{}; + std::int64_t raw_add_cause{25}; + std::string control_error_name; + std::string add_cause_name; + std::optional last_appl_error; +}; + +class CommandTerminationDecoder final { +public: + [[nodiscard]] static CommandTermination decode( + const mms::MmsInformationReport& report, + const ControlObjectReference& object); + + [[nodiscard]] static std::optional try_decode_last_appl_error( + const mms::MmsDataValue& value); + + [[nodiscard]] static bool matches_operate_reference( + const ControlObjectReference& object, + const std::string& reference); + + [[nodiscard]] static bool matches_reported_reference( + const ControlObjectReference& object, + const std::string& reference); + + [[nodiscard]] static std::string control_error_name(std::int64_t value); + [[nodiscard]] static std::string add_cause_name(std::int64_t value); +}; + +} // namespace ar::iec61850::control From 81d1cf294efc42848ce1c45dc9fc7659e0f61f6c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:41:12 +0700 Subject: [PATCH 18/66] Implement correlated CommandTermination decoding --- src/control/command_termination.cpp | 366 ++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 src/control/command_termination.cpp diff --git a/src/control/command_termination.cpp b/src/control/command_termination.cpp new file mode 100644 index 000000000..b415c42be --- /dev/null +++ b/src/control/command_termination.cpp @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/command_termination.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::control { +namespace { + +[[nodiscard]] char ascii_lower(const char value) noexcept { + return value >= 'A' && value <= 'Z' + ? static_cast(value - 'A' + 'a') + : value; +} + +[[nodiscard]] bool ascii_equal( + const std::string& left, + const std::string& right) noexcept { + if (left.size() != right.size()) { + return false; + } + for (std::size_t index = 0U; index < left.size(); ++index) { + if (ascii_lower(left[index]) != ascii_lower(right[index])) { + return false; + } + } + return true; +} + +[[nodiscard]] bool ascii_ends_with( + const std::string& value, + const std::string& suffix) noexcept { + if (suffix.size() > value.size()) { + return false; + } + const auto offset = value.size() - suffix.size(); + for (std::size_t index = 0U; index < suffix.size(); ++index) { + if (ascii_lower(value[offset + index]) != ascii_lower(suffix[index])) { + return false; + } + } + return true; +} + +[[nodiscard]] bool ascii_starts_with( + const std::string& value, + const std::string& prefix) noexcept { + if (prefix.size() > value.size()) { + return false; + } + for (std::size_t index = 0U; index < prefix.size(); ++index) { + if (ascii_lower(value[index]) != ascii_lower(prefix[index])) { + return false; + } + } + return true; +} + +[[nodiscard]] bool ascii_contains( + const std::string& value, + const std::string& needle) noexcept { + if (needle.empty()) { + return true; + } + if (needle.size() > value.size()) { + return false; + } + for (std::size_t offset = 0U; offset + needle.size() <= value.size(); ++offset) { + bool match = true; + for (std::size_t index = 0U; index < needle.size(); ++index) { + if (ascii_lower(value[offset + index]) != ascii_lower(needle[index])) { + match = false; + break; + } + } + if (match) { + return true; + } + } + return false; +} + +[[nodiscard]] std::string trim_delimiter(std::string value, const char delimiter) { + while (!value.empty() && value.front() == delimiter) { + value.erase(value.begin()); + } + while (!value.empty() && value.back() == delimiter) { + value.pop_back(); + } + return value; +} + +[[nodiscard]] std::string object_path_dollars(const ControlObjectReference& object) { + std::string result{object.data_object_path}; + std::replace(result.begin(), result.end(), '.', '$'); + return result; +} + +[[nodiscard]] std::string object_reference(const ControlObjectReference& object) { + return std::string{object.domain} + "/" + std::string{object.logical_node} + "." + + std::string{object.data_object_path}; +} + +[[nodiscard]] std::string mms_reference(const mms::MmsObjectName& name) { + switch (name.kind) { + case mms::MmsObjectNameKind::domain_specific: + return name.domain + "/" + name.item; + case mms::MmsObjectNameKind::vmd_specific: + case mms::MmsObjectNameKind::aa_specific: + return name.item; + } + return {}; +} + +[[nodiscard]] std::string read_text(const mms::MmsDataValue& value) { + if (value.kind() != mms::MmsDataKind::visible_string && + value.kind() != mms::MmsDataKind::mms_string) { + return {}; + } + const auto* text = std::get_if(&value.value()); + return text == nullptr ? std::string{} : *text; +} + +[[nodiscard]] std::optional read_number( + const mms::MmsDataValue& value) noexcept { + if (value.kind() == mms::MmsDataKind::integer) { + if (const auto* number = std::get_if(&value.value())) { + return *number; + } + return std::nullopt; + } + if (value.kind() == mms::MmsDataKind::unsigned_integer) { + if (const auto* number = std::get_if(&value.value())) { + if (*number <= static_cast( + std::numeric_limits::max())) { + return static_cast(*number); + } + } + } + return std::nullopt; +} + +[[nodiscard]] ControlError map_control_error(const std::int64_t value) noexcept { + switch (value) { + case 0: return ControlError::no_error; + case 1: return ControlError::unknown; + case 2: return ControlError::timeout_test; + case 3: return ControlError::operator_test; + default: return ControlError::unknown; + } +} + +[[nodiscard]] AddCause map_add_cause(const std::int64_t value) noexcept { + if (value >= 0 && value <= 27) { + return static_cast(static_cast(value)); + } + return AddCause::unknown; +} + +[[nodiscard]] bool positive_termination( + const std::int64_t error, + const std::int64_t add_cause) noexcept { + return error == 0 && (add_cause == 0 || add_cause == 25); +} + +} // namespace + +std::string CommandTerminationDecoder::control_error_name(const std::int64_t value) { + switch (value) { + case 0: return "no-error"; + case 1: return "unknown"; + case 2: return "timeout-test"; + case 3: return "operator-test"; + default: return "control-error-" + std::to_string(value); + } +} + +std::string CommandTerminationDecoder::add_cause_name(const std::int64_t value) { + switch (value) { + case 0: return "unknown"; + case 1: return "not-supported"; + case 2: return "blocked-by-switching-hierarchy"; + case 3: return "select-failed"; + case 4: return "invalid-position"; + case 5: return "position-reached"; + case 6: return "parameter-change-in-execution"; + case 7: return "step-limit"; + case 8: return "blocked-by-mode"; + case 9: return "blocked-by-process"; + case 10: return "blocked-by-interlocking"; + case 11: return "blocked-by-synchrocheck"; + case 12: return "command-already-in-execution"; + case 13: return "blocked-by-health"; + case 14: return "one-of-n-control"; + case 15: return "abortion-by-cancel"; + case 16: return "time-limit-over"; + case 17: return "abortion-by-trip"; + case 18: return "object-not-selected"; + case 19: return "object-already-selected"; + case 20: return "no-access-authority"; + case 21: return "ended-with-overshoot"; + case 22: return "abortion-due-to-deviation"; + case 23: return "abortion-by-communication-loss"; + case 24: return "abortion-by-command"; + case 25: return "none"; + case 26: return "inconsistent-parameters"; + case 27: return "locked-by-other-client"; + default: return "add-cause-" + std::to_string(value); + } +} + +bool CommandTerminationDecoder::matches_operate_reference( + const ControlObjectReference& object, + const std::string& reference) { + if (!object.valid() || reference.empty()) { + return false; + } + std::string normalized = reference; + std::replace(normalized.begin(), normalized.end(), '.', '$'); + std::replace(normalized.begin(), normalized.end(), '/', '$'); + normalized = trim_delimiter(std::move(normalized), '$'); + + const auto data_path = object_path_dollars(object); + const auto expected = std::string{object.domain} + "$" + + std::string{object.logical_node} + "$CO$" + data_path + "$Oper"; + const auto suffix = std::string{object.logical_node} + "$CO$" + + data_path + "$Oper"; + return ascii_equal(normalized, expected) || ascii_ends_with(normalized, suffix); +} + +bool CommandTerminationDecoder::matches_reported_reference( + const ControlObjectReference& object, + const std::string& reference) { + if (!object.valid() || reference.empty()) { + return false; + } + std::string normalized = reference; + std::replace(normalized.begin(), normalized.end(), '$', '.'); + const auto expected = object_reference(object); + const auto logical_suffix = "/" + std::string{object.logical_node} + "." + + std::string{object.data_object_path}; + const auto co_fragment = "/" + std::string{object.logical_node} + ".CO." + + std::string{object.data_object_path} + "."; + return ascii_equal(normalized, expected) || + ascii_starts_with(normalized, expected + ".") || + ascii_ends_with(normalized, logical_suffix) || + ascii_contains(normalized, co_fragment); +} + +std::optional CommandTerminationDecoder::try_decode_last_appl_error( + const mms::MmsDataValue& value) { + if (value.kind() != mms::MmsDataKind::structure) { + return std::nullopt; + } + const auto& children = value.children(); + if (children.size() < 2U) { + return std::nullopt; + } + + std::vector> numeric; + numeric.reserve(children.size()); + for (std::size_t index = 0U; index < children.size(); ++index) { + if (const auto number = read_number(children[index]); number.has_value()) { + numeric.emplace_back(index, number.value()); + } + } + if (numeric.size() < 2U) { + return std::nullopt; + } + + std::optional error; + for (const auto& [index, number] : numeric) { + if (index == 0U || index == 1U) { + error = number; + break; + } + } + if (!error.has_value()) { + return std::nullopt; + } + + const auto add_cause = numeric.back().second; + LastApplError result; + result.control_object = read_text(children.front()); + result.raw_control_error = error.value(); + result.raw_add_cause = add_cause; + result.control_error = map_control_error(error.value()); + result.add_cause = map_add_cause(add_cause); + result.control_error_name = control_error_name(error.value()); + result.add_cause_name = add_cause_name(add_cause); + return result; +} + +CommandTermination CommandTerminationDecoder::decode( + const mms::MmsInformationReport& report, + const ControlObjectReference& object) { + CommandTermination result; + if (!object.valid()) { + return result; + } + + bool matching_object = false; + bool matching_operate = false; + for (const auto& reference : report.variable_references) { + const auto text = mms_reference(reference); + matching_object = matching_object || matches_reported_reference(object, text); + matching_operate = matching_operate || matches_operate_reference(object, text); + } + + for (const auto& item : report.items) { + if (!item.value.has_value() || + item.value->kind() != mms::MmsDataKind::structure) { + continue; + } + const auto last_error = try_decode_last_appl_error(item.value.value()); + if (!last_error.has_value()) { + continue; + } + + const bool embedded_match = !last_error->control_object.empty() && + (matches_reported_reference(object, last_error->control_object) || + matches_operate_reference(object, last_error->control_object)); + if (!matching_object && !matching_operate && !embedded_match) { + continue; + } + + result.is_for_control_object = true; + result.is_termination = true; + result.positive = positive_termination( + last_error->raw_control_error, last_error->raw_add_cause); + result.control_error = last_error->control_error; + result.add_cause = last_error->add_cause; + result.raw_control_error = last_error->raw_control_error; + result.raw_add_cause = last_error->raw_add_cause; + result.control_error_name = last_error->control_error_name; + result.add_cause_name = last_error->add_cause_name; + result.last_appl_error = last_error; + return result; + } + + // Mirror the C# oracle: a positive enhanced-security termination may carry + // only the exact CO/Oper variable. Ordinary ST/MX process reports must not + // complete the pending command. + if (!matching_operate) { + return result; + } + + result.is_for_control_object = true; + result.is_termination = true; + result.positive = true; + result.control_error = ControlError::no_error; + result.add_cause = AddCause::none; + result.raw_control_error = 0; + result.raw_add_cause = 25; + result.control_error_name = "no-error"; + result.add_cause_name = "none"; + return result; +} + +} // namespace ar::iec61850::control From 68f1eb22086f71fd91a35ded56a5e7919d82315b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:42:02 +0700 Subject: [PATCH 19/66] Add LastApplError and CommandTermination parity tests --- tests/test_command_termination.cpp | 229 +++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 tests/test_command_termination.cpp diff --git a/tests/test_command_termination.cpp b/tests/test_command_termination.cpp new file mode 100644 index 000000000..a5b61a293 --- /dev/null +++ b/tests/test_command_termination.cpp @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/command_termination.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850::control; +using namespace ar::iec61850::mms; + +#define CHECK(condition) do { \ + if (!(condition)) { \ + throw std::runtime_error(std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ +} while (false) + +ControlObjectReference object() { + ControlObjectReference result; + CHECK(try_parse_control_object_reference("LD0/CSWI1.Pos", result)); + return result; +} + +MmsObjectName reference(std::string item) { + MmsObjectName result; + result.kind = MmsObjectNameKind::domain_specific; + result.domain = "LD0"; + result.item = std::move(item); + return result; +} + +MmsInformationReportItem value_item(MmsDataValue value, const std::size_t index = 0U) { + MmsInformationReportItem item; + item.index = index; + item.value = std::move(value); + return item; +} + +MmsDataValue last_appl_error( + std::string control_object, + const std::int64_t error, + const std::int64_t add_cause) { + return MmsDataValue::structure({ + MmsDataValue::visible_string(std::move(control_object)), + MmsDataValue::integer(error), + MmsDataValue::structure({ + MmsDataValue::unsigned_integer(2U), + MmsDataValue::octet_string(std::vector{'H', 'M', 'I'}), + }), + MmsDataValue::unsigned_integer(7U), + MmsDataValue::integer(add_cause), + }); +} + +bool allow_all( + void*, + ControlAction, + const ControlObjectReference&, + const ControlClientIdentity&, + const ControlSequenceView&) noexcept { + return true; +} + +void reference_matching_follows_csharp_oracle() { + const auto target = object(); + CHECK(CommandTerminationDecoder::matches_operate_reference( + target, "LD0/CSWI1$CO$Pos$Oper")); + CHECK(CommandTerminationDecoder::matches_operate_reference( + target, "CSWI1.CO.Pos.Oper")); + CHECK(!CommandTerminationDecoder::matches_operate_reference( + target, "LD0/CSWI1$ST$Pos$stVal")); + + CHECK(CommandTerminationDecoder::matches_reported_reference( + target, "LD0/CSWI1.Pos")); + CHECK(CommandTerminationDecoder::matches_reported_reference( + target, "LD0/CSWI1.Pos.stVal")); + CHECK(CommandTerminationDecoder::matches_reported_reference( + target, "LD0/CSWI1$CO$Pos$Oper")); + CHECK(!CommandTerminationDecoder::matches_reported_reference( + target, "LD0/XCBR1.Pos.stVal")); +} + +void positive_termination_requires_exact_oper_reference() { + MmsInformationReport report; + report.variable_references.push_back(reference("CSWI1$CO$Pos$Oper")); + auto result = CommandTerminationDecoder::decode(report, object()); + CHECK(result.is_for_control_object); + CHECK(result.is_termination); + CHECK(result.positive); + CHECK(result.control_error == ControlError::no_error); + CHECK(result.add_cause == AddCause::none); + CHECK(result.raw_add_cause == 25); + + MmsInformationReport ordinary; + ordinary.variable_references.push_back(reference("CSWI1$ST$Pos$stVal")); + ordinary.items.push_back(value_item(MmsDataValue::boolean(true))); + result = CommandTerminationDecoder::decode(ordinary, object()); + CHECK(!result.is_for_control_object); + CHECK(!result.is_termination); +} + +void negative_last_appl_error_is_correlated_and_mapped() { + MmsInformationReport report; + // Embedded ctlObj is sufficient even if the report variable reference itself + // is not the target object. + report.variable_references.push_back(reference("LLN0$ST$Mod$stVal")); + report.items.push_back(value_item(last_appl_error( + "LD0/CSWI1.Pos", 0, 10))); + + const auto result = CommandTerminationDecoder::decode(report, object()); + CHECK(result.is_for_control_object); + CHECK(result.is_termination); + CHECK(!result.positive); + CHECK(result.control_error == ControlError::no_error); + CHECK(result.add_cause == AddCause::blocked_by_interlocking); + CHECK(result.control_error_name == "no-error"); + CHECK(result.add_cause_name == "blocked-by-interlocking"); + CHECK(result.last_appl_error.has_value()); + CHECK(result.last_appl_error->control_object == "LD0/CSWI1.Pos"); +} + +void omitted_ctl_obj_layout_is_supported_when_report_correlates() { + MmsInformationReport report; + report.variable_references.push_back(reference("CSWI1$CO$Pos$Oper")); + report.items.push_back(value_item(MmsDataValue::structure({ + MmsDataValue::integer(0), + MmsDataValue::structure({ + MmsDataValue::unsigned_integer(2U), + MmsDataValue::octet_string(std::vector{'H', 'M', 'I'}), + }), + MmsDataValue::unsigned_integer(7U), + MmsDataValue::integer(25), + }))); + + const auto result = CommandTerminationDecoder::decode(report, object()); + CHECK(result.is_termination); + CHECK(result.positive); + CHECK(result.last_appl_error.has_value()); + CHECK(result.last_appl_error->control_object.empty()); + CHECK(result.add_cause == AddCause::none); +} + +void uncorrelated_last_appl_error_cannot_complete_command() { + MmsInformationReport report; + report.variable_references.push_back(reference("XCBR1$ST$Pos$stVal")); + report.items.push_back(value_item(last_appl_error( + "LD0/XCBR1.Pos", 0, 11))); + const auto result = CommandTerminationDecoder::decode(report, object()); + CHECK(!result.is_for_control_object); + CHECK(!result.is_termination); +} + +void unknown_numeric_codes_are_preserved_without_false_positive() { + MmsInformationReport report; + report.items.push_back(value_item(last_appl_error( + "LD0/CSWI1.Pos", 99, 88))); + const auto result = CommandTerminationDecoder::decode(report, object()); + CHECK(result.is_termination); + CHECK(!result.positive); + CHECK(result.raw_control_error == 99); + CHECK(result.raw_add_cause == 88); + CHECK(result.control_error == ControlError::unknown); + CHECK(result.add_cause == AddCause::unknown); + CHECK(result.control_error_name == "control-error-99"); + CHECK(result.add_cause_name == "add-cause-88"); +} + +void decoder_output_drives_enhanced_planner_completion() { + GuardedControlPolicy policy; + policy.authorize = allow_all; + const auto target = object(); + GuardedControlPlanner planner{target, ControlModel::direct_enhanced, policy}; + constexpr std::uint8_t encoded_boolean[] = {0x83U, 0x01U, 0xFFU}; + constexpr std::uint8_t origin[] = {'H', 'M', 'I'}; + ControlSequenceView sequence; + sequence.control_value = encoded_boolean; + sequence.origin_identifier = origin; + sequence.timestamp_token = 1U; + const ControlClientIdentity client{0x1234U}; + auto decision = planner.operate(client, sequence, 100U); + CHECK(decision.status == GuardedControlStatus::accepted_waiting_termination); + + MmsInformationReport report; + report.items.push_back(value_item(last_appl_error( + "LD0/CSWI1.Pos", 0, 11))); + const auto termination = CommandTerminationDecoder::decode(report, target); + CHECK(termination.is_termination); + CHECK(!termination.positive); + + decision = planner.command_termination( + termination.control_error, termination.add_cause); + CHECK(decision.status == GuardedControlStatus::negative_termination); + CHECK(decision.add_cause == AddCause::blocked_by_synchrocheck); + CHECK(!planner.state(101U).waiting_for_termination); +} + +void last_appl_error_needs_two_numeric_fields() { + const auto invalid = MmsDataValue::structure({ + MmsDataValue::visible_string("LD0/CSWI1.Pos"), + MmsDataValue::integer(0), + }); + CHECK(!CommandTerminationDecoder::try_decode_last_appl_error(invalid).has_value()); +} + +} // namespace + +int main() { + try { + reference_matching_follows_csharp_oracle(); + positive_termination_requires_exact_oper_reference(); + negative_last_appl_error_is_correlated_and_mapped(); + omitted_ctl_obj_layout_is_supported_when_report_correlates(); + uncorrelated_last_appl_error_cannot_complete_command(); + unknown_numeric_codes_are_preserved_without_false_positive(); + decoder_output_drives_enhanced_planner_completion(); + last_appl_error_needs_two_numeric_fields(); + std::cout << "CommandTermination tests passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} From 64581836000db2d4621eeabe372762df73e4fd5b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:42:22 +0700 Subject: [PATCH 20/66] Add strict CommandTermination validation profile --- .../CMakeLists.txt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/control_termination_profile/CMakeLists.txt diff --git a/tests/control_termination_profile/CMakeLists.txt b/tests/control_termination_profile/CMakeLists.txt new file mode 100644 index 000000000..d55bc0b73 --- /dev/null +++ b/tests/control_termination_profile/CMakeLists.txt @@ -0,0 +1,36 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850ControlTerminationProfile + VERSION 0.1.0 + DESCRIPTION "IEC 61850 CommandTermination and LastApplError parity validation" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +add_executable(ariec61850_command_termination_tests + ${ARIEC61850_ROOT}/tests/test_command_termination.cpp + ${ARIEC61850_ROOT}/src/mms/data_value.cpp + ${ARIEC61850_ROOT}/src/control/guarded_control.cpp + ${ARIEC61850_ROOT}/src/control/command_termination.cpp) + +target_compile_features(ariec61850_command_termination_tests PRIVATE cxx_std_20) +target_include_directories(ariec61850_command_termination_tests PRIVATE + ${ARIEC61850_ROOT}/include) + +if(MSVC) + target_compile_options(ariec61850_command_termination_tests PRIVATE + /W4 /WX /permissive-) +else() + target_compile_options(ariec61850_command_termination_tests PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) +endif() + +enable_testing() +add_test( + NAME ariec61850_command_termination_tests + COMMAND ariec61850_command_termination_tests) + +message(STATUS "arstack61850 Control termination profile: LastApplError mapping=ON") +message(STATUS "arstack61850 Control termination profile: exact CO/Oper positive boundary=ON") +message(STATUS "arstack61850 Control termination profile: ordinary ST/MX completion=DENIED") +message(STATUS "arstack61850 Control termination profile: unknown numeric diagnostics preserved=ON") From 6d8c2bdf60934089b68af7a61b3e3eccc6fa59f4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:42:36 +0700 Subject: [PATCH 21/66] Gate CommandTermination parity on GCC and Clang --- .../control-termination-profile-ci.yml | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/control-termination-profile-ci.yml diff --git a/.github/workflows/control-termination-profile-ci.yml b/.github/workflows/control-termination-profile-ci.yml new file mode 100644 index 000000000..56e0730a2 --- /dev/null +++ b/.github/workflows/control-termination-profile-ci.yml @@ -0,0 +1,62 @@ +name: Control Termination Profile CI + +on: + push: + branches: + - main + pull_request: + paths: + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'tests/test_command_termination.cpp' + - 'tests/control_termination_profile/**' + - 'include/ariec61850/mms/data_value.hpp' + - 'include/ariec61850/mms/reporting.hpp' + - 'src/mms/data_value.cpp' + - '.github/workflows/control-termination-profile-ci.yml' + +permissions: + contents: read + +jobs: + control-termination-profile: + name: Control Termination / ${{ matrix.compiler }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + compiler: [gcc, clang] + + steps: + - uses: actions/checkout@v4 + + - name: Select compiler + shell: bash + run: | + if [ "${{ matrix.compiler }}" = "clang" ]; then + echo "CC=clang" >> "$GITHUB_ENV" + echo "CXX=clang++" >> "$GITHUB_ENV" + else + echo "CC=gcc" >> "$GITHUB_ENV" + echo "CXX=g++" >> "$GITHUB_ENV" + fi + + - name: Configure control termination profile + run: cmake -S tests/control_termination_profile -B build-control-termination + + - name: Build control termination profile + run: cmake --build build-control-termination --parallel + + - name: Run CommandTermination parity tests + run: >- + ctest --test-dir build-control-termination + --output-on-failure + --output-junit control-termination-ctest.xml + + - name: Upload termination evidence + uses: actions/upload-artifact@v4 + with: + name: control-termination-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: build-control-termination/control-termination-ctest.xml From c14316189e3780b2c176e42b447baf6ab8410722 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:48:37 +0700 Subject: [PATCH 22/66] Add non-fatal bounded MMS poll for control completion waits --- include/ariec61850/mms/association_runtime.hpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/include/ariec61850/mms/association_runtime.hpp b/include/ariec61850/mms/association_runtime.hpp index 332df7c03..71af1a698 100644 --- a/include/ariec61850/mms/association_runtime.hpp +++ b/include/ariec61850/mms/association_runtime.hpp @@ -151,6 +151,17 @@ class MmsAssociationRuntime final { [[nodiscard]] MmsPduEnvelope poll_once( std::stop_token stop_token = {}); + + // Bounded receive primitive for application workflows where expiry is a + // normal application result rather than an association fault. A transport + // timeout returns false and leaves the association active. Caller + // cancellation is propagated without rewriting association state. Other + // transport/protocol failures retain the normal fail-closed behavior. + [[nodiscard]] bool try_poll_once_for( + std::chrono::milliseconds timeout, + MmsPduEnvelope& envelope, + std::stop_token stop_token = {}); + [[nodiscard]] bool try_pop_information_report( std::vector& presentation_payload); From 8b664a834fba78a2825844f9ddf45b31e2259577 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:49:07 +0700 Subject: [PATCH 23/66] Implement non-fatal bounded MMS poll for control waits --- src/mms/association_runtime_control_wait.cpp | 49 ++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/mms/association_runtime_control_wait.cpp diff --git a/src/mms/association_runtime_control_wait.cpp b/src/mms/association_runtime_control_wait.cpp new file mode 100644 index 000000000..3461bc62e --- /dev/null +++ b/src/mms/association_runtime_control_wait.cpp @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/association_runtime.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +bool MmsAssociationRuntime::try_poll_once_for( + const std::chrono::milliseconds timeout, + MmsPduEnvelope& envelope, + const std::stop_token stop_token) { + require_associated(); + if (timeout <= std::chrono::milliseconds::zero()) { + throw std::invalid_argument("Bounded MMS poll timeout must be positive."); + } + require_not_cancelled(stop_token); + + try { + auto payload = receive_application_payload( + deadline_after(timeout), stop_token); + envelope = route_received_payload(payload); + return true; + } catch (const MmsTransportTimeoutError&) { + // Intentional application-level expiry. The byte transport remains + // owned by the active association; do not convert a missing + // CommandTermination into an MMS association fault. + return false; + } catch (const MmsTransportCancelledError&) { + // Caller cancellation means "stop waiting" for this API. Preserve the + // association and let the caller decide whether to disconnect. + throw; + } catch (const std::exception& exception) { + if (stop_token.stop_requested()) { + throw MmsTransportCancelledError(exception.what()); + } + fail(exception.what()); + transport_.close(); + invoke_router_.clear(); + information_reports_.clear(); + tpkt_decoder_.reset(); + remote_cotp_reference_ = 0U; + throw; + } +} + +} // namespace ar::iec61850::mms From 9ce8a9389fef0f50783446a7b9f05d7aae225249 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:50:01 +0700 Subject: [PATCH 24/66] Add guarded live IEC 61850 control session API --- .../ariec61850/control/control_session.hpp | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 include/ariec61850/control/control_session.hpp diff --git a/include/ariec61850/control/control_session.hpp b/include/ariec61850/control/control_session.hpp new file mode 100644 index 000000000..ae0495708 --- /dev/null +++ b/include/ariec61850/control/control_session.hpp @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/control/command_termination.hpp" +#include "ariec61850/control/guarded_control.hpp" +#include "ariec61850/control/mms_control_structure.hpp" +#include "ariec61850/mms/association_runtime.hpp" +#include "ariec61850/mms/reporting.hpp" +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::control { + +struct ControlTransportWriteResult final { + bool success{}; + std::optional failure_code; +}; + +class ControlTransport { +public: + virtual ~ControlTransport() = default; + + [[nodiscard]] virtual bool associated() const noexcept = 0; + [[nodiscard]] virtual std::uint64_t association_id() const noexcept = 0; + + [[nodiscard]] virtual std::optional read( + const mms::MmsObjectName& object, + std::stop_token stop_token = {}) = 0; + [[nodiscard]] virtual std::optional variable_specification( + const mms::MmsObjectName& object, + std::stop_token stop_token = {}) = 0; + [[nodiscard]] virtual std::vector domain_variable_names( + const std::string& domain, + std::stop_token stop_token = {}) = 0; + [[nodiscard]] virtual ControlTransportWriteResult write( + const mms::MmsObjectName& object, + mms::MmsDataValue value, + std::stop_token stop_token = {}) = 0; + + // Establishes the completion-observation boundary used by C# smart control: + // stale reports are discarded before SBOw/Oper, while reports arriving + // during the confirmed Write exchange remain queued by the association. + virtual void clear_information_reports() = 0; + [[nodiscard]] virtual bool wait_information_report( + std::chrono::milliseconds timeout, + mms::MmsInformationReport& report, + std::stop_token stop_token = {}) = 0; +}; + +// Production adapter over the already-live MmsAssociationRuntime. It performs +// exactly one confirmed Read/GVAA/Write exchange per requested operation and +// uses the non-fatal bounded poll API while waiting for asynchronous reports. +class MmsAssociationControlTransport final : public ControlTransport { +public: + explicit MmsAssociationControlTransport(mms::MmsAssociationRuntime& association) noexcept; + + [[nodiscard]] bool associated() const noexcept override; + [[nodiscard]] std::uint64_t association_id() const noexcept override; + [[nodiscard]] std::optional read( + const mms::MmsObjectName& object, + std::stop_token stop_token = {}) override; + [[nodiscard]] std::optional variable_specification( + const mms::MmsObjectName& object, + std::stop_token stop_token = {}) override; + [[nodiscard]] std::vector domain_variable_names( + const std::string& domain, + std::stop_token stop_token = {}) override; + [[nodiscard]] ControlTransportWriteResult write( + const mms::MmsObjectName& object, + mms::MmsDataValue value, + std::stop_token stop_token = {}) override; + void clear_information_reports() override; + [[nodiscard]] bool wait_information_report( + std::chrono::milliseconds timeout, + mms::MmsInformationReport& report, + std::stop_token stop_token = {}) override; + +private: + mms::MmsAssociationRuntime& association_; + std::uint64_t association_id_{}; +}; + +struct ControlObjectDescriptor final { + ControlObjectReference object{}; + ControlModel model{ControlModel::unknown}; + mms::MmsTypeSpecification ctl_val_specification{}; + mms::MmsTypeSpecification operate_specification{}; + std::optional select_with_value_specification; + std::optional cancel_specification; + std::optional status_object; + std::string status_functional_constraint; + std::chrono::milliseconds sbo_timeout{10'000}; + std::chrono::milliseconds operate_timeout{10'000}; + bool supports_time_activated_operate{}; + bool supports_command_termination{}; + std::string cdc; + std::vector discovery_evidence; + + [[nodiscard]] bool requires_select() const noexcept { + return model_requires_select(model); + } + [[nodiscard]] bool enhanced() const noexcept { + return model_is_enhanced(model); + } + [[nodiscard]] bool operationally_ready() const noexcept; +}; + +struct ControlDiscoveryOptions final { + std::chrono::milliseconds default_sbo_timeout{10'000}; + std::chrono::milliseconds default_operate_timeout{10'000}; +}; + +class ControlDescriptorDiscovery final { +public: + [[nodiscard]] static ControlObjectDescriptor discover( + ControlTransport& transport, + const std::string& object_reference, + ControlDiscoveryOptions options = {}, + std::stop_token stop_token = {}); +}; + +struct ControlRequest final { + ControlValue control_value{ControlValue::boolean(false)}; + OriginCategory origin_category{OriginCategory::station_control}; + std::vector origin_identifier; + std::optional control_number; + std::optional operate_at; + bool test{}; + bool interlock_check{true}; + bool synchro_check{true}; + bool auto_select{true}; + std::optional command_termination_timeout; +}; + +enum class ControlCompletionState : std::uint8_t { + accepted, + positive_termination, + negative_termination, + rejected, + unsupported, + timed_out, + association_lost, + cancelled, +}; + +struct ControlActionResult final { + ControlAction action{ControlAction::operate}; + ControlCompletionState completion{ControlCompletionState::rejected}; + bool request_accepted{}; + bool command_termination_received{}; + bool positive_termination{}; + std::uint8_t control_number{}; + ControlError control_error{ControlError::no_error}; + AddCause add_cause{AddCause::none}; + std::int64_t raw_control_error{}; + std::int64_t raw_add_cause{25}; + std::optional mms_failure_code; + std::string message; + std::vector diagnostics; + + [[nodiscard]] bool success() const noexcept { + return completion == ControlCompletionState::accepted || + completion == ControlCompletionState::positive_termination; + } +}; + +struct ControlSessionOptions final { + std::chrono::milliseconds application_error_grace_period{400}; + bool require_exact_named_control_fields{true}; + GuardedControlPolicy guarded_policy{}; // authorize==nullptr => default deny. +}; + +class ControlObjectSession final { +public: + ControlObjectSession( + ControlTransport& transport, + ControlObjectDescriptor descriptor, + ControlSessionOptions options = {}); + ~ControlObjectSession(); + + ControlObjectSession(const ControlObjectSession&) = delete; + ControlObjectSession& operator=(const ControlObjectSession&) = delete; + + [[nodiscard]] const ControlObjectDescriptor& descriptor() const noexcept { + return descriptor_; + } + [[nodiscard]] bool has_active_selection() const noexcept { + return active_context_.has_value(); + } + + [[nodiscard]] ControlActionResult select( + const ControlRequest& request, + std::stop_token stop_token = {}); + [[nodiscard]] ControlActionResult select_with_value( + const ControlRequest& request, + std::stop_token stop_token = {}); + [[nodiscard]] ControlActionResult operate( + const ControlRequest& request, + std::stop_token stop_token = {}); + [[nodiscard]] ControlActionResult cancel( + std::stop_token stop_token = {}); + + void on_association_closed() noexcept; + +private: + struct ActiveContext final { + ControlRequest request{}; + MmsControlSequenceContext mms{}; + std::vector encoded_ctl_value; + std::uint64_t timestamp_token{}; + std::uint64_t operate_at_token{}; + std::chrono::steady_clock::time_point created{}; + }; + + [[nodiscard]] ActiveContext create_context(const ControlRequest& request); + [[nodiscard]] ControlSequenceView sequence_view( + const ControlRequest& request, + const ActiveContext& context, + const std::vector& encoded_ctl_value) const noexcept; + [[nodiscard]] ControlActionResult write_failure( + ControlAction action, + const ActiveContext& context, + const ControlTransportWriteResult& write, + const std::optional& app_error = std::nullopt) const; + [[nodiscard]] std::optional wait_for_termination( + std::chrono::milliseconds timeout, + std::stop_token stop_token); + [[nodiscard]] std::optional wait_for_application_error( + std::chrono::milliseconds timeout, + std::stop_token stop_token); + [[nodiscard]] bool selection_expired(const ActiveContext& context) const noexcept; + [[nodiscard]] std::chrono::milliseconds effective_operate_timeout( + const ControlRequest& request) const; + [[nodiscard]] bool positive_sbo_selection(const mms::MmsDataValue& value) const; + void best_effort_cancel() noexcept; + void clear_selection() noexcept; + + ControlTransport& transport_; + ControlObjectDescriptor descriptor_{}; + ControlSessionOptions options_{}; + GuardedControlPlanner planner_; + std::optional active_context_; + std::optional expired_context_; + std::uint8_t next_control_number_{}; +}; + +} // namespace ar::iec61850::control From 6cc7bf2c30a2803467012bea8528401cef43a8f0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:50:37 +0700 Subject: [PATCH 25/66] Add production control transport over MMS association runtime --- .../mms_association_control_transport.cpp | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 src/control/mms_association_control_transport.cpp diff --git a/src/control/mms_association_control_transport.cpp b/src/control/mms_association_control_transport.cpp new file mode 100644 index 000000000..16b1ada5f --- /dev/null +++ b/src/control/mms_association_control_transport.cpp @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/control_session.hpp" + +#include "ariec61850/mms/pdu.hpp" +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::control { +namespace { + +std::atomic g_next_control_association_id{1U}; + +[[nodiscard]] std::span response_payload( + const mms::MmsConfirmedExchangeResult& exchange) noexcept { + return exchange.presentation_payload.empty() + ? std::span{exchange.envelope.mms_payload} + : std::span{exchange.presentation_payload}; +} + +[[nodiscard]] std::uint64_t allocate_association_id() noexcept { + auto value = g_next_control_association_id.fetch_add(1U, std::memory_order_relaxed); + if (value == 0U) { + value = g_next_control_association_id.fetch_add(1U, std::memory_order_relaxed); + } + return value; +} + +} // namespace + +MmsAssociationControlTransport::MmsAssociationControlTransport( + mms::MmsAssociationRuntime& association) noexcept + : association_{association}, association_id_{allocate_association_id()} {} + +bool MmsAssociationControlTransport::associated() const noexcept { + return association_.associated(); +} + +std::uint64_t MmsAssociationControlTransport::association_id() const noexcept { + return association_id_; +} + +std::optional MmsAssociationControlTransport::read( + const mms::MmsObjectName& object, + const std::stop_token stop_token) { + if (!association_.associated()) { + return std::nullopt; + } + const auto invoke_id = association_.next_invoke_id(); + mms::MmsReadRequest request; + request.invoke_id = invoke_id; + request.variables.push_back(object); + const auto encoded = mms::MmsServiceCodec::encode_read_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed(encoded, invoke_id, stop_token); + if (exchange.envelope.kind == mms::MmsPduKind::confirmed_error) { + return std::nullopt; + } + const auto response = mms::MmsServiceCodec::decode_read_response( + response_payload(exchange), invoke_id); + if (response.results.size() != 1U || !response.results.front().success()) { + return std::nullopt; + } + return response.results.front().value; +} + +std::optional +MmsAssociationControlTransport::variable_specification( + const mms::MmsObjectName& object, + const std::stop_token stop_token) { + if (!association_.associated()) { + return std::nullopt; + } + const auto invoke_id = association_.next_invoke_id(); + mms::MmsVariableAccessAttributesRequest request; + request.invoke_id = invoke_id; + request.name = object; + const auto encoded = + mms::MmsServiceCodec::encode_variable_access_attributes_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed(encoded, invoke_id, stop_token); + if (exchange.envelope.kind == mms::MmsPduKind::confirmed_error) { + return std::nullopt; + } + const auto response = + mms::MmsServiceCodec::decode_variable_access_attributes_response( + response_payload(exchange), invoke_id); + return response.type; +} + +std::vector MmsAssociationControlTransport::domain_variable_names( + const std::string& domain, + const std::stop_token stop_token) { + if (!association_.associated() || domain.empty()) { + return {}; + } + + std::vector names; + std::string continue_after; + constexpr std::size_t maximum_pages = 4'096U; + for (std::size_t page = 0U; page < maximum_pages; ++page) { + const auto invoke_id = association_.next_invoke_id(); + mms::MmsGetNameListRequest request; + request.invoke_id = invoke_id; + request.object_class = mms::MmsGetNameListObjectClass::named_variable; + request.scope = mms::MmsObjectScopeKind::domain_specific; + request.domain_id = domain; + request.continue_after = continue_after; + const auto encoded = mms::MmsServiceCodec::encode_get_name_list_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed(encoded, invoke_id, stop_token); + if (exchange.envelope.kind == mms::MmsPduKind::confirmed_error) { + throw std::runtime_error("GetNameList returned a confirmed MMS error during control discovery."); + } + const auto response = mms::MmsServiceCodec::decode_get_name_list_response( + response_payload(exchange), invoke_id); + names.insert(names.end(), response.names.begin(), response.names.end()); + if (!response.more_follows) { + break; + } + if (response.names.empty()) { + throw std::runtime_error( + "GetNameList asserted moreFollows without returning a continuation name."); + } + const auto next = response.names.back(); + if (next.empty() || next == continue_after) { + throw std::runtime_error("GetNameList continuation did not advance."); + } + continue_after = next; + if (page + 1U == maximum_pages) { + throw std::runtime_error("GetNameList exceeded the bounded control-discovery page limit."); + } + } + + std::sort(names.begin(), names.end()); + names.erase(std::unique(names.begin(), names.end()), names.end()); + return names; +} + +ControlTransportWriteResult MmsAssociationControlTransport::write( + const mms::MmsObjectName& object, + mms::MmsDataValue value, + const std::stop_token stop_token) { + if (!association_.associated()) { + return {}; + } + const auto invoke_id = association_.next_invoke_id(); + mms::MmsWriteRequest request; + request.invoke_id = invoke_id; + request.variables.push_back(object); + request.values.push_back(std::move(value)); + const auto encoded = mms::MmsServiceCodec::encode_write_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed(encoded, invoke_id, stop_token); + if (exchange.envelope.kind == mms::MmsPduKind::confirmed_error) { + return {}; + } + const auto response = mms::MmsServiceCodec::decode_write_response( + response_payload(exchange), invoke_id); + if (response.results.size() != 1U) { + return {}; + } + const auto& result = response.results.front(); + return {result.success, result.failure_code}; +} + +void MmsAssociationControlTransport::clear_information_reports() { + std::vector ignored; + while (association_.try_pop_information_report(ignored)) { + } +} + +bool MmsAssociationControlTransport::wait_information_report( + const std::chrono::milliseconds timeout, + mms::MmsInformationReport& report, + const std::stop_token stop_token) { + report = {}; + if (!association_.associated() || timeout <= std::chrono::milliseconds::zero()) { + return false; + } + + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (association_.associated()) { + std::vector queued; + if (association_.try_pop_information_report(queued)) { + report = mms::MmsInformationReportCodec::decode(queued); + return true; + } + + const auto now = std::chrono::steady_clock::now(); + if (now >= deadline) { + return false; + } + auto remaining = std::chrono::duration_cast(deadline - now); + if (remaining <= std::chrono::milliseconds::zero()) { + remaining = std::chrono::milliseconds{1}; + } + + mms::MmsPduEnvelope envelope; + if (!association_.try_poll_once_for(remaining, envelope, stop_token)) { + return false; + } + // route_received_payload() queues InformationReports. Any other PDU is + // intentionally ignored here while preserving its normal router path. + } + return false; +} + +} // namespace ar::iec61850::control From dd57b97055b092c1da5da443d7902722e706b926 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:51:44 +0700 Subject: [PATCH 26/66] Implement live ctlModel and TypeSpecification control discovery --- src/control/control_discovery.cpp | 324 ++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 src/control/control_discovery.cpp diff --git a/src/control/control_discovery.cpp b/src/control/control_discovery.cpp new file mode 100644 index 000000000..a024243ab --- /dev/null +++ b/src/control/control_discovery.cpp @@ -0,0 +1,324 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/control_session.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::control { +namespace { + +[[nodiscard]] std::string normalize_name(const std::string& value) { + std::string result; + result.reserve(value.size()); + for (const auto character : value) { + const auto byte = static_cast(character); + if (std::isalnum(byte) != 0) { + result.push_back(static_cast(std::tolower(byte))); + } + } + return result; +} + +[[nodiscard]] mms::MmsObjectName object_name( + const ControlObjectReference& object, + const std::string_view functional_constraint, + const std::string_view leaf) { + std::array item{}; + std::size_t bytes{}; + if (!build_control_item(object, functional_constraint, leaf, item, bytes) || + bytes == 0U || bytes > item.size()) { + throw std::invalid_argument("Control MMS object name exceeds the bounded identifier buffer."); + } + return mms::MmsObjectName::domain_specific( + std::string{object.domain}, std::string{item.data(), bytes}); +} + +[[nodiscard]] std::optional numeric_value( + const mms::MmsDataValue& value) noexcept { + if (value.kind() == mms::MmsDataKind::integer) { + if (const auto* number = std::get_if(&value.value())) { + return *number; + } + } + if (value.kind() == mms::MmsDataKind::unsigned_integer) { + if (const auto* number = std::get_if(&value.value())) { + if (*number <= static_cast( + std::numeric_limits::max())) { + return static_cast(*number); + } + } + } + return std::nullopt; +} + +[[nodiscard]] ControlModel decode_ctl_model(const mms::MmsDataValue& value) noexcept { + const auto number = numeric_value(value); + if (!number.has_value()) { + return ControlModel::unknown; + } + switch (number.value()) { + case 0: return ControlModel::status_only; + case 1: return ControlModel::direct_normal; + case 2: return ControlModel::select_before_operate_normal; + case 3: return ControlModel::direct_enhanced; + case 4: return ControlModel::select_before_operate_enhanced; + default: return ControlModel::unknown; + } +} + +[[nodiscard]] const mms::MmsTypeSpecification* find_child( + const mms::MmsTypeSpecification& parent, + const std::string& name) noexcept { + const auto target = normalize_name(name); + for (const auto& child : parent.children) { + if (normalize_name(child.name) == target) { + return &child; + } + } + return nullptr; +} + +[[nodiscard]] bool specifications_compatible( + const mms::MmsTypeSpecification& left, + const mms::MmsTypeSpecification& right) noexcept { + if (left.kind != right.kind || left.size != right.size || + left.exponent_width != right.exponent_width || + left.children.size() != right.children.size()) { + return false; + } + for (std::size_t index = 0U; index < left.children.size(); ++index) { + if (!specifications_compatible(left.children[index], right.children[index])) { + return false; + } + } + return true; +} + +[[nodiscard]] bool contains_name( + const std::vector& names, + const std::string& item) { + return std::any_of(names.begin(), names.end(), [&](const std::string& candidate) { + if (candidate.size() != item.size()) { + return false; + } + for (std::size_t index = 0U; index < item.size(); ++index) { + const auto left = static_cast(candidate[index]); + const auto right = static_cast(item[index]); + if (std::tolower(left) != std::tolower(right)) { + return false; + } + } + return true; + }); +} + +[[nodiscard]] std::chrono::milliseconds read_timeout_or_fallback( + ControlTransport& transport, + const mms::MmsObjectName& object, + const std::vector& names, + const std::chrono::milliseconds fallback, + const std::stop_token stop_token) { + if (!contains_name(names, object.item)) { + return fallback; + } + const auto value = transport.read(object, stop_token); + if (!value.has_value()) { + return fallback; + } + const auto number = numeric_value(value.value()); + if (!number.has_value() || number.value() <= 0) { + return fallback; + } + return std::chrono::milliseconds{number.value()}; +} + +[[nodiscard]] std::optional find_status_object( + const ControlObjectReference& object, + const std::vector& names, + std::string& functional_constraint) { + std::string data_path{object.data_object_path}; + std::replace(data_path.begin(), data_path.end(), '.', '$'); + const std::string ln{object.logical_node}; + const std::array, 4U> candidates{{ + {ln + "$ST$" + data_path + "$stVal", "ST"}, + {ln + "$ST$" + data_path + "$posVal", "ST"}, + {ln + "$MX$" + data_path + "$mag$f", "MX"}, + {ln + "$MX$" + data_path + "$mag$i", "MX"}, + }}; + for (const auto& [item, fc] : candidates) { + if (contains_name(names, item)) { + functional_constraint = fc; + return mms::MmsObjectName::domain_specific( + std::string{object.domain}, item); + } + } + functional_constraint.clear(); + return std::nullopt; +} + +[[nodiscard]] std::string infer_cdc(const mms::MmsTypeSpecification& ctl_val) { + switch (ctl_val.kind) { + case mms::MmsTypeKind::boolean: return "SPC"; + case mms::MmsTypeKind::bit_string: return "DPC"; + case mms::MmsTypeKind::floating_point: return "APC"; + case mms::MmsTypeKind::integer: + case mms::MmsTypeKind::unsigned_integer: + case mms::MmsTypeKind::bcd: + return "INC/ISC"; + case mms::MmsTypeKind::structure: { + bool pos = false; + bool trans = false; + bool f = false; + bool i = false; + for (const auto& child : ctl_val.children) { + const auto name = normalize_name(child.name); + pos = pos || name == "posval" || name == "position"; + trans = trans || name == "transind" || name == "transient"; + f = f || name == "f"; + i = i || name == "i"; + } + if (pos || trans) return "BSC"; + if (f || i) return "APC"; + return "vendor-specific"; + } + default: + return "vendor-specific"; + } +} + +[[nodiscard]] bool has_named_child( + const mms::MmsTypeSpecification& specification, + const std::string& name) noexcept { + return find_child(specification, name) != nullptr; +} + +} // namespace + +bool ControlObjectDescriptor::operationally_ready() const noexcept { + if (!object.valid() || model == ControlModel::status_only || + model == ControlModel::unknown || + operate_specification.kind != mms::MmsTypeKind::structure || + ctl_val_specification.kind == mms::MmsTypeKind::unknown) { + return false; + } + if (requires_select() && !cancel_specification.has_value()) { + return false; + } + if (model == ControlModel::select_before_operate_enhanced && + !select_with_value_specification.has_value()) { + return false; + } + if (enhanced() && !supports_command_termination) { + return false; + } + return true; +} + +ControlObjectDescriptor ControlDescriptorDiscovery::discover( + ControlTransport& transport, + const std::string& object_reference_text, + const ControlDiscoveryOptions options, + const std::stop_token stop_token) { + if (!transport.associated()) { + throw std::invalid_argument("IEC 61850 control discovery requires an active MMS association."); + } + if (options.default_sbo_timeout <= std::chrono::milliseconds::zero() || + options.default_operate_timeout <= std::chrono::milliseconds::zero()) { + throw std::invalid_argument("Control discovery fallback timeouts must be positive."); + } + + ControlObjectReference object; + if (!try_parse_control_object_reference(object_reference_text, object)) { + throw std::invalid_argument( + "Control object reference must use the controllable Data Object root LD/LN.DO."); + } + + ControlObjectDescriptor descriptor; + descriptor.object = object; + + const auto ctl_model_object = object_name(object, "CF", "ctlModel"); + const auto ctl_model_value = transport.read(ctl_model_object, stop_token); + if (!ctl_model_value.has_value()) { + throw std::runtime_error("Cannot discover ctlModel for the control object."); + } + descriptor.model = decode_ctl_model(ctl_model_value.value()); + descriptor.discovery_evidence.push_back( + "ctlModel=" + std::to_string(static_cast(descriptor.model))); + if (descriptor.model == ControlModel::status_only || + descriptor.model == ControlModel::unknown) { + throw std::runtime_error("Control object is not command-ready according to ctlModel."); + } + + const auto oper_object = object_name(object, "CO", "Oper"); + const auto oper_specification = transport.variable_specification(oper_object, stop_token); + if (!oper_specification.has_value() || + oper_specification->kind != mms::MmsTypeKind::structure) { + throw std::runtime_error("Cannot retrieve exact live Oper TypeSpecification."); + } + const auto* ctl_val = find_child(oper_specification.value(), "ctlVal"); + if (ctl_val == nullptr) { + throw std::runtime_error( + "Live Oper TypeSpecification has no named ctlVal field; positional guessing is refused."); + } + descriptor.operate_specification = oper_specification.value(); + descriptor.ctl_val_specification = *ctl_val; + descriptor.discovery_evidence.push_back( + "Oper=" + descriptor.operate_specification.signature()); + + if (descriptor.model == ControlModel::select_before_operate_enhanced) { + const auto sbow_object = object_name(object, "CO", "SBOw"); + const auto sbow = transport.variable_specification(sbow_object, stop_token); + if (!sbow.has_value() || sbow->kind != mms::MmsTypeKind::structure) { + throw std::runtime_error("SBO enhanced requires an exact live SBOw TypeSpecification."); + } + const auto* sbow_ctl_val = find_child(sbow.value(), "ctlVal"); + if (sbow_ctl_val == nullptr || + !specifications_compatible(descriptor.ctl_val_specification, *sbow_ctl_val)) { + throw std::runtime_error( + "Live ctlVal TypeSpecification differs between Oper and SBOw."); + } + descriptor.select_with_value_specification = sbow.value(); + descriptor.discovery_evidence.push_back("SBOw=" + sbow->signature()); + } + + const auto cancel_object = object_name(object, "CO", "Cancel"); + const auto cancel = transport.variable_specification(cancel_object, stop_token); + if (cancel.has_value()) { + descriptor.cancel_specification = cancel.value(); + descriptor.discovery_evidence.push_back("Cancel=" + cancel->signature()); + } + + const auto names = transport.domain_variable_names(std::string{object.domain}, stop_token); + descriptor.sbo_timeout = read_timeout_or_fallback( + transport, + object_name(object, "CF", "sboTimeout"), + names, + options.default_sbo_timeout, + stop_token); + descriptor.operate_timeout = read_timeout_or_fallback( + transport, + object_name(object, "CF", "operTimeout"), + names, + options.default_operate_timeout, + stop_token); + descriptor.status_object = find_status_object( + object, names, descriptor.status_functional_constraint); + descriptor.supports_time_activated_operate = + has_named_child(descriptor.operate_specification, "operTm"); + descriptor.supports_command_termination = model_is_enhanced(descriptor.model); + descriptor.cdc = infer_cdc(descriptor.ctl_val_specification); + + if (!descriptor.operationally_ready()) { + throw std::runtime_error( + "Control descriptor is incomplete and cannot safely execute commands."); + } + return descriptor; +} + +} // namespace ar::iec61850::control From c58635a27dc2c2d9d660fe499eb82b6b3529d6d2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:53:49 +0700 Subject: [PATCH 27/66] Implement guarded Direct SBO and enhanced live control orchestration --- src/control/control_session.cpp | 742 ++++++++++++++++++++++++++++++++ 1 file changed, 742 insertions(+) create mode 100644 src/control/control_session.cpp diff --git a/src/control/control_session.cpp b/src/control/control_session.cpp new file mode 100644 index 000000000..484491f50 --- /dev/null +++ b/src/control/control_session.cpp @@ -0,0 +1,742 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/control_session.hpp" + +#include "ariec61850/mms/data_codec.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::control { +namespace { + +[[nodiscard]] std::string normalize_name(const std::string& value) { + std::string result; + result.reserve(value.size()); + for (const auto character : value) { + if ((character >= 'A' && character <= 'Z') || + (character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9')) { + result.push_back(character >= 'A' && character <= 'Z' + ? static_cast(character - 'A' + 'a') + : character); + } + } + return result; +} + +[[nodiscard]] const mms::MmsTypeSpecification* find_child( + const mms::MmsTypeSpecification& parent, + const std::string& name) noexcept { + const auto target = normalize_name(name); + for (const auto& child : parent.children) { + if (normalize_name(child.name) == target) { + return &child; + } + } + return nullptr; +} + +[[nodiscard]] mms::MmsObjectName object_name( + const ControlObjectReference& object, + const std::string_view functional_constraint, + const std::string_view leaf) { + std::array item{}; + std::size_t bytes{}; + if (!build_control_item(object, functional_constraint, leaf, item, bytes)) { + throw std::invalid_argument("Failed to build bounded control MMS object name."); + } + return mms::MmsObjectName::domain_specific( + std::string{object.domain}, std::string{item.data(), bytes}); +} + +[[nodiscard]] std::uint8_t next_control_number(std::uint8_t& value) noexcept { + value = value >= 255U ? 1U : static_cast(value + 1U); + return value; +} + +[[nodiscard]] std::uint64_t time_token( + const std::chrono::system_clock::time_point value) noexcept { + const auto nanos = std::chrono::duration_cast( + value.time_since_epoch()).count(); + return static_cast(nanos); +} + +[[nodiscard]] std::optional timestamp_value( + const std::chrono::system_clock::time_point value, + const mms::MmsTypeSpecification& specification) { + if (specification.kind == mms::MmsTypeKind::utc_time) { + return mms::MmsDataValue::utc_time(mms::Iec61850UtcTime{value, 0U}); + } + if (specification.kind != mms::MmsTypeKind::binary_time) { + return std::nullopt; + } + + using namespace std::chrono; + constexpr sys_days binary_epoch{year{1984}/1/1}; + const auto millis = floor(value); + const auto day = floor(millis); + if (day < binary_epoch) { + std::array zero{}; + const auto size = specification.size.value_or(6U) == 4U ? 4U : 6U; + return mms::MmsDataValue::binary_time( + std::span{zero}.first(size)); + } + + const auto milliseconds_since_midnight = + duration_cast(millis - day).count(); + const auto days_since_epoch = (day - binary_epoch).count(); + if (milliseconds_since_midnight < 0 || + milliseconds_since_midnight > static_cast( + std::numeric_limits::max()) || + days_since_epoch < 0 || days_since_epoch > 65'535) { + return std::nullopt; + } + + const auto ms = static_cast(milliseconds_since_midnight); + std::array bytes{ + static_cast((ms >> 24U) & 0xFFU), + static_cast((ms >> 16U) & 0xFFU), + static_cast((ms >> 8U) & 0xFFU), + static_cast(ms & 0xFFU), + static_cast((static_cast(days_since_epoch) >> 8U) & 0xFFU), + static_cast(static_cast(days_since_epoch) & 0xFFU), + }; + const auto size = specification.size.value_or(6U) == 4U ? 4U : 6U; + return mms::MmsDataValue::binary_time( + std::span{bytes}.first(size)); +} + +[[nodiscard]] GuardedControlPolicy planner_policy( + const ControlObjectDescriptor& descriptor, + const ControlSessionOptions& options) noexcept { + auto policy = options.guarded_policy; + policy.sbo_timeout_ms = descriptor.sbo_timeout.count() <= 0 + ? 10'000U + : static_cast(descriptor.sbo_timeout.count()); + return policy; +} + +[[nodiscard]] ControlActionResult rejected( + const ControlAction action, + std::string message, + const std::uint8_t control_number = 0U) { + ControlActionResult result; + result.action = action; + result.completion = ControlCompletionState::rejected; + result.control_number = control_number; + result.message = std::move(message); + return result; +} + +[[nodiscard]] ControlActionResult unsupported( + const ControlAction action, + std::string message) { + auto result = rejected(action, std::move(message)); + result.completion = ControlCompletionState::unsupported; + return result; +} + +[[nodiscard]] ControlActionResult from_planner_rejection( + const GuardedControlDecision& decision) { + auto result = rejected(decision.action, "Guarded control policy rejected the action.", + decision.control_number); + result.control_error = decision.control_error; + result.add_cause = decision.add_cause; + return result; +} + +[[nodiscard]] bool same_reference_text( + const std::string& selected, + const ControlObjectReference& object) { + if (selected.empty()) { + return false; + } + std::string normalized = selected; + std::replace(normalized.begin(), normalized.end(), '$', '.'); + const auto marker = std::string{".CO."}; + if (const auto position = normalized.find(marker); position != std::string::npos) { + normalized.erase(position, marker.size() - 1U); + } + const auto expected = std::string{object.domain} + "/" + + std::string{object.logical_node} + "." + std::string{object.data_object_path}; + const auto relative = std::string{object.logical_node} + "." + + std::string{object.data_object_path}; + if (normalized == expected || normalized == relative) { + return true; + } + if (normalized.size() >= relative.size() + 1U && + normalized.ends_with("/" + relative)) { + return true; + } + // C# oracle permits opaque short selection tokens, but not a token that + // looks like a different IEC 61850 object reference. + return normalized.find('/') == std::string::npos && + normalized.find('.') == std::string::npos && + normalized.find('$') == std::string::npos; +} + +} // namespace + +ControlObjectSession::ControlObjectSession( + ControlTransport& transport, + ControlObjectDescriptor descriptor, + ControlSessionOptions options) + : transport_{transport}, + descriptor_{std::move(descriptor)}, + options_{std::move(options)}, + planner_{descriptor_.object, descriptor_.model, + planner_policy(descriptor_, options_)} { + if (!transport_.associated()) { + throw std::invalid_argument("Control session requires an active MMS association."); + } + if (!descriptor_.operationally_ready()) { + throw std::invalid_argument("Control descriptor is not operationally ready."); + } + if (options_.application_error_grace_period < std::chrono::milliseconds::zero()) { + throw std::invalid_argument("Application error grace period must not be negative."); + } +} + +ControlObjectSession::~ControlObjectSession() { + best_effort_cancel(); +} + +ControlObjectSession::ActiveContext ControlObjectSession::create_context( + const ControlRequest& request) { + ActiveContext context; + context.request = request; + context.created = std::chrono::steady_clock::now(); + + const auto bound = MmsControlStructureBuilder::bind_control_value( + request.control_value, descriptor_.ctl_val_specification); + if (!bound.success()) { + throw std::invalid_argument( + "Control value does not match live ctlVal TypeSpecification at " + bound.path + "."); + } + context.encoded_ctl_value = mms::MmsDataCodec::encode(bound.value.value()); + + const auto assigned = request.control_number.value_or( + next_control_number(next_control_number_)); + const auto now = std::chrono::system_clock::now(); + const auto* timestamp_specification = find_child( + descriptor_.operate_specification, "T"); + if (timestamp_specification == nullptr) { + throw std::runtime_error("Live Oper TypeSpecification has no named T field."); + } + const auto timestamp = timestamp_value(now, *timestamp_specification); + if (!timestamp.has_value()) { + throw std::runtime_error("Live T field has an unsupported timestamp type."); + } + + context.mms.control_value = request.control_value; + context.mms.origin_category = request.origin_category; + context.mms.origin_identifier = request.origin_identifier; + context.mms.control_number = assigned; + context.mms.timestamp = timestamp.value(); + context.mms.test = request.test; + context.mms.interlock_check = request.interlock_check; + context.mms.synchro_check = request.synchro_check; + context.timestamp_token = time_token(now); + + if (request.operate_at.has_value()) { + const auto* operate_at_specification = find_child( + descriptor_.operate_specification, "operTm"); + if (operate_at_specification == nullptr) { + throw std::invalid_argument( + "Time-activated operation requested but live Oper has no operTm field."); + } + const auto operate_at = timestamp_value( + request.operate_at.value(), *operate_at_specification); + if (!operate_at.has_value()) { + throw std::invalid_argument("Live operTm field has an unsupported timestamp type."); + } + context.mms.operate_at = operate_at.value(); + context.operate_at_token = time_token(request.operate_at.value()); + } + return context; +} + +ControlSequenceView ControlObjectSession::sequence_view( + const ControlRequest& request, + const ActiveContext& context, + const std::vector& encoded_ctl_value) const noexcept { + ControlSequenceView sequence; + sequence.control_value = encoded_ctl_value; + sequence.origin_category = request.origin_category; + sequence.origin_identifier = request.origin_identifier; + sequence.control_number = request.control_number.value_or(context.mms.control_number); + sequence.timestamp_token = context.timestamp_token; + sequence.operate_at_token = request.operate_at.has_value() + ? time_token(request.operate_at.value()) + : 0U; + sequence.test = request.test; + sequence.interlock_check = request.interlock_check; + sequence.synchro_check = request.synchro_check; + return sequence; +} + +bool ControlObjectSession::positive_sbo_selection( + const mms::MmsDataValue& value) const { + if (value.kind() == mms::MmsDataKind::boolean) { + if (const auto* selected = std::get_if(&value.value())) { + return *selected; + } + return false; + } + if (value.kind() == mms::MmsDataKind::visible_string || + value.kind() == mms::MmsDataKind::mms_string) { + if (const auto* selected = std::get_if(&value.value())) { + return same_reference_text(*selected, descriptor_.object); + } + } + return false; +} + +ControlActionResult ControlObjectSession::select( + const ControlRequest& request, + const std::stop_token stop_token) { + if (descriptor_.model != ControlModel::select_before_operate_normal) { + return unsupported(ControlAction::select, + "Select read is valid only for SBO normal security."); + } + if (!transport_.associated()) { + return rejected(ControlAction::select, "MMS association is not active."); + } + + auto context = create_context(request); + const auto planner_decision = planner_.select( + {transport_.association_id()}, + sequence_view(request, context, context.encoded_ctl_value), + 0U); + if (!planner_decision.accepted()) { + return from_planner_rejection(planner_decision); + } + + const auto selected = transport_.read( + object_name(descriptor_.object, "CO", "SBO"), stop_token); + if (!selected.has_value() || !positive_sbo_selection(selected.value())) { + planner_.on_association_closed(transport_.association_id()); + return rejected(ControlAction::select, + "SBO read did not return a positive selected-object reference.", + context.mms.control_number); + } + + active_context_ = context; + expired_context_.reset(); + ControlActionResult result; + result.action = ControlAction::select; + result.completion = ControlCompletionState::accepted; + result.request_accepted = true; + result.control_number = context.mms.control_number; + result.message = "SBO select accepted; exact sequence retained until Operate/Cancel/timeout."; + return result; +} + +ControlActionResult ControlObjectSession::select_with_value( + const ControlRequest& request, + const std::stop_token stop_token) { + if (descriptor_.model != ControlModel::select_before_operate_enhanced || + !descriptor_.select_with_value_specification.has_value()) { + return unsupported(ControlAction::select_with_value, + "SelectWithValue is valid only for SBO enhanced security."); + } + if (!transport_.associated()) { + return rejected(ControlAction::select_with_value, "MMS association is not active."); + } + + auto context = create_context(request); + const auto planner_decision = planner_.select_with_value( + {transport_.association_id()}, + sequence_view(request, context, context.encoded_ctl_value), + 0U); + if (!planner_decision.accepted()) { + return from_planner_rejection(planner_decision); + } + + const auto value = MmsControlStructureBuilder::build_select_with_value( + context.mms, + descriptor_.select_with_value_specification.value(), + options_.require_exact_named_control_fields); + if (!value.success()) { + planner_.on_association_closed(transport_.association_id()); + return rejected(ControlAction::select_with_value, + "Cannot construct exact live SBOw structure at " + value.path + ".", + context.mms.control_number); + } + + transport_.clear_information_reports(); + const auto write = transport_.write( + object_name(descriptor_.object, "CO", "SBOw"), + value.value.value(), stop_token); + if (!write.success) { + const auto app_error = wait_for_application_error( + options_.application_error_grace_period, stop_token); + planner_.on_association_closed(transport_.association_id()); + return write_failure(ControlAction::select_with_value, context, write, app_error); + } + + const auto post_error = wait_for_application_error( + options_.application_error_grace_period, stop_token); + if (post_error.has_value() && !post_error->positive) { + planner_.on_association_closed(transport_.association_id()); + ControlActionResult result; + result.action = ControlAction::select_with_value; + result.completion = ControlCompletionState::rejected; + result.request_accepted = true; + result.control_number = context.mms.control_number; + result.control_error = post_error->control_error; + result.add_cause = post_error->add_cause; + result.raw_control_error = post_error->raw_control_error; + result.raw_add_cause = post_error->raw_add_cause; + result.message = "SBOw received asynchronous LastApplError after MMS service acceptance."; + return result; + } + + active_context_ = context; + expired_context_.reset(); + ControlActionResult result; + result.action = ControlAction::select_with_value; + result.completion = ControlCompletionState::accepted; + result.request_accepted = true; + result.control_number = context.mms.control_number; + result.message = "SBOw accepted; exact selected sequence retained for Operate."; + return result; +} + +ControlActionResult ControlObjectSession::operate( + const ControlRequest& request, + const std::stop_token stop_token) { + if (!descriptor_.operationally_ready()) { + return unsupported(ControlAction::operate, "Control descriptor is not operationally ready."); + } + if (request.operate_at.has_value() && !descriptor_.supports_time_activated_operate) { + return unsupported(ControlAction::operate, + "Time-activated operation requested but live Oper has no operTm."); + } + if (!transport_.associated()) { + return rejected(ControlAction::operate, "MMS association is not active."); + } + + if (descriptor_.requires_select() && !active_context_.has_value()) { + if (expired_context_.has_value()) { + try { + const auto rebound = MmsControlStructureBuilder::bind_control_value( + request.control_value, descriptor_.ctl_val_specification); + if (rebound.success() && + mms::MmsDataCodec::encode(rebound.value.value()) == + expired_context_->encoded_ctl_value) { + const auto number = expired_context_->mms.control_number; + expired_context_.reset(); + ControlActionResult result; + result.action = ControlAction::operate; + result.completion = ControlCompletionState::timed_out; + result.control_number = number; + result.message = "SBO selection timeout expired before Operate."; + return result; + } + } catch (...) { + } + expired_context_.reset(); + } + if (!request.auto_select) { + return rejected(ControlAction::operate, + "Object is not selected and auto_select=false."); + } + const auto selected = descriptor_.model == + ControlModel::select_before_operate_enhanced + ? select_with_value(request, stop_token) + : select(request, stop_token); + if (!selected.success()) { + return selected; + } + } + + ActiveContext context = active_context_.has_value() + ? active_context_.value() + : create_context(request); + + if (active_context_.has_value() && selection_expired(context)) { + expired_context_ = context; + best_effort_cancel(); + ControlActionResult result; + result.action = ControlAction::operate; + result.completion = ControlCompletionState::timed_out; + result.control_number = context.mms.control_number; + result.message = "SBO selection timeout expired before Operate."; + return result; + } + + const auto incoming_bound = MmsControlStructureBuilder::bind_control_value( + request.control_value, descriptor_.ctl_val_specification); + if (!incoming_bound.success()) { + best_effort_cancel(); + return rejected(ControlAction::operate, + "Operate ctlVal no longer matches the selected live type.", + context.mms.control_number); + } + const auto incoming_encoded = mms::MmsDataCodec::encode(incoming_bound.value.value()); + const auto planner_decision = planner_.operate( + {transport_.association_id()}, + sequence_view(request, context, incoming_encoded), + 0U); + if (!planner_decision.accepted()) { + best_effort_cancel(); + return from_planner_rejection(planner_decision); + } + + const auto oper_value = MmsControlStructureBuilder::build_operate( + context.mms, + descriptor_.operate_specification, + options_.require_exact_named_control_fields); + if (!oper_value.success()) { + clear_selection(); + return rejected(ControlAction::operate, + "Cannot construct exact live Oper structure at " + oper_value.path + ".", + context.mms.control_number); + } + + transport_.clear_information_reports(); + ControlTransportWriteResult write; + try { + write = transport_.write( + object_name(descriptor_.object, "CO", "Oper"), + oper_value.value.value(), stop_token); + } catch (const mms::MmsTransportCancelledError&) { + clear_selection(); + ControlActionResult result; + result.action = ControlAction::operate; + result.completion = ControlCompletionState::cancelled; + result.control_number = context.mms.control_number; + result.message = "Control operation cancelled before confirmed Write completion."; + return result; + } + + if (!write.success) { + const auto app_error = wait_for_application_error( + options_.application_error_grace_period, stop_token); + clear_selection(); + return write_failure(ControlAction::operate, context, write, app_error); + } + + if (!descriptor_.enhanced()) { + clear_selection(); + ControlActionResult result; + result.action = ControlAction::operate; + result.completion = ControlCompletionState::accepted; + result.request_accepted = true; + result.control_number = context.mms.control_number; + result.message = "Operate service accepted (normal-security completion boundary)."; + return result; + } + + const auto termination = wait_for_termination( + effective_operate_timeout(request), stop_token); + if (!termination.has_value()) { + const bool still_associated = transport_.associated(); + clear_selection(); + ControlActionResult result; + result.action = ControlAction::operate; + result.completion = still_associated + ? ControlCompletionState::timed_out + : ControlCompletionState::association_lost; + result.request_accepted = true; + result.control_number = context.mms.control_number; + result.message = still_associated + ? "Operate was accepted, but no correlated CommandTermination arrived before timeout." + : "MMS association was lost while waiting for CommandTermination."; + return result; + } + + const auto planner_completion = planner_.command_termination( + termination->control_error, termination->add_cause); + clear_selection(); + ControlActionResult result; + result.action = ControlAction::operate; + result.completion = termination->positive + ? ControlCompletionState::positive_termination + : ControlCompletionState::negative_termination; + result.request_accepted = true; + result.command_termination_received = true; + result.positive_termination = termination->positive; + result.control_number = context.mms.control_number; + result.control_error = termination->control_error; + result.add_cause = termination->add_cause; + result.raw_control_error = termination->raw_control_error; + result.raw_add_cause = termination->raw_add_cause; + result.message = termination->positive + ? "Positive CommandTermination received." + : "Negative CommandTermination / LastApplError received."; + result.diagnostics.push_back( + "planner_completion=" + std::to_string(static_cast(planner_completion.status))); + return result; +} + +ControlActionResult ControlObjectSession::cancel( + const std::stop_token stop_token) { + if (!active_context_.has_value()) { + return rejected(ControlAction::cancel, + "No active selection exists for this control session."); + } + if (!descriptor_.cancel_specification.has_value()) { + clear_selection(); + return unsupported(ControlAction::cancel, + "IED did not expose a decodable Cancel TypeSpecification."); + } + + const auto context = active_context_.value(); + const auto planner_decision = planner_.cancel( + {transport_.association_id()}, 0U); + if (!planner_decision.accepted()) { + return from_planner_rejection(planner_decision); + } + + const auto cancel_value = MmsControlStructureBuilder::build_cancel( + context.mms, + descriptor_.cancel_specification.value(), + options_.require_exact_named_control_fields); + if (!cancel_value.success()) { + clear_selection(); + return rejected(ControlAction::cancel, + "Cannot construct exact live Cancel structure at " + cancel_value.path + ".", + context.mms.control_number); + } + + const auto write = transport_.write( + object_name(descriptor_.object, "CO", "Cancel"), + cancel_value.value.value(), stop_token); + clear_selection(); + if (!write.success) { + return write_failure(ControlAction::cancel, context, write); + } + + ControlActionResult result; + result.action = ControlAction::cancel; + result.completion = ControlCompletionState::accepted; + result.request_accepted = true; + result.control_number = context.mms.control_number; + result.message = "Cancel accepted; local SBO ownership released."; + return result; +} + +ControlActionResult ControlObjectSession::write_failure( + const ControlAction action, + const ActiveContext& context, + const ControlTransportWriteResult& write, + const std::optional& app_error) const { + ControlActionResult result; + result.action = action; + result.completion = ControlCompletionState::rejected; + result.control_number = context.mms.control_number; + result.mms_failure_code = write.failure_code; + result.message = "MMS control Write was rejected."; + if (app_error.has_value()) { + result.control_error = app_error->control_error; + result.add_cause = app_error->add_cause; + result.raw_control_error = app_error->raw_control_error; + result.raw_add_cause = app_error->raw_add_cause; + result.diagnostics.push_back( + app_error->control_error_name + "/" + app_error->add_cause_name); + } + return result; +} + +std::optional ControlObjectSession::wait_for_termination( + const std::chrono::milliseconds timeout, + const std::stop_token stop_token) { + if (timeout <= std::chrono::milliseconds::zero()) { + return std::nullopt; + } + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (transport_.associated()) { + const auto now = std::chrono::steady_clock::now(); + if (now >= deadline) { + return std::nullopt; + } + auto remaining = std::chrono::duration_cast(deadline - now); + if (remaining <= std::chrono::milliseconds::zero()) { + remaining = std::chrono::milliseconds{1}; + } + mms::MmsInformationReport report; + if (!transport_.wait_information_report(remaining, report, stop_token)) { + return std::nullopt; + } + auto termination = CommandTerminationDecoder::decode(report, descriptor_.object); + if (termination.is_for_control_object && termination.is_termination) { + return termination; + } + } + return std::nullopt; +} + +std::optional ControlObjectSession::wait_for_application_error( + const std::chrono::milliseconds timeout, + const std::stop_token stop_token) { + return wait_for_termination(timeout, stop_token); +} + +bool ControlObjectSession::selection_expired(const ActiveContext& context) const noexcept { + return std::chrono::steady_clock::now() - context.created > descriptor_.sbo_timeout; +} + +std::chrono::milliseconds ControlObjectSession::effective_operate_timeout( + const ControlRequest& request) const { + auto timeout = request.command_termination_timeout.value_or( + descriptor_.operate_timeout); + if (timeout <= std::chrono::milliseconds::zero()) { + timeout = descriptor_.operate_timeout; + } + if (!request.operate_at.has_value()) { + return timeout; + } + const auto now = std::chrono::system_clock::now(); + if (request.operate_at.value() <= now) { + return timeout; + } + return std::chrono::duration_cast( + request.operate_at.value() - now) + timeout; +} + +void ControlObjectSession::best_effort_cancel() noexcept { + if (!active_context_.has_value()) { + return; + } + const auto context = active_context_.value(); + try { + if (descriptor_.cancel_specification.has_value() && transport_.associated()) { + const auto value = MmsControlStructureBuilder::build_cancel( + context.mms, + descriptor_.cancel_specification.value(), + options_.require_exact_named_control_fields); + if (value.success()) { + static_cast(transport_.write( + object_name(descriptor_.object, "CO", "Cancel"), + value.value.value(), {})); + } + } + } catch (...) { + } + clear_selection(); +} + +void ControlObjectSession::clear_selection() noexcept { + planner_.on_association_closed(transport_.association_id()); + active_context_.reset(); +} + +void ControlObjectSession::on_association_closed() noexcept { + if (active_context_.has_value()) { + expired_context_.reset(); + } + planner_.on_association_closed(transport_.association_id()); + active_context_.reset(); +} + +} // namespace ar::iec61850::control From f5923f934b3976c8c10d3d89635826bf76e7a5db Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:55:36 +0700 Subject: [PATCH 28/66] Add deterministic smart-control session parity tests --- tests/test_control_session.cpp | 457 +++++++++++++++++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 tests/test_control_session.cpp diff --git a/tests/test_control_session.cpp b/tests/test_control_session.cpp new file mode 100644 index 000000000..dbd6e40e2 --- /dev/null +++ b/tests/test_control_session.cpp @@ -0,0 +1,457 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/control_session.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; +using namespace ar::iec61850::control; +using namespace ar::iec61850::mms; + +#define CHECK(condition) do { \ + if (!(condition)) { \ + throw std::runtime_error(std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ +} while (false) + +bool allow_all( + void*, + ControlAction, + const ControlObjectReference&, + const ControlClientIdentity&, + const ControlSequenceView&) noexcept { + return true; +} + +MmsTypeSpecification scalar( + const MmsTypeKind kind, + std::string name, + const std::optional size = std::nullopt) { + MmsTypeSpecification result; + result.kind = kind; + result.name = std::move(name); + result.size = size; + return result; +} + +MmsTypeSpecification structure( + std::string name, + std::vector children) { + MmsTypeSpecification result; + result.kind = MmsTypeKind::structure; + result.name = std::move(name); + result.children = std::move(children); + return result; +} + +MmsTypeSpecification origin_spec() { + return structure("origin", { + scalar(MmsTypeKind::unsigned_integer, "orCat"), + scalar(MmsTypeKind::octet_string, "orIdent", 64U), + }); +} + +MmsTypeSpecification oper_spec(const bool include_oper_tm = false) { + std::vector children{ + scalar(MmsTypeKind::bit_string, "ctlVal", 2U), + }; + if (include_oper_tm) { + children.push_back(scalar(MmsTypeKind::utc_time, "operTm")); + } + children.push_back(origin_spec()); + children.push_back(scalar(MmsTypeKind::unsigned_integer, "ctlNum")); + children.push_back(scalar(MmsTypeKind::utc_time, "T")); + children.push_back(scalar(MmsTypeKind::boolean, "Test")); + children.push_back(scalar(MmsTypeKind::bit_string, "Check", 2U)); + return structure("Oper", std::move(children)); +} + +MmsTypeSpecification cancel_spec() { + return structure("Cancel", { + scalar(MmsTypeKind::bit_string, "ctlVal", 2U), + origin_spec(), + scalar(MmsTypeKind::unsigned_integer, "ctlNum"), + scalar(MmsTypeKind::utc_time, "T"), + scalar(MmsTypeKind::boolean, "Test"), + scalar(MmsTypeKind::bit_string, "Check", 2U), + }); +} + +MmsObjectName name(std::string item) { + return MmsObjectName::domain_specific("LD0", std::move(item)); +} + +MmsInformationReport positive_oper_report() { + MmsInformationReport report; + report.variable_references.push_back(name("CSWI1$CO$Pos$Oper")); + return report; +} + +MmsInformationReport ordinary_status_report() { + MmsInformationReport report; + report.variable_references.push_back(name("CSWI1$ST$Pos$stVal")); + MmsInformationReportItem item; + item.index = 0U; + item.value = MmsDataValue::boolean(true); + report.items.push_back(std::move(item)); + return report; +} + +MmsInformationReport negative_error_report(const std::int64_t add_cause) { + MmsInformationReport report; + MmsInformationReportItem item; + item.index = 0U; + item.value = MmsDataValue::structure({ + MmsDataValue::visible_string("LD0/CSWI1.Pos"), + MmsDataValue::integer(0), + MmsDataValue::structure({ + MmsDataValue::unsigned_integer(2U), + MmsDataValue::octet_string(std::vector{'H', 'M', 'I'}), + }), + MmsDataValue::unsigned_integer(7U), + MmsDataValue::integer(add_cause), + }); + report.items.push_back(std::move(item)); + return report; +} + +class ScriptedControlTransport final : public ControlTransport { +public: + bool is_associated{true}; + std::uint64_t id{0xA55AU}; + std::map reads; + std::map specifications; + std::map> names; + std::deque write_results; + std::deque reports; + std::vector events; + std::vector writes; + std::size_t clear_count{}; + + [[nodiscard]] bool associated() const noexcept override { return is_associated; } + [[nodiscard]] std::uint64_t association_id() const noexcept override { return id; } + + [[nodiscard]] std::optional read( + const MmsObjectName& object, + std::stop_token = {}) override { + events.push_back("read:" + object.reference()); + const auto found = reads.find(object.reference()); + return found == reads.end() ? std::nullopt + : std::optional{found->second}; + } + + [[nodiscard]] std::optional variable_specification( + const MmsObjectName& object, + std::stop_token = {}) override { + events.push_back("gvaa:" + object.reference()); + const auto found = specifications.find(object.reference()); + return found == specifications.end() + ? std::nullopt + : std::optional{found->second}; + } + + [[nodiscard]] std::vector domain_variable_names( + const std::string& domain, + std::stop_token = {}) override { + events.push_back("names:" + domain); + const auto found = names.find(domain); + return found == names.end() ? std::vector{} : found->second; + } + + [[nodiscard]] ControlTransportWriteResult write( + const MmsObjectName& object, + MmsDataValue, + std::stop_token = {}) override { + events.push_back("write:" + object.reference()); + writes.push_back(object.reference()); + if (write_results.empty()) { + return {true, std::nullopt}; + } + const auto result = write_results.front(); + write_results.pop_front(); + return result; + } + + void clear_information_reports() override { + ++clear_count; + events.push_back("clear-reports"); + reports.clear(); + for (auto& report : reports_after_clear) { + reports.push_back(std::move(report)); + } + reports_after_clear.clear(); + } + + [[nodiscard]] bool wait_information_report( + std::chrono::milliseconds, + MmsInformationReport& report, + std::stop_token = {}) override { + events.push_back("wait-report"); + if (reports.empty()) { + return false; + } + report = std::move(reports.front()); + reports.pop_front(); + return true; + } + + std::vector reports_after_clear; +}; + +ControlObjectReference parsed_object() { + ControlObjectReference object; + CHECK(try_parse_control_object_reference("LD0/CSWI1.Pos", object)); + return object; +} + +ControlObjectDescriptor descriptor(const ControlModel model) { + ControlObjectDescriptor result; + result.object = parsed_object(); + result.model = model; + result.ctl_val_specification = scalar(MmsTypeKind::bit_string, "ctlVal", 2U); + result.operate_specification = oper_spec(); + result.cancel_specification = cancel_spec(); + if (model == ControlModel::select_before_operate_enhanced) { + auto sbow = oper_spec(); + sbow.name = "SBOw"; + result.select_with_value_specification = sbow; + } + result.sbo_timeout = std::chrono::milliseconds{10'000}; + result.operate_timeout = std::chrono::milliseconds{10'000}; + result.supports_command_termination = model_is_enhanced(model); + result.supports_time_activated_operate = false; + result.cdc = "DPC"; + return result; +} + +ControlSessionOptions options() { + ControlSessionOptions result; + result.application_error_grace_period = std::chrono::milliseconds{1}; + result.guarded_policy.authorize = allow_all; + return result; +} + +ControlRequest request(const DoublePointValue value = DoublePointValue::on) { + ControlRequest result; + result.control_value = ControlValue::double_point(value); + result.origin_category = OriginCategory::station_control; + result.origin_identifier = {'H', 'M', 'I'}; + result.interlock_check = true; + result.synchro_check = true; + return result; +} + +void discovery_matches_csharp_ctlmodel_gvaa_and_timeout_rules() { + ScriptedControlTransport transport; + transport.reads["LD0/CSWI1$CF$Pos$ctlModel"] = MmsDataValue::integer(2); + transport.reads["LD0/CSWI1$CF$Pos$sboTimeout"] = MmsDataValue::unsigned_integer(1'500U); + transport.reads["LD0/CSWI1$CF$Pos$operTimeout"] = MmsDataValue::integer(2'500); + transport.specifications["LD0/CSWI1$CO$Pos$Oper"] = oper_spec(); + transport.specifications["LD0/CSWI1$CO$Pos$Cancel"] = cancel_spec(); + transport.names["LD0"] = { + "CSWI1$CF$Pos$sboTimeout", + "CSWI1$CF$Pos$operTimeout", + "CSWI1$ST$Pos$stVal", + }; + + const auto found = ControlDescriptorDiscovery::discover( + transport, "LD0/CSWI1.Pos"); + CHECK(found.model == ControlModel::select_before_operate_normal); + CHECK(found.cdc == "DPC"); + CHECK(found.sbo_timeout == std::chrono::milliseconds{1'500}); + CHECK(found.operate_timeout == std::chrono::milliseconds{2'500}); + CHECK(found.status_object.has_value()); + CHECK(found.status_object->reference() == "LD0/CSWI1$ST$Pos$stVal"); + CHECK(found.status_functional_constraint == "ST"); + CHECK(found.operationally_ready()); + + // Cancel is mandatory for a model that requires Select. + transport.specifications.erase("LD0/CSWI1$CO$Pos$Cancel"); + bool rejected = false; + try { + static_cast(ControlDescriptorDiscovery::discover( + transport, "LD0/CSWI1.Pos")); + } catch (const std::runtime_error&) { + rejected = true; + } + CHECK(rejected); +} + +void direct_normal_is_exactly_one_oper_write() { + ScriptedControlTransport transport; + ControlObjectSession session{ + transport, descriptor(ControlModel::direct_normal), options()}; + const auto result = session.operate(request()); + CHECK(result.success()); + CHECK(result.completion == ControlCompletionState::accepted); + CHECK(result.request_accepted); + CHECK(result.control_number == 1U); + CHECK(transport.writes.size() == 1U); + CHECK(transport.writes.front() == "LD0/CSWI1$CO$Pos$Oper"); + CHECK(transport.clear_count == 1U); + const auto clear = std::find( + transport.events.begin(), transport.events.end(), "clear-reports"); + const auto write = std::find( + transport.events.begin(), transport.events.end(), + "write:LD0/CSWI1$CO$Pos$Oper"); + CHECK(clear < write); +} + +void sbo_normal_select_oper_and_cancel_are_immutable() { + ScriptedControlTransport transport; + transport.reads["LD0/CSWI1$CO$Pos$SBO"] = + MmsDataValue::visible_string("LD0/CSWI1.Pos"); + ControlObjectSession session{ + transport, descriptor(ControlModel::select_before_operate_normal), options()}; + + auto selected = session.select(request()); + CHECK(selected.success()); + CHECK(session.has_active_selection()); + CHECK(transport.writes.empty()); // normal SBO Select is a Read. + + const auto operated = session.operate(request()); + CHECK(operated.success()); + CHECK(transport.writes.size() == 1U); + CHECK(transport.writes.front() == "LD0/CSWI1$CO$Pos$Oper"); + CHECK(!session.has_active_selection()); + + selected = session.select(request()); + CHECK(selected.success()); + const auto cancelled = session.cancel(); + CHECK(cancelled.success()); + CHECK(transport.writes.back() == "LD0/CSWI1$CO$Pos$Cancel"); + CHECK(!session.has_active_selection()); +} + +void stale_sbo_sequence_is_cancelled_not_operated() { + ScriptedControlTransport transport; + transport.reads["LD0/CSWI1$CO$Pos$SBO"] = MmsDataValue::boolean(true); + ControlObjectSession session{ + transport, descriptor(ControlModel::select_before_operate_normal), options()}; + CHECK(session.select(request(DoublePointValue::on)).success()); + + const auto result = session.operate(request(DoublePointValue::off)); + CHECK(!result.success()); + CHECK(result.completion == ControlCompletionState::rejected); + CHECK(transport.writes.size() == 1U); + CHECK(transport.writes.front() == "LD0/CSWI1$CO$Pos$Cancel"); + CHECK(!session.has_active_selection()); +} + +void auto_select_performs_sbo_read_then_one_oper_write() { + ScriptedControlTransport transport; + transport.reads["LD0/CSWI1$CO$Pos$SBO"] = MmsDataValue::visible_string("SEL-TOKEN"); + ControlObjectSession session{ + transport, descriptor(ControlModel::select_before_operate_normal), options()}; + const auto result = session.operate(request()); + CHECK(result.success()); + CHECK(transport.writes.size() == 1U); + CHECK(std::find(transport.events.begin(), transport.events.end(), + "read:LD0/CSWI1$CO$Pos$SBO") != transport.events.end()); +} + +void enhanced_oper_ignores_status_and_requires_exact_termination() { + ScriptedControlTransport transport; + transport.reports_after_clear.push_back(ordinary_status_report()); + transport.reports_after_clear.push_back(positive_oper_report()); + ControlObjectSession session{ + transport, descriptor(ControlModel::direct_enhanced), options()}; + const auto result = session.operate(request()); + CHECK(result.success()); + CHECK(result.completion == ControlCompletionState::positive_termination); + CHECK(result.command_termination_received); + CHECK(result.positive_termination); + CHECK(transport.writes.size() == 1U); +} + +void enhanced_negative_last_appl_error_is_returned() { + ScriptedControlTransport transport; + transport.reports_after_clear.push_back(negative_error_report(10)); + ControlObjectSession session{ + transport, descriptor(ControlModel::direct_enhanced), options()}; + const auto result = session.operate(request()); + CHECK(!result.success()); + CHECK(result.completion == ControlCompletionState::negative_termination); + CHECK(result.request_accepted); + CHECK(result.command_termination_received); + CHECK(result.add_cause == AddCause::blocked_by_interlocking); + CHECK(transport.writes.size() == 1U); +} + +void enhanced_timeout_is_not_association_failure() { + ScriptedControlTransport transport; + auto desc = descriptor(ControlModel::direct_enhanced); + desc.operate_timeout = std::chrono::milliseconds{1}; + ControlObjectSession session{transport, desc, options()}; + const auto result = session.operate(request()); + CHECK(!result.success()); + CHECK(result.completion == ControlCompletionState::timed_out); + CHECK(result.request_accepted); + CHECK(transport.associated()); + CHECK(transport.writes.size() == 1U); +} + +void sbow_application_error_after_write_rejects_selection() { + ScriptedControlTransport transport; + transport.reports_after_clear.push_back(negative_error_report(11)); + ControlObjectSession session{ + transport, + descriptor(ControlModel::select_before_operate_enhanced), + options()}; + const auto result = session.select_with_value(request()); + CHECK(!result.success()); + CHECK(result.completion == ControlCompletionState::rejected); + CHECK(result.request_accepted); + CHECK(result.add_cause == AddCause::blocked_by_synchrocheck); + CHECK(!session.has_active_selection()); + CHECK(transport.writes.size() == 1U); + CHECK(transport.writes.front() == "LD0/CSWI1$CO$Pos$SBOw"); +} + +void default_authorization_still_fails_closed_at_live_session() { + ScriptedControlTransport transport; + ControlSessionOptions denied_options; + ControlObjectSession session{ + transport, descriptor(ControlModel::direct_normal), denied_options}; + const auto result = session.operate(request()); + CHECK(!result.success()); + CHECK(result.completion == ControlCompletionState::rejected); + CHECK(result.add_cause == AddCause::no_access_authority); + CHECK(transport.writes.empty()); +} + +} // namespace + +int main() { + try { + discovery_matches_csharp_ctlmodel_gvaa_and_timeout_rules(); + direct_normal_is_exactly_one_oper_write(); + sbo_normal_select_oper_and_cancel_are_immutable(); + stale_sbo_sequence_is_cancelled_not_operated(); + auto_select_performs_sbo_read_then_one_oper_write(); + enhanced_oper_ignores_status_and_requires_exact_termination(); + enhanced_negative_last_appl_error_is_returned(); + enhanced_timeout_is_not_association_failure(); + sbow_application_error_after_write_rejects_selection(); + default_authorization_still_fails_closed_at_live_session(); + std::cout << "Control session parity tests passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} From cbd1e762f60b10d1c63c144090aa47cf4f77de23 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:56:03 +0700 Subject: [PATCH 29/66] Add strict live control session parity profile --- tests/control_session_profile/CMakeLists.txt | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/control_session_profile/CMakeLists.txt diff --git a/tests/control_session_profile/CMakeLists.txt b/tests/control_session_profile/CMakeLists.txt new file mode 100644 index 000000000..13b19f178 --- /dev/null +++ b/tests/control_session_profile/CMakeLists.txt @@ -0,0 +1,53 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850ControlSessionProfile + VERSION 0.1.0 + DESCRIPTION "IEC 61850 smart-control discovery and session parity validation" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +function(ariec61850_control_strict target) + target_compile_features(${target} PRIVATE cxx_std_20) + target_include_directories(${target} PRIVATE ${ARIEC61850_ROOT}/include) + if(MSVC) + target_compile_options(${target} PRIVATE /W4 /WX /permissive-) + else() + target_compile_options(${target} PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) + endif() +endfunction() + +add_executable(ariec61850_control_session_tests + ${ARIEC61850_ROOT}/tests/test_control_session.cpp + ${ARIEC61850_ROOT}/src/asn1/ber.cpp + ${ARIEC61850_ROOT}/src/mms/utc_time.cpp + ${ARIEC61850_ROOT}/src/mms/data_value.cpp + ${ARIEC61850_ROOT}/src/mms/data_codec.cpp + ${ARIEC61850_ROOT}/src/mms/services.cpp + ${ARIEC61850_ROOT}/src/control/guarded_control.cpp + ${ARIEC61850_ROOT}/src/control/mms_control_structure.cpp + ${ARIEC61850_ROOT}/src/control/command_termination.cpp + ${ARIEC61850_ROOT}/src/control/control_discovery.cpp + ${ARIEC61850_ROOT}/src/control/control_session.cpp) +ariec61850_control_strict(ariec61850_control_session_tests) + +# Production adapter compile gate. Linking is exercised by repository C++ CI +# once these sources are part of ariec61850_core; this target catches API and +# warning regressions without duplicating the complete association link graph. +add_library(ariec61850_control_transport_compile OBJECT + ${ARIEC61850_ROOT}/src/control/mms_association_control_transport.cpp + ${ARIEC61850_ROOT}/src/mms/association_runtime_control_wait.cpp) +ariec61850_control_strict(ariec61850_control_transport_compile) + +enable_testing() +add_test( + NAME ariec61850_control_session_tests + COMMAND ariec61850_control_session_tests) + +message(STATUS "arstack61850 Control session: ctlModel + live GVAA discovery=ON") +message(STATUS "arstack61850 Control session: Direct/SBO/SBOw/Oper/Cancel orchestration=ON") +message(STATUS "arstack61850 Control session: subscribe/drain before command Write=ON") +message(STATUS "arstack61850 Control session: enhanced CommandTermination completion=ON") +message(STATUS "arstack61850 Control session: command retry=OFF") +message(STATUS "arstack61850 Control session: default authorization=DENY") From 2f49987ef5dcedeeb9b0926899dcf38b920c47f4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:56:22 +0700 Subject: [PATCH 30/66] Gate smart-control session parity on GCC and Clang --- .../workflows/control-session-profile-ci.yml | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/control-session-profile-ci.yml diff --git a/.github/workflows/control-session-profile-ci.yml b/.github/workflows/control-session-profile-ci.yml new file mode 100644 index 000000000..b1936b143 --- /dev/null +++ b/.github/workflows/control-session-profile-ci.yml @@ -0,0 +1,61 @@ +name: Control Session Profile CI + +on: + push: + branches: + - main + pull_request: + paths: + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/mms/association_runtime.hpp' + - 'src/mms/association_runtime_control_wait.cpp' + - 'tests/test_control_session.cpp' + - 'tests/control_session_profile/**' + - '.github/workflows/control-session-profile-ci.yml' + +permissions: + contents: read + +jobs: + control-session-profile: + name: Control Session / ${{ matrix.compiler }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + compiler: [gcc, clang] + + steps: + - uses: actions/checkout@v4 + + - name: Select compiler + shell: bash + run: | + if [ "${{ matrix.compiler }}" = "clang" ]; then + echo "CC=clang" >> "$GITHUB_ENV" + echo "CXX=clang++" >> "$GITHUB_ENV" + else + echo "CC=gcc" >> "$GITHUB_ENV" + echo "CXX=g++" >> "$GITHUB_ENV" + fi + + - name: Configure control session profile + run: cmake -S tests/control_session_profile -B build-control-session + + - name: Build control session profile + run: cmake --build build-control-session --parallel + + - name: Run smart-control parity tests + run: >- + ctest --test-dir build-control-session + --output-on-failure + --output-junit control-session-ctest.xml + + - name: Upload control session evidence + uses: actions/upload-artifact@v4 + with: + name: control-session-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: build-control-session/control-session-ctest.xml From 1367089e2406af138e0615a51b6a5aba3fcb2389 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 20:00:35 +0700 Subject: [PATCH 31/66] Link MMS PDU dependencies in control session profile --- tests/control_session_profile/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/control_session_profile/CMakeLists.txt b/tests/control_session_profile/CMakeLists.txt index 13b19f178..eed426ee3 100644 --- a/tests/control_session_profile/CMakeLists.txt +++ b/tests/control_session_profile/CMakeLists.txt @@ -21,9 +21,11 @@ endfunction() add_executable(ariec61850_control_session_tests ${ARIEC61850_ROOT}/tests/test_control_session.cpp ${ARIEC61850_ROOT}/src/asn1/ber.cpp + ${ARIEC61850_ROOT}/src/osi/presentation.cpp ${ARIEC61850_ROOT}/src/mms/utc_time.cpp ${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/control/guarded_control.cpp ${ARIEC61850_ROOT}/src/control/mms_control_structure.cpp From a65eb9633d2c0e8cbd54d122bbc6b56ca53eb75d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 20:03:55 +0700 Subject: [PATCH 32/66] Fix non-default MMS value fixtures in control session tests --- tests/test_control_session.cpp | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/test_control_session.cpp b/tests/test_control_session.cpp index dbd6e40e2..94fd10486 100644 --- a/tests/test_control_session.cpp +++ b/tests/test_control_session.cpp @@ -257,9 +257,12 @@ ControlRequest request(const DoublePointValue value = DoublePointValue::on) { void discovery_matches_csharp_ctlmodel_gvaa_and_timeout_rules() { ScriptedControlTransport transport; - transport.reads["LD0/CSWI1$CF$Pos$ctlModel"] = MmsDataValue::integer(2); - transport.reads["LD0/CSWI1$CF$Pos$sboTimeout"] = MmsDataValue::unsigned_integer(1'500U); - transport.reads["LD0/CSWI1$CF$Pos$operTimeout"] = MmsDataValue::integer(2'500); + transport.reads.insert_or_assign( + "LD0/CSWI1$CF$Pos$ctlModel", MmsDataValue::integer(2)); + transport.reads.insert_or_assign( + "LD0/CSWI1$CF$Pos$sboTimeout", MmsDataValue::unsigned_integer(1'500U)); + transport.reads.insert_or_assign( + "LD0/CSWI1$CF$Pos$operTimeout", MmsDataValue::integer(2'500)); transport.specifications["LD0/CSWI1$CO$Pos$Oper"] = oper_spec(); transport.specifications["LD0/CSWI1$CO$Pos$Cancel"] = cancel_spec(); transport.names["LD0"] = { @@ -279,16 +282,15 @@ void discovery_matches_csharp_ctlmodel_gvaa_and_timeout_rules() { CHECK(found.status_functional_constraint == "ST"); CHECK(found.operationally_ready()); - // Cancel is mandatory for a model that requires Select. transport.specifications.erase("LD0/CSWI1$CO$Pos$Cancel"); - bool rejected = false; + bool rejected_discovery = false; try { static_cast(ControlDescriptorDiscovery::discover( transport, "LD0/CSWI1.Pos")); } catch (const std::runtime_error&) { - rejected = true; + rejected_discovery = true; } - CHECK(rejected); + CHECK(rejected_discovery); } void direct_normal_is_exactly_one_oper_write() { @@ -313,15 +315,15 @@ void direct_normal_is_exactly_one_oper_write() { void sbo_normal_select_oper_and_cancel_are_immutable() { ScriptedControlTransport transport; - transport.reads["LD0/CSWI1$CO$Pos$SBO"] = - MmsDataValue::visible_string("LD0/CSWI1.Pos"); + transport.reads.insert_or_assign( + "LD0/CSWI1$CO$Pos$SBO", MmsDataValue::visible_string("LD0/CSWI1.Pos")); ControlObjectSession session{ transport, descriptor(ControlModel::select_before_operate_normal), options()}; auto selected = session.select(request()); CHECK(selected.success()); CHECK(session.has_active_selection()); - CHECK(transport.writes.empty()); // normal SBO Select is a Read. + CHECK(transport.writes.empty()); const auto operated = session.operate(request()); CHECK(operated.success()); @@ -339,7 +341,8 @@ void sbo_normal_select_oper_and_cancel_are_immutable() { void stale_sbo_sequence_is_cancelled_not_operated() { ScriptedControlTransport transport; - transport.reads["LD0/CSWI1$CO$Pos$SBO"] = MmsDataValue::boolean(true); + transport.reads.insert_or_assign( + "LD0/CSWI1$CO$Pos$SBO", MmsDataValue::boolean(true)); ControlObjectSession session{ transport, descriptor(ControlModel::select_before_operate_normal), options()}; CHECK(session.select(request(DoublePointValue::on)).success()); @@ -354,7 +357,8 @@ void stale_sbo_sequence_is_cancelled_not_operated() { void auto_select_performs_sbo_read_then_one_oper_write() { ScriptedControlTransport transport; - transport.reads["LD0/CSWI1$CO$Pos$SBO"] = MmsDataValue::visible_string("SEL-TOKEN"); + transport.reads.insert_or_assign( + "LD0/CSWI1$CO$Pos$SBO", MmsDataValue::visible_string("SEL-TOKEN")); ControlObjectSession session{ transport, descriptor(ControlModel::select_before_operate_normal), options()}; const auto result = session.operate(request()); From 20dcc18bc6b57fb21ea3bbebda49c5b717911d58 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 20:05:19 +0700 Subject: [PATCH 33/66] Link Session codec dependency in control session profile --- tests/control_session_profile/CMakeLists.txt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/control_session_profile/CMakeLists.txt b/tests/control_session_profile/CMakeLists.txt index eed426ee3..15f9aaf9d 100644 --- a/tests/control_session_profile/CMakeLists.txt +++ b/tests/control_session_profile/CMakeLists.txt @@ -21,6 +21,7 @@ endfunction() add_executable(ariec61850_control_session_tests ${ARIEC61850_ROOT}/tests/test_control_session.cpp ${ARIEC61850_ROOT}/src/asn1/ber.cpp + ${ARIEC61850_ROOT}/src/osi/session.cpp ${ARIEC61850_ROOT}/src/osi/presentation.cpp ${ARIEC61850_ROOT}/src/mms/utc_time.cpp ${ARIEC61850_ROOT}/src/mms/data_value.cpp @@ -34,9 +35,6 @@ add_executable(ariec61850_control_session_tests ${ARIEC61850_ROOT}/src/control/control_session.cpp) ariec61850_control_strict(ariec61850_control_session_tests) -# Production adapter compile gate. Linking is exercised by repository C++ CI -# once these sources are part of ariec61850_core; this target catches API and -# warning regressions without duplicating the complete association link graph. add_library(ariec61850_control_transport_compile OBJECT ${ARIEC61850_ROOT}/src/control/mms_association_control_transport.cpp ${ARIEC61850_ROOT}/src/mms/association_runtime_control_wait.cpp) From ea0a0963456952d07fab2e18d2dfa6ee84791c7d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 20:09:08 +0700 Subject: [PATCH 34/66] Integrate guarded control parity into normal core build --- CMakeLists.txt | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 559815fc2..02c4d2719 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,11 +31,18 @@ add_library(ariec61850_core src/mms/invoke_router.cpp src/mms/reporting.cpp src/mms/association_runtime.cpp + src/mms/association_runtime_control_wait.cpp src/mms/report_subscription_runtime.cpp src/mms/tcp_transport.cpp src/mms/live_discovery.cpp src/mms/rcb_selection.cpp src/mms/rcb_failover.cpp + src/control/guarded_control.cpp + src/control/mms_control_structure.cpp + src/control/command_termination.cpp + src/control/control_discovery.cpp + src/control/control_session.cpp + src/control/mms_association_control_transport.cpp src/sampled_values/pdu_codec.cpp src/sampled_values/frame_codec.cpp src/sampled_values/quality.cpp @@ -282,6 +289,27 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_sanitizers(ariec61850_control_block_read_tests) add_test(NAME ariec61850_control_block_read_tests COMMAND ariec61850_control_block_read_tests) + add_executable(ariec61850_control_mms_structure_tests tests/test_control_mms_structure.cpp) + target_link_libraries(ariec61850_control_mms_structure_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_control_mms_structure_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_control_mms_structure_tests) + ariec61850_apply_sanitizers(ariec61850_control_mms_structure_tests) + add_test(NAME ariec61850_control_mms_structure_tests COMMAND ariec61850_control_mms_structure_tests) + + add_executable(ariec61850_command_termination_tests tests/test_command_termination.cpp) + target_link_libraries(ariec61850_command_termination_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_command_termination_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_command_termination_tests) + ariec61850_apply_sanitizers(ariec61850_command_termination_tests) + add_test(NAME ariec61850_command_termination_tests COMMAND ariec61850_command_termination_tests) + + add_executable(ariec61850_control_session_tests tests/test_control_session.cpp) + target_link_libraries(ariec61850_control_session_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_control_session_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_control_session_tests) + ariec61850_apply_sanitizers(ariec61850_control_session_tests) + add_test(NAME ariec61850_control_session_tests COMMAND ariec61850_control_session_tests) + add_executable(ariec61850_fuzz_smoke_tests tests/test_fuzz_smoke.cpp) target_link_libraries(ariec61850_fuzz_smoke_tests PRIVATE ARIEC61850::core) target_include_directories(ariec61850_fuzz_smoke_tests PRIVATE From f2c4e68835e37b145afcf7abfd444f9537e33899 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 20:10:56 +0700 Subject: [PATCH 35/66] Update port status for guarded control parity C1-C4 --- PORT_STATUS.md | 121 +++++++++++++++++++++++++++++-------------------- 1 file changed, 73 insertions(+), 48 deletions(-) diff --git a/PORT_STATUS.md b/PORT_STATUS.md index 6de67e1c4..1bef3f79e 100644 --- a/PORT_STATUS.md +++ b/PORT_STATUS.md @@ -2,7 +2,9 @@ ## Current milestone -**Phase 4C.1 — built-in non-blocking TCP, bounded read-only discovery, C#-compatible live-model parity, and physical interoperability evidence tooling are implemented for review.** +**Phase 4D-C5 — guarded IEC 61850 Smart Control is integrated on the current branch for review and controlled laboratory interoperability testing.** + +The implementation preserves the existing Phase 4C live discovery and Phase 4D Dynamic DataSet/reporting work from `main`. Buffered-report recovery and ESP32 process-bus work remain separate concerns and are not used as evidence for MMS control behavior. ## Delivered modules @@ -12,50 +14,73 @@ | SCL and COMTRADE engineering formats | Merged to `main` | | TPKT/COTP and Session/Presentation/ACSE | Merged to `main` | | MMS Initiate and confirmed services | Merged to `main` | -| Offline DataSet/RCB/report monitoring | Merged to `main` | -| Transport-injected MMS association/runtime | Merged to `main` | -| Persistent RCB subscription runtime | Merged to `main` | -| Built-in Windows/Linux TCP transport | Implemented in PR #14 | -| Live read-only MMS discovery | Implemented in PR #14 | -| `live-ied-model-v1` hierarchy/parity mapper | Implemented in PR #14 | -| Physical read-only evidence runner | Implemented in PR #14 | -| Physical IED acceptance evidence | Pending lab execution | - -## Phase 4C.1 behavior - -- Non-blocking Winsock/POSIX TCP honors deadlines and cancellation. -- Discovery is bounded and sends only GetNameList, GetVariableAccessAttributes, - GetNamedVariableListAttributes, and Read. -- MMS references are mapped to Logical Device, Logical Node, Data Object, and Data Attribute. -- Functional constraints, MMS/SCL type evidence, DataSets, and report controls are retained. -- Direct leaf and nested TypeSpecification evidence are resolved into the model. -- IED identity and CDC are inferred conservatively with explicit confidence. -- Canonical manifests and fingerprints make unchanged-model runs comparable. -- C# and C++ `live-ied-model-v1` JSON can be compared by the parity script. -- The lab runner performs repeated reconnect/discovery cycles and records acceptance evidence. - -## Validation completed without a physical IED - -- Phase 4B baseline GCC, Clang, MSVC, sanitizer, CTest, and fuzz evidence remains valid. -- Phase 4C TCP loopback, cancellation, strict GCC/Clang warning, and sanitizer checks passed locally. -- Live-model hierarchy, deterministic fingerprint, parity, and multi-translation-unit header - validation passed locally with GCC and Clang; ASan/UBSan smoke passed. -- Python parity and interoperability scripts pass syntax checks and a deterministic simulated - three-cycle acceptance run. -- The discovery regression decodes all emitted requests and rejects MMS Write. - -## Remaining acceptance gates - -- Run repository GCC/Clang/MSVC and Security/Evidence workflows through `workflow_dispatch`. -- Run the physical evidence runner against a reachable IED or vendor simulator. -- Record a primary-vendor ten-cycle run, timeout/reconnect evidence, and large-model pagination - where available. -- Export the C# oracle model and accept C#↔C++ parity against the same IED configuration. -- Review warning policy and packet-capture references before merging PR #14. - -## Safety boundary - -The live discovery surface is read-only and does not construct Write, control, GI, RCB -reservation/enable, dynamic DataSet mutation, or file-service requests. The generic TCP transport -can be used by other explicit runtime surfaces, but those surfaces retain their own authorization -and physical-laboratory gates. +| Built-in Windows/Linux TCP transport | Merged to `main` | +| Live read-only MMS discovery/model parity | Merged to `main` | +| Dynamic DataSet lifecycle | Merged to `main` | +| Guarded Dynamic RCB live-trial planner | Merged to `main` | +| Guarded Direct/SBO control safety core | Integrated from Phase 4D-C1 / PR #25 | +| Live-type Oper/SBOw/Cancel MMS structure builder | Integrated from Phase 4D-C2 / PR #27 | +| CommandTermination and LastApplError/AddCause correlation | Integrated from Phase 4D-C3 / PR #28 | +| Live control discovery/session over `MmsAssociationRuntime` | Integrated from Phase 4D-C4 / PR #29 | +| Guarded physical interoperability harness and read-only inventory probes | Integrated from Phase 4D-C5 / PR #30 | + +## Smart Control behavior + +- `ctlModel` values 0..4 match the C# oracle: status-only, Direct normal, SBO normal, Direct enhanced, and SBO enhanced. +- Control roots are Data Objects (`LD/LN.DO`); service-leaf references are rejected as roots. +- The control safety planner is association-aware and authorization is **default deny**. +- `ctlNum` is monotonic in the range 1..255 and wraps to 1; zero is never auto-generated. +- SBO retains an immutable selected sequence across `ctlVal`, origin, `ctlNum`, `T`, optional `operTm`, `Test`, synchrocheck, and interlock-check. +- A second association cannot operate or cancel another association's selection. +- Selection timeout, association loss, value/check mismatch, or authorization loss fail closed. +- Direct normal and SBO normal complete at the accepted MMS service boundary. +- Direct enhanced and SBO enhanced remain pending until a correlated CommandTermination arrives. +- Ordinary ST/MX process reports cannot complete an enhanced command. +- LastApplError retains the raw ControlError/AddCause values while mapping the standard ControlError 0..3 and AddCause 0..27 names. +- Positive Oper-only CommandTermination requires an exact `CO/Oper` reference. +- The live TypeSpecification is the source of truth for `ctlVal`, origin, `ctlNum`, `T`, `Test`, `Check`, and optional `operTm`; unknown vendor fields are rejected rather than guessed. +- DPC uses MMS network bit order (`Off=01`, `On=10`). +- `Check` is exactly two bits: bit 0 synchrocheck and bit 1 interlock-check. +- Normal SBO Select is a Read of `SBO`; SBO enhanced SelectWithValue is exactly one `SBOw` Write. +- `Cancel` is built from the exact retained selected sequence. +- Before `SBOw` or `Oper`, stale InformationReports are drained; reports arriving during the confirmed exchange remain available for application-error/termination correlation. +- Missing CommandTermination expires as a control timeout and does not automatically fault an otherwise healthy MMS association. +- **No automatic command retry is performed.** + +## Deterministic validation + +Dedicated strict profiles cover: + +- GCC and Clang `-fno-exceptions -fno-rtti` guarded-control safety semantics; +- default-deny authorization and revoked authorization between Select and Oper; +- second-client takeover rejection, selection expiry, association-loss cleanup, Cancel ownership, and `ctlNum` wrap; +- exact live-TypeSpecification control-value binding and golden MMS Data bytes for Oper; +- DPC network bit order, StepPosition, and conservative analogue structures; +- exact `origin`, `ctlNum`, `T`, `Test`, and two-bit `Check` construction; +- LastApplError layouts, unknown raw-code preservation, and object correlation; +- positive and negative enhanced CommandTermination behavior; +- live descriptor discovery using `ctlModel`, GVAA, GetNameList, CF timeouts, and ST/MX status-reference priority; +- Direct one-write behavior, SBO Select to Operate, SBOw asynchronous application-error handling, AutoSelect, stale-selection Cancel, and enhanced termination wait; +- production `MmsAssociationControlTransport` strict compile validation; +- integration of C1-C5 sources into the normal `ARIEC61850::core` build graph and CTest targets. + +## Physical interoperability acceptance + +Physical IED control acceptance is not claimed until evidence is retained from an isolated, non-operational laboratory IED or simulator for: + +1. read-only live `ctlModel` and GVAA discovery for a known controllable object; +2. Direct normal `Oper` with exactly one command Write and observed process/status feedback; +3. SBO normal `SBO -> Oper` plus explicit `Cancel` and selection-timeout behavior; +4. Direct enhanced `Oper -> CommandTermination` positive and a safe negative LastApplError/AddCause path; +5. SBO enhanced `SBOw -> Oper -> CommandTermination` plus ownership/contention behavior; +6. association loss during selection and while waiting for enhanced termination; +7. interlock/synchrocheck behavior only where the lab setup can exercise it safely; +8. packet capture and deterministic event log proving no automatic command retry. + +## Safety and claim boundary + +- Live inventory and raw `ctlModel` probes are read-only. +- Mutating control requires explicit command intent, an exact object root, live type discovery, and the harness authorization gate. +- The implementation and deterministic tests are not an IEC 61850 conformance certificate. +- No hosted CI result is treated as proof of a physical IED command. +- Sampled Values, ESP32 hardware transmission, and BRCB persistence remain outside Smart Control acceptance. From 9ffc0c30cdb4c7502935fa3d0036cb5f583f0029 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:23:34 +0700 Subject: [PATCH 36/66] Add guarded live control interoperability harness --- tools/control_interop_probe.cpp | 729 ++++++++++++++++++++++++++++++++ 1 file changed, 729 insertions(+) create mode 100644 tools/control_interop_probe.cpp diff --git a/tools/control_interop_probe.cpp b/tools/control_interop_probe.cpp new file mode 100644 index 000000000..d0b7a6983 --- /dev/null +++ b/tools/control_interop_probe.cpp @@ -0,0 +1,729 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/control_session.hpp" +#include "ariec61850/mms/live_discovery.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; +using namespace ar::iec61850::control; +using namespace ar::iec61850::mms; + +constexpr std::string_view kArmToken{"IEC61850-LAB-CONTROL"}; + +struct Options final { + MmsEndpoint endpoint{}; + std::string object_reference; + std::string action{"discover"}; + std::string value; + std::string value_kind; + std::string origin_identifier{"ARSTACK-C5"}; + OriginCategory origin_category{OriginCategory::station_control}; + std::chrono::milliseconds timeout{5'000}; + std::optional termination_timeout; + bool test{}; + bool interlock_check{true}; + bool synchro_check{true}; + bool auto_select{true}; + bool armed{}; + std::string evidence_path; + bool self_test{}; +}; + +struct LabAuthorization final { + bool armed{}; +}; + +[[nodiscard]] bool authorize_lab_control( + void* context, + ControlAction, + const ControlObjectReference&, + const ControlClientIdentity&, + const ControlSequenceView&) noexcept { + const auto* authorization = static_cast(context); + return authorization != nullptr && authorization->armed; +} + +[[nodiscard]] std::string lower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const char ch) { + return static_cast(std::tolower(static_cast(ch))); + }); + return value; +} + +[[nodiscard]] std::uint16_t parse_port(const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoul(value, &consumed, 10); + if (consumed != value.size() || parsed == 0UL || parsed > 65'535UL) { + throw std::invalid_argument("MMS TCP port must be in the range 1..65535."); + } + return static_cast(parsed); +} + +[[nodiscard]] std::chrono::milliseconds parse_timeout( + const std::string& option, + const std::string& value) { + std::size_t consumed{}; + 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 millisecond value."); + } + return std::chrono::milliseconds{static_cast(parsed)}; +} + +[[nodiscard]] bool parse_switch(const std::string& option, const std::string& value) { + const auto normalized = lower(value); + if (normalized == "on" || normalized == "true" || normalized == "1") { + return true; + } + if (normalized == "off" || normalized == "false" || normalized == "0") { + return false; + } + throw std::invalid_argument(option + " requires on/off."); +} + +[[nodiscard]] OriginCategory parse_origin_category(const std::string& value) { + const auto normalized = lower(value); + if (normalized == "bay") return OriginCategory::bay_control; + if (normalized == "station") return OriginCategory::station_control; + if (normalized == "remote") return OriginCategory::remote_control; + if (normalized == "automatic-bay") return OriginCategory::automatic_bay; + if (normalized == "automatic-station") return OriginCategory::automatic_station; + if (normalized == "automatic-remote") return OriginCategory::automatic_remote; + if (normalized == "maintenance") return OriginCategory::maintenance; + if (normalized == "process") return OriginCategory::process; + throw std::invalid_argument( + "--origin-category requires bay, station, remote, automatic-bay, " + "automatic-station, automatic-remote, maintenance, or process."); +} + +[[nodiscard]] const char* model_name(const ControlModel model) noexcept { + switch (model) { + case ControlModel::status_only: return "status-only"; + case ControlModel::direct_normal: return "direct-normal"; + case ControlModel::select_before_operate_normal: return "sbo-normal"; + case ControlModel::direct_enhanced: return "direct-enhanced"; + case ControlModel::select_before_operate_enhanced: return "sbo-enhanced"; + case ControlModel::unknown: return "unknown"; + } + return "unknown"; +} + +[[nodiscard]] const char* completion_name(const ControlCompletionState state) noexcept { + switch (state) { + case ControlCompletionState::accepted: return "accepted"; + case ControlCompletionState::positive_termination: return "positive-termination"; + case ControlCompletionState::negative_termination: return "negative-termination"; + case ControlCompletionState::rejected: return "rejected"; + case ControlCompletionState::unsupported: return "unsupported"; + case ControlCompletionState::timed_out: return "timed-out"; + case ControlCompletionState::association_lost: return "association-lost"; + case ControlCompletionState::cancelled: return "cancelled"; + } + return "unknown"; +} + +[[nodiscard]] const char* action_name(const ControlAction action) noexcept { + switch (action) { + case ControlAction::select: return "select"; + case ControlAction::select_with_value: return "select-with-value"; + case ControlAction::operate: return "operate"; + case ControlAction::cancel: return "cancel"; + } + return "unknown"; +} + +[[nodiscard]] const char* add_cause_name(const AddCause cause) noexcept { + switch (cause) { + case AddCause::unknown: return "unknown"; + case AddCause::not_supported: return "not-supported"; + case AddCause::blocked_by_switching_hierarchy: return "blocked-by-switching-hierarchy"; + case AddCause::select_failed: return "select-failed"; + case AddCause::invalid_position: return "invalid-position"; + case AddCause::position_reached: return "position-reached"; + case AddCause::parameter_change_in_execution: return "parameter-change-in-execution"; + case AddCause::step_limit: return "step-limit"; + case AddCause::blocked_by_mode: return "blocked-by-mode"; + case AddCause::blocked_by_process: return "blocked-by-process"; + case AddCause::blocked_by_interlocking: return "blocked-by-interlocking"; + case AddCause::blocked_by_synchrocheck: return "blocked-by-synchrocheck"; + case AddCause::command_already_in_execution: return "command-already-in-execution"; + case AddCause::blocked_by_health: return "blocked-by-health"; + case AddCause::one_of_n_control: return "one-of-n-control"; + case AddCause::abortion_by_cancel: return "abortion-by-cancel"; + case AddCause::time_limit_over: return "time-limit-over"; + case AddCause::abortion_by_trip: return "abortion-by-trip"; + case AddCause::object_not_selected: return "object-not-selected"; + case AddCause::object_already_selected: return "object-already-selected"; + case AddCause::no_access_authority: return "no-access-authority"; + case AddCause::ended_with_overshoot: return "ended-with-overshoot"; + case AddCause::abortion_due_to_deviation: return "abortion-due-to-deviation"; + case AddCause::abortion_by_communication_loss: return "abortion-by-communication-loss"; + case AddCause::abortion_by_command: return "abortion-by-command"; + case AddCause::none: return "none"; + case AddCause::inconsistent_parameters: return "inconsistent-parameters"; + case AddCause::locked_by_other_client: return "locked-by-other-client"; + } + return "unknown"; +} + +[[nodiscard]] std::string json_escape(const std::string& value) { + std::ostringstream output; + for (const auto ch : value) { + switch (ch) { + case '\\': output << "\\\\"; break; + case '"': output << "\\\""; break; + case '\n': output << "\\n"; break; + case '\r': output << "\\r"; break; + case '\t': output << "\\t"; break; + default: + if (static_cast(ch) < 0x20U) { + output << "\\u" << std::hex << std::setw(4) << std::setfill('0') + << static_cast(static_cast(ch)) + << std::dec; + } else { + output << ch; + } + } + } + return output.str(); +} + +[[nodiscard]] std::string hex_bytes(const std::vector& bytes) { + std::ostringstream output; + output << std::uppercase << std::hex << std::setfill('0'); + for (const auto byte : bytes) { + output << std::setw(2) << static_cast(byte); + } + return output.str(); +} + +[[nodiscard]] std::string value_text(const MmsDataValue& value) { + switch (value.kind()) { + case MmsDataKind::boolean: + return std::get(value.value()) ? "true" : "false"; + case MmsDataKind::integer: + return std::to_string(std::get(value.value())); + case MmsDataKind::unsigned_integer: + return std::to_string(std::get(value.value())); + case MmsDataKind::floating_point: { + std::ostringstream output; + if (const auto* float_value = std::get_if(&value.value())) { + output << *float_value; + } else if (const auto* double_value = std::get_if(&value.value())) { + output << *double_value; + } + return output.str(); + } + case MmsDataKind::visible_string: + case MmsDataKind::mms_string: + return std::get(value.value()); + case MmsDataKind::array: + case MmsDataKind::structure: { + std::ostringstream output; + output << '['; + for (std::size_t index = 0U; index < value.children().size(); ++index) { + if (index != 0U) output << ','; + output << value_text(value.children()[index]); + } + output << ']'; + return output.str(); + } + case MmsDataKind::bit_string: + case MmsDataKind::octet_string: + case MmsDataKind::binary_time: + case MmsDataKind::bcd: + case MmsDataKind::boolean_array: + case MmsDataKind::object_id: + case MmsDataKind::unknown: + return "0x" + hex_bytes(value.raw_value()); + case MmsDataKind::utc_time: + return ""; + } + return ""; +} + +class RecordingControlTransport final : public ControlTransport { +public: + explicit RecordingControlTransport(ControlTransport& inner) noexcept : inner_{inner} {} + + [[nodiscard]] bool associated() const noexcept override { return inner_.associated(); } + [[nodiscard]] std::uint64_t association_id() const noexcept override { + return inner_.association_id(); + } + + [[nodiscard]] std::optional read( + const MmsObjectName& object, + std::stop_token stop_token = {}) override { + reads.push_back(object.reference()); + return inner_.read(object, stop_token); + } + + [[nodiscard]] std::optional variable_specification( + const MmsObjectName& object, + std::stop_token stop_token = {}) override { + specifications.push_back(object.reference()); + return inner_.variable_specification(object, stop_token); + } + + [[nodiscard]] std::vector domain_variable_names( + const std::string& domain, + std::stop_token stop_token = {}) override { + name_lists.push_back(domain); + return inner_.domain_variable_names(domain, stop_token); + } + + [[nodiscard]] ControlTransportWriteResult write( + const MmsObjectName& object, + MmsDataValue value, + std::stop_token stop_token = {}) override { + writes.push_back(object.reference()); + return inner_.write(object, std::move(value), stop_token); + } + + void clear_information_reports() override { + ++report_drains; + inner_.clear_information_reports(); + } + + [[nodiscard]] bool wait_information_report( + const std::chrono::milliseconds timeout, + MmsInformationReport& report, + std::stop_token stop_token = {}) override { + ++report_waits; + const auto received = inner_.wait_information_report(timeout, report, stop_token); + if (received) ++reports_received; + return received; + } + + std::vector reads; + std::vector specifications; + std::vector name_lists; + std::vector writes; + std::size_t report_drains{}; + std::size_t report_waits{}; + std::size_t reports_received{}; + +private: + ControlTransport& inner_; +}; + +[[nodiscard]] ControlValue parse_control_value( + const std::string& raw_kind, + const std::string& raw_value, + const std::string& cdc) { + auto kind = lower(raw_kind); + if (kind.empty()) { + const auto normalized_cdc = lower(cdc); + if (normalized_cdc == "spc") kind = "bool"; + else if (normalized_cdc == "dpc") kind = "dpc"; + else if (normalized_cdc == "apc") kind = "float"; + else if (normalized_cdc == "bsc") kind = "step"; + else if (normalized_cdc == "inc/isc") kind = "int"; + else throw std::invalid_argument( + "Cannot infer --value-kind for vendor-specific CDC; specify it explicitly."); + } + + if (kind == "bool" || kind == "boolean") { + return ControlValue::boolean(parse_switch("--value", raw_value)); + } + if (kind == "dpc" || kind == "double-point") { + const auto value = lower(raw_value); + if (value == "off" || value == "1") return ControlValue::double_point(DoublePointValue::off); + if (value == "on" || value == "2") return ControlValue::double_point(DoublePointValue::on); + if (value == "intermediate" || value == "0") return ControlValue::double_point(DoublePointValue::intermediate); + if (value == "bad" || value == "3") return ControlValue::double_point(DoublePointValue::bad); + throw std::invalid_argument("DPC --value requires off, on, intermediate, or bad."); + } + if (kind == "int" || kind == "integer") { + std::size_t consumed{}; + const auto parsed = std::stoll(raw_value, &consumed, 0); + if (consumed != raw_value.size()) throw std::invalid_argument("Invalid signed integer --value."); + return ControlValue::integer(static_cast(parsed)); + } + if (kind == "uint" || kind == "unsigned") { + std::size_t consumed{}; + const auto parsed = std::stoull(raw_value, &consumed, 0); + if (consumed != raw_value.size()) throw std::invalid_argument("Invalid unsigned integer --value."); + return ControlValue::unsigned_integer(static_cast(parsed)); + } + if (kind == "float" || kind == "double") { + std::size_t consumed{}; + const auto parsed = std::stod(raw_value, &consumed); + if (consumed != raw_value.size()) throw std::invalid_argument("Invalid floating-point --value."); + return ControlValue::floating_point(parsed); + } + if (kind == "step") { + const auto colon = raw_value.find(':'); + const auto position_text = raw_value.substr(0U, colon); + std::size_t consumed{}; + const auto parsed = std::stoll(position_text, &consumed, 0); + if (consumed != position_text.size()) throw std::invalid_argument("Invalid step position --value."); + bool transient{}; + if (colon != std::string::npos) { + transient = parse_switch("step transient", raw_value.substr(colon + 1U)); + } + return ControlValue::step_position({static_cast(parsed), transient}); + } + throw std::invalid_argument( + "--value-kind requires bool, dpc, int, uint, float, or step."); +} + +[[nodiscard]] ControlActionResult execute_action( + const Options& options, + const ControlObjectDescriptor& descriptor, + ControlObjectSession& session, + const ControlRequest& request) { + if (options.action == "operate") { + return session.operate(request); + } + + if (options.action == "select-operate" || options.action == "select-cancel") { + if (!descriptor.requires_select()) { + throw std::invalid_argument( + options.action + " requires an SBO control model discovered from the IED."); + } + ControlActionResult selected; + if (descriptor.model == ControlModel::select_before_operate_enhanced) { + selected = session.select_with_value(request); + } else { + selected = session.select(request); + } + if (!selected.success()) return selected; + if (options.action == "select-cancel") return session.cancel(); + + auto operate_request = request; + operate_request.auto_select = false; + return session.operate(operate_request); + } + + throw std::invalid_argument( + "--action requires discover, operate, select-operate, or select-cancel."); +} + +void print_descriptor(const ControlObjectDescriptor& descriptor) { + std::cout << "CONTROL_DESCRIPTOR object=" << descriptor.object.domain << '/' + << descriptor.object.logical_node << '.' << descriptor.object.data_object_path + << " ctlModel=" << model_name(descriptor.model) + << " cdc=" << descriptor.cdc + << " requiresSelect=" << (descriptor.requires_select() ? "true" : "false") + << " enhanced=" << (descriptor.enhanced() ? "true" : "false") + << " sboTimeoutMs=" << descriptor.sbo_timeout.count() + << " operTimeoutMs=" << descriptor.operate_timeout.count() + << " commandTermination=" + << (descriptor.supports_command_termination ? "true" : "false") + << '\n'; + if (descriptor.status_object) { + std::cout << "STATUS_OBJECT " << descriptor.status_object->reference() + << " fc=" << descriptor.status_functional_constraint << '\n'; + } + for (const auto& evidence : descriptor.discovery_evidence) { + std::cout << "DISCOVERY_EVIDENCE " << evidence << '\n'; + } +} + +void write_evidence( + const Options& options, + const ControlObjectDescriptor& descriptor, + const RecordingControlTransport& transport, + const std::optional& status_before, + const std::optional& status_after, + const std::optional& result, + const MmsAssociationRuntime& association) { + if (options.evidence_path.empty()) return; + + std::ofstream output{options.evidence_path, std::ios::binary | std::ios::trunc}; + if (!output) { + throw std::runtime_error("Cannot open evidence file: " + options.evidence_path); + } + + output << "{\n" + << " \"schema\": \"arstack61850-control-interop-v1\",\n" + << " \"endpoint\": \"" << json_escape(options.endpoint.host) << ':' + << options.endpoint.port << "\",\n" + << " \"object\": \"" << json_escape(options.object_reference) << "\",\n" + << " \"action\": \"" << json_escape(options.action) << "\",\n" + << " \"armed\": " << (options.armed ? "true" : "false") << ",\n" + << " \"ctlModel\": \"" << model_name(descriptor.model) << "\",\n" + << " \"cdc\": \"" << json_escape(descriptor.cdc) << "\",\n" + << " \"associationProfile\": \"" + << json_escape(association.active_association_profile()) << "\",\n" + << " \"statusBefore\": "; + if (status_before) output << '"' << json_escape(*status_before) << '"'; + else output << "null"; + output << ",\n \"statusAfter\": "; + if (status_after) output << '"' << json_escape(*status_after) << '"'; + else output << "null"; + output << ",\n \"mmsControlWriteCount\": " << transport.writes.size() << ",\n" + << " \"mmsControlWrites\": ["; + for (std::size_t index = 0U; index < transport.writes.size(); ++index) { + if (index != 0U) output << ','; + output << "\"" << json_escape(transport.writes[index]) << "\""; + } + output << "],\n" + << " \"readCount\": " << transport.reads.size() << ",\n" + << " \"gvaaCount\": " << transport.specifications.size() << ",\n" + << " \"reportDrainCount\": " << transport.report_drains << ",\n" + << " \"reportWaitCount\": " << transport.report_waits << ",\n" + << " \"informationReportsReceived\": " << transport.reports_received << ",\n" + << " \"associationEventCount\": " << association.events().size(); + if (result) { + output << ",\n \"result\": {\n" + << " \"action\": \"" << action_name(result->action) << "\",\n" + << " \"completion\": \"" << completion_name(result->completion) << "\",\n" + << " \"requestAccepted\": " + << (result->request_accepted ? "true" : "false") << ",\n" + << " \"commandTerminationReceived\": " + << (result->command_termination_received ? "true" : "false") << ",\n" + << " \"positiveTermination\": " + << (result->positive_termination ? "true" : "false") << ",\n" + << " \"controlNumber\": " << static_cast(result->control_number) << ",\n" + << " \"rawControlError\": " << result->raw_control_error << ",\n" + << " \"rawAddCause\": " << result->raw_add_cause << ",\n" + << " \"addCause\": \"" << add_cause_name(result->add_cause) << "\",\n" + << " \"message\": \"" << json_escape(result->message) << "\"\n" + << " }"; + } + output << "\n}\n"; +} + +void print_usage() { + std::cout + << "Usage:\n" + << " ariec61850_control_interop_probe [port] --object LD/LN.DO [options]\n\n" + << "Read-only default:\n" + << " No --action means discover only. Discovery reads ctlModel, exact live GVAA,\n" + << " CF timeouts when exposed, and the best matching ST/MX status value.\n\n" + << "Write actions (LAB ONLY):\n" + << " --action operate|select-operate|select-cancel\n" + << " --value VALUE Required for a write action.\n" + << " --value-kind KIND bool|dpc|int|uint|float|step; inferred from CDC when possible.\n" + << " --arm IEC61850-LAB-CONTROL Mandatory exact token before any control Write.\n\n" + << "Control sequence options:\n" + << " --origin TEXT Origin identifier (default ARSTACK-C5, max 64 bytes).\n" + << " --origin-category CAT bay|station|remote|automatic-bay|automatic-station|\n" + << " automatic-remote|maintenance|process.\n" + << " --test on|off IEC 61850 Test flag (default off).\n" + << " --interlock-check on|off Check bit 1 (default on).\n" + << " --synchro-check on|off Check bit 0 (default on).\n" + << " --auto-select on|off Operate may select automatically (default on).\n" + << " --termination-timeout-ms N Override enhanced CommandTermination wait.\n" + << " --timeout-ms N MMS connect/request timeout (default 5000).\n" + << " --evidence FILE Write machine-readable JSON evidence.\n" + << " --self-test Offline harness safety self-test; no network.\n\n" + << "Safety: this tool never retries a command automatically. For a live write, use an\n" + << "isolated/non-operational laboratory IED and capture tcp.port==102 in Wireshark.\n"; +} + +[[nodiscard]] Options parse_options(const int argc, char** argv) { + Options options; + options.endpoint.port = 102U; + + if (argc == 2 && std::string_view{argv[1]} == "--self-test") { + options.self_test = true; + return options; + } + if (argc < 2 || std::string_view{argv[1]} == "--help" || + std::string_view{argv[1]} == "-h") { + return options; + } + + options.endpoint.host = argv[1]; + int argument = 2; + if (argument < argc && std::string_view{argv[argument]}.find("--") != 0U) { + options.endpoint.port = parse_port(argv[argument]); + ++argument; + } + + while (argument < argc) { + const std::string option = argv[argument++]; + if (option == "--help" || option == "-h") return Options{}; + if (argument >= argc) throw std::invalid_argument(option + " requires a value."); + const std::string value = argv[argument++]; + if (option == "--object") options.object_reference = value; + else if (option == "--action") options.action = lower(value); + else if (option == "--value") options.value = value; + else if (option == "--value-kind") options.value_kind = lower(value); + else if (option == "--origin") options.origin_identifier = value; + else if (option == "--origin-category") options.origin_category = parse_origin_category(value); + else if (option == "--timeout-ms") options.timeout = parse_timeout(option, value); + else if (option == "--termination-timeout-ms") { + options.termination_timeout = parse_timeout(option, value); + } else if (option == "--test") options.test = parse_switch(option, value); + else if (option == "--interlock-check") options.interlock_check = parse_switch(option, value); + else if (option == "--synchro-check") options.synchro_check = parse_switch(option, value); + else if (option == "--auto-select") options.auto_select = parse_switch(option, value); + else if (option == "--arm") options.armed = value == kArmToken; + else if (option == "--evidence") options.evidence_path = value; + else throw std::invalid_argument("Unknown option: " + option); + } + return options; +} + +void validate_options(const Options& options) { + if (options.endpoint.host.empty()) throw std::invalid_argument("Missing host."); + if (options.object_reference.empty()) throw std::invalid_argument("--object is required."); + if (options.origin_identifier.size() > 64U) { + throw std::invalid_argument("--origin must not exceed 64 bytes."); + } + if (options.action != "discover" && options.action != "operate" && + options.action != "select-operate" && options.action != "select-cancel") { + throw std::invalid_argument( + "--action requires discover, operate, select-operate, or select-cancel."); + } + if (options.action != "discover") { + if (!options.armed) { + throw std::invalid_argument( + "Control Write is locked. Add exact --arm IEC61850-LAB-CONTROL only for an isolated lab IED."); + } + if (options.value.empty()) throw std::invalid_argument("--value is required for a control action."); + } +} + +int run_self_test() { + Options read_only; + read_only.endpoint.host = "127.0.0.1"; + read_only.object_reference = "LD0/CSWI1.Pos"; + validate_options(read_only); + + auto blocked = read_only; + blocked.action = "operate"; + blocked.value = "on"; + bool rejected{}; + try { + validate_options(blocked); + } catch (const std::invalid_argument&) { + rejected = true; + } + if (!rejected) { + std::cerr << "SELF_TEST FAIL: unarmed control action was not rejected.\n"; + return 1; + } + + blocked.armed = true; + validate_options(blocked); + const auto dpc = parse_control_value("dpc", "on", "DPC"); + if (dpc.kind() != ControlValueKind::double_point) { + std::cerr << "SELF_TEST FAIL: DPC parsing mismatch.\n"; + return 1; + } + + std::cout << "CONTROL_INTEROP_SELF_TEST PASS readOnlyDefault=true armGate=true noAutoRetry=true\n"; + return 0; +} + +} // namespace + +int main(int argc, char** argv) { + try { + const auto options = parse_options(argc, argv); + if (options.self_test) return run_self_test(); + if (options.endpoint.host.empty()) { + print_usage(); + return argc > 1 ? 0 : 2; + } + validate_options(options); + + MmsAssociationOptions association_options; + association_options.connect_timeout = options.timeout; + association_options.request_timeout = options.timeout; + MmsTcpLiveDiscoverySession live_session{{}, association_options}; + live_session.connect(options.endpoint); + + MmsAssociationControlTransport association_transport{live_session.association()}; + RecordingControlTransport transport{association_transport}; + const auto descriptor = ControlDescriptorDiscovery::discover( + transport, options.object_reference); + print_descriptor(descriptor); + + std::optional status_before; + if (descriptor.status_object) { + if (const auto value = transport.read(*descriptor.status_object)) { + status_before = value_text(*value); + std::cout << "STATUS_BEFORE " << *status_before << '\n'; + } + } + + if (options.action == "discover") { + std::cout << "CONTROL_INTEROP_RESULT mode=read-only writes=0 status=DISCOVERY_PASS\n"; + write_evidence( + options, descriptor, transport, status_before, std::nullopt, + std::nullopt, live_session.association()); + live_session.disconnect(); + return 0; + } + + LabAuthorization authorization{options.armed}; + ControlSessionOptions session_options; + session_options.guarded_policy.authorization_context = &authorization; + session_options.guarded_policy.authorize = authorize_lab_control; + ControlObjectSession control_session{transport, descriptor, session_options}; + + ControlRequest request; + request.control_value = parse_control_value( + options.value_kind, options.value, descriptor.cdc); + request.origin_category = options.origin_category; + request.origin_identifier.assign( + options.origin_identifier.begin(), options.origin_identifier.end()); + request.test = options.test; + request.interlock_check = options.interlock_check; + request.synchro_check = options.synchro_check; + request.auto_select = options.auto_select; + request.command_termination_timeout = options.termination_timeout; + + const auto result = execute_action(options, descriptor, control_session, request); + + std::optional status_after; + if (descriptor.status_object && transport.associated()) { + if (const auto value = transport.read(*descriptor.status_object)) { + status_after = value_text(*value); + std::cout << "STATUS_AFTER " << *status_after << '\n'; + } + } + + std::cout << "CONTROL_RESULT action=" << action_name(result.action) + << " completion=" << completion_name(result.completion) + << " accepted=" << (result.request_accepted ? "true" : "false") + << " termination=" + << (result.command_termination_received ? "true" : "false") + << " ctlNum=" << static_cast(result.control_number) + << " addCause=" << add_cause_name(result.add_cause) + << " rawAddCause=" << result.raw_add_cause + << " message=\"" << result.message << "\"\n"; + + std::cout << "NO_RETRY_EVIDENCE controlWrites=" << transport.writes.size(); + for (const auto& write : transport.writes) { + std::cout << " write=" << write; + } + std::cout << '\n'; + std::cout << "PCAP_HINT filter=\"tcp.port == " << options.endpoint.port + << " && ip.addr == " << options.endpoint.host << "\"\n"; + + write_evidence( + options, descriptor, transport, status_before, status_after, + result, live_session.association()); + live_session.disconnect(); + return result.success() ? 0 : 4; + } catch (const std::exception& exception) { + std::cerr << "Control interoperability probe failed: " << exception.what() << '\n'; + return 1; + } +} From 60c5426ea997cb9140d51a27f8e1785cc70a6aa0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:24:12 +0700 Subject: [PATCH 37/66] Add standalone C5 control interop build profile --- tools/control_interop_probe/CMakeLists.txt | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tools/control_interop_probe/CMakeLists.txt diff --git a/tools/control_interop_probe/CMakeLists.txt b/tools/control_interop_probe/CMakeLists.txt new file mode 100644 index 000000000..9206accf6 --- /dev/null +++ b/tools/control_interop_probe/CMakeLists.txt @@ -0,0 +1,37 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850ControlInteropProbe + VERSION 0.1.0 + DESCRIPTION "Guarded IEC 61850 live control interoperability harness" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") +set(ARIEC61850_BUILD_TOOLS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_FUZZERS OFF CACHE BOOL "" FORCE) + +add_subdirectory(${ARIEC61850_ROOT} ariec61850-core) + +add_executable(ariec61850_control_interop_probe + ${ARIEC61850_ROOT}/tools/control_interop_probe.cpp) +target_link_libraries(ariec61850_control_interop_probe PRIVATE ARIEC61850::core) +target_compile_features(ariec61850_control_interop_probe PRIVATE cxx_std_20) + +if(MSVC) + target_compile_options(ariec61850_control_interop_probe PRIVATE /W4 /WX /permissive-) +else() + target_compile_options(ariec61850_control_interop_probe PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) +endif() + +enable_testing() +add_test( + NAME ariec61850_control_interop_self_test + COMMAND ariec61850_control_interop_probe --self-test) +add_test( + NAME ariec61850_control_interop_help + COMMAND ariec61850_control_interop_probe --help) + +message(STATUS "arstack61850 C5 interop harness: read-only default=ON") +message(STATUS "arstack61850 C5 interop harness: explicit arm token required for Write=ON") +message(STATUS "arstack61850 C5 interop harness: automatic command retry=OFF") From 022659b2f5e91b23e38a66b1ad47f7ebf19cb4ce Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:24:28 +0700 Subject: [PATCH 38/66] Add C5 control interop harness CI --- .../workflows/control-interop-harness-ci.yml | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/control-interop-harness-ci.yml diff --git a/.github/workflows/control-interop-harness-ci.yml b/.github/workflows/control-interop-harness-ci.yml new file mode 100644 index 000000000..2c31f1986 --- /dev/null +++ b/.github/workflows/control-interop-harness-ci.yml @@ -0,0 +1,71 @@ +name: Control Interop Harness CI + +on: + push: + branches: + - "agent/phase-4d-c5-control-interoperability" + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + control-interop: + name: Control Interop Harness / ${{ matrix.compiler }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + compiler: gcc + - os: ubuntu-latest + compiler: clang + - os: windows-latest + compiler: msvc + + steps: + - uses: actions/checkout@v4 + + - name: Select compiler + if: runner.os == 'Linux' + shell: bash + run: | + if [ "${{ matrix.compiler }}" = "clang" ]; then + echo "CC=clang" >> "$GITHUB_ENV" + echo "CXX=clang++" >> "$GITHUB_ENV" + else + echo "CC=gcc" >> "$GITHUB_ENV" + echo "CXX=g++" >> "$GITHUB_ENV" + fi + + - name: Configure C5 harness + shell: bash + run: cmake -S tools/control_interop_probe -B build-control-interop + + - name: Build C5 harness + shell: bash + run: cmake --build build-control-interop --config Release --parallel + + - name: Run offline safety self-test + shell: bash + run: ctest --test-dir build-control-interop -C Release --output-on-failure + + - name: Capture C5 harness contract + if: runner.os == 'Linux' + shell: bash + run: | + mkdir -p evidence/control-interop + ./build-control-interop/ariec61850_control_interop_probe --self-test \ + | tee evidence/control-interop/self-test-${{ matrix.compiler }}.txt + ./build-control-interop/ariec61850_control_interop_probe --help \ + > evidence/control-interop/help-${{ matrix.compiler }}.txt + + - name: Upload C5 harness evidence + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: control-interop-harness-${{ matrix.compiler }} + path: evidence/control-interop + if-no-files-found: error From f5f630273d62e2687002e825a9e14137f7cededc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:25:29 +0700 Subject: [PATCH 39/66] Document C5 physical control interoperability procedure --- docs/CONTROL_INTEROP_RUNBOOK.md | 297 ++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 docs/CONTROL_INTEROP_RUNBOOK.md diff --git a/docs/CONTROL_INTEROP_RUNBOOK.md b/docs/CONTROL_INTEROP_RUNBOOK.md new file mode 100644 index 000000000..e1c9d06dc --- /dev/null +++ b/docs/CONTROL_INTEROP_RUNBOOK.md @@ -0,0 +1,297 @@ +# IEC 61850 Control Interoperability Runbook + +## Purpose + +This runbook is the physical/laboratory acceptance procedure for Phase 4D-C5. +It validates the guarded C++ control path against a real IED or a vendor simulator. +It is not an IEC 61850 conformance certificate. + +Sampled Values / ESP32-P4 testing is deliberately out of scope here. + +## Safety boundary + +Use only an isolated, non-operational laboratory IED or simulator whose controlled +output cannot operate primary plant. Verify the physical/control circuit is safe +before arming any Write. + +`ariec61850_control_interop_probe` is read-only by default. A control Write is +blocked unless the exact token below is supplied: + +```text +--arm IEC61850-LAB-CONTROL +``` + +The harness never retries a command automatically. + +## Build + +### Windows / MSVC + +```powershell +cmake -S tools/control_interop_probe -B build-control-interop +cmake --build build-control-interop --config Release --parallel +ctest --test-dir build-control-interop -C Release --output-on-failure +``` + +Executable: + +```text +build-control-interop\Release\ariec61850_control_interop_probe.exe +``` + +### Linux + +```bash +cmake -S tools/control_interop_probe -B build-control-interop +cmake --build build-control-interop --parallel +ctest --test-dir build-control-interop --output-on-failure +``` + +Executable: + +```text +build-control-interop/ariec61850_control_interop_probe +``` + +## 0. Offline safety gate + +Run this before connecting to an IED: + +```bash +ariec61850_control_interop_probe --self-test +``` + +Expected: + +```text +CONTROL_INTEROP_SELF_TEST PASS readOnlyDefault=true armGate=true noAutoRetry=true +``` + +## 1. Start packet capture + +Start Wireshark before connecting. + +Recommended display filter after the run: + +```text +tcp.port == 102 && ip.addr == +``` + +Retain the original PCAP/PCAPNG together with the JSON evidence produced by the +harness. Keep capture timestamps synchronized with the engineering workstation. + +## 2. Read-only discovery gate + +Example: + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --evidence c5-discovery.json +``` + +This may perform MMS Read, GVAA and GetNameList, but no Write. + +Accept when the output proves: + +- the object root resolves as `LD/LN.DO`; +- `ctlModel` is one of Direct normal, SBO normal, Direct enhanced or SBO enhanced; +- exact live `Oper` TypeSpecification is available; +- `SBOw` TypeSpecification is available for SBO enhanced; +- `Cancel` TypeSpecification is available for an SBO model; +- the inferred CDC/control value type is credible; +- `sboTimeout` / `operTimeout` are read when exposed, otherwise bounded fallbacks are explicit; +- the best matching ST/MX status object is identified when exposed; +- evidence JSON reports `mmsControlWriteCount: 0`. + +Do not proceed to Write if discovery is incomplete or vendor-specific command +fields cannot be bound conservatively. + +## 3. Direct normal acceptance + +Example DPC command: + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --action operate \ + --value on \ + --value-kind dpc \ + --origin ARSTACK-C5 \ + --origin-category station \ + --interlock-check on \ + --synchro-check on \ + --arm IEC61850-LAB-CONTROL \ + --evidence c5-direct-normal-on.json +``` + +Acceptance: + +- discovered model is `direct-normal`; +- exactly one `CO/.../Oper` Write is recorded; +- no second command Write appears in the PCAP; +- MMS service completion is accepted; +- status feedback is recorded when the IED exposes a readable ST/MX value. + +Repeat only as a new deliberate operator action if an opposite command is needed. +A second manual action is not an automatic retry. + +## 4. SBO normal acceptance + +Use explicit sequencing so Select and Oper are visible as separate phases: + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --action select-operate \ + --value on \ + --value-kind dpc \ + --arm IEC61850-LAB-CONTROL \ + --evidence c5-sbo-normal-operate.json +``` + +Acceptance: + +- discovered model is `sbo-normal`; +- Select is an MMS Read of `SBO`; +- selection response identifies the selected object or an accepted opaque token; +- Oper uses the immutable selected sequence; +- exactly one command Write is `Oper`; +- no automatic retry appears. + +Explicit Cancel test: + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --action select-cancel \ + --value on \ + --value-kind dpc \ + --arm IEC61850-LAB-CONTROL \ + --evidence c5-sbo-normal-cancel.json +``` + +Acceptance: + +- SBO Select succeeds; +- exactly one `Cancel` Write releases the selection; +- no Oper Write is sent. + +Selection timeout should be tested only with a deliberate bounded lab procedure. +Do not automate repeated Select attempts. + +## 5. Direct enhanced acceptance + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --action operate \ + --value on \ + --value-kind dpc \ + --termination-timeout-ms 10000 \ + --arm IEC61850-LAB-CONTROL \ + --evidence c5-direct-enhanced.json +``` + +Acceptance: + +- exactly one `Oper` Write is sent; +- MMS Write acceptance alone does not complete the command; +- ordinary ST/MX reports are ignored as completion; +- a correlated `CommandTermination` completes the command; +- positive termination is recorded as `positive-termination`, or a negative + LastApplError path records ControlError/AddCause without a retry; +- lack of termination becomes a control timeout without automatically faulting a + healthy MMS association. + +## 6. SBO enhanced acceptance + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --action select-operate \ + --value on \ + --value-kind dpc \ + --termination-timeout-ms 10000 \ + --arm IEC61850-LAB-CONTROL \ + --evidence c5-sbo-enhanced.json +``` + +Acceptance: + +- one `SBOw` Write establishes the value-bearing selection; +- the asynchronous application-error grace window is observed; +- one `Oper` Write follows only after successful selection; +- correlated CommandTermination is required for completion; +- JSON/PCAP therefore show two deliberate command-service Writes (`SBOw`, `Oper`), + not duplicate retries of either service. + +Explicit Cancel test uses `--action select-cancel`. For SBO enhanced the expected +write sequence is `SBOw`, then `Cancel`. + +## 7. Negative-path evidence + +Capture at least one negative enhanced-security result when it can be produced +safely by the lab IED/simulator. Preferred cases are a simulator-configured +interlock or synchrocheck rejection. + +Acceptance evidence should preserve: + +- raw ControlError; +- raw AddCause; +- mapped AddCause text such as `blocked-by-interlocking` or + `blocked-by-synchrocheck`; +- the exact control object; +- the number and identity of Write services; +- proof that no retry follows the rejection. + +Do not deliberately defeat a real interlock merely to create a negative test. + +## 8. Association-loss evidence + +Use a simulator or otherwise safe lab setup. Establish an SBO selection, then +cause the MMS association to disappear before Oper. Verify the client fails +closed and the next command requires a new explicit action/selection. + +For enhanced control, separately exercise association loss while waiting for +CommandTermination where possible. + +## Evidence bundle + +For each accepted case retain: + +```text +C5___/ + evidence.json + capture.pcapng + notes.txt + optional_ied_event_log.txt +``` + +Minimum `notes.txt` content: + +```text +IED vendor/model/firmware: +IED IP: +Control object: +ctlModel: +Test case: +Expected safe plant/simulator response: +Observed response: +PC clock/time source: +Operator: +Date/time: +``` + +The JSON field `mmsControlWriteCount` and `mmsControlWrites` must agree with the +PCAP. A mismatch is a failed evidence gate and must be investigated before any +production-control claim. + +## Acceptance state + +Hosted CI can mark the harness `SOFTWARE_READY_FOR_LAB`. + +Only retained physical/simulator evidence for the required model(s) can move the +specific tested IED profile to `LAB_INTEROP_PASSED`. + +Neither state means IEC 61850 conformance certification. From 47b524556e62368347ba2b57ff6a6a1a902e7d5b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:27:45 +0700 Subject: [PATCH 40/66] Add one-click Windows build for C5 control harness --- scripts/build_control_interop.ps1 | 41 +++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 scripts/build_control_interop.ps1 diff --git a/scripts/build_control_interop.ps1 b/scripts/build_control_interop.ps1 new file mode 100644 index 000000000..38b466197 --- /dev/null +++ b/scripts/build_control_interop.ps1 @@ -0,0 +1,41 @@ +param( + [ValidateSet('Release','Debug')] + [string]$Configuration = 'Release', + [switch]$Clean +) + +$ErrorActionPreference = 'Stop' +$Root = Split-Path -Parent $PSScriptRoot +$Build = Join-Path $Root 'build-control-interop' + +if ($Clean -and (Test-Path $Build)) { + Remove-Item -Recurse -Force $Build +} + +Write-Host "[C5] Configuring IEC 61850 Control Interop harness..." +cmake -S (Join-Path $Root 'tools/control_interop_probe') -B $Build +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "[C5] Building $Configuration..." +cmake --build $Build --config $Configuration --parallel +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "[C5] Running offline safety tests..." +ctest --test-dir $Build -C $Configuration --output-on-failure +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +$CandidateA = Join-Path $Build "$Configuration\ariec61850_control_interop_probe.exe" +$CandidateB = Join-Path $Build 'ariec61850_control_interop_probe.exe' +$Exe = if (Test-Path $CandidateA) { $CandidateA } else { $CandidateB } + +if (-not (Test-Path $Exe)) { + throw "Build succeeded but control interop executable was not found." +} + +Write-Host "" +Write-Host "[C5] READY: $Exe" +Write-Host "[C5] Safe first command (read-only):" +Write-Host " `"$Exe`" 102 --object LD0/CSWI1.Pos --evidence c5-discovery.json" +Write-Host "" +Write-Host "Live Write remains locked unless the executable receives:" +Write-Host " --arm IEC61850-LAB-CONTROL" From 2c9abcbaecfcd60a47689987a73b8c2c9e837265 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:29:27 +0700 Subject: [PATCH 41/66] Publish C5 harness binaries with CI evidence --- .../workflows/control-interop-harness-ci.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/control-interop-harness-ci.yml b/.github/workflows/control-interop-harness-ci.yml index 2c31f1986..12e355c86 100644 --- a/.github/workflows/control-interop-harness-ci.yml +++ b/.github/workflows/control-interop-harness-ci.yml @@ -52,7 +52,7 @@ jobs: shell: bash run: ctest --test-dir build-control-interop -C Release --output-on-failure - - name: Capture C5 harness contract + - name: Stage Linux harness and contract if: runner.os == 'Linux' shell: bash run: | @@ -61,9 +61,20 @@ jobs: | tee evidence/control-interop/self-test-${{ matrix.compiler }}.txt ./build-control-interop/ariec61850_control_interop_probe --help \ > evidence/control-interop/help-${{ matrix.compiler }}.txt + cp build-control-interop/ariec61850_control_interop_probe \ + evidence/control-interop/ariec61850_control_interop_probe-${{ matrix.compiler }} - - name: Upload C5 harness evidence - if: runner.os == 'Linux' + - name: Stage Windows harness and contract + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path evidence/control-interop | Out-Null + $exe = 'build-control-interop/Release/ariec61850_control_interop_probe.exe' + & $exe --self-test | Tee-Object -FilePath evidence/control-interop/self-test-msvc.txt + & $exe --help | Out-File -Encoding utf8 evidence/control-interop/help-msvc.txt + Copy-Item $exe evidence/control-interop/ariec61850_control_interop_probe.exe + + - name: Upload C5 harness evidence and binary uses: actions/upload-artifact@v4 with: name: control-interop-harness-${{ matrix.compiler }} From a9f96ad64dcbbde4ad1be50c3b2909b850d234da Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:31:59 +0700 Subject: [PATCH 42/66] Sync migration checklist through Control C5 --- MIGRATION_CHECKLIST.md | 66 ++++++++++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 18 deletions(-) diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index 483f585ee..7f5cc5538 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -109,19 +109,19 @@ behavioral oracle until laboratory interoperability is complete. - [x] URCB reservation, RptEna enable/disable, optional GI, and touched-state cleanup. - [x] Persistent report polling and Phase 4A monitor ingestion. - [x] Cleanup-required evidence and retry path after association loss. -- [ ] BRCB EntryID resume, purge decisioning, and buffer-overflow recovery. +- [x] BRCB retained-history replay, EntryID resume/rewind, PurgeBuf, replay-gap/overflow recovery, association ownership, ResvTms/Owner lifecycle, and recovery image v2 with legacy v1 restore. ### Phase 4C — built-in TCP and live read-only discovery -- [~] Non-blocking Windows Winsock and POSIX TCP `MmsByteTransport` implementation. -- [~] IPv4/IPv6 resolution, TCP_NODELAY, keepalive, partial-send, and peer-close handling. -- [~] Deadline and cancellation-aware connect/send/receive readiness waits. -- [~] Bounded live GetNameList pagination for domains, variables, and DataSets. -- [~] Optional GetVariableAccessAttributes type probes. -- [~] Optional GetNamedVariableListAttributes DataSet directory reads. -- [~] Optional read-only RCB attribute inventory/state probes. -- [~] Read-only `ariec61850_live_discover` human/JSON CLI. -- [~] Discovery service allowlist regression proving no MMS Write request is emitted. +- [x] Non-blocking Windows Winsock and POSIX TCP `MmsByteTransport` implementation. +- [x] IPv4/IPv6 resolution, TCP_NODELAY, keepalive, partial-send, and peer-close handling. +- [x] Deadline and cancellation-aware connect/send/receive readiness waits. +- [x] Bounded live GetNameList pagination for domains, variables, and DataSets. +- [x] Optional GetVariableAccessAttributes type probes. +- [x] Optional GetNamedVariableListAttributes DataSet directory reads. +- [x] Optional read-only RCB attribute inventory/state probes. +- [x] Read-only `ariec61850_live_discover` human/JSON CLI. +- [x] Discovery service allowlist regression proving no MMS Write request is emitted. ### Phase 4C.1 — live-model parity and physical read-only interoperability @@ -146,10 +146,36 @@ behavioral oracle until laboratory interoperability is complete. - [x] Controlled OCR7SR12 timeout/recovery evidence accepted: healthy baseline, post-association response withholding, client request timeout observed, fresh direct recovery, and identical `934b555dff76a46f` structural fingerprint before/after recovery. - [ ] Pagination continuation evidence accepted on a target that requires more than one page. +### Phase 4D-R — BRCB operational semantics + +- [x] Retained bounded history with independent delivery cursor. +- [x] EntryID replay/resume and rewind-to-oldest semantics. +- [x] PurgeBuf and explicit replay-gap handling. +- [x] Association-aware reservation/ownership with ResvTms reconnect/expiry behavior. +- [x] MMS BRCB operational object exposure and association-context propagation. +- [x] TCP/reset/COTP-DR association-loss lifecycle bridge. +- [x] Recovery image v2 preserves retained replay history/cursor/gap and restores legacy v1. +- [~] Physical ESP32-P4 BRCB raw-partition geometry/latency/endurance/power-loss evidence remains a separate NVM gate. + +### Phase 4D-C — guarded IEC 61850 Control Model parity + +- [x] C1 association-aware, default-deny guarded-control safety state machine. +- [x] C1 Direct normal, SBO normal, Direct enhanced, and SBO enhanced lifecycle semantics. +- [x] C1 immutable selected sequence, ownership, timeout, Cancel, association-loss cleanup, and ctlNum 1..255 wrap. +- [x] C2 exact live-TypeSpecification `Oper`, `SBOw`, and `Cancel` structure builder. +- [x] C2 typed SPC/DPC/integer/unsigned/floating/step control values and conservative vendor-field rejection. +- [x] C2 exact origin, ctlNum, T, optional operTm, Test, and two-bit Check construction with golden MMS Data evidence. +- [x] C3 enhanced-security CommandTermination and LastApplError object correlation. +- [x] C3 standard ControlError and AddCause 0..27 mapping with raw unknown-code preservation. +- [x] C4 live ctlModel/GVAA/GetNameList/CF-timeout discovery over the production MMS association runtime. +- [x] C4 Direct/SBO/SBOw/Oper/Cancel orchestration, asynchronous application-error grace window, non-fatal enhanced termination timeout, and no automatic command retry. +- [x] C1-C4 integrated into the normal `ARIEC61850::core` build and GCC/Clang/MSVC CTest matrix. +- [x] C5 guarded live-interoperability harness implemented with read-only default, explicit arm token, typed values, status-before/after reads, deterministic control-Write counting, JSON evidence, Wireshark filter hint, and offline safety self-test. +- [x] C5 dedicated GCC/Clang/MSVC harness CI and build artifact workflow implemented. +- [~] Physical/simulator C5 acceptance for Direct normal, SBO normal, Direct enhanced, SBO enhanced, negative LastApplError/AddCause, and association-loss cases is pending retained laboratory evidence. + ### Later Phase 4 work -- [ ] Direct and SBO control state machines. -- [ ] Enhanced-security termination/error handling. - [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. @@ -161,17 +187,21 @@ behavioral oracle until laboratory interoperability is complete. - [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. +- [ ] Dynamic DataSet define/delete parity and full dynamic RCB lifecycle parity where still missing from the C# surface. ## Phase 5 — simulator and applications -- [ ] Deterministic IED simulation and read-only MMS server. -- [ ] CLI parity and Windows Npcap abstraction. -- [ ] Native Engineering Workbench, simulator, discovery, and SV publisher UI. +- [ ] Deterministic IED simulation and read-only MMS server integration parity. +- [ ] CLI/application parity and broader Windows Npcap abstraction where still required. +- [ ] Native Engineering Workbench, simulator, discovery, and SV publisher UI parity where product scope still requires it. ## Safety gate -SCL, COMTRADE, PCAP, and the Phase 4C/4C.1 live discovery surface are read-only. MMS file +SCL, COMTRADE, PCAP, and the Phase 4C/4C.1 discovery surface remain 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. +authorization. Phase 4D-C live control is an explicit, authorization-gated API and the C5 +interoperability harness is read-only by default; it refuses a control Write without the exact +laboratory arm token. RCB mutation, control, dynamic DataSet mutation, remote file mutation, +Npcap transmission, and real-time process-bus scheduling retain separate explicit safety gates. +Hosted CI does not constitute physical IED interoperability or IEC 61850 conformance certification. From 2da72e0e0e249f0aec0008cb8a591cb241efdbf9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:32:52 +0700 Subject: [PATCH 43/66] Advance port status to Control C5 lab readiness --- PORT_STATUS.md | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/PORT_STATUS.md b/PORT_STATUS.md index 1bef3f79e..794e4d413 100644 --- a/PORT_STATUS.md +++ b/PORT_STATUS.md @@ -2,9 +2,9 @@ ## Current milestone -**Phase 4D-C5 — guarded IEC 61850 Smart Control is integrated on the current branch for review and controlled laboratory interoperability testing.** +**Phase 4D-C5 — guarded IEC 61850 Smart Control is integrated for controlled laboratory interoperability: live `ctlModel`/TypeSpecification discovery, Direct/SBO normal and enhanced sequencing, exact `Oper`/`SBOw`/`Cancel` structures, CommandTermination correlation, LastApplError/AddCause diagnostics, and a read-only-by-default evidence harness.** -The implementation preserves the existing Phase 4C live discovery and Phase 4D Dynamic DataSet/reporting work from `main`. Buffered-report recovery and ESP32 process-bus work remain separate concerns and are not used as evidence for MMS control behavior. +The integration preserves Phase 4C live discovery and the Dynamic DataSet/reporting work already merged to `main`. Buffered-report recovery and ESP32 process-bus work remain separate concerns and are not used as evidence for MMS control behavior. ## Delivered modules @@ -36,17 +36,36 @@ The implementation preserves the existing Phase 4C live discovery and Phase 4D D - Direct normal and SBO normal complete at the accepted MMS service boundary. - Direct enhanced and SBO enhanced remain pending until a correlated CommandTermination arrives. - Ordinary ST/MX process reports cannot complete an enhanced command. -- LastApplError retains the raw ControlError/AddCause values while mapping the standard ControlError 0..3 and AddCause 0..27 names. +- LastApplError retains raw ControlError/AddCause values while mapping the standard ControlError 0..3 and AddCause 0..27 names. - Positive Oper-only CommandTermination requires an exact `CO/Oper` reference. -- The live TypeSpecification is the source of truth for `ctlVal`, origin, `ctlNum`, `T`, `Test`, `Check`, and optional `operTm`; unknown vendor fields are rejected rather than guessed. +- Live TypeSpecification is authoritative for `ctlVal`, origin, `ctlNum`, `T`, `Test`, `Check`, and optional `operTm`; unknown vendor fields are rejected rather than guessed. - DPC uses MMS network bit order (`Off=01`, `On=10`). - `Check` is exactly two bits: bit 0 synchrocheck and bit 1 interlock-check. - Normal SBO Select is a Read of `SBO`; SBO enhanced SelectWithValue is exactly one `SBOw` Write. - `Cancel` is built from the exact retained selected sequence. -- Before `SBOw` or `Oper`, stale InformationReports are drained; reports arriving during the confirmed exchange remain available for application-error/termination correlation. +- Stale InformationReports are drained before `SBOw` or `Oper`; reports arriving during the confirmed exchange remain available for application-error/termination correlation. - Missing CommandTermination expires as a control timeout and does not automatically fault an otherwise healthy MMS association. - **No automatic command retry is performed.** +## C5 interoperability harness + +`ariec61850_control_interop_probe` is the controlled live-test harness. + +- Read-only discovery is the default. +- A control Write is rejected unless the exact `--arm IEC61850-LAB-CONTROL` token is supplied. +- The operator must provide an explicit object, action, and typed value. +- Supported actions are `operate`, `select-operate`, and `select-cancel`. +- SPC, DPC, integer, unsigned, floating-point, and step-position values are bound conservatively from live type evidence. +- Origin category/identifier, Test, synchrocheck, and interlock-check are explicit. +- Live status is read before and after the action when the IED exposes a usable ST/MX reference. +- Every control Write reference is counted and retained in JSON evidence. +- JSON evidence includes `ctlModel`, CDC, status, Write count/list, report-observation counts, completion, raw ControlError/AddCause, and mapped AddCause. +- The tool prints a Wireshark MMS/TCP filter for JSON-to-PCAP reconciliation. +- Offline `--self-test` verifies the read-only default and arm gate without network access. +- A standalone CMake profile, Windows build helper, and cross-platform CI workflow are provided. + +The laboratory procedure and evidence bundle are defined in `docs/CONTROL_INTEROP_RUNBOOK.md`. + ## Deterministic validation Dedicated strict profiles cover: @@ -62,25 +81,28 @@ Dedicated strict profiles cover: - live descriptor discovery using `ctlModel`, GVAA, GetNameList, CF timeouts, and ST/MX status-reference priority; - Direct one-write behavior, SBO Select to Operate, SBOw asynchronous application-error handling, AutoSelect, stale-selection Cancel, and enhanced termination wait; - production `MmsAssociationControlTransport` strict compile validation; -- integration of C1-C5 sources into the normal `ARIEC61850::core` build graph and CTest targets. +- integration of C1-C4 sources into the normal `ARIEC61850::core` build graph and CTest targets; +- C5 harness offline safety gate and cross-platform build profile. ## Physical interoperability acceptance -Physical IED control acceptance is not claimed until evidence is retained from an isolated, non-operational laboratory IED or simulator for: +The software path and live harness are ready for controlled interoperability testing, but **physical IED control acceptance is not claimed** until retained evidence exists for: -1. read-only live `ctlModel` and GVAA discovery for a known controllable object; +1. read-only live `ctlModel` and GVAA discovery for a known controllable object with `mmsControlWriteCount=0`; 2. Direct normal `Oper` with exactly one command Write and observed process/status feedback; 3. SBO normal `SBO -> Oper` plus explicit `Cancel` and selection-timeout behavior; 4. Direct enhanced `Oper -> CommandTermination` positive and a safe negative LastApplError/AddCause path; 5. SBO enhanced `SBOw -> Oper -> CommandTermination` plus ownership/contention behavior; 6. association loss during selection and while waiting for enhanced termination; 7. interlock/synchrocheck behavior only where the lab setup can exercise it safely; -8. packet capture and deterministic event log proving no automatic command retry. +8. packet capture and JSON/event evidence proving no automatic command retry. + +An individual profile may move from `SOFTWARE_READY_FOR_LAB` to `LAB_INTEROP_PASSED` only when its retained JSON and PCAP evidence agree. ## Safety and claim boundary - Live inventory and raw `ctlModel` probes are read-only. -- Mutating control requires explicit command intent, an exact object root, live type discovery, and the harness authorization gate. +- Mutating control requires explicit intent, an exact object root, live type discovery, and the harness authorization gate. - The implementation and deterministic tests are not an IEC 61850 conformance certificate. - No hosted CI result is treated as proof of a physical IED command. - Sampled Values, ESP32 hardware transmission, and BRCB persistence remain outside Smart Control acceptance. From d7ea8c563c18749470c7be28a7fde843c01e409e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:35:29 +0700 Subject: [PATCH 44/66] Advance migration plan through Control C5 --- MIGRATION_PLAN.md | 136 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 96 insertions(+), 40 deletions(-) diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md index 57872a991..bb97248ad 100644 --- a/MIGRATION_PLAN.md +++ b/MIGRATION_PLAN.md @@ -1,64 +1,120 @@ # ARIEC61850 C++ Migration Plan The migration follows deterministic protocol foundations first, then engineering formats, -association/runtime behavior, live read-only interoperability, and finally active services and -applications. The C# repository remains the behavioral oracle until same-target laboratory -comparison is accepted. +association/runtime behavior, live read-only interoperability, bounded active-service semantics, +and finally physical interoperability plus application parity. The C# repository remains the +behavioral oracle until same-target laboratory comparison is accepted. ## Completed foundation - Phase 0: C++20/CMake, strict warnings, BER, Ethernet/VLAN, PCAP. - Phase 1: MMS Data/UTC, GOOSE, Sampled Values, evidence and fuzzing. -- Phase 2: read-only SCL and COMTRADE. +- Phase 2: read-only SCL and COMTRADE foundations. - Phase 3: TPKT/COTP, Session, Presentation, ACSE, MMS Initiate and confirmed services. - Phase 4A: DataSet/RCB inventory, InformationReport decoding, continuity monitoring. - Phase 4B: transport-injected association and persistent report-subscription runtime. +- Phase 4C/4C.1: built-in Windows/Linux TCP, live read-only discovery/model projection, + C#↔C++ structural/type parity, repeated physical read-only interoperability, timeout/recovery, + RCB/control-block deep-read evidence, and large-model GetNameList pagination. -## Current delivery — Phase 4C and 4C.1 +## Completed software tranche — Phase 4D-R -### Phase 4C +BRCB operational semantics are implemented and regression-tested: -- built-in non-blocking Windows/Linux TCP transport; -- deadline/cancellation-aware connect, send, and receive; -- bounded live domain, variable, DataSet, type, and RCB discovery; -- strict read-only service allowlist. +- bounded retained report history with an independent delivery cursor; +- EntryID replay/resume and rewind-to-oldest; +- PurgeBuf and replay-gap/overflow handling; +- association-aware reservation/Owner/ResvTms lifecycle; +- association identity propagation through MMS writes; +- TCP/reset/COTP-DR association-loss cleanup; +- replay-capable recovery image v2 with legacy-v1 restore. -### Phase 4C.1 +Physical BRCB raw-partition geometry, latency, endurance budgeting and controlled power-loss +acceptance remain a separate ESP32-P4 NVM evidence gate. -- C#-compatible `live-ied-model-v1` hierarchy; -- IED/LD/LN/DO/DA references, FC, type, DataSet, and RCB evidence; -- conservative identity and CDC inference; -- deterministic manifest/fingerprint; -- C#↔C++ parity checker; -- repeated physical read-only interoperability runner and evidence format. +## Current delivery — Phase 4D-C5 guarded control interoperability -### Acceptance work still required +### C1 — safety state machine -- GCC/Clang/MSVC and Security/Evidence workflow run on PR #14; -- full branch CTest; -- reachable IED/vendor-simulator run; -- primary-vendor ten-cycle and timeout/reconnect evidence; -- same-target C# and C++ parity review; -- large-model pagination evidence when available. +- `ctlModel` 0..4 parity with the C# oracle; +- Direct normal, SBO normal, Direct enhanced and SBO enhanced lifecycle semantics; +- association-aware ownership and default-deny authorization; +- immutable selected sequence across ctlVal/origin/ctlNum/T/operTm/Test/Check; +- selection timeout, association-loss cleanup and explicit Cancel; +- ctlNum 1..255 wrap with zero excluded; +- no automatic command retry. -## Next implementation priority after Phase 4C.1 acceptance +### C2 — exact MMS control structures -### Phase 4D — guarded control foundation +- live TypeSpecification-driven `Oper`, `SBOw` and `Cancel` construction; +- conservative SPC/DPC/integer/unsigned/floating/step-position binding; +- exact origin, ctlNum, T, optional operTm, Test and two-bit Check fields; +- DPC network-bit-order parity and golden MMS Data vectors; +- unknown vendor-specific fields are rejected instead of guessed. -- Direct Operate and Select-Before-Operate state machines; -- normal and enhanced security; -- select, operate, cancel, ownership, timeout, and command termination; -- LastApplError and AddCause; -- default-deny authorization, operator confirmation, and full audit trail; -- no automatic command retry. +### C3 — enhanced-security completion and diagnostics + +- correlated CommandTermination handling; +- LastApplError decoding with standard ControlError and AddCause 0..27 mapping; +- raw unknown-code preservation; +- exact controlled-object correlation; +- ordinary ST/MX process reports cannot complete an enhanced command. + +### C4 — live guarded control session + +- live ctlModel Read and exact Oper/SBOw/Cancel GVAA discovery; +- domain variable discovery and CF sboTimeout/operTimeout reads; +- Direct Operate, SBO Read -> Oper, SBOw -> Oper and explicit Cancel orchestration; +- asynchronous application-error grace window; +- enhanced commands remain pending until correlated CommandTermination; +- missing termination is a control timeout, not an automatic association fault; +- C1-C4 are integrated into `ARIEC61850::core` and the normal GCC/Clang/MSVC CTest matrix. + +### C5 — controlled live interoperability harness + +- `ariec61850_control_interop_probe` is read-only by default; +- any live control Write requires the exact `--arm IEC61850-LAB-CONTROL` token plus an explicit + object, action and value; +- supported deliberate lab actions are `operate`, `select-operate`, and `select-cancel`; +- status-before/status-after reads are captured when a suitable ST/MX object exists; +- all control Write references are counted for deterministic no-retry evidence; +- optional JSON evidence records ctlModel, CDC, status, Write list/count, report observations, + completion, raw ControlError/AddCause and mapped AddCause; +- Wireshark filter guidance lets JSON evidence be reconciled with PCAP/PCAPNG; +- a standalone CMake profile, Windows PowerShell build helper and GCC/Clang/MSVC CI artifacts + make the harness directly usable in a laboratory. + +Current software state: + +`SOFTWARE_READY_FOR_LAB` + +Physical/simulator evidence is still required before changing a tested profile to: + +`LAB_INTEROP_PASSED` + +The required procedure is documented in `docs/CONTROL_INTEROP_RUNBOOK.md`. + +## C5 laboratory acceptance order + +1. Read-only live ctlModel/GVAA/status discovery with zero control Writes. +2. Direct normal: exactly one Oper Write plus status/process feedback. +3. SBO normal: SBO Read -> exactly one Oper Write; separately Select -> Cancel. +4. Direct enhanced: exactly one Oper Write -> correlated CommandTermination. +5. SBO enhanced: one SBOw Write -> one Oper Write -> correlated CommandTermination. +6. Safe negative LastApplError/AddCause case where the simulator/lab IED can provide it. +7. Association loss during SBO selection and enhanced-termination wait. +8. Reconcile JSON Write counts/references with retained PCAP and prove no automatic retry. + +Physical command testing must remain on an isolated/non-operational laboratory IED or simulator. +Hosted CI does not issue a physical command and is not IEC 61850 conformance certification. -Phase 4D must not begin physical command testing until Phase 4C.1 transport/model identity and -same-target read-only parity are accepted. +## Next implementation priorities after accepted C5 evidence -### Later work +1. MMS file and fault-record transfer parity. +2. Dynamic DataSet define/delete and the remaining dynamic RCB lifecycle parity. +3. Mutable SCL workspace/export and live-discovery-to-SCL reconstruction parity. +4. Deterministic IED simulator/server integration needed for repeatable multi-client regression. +5. CLI/application and native engineering-tool parity where product scope requires it. -- BRCB EntryID resume, replay, purge, and overflow recovery; -- dynamic DataSet lifecycle; -- MMS file and fault-record transfer; -- deterministic IED simulator/read-only server; -- CLI parity, Windows Npcap abstraction, and native applications. +Sampled Values / ESP32-P4 process-bus development is intentionally maintained separately from +this MMS-control tranche. From e2c6316ce407dee667f55ed5d723a0abdabc766b Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 11 Aug 2026 06:17:27 +0700 Subject: [PATCH 45/66] feat(reporting): add guarded static RCB sessions --- CMakeLists.txt | 1 + REPORTING_PROFILE.md | 19 + docs/FEATURE_MATRIX.md | 1 + docs/STATIC_RCB_LIVE_TRIAL.md | 80 ++++ .../ariec61850/mms/static_report_session.hpp | 83 +++++ scripts/build_dynamic_rcb_trial.ps1 | 19 + src/mms/static_report_session.cpp | 199 ++++++++++ tests/test_mms_runtime.cpp | 155 ++++++++ tools/dynamic_rcb_trial/CMakeLists.txt | 15 + tools/static_rcb_trial.cpp | 343 ++++++++++++++++++ 10 files changed, 915 insertions(+) create mode 100644 docs/STATIC_RCB_LIVE_TRIAL.md create mode 100644 include/ariec61850/mms/static_report_session.hpp create mode 100644 src/mms/static_report_session.cpp create mode 100644 tools/static_rcb_trial.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 559815fc2..0c9720733 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,7 @@ add_library(ariec61850_core src/mms/live_discovery.cpp src/mms/rcb_selection.cpp src/mms/rcb_failover.cpp + src/mms/static_report_session.cpp src/sampled_values/pdu_codec.cpp src/sampled_values/frame_codec.cpp src/sampled_values/quality.cpp diff --git a/REPORTING_PROFILE.md b/REPORTING_PROFILE.md index c52aa3641..6e2027554 100644 --- a/REPORTING_PROFILE.md +++ b/REPORTING_PROFILE.md @@ -21,6 +21,25 @@ may rank another candidate. Once any mutation is attempted, automatic switching is disabled; an uncertain write must be cleaned up and diagnosed on the original RCB instead of risking a second remote mutation. +## Static DataSet report session + +`MmsStaticReportSessionRuntime` composes the static pool selector, bounded +pre-claim contention/failover, populated DataSet-directory evidence, and the +persistent subscription runtime. `prepare()` is read-only and may rerank after +a busy or flapping candidate. `start()` uses the RCB's existing static binding; +the runtime rejects any request to rewrite `DatSet`. + +The guarded `ariec61850_static_rcb_trial` host tool exposes this lifecycle for +authorized lab validation. Its default mode performs discovery and pre-claim +Reads only. Armed mode enables the selected free BRCB/URCB, optionally requests +GI, observes InformationReports, and cleans up only state acquired by the +runtime. See [docs/STATIC_RCB_LIVE_TRIAL.md](docs/STATIC_RCB_LIVE_TRIAL.md). + +The current guarded single-profile run used a populated static URCB: three +pre-claim Reads remained stable, GI produced one decoded report with zero decode +failures, and disable/reservation release completed without deferred cleanup. +This is not yet long-duration or multi-vendor acceptance. + ## Phase 4C.1 live-model integration Read-only discovery includes DataSet directory and RCB state evidence in `live-ied-model-v1`. diff --git a/docs/FEATURE_MATRIX.md b/docs/FEATURE_MATRIX.md index a13b76d2d..728b4fe11 100644 --- a/docs/FEATURE_MATRIX.md +++ b/docs/FEATURE_MATRIX.md @@ -38,6 +38,7 @@ Status terms are intentionally conservative: | Reporting | Bound/Unbound runtime semantics | Live-proven | One current snapshot observed 6 Bound and 280 Unbound. This is mutable runtime state, not a permanent device capability claim. | | Reporting | Dynamic RCB candidate planner | Live-proven | Read-only ranking selected an empty dynamic BRCB candidate on current endpoint. | | Reporting | Conservative operational availability | Live-proven | Separates dynamic-slot eligibility from populated/static report availability. | +| Reporting | Static DataSet + RCB session | Live-proven / guarded single profile | Reusable runtime selected a populated static URCB after BRCB-only planning correctly found no eligible candidate. Three pre-claim Reads stayed stable; the armed trial reserved/enabled it, requested GI, preserved the existing binding, decoded 1 report with 0 failures, then disabled/released it with cleanup verified. `DatSet` rewrites are rejected. | | Reporting | Read-only contention pre-claim probe | Live-proven | Three repeated probes on `OCR7SR12CTRL/BI6GGIO1.urcbA01` remained free/stable and returned `StableProceed`; no claim/write was performed. | | Reporting | Bounded smart pre-claim failover | Offline-tested / stable live probe | Immediately before mutation, repeated read-only probes reject busy/flapping candidates, add them to the command-local exclusion set, and rerank the next candidate. A stable live candidate passed three probes; a live contended switch remains pending. Mutation failures never trigger automatic switching. | | Reporting | RCB claim/reservation/enable | Live-proven / guarded lab | Explicitly armed Dynamic RCB trial bound and enabled one BRCB, requested GI, then disabled and unbound it with cleanup verified. Live discovery itself remains read-only. | diff --git a/docs/STATIC_RCB_LIVE_TRIAL.md b/docs/STATIC_RCB_LIVE_TRIAL.md new file mode 100644 index 000000000..2b83eb6b9 --- /dev/null +++ b/docs/STATIC_RCB_LIVE_TRIAL.md @@ -0,0 +1,80 @@ +# Static DataSet / RCB Live Trial + +`ariec61850_static_rcb_trial` validates the client-side static reporting path +without changing the IED's DataSet model. It discovers populated DataSet +directories and their bound RCBs, applies Smart RCB ranking, then performs +bounded repeated pre-claim Reads immediately before any optional activation. + +## Safety boundary + +The default command is read-only. It sends association, discovery, DataSet +directory, RCB-state, and contention-probe Reads only. Armed execution requires +the exact token `IEC61850-LAB-STATIC-RCB`. + +An armed trial may reserve a selected free URCB, write `RptEna=true`, and issue +`GI=true`. Shutdown writes only the inverse state touched by that runtime: +`RptEna=false` and, for a runtime-reserved URCB, `Resv=false`. + +The static trial never: + +- writes the RCB `DatSet` attribute; +- defines or deletes a DataSet; +- disables or takes over an RCB observed as busy; +- switches to another RCB after any mutation begins. + +If a candidate becomes enabled, reserved, or flaps during the pre-claim window, +the command excludes it and reranks another eligible static candidate within a +hard attempt bound. + +## Build + +```powershell +.\scripts\build_dynamic_rcb_trial.ps1 -BuildDir build-static-rcb-trial -Configuration Release +``` + +The shared harness builds both the dynamic and static reporting executables and +runs their help smoke tests plus the dynamic member-planner regression. + +## Read-only planning + +```powershell +.\build-static-rcb-trial\Release\ariec61850_static_rcb_trial.exe --no-urcb-fallback +``` + +Success ends with `READ_ONLY_STATIC_PLAN_OK` and prints the selected RCB, mode, +bound DataSet, member count, and every bounded pre-claim attempt. + +Use `--dataset-ref ` to require a particular static DataSet or +`--preferred-rcb ` to rank one RCB first. A preferred RCB is not an +ownership override: a busy preferred candidate is still skipped. + +## Guarded subscription + +Run only against an authorized lab IED: + +```powershell +.\build-static-rcb-trial\Release\ariec61850_static_rcb_trial.exe --no-urcb-fallback --arm IEC61850-LAB-STATIC-RCB +``` + +The strongest successful result is `SMART_STATIC_RCB_TRIAL_PASS`, which means +the selected static RCB was enabled, its binding remained populated during +bounded confirmation Reads, an InformationReport was decoded, and cleanup +completed without deferred state. + +`STATIC_TRIAL_LIFECYCLE_PASS_REPORT_PENDING` exits with code 4. It means guarded +enable/confirm/disable cleanup passed, but no GI report arrived within the +bounded observation window. That is lifecycle evidence, not report-delivery +acceptance. + +## Portable runtime API + +Applications can embed `MmsStaticReportSessionRuntime` directly: + +1. retain the `MmsLiveDiscoveryResult` for the session lifetime; +2. call `prepare()` for read-only selection and pre-claim failover; +3. inspect `snapshot()` and the selected static binding; +4. call `start()` only when remote activation is authorized; +5. poll or drain reports, then call `stop()` before disconnecting. + +The runtime rejects `write_data_set_reference=true` at construction, so static +and dynamic DataSet ownership cannot be accidentally mixed. diff --git a/include/ariec61850/mms/static_report_session.hpp b/include/ariec61850/mms/static_report_session.hpp new file mode 100644 index 000000000..efb71e444 --- /dev/null +++ b/include/ariec61850/mms/static_report_session.hpp @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/rcb_contention.hpp" +#include "ariec61850/mms/rcb_failover.hpp" +#include "ariec61850/mms/rcb_selection.hpp" +#include "ariec61850/mms/report_subscription_runtime.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +class MmsStaticReportSessionError final : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +struct MmsStaticReportSessionOptions final { + MmsRcbStaticSelectionOptions selection; + MmsRcbContentionProbeOptions contention; + std::size_t maximum_candidate_attempts{4U}; + MmsReportSubscriptionOptions subscription; +}; + +struct MmsStaticReportSessionSnapshot final { + bool prepared{}; + bool active{}; + std::string selected_rcb_reference; + std::string data_set_reference; + std::size_t data_set_member_count{}; + MmsRcbPreclaimFailoverSnapshot failover; + std::vector blockers; + std::optional subscription; +}; + +// Client-side static DataSet reporting orchestration. prepare() performs only +// bounded Read operations. start() never rewrites DatSet and delegates guarded +// reservation/enable/report/cleanup behavior to MmsReportSubscriptionRuntime. +class MmsStaticReportSessionRuntime final { +public: + MmsStaticReportSessionRuntime( + MmsAssociationRuntime& association, + const MmsLiveDiscoveryResult& discovery, + MmsStaticReportSessionOptions options = {}); + + void prepare(std::stop_token stop_token = {}); + void start(std::stop_token stop_token = {}); + void stop(std::stop_token stop_token = {}) noexcept; + [[nodiscard]] bool poll_once(std::stop_token stop_token = {}); + [[nodiscard]] std::size_t drain_queued_reports(); + + [[nodiscard]] MmsStaticReportSessionSnapshot snapshot() const; + [[nodiscard]] bool prepared() const noexcept { return prepared_; } + [[nodiscard]] bool active() const noexcept { + return subscription_ != nullptr && subscription_->active(); + } + [[nodiscard]] const MmsReportControlCandidate* selected_candidate() const noexcept { + return selected_candidate_ ? &*selected_candidate_ : nullptr; + } + +private: + void resolve_selected_static_binding(); + + MmsAssociationRuntime& association_; + const MmsLiveDiscoveryResult& discovery_; + MmsStaticReportSessionOptions options_; + MmsRcbPreclaimFailoverTracker failover_; + std::optional selected_candidate_; + std::optional selected_directory_; + std::string selected_data_set_reference_; + std::vector blockers_; + std::unique_ptr subscription_; + bool prepared_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/scripts/build_dynamic_rcb_trial.ps1 b/scripts/build_dynamic_rcb_trial.ps1 index ff67ecc57..1b07792d2 100644 --- a/scripts/build_dynamic_rcb_trial.ps1 +++ b/scripts/build_dynamic_rcb_trial.ps1 @@ -31,3 +31,22 @@ Write-Host " `"$exe`" 192.168.1.10 --no-urcb-fallback --auto-members 4" Write-Host "" Write-Host "Armed lab lifecycle example:" Write-Host " `"$exe`" 192.168.1.10 --no-urcb-fallback --auto-members 4 --arm IEC61850-LAB-DYNAMIC-RCB" + +$staticCandidates = @( + (Join-Path $build "$Configuration\ariec61850_static_rcb_trial.exe"), + (Join-Path $build "ariec61850_static_rcb_trial.exe") +) +$staticExe = $staticCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $staticExe) { + throw "ariec61850_static_rcb_trial.exe was not found after build." +} + +Write-Host "" +Write-Host "Static RCB trial executable:" +Write-Host " $staticExe" +Write-Host "" +Write-Host "Read-only static plan example:" +Write-Host " `"$staticExe`" 192.168.1.10 --no-urcb-fallback" +Write-Host "" +Write-Host "Armed static subscription example:" +Write-Host " `"$staticExe`" 192.168.1.10 --no-urcb-fallback --arm IEC61850-LAB-STATIC-RCB" diff --git a/src/mms/static_report_session.cpp b/src/mms/static_report_session.cpp new file mode 100644 index 000000000..b11bc104d --- /dev/null +++ b/src/mms/static_report_session.cpp @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_report_session.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool same_object_name( + const std::string& left, + const std::string& right) noexcept { + try { + return MmsDataSetDirectoryCodec::parse_data_set_reference(left) == + MmsDataSetDirectoryCodec::parse_data_set_reference(right); + } catch (...) { + return false; + } +} + +[[nodiscard]] const MmsReportControlEvidence* find_control_evidence( + const MmsLiveDiscoveryResult& discovery, + const std::string& reference) noexcept { + const auto found = std::find_if( + discovery.report_controls.begin(), + discovery.report_controls.end(), + [&reference](const auto& evidence) { + return evidence.candidate.reference == reference; + }); + return found == discovery.report_controls.end() ? nullptr : &*found; +} + +[[nodiscard]] const MmsDataSetDirectoryEvidence* find_directory_evidence( + const MmsLiveDiscoveryResult& discovery, + const std::string& reference) noexcept { + const auto found = std::find_if( + discovery.data_set_directories.begin(), + discovery.data_set_directories.end(), + [&reference](const auto& evidence) { + return same_object_name(evidence.candidate.reference, reference); + }); + return found == discovery.data_set_directories.end() ? nullptr : &*found; +} + +} // namespace + +MmsStaticReportSessionRuntime::MmsStaticReportSessionRuntime( + MmsAssociationRuntime& association, + const MmsLiveDiscoveryResult& discovery, + MmsStaticReportSessionOptions options) + : association_{association}, + discovery_{discovery}, + options_{std::move(options)}, + failover_{options_.maximum_candidate_attempts} { + if (options_.subscription.write_data_set_reference) { + throw std::invalid_argument( + "Static report session cannot rewrite the RCB DatSet binding."); + } +} + +void MmsStaticReportSessionRuntime::prepare( + const std::stop_token stop_token) { + if (prepared_ || selected_candidate_ || subscription_) { + throw MmsStaticReportSessionError( + "Static report session prepare may be called only once."); + } + if (!association_.associated()) { + throw MmsStaticReportSessionError( + "Static report session requires an active MMS association."); + } + + MmsRcbContentionProbeClient probe{association_}; + while (failover_.may_attempt()) { + auto selection_options = options_.selection; + selection_options.excluded_rcb_references = + failover_.excluded_rcb_references(); + const auto selection = MmsRcbPoolSelector::build_static_selection( + discovery_, selection_options); + if (!selection.selected()) { + blockers_ = selection.blockers; + break; + } + const auto* candidate = MmsRcbPoolSelector::select_report_control( + discovery_, selection); + if (candidate == nullptr) { + blockers_.push_back( + "Static RCB selection returned no matching inventory candidate."); + break; + } + + const auto contention = probe.probe( + *candidate, options_.contention, stop_token); + const auto outcome = failover_.observe(contention); + if (outcome == MmsRcbPreclaimOutcome::stable_proceed) { + selected_candidate_ = *candidate; + resolve_selected_static_binding(); + prepared_ = true; + return; + } + } + + if (blockers_.empty()) { + blockers_.push_back( + failover_.snapshot().exhausted + ? "Static report pre-claim candidate attempt limit was exhausted." + : "No stable static RCB candidate remained after pre-claim selection."); + } + throw MmsStaticReportSessionError(blockers_.front()); +} + +void MmsStaticReportSessionRuntime::resolve_selected_static_binding() { + if (!selected_candidate_) { + throw MmsStaticReportSessionError( + "Cannot resolve a static DataSet without a selected RCB."); + } + const auto* control = find_control_evidence( + discovery_, selected_candidate_->reference); + if (control == nullptr || !control->state) { + throw MmsStaticReportSessionError( + "Selected static RCB has no successful runtime-state evidence."); + } + selected_data_set_reference_ = control->state->data_set_reference; + if (selected_data_set_reference_.empty()) { + throw MmsStaticReportSessionError( + "Selected static RCB has no bound DataSet reference."); + } + const auto* directory = find_directory_evidence( + discovery_, selected_data_set_reference_); + if (directory == nullptr || !directory->directory || + directory->directory->members.empty()) { + throw MmsStaticReportSessionError( + "Selected static RCB DataSet directory is missing or empty."); + } + selected_directory_ = *directory->directory; +} + +void MmsStaticReportSessionRuntime::start( + const std::stop_token stop_token) { + if (!prepared_ || !selected_candidate_ || !selected_directory_) { + throw MmsStaticReportSessionError( + "Static report session must be prepared before start."); + } + if (subscription_) { + throw MmsStaticReportSessionError( + "Static report subscription has already been started."); + } + subscription_ = std::make_unique( + association_, *selected_candidate_, *selected_directory_, + options_.subscription); + subscription_->start(stop_token); +} + +void MmsStaticReportSessionRuntime::stop( + const std::stop_token stop_token) noexcept { + if (subscription_) { + subscription_->stop(stop_token); + } +} + +bool MmsStaticReportSessionRuntime::poll_once( + const std::stop_token stop_token) { + if (!subscription_) { + throw MmsStaticReportSessionError( + "Static report session has not been started."); + } + return subscription_->poll_once(stop_token); +} + +std::size_t MmsStaticReportSessionRuntime::drain_queued_reports() { + if (!subscription_) { + throw MmsStaticReportSessionError( + "Static report session has not been started."); + } + return subscription_->drain_queued_reports(); +} + +MmsStaticReportSessionSnapshot MmsStaticReportSessionRuntime::snapshot() const { + MmsStaticReportSessionSnapshot result; + result.prepared = prepared_; + result.active = active(); + result.failover = failover_.snapshot(); + result.blockers = blockers_; + result.selected_rcb_reference = selected_candidate_ + ? selected_candidate_->reference + : std::string{}; + result.data_set_reference = selected_data_set_reference_; + result.data_set_member_count = selected_directory_ + ? selected_directory_->members.size() + : 0U; + if (subscription_) { + result.subscription = subscription_->snapshot(); + } + return result; +} + +} // namespace ar::iec61850::mms diff --git a/tests/test_mms_runtime.cpp b/tests/test_mms_runtime.cpp index 5b1ff2f3b..32edad863 100644 --- a/tests/test_mms_runtime.cpp +++ b/tests/test_mms_runtime.cpp @@ -5,6 +5,7 @@ #include "ariec61850/mms/report_subscription_runtime.hpp" #include "ariec61850/mms/reporting.hpp" #include "ariec61850/mms/services.hpp" +#include "ariec61850/mms/static_report_session.hpp" #include "ariec61850/osi/cotp.hpp" #include "ariec61850/osi/tpkt.hpp" @@ -387,6 +388,158 @@ void queue_write_success( mms::MmsServiceCodec::encode_write_response_p_data(response))); } +[[nodiscard]] mms::MmsReportControlCandidate make_static_brcb_candidate( + std::string name) { + mms::MmsReportControlCandidate candidate; + candidate.domain = "LD0"; + candidate.logical_node = "LLN0"; + candidate.functional_constraint = "BR"; + candidate.name = std::move(name); + candidate.reference = "LD0/LLN0.BR." + candidate.name; + candidate.buffered = true; + candidate.attributes = { + "RptID", "RptEna", "DatSet", "ConfRev", "GI", "ResvTms"}; + return candidate; +} + +[[nodiscard]] mms::MmsLiveDiscoveryResult make_static_discovery( + const mms::MmsReportControlCandidate& preferred, + const mms::MmsReportControlCandidate& fallback) { + mms::MmsLiveDiscoveryResult discovery; + discovery.report_inventory.report_controls = {preferred, fallback}; + + for (const auto& candidate : discovery.report_inventory.report_controls) { + mms::MmsReportControlEvidence control; + control.candidate = candidate; + control.requested_attributes = { + "DatSet", "RptID", "ConfRev", "RptEna", "ResvTms"}; + mms::MmsReportControlState state; + state.candidate = candidate; + state.data_set_reference = "LD0/LLN0.DataSet"; + state.report_id = "RPT-" + candidate.name; + state.configuration_revision = 1U; + state.report_enabled = false; + state.reservation_time_seconds = 0U; + control.state = std::move(state); + discovery.report_controls.push_back(std::move(control)); + } + + mms::MmsDataSetDirectoryEvidence directory; + directory.candidate.domain = "LD0"; + directory.candidate.logical_node = "LLN0"; + directory.candidate.name = "DataSet"; + directory.candidate.reference = "LD0/LLN0.DataSet"; + directory.directory = make_directory(); + discovery.data_set_directories.push_back(std::move(directory)); + return discovery; +} + +void queue_static_preclaim_response( + ScriptedTransport& transport, + const std::uint32_t invoke_id, + const bool enabled) { + mms::MmsReadResponse response; + response.invoke_id = invoke_id; + response.results = { + {mms::MmsDataValue::visible_string("LD0/LLN0.DataSet"), std::nullopt}, + {mms::MmsDataValue::unsigned_integer(1U), std::nullopt}, + {mms::MmsDataValue::boolean(enabled), std::nullopt}, + {mms::MmsDataValue::unsigned_integer(0U), std::nullopt}, + }; + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_read_response_p_data(response))); +} + +void queue_static_subscription_probe_response( + ScriptedTransport& transport, + const std::uint32_t invoke_id) { + mms::MmsReadResponse response; + response.invoke_id = invoke_id; + response.results = { + {mms::MmsDataValue::visible_string("RPT-FALLBACK"), std::nullopt}, + {mms::MmsDataValue::boolean(false), std::nullopt}, + {mms::MmsDataValue::visible_string("LD0/LLN0.DataSet"), std::nullopt}, + {mms::MmsDataValue::unsigned_integer(1U), std::nullopt}, + {mms::MmsDataValue::boolean(false), std::nullopt}, + {mms::MmsDataValue::unsigned_integer(0U), std::nullopt}, + }; + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_read_response_p_data(response))); +} + +void static_session_skips_contended_preferred_and_subscribes_fallback() { + ScriptedTransport transport; + queue_handshake(transport); + mms::MmsAssociationRuntime association{transport}; + association.connect({"127.0.0.1", 102U}); + + const auto preferred = make_static_brcb_candidate("brcbPreferred01"); + const auto fallback = make_static_brcb_candidate("brcbFallback01"); + const auto discovery = make_static_discovery(preferred, fallback); + + queue_static_preclaim_response(transport, 1U, true); + queue_static_preclaim_response(transport, 2U, false); + queue_static_subscription_probe_response(transport, 3U); + queue_write_success(transport, 4U); // RptEna=true + queue_write_success(transport, 5U); // GI=true + + mms::MmsStaticReportSessionOptions options; + options.selection.preferred_rcb_reference = preferred.reference; + options.selection.allow_urcb_fallback = false; + options.contention.probe_count = 1U; + options.contention.probe_delay = std::chrono::milliseconds{0}; + options.maximum_candidate_attempts = 2U; + + mms::MmsStaticReportSessionRuntime session{ + association, discovery, options}; + session.prepare(); + const auto prepared = session.snapshot(); + CHECK(prepared.prepared); + CHECK(!prepared.active); + CHECK(prepared.selected_rcb_reference == fallback.reference); + CHECK(prepared.data_set_reference == "LD0/LLN0.DataSet"); + CHECK(prepared.data_set_member_count == 1U); + CHECK(prepared.failover.attempts.size() == 2U); + CHECK(prepared.failover.excluded_rcb_references == + std::vector{preferred.reference}); + + session.start(); + CHECK(session.active()); + auto active = session.snapshot(); + CHECK(active.subscription.has_value()); + CHECK(active.subscription->enabled_by_runtime); + CHECK(!active.subscription->reservation_touched); + + transport.push_receive(wrap_application( + mms::MmsInformationReportCodec::encode_p_data(make_report()))); + CHECK(session.poll_once()); + CHECK(session.snapshot().subscription->received_reports == 1U); + + queue_write_success(transport, 6U); // RptEna=false + session.stop(); + const auto stopped = session.snapshot(); + CHECK(!stopped.active); + CHECK(stopped.subscription->state == + mms::MmsReportSubscriptionState::stopped); + CHECK(!stopped.subscription->cleanup_required); + CHECK(transport.sent().size() == 8U); // CR, AARQ, 3 reads, 3 writes +} + +void static_session_rejects_data_set_rebinding() { + ScriptedTransport transport; + mms::MmsAssociationRuntime association{transport}; + const auto preferred = make_static_brcb_candidate("brcbPreferred01"); + const auto fallback = make_static_brcb_candidate("brcbFallback01"); + const auto discovery = make_static_discovery(preferred, fallback); + mms::MmsStaticReportSessionOptions options; + options.subscription.write_data_set_reference = true; + check_throws([&] { + mms::MmsStaticReportSessionRuntime session{ + association, discovery, options}; + static_cast(session); + }); +} + void subscription_runtime_reserves_enables_receives_and_cleans_up() { ScriptedTransport transport; queue_handshake(transport); @@ -486,6 +639,8 @@ int main() { {"report subscription lifecycle", subscription_runtime_reserves_enables_receives_and_cleans_up}, {"report cleanup after association loss", subscription_marks_cleanup_required_when_association_is_lost}, {"report takeover protection", subscription_does_not_take_over_an_enabled_rcb}, + {"static report session failover lifecycle", static_session_skips_contended_preferred_and_subscribes_fallback}, + {"static report session forbids DataSet rebinding", static_session_rejects_data_set_rebinding}, }; std::size_t passed = 0U; diff --git a/tools/dynamic_rcb_trial/CMakeLists.txt b/tools/dynamic_rcb_trial/CMakeLists.txt index 1131c3a06..2ad9e0637 100644 --- a/tools/dynamic_rcb_trial/CMakeLists.txt +++ b/tools/dynamic_rcb_trial/CMakeLists.txt @@ -28,6 +28,21 @@ endif() enable_testing() add_test(NAME dynamic_rcb_trial_help COMMAND ariec61850_dynamic_rcb_trial --help) +add_executable(ariec61850_static_rcb_trial + ${CMAKE_CURRENT_LIST_DIR}/../static_rcb_trial.cpp) +target_link_libraries(ariec61850_static_rcb_trial PRIVATE ARIEC61850::core) +target_compile_features(ariec61850_static_rcb_trial PRIVATE cxx_std_20) +if(MSVC) + target_compile_options(ariec61850_static_rcb_trial PRIVATE /W4 /permissive- /WX) +else() + target_compile_options(ariec61850_static_rcb_trial PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) +endif() +if(WIN32) + target_link_libraries(ariec61850_static_rcb_trial PRIVATE ws2_32) +endif() +add_test(NAME static_rcb_trial_help COMMAND ariec61850_static_rcb_trial --help) + add_executable(ariec61850_dynamic_report_planner_tests ${CMAKE_CURRENT_LIST_DIR}/test_dynamic_report_planner.cpp) target_link_libraries(ariec61850_dynamic_report_planner_tests PRIVATE diff --git a/tools/static_rcb_trial.cpp b/tools/static_rcb_trial.cpp new file mode 100644 index 000000000..59636abf8 --- /dev/null +++ b/tools/static_rcb_trial.cpp @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/services.hpp" +#include "ariec61850/mms/static_report_session.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::string_view k_arm_token = "IEC61850-LAB-STATIC-RCB"; + +struct CliOptions final { + mms::MmsEndpoint endpoint; + std::chrono::milliseconds timeout{5'000}; + std::string preferred_rcb_reference; + std::string preferred_data_set_reference; + std::size_t maximum_data_set_directories{4'096U}; + std::size_t maximum_rcb_probes{4'096U}; + std::size_t maximum_claim_candidates{4U}; + std::size_t preclaim_probe_count{3U}; + std::chrono::milliseconds preclaim_probe_delay{250}; + int contention_cooldown_seconds{60}; + std::size_t confirmation_cycles{3U}; + std::chrono::milliseconds confirmation_delay{150}; + bool allow_urcb_fallback{true}; + bool trigger_gi{true}; + bool armed{}; +}; + +[[nodiscard]] std::size_t parse_positive( + const std::string& option, + const std::string& text) { + std::size_t consumed = 0U; + const auto value = std::stoull(text, &consumed, 10); + if (consumed != text.size() || value == 0ULL || + value > static_cast( + std::numeric_limits::max())) { + throw std::invalid_argument(option + " requires a positive integer."); + } + return static_cast(value); +} + +[[nodiscard]] std::uint16_t parse_port(const std::string& text) { + const auto value = parse_positive("port", text); + if (value > 65'535U) { + throw std::invalid_argument("port must be 1..65535."); + } + return static_cast(value); +} + +void print_usage() { + std::cout + << "Usage: ariec61850_static_rcb_trial [port] [options]\n\n" + << "Default mode is READ-ONLY: discover populated static DataSets, rank\n" + << "their free BRCB/URCB bindings, and run bounded pre-claim Read probes.\n" + << "No MMS Write is sent unless the exact --arm token is supplied.\n\n" + << "Options:\n" + << " --preferred-rcb REF Prefer one static RCB reference.\n" + << " --dataset-ref REF Require a specific bound static DataSet.\n" + << " --no-urcb-fallback Use BRCB candidates only.\n" + << " --max-datasets N Bound DataSet directory reads (default 4096).\n" + << " --max-rcb N Bound RCB state probes (default 4096).\n" + << " --max-claim-candidates N Bound pre-claim candidates (default 4).\n" + << " --preclaim-probes N Read-only probes per candidate (default 3).\n" + << " --preclaim-delay-ms N Delay between pre-claim probes (default 250).\n" + << " --contention-cooldown N Recorded skip cooldown seconds (default 60).\n" + << " --probe-cycles N Confirmation reads after enable (default 3).\n" + << " --probe-delay-ms N Delay between confirmation reads (default 150).\n" + << " --timeout-ms N Connect/request timeout (default 5000).\n" + << " --no-gi Do not request GI after enabling.\n" + << " --arm " << k_arm_token << "\n" + << " Enable the selected static RCB, observe reports,\n" + << " then disable/release only state touched here.\n" + << " -h, --help Show this help.\n\n" + << "This harness never rewrites DatSet, creates/deletes a DataSet, disables a\n" + << "busy RCB, or switches candidates after a mutation begins.\n"; +} + +[[nodiscard]] CliOptions parse_cli(const int argc, char** argv) { + if (argc < 2) { + throw std::invalid_argument("host is required."); + } + CliOptions options; + options.endpoint.host = argv[1]; + options.endpoint.port = 102U; + int index = 2; + if (index < argc && std::string_view{argv[index]}.rfind("--", 0) != 0U) { + options.endpoint.port = parse_port(argv[index++]); + } + while (index < argc) { + const std::string option = argv[index++]; + if (option == "--no-urcb-fallback") { + options.allow_urcb_fallback = false; + } else if (option == "--no-gi") { + options.trigger_gi = false; + } else if (option == "--preferred-rcb" || + option == "--dataset-ref" || + option == "--max-datasets" || + option == "--max-rcb" || + option == "--max-claim-candidates" || + option == "--preclaim-probes" || + option == "--preclaim-delay-ms" || + option == "--contention-cooldown" || + option == "--probe-cycles" || + option == "--probe-delay-ms" || + option == "--timeout-ms" || option == "--arm") { + if (index >= argc) { + throw std::invalid_argument(option + " requires a value."); + } + const std::string value = argv[index++]; + if (option == "--preferred-rcb") { + options.preferred_rcb_reference = value; + } else if (option == "--dataset-ref") { + options.preferred_data_set_reference = value; + } else if (option == "--max-datasets") { + options.maximum_data_set_directories = parse_positive(option, value); + } else if (option == "--max-rcb") { + options.maximum_rcb_probes = parse_positive(option, value); + } else if (option == "--max-claim-candidates") { + options.maximum_claim_candidates = parse_positive(option, value); + } else if (option == "--preclaim-probes") { + options.preclaim_probe_count = parse_positive(option, value); + } else if (option == "--preclaim-delay-ms") { + options.preclaim_probe_delay = std::chrono::milliseconds{ + static_cast(parse_positive(option, value))}; + } else if (option == "--contention-cooldown") { + const auto seconds = parse_positive(option, value); + if (seconds > static_cast( + std::numeric_limits::max())) { + throw std::invalid_argument( + option + " exceeds the supported integer range."); + } + options.contention_cooldown_seconds = static_cast(seconds); + } else if (option == "--probe-cycles") { + options.confirmation_cycles = parse_positive(option, value); + } else if (option == "--probe-delay-ms") { + options.confirmation_delay = std::chrono::milliseconds{ + static_cast(parse_positive(option, value))}; + } else if (option == "--timeout-ms") { + options.timeout = std::chrono::milliseconds{ + static_cast(parse_positive(option, value))}; + } else if (option == "--arm") { + if (value != k_arm_token) { + throw std::invalid_argument("invalid --arm token."); + } + options.armed = true; + } + } else { + throw std::invalid_argument("Unknown option: " + option); + } + } + return options; +} + +[[nodiscard]] std::span response_payload( + const mms::MmsConfirmedExchangeResult& exchange) { + if (!exchange.presentation_payload.empty()) { + return exchange.presentation_payload; + } + return exchange.envelope.mms_payload; +} + +void confirmation_read( + mms::MmsAssociationRuntime& association, + const mms::MmsReportControlCandidate& candidate) { + const auto invoke_id = association.next_invoke_id(); + mms::MmsReadRequest request; + request.invoke_id = invoke_id; + request.variables.push_back(candidate.attribute_object_name("RptEna")); + request.variables.push_back(candidate.attribute_object_name("DatSet")); + const auto encoded = mms::MmsServiceCodec::encode_read_request_p_data( + request, association.negotiated().presentation_context_id); + const auto exchange = association.exchange_confirmed(encoded, invoke_id); + if (exchange.envelope.kind != mms::MmsPduKind::confirmed_response) { + throw std::runtime_error( + "Static RCB confirmation read did not return Confirmed-Response."); + } + const auto response = mms::MmsServiceCodec::decode_read_response( + response_payload(exchange), invoke_id); + if (response.results.size() != 2U || !response.results[0].success() || + !response.results[1].success()) { + throw std::runtime_error( + "Static RCB confirmation read returned incomplete results."); + } + const auto* enabled = std::get_if(&response.results[0].value->value()); + const auto* data_set = std::get_if( + &response.results[1].value->value()); + if (enabled == nullptr || !*enabled || data_set == nullptr || + data_set->empty()) { + throw std::runtime_error( + "Static RCB confirmation did not preserve RptEna=true plus DatSet."); + } +} + +void print_preclaim_evidence( + const mms::MmsStaticReportSessionSnapshot& snapshot) { + for (const auto& attempt : snapshot.failover.attempts) { + std::cout << "PRECLAIM_ATTEMPT attempt=" << attempt.attempt_number + << " rcb=" << attempt.rcb_reference + << " outcome=" + << mms::mms_rcb_preclaim_outcome_name(attempt.outcome) + << " busy=" << (attempt.busy ? "true" : "false") + << " flapping=" << (attempt.flapping ? "true" : "false") + << " cooldownSeconds=" << attempt.cooldown_seconds << '\n'; + } +} + +} // namespace + +int main(const int argc, char** argv) { + try { + if (argc == 2 && (std::string_view{argv[1]} == "--help" || + std::string_view{argv[1]} == "-h")) { + print_usage(); + return 0; + } + const auto cli = parse_cli(argc, argv); + + mms::MmsAssociationOptions association_options; + association_options.connect_timeout = cli.timeout; + association_options.request_timeout = cli.timeout; + mms::MmsTcpLiveDiscoverySession live_session{{}, association_options}; + live_session.connect(cli.endpoint); + + mms::MmsLiveDiscoveryOptions discovery_options; + discovery_options.probe_variable_types = false; + discovery_options.read_data_set_directories = true; + discovery_options.maximum_data_set_directories = + cli.maximum_data_set_directories; + discovery_options.probe_report_controls = true; + discovery_options.maximum_report_control_probes = cli.maximum_rcb_probes; + auto discovery = live_session.discover(discovery_options); + + mms::MmsStaticReportSessionOptions session_options; + session_options.selection.preferred_rcb_reference = + cli.preferred_rcb_reference; + session_options.selection.preferred_data_set_reference = + cli.preferred_data_set_reference; + session_options.selection.allow_urcb_fallback = + cli.allow_urcb_fallback; + session_options.selection.allow_polling_fallback = false; + session_options.contention.probe_count = cli.preclaim_probe_count; + session_options.contention.probe_delay = cli.preclaim_probe_delay; + session_options.contention.cooldown_seconds = + cli.contention_cooldown_seconds; + session_options.maximum_candidate_attempts = + cli.maximum_claim_candidates; + session_options.subscription.trigger_general_interrogation = + cli.trigger_gi; + session_options.subscription.reserve_unbuffered_rcb = true; + session_options.subscription.write_data_set_reference = false; + + mms::MmsStaticReportSessionRuntime report_session{ + live_session.association(), discovery, session_options}; + try { + report_session.prepare(); + auto prepared = report_session.snapshot(); + print_preclaim_evidence(prepared); + const auto* candidate = report_session.selected_candidate(); + if (candidate == nullptr) { + throw std::runtime_error( + "Static session prepared without a selected RCB."); + } + std::cout << "STATIC_PLAN selectedRcb=" + << prepared.selected_rcb_reference + << " mode=" << (candidate->buffered ? "BRCB" : "URCB") + << " dataSet=" << prepared.data_set_reference + << " members=" << prepared.data_set_member_count << '\n'; + + if (!cli.armed) { + live_session.disconnect(); + std::cout << "READ_ONLY_STATIC_PLAN_OK\n"; + return 0; + } + + report_session.start(); + std::cout << "STATIC_RCB_ENABLE_OK gi=" + << (cli.trigger_gi ? "requested" : "not-requested") + << '\n'; + for (std::size_t cycle = 0U; + cycle < cli.confirmation_cycles; ++cycle) { + std::this_thread::sleep_for(cli.confirmation_delay); + confirmation_read(live_session.association(), *candidate); + const auto drained = report_session.drain_queued_reports(); + std::cout << "CONFIRM_READ cycle=" << (cycle + 1U) + << " queuedReportsDrained=" << drained << '\n'; + } + + const auto active = report_session.snapshot(); + if (!active.subscription) { + throw std::runtime_error( + "Static subscription snapshot is unavailable."); + } + std::cout << "REPORT_EVIDENCE received=" + << active.subscription->received_reports + << " decodeFailures=" + << active.subscription->decode_failures + << " streams=" << active.subscription->streams.size() + << '\n'; + + report_session.stop(); + const auto stopped = report_session.snapshot(); + if (!stopped.subscription || stopped.subscription->cleanup_required) { + throw std::runtime_error( + "Static RCB stop left cleanup_required=true."); + } + std::cout << "STATIC_RCB_DISABLE_OK\n"; + live_session.disconnect(); + + if (cli.trigger_gi && active.subscription->received_reports == 0U) { + std::cout + << "STATIC_TRIAL_LIFECYCLE_PASS_REPORT_PENDING: enable/disable " + "and binding preservation passed, but no GI report was " + "observed during bounded confirmation reads.\n"; + return 4; + } + std::cout << "SMART_STATIC_RCB_TRIAL_PASS\n"; + return 0; + } catch (...) { + report_session.stop(); + live_session.disconnect(); + throw; + } + } catch (const std::exception& exception) { + std::cerr << "Static RCB trial failed: " << exception.what() << '\n'; + return 2; + } +} From e7aa282e75f28c036e2f7338e88c0cb623379886 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:36:28 +0700 Subject: [PATCH 46/66] Sync feature matrix through Control C5 --- docs/FEATURE_MATRIX.md | 146 ++++++++++++++++++++--------------------- 1 file changed, 71 insertions(+), 75 deletions(-) diff --git a/docs/FEATURE_MATRIX.md b/docs/FEATURE_MATRIX.md index a13b76d2d..616803dcd 100644 --- a/docs/FEATURE_MATRIX.md +++ b/docs/FEATURE_MATRIX.md @@ -2,95 +2,91 @@ Status terms are intentionally conservative: -- **Live-proven** — exercised against a reachable IED/vendor simulator endpoint. -- **Offline-tested** — deterministic unit/integration/fuzz evidence exists, but no - current live-device claim is made. -- **Partial** — useful implementation exists but important parity/interop gaps remain. +- **Live-proven** — exercised against a reachable IED or vendor simulator. +- **Offline-tested** — deterministic unit/integration/fuzz/CI evidence exists, but no current live-device claim is made. +- **Software-ready for lab** — the production path and guarded live-test harness are implemented; retained physical/simulator evidence is still required. +- **Partial** — useful implementation exists but important parity or interoperability gaps remain. - **Planned** — not implemented as a usable public feature yet. | Area | Feature | Status | Current evidence / boundary | |---|---|---|---| -| Transport | TCP client transport | Live-proven | Windows/POSIX transport used by live MMS discovery. | -| OSI | TPKT / COTP | Live-proven | Live association path reaches MMS endpoint. | -| OSI | Session / Presentation / ACSE | Live-proven | Current association profile + tolerant C#-compatible acceptance path interoperates with current test endpoint. | -| MMS client | Initiate / association lifecycle | Live-proven | Read-only discovery session associates successfully. Multi-profile reconnect retry is still incomplete. | -| MMS client | GetNameList domains | Live-proven | Current OCR7SR12 evidence discovers 4 domains. | -| MMS client | GetNameList variables | Live-proven | Thousands of MMS variables discovered live; inventory can vary with exposed runtime/configuration state. | -| MMS client | GetVariableAccessAttributes | Live-proven | C++ LN-root planner completed 123/123 probes and projected exact MMS types across 6990 canonical DA on the current endpoint. | -| MMS client | Read | Live-proven | Full current OCR7SR12 RCB inventory was probed read-only: 286/286 represented, 0 NotRead and 0 ReadFailed in the supplied full-probe snapshot. | -| MMS client | GetNamedVariableListAttributes | Live-proven/optional | Used when DataSet directory reads are enabled; may be deliberately skipped for structural-only runs. | -| MMS client | GO/SV/SG/LG exact-attribute deep Read | SGCB live-proven / integrated path offline-tested | Standalone bounded reader physically read `OCR7SR12PROT/LLN0.SP.SGCB` 5/5 (`ActSG`, `CnfEdit`, `EditSG`, `LActTm`, `NumOfSG`). The new `live_discover --control-block-values` overlay is CI-tested and awaits one integrated physical regression. | -| Live model | LD/LN/DO/DA projection | Live-proven | Same-IED OCR7SR12 evidence: 4 LD, 123 LN, 1186 DO, 6990 DA; C#↔C++ structural comparison has blocking=0. | -| Live model | IED identity resolver | Live-proven | OCR7SR12 inferred from logical-device domain suffix evidence. | -| Live model | CDC inference | Live-proven + offline-tested | C# registry/pattern port used by live model; exact CDC still depends on available evidence. | -| Live model | TypeTemplates | Live-proven | Live C# and C++ captures canonicalize to the same 471 template identities; raw projection contains 1309 candidates in both current captures. | -| Live model | VariableTypeDiscoveries | Live-proven | C# direct probes and C++ LN-root probes use different request strategies; common projected DA type signatures compare with blocking=0. | -| Live model | Structural fingerprint | Live-proven / stability pending | Current live capture emits a structure-only fingerprint excluding mutable DataSet/RCB runtime state; repeated-cycle stability remains an acceptance gate. | -| Live model | Runtime snapshot fingerprint | Live-proven | Current live capture includes mutable DataSet inventory and RCB binding/enable/reservation evidence separately from structural identity. | -| Live model | Control-block runtime evidence overlay | Offline-tested / physical integration pending | Mutable GO/SV/SG/LG values are projected after structural model build, so deep runtime evidence does not change the canonical structural fingerprint. Complete/partial/failed/bounded states are reported explicitly. | -| Cross-language | Same-IED C# ↔ C++ structural comparator | Live-proven | OCR7SR12 structural comparison is PASS with blocking=0 and totalFindings=0; `--types` is also PASS with blocking=0 and totalFindings=0 on common exact type evidence. | -| Cross-language | Same-IED runtime comparator | Live-proven / mutable | Runtime comparison is informational; C++-only `DataSetBindingStatus` is treated as enrichment rather than a false cross-schema mismatch. | -| DataSet | Name inventory | Live-proven | Dynamic inventory may legitimately change between sessions. | -| DataSet | Member directory | Live-proven/optional | Explicitly skipped with `--no-datasets`; empty member list then produces evidence warning, not structural failure. | -| DataSet | Dynamic create/verify/delete lifecycle | Live-proven / guarded lab | One bounded lifecycle created a domain-specific DataSet, verified exact member order, used it for reporting, and deleted only the runtime-owned DataSet. | -| Reporting | RCB inventory | Live-proven | Current endpoint exposes 286 RCB identities: 8 BRCB and 278 URCB in the C# oracle capture. | -| Reporting | Full RCB read-only probe | Live-proven | Current C++ full-probe snapshot classified all 286 RCBs with 0 NotRead and 0 ReadFailed. | -| Reporting | Bound/Unbound runtime semantics | Live-proven | One current snapshot observed 6 Bound and 280 Unbound. This is mutable runtime state, not a permanent device capability claim. | -| Reporting | Dynamic RCB candidate planner | Live-proven | Read-only ranking selected an empty dynamic BRCB candidate on current endpoint. | -| Reporting | Conservative operational availability | Live-proven | Separates dynamic-slot eligibility from populated/static report availability. | -| Reporting | Read-only contention pre-claim probe | Live-proven | Three repeated probes on `OCR7SR12CTRL/BI6GGIO1.urcbA01` remained free/stable and returned `StableProceed`; no claim/write was performed. | -| Reporting | Bounded smart pre-claim failover | Offline-tested / stable live probe | Immediately before mutation, repeated read-only probes reject busy/flapping candidates, add them to the command-local exclusion set, and rerank the next candidate. A stable live candidate passed three probes; a live contended switch remains pending. Mutation failures never trigger automatic switching. | -| Reporting | RCB claim/reservation/enable | Live-proven / guarded lab | Explicitly armed Dynamic RCB trial bound and enabled one BRCB, requested GI, then disabled and unbound it with cleanup verified. Live discovery itself remains read-only. | -| Reporting | InformationReport decode / subscription runtime | Live-proven / single profile | The guarded trial received and decoded an InformationReport through the persistent subscription runtime; long-duration and multi-vendor acceptance remain pending. | -| GOOSE | PDU / Ethernet encode-decode | Offline-tested | Golden vectors, deterministic tests and fuzz corpus. | -| GOOSE | Publisher/subscriber runtime foundations | Offline-tested | Physical multi-vendor process-bus acceptance pending. | -| Sampled Values | PDU / Ethernet encode-decode | Offline-tested | Golden vectors, deterministic tests and fuzz corpus. | +| Transport | TCP client transport | Live-proven | Windows/POSIX transport is used by live discovery, reporting, Dynamic DataSet, and C5 control paths. | +| OSI | TPKT / COTP | Live-proven | Live association path reaches MMS endpoints; controlled timeout/recovery evidence exists. | +| OSI | Session / Presentation / ACSE | Live-proven | Current association profiles and the C#-compatible fallback path interoperate with exercised endpoints. | +| MMS client | Initiate / association lifecycle | Live-proven | Fresh association/reconnect cycles are accepted on current evidence sets. | +| MMS client | GetNameList domains/variables | Live-proven | Large live inventories and pagination are exercised. | +| MMS client | GetVariableAccessAttributes | Live-proven | Live model and C5 control discovery consume exact MMS TypeSpecification evidence. | +| MMS client | Read | Live-proven | Discovery, reporting, control descriptor, timeout, and status reads are exercised live. | +| MMS client | Write codec/runtime | Live-proven / guarded simulator | C5 sent explicitly armed `SBOw`, `Cancel`, and `Oper` Writes to IEDScout with JSON/PCAP accounting; physical IED control remains pending. | +| MMS client | GetNamedVariableListAttributes | Live-proven | Dynamic DataSet verification and report binding use the exact ordered directory. | +| MMS client | GO/SV/SG/LG exact-attribute deep Read | Live-proven | OCR7SR12 SGCB deep read accepted 5/5 attributes without mutation. | +| Live model | LD/LN/DO/DA projection | Live-proven | OCR7SR12 structural/type comparison with the C# oracle has zero blocking findings. | +| Live model | IED identity resolver | Live-proven | Identity is inferred conservatively from live logical-device evidence. | +| Live model | CDC inference | Live-proven + offline-tested | CDC inference is conservative; exact command binding uses live TypeSpecification. | +| Live model | Structural fingerprint | Live-proven | Repeated cycles and timeout/recovery retain the structure-only fingerprint. | +| Cross-language | Same-IED C# ↔ C++ structural/type comparator | Live-proven | Structural and common exact-type comparison pass with zero blocking findings on accepted evidence. | +| DataSet | Name inventory/member directory | Live-proven | Dynamic inventory may legitimately vary with runtime configuration. | +| DataSet | Dynamic create/verify/delete | Live-proven / guarded lab | One bounded lifecycle created an owned DataSet, verified exact ordered members, used it for reporting, then deleted only that owned DataSet. | +| Reporting | RCB inventory/read-only probe | Live-proven | Current endpoint evidence includes full RCB inventory and read-only classification. | +| Reporting | Dynamic RCB candidate planner | Live-proven | Read-only ranking identifies safe dynamic candidates without mutation. | +| Reporting | Bounded smart pre-claim failover | Offline-tested / stable live probe | Repeated read-only probes exclude busy/flapping candidates and rerank before mutation; mutation failures never trigger automatic switching. | +| Reporting | Guarded Dynamic RCB lifecycle | Live-proven / guarded lab | Explicitly armed trial bound/enabled one RCB, requested GI, decoded a report, then disabled/unbound with cleanup verification. | +| Reporting | BRCB retained replay / EntryID / PurgeBuf | Offline-tested | Bounded history, replay/resume/rewind, PurgeBuf, overflow, and replay-gap behavior are implemented. | +| Reporting | BRCB Owner / ResvTms / association lifecycle | Offline-tested | Multi-client ownership, reconnect/expiry, and association-loss semantics are hard-profile tested. | +| Reporting | BRCB recovery image v2 | Offline-tested / physical NVM pending | Recovery preserves retained window/cursor/gap with v1 restore; flash endurance and power-loss hardware evidence are separate. | +| Control | `ctlModel` discovery | Live-proven / simulator | C5 rediscovered live `ctlModel` and exact command types before each action on IEDScout. | +| Control | Direct normal | Software-ready for lab | Exactly-one `Oper` semantics are deterministic; retained live Direct normal evidence remains pending. | +| Control | SBO normal | Software-ready for lab | Normal Select is an `SBO` Read; immutable sequence, Oper, and explicit Cancel are implemented; live evidence remains pending. | +| Control | Direct enhanced | Live-proven / simulator | IEDScout OFF and ON-restore each sent one `Oper`, received positive CommandTermination, and changed/restored status without retry. | +| Control | SBO enhanced | Live-proven subset / simulator | IEDScout accepted `SBOw -> Cancel`; full `SBOw -> Oper -> CommandTermination` remains pending for an SBO-enhanced profile. | +| Control | `Oper` / `SBOw` / `Cancel` live-type binding | Live-proven / simulator | Exact Boolean structures from live GVAA were sent successfully; unknown vendor fields still fail closed. | +| Control | Control value types | Offline-tested | SPC/DPC/integer/unsigned/floating/step binding is conservative; DPC uses network bit order. | +| Control | Ownership / immutable sequence | Offline-tested | Second-client takeover, expiry, mismatch, authorization revocation, and association-loss cleanup are covered. | +| Control | CommandTermination | Live-proven / simulator | Positive enhanced termination is exact-control correlated; ordinary ST/MX reports cannot complete a command. | +| Control | LastApplError / ControlError / AddCause | Live-proven / simulator | IEDScout negative path decoded DataAccessError 3, ControlError 3, and AddCause 8; generic reports require exact origin/ctlNum correlation. | +| Control | Automatic command retry | Disabled / live-proven | Every live case reconciled the exact Write count with PCAP; no automatic retry occurred. | +| Control | Live interoperability harness | Live-proven / simulator | Read-only default, exact arm token, typed values, JSON evidence, PCAP reconciliation, and state restoration are exercised. | +| GOOSE | PDU / Ethernet encode-decode | Offline-tested | Golden vectors, deterministic tests, and fuzz corpus; physical multi-vendor acceptance is separate. | +| GOOSE | Publisher/subscriber runtime foundations | Offline-tested | Physical process-bus acceptance remains pending. | +| Sampled Values | PDU / Ethernet encode-decode | Offline-tested | Process-bus work remains separate from MMS control evidence. | | Sampled Values | Caller-owned bounded encode path | Offline-tested | `encode_into(span)` avoids fresh frame-buffer allocation in steady-state publisher design. | | Sampled Values | Live MCU publisher | Planned / embedded path staged | First reference hardware is the Waveshare ESP32-S3-POE-ETH-8DI-8DO; synthetic W5500/ESP-IDF MACRAW transmission proof is required before any timing-grade claim. | -| SCL | Read-only parser / engineering support | Offline-tested | Host-only engineering surface; not part of MCU runtime core. | -| COMTRADE | CFG/DAT read support | Offline-tested | Host engineering feature. | -| PCAP | Read/write/evidence support | Offline-tested | Host evidence feature. | +| SCL | Read-only parser / engineering support | Offline-tested | Host engineering surface; mutable standards-aware export remains later work. | +| COMTRADE | CFG/DAT read support | Offline-tested | ASCII/BINARY/BINARY32/FLOAT32 and mapping foundations are implemented. | +| PCAP | Read/write/evidence support | Live-proven for C5 evidence | Npcap loopback captures were reconciled with JSON Write lists and retained SHA-256 hashes. | | Embedded | `ARIEC61850::embedded_core` boundary | Offline-tested | Host-only services excluded by dedicated build profile. | -| Embedded | no-RTTI profile | Offline-tested | GCC/Clang host-simulation gate. | -| Embedded | no-exceptions MCU core | Planned | Legacy protocol error paths still contain exception debt. | +| Embedded | no-RTTI hard profiles | Offline-tested | GCC/Clang embedded/hard-profile gates are maintained. | +| Embedded | no-exceptions guarded control core | CI-gated | C1 has a dedicated `-fno-exceptions -fno-rtti` GCC/Clang hard profile. | +| Embedded | no-exceptions full MCU codec | Partial | Shared legacy validation/convenience paths still carry exception debt. | | Embedded | ESP32-S3 W5500 / ESP-IDF Ethernet adapter | Planned | Reference design uses ESP-IDF W5500 MACRAW integration with raw Ethernet for SV/GOOSE and `esp_netif`/lwIP for MMS TCP on the same interface. | | 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. | +| MMS server | Static association/browse/read/write foundations | Offline-tested / partial | Static server/runtime components exist; full deterministic simulator/server integration parity remains later work. | | 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/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. | -| Acceptance | Integrated control-block deep-read regression | Pending one physical run | Standalone SGCB 5/5 is live-proven; the newly wired `live_discover --control-block-values` path must now reproduce the result on OCR7SR12. | -| Acceptance | Multi-vendor physical interoperability | Not yet complete | Required before full replacement/industrial claims. | +| Security | ASan/UBSan | CI-gated | Security/Evidence workflow is required on active heads. | +| Security | libFuzzer corpora | CI-gated | BER, GOOSE, SV, PCAP, SCL, COMTRADE, OSI, MMS services/reporting, and file-service corpora are maintained. | +| Portability | GCC / Clang / MSVC | CI-gated | C1-C5 build through normal and dedicated matrices; MSVC was validated locally for this integration. | +| Acceptance | Read-only same-IED structural stability | Live-proven | Repeated-cycle and timeout/recovery evidence are retained. | +| Acceptance | Control C5 simulator subset | Live-proven | SBO enhanced Select/Cancel, negative diagnostics, Direct enhanced positive termination, Write counts, and state restoration are retained. | +| Acceptance | Full physical IEC 61850 control interoperability | Pending | Direct/SBO normal, full SBO enhanced Oper, association-loss, contention, and physical-IED evidence remain required. | +| Acceptance | Multi-vendor physical interoperability | Not yet complete | Required before industrial replacement or broad conformance claims. | -## Current Phase 4C mutation boundary +## Current live mutation boundary -The live discovery workflow may issue read-only MMS operations such as -GetNameList, GetVariableAccessAttributes, GetNamedVariableListAttributes and -Read. Optional `--control-block-values` performs bounded MMS Read only against -exact GO/SV/SG/LG variable names already returned by live `GetNameList`. It must -not perform Write, control, RCB reservation/enable, GoEna/SvEna mutation, GI, -setting-group mutation, dynamic DataSet mutation, or file-service mutation. +Phase 4C/4C.1 discovery remains read-only and sends only explicitly allowed discovery/read services. -## Current live parity milestone +Dynamic DataSet/reporting and Phase 4D-C control use separate, explicit mutation APIs. The C5 harness performs discovery only unless an action is supplied, and refuses every control Write without the exact laboratory arm token plus target object and value. It never retries a command automatically. -On the currently exercised OCR7SR12 endpoint, C# and C++ `live-ied-model-v1` -exports now agree structurally on LD/LN/DO/DA, RCB/control-block identities and -canonical type-template projection. Common exact DA MMS type signatures also -compare with zero blocking findings. A separate C++ read-only full-RCB snapshot -classified all 286 RCBs (6 Bound, 280 Unbound at that instant) with zero -NotRead/ReadFailed entries. The standalone deep reader also read the real SGCB -5/5 without mutation. These facts are evidence for this endpoint and -configuration only; they are not a multi-vendor or full-stack parity claim. +Reporting/BRCB mutation semantics are distinct from the C5 control gate. Failure in either domain does not authorize automatic mutation or failover in the other. -## Public Alpha exit direction +## Current control milestone -A Public Alpha can be useful before full ARIEC61850 parity if it provides a -reproducible Windows/Linux build, stable read-only live discovery/model JSON, -structural C#↔C++ same-IED comparison, documented GOOSE/SV codecs, and honest -interop evidence. Full server/control/reporting/file/TLS parity is not an A1 -requirement. +C1-C5 provide a guarded production control path plus a live evidence harness. IEDScout simulator evidence is retained for a meaningful subset, but the overall project remains short of complete physical and multi-model acceptance. + +`SOFTWARE_READY_FOR_LAB` applies to all implemented control models. + +`LAB_INTEROP_PASSED` applies only to a specific IED/control-model profile after its required positive, negative, failure, and cleanup evidence agrees across JSON and PCAP. The retained IEDScout results do not constitute IEC 61850 conformance certification. + +## Next parity direction + +The next gaps are full remaining control-model/association-loss evidence, MMS file and fault-record transfer, mutable SCL export/reconstruction, deterministic IED simulator/server integration, and broader application/UI parity. Sampled Values and ESP hardware work remain separate from this MMS-control tranche. From 3bd902ff9d845f15e2f3ad9f1560056cb18f5b66 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 04:14:17 +0700 Subject: [PATCH 47/66] c5: add read-only live control inventory probe --- tools/control_inventory_probe.cpp | 344 ++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 tools/control_inventory_probe.cpp diff --git a/tools/control_inventory_probe.cpp b/tools/control_inventory_probe.cpp new file mode 100644 index 000000000..985bb8e41 --- /dev/null +++ b/tools/control_inventory_probe.cpp @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/control_session.hpp" +#include "ariec61850/mms/live_discovery.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; +using namespace ar::iec61850::control; +using namespace ar::iec61850::mms; + +struct Options final { + MmsEndpoint endpoint{}; + std::chrono::milliseconds timeout{5'000}; + std::string evidence_path; + bool self_test{}; +}; + +struct ControlCandidate final { + std::string domain; + std::string object_reference; + std::string ctl_model_item; +}; + +struct CandidateEvidence final { + ControlCandidate candidate; + bool operationally_ready{}; + std::string ctl_model; + std::string cdc; + std::string status_object; + std::string diagnostic; +}; + +[[nodiscard]] std::string lower_ascii(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const char ch) { + return static_cast(std::tolower(static_cast(ch))); + }); + return value; +} + +[[nodiscard]] std::vector split_dollar(const std::string& value) { + std::vector parts; + std::size_t begin{}; + while (begin <= value.size()) { + const auto end = value.find('$', begin); + parts.push_back(value.substr( + begin, end == std::string::npos ? std::string::npos : end - begin)); + if (end == std::string::npos) break; + begin = end + 1U; + } + return parts; +} + +[[nodiscard]] std::vector extract_control_candidates( + const MmsDiscoverySnapshot& snapshot) { + std::vector candidates; + std::set> seen; + + for (const auto& [domain, variables] : snapshot.domain_variables) { + for (const auto& item : variables) { + const auto parts = split_dollar(item); + if (parts.size() < 4U || parts.front().empty()) continue; + if (lower_ascii(parts[1]) != "cf" || + lower_ascii(parts.back()) != "ctlmodel") { + continue; + } + + std::string data_object_path; + for (std::size_t index = 2U; index + 1U < parts.size(); ++index) { + if (parts[index].empty()) { + data_object_path.clear(); + break; + } + if (!data_object_path.empty()) data_object_path.push_back('.'); + data_object_path += parts[index]; + } + if (data_object_path.empty()) continue; + + ControlCandidate candidate; + candidate.domain = domain; + candidate.object_reference = domain + "/" + parts.front() + "." + data_object_path; + candidate.ctl_model_item = item; + + const auto identity = lower_ascii(candidate.object_reference); + if (seen.insert(identity).second) { + candidates.push_back(std::move(candidate)); + } + } + } + + std::sort(candidates.begin(), candidates.end(), [](const auto& left, const auto& right) { + return left.object_reference < right.object_reference; + }); + return candidates; +} + +[[nodiscard]] const char* model_name(const ControlModel model) noexcept { + switch (model) { + case ControlModel::status_only: return "status-only"; + case ControlModel::direct_normal: return "direct-normal"; + case ControlModel::select_before_operate_normal: return "sbo-normal"; + case ControlModel::direct_enhanced: return "direct-enhanced"; + case ControlModel::select_before_operate_enhanced: return "sbo-enhanced"; + case ControlModel::unknown: return "unknown"; + } + return "unknown"; +} + +[[nodiscard]] std::uint16_t parse_port(const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoul(value, &consumed, 10); + if (consumed != value.size() || parsed == 0UL || parsed > 65'535UL) { + throw std::invalid_argument("MMS TCP port must be in the range 1..65535."); + } + return static_cast(parsed); +} + +[[nodiscard]] std::chrono::milliseconds parse_timeout(const std::string& value) { + std::size_t consumed{}; + 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("--timeout-ms requires a positive millisecond value."); + } + return std::chrono::milliseconds{static_cast(parsed)}; +} + +[[nodiscard]] std::string json_escape(const std::string& value) { + std::ostringstream output; + for (const auto ch : value) { + switch (ch) { + case '\\': output << "\\\\"; break; + case '"': output << "\\\""; break; + case '\n': output << "\\n"; break; + case '\r': output << "\\r"; break; + case '\t': output << "\\t"; break; + default: + if (static_cast(ch) < 0x20U) output << '?'; + else output << ch; + } + } + return output.str(); +} + +void print_usage() { + std::cout + << "Usage:\n" + << " ariec61850_control_inventory_probe [port] [options]\n\n" + << "Options:\n" + << " --timeout-ms N MMS connect/request timeout (default 5000).\n" + << " --evidence FILE Write JSON inventory evidence.\n" + << " --self-test Offline parser/safety self-test; no network.\n\n" + << "Safety:\n" + << " This executable is read-only. It has no control Write action and no arm token.\n" + << " It enumerates MMS domains/named variables, finds CF/.../ctlModel candidates,\n" + << " and validates candidates with Read/GVAA only.\n"; +} + +[[nodiscard]] Options parse_options(const int argc, char** argv) { + Options options; + options.endpoint.port = 102U; + if (argc == 2 && std::string_view{argv[1]} == "--self-test") { + options.self_test = true; + return options; + } + if (argc < 2 || std::string_view{argv[1]} == "--help" || + std::string_view{argv[1]} == "-h") { + return options; + } + + options.endpoint.host = argv[1]; + int argument = 2; + if (argument < argc && std::string_view{argv[argument]}.find("--") != 0U) { + options.endpoint.port = parse_port(argv[argument]); + ++argument; + } + while (argument < argc) { + const std::string option = argv[argument++]; + if (option == "--help" || option == "-h") return Options{}; + if (argument >= argc) throw std::invalid_argument(option + " requires a value."); + const std::string value = argv[argument++]; + if (option == "--timeout-ms") options.timeout = parse_timeout(value); + else if (option == "--evidence") options.evidence_path = value; + else throw std::invalid_argument("Unknown option: " + option); + } + return options; +} + +void write_evidence( + const Options& options, + const MmsLiveDiscoveryResult& discovery, + const std::vector& evidence) { + if (options.evidence_path.empty()) return; + std::ofstream output{options.evidence_path, std::ios::binary | std::ios::trunc}; + if (!output) { + throw std::runtime_error("Cannot open evidence file: " + options.evidence_path); + } + + std::size_t ready{}; + for (const auto& item : evidence) { + if (item.operationally_ready) ++ready; + } + + output << "{\n" + << " \"schema\": \"arstack61850-control-inventory-v1\",\n" + << " \"endpoint\": \"" << json_escape(options.endpoint.host) << ':' + << options.endpoint.port << "\",\n" + << " \"readOnly\": true,\n" + << " \"mmsControlWriteCount\": 0,\n" + << " \"domainCount\": " << discovery.domain_count() << ",\n" + << " \"variableCount\": " << discovery.variable_count() << ",\n" + << " \"candidateCount\": " << evidence.size() << ",\n" + << " \"operationallyReadyCount\": " << ready << ",\n" + << " \"candidates\": [\n"; + for (std::size_t index = 0U; index < evidence.size(); ++index) { + const auto& item = evidence[index]; + output << " {\"object\": \"" << json_escape(item.candidate.object_reference) + << "\", \"ctlModelItem\": \"" << json_escape(item.candidate.ctl_model_item) + << "\", \"operationallyReady\": " + << (item.operationally_ready ? "true" : "false") + << ", \"ctlModel\": \"" << json_escape(item.ctl_model) + << "\", \"cdc\": \"" << json_escape(item.cdc) + << "\", \"statusObject\": \"" << json_escape(item.status_object) + << "\", \"diagnostic\": \"" << json_escape(item.diagnostic) << "\"}"; + if (index + 1U != evidence.size()) output << ','; + output << '\n'; + } + output << " ]\n}\n"; +} + +int run_self_test() { + MmsDiscoverySnapshot snapshot; + snapshot.domain_variables["LD0"] = { + "LLN0$ST$Mod$stVal", + "CSWI1$CF$Pos$ctlModel", + "CSWI1$CO$Pos$Oper", + "CILO1$CF$EnaOpn$ctlModel", + "MMXU1$MX$A$phsA$cVal$mag$f", + }; + const auto candidates = extract_control_candidates(snapshot); + if (candidates.size() != 2U || + candidates[0].object_reference != "LD0/CILO1.EnaOpn" || + candidates[1].object_reference != "LD0/CSWI1.Pos") { + std::cerr << "CONTROL_INVENTORY_SELF_TEST FAIL candidate extraction mismatch.\n"; + return 1; + } + std::cout << "CONTROL_INVENTORY_SELF_TEST PASS readOnly=true controlWritePath=false candidates=2\n"; + return 0; +} + +} // namespace + +int main(int argc, char** argv) { + try { + const auto options = parse_options(argc, argv); + if (options.self_test) return run_self_test(); + if (options.endpoint.host.empty()) { + print_usage(); + return argc > 1 ? 0 : 2; + } + + MmsAssociationOptions association_options; + association_options.connect_timeout = options.timeout; + association_options.request_timeout = options.timeout; + MmsTcpLiveDiscoverySession live_session{{}, association_options}; + live_session.connect(options.endpoint); + + MmsLiveDiscoveryOptions discovery_options; + discovery_options.probe_variable_types = false; + discovery_options.read_data_set_directories = false; + discovery_options.probe_report_controls = false; + const auto discovery = live_session.discover(discovery_options); + + std::cout << "CONTROL_INVENTORY_ENDPOINT " << options.endpoint.host << ':' + << options.endpoint.port << '\n'; + std::cout << "MMS_INVENTORY domains=" << discovery.domain_count() + << " variables=" << discovery.variable_count() << '\n'; + + const auto candidates = extract_control_candidates(discovery.names); + MmsAssociationControlTransport transport{live_session.association()}; + std::vector evidence; + evidence.reserve(candidates.size()); + + for (const auto& candidate : candidates) { + CandidateEvidence item; + item.candidate = candidate; + try { + const auto descriptor = ControlDescriptorDiscovery::discover( + transport, candidate.object_reference); + item.operationally_ready = descriptor.operationally_ready(); + item.ctl_model = model_name(descriptor.model); + item.cdc = descriptor.cdc; + if (descriptor.status_object) item.status_object = descriptor.status_object->reference(); + std::cout << "CONTROL_READY object=" << candidate.object_reference + << " ctlModel=" << item.ctl_model + << " cdc=" << item.cdc + << " requiresSelect=" << (descriptor.requires_select() ? "true" : "false") + << " enhanced=" << (descriptor.enhanced() ? "true" : "false"); + if (!item.status_object.empty()) { + std::cout << " status=" << item.status_object; + } + std::cout << '\n'; + } catch (const std::exception& exception) { + item.diagnostic = exception.what(); + std::cout << "CONTROL_CANDIDATE object=" << candidate.object_reference + << " ctlModelItem=" << candidate.ctl_model_item + << " ready=false reason=\"" << item.diagnostic << "\"\n"; + } + evidence.push_back(std::move(item)); + } + + std::size_t ready{}; + for (const auto& item : evidence) { + if (item.operationally_ready) ++ready; + } + std::cout << "CONTROL_INVENTORY_RESULT readOnly=true mmsControlWriteCount=0" + << " candidates=" << evidence.size() + << " ready=" << ready << '\n'; + std::cout << "PCAP_HINT filter=\"tcp.port == " << options.endpoint.port + << " && ip.addr == " << options.endpoint.host << "\"\n"; + + write_evidence(options, discovery, evidence); + live_session.disconnect(); + return ready > 0U ? 0 : 3; + } catch (const std::exception& exception) { + std::cerr << "Control inventory probe failed: " << exception.what() << '\n'; + return 1; + } +} From e59344ec1eabf1045bca1e62bb80c0c91a2827bf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 04:14:32 +0700 Subject: [PATCH 48/66] c5: build read-only control inventory companion --- tools/control_interop_probe/CMakeLists.txt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tools/control_interop_probe/CMakeLists.txt b/tools/control_interop_probe/CMakeLists.txt index 9206accf6..57ba109b0 100644 --- a/tools/control_interop_probe/CMakeLists.txt +++ b/tools/control_interop_probe/CMakeLists.txt @@ -17,11 +17,19 @@ add_executable(ariec61850_control_interop_probe target_link_libraries(ariec61850_control_interop_probe PRIVATE ARIEC61850::core) target_compile_features(ariec61850_control_interop_probe PRIVATE cxx_std_20) +add_executable(ariec61850_control_inventory_probe + ${ARIEC61850_ROOT}/tools/control_inventory_probe.cpp) +target_link_libraries(ariec61850_control_inventory_probe PRIVATE ARIEC61850::core) +target_compile_features(ariec61850_control_inventory_probe PRIVATE cxx_std_20) + if(MSVC) target_compile_options(ariec61850_control_interop_probe PRIVATE /W4 /WX /permissive-) + target_compile_options(ariec61850_control_inventory_probe PRIVATE /W4 /WX /permissive-) else() target_compile_options(ariec61850_control_interop_probe PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) + target_compile_options(ariec61850_control_inventory_probe PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) endif() enable_testing() @@ -31,7 +39,14 @@ add_test( add_test( NAME ariec61850_control_interop_help COMMAND ariec61850_control_interop_probe --help) +add_test( + NAME ariec61850_control_inventory_self_test + COMMAND ariec61850_control_inventory_probe --self-test) +add_test( + NAME ariec61850_control_inventory_help + COMMAND ariec61850_control_inventory_probe --help) message(STATUS "arstack61850 C5 interop harness: read-only default=ON") message(STATUS "arstack61850 C5 interop harness: explicit arm token required for Write=ON") message(STATUS "arstack61850 C5 interop harness: automatic command retry=OFF") +message(STATUS "arstack61850 C5 control inventory companion: read-only only=ON") From 884ee54ead1d4be454eb908d2a9187adcf6fa552 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 04:14:45 +0700 Subject: [PATCH 49/66] c5: surface safe control inventory command after build --- scripts/build_control_interop.ps1 | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/scripts/build_control_interop.ps1 b/scripts/build_control_interop.ps1 index 38b466197..28d95fe8e 100644 --- a/scripts/build_control_interop.ps1 +++ b/scripts/build_control_interop.ps1 @@ -28,14 +28,25 @@ $CandidateA = Join-Path $Build "$Configuration\ariec61850_control_interop_probe. $CandidateB = Join-Path $Build 'ariec61850_control_interop_probe.exe' $Exe = if (Test-Path $CandidateA) { $CandidateA } else { $CandidateB } +$InventoryCandidateA = Join-Path $Build "$Configuration\ariec61850_control_inventory_probe.exe" +$InventoryCandidateB = Join-Path $Build 'ariec61850_control_inventory_probe.exe' +$InventoryExe = if (Test-Path $InventoryCandidateA) { $InventoryCandidateA } else { $InventoryCandidateB } + if (-not (Test-Path $Exe)) { throw "Build succeeded but control interop executable was not found." } +if (-not (Test-Path $InventoryExe)) { + throw "Build succeeded but read-only control inventory executable was not found." +} Write-Host "" Write-Host "[C5] READY: $Exe" -Write-Host "[C5] Safe first command (read-only):" -Write-Host " `"$Exe`" 102 --object LD0/CSWI1.Pos --evidence c5-discovery.json" +Write-Host "[C5] INVENTORY READY: $InventoryExe" +Write-Host "[C5] Safest first command for an unknown IED model (read-only, zero control Write):" +Write-Host " `"$InventoryExe`" 102 --evidence c5-control-inventory.json" +Write-Host "" +Write-Host "[C5] After inventory identifies an exact control object, descriptor discovery remains read-only:" +Write-Host " `"$Exe`" 102 --object --evidence c5-discovery.json" Write-Host "" -Write-Host "Live Write remains locked unless the executable receives:" +Write-Host "Live Write remains locked unless the interop executable receives:" Write-Host " --arm IEC61850-LAB-CONTROL" From 0921d0873197c13b1ede5798f6125cee0dcd870a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 04:15:18 +0700 Subject: [PATCH 50/66] c5: validate and publish read-only control inventory probe --- .github/workflows/control-interop-harness-ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/control-interop-harness-ci.yml b/.github/workflows/control-interop-harness-ci.yml index 12e355c86..9623c7fed 100644 --- a/.github/workflows/control-interop-harness-ci.yml +++ b/.github/workflows/control-interop-harness-ci.yml @@ -61,8 +61,14 @@ jobs: | tee evidence/control-interop/self-test-${{ matrix.compiler }}.txt ./build-control-interop/ariec61850_control_interop_probe --help \ > evidence/control-interop/help-${{ matrix.compiler }}.txt + ./build-control-interop/ariec61850_control_inventory_probe --self-test \ + | tee evidence/control-interop/inventory-self-test-${{ matrix.compiler }}.txt + ./build-control-interop/ariec61850_control_inventory_probe --help \ + > evidence/control-interop/inventory-help-${{ matrix.compiler }}.txt cp build-control-interop/ariec61850_control_interop_probe \ evidence/control-interop/ariec61850_control_interop_probe-${{ matrix.compiler }} + cp build-control-interop/ariec61850_control_inventory_probe \ + evidence/control-interop/ariec61850_control_inventory_probe-${{ matrix.compiler }} - name: Stage Windows harness and contract if: runner.os == 'Windows' @@ -70,9 +76,13 @@ jobs: run: | New-Item -ItemType Directory -Force -Path evidence/control-interop | Out-Null $exe = 'build-control-interop/Release/ariec61850_control_interop_probe.exe' + $inventory = 'build-control-interop/Release/ariec61850_control_inventory_probe.exe' & $exe --self-test | Tee-Object -FilePath evidence/control-interop/self-test-msvc.txt & $exe --help | Out-File -Encoding utf8 evidence/control-interop/help-msvc.txt + & $inventory --self-test | Tee-Object -FilePath evidence/control-interop/inventory-self-test-msvc.txt + & $inventory --help | Out-File -Encoding utf8 evidence/control-interop/inventory-help-msvc.txt Copy-Item $exe evidence/control-interop/ariec61850_control_interop_probe.exe + Copy-Item $inventory evidence/control-interop/ariec61850_control_inventory_probe.exe - name: Upload C5 harness evidence and binary uses: actions/upload-artifact@v4 From 9da05144bf6ad4da57045dee3b9964ed44459f0d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 04:23:10 +0700 Subject: [PATCH 51/66] C5: add raw read-only ctlModel diagnostic probe --- tools/control_ctlmodel_probe.cpp | 188 +++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 tools/control_ctlmodel_probe.cpp diff --git a/tools/control_ctlmodel_probe.cpp b/tools/control_ctlmodel_probe.cpp new file mode 100644 index 000000000..8ed7470fd --- /dev/null +++ b/tools/control_ctlmodel_probe.cpp @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/control_session.hpp" +#include "ariec61850/mms/data_codec.hpp" +#include "ariec61850/mms/live_discovery.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; +using namespace ar::iec61850::control; +using namespace ar::iec61850::mms; + +[[nodiscard]] const char* kind_name(const MmsDataKind kind) noexcept { + switch (kind) { + case MmsDataKind::array: return "array"; + case MmsDataKind::structure: return "structure"; + case MmsDataKind::boolean: return "boolean"; + case MmsDataKind::bit_string: return "bit-string"; + case MmsDataKind::integer: return "integer"; + case MmsDataKind::unsigned_integer: return "unsigned-integer"; + case MmsDataKind::floating_point: return "floating-point"; + case MmsDataKind::octet_string: return "octet-string"; + case MmsDataKind::visible_string: return "visible-string"; + case MmsDataKind::binary_time: return "binary-time"; + case MmsDataKind::bcd: return "bcd"; + case MmsDataKind::boolean_array: return "boolean-array"; + case MmsDataKind::object_id: return "object-id"; + case MmsDataKind::mms_string: return "mms-string"; + case MmsDataKind::utc_time: return "utc-time"; + case MmsDataKind::unknown: return "unknown"; + } + return "unknown"; +} + +[[nodiscard]] std::string raw_hex(const MmsDataValue& value) { + std::ostringstream output; + output << std::uppercase << std::hex << std::setfill('0'); + for (const auto byte : value.raw_value()) { + output << std::setw(2) << static_cast(byte); + } + return output.str(); +} + +[[nodiscard]] std::optional numeric_value(const MmsDataValue& value) noexcept { + if (value.kind() == MmsDataKind::integer) { + if (const auto* number = std::get_if(&value.value())) return *number; + } + if (value.kind() == MmsDataKind::unsigned_integer) { + if (const auto* number = std::get_if(&value.value())) { + if (*number <= static_cast(std::numeric_limits::max())) { + return static_cast(*number); + } + } + } + return std::nullopt; +} + +[[nodiscard]] std::string ctl_model_name(const std::optional value) { + if (!value) return "not-decoded"; + switch (*value) { + case 0: return "status-only"; + case 1: return "direct-normal"; + case 2: return "sbo-normal"; + case 3: return "direct-enhanced"; + case 4: return "sbo-enhanced"; + default: return "out-of-standard-range"; + } +} + +[[nodiscard]] std::uint16_t parse_port(const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoul(value, &consumed, 10); + if (consumed != value.size() || parsed == 0UL || parsed > 65'535UL) { + throw std::invalid_argument("MMS TCP port must be in the range 1..65535."); + } + return static_cast(parsed); +} + +int run_self_test() { + const auto value = MmsDataValue::integer(2); + if (std::string{kind_name(value.kind())} != "integer" || + ctl_model_name(numeric_value(value)) != "sbo-normal") { + std::cerr << "CTLMODEL_RAW_SELF_TEST FAIL\n"; + return 1; + } + std::cout << "CTLMODEL_RAW_SELF_TEST PASS readOnly=true controlWritePath=false\n"; + return 0; +} + +void print_usage() { + std::cout + << "Usage:\n" + << " ariec61850_control_ctlmodel_probe [port] --object LD/LN.DO\n\n" + << "Safety: read-only diagnostic. Performs one MMS Read of CF/.../ctlModel.\n" + << "It has no Write action and no arm token.\n"; +} + +} // namespace + +int main(int argc, char** argv) { + try { + if (argc == 2 && std::string_view{argv[1]} == "--self-test") return run_self_test(); + if (argc < 2 || std::string_view{argv[1]} == "--help" || + std::string_view{argv[1]} == "-h") { + print_usage(); + return argc > 1 ? 0 : 2; + } + + MmsEndpoint endpoint; + endpoint.host = argv[1]; + endpoint.port = 102U; + int argument = 2; + if (argument < argc && std::string_view{argv[argument]}.find("--") != 0U) { + endpoint.port = parse_port(argv[argument]); + ++argument; + } + + std::string object_text; + while (argument < argc) { + const std::string option = argv[argument++]; + if (argument >= argc) throw std::invalid_argument(option + " requires a value."); + const std::string value = argv[argument++]; + if (option == "--object") object_text = value; + else throw std::invalid_argument("Unknown option: " + option); + } + if (object_text.empty()) throw std::invalid_argument("--object is required."); + + ControlObjectReference object; + if (!try_parse_control_object_reference(object_text, object)) { + throw std::invalid_argument("Control object must use LD/LN.DO form."); + } + std::array item_buffer{}; + std::size_t item_bytes{}; + if (!build_control_item(object, "CF", "ctlModel", item_buffer, item_bytes)) { + throw std::runtime_error("Cannot build ctlModel MMS item."); + } + const std::string item{item_buffer.data(), item_bytes}; + + MmsAssociationOptions association_options; + association_options.connect_timeout = std::chrono::milliseconds{5'000}; + association_options.request_timeout = std::chrono::milliseconds{5'000}; + MmsTcpLiveDiscoverySession live_session{{}, association_options}; + live_session.connect(endpoint); + MmsAssociationControlTransport transport{live_session.association()}; + + const auto mms_object = MmsObjectName::domain_specific(std::string{object.domain}, item); + const auto value = transport.read(mms_object); + if (!value) { + throw std::runtime_error("MMS Read returned no ctlModel value."); + } + + const auto numeric = numeric_value(*value); + std::cout << "CTLMODEL_RAW object=" << object_text + << " mms=" << mms_object.reference() + << " kind=" << kind_name(value->kind()) + << " display=\"" << MmsDataCodec::to_display_string(*value) << "\"" + << " rawHex=" << (raw_hex(*value).empty() ? "" : raw_hex(*value)); + if (value->unknown_tag_number()) { + std::cout << " unknownTag=" << *value->unknown_tag_number(); + } + if (numeric) { + std::cout << " numeric=" << *numeric; + } + std::cout << " interpreted=" << ctl_model_name(numeric) << '\n'; + std::cout << "CTLMODEL_RAW_RESULT readOnly=true mmsControlWriteCount=0\n"; + std::cout << "PCAP_HINT filter=\"tcp.port == " << endpoint.port + << " && ip.addr == " << endpoint.host << "\"\n"; + + live_session.disconnect(); + return 0; + } catch (const std::exception& exception) { + std::cerr << "ctlModel raw probe failed: " << exception.what() << '\n'; + return 1; + } +} From e6ecf750b9b8056aa7c64d2d5703cd27a1649f77 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 04:23:30 +0700 Subject: [PATCH 52/66] C5: build raw ctlModel diagnostic companion --- tools/control_interop_probe/CMakeLists.txt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tools/control_interop_probe/CMakeLists.txt b/tools/control_interop_probe/CMakeLists.txt index 57ba109b0..86772d31e 100644 --- a/tools/control_interop_probe/CMakeLists.txt +++ b/tools/control_interop_probe/CMakeLists.txt @@ -22,14 +22,22 @@ add_executable(ariec61850_control_inventory_probe target_link_libraries(ariec61850_control_inventory_probe PRIVATE ARIEC61850::core) target_compile_features(ariec61850_control_inventory_probe PRIVATE cxx_std_20) +add_executable(ariec61850_control_ctlmodel_probe + ${ARIEC61850_ROOT}/tools/control_ctlmodel_probe.cpp) +target_link_libraries(ariec61850_control_ctlmodel_probe PRIVATE ARIEC61850::core) +target_compile_features(ariec61850_control_ctlmodel_probe PRIVATE cxx_std_20) + if(MSVC) target_compile_options(ariec61850_control_interop_probe PRIVATE /W4 /WX /permissive-) target_compile_options(ariec61850_control_inventory_probe PRIVATE /W4 /WX /permissive-) + target_compile_options(ariec61850_control_ctlmodel_probe PRIVATE /W4 /WX /permissive-) else() target_compile_options(ariec61850_control_interop_probe PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) target_compile_options(ariec61850_control_inventory_probe PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) + target_compile_options(ariec61850_control_ctlmodel_probe PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) endif() enable_testing() @@ -45,8 +53,15 @@ add_test( add_test( NAME ariec61850_control_inventory_help COMMAND ariec61850_control_inventory_probe --help) +add_test( + NAME ariec61850_control_ctlmodel_self_test + COMMAND ariec61850_control_ctlmodel_probe --self-test) +add_test( + NAME ariec61850_control_ctlmodel_help + COMMAND ariec61850_control_ctlmodel_probe --help) message(STATUS "arstack61850 C5 interop harness: read-only default=ON") message(STATUS "arstack61850 C5 interop harness: explicit arm token required for Write=ON") message(STATUS "arstack61850 C5 interop harness: automatic command retry=OFF") message(STATUS "arstack61850 C5 control inventory companion: read-only only=ON") +message(STATUS "arstack61850 C5 raw ctlModel diagnostic companion: read-only only=ON") From b9759f24183fbeeaa60330c3c54e94a995899211 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 11 Aug 2026 06:01:12 +0700 Subject: [PATCH 53/66] Harden live Smart Control diagnostics --- MIGRATION_CHECKLIST.md | 2 +- PORT_STATUS.md | 2 +- .../CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md | 64 +++++++++++++++++ docs/CONTROL_INTEROP_RUNBOOK.md | 1 + .../control/command_termination.hpp | 14 +++- .../ariec61850/control/control_session.hpp | 6 +- src/control/command_termination.cpp | 72 +++++++++++++++++-- src/control/control_session.cpp | 23 +++--- tests/test_command_termination.cpp | 57 +++++++++++++++ tools/control_interop_probe.cpp | 39 ++++++++++ 10 files changed, 262 insertions(+), 18 deletions(-) create mode 100644 docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index 7f5cc5538..9d3022163 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -172,7 +172,7 @@ behavioral oracle until laboratory interoperability is complete. - [x] C1-C4 integrated into the normal `ARIEC61850::core` build and GCC/Clang/MSVC CTest matrix. - [x] C5 guarded live-interoperability harness implemented with read-only default, explicit arm token, typed values, status-before/after reads, deterministic control-Write counting, JSON evidence, Wireshark filter hint, and offline safety self-test. - [x] C5 dedicated GCC/Clang/MSVC harness CI and build artifact workflow implemented. -- [~] Physical/simulator C5 acceptance for Direct normal, SBO normal, Direct enhanced, SBO enhanced, negative LastApplError/AddCause, and association-loss cases is pending retained laboratory evidence. +- [~] IEDScout simulator C5 acceptance is retained for SBO enhanced SelectWithValue/Cancel, negative LastApplError/AddCause, and Direct enhanced Oper/positive CommandTermination with state restoration. Direct normal, SBO normal, association-loss, multi-client contention, and physical-IED cases remain pending. ### Later Phase 4 work diff --git a/PORT_STATUS.md b/PORT_STATUS.md index 794e4d413..734fc29a7 100644 --- a/PORT_STATUS.md +++ b/PORT_STATUS.md @@ -59,7 +59,7 @@ The integration preserves Phase 4C live discovery and the Dynamic DataSet/report - Origin category/identifier, Test, synchrocheck, and interlock-check are explicit. - Live status is read before and after the action when the IED exposes a usable ST/MX reference. - Every control Write reference is counted and retained in JSON evidence. -- JSON evidence includes `ctlModel`, CDC, status, Write count/list, report-observation counts, completion, raw ControlError/AddCause, and mapped AddCause. +- JSON evidence includes `ctlModel`, CDC, status, Write count/list, report-observation counts, completion, MMS DataAccessError code/name, raw ControlError/AddCause, and mapped AddCause. - The tool prints a Wireshark MMS/TCP filter for JSON-to-PCAP reconciliation. - Offline `--self-test` verifies the read-only default and arm gate without network access. - A standalone CMake profile, Windows build helper, and cross-platform CI workflow are provided. diff --git a/docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md b/docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md new file mode 100644 index 000000000..1d3d71b61 --- /dev/null +++ b/docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md @@ -0,0 +1,64 @@ +# Smart Control Live Result — 2026-08-11 + +## Scope and target + +Phase 4D-C5 was exercised against `192.168.1.10:102`. The endpoint was verified locally before any Write: + +- listener process: OMICRON IEDScout 4 (`IEDScout.exe`); +- network path: local `KM_Test` address with traffic captured through Npcap loopback; +- control object: `BCU7SLCTRL/CSWI1.Pos`; +- association profile: `BalancedApTitle`; +- automatic command retry: disabled. + +This is simulator interoperability evidence, not physical-plant or IEC 61850 conformance evidence. + +## Results + +| Case | Live model | Result | Wire evidence | +|---|---|---|---| +| Read-only inventory | 4 domains, 5,567 variables, 80 candidates, 17 control-ready | Passed; zero control Write | `mmsControlWriteCount=0` | +| Read-only descriptor | SBO enhanced, Boolean `ctlVal`, exact `Oper`/`SBOw`/`Cancel` types | Passed; status `0x0680`; zero control Write | PCAP: 0 Write requests | +| Negative SBOw with `Test=true` | SBO enhanced | Correctly rejected and decoded as MMS DataAccessError 3 `object-access-denied`, ControlError 3 `operator-test`, AddCause 8 `blocked-by-mode` | Exactly one `SBOw`; one `LastApplError`; no `Cancel`, `Oper`, or retry | +| SBO enhanced select/cancel with `Test=false` | SBO enhanced | Passed; `SBOw` then `Cancel`; status unchanged | Exactly two Writes in order: `SBOw`, `Cancel` | +| Direct enhanced operate OFF | Direct enhanced | Passed with positive CommandTermination; status `0x0680 -> 0x0640` | Exactly one `Oper`; one InformationReport; no retry | +| Direct enhanced operate ON restore | Direct enhanced | Passed with positive CommandTermination; status restored `0x0640 -> 0x0680` | Exactly one `Oper`; one InformationReport; no retry | + +The status values are MMS two-bit bit strings represented by the harness as raw bytes: `0x0680` means six unused bits with payload `10`, while `0x0640` carries payload `01`. + +The live `ctlModel` was rediscovered before every action. IEDScout exposed SBO enhanced (`4`) for the selection cases and Direct enhanced (`3`) for the later operate cases. A mismatched `select-operate` request was rejected locally before any Write when the rediscovered model was Direct enhanced. + +## Live-discovered interoperability repair + +The negative path exposed two IEDScout `LastApplError` variants: + +1. VMD-specific `LastApplError` with omitted `ctlObj`; +2. an embedded exact CO object root such as `BCU7SLCTRL/CSWI1$CO$Pos`, without an `Oper` leaf. + +The decoder now accepts the omitted-`ctlObj` variant only when origin category, origin identifier, and `ctlNum` exactly match the active command. It also recognizes an exact embedded CO object root. Mismatched sequence correlation remains rejected, so an unrelated generic application error cannot complete or diagnose another command. + +The C5 JSON evidence now records both the MMS DataAccessError numeric code/name and the IEC 61850 ControlError/AddCause diagnostics. + +## Artifact hashes + +Generated artifacts remain under the ignored local build evidence directory and are not committed to the public source tree. + +| Artifact | SHA-256 | +|---|---| +| `control-inventory.json` | `A57A97C9D330FA8F33078A3617E5EBD3D450EF35A32FDB3EE6A9B7F3B4AEE41C` | +| `cswi1-pos-discovery-loopback-captured.json` | `9DBF4BDF020F3E4CCE9C0A985A33C64217EBBEFBE494F67153A2570DD80A32D9` | +| `capture-discovery-loopback.pcapng` | `10449524587E10D41152C8DE578DCB716DE65AA6BA2D9FCAC39A051109529279` | +| `cswi1-pos-sbo-enhanced-select-cancel-accepted-diagnostics.json` | `2B7A2097BC4785553A221ED9B779ACDF6E8D07DA802E3D61915E2010CF44F907` | +| `cswi1-pos-sbo-enhanced-select-cancel-accepted-diagnostics.pcapng` | `6F86D9A7F4086DB818DEF597B15D034E22BE514E9F1EBE6A119386AA9405DCF1` | +| `cswi1-pos-sbo-enhanced-select-cancel-success.json` | `04D4EEDE0A1521DAAB57339D9F3157C5AEDFD007593A490A0608AC67694A0DF2` | +| `cswi1-pos-sbo-enhanced-select-cancel-success.pcapng` | `A9D4F7A2C48210F140FEE8F13C23C26D05A4AA4CE7E6FE285D890460061886CC` | +| `cswi1-pos-direct-enhanced-operate-off.json` | `1A8F92B11C95CDB698483966E08FB9E9B3F743C7D7D8197F34F0425374AF39FB` | +| `cswi1-pos-direct-enhanced-operate-off.pcapng` | `DA315C9132B2BFEE3FAFB61808506462299F67A6926F407FF4D51227BF61CC4E` | +| `cswi1-pos-direct-enhanced-operate-on-restore.json` | `BEAA47AF23BF9E2580034D1504FF3115FA68A33AED2BBD91B3D2FE2C1DC20114` | +| `cswi1-pos-direct-enhanced-operate-on-restore.pcapng` | `E794F20BE2D5A22C9AB29C42AD8A8266281956BABE52A1CB3EAE4778F6C9D0E5` | + +## Remaining acceptance boundary + +- GCC/Clang profiles must pass on the integration PR; only MSVC was available locally. +- Direct normal and SBO normal were not exposed by this tested control object during the retained cases. +- Association-loss and multi-client contention were not forced against the running IEDScout process. +- Physical IED behavior remains unclaimed. diff --git a/docs/CONTROL_INTEROP_RUNBOOK.md b/docs/CONTROL_INTEROP_RUNBOOK.md index e1c9d06dc..9ac2820c5 100644 --- a/docs/CONTROL_INTEROP_RUNBOOK.md +++ b/docs/CONTROL_INTEROP_RUNBOOK.md @@ -239,6 +239,7 @@ Acceptance evidence should preserve: - raw ControlError; - raw AddCause; +- MMS DataAccessError numeric code and standard name when the confirmed Write is rejected; - mapped AddCause text such as `blocked-by-interlocking` or `blocked-by-synchrocheck`; - the exact control object; diff --git a/include/ariec61850/control/command_termination.hpp b/include/ariec61850/control/command_termination.hpp index 9e493d2b6..e4d241ff4 100644 --- a/include/ariec61850/control/command_termination.hpp +++ b/include/ariec61850/control/command_termination.hpp @@ -6,12 +6,17 @@ #include #include +#include #include +#include namespace ar::iec61850::control { struct LastApplError final { std::string control_object; + std::optional origin_category; + std::vector origin_identifier; + std::optional control_number; std::int64_t raw_control_error{}; std::int64_t raw_add_cause{25}; ControlError control_error{ControlError::no_error}; @@ -20,6 +25,12 @@ struct LastApplError final { std::string add_cause_name; }; +struct CommandCorrelation final { + std::int64_t origin_category{}; + std::span origin_identifier; + std::uint8_t control_number{}; +}; + struct CommandTermination final { bool is_for_control_object{}; bool is_termination{}; @@ -37,7 +48,8 @@ class CommandTerminationDecoder final { public: [[nodiscard]] static CommandTermination decode( const mms::MmsInformationReport& report, - const ControlObjectReference& object); + const ControlObjectReference& object, + const CommandCorrelation* correlation = nullptr); [[nodiscard]] static std::optional try_decode_last_appl_error( const mms::MmsDataValue& value); diff --git a/include/ariec61850/control/control_session.hpp b/include/ariec61850/control/control_session.hpp index ae0495708..08846b758 100644 --- a/include/ariec61850/control/control_session.hpp +++ b/include/ariec61850/control/control_session.hpp @@ -230,10 +230,12 @@ class ControlObjectSession final { const std::optional& app_error = std::nullopt) const; [[nodiscard]] std::optional wait_for_termination( std::chrono::milliseconds timeout, - std::stop_token stop_token); + std::stop_token stop_token, + const ActiveContext& context); [[nodiscard]] std::optional wait_for_application_error( std::chrono::milliseconds timeout, - std::stop_token stop_token); + std::stop_token stop_token, + const ActiveContext& context); [[nodiscard]] bool selection_expired(const ActiveContext& context) const noexcept; [[nodiscard]] std::chrono::milliseconds effective_operate_timeout( const ControlRequest& request) const; diff --git a/src/control/command_termination.cpp b/src/control/command_termination.cpp index b415c42be..df150b5c9 100644 --- a/src/control/command_termination.cpp +++ b/src/control/command_termination.cpp @@ -146,6 +146,34 @@ namespace { return std::nullopt; } +[[nodiscard]] std::vector read_octets( + const mms::MmsDataValue& value) { + if (value.kind() != mms::MmsDataKind::octet_string) { + return {}; + } + return value.raw_value(); +} + +[[nodiscard]] bool is_generic_last_appl_error_reference( + const std::string& reference) noexcept { + return ascii_equal(reference, "LastApplError") || + ascii_ends_with(reference, "$LastApplError") || + ascii_ends_with(reference, ".LastApplError"); +} + +[[nodiscard]] bool matches_correlation( + const LastApplError& error, + const CommandCorrelation& expected) noexcept { + return error.control_object.empty() && + error.origin_category.has_value() && + error.origin_category.value() == expected.origin_category && + error.control_number.has_value() && + error.control_number.value() == expected.control_number && + std::equal( + error.origin_identifier.begin(), error.origin_identifier.end(), + expected.origin_identifier.begin(), expected.origin_identifier.end()); +} + [[nodiscard]] ControlError map_control_error(const std::int64_t value) noexcept { switch (value) { case 0: return ControlError::no_error; @@ -245,12 +273,18 @@ bool CommandTerminationDecoder::matches_reported_reference( const auto expected = object_reference(object); const auto logical_suffix = "/" + std::string{object.logical_node} + "." + std::string{object.data_object_path}; - const auto co_fragment = "/" + std::string{object.logical_node} + ".CO." + - std::string{object.data_object_path} + "."; + const auto co_object = std::string{object.domain} + "/" + + std::string{object.logical_node} + ".CO." + + std::string{object.data_object_path}; + const auto co_suffix = "/" + std::string{object.logical_node} + ".CO." + + std::string{object.data_object_path}; return ascii_equal(normalized, expected) || ascii_starts_with(normalized, expected + ".") || ascii_ends_with(normalized, logical_suffix) || - ascii_contains(normalized, co_fragment); + ascii_equal(normalized, co_object) || + ascii_equal(normalized, co_object + ".") || + ascii_ends_with(normalized, co_suffix) || + ascii_contains(normalized, co_suffix + "."); } std::optional CommandTerminationDecoder::try_decode_last_appl_error( @@ -288,6 +322,27 @@ std::optional CommandTerminationDecoder::try_decode_last_appl_err const auto add_cause = numeric.back().second; LastApplError result; result.control_object = read_text(children.front()); + const auto error_index = std::find_if( + numeric.begin(), numeric.end(), [](const auto& entry) { + return entry.first == 0U || entry.first == 1U; + })->first; + const auto origin_index = error_index + 1U; + const auto control_number_index = error_index + 2U; + if (origin_index < children.size() && + children[origin_index].kind() == mms::MmsDataKind::structure) { + const auto& origin = children[origin_index].children(); + if (origin.size() >= 2U) { + result.origin_category = read_number(origin[0]); + result.origin_identifier = read_octets(origin[1]); + } + } + if (control_number_index < children.size()) { + const auto control_number = read_number(children[control_number_index]); + if (control_number.has_value() && control_number.value() >= 0 && + control_number.value() <= std::numeric_limits::max()) { + result.control_number = static_cast(control_number.value()); + } + } result.raw_control_error = error.value(); result.raw_add_cause = add_cause; result.control_error = map_control_error(error.value()); @@ -299,7 +354,8 @@ std::optional CommandTerminationDecoder::try_decode_last_appl_err CommandTermination CommandTerminationDecoder::decode( const mms::MmsInformationReport& report, - const ControlObjectReference& object) { + const ControlObjectReference& object, + const CommandCorrelation* correlation) { CommandTermination result; if (!object.valid()) { return result; @@ -307,10 +363,13 @@ CommandTermination CommandTerminationDecoder::decode( bool matching_object = false; bool matching_operate = false; + bool generic_last_appl_error = false; for (const auto& reference : report.variable_references) { const auto text = mms_reference(reference); matching_object = matching_object || matches_reported_reference(object, text); matching_operate = matching_operate || matches_operate_reference(object, text); + generic_last_appl_error = generic_last_appl_error || + is_generic_last_appl_error_reference(text); } for (const auto& item : report.items) { @@ -326,7 +385,10 @@ CommandTermination CommandTerminationDecoder::decode( const bool embedded_match = !last_error->control_object.empty() && (matches_reported_reference(object, last_error->control_object) || matches_operate_reference(object, last_error->control_object)); - if (!matching_object && !matching_operate && !embedded_match) { + const bool correlated_generic = generic_last_appl_error && + correlation != nullptr && matches_correlation(*last_error, *correlation); + if (!matching_object && !matching_operate && !embedded_match && + !correlated_generic) { continue; } diff --git a/src/control/control_session.cpp b/src/control/control_session.cpp index 484491f50..66c89c68a 100644 --- a/src/control/control_session.cpp +++ b/src/control/control_session.cpp @@ -378,13 +378,13 @@ ControlActionResult ControlObjectSession::select_with_value( value.value.value(), stop_token); if (!write.success) { const auto app_error = wait_for_application_error( - options_.application_error_grace_period, stop_token); + options_.application_error_grace_period, stop_token, context); planner_.on_association_closed(transport_.association_id()); return write_failure(ControlAction::select_with_value, context, write, app_error); } const auto post_error = wait_for_application_error( - options_.application_error_grace_period, stop_token); + options_.application_error_grace_period, stop_token, context); if (post_error.has_value() && !post_error->positive) { planner_.on_association_closed(transport_.association_id()); ControlActionResult result; @@ -521,7 +521,7 @@ ControlActionResult ControlObjectSession::operate( if (!write.success) { const auto app_error = wait_for_application_error( - options_.application_error_grace_period, stop_token); + options_.application_error_grace_period, stop_token, context); clear_selection(); return write_failure(ControlAction::operate, context, write, app_error); } @@ -538,7 +538,7 @@ ControlActionResult ControlObjectSession::operate( } const auto termination = wait_for_termination( - effective_operate_timeout(request), stop_token); + effective_operate_timeout(request), stop_token, context); if (!termination.has_value()) { const bool still_associated = transport_.associated(); clear_selection(); @@ -650,7 +650,8 @@ ControlActionResult ControlObjectSession::write_failure( std::optional ControlObjectSession::wait_for_termination( const std::chrono::milliseconds timeout, - const std::stop_token stop_token) { + const std::stop_token stop_token, + const ActiveContext& context) { if (timeout <= std::chrono::milliseconds::zero()) { return std::nullopt; } @@ -668,7 +669,12 @@ std::optional ControlObjectSession::wait_for_termination( if (!transport_.wait_information_report(remaining, report, stop_token)) { return std::nullopt; } - auto termination = CommandTerminationDecoder::decode(report, descriptor_.object); + const CommandCorrelation correlation{ + static_cast(context.mms.origin_category), + context.mms.origin_identifier, + context.mms.control_number}; + auto termination = CommandTerminationDecoder::decode( + report, descriptor_.object, &correlation); if (termination.is_for_control_object && termination.is_termination) { return termination; } @@ -678,8 +684,9 @@ std::optional ControlObjectSession::wait_for_termination( std::optional ControlObjectSession::wait_for_application_error( const std::chrono::milliseconds timeout, - const std::stop_token stop_token) { - return wait_for_termination(timeout, stop_token); + const std::stop_token stop_token, + const ActiveContext& context) { + return wait_for_termination(timeout, stop_token, context); } bool ControlObjectSession::selection_expired(const ActiveContext& context) const noexcept { diff --git a/tests/test_command_termination.cpp b/tests/test_command_termination.cpp index a5b61a293..15299440d 100644 --- a/tests/test_command_termination.cpp +++ b/tests/test_command_termination.cpp @@ -35,6 +35,13 @@ MmsObjectName reference(std::string item) { return result; } +MmsObjectName vmd_reference(std::string item) { + MmsObjectName result; + result.kind = MmsObjectNameKind::vmd_specific; + result.item = std::move(item); + return result; +} + MmsInformationReportItem value_item(MmsDataValue value, const std::size_t index = 0U) { MmsInformationReportItem item; item.index = index; @@ -82,6 +89,8 @@ void reference_matching_follows_csharp_oracle() { target, "LD0/CSWI1.Pos.stVal")); CHECK(CommandTerminationDecoder::matches_reported_reference( target, "LD0/CSWI1$CO$Pos$Oper")); + CHECK(CommandTerminationDecoder::matches_reported_reference( + target, "LD0/CSWI1$CO$Pos")); CHECK(!CommandTerminationDecoder::matches_reported_reference( target, "LD0/XCBR1.Pos.stVal")); } @@ -146,6 +155,53 @@ void omitted_ctl_obj_layout_is_supported_when_report_correlates() { CHECK(result.add_cause == AddCause::none); } +void generic_omitted_ctl_obj_requires_exact_sequence_correlation() { + MmsInformationReport report; + report.variable_references.push_back(vmd_reference("LastApplError")); + report.items.push_back(value_item(MmsDataValue::structure({ + MmsDataValue::integer(3), + MmsDataValue::structure({ + MmsDataValue::integer(7), + MmsDataValue::octet_string( + std::vector{'A', 'R', 'S', 'T', 'A', 'C', 'K'}), + }), + MmsDataValue::unsigned_integer(41U), + MmsDataValue::integer(8), + }))); + + const std::vector expected_origin{ + 'A', 'R', 'S', 'T', 'A', 'C', 'K'}; + const CommandCorrelation matching{7, expected_origin, 41U}; + auto result = CommandTerminationDecoder::decode(report, object(), &matching); + CHECK(result.is_for_control_object); + CHECK(result.is_termination); + CHECK(!result.positive); + CHECK(result.raw_control_error == 3); + CHECK(result.control_error == ControlError::operator_test); + CHECK(result.raw_add_cause == 8); + CHECK(result.add_cause == AddCause::blocked_by_mode); + CHECK(result.last_appl_error.has_value()); + CHECK(result.last_appl_error->control_object.empty()); + CHECK(result.last_appl_error->origin_category == 7); + CHECK(result.last_appl_error->origin_identifier == expected_origin); + CHECK(result.last_appl_error->control_number == 41U); + + const CommandCorrelation wrong_number{7, expected_origin, 42U}; + result = CommandTerminationDecoder::decode(report, object(), &wrong_number); + CHECK(!result.is_for_control_object); + CHECK(!result.is_termination); + + const std::vector wrong_origin{'O', 'T', 'H', 'E', 'R'}; + const CommandCorrelation wrong_identity{7, wrong_origin, 41U}; + result = CommandTerminationDecoder::decode(report, object(), &wrong_identity); + CHECK(!result.is_for_control_object); + CHECK(!result.is_termination); + + result = CommandTerminationDecoder::decode(report, object()); + CHECK(!result.is_for_control_object); + CHECK(!result.is_termination); +} + void uncorrelated_last_appl_error_cannot_complete_command() { MmsInformationReport report; report.variable_references.push_back(reference("XCBR1$ST$Pos$stVal")); @@ -216,6 +272,7 @@ int main() { positive_termination_requires_exact_oper_reference(); negative_last_appl_error_is_correlated_and_mapped(); omitted_ctl_obj_layout_is_supported_when_report_correlates(); + generic_omitted_ctl_obj_requires_exact_sequence_correlation(); uncorrelated_last_appl_error_cannot_complete_command(); unknown_numeric_codes_are_preserved_without_false_positive(); decoder_output_drives_enhanced_planner_completion(); diff --git a/tools/control_interop_probe.cpp b/tools/control_interop_probe.cpp index d0b7a6983..3754be681 100644 --- a/tools/control_interop_probe.cpp +++ b/tools/control_interop_probe.cpp @@ -185,6 +185,24 @@ struct LabAuthorization final { return "unknown"; } +[[nodiscard]] const char* data_access_error_name(const std::uint32_t code) noexcept { + switch (code) { + case 0U: return "object-invalidated"; + case 1U: return "hardware-fault"; + case 2U: return "temporarily-unavailable"; + case 3U: return "object-access-denied"; + case 4U: return "object-undefined"; + case 5U: return "invalid-address"; + case 6U: return "type-unsupported"; + case 7U: return "type-inconsistent"; + case 8U: return "object-attribute-inconsistent"; + case 9U: return "object-access-unsupported"; + case 10U: return "object-non-existent"; + case 11U: return "object-value-invalid"; + default: return "unknown"; + } +} + [[nodiscard]] std::string json_escape(const std::string& value) { std::ostringstream output; for (const auto ch : value) { @@ -496,6 +514,19 @@ void write_evidence( << " \"positiveTermination\": " << (result->positive_termination ? "true" : "false") << ",\n" << " \"controlNumber\": " << static_cast(result->control_number) << ",\n" + << " \"mmsFailureCode\": "; + if (result->mms_failure_code) { + output << *result->mms_failure_code; + } else { + output << "null"; + } + output << ",\n \"mmsFailure\": "; + if (result->mms_failure_code) { + output << '"' << data_access_error_name(*result->mms_failure_code) << '"'; + } else { + output << "null"; + } + output << ",\n" << " \"rawControlError\": " << result->raw_control_error << ",\n" << " \"rawAddCause\": " << result->raw_add_cause << ",\n" << " \"addCause\": \"" << add_cause_name(result->add_cause) << "\",\n" @@ -705,6 +736,14 @@ int main(int argc, char** argv) { << " termination=" << (result.command_termination_received ? "true" : "false") << " ctlNum=" << static_cast(result.control_number) + << " mmsFailure="; + if (result.mms_failure_code) { + std::cout << *result.mms_failure_code << ':' + << data_access_error_name(*result.mms_failure_code); + } else { + std::cout << "none"; + } + std::cout << " addCause=" << add_cause_name(result.add_cause) << " rawAddCause=" << result.raw_add_cause << " message=\"" << result.message << "\"\n"; From f88f3df4b8b874b51205d20de9c3a32dbecc3191 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 06:07:01 +0700 Subject: [PATCH 54/66] docs(control): record C5 simulator interoperability acceptance --- docs/CONTROL_INTEROP_ACCEPTANCE.md | 239 +++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/CONTROL_INTEROP_ACCEPTANCE.md diff --git a/docs/CONTROL_INTEROP_ACCEPTANCE.md b/docs/CONTROL_INTEROP_ACCEPTANCE.md new file mode 100644 index 000000000..e96b3b585 --- /dev/null +++ b/docs/CONTROL_INTEROP_ACCEPTANCE.md @@ -0,0 +1,239 @@ +# IEC 61850 Control Interoperability Acceptance Record + +## Scope + +This record captures the Phase 4D-C5 live simulator interoperability evidence gathered on 2026-08-11 against an isolated IEC 61850 vendor simulator. + +It documents observed interoperability of the guarded C++ control path. It is not an IEC 61850 conformance certificate and is not evidence of physical primary-plant or production-relay control. + +## Tested profile + +- Endpoint: `192.168.1.10:102` +- Simulator model/profile: `BCU7SL` +- Control object: `BCU7SLCTRL/CSWI1.Pos` +- Discovered CDC: `SPC` +- Live `ctlVal` TypeSpecification: `boolean` +- Status object: `BCU7SLCTRL/CSWI1$ST$Pos$stVal` +- `sboTimeout`: 30000 ms +- `operTimeout`: 1000 ms +- Origin used by the harness: `ARSTACK-C5`, station control +- Interlock-check: enabled +- Synchro-check: enabled +- Harness safety gate: exact `--arm IEC61850-LAB-CONTROL` +- Automatic command retry: disabled + +The live descriptor discovery also confirmed exact named structures for `Oper`, `SBOw` where applicable, and `Cancel`. The control path followed the live TypeSpecification instead of guessing a positional layout. + +## Acceptance matrix + +| ctlModel | Model | Tested path | Result | Observed command Writes | +|---|---|---|---|---| +| 1 | Direct with normal security | OFF and ON | PASS | exactly one `Oper` per deliberate action | +| 2 | SBO with normal security | Select/Operate OFF, Select/Operate ON, Select/Cancel | PASS | exactly one `Oper` per operate action; exactly one `Cancel` for cancel | +| 3 | Direct with enhanced security | OFF and ON | PASS | exactly one `Oper` per deliberate action | +| 4 | SBO with enhanced security | SBOw/Operate OFF and ON | PASS | exactly `SBOw`, then `Oper` per successful action | + +Overall tested-profile state: **SIMULATOR_INTEROP_PASSED**. + +## Direct normal — ctlModel 1 + +Raw discovery reported `numeric=1`, interpreted as `direct-normal`. + +### OFF + +- status before: `0x0680` +- status after: `0x0640` +- completion: `accepted` +- accepted: `true` +- termination: `false` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +### ON + +- status before: `0x0640` +- status after: `0x0680` +- completion: `accepted` +- accepted: `true` +- termination: `false` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +This matches the normal-security completion boundary: successful MMS Oper service acceptance completes the command; CommandTermination is not required. + +Local evidence filenames produced by the harness: + +- `bcu7sl-direct-normal-off.json` +- `bcu7sl-direct-normal-on.json` + +## SBO normal — ctlModel 2 + +Raw discovery reported `numeric=2`, interpreted as `sbo-normal`. + +The model required Select and did not require CommandTermination. + +### Select / Operate OFF + +- completion: `accepted` +- accepted: `true` +- termination: `false` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +### Select / Operate ON + +- completion: `accepted` +- accepted: `true` +- termination: `false` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +For this simulator, status could converge shortly after the normal-security MMS completion boundary. A subsequent deliberate command observed the expected new state. The harness therefore does not redefine normal-security completion as a status-change wait. + +### Select / Cancel + +- completion: `accepted` +- accepted: `true` +- termination: `false` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Cancel` +- no `Oper` Write followed + +The SBO normal Select service is a Read of `SBO`, so the read is intentionally not counted in `mmsControlWriteCount`. + +Local evidence filenames produced by the harness: + +- `bcu7sl-sbo-normal-off.json` +- `bcu7sl-sbo-normal-on.json` +- `bcu7sl-sbo-normal-cancel.json` + +## Direct enhanced — ctlModel 3 + +Raw discovery reported `numeric=3`, interpreted as `direct-enhanced`. + +The descriptor reported `requiresSelect=false`, `enhanced=true`, and `commandTermination=true`. + +### OFF + +- status before: `0x0680` +- status after: `0x0640` +- completion: `positive-termination` +- accepted: `true` +- termination: `true` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +### ON + +- status before: `0x0640` +- status after: `0x0680` +- completion: `positive-termination` +- accepted: `true` +- termination: `true` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +Both directions completed only after a positive correlated CommandTermination. MMS Write acceptance alone was not treated as enhanced-security completion. + +Local evidence filenames produced by the harness: + +- `bcu7sl-direct-enhanced-off.json` +- `bcu7sl-direct-enhanced-on.json` + +## SBO enhanced — ctlModel 4 + +Raw discovery reported `numeric=4`, interpreted as `sbo-enhanced`. + +The descriptor reported `requiresSelect=true`, `enhanced=true`, and `commandTermination=true`. + +Live TypeSpecification discovery reported the named structures: + +```text +Oper=structure(ctlVal:boolean,origin:structure(orCat:integer,orIdent:octet-string),ctlNum:unsigned,T:utc-time,Test:boolean,Check:bit-string) +SBOw=structure(ctlVal:boolean,origin:structure(orCat:integer,orIdent:octet-string),ctlNum:unsigned,T:utc-time,Test:boolean,Check:bit-string) +Cancel=structure(ctlVal:boolean,origin:structure(orCat:integer,orIdent:octet-string),ctlNum:unsigned,T:utc-time,Test:boolean) +``` + +### OFF + +- status before: `0x0680` +- status after: `0x0640` +- completion: `positive-termination` +- accepted: `true` +- termination: `true` +- command Writes: 2 +- Write 1: `BCU7SLCTRL/CSWI1$CO$Pos$SBOw` +- Write 2: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +### ON after OFF + +- status before: `0x0640` +- status after: `0x0680` +- completion: `positive-termination` +- accepted: `true` +- termination: `true` +- command Writes: 2 +- Write 1: `BCU7SLCTRL/CSWI1$CO$Pos$SBOw` +- Write 2: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +### Safe rejection / no-retry evidence + +An earlier SBOw attempt was rejected by the simulator before Oper. The harness recorded: + +- completion: `rejected` +- accepted: `false` +- command Writes: 1 +- only Write: `BCU7SLCTRL/CSWI1$CO$Pos$SBOw` +- no `Oper` Write followed +- no automatic retry followed + +The exact application-side reason was not established by retained LastApplError/PCAP evidence, so this record does not assign a specific rejection cause. + +Local evidence filenames produced by the harness: + +- `bcu7sl-sbo-enhanced-off.json` +- `bcu7sl-sbo-enhanced-on-after-off.json` +- `bcu7sl-sbo-enhanced-on.json` (rejected SBOw case before the successful opposite-direction sequence) + +## Type-safety evidence + +Before the successful SPC tests, an intentionally incorrect `--value-kind dpc` attempt was rejected locally because the live `ctlVal` TypeSpecification was Boolean: + +```text +Control value does not match live ctlVal TypeSpecification at ctlVal. +``` + +No valid control sequence was sent for the mismatched value type. This confirms the live TypeSpecification gate prevents the client from guessing a DPC encoding for an SPC/Boolean command. + +## No-retry evidence + +Across the accepted cases, the harness reported exact command Write counts matching the expected control model: + +- Direct normal: 1 `Oper` +- SBO normal Operate: 1 `Oper` after Select Read +- SBO normal Cancel: 1 `Cancel` after Select Read +- Direct enhanced: 1 `Oper` +- SBO enhanced: 1 `SBOw` + 1 `Oper` + +The rejected SBO enhanced selection stopped after the single rejected `SBOw`. No automatic second selection or Oper was generated. + +## Claim boundary + +This evidence is sufficient to label this specific tested simulator profile: + +`SIMULATOR_INTEROP_PASSED` + +It does **not** establish: + +- IEC 61850 conformance certification; +- interoperability with every vendor implementation; +- physical IED / relay acceptance; +- energized primary-plant control suitability; +- negative LastApplError/AddCause coverage for every rejection mode; +- association-loss coverage for every control phase. + +Those remain separate acceptance gates. + +## Retention note + +The JSON filenames above were generated on the operator workstation. For audit-grade retention, pair each JSON with its matching PCAP/PCAPNG and simulator/event evidence according to `docs/CONTROL_INTEROP_RUNBOOK.md`. The JSON `mmsControlWriteCount` / `mmsControlWrites` values must agree with the packet capture before upgrading any physical-IED claim. From 6191e2033695771cc4076c9d57298297816c811b Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 11 Aug 2026 06:10:08 +0700 Subject: [PATCH 55/66] docs(control): reconcile parallel simulator acceptance --- MIGRATION_CHECKLIST.md | 3 ++- docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md | 2 ++ docs/FEATURE_MATRIX.md | 14 +++++++------- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index 9d3022163..c5b8c7fca 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -172,7 +172,8 @@ behavioral oracle until laboratory interoperability is complete. - [x] C1-C4 integrated into the normal `ARIEC61850::core` build and GCC/Clang/MSVC CTest matrix. - [x] C5 guarded live-interoperability harness implemented with read-only default, explicit arm token, typed values, status-before/after reads, deterministic control-Write counting, JSON evidence, Wireshark filter hint, and offline safety self-test. - [x] C5 dedicated GCC/Clang/MSVC harness CI and build artifact workflow implemented. -- [~] IEDScout simulator C5 acceptance is retained for SBO enhanced SelectWithValue/Cancel, negative LastApplError/AddCause, and Direct enhanced Oper/positive CommandTermination with state restoration. Direct normal, SBO normal, association-loss, multi-client contention, and physical-IED cases remain pending. +- [x] C5 tested-simulator acceptance is recorded for Direct normal, SBO normal, Direct enhanced, and SBO enhanced; the complementary locally retained subset includes hashed JSON/PCAP for SBO enhanced SelectWithValue/Cancel, negative LastApplError/AddCause, Direct enhanced Oper/positive CommandTermination, exact Write counts, and state restoration. +- [~] Association-loss, multi-client contention, physical-IED, and broader multi-vendor control cases remain pending. ### Later Phase 4 work diff --git a/docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md b/docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md index 1d3d71b61..8f26f6f2d 100644 --- a/docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md +++ b/docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md @@ -58,6 +58,8 @@ Generated artifacts remain under the ignored local build evidence directory and ## Remaining acceptance boundary +These bullets describe the scope of this locally retained, hash-identified capture set. A complementary parallel simulator run covering all four control models is recorded in `CONTROL_INTEROP_ACCEPTANCE.md`; its local artifact filenames are documented there, but those artifacts are not part of this checkout. + - GCC/Clang profiles must pass on the integration PR; only MSVC was available locally. - Direct normal and SBO normal were not exposed by this tested control object during the retained cases. - Association-loss and multi-client contention were not forced against the running IEDScout process. diff --git a/docs/FEATURE_MATRIX.md b/docs/FEATURE_MATRIX.md index 616803dcd..e9d61d09d 100644 --- a/docs/FEATURE_MATRIX.md +++ b/docs/FEATURE_MATRIX.md @@ -35,10 +35,10 @@ Status terms are intentionally conservative: | Reporting | BRCB Owner / ResvTms / association lifecycle | Offline-tested | Multi-client ownership, reconnect/expiry, and association-loss semantics are hard-profile tested. | | Reporting | BRCB recovery image v2 | Offline-tested / physical NVM pending | Recovery preserves retained window/cursor/gap with v1 restore; flash endurance and power-loss hardware evidence are separate. | | Control | `ctlModel` discovery | Live-proven / simulator | C5 rediscovered live `ctlModel` and exact command types before each action on IEDScout. | -| Control | Direct normal | Software-ready for lab | Exactly-one `Oper` semantics are deterministic; retained live Direct normal evidence remains pending. | -| Control | SBO normal | Software-ready for lab | Normal Select is an `SBO` Read; immutable sequence, Oper, and explicit Cancel are implemented; live evidence remains pending. | +| Control | Direct normal | Live-proven / simulator | The retained C5 acceptance record covers OFF and ON with exactly one `Oper` Write per deliberate action. | +| Control | SBO normal | Live-proven / simulator | The retained C5 acceptance record covers Select Read followed by exactly one `Oper`, plus explicit Select/Cancel with no following `Oper`. | | Control | Direct enhanced | Live-proven / simulator | IEDScout OFF and ON-restore each sent one `Oper`, received positive CommandTermination, and changed/restored status without retry. | -| Control | SBO enhanced | Live-proven subset / simulator | IEDScout accepted `SBOw -> Cancel`; full `SBOw -> Oper -> CommandTermination` remains pending for an SBO-enhanced profile. | +| Control | SBO enhanced | Live-proven / simulator | Retained evidence covers `SBOw -> Oper -> CommandTermination` in both directions, plus a separate accepted `SBOw -> Cancel` case. | | Control | `Oper` / `SBOw` / `Cancel` live-type binding | Live-proven / simulator | Exact Boolean structures from live GVAA were sent successfully; unknown vendor fields still fail closed. | | Control | Control value types | Offline-tested | SPC/DPC/integer/unsigned/floating/step binding is conservative; DPC uses network bit order. | | Control | Ownership / immutable sequence | Offline-tested | Second-client takeover, expiry, mismatch, authorization revocation, and association-loss cleanup are covered. | @@ -67,8 +67,8 @@ Status terms are intentionally conservative: | Security | libFuzzer corpora | CI-gated | BER, GOOSE, SV, PCAP, SCL, COMTRADE, OSI, MMS services/reporting, and file-service corpora are maintained. | | Portability | GCC / Clang / MSVC | CI-gated | C1-C5 build through normal and dedicated matrices; MSVC was validated locally for this integration. | | Acceptance | Read-only same-IED structural stability | Live-proven | Repeated-cycle and timeout/recovery evidence are retained. | -| Acceptance | Control C5 simulator subset | Live-proven | SBO enhanced Select/Cancel, negative diagnostics, Direct enhanced positive termination, Write counts, and state restoration are retained. | -| Acceptance | Full physical IEC 61850 control interoperability | Pending | Direct/SBO normal, full SBO enhanced Oper, association-loss, contention, and physical-IED evidence remain required. | +| Acceptance | Control C5 tested simulator profile | Live-proven | All four ctlModels are recorded in `CONTROL_INTEROP_ACCEPTANCE.md`; the locally retained subset adds hashed JSON/PCAP evidence for negative diagnostics, SBO enhanced Cancel, Direct enhanced termination, exact Write counts, and state restoration. | +| Acceptance | Full physical IEC 61850 control interoperability | Pending | Association-loss, contention, physical-IED, and broader multi-vendor evidence remain required. | | Acceptance | Multi-vendor physical interoperability | Not yet complete | Required before industrial replacement or broad conformance claims. | ## Current live mutation boundary @@ -81,11 +81,11 @@ Reporting/BRCB mutation semantics are distinct from the C5 control gate. Failure ## Current control milestone -C1-C5 provide a guarded production control path plus a live evidence harness. IEDScout simulator evidence is retained for a meaningful subset, but the overall project remains short of complete physical and multi-model acceptance. +C1-C5 provide a guarded production control path plus a live evidence harness. The tested simulator profile has an acceptance record for all four control models, and a complementary locally retained subset includes hashed JSON/PCAP evidence. The project remains short of physical and broad multi-vendor acceptance. `SOFTWARE_READY_FOR_LAB` applies to all implemented control models. -`LAB_INTEROP_PASSED` applies only to a specific IED/control-model profile after its required positive, negative, failure, and cleanup evidence agrees across JSON and PCAP. The retained IEDScout results do not constitute IEC 61850 conformance certification. +`SIMULATOR_INTEROP_PASSED` applies only to the specific simulator profile recorded in `CONTROL_INTEROP_ACCEPTANCE.md`. Any stronger `LAB_INTEROP_PASSED` or physical-IED claim still requires the runbook's retained positive, negative, failure, cleanup, JSON, and packet-capture evidence. These results do not constitute IEC 61850 conformance certification. ## Next parity direction From 3844a7f56bd3c792c197276951b48739c033a8c7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:01:04 +0700 Subject: [PATCH 56/66] Harden dynamic DataSet ownership scope --- include/ariec61850/mms/dynamic_data_set.hpp | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/include/ariec61850/mms/dynamic_data_set.hpp b/include/ariec61850/mms/dynamic_data_set.hpp index 8b0f1abb3..1bb0947b4 100644 --- a/include/ariec61850/mms/dynamic_data_set.hpp +++ b/include/ariec61850/mms/dynamic_data_set.hpp @@ -145,10 +145,25 @@ class MmsDynamicDataSetRuntime final { [[nodiscard]] MmsDataSetDirectoryResponse verify( const MmsObjectName& data_set_name, std::stop_token stop_token); + void synchronize_ownership_scope() const; + void capture_ownership_scope() const; + void clear_ownership_scope() const noexcept; + void require_request_within_negotiated_limit( + std::span mms_pdu, + const char* operation) const; MmsAssociationRuntime& association_; MmsDynamicDataSetOptions options_{}; - std::set> owned_data_sets_; + mutable std::set> owned_data_sets_; + mutable bool ownership_scope_valid_{}; + mutable MmsEndpoint ownership_endpoint_{}; + mutable std::size_t ownership_event_count_{}; + mutable MmsAssociationEventKind ownership_marker_kind_{ + MmsAssociationEventKind::state_changed}; + mutable MmsAssociationRuntimeState ownership_marker_state_{ + MmsAssociationRuntimeState::disconnected}; + mutable std::optional ownership_marker_invoke_id_; + mutable std::string ownership_marker_message_; }; } // namespace ar::iec61850::mms From b36f2f722995b8a2810368d2ccc0cd1413f903b2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:02:16 +0700 Subject: [PATCH 57/66] Enforce dynamic DataSet association and PDU safety --- src/mms/dynamic_data_set.cpp | 120 +++++++++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 6 deletions(-) diff --git a/src/mms/dynamic_data_set.cpp b/src/mms/dynamic_data_set.cpp index f3a20d9fd..f0de99808 100644 --- a/src/mms/dynamic_data_set.cpp +++ b/src/mms/dynamic_data_set.cpp @@ -382,6 +382,92 @@ std::string MmsDynamicDataSetRuntime::normalize_reference( return MmsDataSetDirectoryCodec::to_iec_reference(name); } +void MmsDynamicDataSetRuntime::clear_ownership_scope() const noexcept { + owned_data_sets_.clear(); + ownership_scope_valid_ = false; + ownership_endpoint_ = {}; + ownership_event_count_ = 0U; + ownership_marker_kind_ = MmsAssociationEventKind::state_changed; + ownership_marker_state_ = MmsAssociationRuntimeState::disconnected; + ownership_marker_invoke_id_.reset(); + ownership_marker_message_.clear(); +} + +void MmsDynamicDataSetRuntime::capture_ownership_scope() const { + const auto& events = association_.events(); + if (!association_.associated() || events.empty()) { + clear_ownership_scope(); + return; + } + ownership_scope_valid_ = true; + ownership_endpoint_ = association_.endpoint(); + ownership_event_count_ = events.size(); + const auto& marker = events.back(); + ownership_marker_kind_ = marker.kind; + ownership_marker_state_ = marker.state; + ownership_marker_invoke_id_ = marker.invoke_id; + ownership_marker_message_ = marker.message; +} + +void MmsDynamicDataSetRuntime::synchronize_ownership_scope() const { + if (owned_data_sets_.empty()) { + if (!association_.associated()) { + clear_ownership_scope(); + } + return; + } + if (!ownership_scope_valid_ || !association_.associated() || + association_.endpoint() != ownership_endpoint_) { + clear_ownership_scope(); + return; + } + + const auto& events = association_.events(); + if (ownership_event_count_ == 0U || events.size() < ownership_event_count_) { + clear_ownership_scope(); + return; + } + const auto& marker = events[ownership_event_count_ - 1U]; + if (marker.kind != ownership_marker_kind_ || + marker.state != ownership_marker_state_ || + marker.invoke_id != ownership_marker_invoke_id_ || + marker.message != ownership_marker_message_) { + // The bounded event deque shifted, so the original association scope can + // no longer be proven. Forget ownership rather than risk deleting a + // foreign DataSet. + clear_ownership_scope(); + return; + } + + for (std::size_t index = ownership_event_count_; index < events.size(); ++index) { + switch (events[index].kind) { + case MmsAssociationEventKind::association_accepted: + case MmsAssociationEventKind::disconnected: + case MmsAssociationEventKind::cancelled: + case MmsAssociationEventKind::timed_out: + case MmsAssociationEventKind::faulted: + clear_ownership_scope(); + return; + default: + break; + } + } +} + +void MmsDynamicDataSetRuntime::require_request_within_negotiated_limit( + const std::span mms_pdu, + const char* operation) const { + const auto negotiated_limit = + static_cast(association_.negotiated().maximum_mms_pdu_size); + if (negotiated_limit == 0U || mms_pdu.size() > negotiated_limit) { + throw MmsDynamicDataSetError( + std::string{"MMS "} + operation + " request is " + + std::to_string(mms_pdu.size()) + + " bytes and exceeds the negotiated maximum MMS PDU size of " + + std::to_string(negotiated_limit) + " bytes."); + } +} + MmsDataSetDirectoryResponse MmsDynamicDataSetRuntime::verify( const MmsObjectName& data_set_name, const std::stop_token stop_token) { @@ -401,9 +487,11 @@ MmsDynamicDataSetCreateResult MmsDynamicDataSetRuntime::create( const std::span members, const std::stop_token stop_token) { if (!association_.associated()) { + clear_ownership_scope(); throw MmsDynamicDataSetError( "Cannot create a dynamic DataSet without an active MMS association."); } + synchronize_ownership_scope(); if (members.empty() || members.size() > options_.maximum_members) { throw MmsDynamicDataSetError( "Dynamic DataSet member count exceeds the configured operational limit."); @@ -413,7 +501,7 @@ MmsDynamicDataSetCreateResult MmsDynamicDataSetRuntime::create( const auto normalized = normalize_reference(data_set_reference); if (owned_data_sets_.contains(normalized)) { throw MmsDynamicDataSetError( - "This runtime already owns the requested dynamic DataSet reference."); + "This runtime already owns the requested dynamic DataSet reference in the current association scope."); } const auto data_set_name = MmsDataSetDirectoryCodec::parse_data_set_reference(normalized); @@ -422,8 +510,10 @@ MmsDynamicDataSetCreateResult MmsDynamicDataSetRuntime::create( request.invoke_id = invoke_id; request.data_set_name = data_set_name; request.members.assign(members.begin(), members.end()); - const auto encoded = MmsNamedVariableListCodec::encode_define_request_p_data( - request, association_.negotiated().presentation_context_id); + const auto pdu = MmsNamedVariableListCodec::encode_define_request_pdu(request); + require_request_within_negotiated_limit(pdu, "DefineNamedVariableList"); + const auto encoded = MmsPduCodec::wrap_p_data( + pdu, association_.negotiated().presentation_context_id); const auto exchange = association_.exchange_confirmed( encoded, invoke_id, stop_token); require_confirmed_response(exchange, "DefineNamedVariableList"); @@ -459,7 +549,16 @@ MmsDynamicDataSetCreateResult MmsDynamicDataSetRuntime::create( } } + const bool first_owned_data_set = owned_data_sets_.empty(); owned_data_sets_.insert(normalized); + if (first_owned_data_set) { + capture_ownership_scope(); + if (!ownership_scope_valid_) { + owned_data_sets_.erase(normalized); + throw MmsDynamicDataSetError( + "Dynamic DataSet was created, but the current association ownership scope could not be retained safely."); + } + } return result; } @@ -470,8 +569,10 @@ MmsDeleteNamedVariableListResponse MmsDynamicDataSetRuntime::remove_impl( MmsDataSetDirectoryCodec::parse_data_set_reference(normalized_reference); const auto invoke_id = association_.next_invoke_id(); const MmsDeleteNamedVariableListRequest request{invoke_id, data_set_name}; - const auto encoded = MmsNamedVariableListCodec::encode_delete_request_p_data( - request, association_.negotiated().presentation_context_id); + const auto pdu = MmsNamedVariableListCodec::encode_delete_request_pdu(request); + require_request_within_negotiated_limit(pdu, "DeleteNamedVariableList"); + const auto encoded = MmsPduCodec::wrap_p_data( + pdu, association_.negotiated().presentation_context_id); const auto exchange = association_.exchange_confirmed( encoded, invoke_id, stop_token); require_confirmed_response(exchange, "DeleteNamedVariableList"); @@ -484,19 +585,24 @@ MmsDeleteNamedVariableListResponse MmsDynamicDataSetRuntime::remove( const MmsDynamicDataSetDeletePolicy policy, const std::stop_token stop_token) { if (!association_.associated()) { + clear_ownership_scope(); throw MmsDynamicDataSetError( "Cannot delete a dynamic DataSet without an active MMS association."); } + synchronize_ownership_scope(); const auto normalized = normalize_reference(data_set_reference); const auto owned = owned_data_sets_.contains(normalized); if (!owned && policy != MmsDynamicDataSetDeletePolicy::explicit_override) { throw MmsDynamicDataSetError( - "Refusing to delete a DataSet not created by this runtime; use explicit_override intentionally."); + "Refusing to delete a DataSet not created in the current association scope; use explicit_override intentionally."); } const auto response = remove_impl(normalized, stop_token); if (response.deleted() || response.number_matched.value_or(1U) == 0U) { owned_data_sets_.erase(normalized); + if (owned_data_sets_.empty()) { + clear_ownership_scope(); + } } return response; } @@ -504,6 +610,7 @@ MmsDeleteNamedVariableListResponse MmsDynamicDataSetRuntime::remove( bool MmsDynamicDataSetRuntime::owns( const std::string& data_set_reference) const { try { + synchronize_ownership_scope(); return owned_data_sets_.contains(normalize_reference(data_set_reference)); } catch (...) { return false; @@ -511,6 +618,7 @@ bool MmsDynamicDataSetRuntime::owns( } std::vector MmsDynamicDataSetRuntime::owned_data_sets() const { + synchronize_ownership_scope(); return {owned_data_sets_.begin(), owned_data_sets_.end()}; } From 05f78a45aa20d88e2351fa479f3e2303dc3cae9e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:03:51 +0700 Subject: [PATCH 58/66] Gate dynamic RCB cleanup on owned mutations --- tools/dynamic_rcb_trial.cpp | 65 +++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/tools/dynamic_rcb_trial.cpp b/tools/dynamic_rcb_trial.cpp index a78f1cc59..b4435ef79 100644 --- a/tools/dynamic_rcb_trial.cpp +++ b/tools/dynamic_rcb_trial.cpp @@ -246,26 +246,56 @@ void print_candidates(const mms::MmsRcbSelectionEvidence& selection) { } } +[[nodiscard]] bool subscription_wrote_attribute( + const mms::MmsReportSubscriptionSnapshot& snapshot, + const std::string_view attribute) { + const std::string expected = "RCB attribute written: " + std::string{attribute} + "."; + return std::any_of( + snapshot.events.begin(), snapshot.events.end(), + [&expected](const auto& event) { + return event.kind == mms::MmsReportSubscriptionEventKind::attribute_written && + event.message == expected; + }); +} + void best_effort_cleanup( mms::MmsAssociationRuntime& association, const mms::MmsReportControlCandidate* candidate, + const mms::MmsReportSubscriptionSnapshot* subscription_snapshot, mms::MmsDynamicDataSetRuntime* dynamic_data_set, const std::string& data_set_reference, const bool data_set_created) noexcept { if (!association.associated()) return; - if (candidate != nullptr) { - try { require_write_success(association, *candidate, "RptEna", mms::MmsDataValue::boolean(false)); } - catch (const std::exception& exception) { std::cerr << "CLEANUP warning: RptEna=false failed: " << exception.what() << '\n'; } - if (!candidate->buffered && contains_attribute(*candidate, "Resv")) { - try { require_write_success(association, *candidate, "Resv", mms::MmsDataValue::boolean(false)); } - catch (const std::exception& exception) { std::cerr << "CLEANUP warning: Resv=false failed: " << exception.what() << '\n'; } - } - if (contains_attribute(*candidate, "DatSet")) { - try { require_write_success(association, *candidate, "DatSet", mms::MmsDataValue::visible_string("")); } - catch (const std::exception& exception) { std::cerr << "CLEANUP warning: DatSet unbind failed: " << exception.what() << '\n'; } + + bool safe_to_delete_data_set = true; + const bool data_set_binding_touched = + candidate != nullptr && subscription_snapshot != nullptr && + subscription_wrote_attribute(*subscription_snapshot, "DatSet"); + + // RptEna and Resv cleanup belongs exclusively to + // MmsReportSubscriptionRuntime, which already tracks whether this run + // successfully touched those attributes. Never emit unconditional cleanup + // writes here: a re-probe may have rejected the RCB because another client + // claimed it after discovery. + if (subscription_snapshot != nullptr && subscription_snapshot->cleanup_required) { + safe_to_delete_data_set = false; + std::cerr << "CLEANUP warning: subscription still has owned RCB cleanup pending; " + "retaining any bound Dynamic DataSet rather than unbinding/deleting unsafely.\n"; + } else if (data_set_binding_touched && candidate != nullptr && + contains_attribute(*candidate, "DatSet")) { + try { + require_write_success( + association, *candidate, "DatSet", + mms::MmsDataValue::visible_string("")); + std::cerr << "CLEANUP RCB DatSet unbound because this run wrote the binding.\n"; + } catch (const std::exception& exception) { + safe_to_delete_data_set = false; + std::cerr << "CLEANUP warning: owned DatSet unbind failed: " + << exception.what() << '\n'; } } - if (data_set_created && dynamic_data_set != nullptr) { + + if (data_set_created && dynamic_data_set != nullptr && safe_to_delete_data_set) { try { const auto response = dynamic_data_set->remove(data_set_reference); std::cerr << "CLEANUP Dynamic DataSet delete matched=" << response.number_matched.value_or(0U) @@ -273,6 +303,8 @@ void best_effort_cleanup( } catch (const std::exception& exception) { std::cerr << "CLEANUP warning: Dynamic DataSet delete failed: " << exception.what() << '\n'; } + } else if (data_set_created && !safe_to_delete_data_set) { + std::cerr << "CLEANUP retained Dynamic DataSet because safe RCB cleanup could not be proven.\n"; } } @@ -465,10 +497,15 @@ int main(int argc, char** argv) { std::cout << "SMART_DYNAMIC_RCB_TRIAL_PASS\n"; return 0; } catch (...) { - if (subscription) subscription->stop(); + std::optional cleanup_snapshot; + if (subscription) { + subscription->stop(); + cleanup_snapshot = subscription->snapshot(); + } best_effort_cleanup( - session.association(), candidate, &dynamic_data_set, - data_set_reference, data_set_created); + session.association(), candidate, + cleanup_snapshot ? &*cleanup_snapshot : nullptr, + &dynamic_data_set, data_set_reference, data_set_created); session.disconnect(); throw; } From 89a62127027ab4a925414223c0e594a78b603501 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:05:10 +0700 Subject: [PATCH 59/66] Add dynamic DataSet safety regressions --- tests/test_dynamic_data_set.cpp | 154 ++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/tests/test_dynamic_data_set.cpp b/tests/test_dynamic_data_set.cpp index c7708690b..b243524f6 100644 --- a/tests/test_dynamic_data_set.cpp +++ b/tests/test_dynamic_data_set.cpp @@ -1,15 +1,20 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/acse/association.hpp" #include "ariec61850/mms/dynamic_data_set.hpp" #include "ariec61850/mms/pdu.hpp" +#include "ariec61850/osi/cotp.hpp" +#include "ariec61850/osi/tpkt.hpp" #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -24,11 +29,14 @@ using ar::iec61850::mms::MmsDefineNamedVariableListResponse; using ar::iec61850::mms::MmsDeleteNamedVariableListRequest; using ar::iec61850::mms::MmsDeleteNamedVariableListResponse; using ar::iec61850::mms::MmsDynamicDataSetError; +using ar::iec61850::mms::MmsDynamicDataSetOptions; using ar::iec61850::mms::MmsDynamicDataSetRuntime; using ar::iec61850::mms::MmsEndpoint; using ar::iec61850::mms::MmsNamedVariableListCodec; using ar::iec61850::mms::MmsObjectName; using ar::iec61850::mms::MmsPduCodec; +namespace acse = ar::iec61850::acse; +namespace osi = ar::iec61850::osi; #define CHECK(condition) \ do { \ @@ -94,6 +102,88 @@ class NullTransport final : public MmsByteTransport { [[nodiscard]] bool connected() const noexcept override { return false; } }; +class ScriptedTransport final : public MmsByteTransport { +public: + void connect( + const MmsEndpoint& endpoint, + Deadline, + std::stop_token stop_token) override { + if (stop_token.stop_requested()) { + throw std::runtime_error("scripted connect cancelled"); + } + endpoint_ = endpoint; + connected_ = true; + } + + void send( + const std::span bytes, + Deadline, + std::stop_token stop_token) override { + if (stop_token.stop_requested()) { + throw std::runtime_error("scripted send cancelled"); + } + if (!connected_) { + throw std::runtime_error("scripted transport is disconnected"); + } + sent_.emplace_back(bytes.begin(), bytes.end()); + } + + [[nodiscard]] std::vector receive( + Deadline, + std::stop_token stop_token) override { + if (stop_token.stop_requested()) { + throw std::runtime_error("scripted receive cancelled"); + } + if (!connected_) { + throw std::runtime_error("scripted transport is disconnected"); + } + if (receive_queue_.empty()) { + throw ar::iec61850::mms::MmsTransportTimeoutError( + "scripted receive timeout"); + } + auto value = std::move(receive_queue_.front()); + receive_queue_.pop_front(); + return value; + } + + void close() noexcept override { connected_ = false; } + [[nodiscard]] bool connected() const noexcept override { return connected_; } + + void push_receive(std::vector bytes) { + receive_queue_.push_back(std::move(bytes)); + } + + [[nodiscard]] const std::vector>& sent() const noexcept { + return sent_; + } + +private: + MmsEndpoint endpoint_; + bool connected_{}; + std::deque> receive_queue_; + std::vector> sent_; +}; + +[[nodiscard]] std::vector wrap_application( + const std::span application) { + const auto cotp = osi::CotpFrameCodec::encode_data(application); + return osi::TpktFrameCodec::encode(cotp); +} + +void queue_handshake(ScriptedTransport& transport) { + const auto request_bytes = + acse::AcseAssociationCodec::build_default_association_request(); + const auto confirm = osi::CotpFrameCodec::encode_connection_confirm( + 0x0001U, 0x2345U, 0x0AU); + transport.push_receive(osi::TpktFrameCodec::encode(confirm)); + + const auto request = acse::AcseAssociationCodec::decode_association_request( + request_bytes); + const auto response = acse::AcseAssociationCodec::build_accept_response( + request, acse::AcseAssociationCodec::default_accept_aare()); + transport.push_receive(wrap_application(response.payload)); +} + MmsDefineNamedVariableListRequest oracle_define_request() { MmsDefineNamedVariableListRequest request; request.invoke_id = 8U; @@ -198,6 +288,68 @@ void runtime_requires_live_association_before_mutation() { }); } +void ownership_is_invalidated_across_same_endpoint_reconnect() { + ScriptedTransport transport; + MmsAssociationRuntime association{transport}; + const MmsEndpoint endpoint{"127.0.0.1", 102U}; + queue_handshake(transport); + association.connect(endpoint); + + MmsDynamicDataSetOptions options; + options.verify_after_create = false; + MmsDynamicDataSetRuntime runtime{association, options}; + const auto request = oracle_define_request(); + + transport.push_receive(wrap_application( + MmsNamedVariableListCodec::encode_define_response_p_data({1U}))); + const auto created = runtime.create( + "LD0/LLN0.AR_DYN_DS01", request.members); + CHECK(created.data_set_reference == "LD0/LLN0.AR_DYN_DS01"); + CHECK(runtime.owns(created.data_set_reference)); + + association.disconnect(); + queue_handshake(transport); + association.connect(endpoint); + + CHECK(!runtime.owns(created.data_set_reference)); + const auto sent_before = transport.sent().size(); + check_throws([&] { + static_cast(runtime.remove(created.data_set_reference)); + }); + CHECK(transport.sent().size() == sent_before); + association.disconnect(); +} + +void oversized_define_is_rejected_before_network_send() { + ScriptedTransport transport; + MmsAssociationRuntime association{transport}; + const MmsEndpoint endpoint{"127.0.0.1", 102U}; + queue_handshake(transport); + association.connect(endpoint); + + MmsDynamicDataSetOptions options; + options.maximum_members = 512U; + options.verify_after_create = false; + MmsDynamicDataSetRuntime runtime{association, options}; + + std::vector members; + members.reserve(300U); + for (std::size_t index = 0U; index < 300U; ++index) { + std::string item = "GGIO1$ST$"; + item.append(220U, 'A'); + item += std::to_string(index); + members.push_back(MmsObjectName::domain_specific("LD0", std::move(item))); + } + + const auto sent_before = transport.sent().size(); + check_throws([&] { + static_cast(runtime.create( + "LD0/LLN0.AR_OVERSIZED", members)); + }); + CHECK(transport.sent().size() == sent_before); + association.disconnect(); +} + } // namespace int main() { @@ -208,6 +360,8 @@ int main() { delete_response_reads_and_emits_csharp_oracle_counts(); invalid_or_unsafe_requests_fail_closed(); runtime_requires_live_association_before_mutation(); + ownership_is_invalidated_across_same_endpoint_reconnect(); + oversized_define_is_rejected_before_network_send(); std::cout << "Dynamic DataSet tests passed.\n"; return 0; } catch (const std::exception& exception) { From cd56ac571099eb82847e373dcdce5b5f3fdfb9df Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:05:31 +0700 Subject: [PATCH 60/66] Add dynamic RCB cleanup ownership regression --- .../test_dynamic_cleanup_ownership.cpp | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tools/dynamic_rcb_trial/test_dynamic_cleanup_ownership.cpp diff --git a/tools/dynamic_rcb_trial/test_dynamic_cleanup_ownership.cpp b/tools/dynamic_rcb_trial/test_dynamic_cleanup_ownership.cpp new file mode 100644 index 000000000..fb62ce787 --- /dev/null +++ b/tools/dynamic_rcb_trial/test_dynamic_cleanup_ownership.cpp @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#define main ariec61850_dynamic_rcb_trial_entry_for_test +#include "../dynamic_rcb_trial.cpp" +#undef main + +#include +#include +#include + +namespace { + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + throw std::runtime_error( \ + std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ + } while (false) + +void cleanup_requires_positive_mutation_evidence() { + ar::iec61850::mms::MmsReportSubscriptionSnapshot snapshot; + CHECK(!subscription_wrote_attribute(snapshot, "DatSet")); + CHECK(!subscription_wrote_attribute(snapshot, "RptEna")); + + snapshot.events.push_back({ + ar::iec61850::mms::MmsReportSubscriptionEventKind::probe_completed, + ar::iec61850::mms::MmsReportSubscriptionState::probing, + "RCB live-state probe completed."}); + CHECK(!subscription_wrote_attribute(snapshot, "DatSet")); + + snapshot.events.push_back({ + ar::iec61850::mms::MmsReportSubscriptionEventKind::attribute_written, + ar::iec61850::mms::MmsReportSubscriptionState::configuring, + "RCB attribute written: DatSet."}); + CHECK(subscription_wrote_attribute(snapshot, "DatSet")); + CHECK(!subscription_wrote_attribute(snapshot, "RptEna")); + + snapshot.events.push_back({ + ar::iec61850::mms::MmsReportSubscriptionEventKind::attribute_written, + ar::iec61850::mms::MmsReportSubscriptionState::enabling, + "RCB attribute written: RptEna."}); + CHECK(subscription_wrote_attribute(snapshot, "DatSet")); + CHECK(subscription_wrote_attribute(snapshot, "RptEna")); +} + +} // namespace + +int main() { + try { + cleanup_requires_positive_mutation_evidence(); + std::cout << "Dynamic RCB cleanup ownership tests passed.\n"; + return 0; + } catch (const std::exception& exception) { + std::cerr << "Dynamic RCB cleanup ownership test failure: " + << exception.what() << '\n'; + return 1; + } +} From 04c6ad3a9a541f45af3c479598cf8cca97d9403f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:05:48 +0700 Subject: [PATCH 61/66] Run dynamic reporting safety regression --- tools/dynamic_rcb_trial/CMakeLists.txt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tools/dynamic_rcb_trial/CMakeLists.txt b/tools/dynamic_rcb_trial/CMakeLists.txt index 2ad9e0637..9758ceff4 100644 --- a/tools/dynamic_rcb_trial/CMakeLists.txt +++ b/tools/dynamic_rcb_trial/CMakeLists.txt @@ -56,3 +56,20 @@ else() endif() add_test(NAME dynamic_report_planner_tests COMMAND ariec61850_dynamic_report_planner_tests) + +add_executable(ariec61850_dynamic_cleanup_ownership_tests + ${CMAKE_CURRENT_LIST_DIR}/test_dynamic_cleanup_ownership.cpp) +target_link_libraries(ariec61850_dynamic_cleanup_ownership_tests PRIVATE + ARIEC61850::core) +target_compile_features(ariec61850_dynamic_cleanup_ownership_tests PRIVATE cxx_std_20) +if(MSVC) + target_compile_options(ariec61850_dynamic_cleanup_ownership_tests PRIVATE /W4 /permissive- /WX) +else() + target_compile_options(ariec61850_dynamic_cleanup_ownership_tests PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) +endif() +if(WIN32) + target_link_libraries(ariec61850_dynamic_cleanup_ownership_tests PRIVATE ws2_32) +endif() +add_test(NAME dynamic_cleanup_ownership_tests + COMMAND ariec61850_dynamic_cleanup_ownership_tests) From a2b680ae2359bb2be58227969e915f311656c287 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:29:12 +0700 Subject: [PATCH 62/66] Prepare current-main embedded profile for static MMS server --- embedded/CMakeLists.txt | 42 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/embedded/CMakeLists.txt b/embedded/CMakeLists.txt index 7eaba0dc5..1e93fc7d9 100644 --- a/embedded/CMakeLists.txt +++ b/embedded/CMakeLists.txt @@ -34,6 +34,13 @@ set(ARIEC61850_EMBEDDED_SOURCES ${ARIEC61850_ROOT}/src/osi/presentation.cpp ${ARIEC61850_ROOT}/src/acse/association.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/osi/tpkt_span.cpp + ${ARIEC61850_ROOT}/src/osi/cotp_span.cpp + ${ARIEC61850_ROOT}/src/osi/session_span.cpp + ${ARIEC61850_ROOT}/src/osi/presentation_span.cpp + ${ARIEC61850_ROOT}/src/acse/association_span.cpp + ${ARIEC61850_ROOT}/src/mms/utc_time.cpp ${ARIEC61850_ROOT}/src/mms/data_value.cpp ${ARIEC61850_ROOT}/src/mms/data_codec.cpp @@ -41,7 +48,18 @@ set(ARIEC61850_EMBEDDED_SOURCES ${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) + ${ARIEC61850_ROOT}/src/mms/file_service.cpp + + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/write_span.cpp + ${ARIEC61850_ROOT}/src/mms/data_set_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_write.cpp + ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_dispatcher.cpp + ${ARIEC61850_ROOT}/src/mms/connection_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/static_server_session.cpp) add_library(ariec61850_embedded_core STATIC ${ARIEC61850_EMBEDDED_SOURCES}) add_library(ARIEC61850::embedded_core ALIAS ariec61850_embedded_core) @@ -98,6 +116,28 @@ ariec61850_apply_embedded_flags(ariec61850_embedded_link_smoke) enable_testing() add_test(NAME ariec61850_embedded_link_smoke COMMAND ariec61850_embedded_link_smoke) +foreach(server_smoke IN ITEMS + osi + session_presentation + association + mms_pdu + mms_get_name_list + mms_static_model + mms_write + mms_dispatcher + mms_connection_runtime + mms_data_set) + set(server_smoke_target "ariec61850_embedded_${server_smoke}_smoke") + add_executable( + ${server_smoke_target} + ${CMAKE_CURRENT_LIST_DIR}/${server_smoke}_hard_profile_smoke.cpp) + target_link_libraries( + ${server_smoke_target} PRIVATE ARIEC61850::embedded_core) + target_compile_features(${server_smoke_target} PRIVATE cxx_std_20) + ariec61850_apply_embedded_flags(${server_smoke_target}) + add_test(NAME ${server_smoke_target} COMMAND ${server_smoke_target}) +endforeach() + message(STATUS "arstack61850 embedded profile: host services excluded") message(STATUS "arstack61850 embedded profile: RTTI disabled=${ARIEC61850_EMBEDDED_NO_RTTI}") message(STATUS "arstack61850 embedded profile: exceptions disabled=${ARIEC61850_EMBEDDED_NO_EXCEPTIONS}") From 747b6751e6071214c0b429e979227f9270a0e9e8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:30:11 +0700 Subject: [PATCH 63/66] Prepare current-main build for static MMS server --- CMakeLists.txt | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 44a014ab5..4483d6411 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,12 +65,38 @@ add_library(ariec61850_core add_library(ARIEC61850::core ALIAS ariec61850_core) +add_library(ariec61850_mms_server_core + src/asn1/ber_span_reader.cpp + src/osi/tpkt_span.cpp + src/osi/cotp_span.cpp + src/osi/session_span.cpp + src/osi/presentation_span.cpp + src/acse/association_span.cpp + src/mms/pdu_span.cpp + src/mms/services_span.cpp + src/mms/write_span.cpp + src/mms/data_set_span.cpp + src/mms/static_object_table.cpp + src/mms/static_write.cpp + src/mms/static_data_set_table.cpp + src/mms/static_dispatcher.cpp + src/mms/connection_runtime.cpp + src/mms/static_server_session.cpp) + +add_library(ARIEC61850::mms_server_core ALIAS ariec61850_mms_server_core) + target_compile_features(ariec61850_core PUBLIC cxx_std_20) target_include_directories(ariec61850_core PUBLIC $ $) +target_compile_features(ariec61850_mms_server_core PUBLIC cxx_std_20) +target_include_directories(ariec61850_mms_server_core + PUBLIC + $ + $) + if(WIN32) target_link_libraries(ariec61850_core PRIVATE ws2_32) endif() @@ -105,6 +131,8 @@ endfunction() ariec61850_apply_warnings(ariec61850_core) ariec61850_apply_sanitizers(ariec61850_core) +ariec61850_apply_warnings(ariec61850_mms_server_core) +ariec61850_apply_sanitizers(ariec61850_mms_server_core) if(ARIEC61850_BUILD_TOOLS) add_executable(ariec61850_pcap_interop_check tools/pcap_interop_check.cpp) @@ -148,6 +176,17 @@ if(ARIEC61850_BUILD_TOOLS) target_compile_features(ariec61850_file_transfer PRIVATE cxx_std_20) ariec61850_apply_warnings(ariec61850_file_transfer) ariec61850_apply_sanitizers(ariec61850_file_transfer) + + add_executable(ariec61850_static_ied_server tools/static_ied_server.cpp) + target_link_libraries( + ariec61850_static_ied_server + PRIVATE ARIEC61850::mms_server_core ARIEC61850::core) + if(WIN32) + target_link_libraries(ariec61850_static_ied_server PRIVATE ws2_32) + endif() + target_compile_features(ariec61850_static_ied_server PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_static_ied_server) + ariec61850_apply_sanitizers(ariec61850_static_ied_server) endif() if(ARIEC61850_BUILD_TESTS) @@ -322,6 +361,34 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_sanitizers(ariec61850_fuzz_smoke_tests) add_test(NAME ariec61850_fuzz_smoke_tests COMMAND ariec61850_fuzz_smoke_tests) + add_executable( + ariec61850_mms_server_session_tests + tests/test_mms_static_server_session.cpp) + target_link_libraries( + ariec61850_mms_server_session_tests + PRIVATE ARIEC61850::mms_server_core) + target_compile_features( + ariec61850_mms_server_session_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_mms_server_session_tests) + ariec61850_apply_sanitizers(ariec61850_mms_server_session_tests) + add_test( + NAME ariec61850_mms_server_session_tests + COMMAND ariec61850_mms_server_session_tests) + + add_executable( + ariec61850_mms_connection_runtime_tests + embedded/mms_connection_runtime_hard_profile_smoke.cpp) + target_link_libraries( + ariec61850_mms_connection_runtime_tests + PRIVATE ARIEC61850::mms_server_core) + target_compile_features( + ariec61850_mms_connection_runtime_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_mms_connection_runtime_tests) + ariec61850_apply_sanitizers(ariec61850_mms_connection_runtime_tests) + add_test( + NAME ariec61850_mms_connection_runtime_tests + COMMAND ariec61850_mms_connection_runtime_tests) + if(ARIEC61850_BUILD_TOOLS) add_test( NAME ariec61850_pcap_interop_smoke @@ -339,6 +406,9 @@ if(ARIEC61850_BUILD_TESTS) add_test( NAME ariec61850_file_transfer_help COMMAND ariec61850_file_transfer --help) + add_test( + NAME ariec61850_static_ied_server_help + COMMAND ariec61850_static_ied_server --help) endif() endif() From a5a508313e08be7a1c40333fa4a5b2e21be7fff1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:32:23 +0700 Subject: [PATCH 64/66] Adopt no-throw UTC decode primitive for MMS server --- include/ariec61850/mms/utc_time.hpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/include/ariec61850/mms/utc_time.hpp b/include/ariec61850/mms/utc_time.hpp index 6b62af416..78d00ca1e 100644 --- a/include/ariec61850/mms/utc_time.hpp +++ b/include/ariec61850/mms/utc_time.hpp @@ -13,14 +13,24 @@ struct Iec61850UtcTime final { std::chrono::system_clock::time_point value{}; std::uint8_t quality{}; + // No-throw decode primitive for constrained/embedded profiles. + [[nodiscard]] static bool try_from_bytes( + std::span bytes, + Iec61850UtcTime& result) noexcept; + +#if !defined(ARIEC61850_NO_EXCEPTIONS) + // Host convenience wrapper retained when C++ exceptions are available. [[nodiscard]] static Iec61850UtcTime from_bytes(std::span bytes); +#endif // Allocation-free wire path for embedded publishers. Returns false when // the timestamp cannot be represented by IEC 61850's 32-bit seconds field. [[nodiscard]] bool try_write_bytes(std::span destination) const noexcept; +#if !defined(ARIEC61850_NO_EXCEPTIONS) // Host convenience wrapper retained for source compatibility. [[nodiscard]] std::vector to_bytes() const; +#endif friend bool operator==(const Iec61850UtcTime&, const Iec61850UtcTime&) = default; }; From c0ff55db32683d3fd1a8400d50c58fa2dbe9abb9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:32:37 +0700 Subject: [PATCH 65/66] Adopt no-throw UTC implementation for MMS server --- src/mms/utc_time.cpp | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/mms/utc_time.cpp b/src/mms/utc_time.cpp index 0abb34c5a..67439e4f7 100644 --- a/src/mms/utc_time.cpp +++ b/src/mms/utc_time.cpp @@ -7,13 +7,18 @@ #include #include #include +#if !defined(ARIEC61850_NO_EXCEPTIONS) #include +#endif namespace ar::iec61850::mms { -Iec61850UtcTime Iec61850UtcTime::from_bytes(const std::span bytes) { +bool Iec61850UtcTime::try_from_bytes( + const std::span bytes, + Iec61850UtcTime& result) noexcept { if (bytes.size() != 8U) { - throw std::invalid_argument("IEC 61850 UTC time requires exactly 8 bytes."); + result = {}; + return false; } const auto seconds = @@ -30,8 +35,19 @@ Iec61850UtcTime Iec61850UtcTime::from_bytes(const std::span const auto timestamp = std::chrono::system_clock::time_point{std::chrono::seconds{seconds}} + std::chrono::duration_cast( fraction_duration{fraction}); - return {timestamp, bytes[7]}; + result = {timestamp, bytes[7]}; + return true; +} + +#if !defined(ARIEC61850_NO_EXCEPTIONS) +Iec61850UtcTime Iec61850UtcTime::from_bytes(const std::span bytes) { + Iec61850UtcTime result{}; + if (!try_from_bytes(bytes, result)) { + throw std::invalid_argument("IEC 61850 UTC time requires exactly 8 bytes."); + } + return result; } +#endif bool Iec61850UtcTime::try_write_bytes( const std::span destination) const noexcept { @@ -64,6 +80,7 @@ bool Iec61850UtcTime::try_write_bytes( return true; } +#if !defined(ARIEC61850_NO_EXCEPTIONS) std::vector Iec61850UtcTime::to_bytes() const { std::array bytes{}; if (!try_write_bytes(bytes)) { @@ -71,5 +88,6 @@ std::vector Iec61850UtcTime::to_bytes() const { } return {bytes.begin(), bytes.end()}; } +#endif } // namespace ar::iec61850::mms From bc10c4bb5859299f37f245a9a19b8d17e2c1430e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:36:47 +0700 Subject: [PATCH 66/66] Port clean static MMS server onto current main --- docs/STATIC_IED_SERVER.md | 65 ++ embedded/association_hard_profile_smoke.cpp | 166 +++ ..._connection_runtime_hard_profile_smoke.cpp | 418 ++++++++ embedded/mms_data_set_hard_profile_smoke.cpp | 214 ++++ .../mms_dispatcher_hard_profile_smoke.cpp | 320 ++++++ .../mms_get_name_list_hard_profile_smoke.cpp | 162 +++ embedded/mms_pdu_hard_profile_smoke.cpp | 209 ++++ .../mms_static_model_hard_profile_smoke.cpp | 253 +++++ embedded/mms_write_hard_profile_smoke.cpp | 225 ++++ embedded/osi_hard_profile_smoke.cpp | 234 +++++ ...ession_presentation_hard_profile_smoke.cpp | 212 ++++ include/ariec61850/acse/association_span.hpp | 73 ++ include/ariec61850/asn1/ber_span_reader.hpp | 46 + include/ariec61850/asn1/ber_span_writer.hpp | 193 ++++ include/ariec61850/asn1/ber_types.hpp | 15 + include/ariec61850/mms/connection_runtime.hpp | 91 ++ include/ariec61850/mms/data_set_span.hpp | 63 ++ include/ariec61850/mms/data_span_codec.hpp | 340 ++++++ include/ariec61850/mms/pdu_span.hpp | 95 ++ include/ariec61850/mms/services_span.hpp | 219 ++++ .../ariec61850/mms/static_data_set_table.hpp | 51 + include/ariec61850/mms/static_dispatcher.hpp | 82 ++ .../ariec61850/mms/static_object_table.hpp | 74 ++ .../ariec61850/mms/static_server_session.hpp | 100 ++ include/ariec61850/osi/cotp_span.hpp | 96 ++ include/ariec61850/osi/presentation_span.hpp | 82 ++ include/ariec61850/osi/session_span.hpp | 99 ++ include/ariec61850/osi/tpkt_span.hpp | 58 ++ src/acse/association_span.cpp | 417 ++++++++ src/asn1/ber_span_reader.cpp | 142 +++ src/mms/connection_runtime.cpp | 408 ++++++++ src/mms/data_set_span.cpp | 355 +++++++ src/mms/pdu_span.cpp | 399 +++++++ src/mms/services_span.cpp | 971 ++++++++++++++++++ src/mms/static_data_set_table.cpp | 116 +++ src/mms/static_dispatcher.cpp | 415 ++++++++ src/mms/static_object_table.cpp | 111 ++ src/mms/static_server_session.cpp | 184 ++++ src/mms/static_write.cpp | 33 + src/mms/write_span.cpp | 458 +++++++++ src/osi/cotp_span.cpp | 432 ++++++++ src/osi/presentation_span.cpp | 728 +++++++++++++ src/osi/session_span.cpp | 346 +++++++ src/osi/tpkt_span.cpp | 87 ++ tests/test_mms_static_server_session.cpp | 232 +++++ tools/static_ied_server.cpp | 638 ++++++++++++ 46 files changed, 10727 insertions(+) create mode 100644 docs/STATIC_IED_SERVER.md create mode 100644 embedded/association_hard_profile_smoke.cpp create mode 100644 embedded/mms_connection_runtime_hard_profile_smoke.cpp create mode 100644 embedded/mms_data_set_hard_profile_smoke.cpp create mode 100644 embedded/mms_dispatcher_hard_profile_smoke.cpp create mode 100644 embedded/mms_get_name_list_hard_profile_smoke.cpp create mode 100644 embedded/mms_pdu_hard_profile_smoke.cpp create mode 100644 embedded/mms_static_model_hard_profile_smoke.cpp create mode 100644 embedded/mms_write_hard_profile_smoke.cpp create mode 100644 embedded/osi_hard_profile_smoke.cpp create mode 100644 embedded/session_presentation_hard_profile_smoke.cpp create mode 100644 include/ariec61850/acse/association_span.hpp create mode 100644 include/ariec61850/asn1/ber_span_reader.hpp create mode 100644 include/ariec61850/asn1/ber_span_writer.hpp create mode 100644 include/ariec61850/asn1/ber_types.hpp create mode 100644 include/ariec61850/mms/connection_runtime.hpp create mode 100644 include/ariec61850/mms/data_set_span.hpp create mode 100644 include/ariec61850/mms/data_span_codec.hpp create mode 100644 include/ariec61850/mms/pdu_span.hpp create mode 100644 include/ariec61850/mms/services_span.hpp create mode 100644 include/ariec61850/mms/static_data_set_table.hpp create mode 100644 include/ariec61850/mms/static_dispatcher.hpp create mode 100644 include/ariec61850/mms/static_object_table.hpp create mode 100644 include/ariec61850/mms/static_server_session.hpp create mode 100644 include/ariec61850/osi/cotp_span.hpp create mode 100644 include/ariec61850/osi/presentation_span.hpp create mode 100644 include/ariec61850/osi/session_span.hpp create mode 100644 include/ariec61850/osi/tpkt_span.hpp create mode 100644 src/acse/association_span.cpp create mode 100644 src/asn1/ber_span_reader.cpp create mode 100644 src/mms/connection_runtime.cpp create mode 100644 src/mms/data_set_span.cpp create mode 100644 src/mms/pdu_span.cpp create mode 100644 src/mms/services_span.cpp create mode 100644 src/mms/static_data_set_table.cpp create mode 100644 src/mms/static_dispatcher.cpp create mode 100644 src/mms/static_object_table.cpp create mode 100644 src/mms/static_server_session.cpp create mode 100644 src/mms/static_write.cpp create mode 100644 src/mms/write_span.cpp create mode 100644 src/osi/cotp_span.cpp create mode 100644 src/osi/presentation_span.cpp create mode 100644 src/osi/session_span.cpp create mode 100644 src/osi/tpkt_span.cpp create mode 100644 tests/test_mms_static_server_session.cpp create mode 100644 tools/static_ied_server.cpp diff --git a/docs/STATIC_IED_SERVER.md b/docs/STATIC_IED_SERVER.md new file mode 100644 index 000000000..9b45872e4 --- /dev/null +++ b/docs/STATIC_IED_SERVER.md @@ -0,0 +1,65 @@ +# Static IEC 61850 server foundation + +`ariec61850_static_ied_server` is the first runnable desktop adapter for the +bounded MMS server core. It deliberately keeps platform networking outside the +protocol engine, matching the same boundary required by ESP-IDF/lwIP. + +```text +TCP listen/accept (WinSock or BSD sockets) + -> connected TcpByteStream callbacks + -> MmsStaticServerSession fixed-buffer pump + -> MmsStaticConnectionRuntime + -> MmsStaticApplicationDispatcher + -> static object/DataSet tables +``` + +The included reference model is read-only: + +```text +ESP32S3IOLD0 +|- LLN0 Mod.stVal +|- LPHD1 PhyHealth.stVal +`- GGIO1 Ind1.stVal ... Ind8.stVal +``` + +## Desktop smoke run + +Build with `ARIEC61850_BUILD_TOOLS=ON`, then run: + +```powershell +.\ariec61850_static_ied_server.exe --bind 0.0.0.0 --port 8102 --di-mask 0x35 +``` + +Port 8102 is the unprivileged development default. Use `--port 102` for a +normal IEC 61850 lab endpoint when the operating system permits binding it. + +Implemented server services in this slice: + +- COTP connection accept; +- ACSE association accept and MMS Initiate response; +- `GetNameList`; +- `GetVariableAccessAttributes`; +- `GetNamedVariableListAttributes` when a static DataSet table is supplied; +- `Read`; +- bounded `Write` only for object entries that explicitly provide a write + callback (the included desktop model provides none). + +## ESP32/lwIP integration boundary + +The protocol core does not include an ESP-IDF header or own a listening socket. +An ESP-IDF application accepts a TCP/102 connection through lwIP, supplies +`TcpSendFn`/`TcpReceiveFn` callbacks, gives each connection its own +`MmsStaticConnectionRuntime`, `MmsStaticServerSession`, and fixed buffers, then +calls `poll_once()` from its task or event loop. Buffer capacities and the +maximum concurrent association count remain product-profile decisions. + +## Current limits + +- The desktop adapter serves connections sequentially. The core is per-session + and supports platform-owned multi-client orchestration, but the adapter does + not yet schedule multiple clients concurrently. +- Physical IEDScout browse/read acceptance is not yet recorded. +- The reference values are startup constants; a board adapter still needs to + bind the eight GGIO values to debounced input snapshots. +- Reporting, control, authentication, TLS, file services, and runtime SCL model + loading are outside this first read-only server slice. diff --git a/embedded/association_hard_profile_smoke.cpp b/embedded/association_hard_profile_smoke.cpp new file mode 100644 index 000000000..558234eaf --- /dev/null +++ b/embedded/association_hard_profile_smoke.cpp @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/acse/association_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kRequest{ + 0x0DU, 0xB6U, 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, 0x14U, 0x02U, + 0x00U, 0x02U, 0x33U, 0x02U, 0x00U, 0x01U, 0x34U, 0x02U, 0x00U, 0x01U, 0xC1U, 0xA0U, + 0x31U, 0x81U, 0x9DU, 0xA0U, 0x03U, 0x80U, 0x01U, 0x01U, 0xA2U, 0x81U, 0x95U, 0x81U, + 0x04U, 0x00U, 0x00U, 0x00U, 0x01U, 0x82U, 0x04U, 0x00U, 0x00U, 0x00U, 0x01U, 0xA4U, + 0x23U, 0x30U, 0x0FU, 0x02U, 0x01U, 0x01U, 0x06U, 0x04U, 0x52U, 0x01U, 0x00U, 0x01U, + 0x30U, 0x04U, 0x06U, 0x02U, 0x51U, 0x01U, 0x30U, 0x10U, 0x02U, 0x01U, 0x03U, 0x06U, + 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x01U, 0x30U, 0x04U, 0x06U, 0x02U, 0x51U, 0x01U, + 0x61U, 0x62U, 0x30U, 0x60U, 0x02U, 0x01U, 0x01U, 0xA0U, 0x5BU, 0x60U, 0x59U, 0xA1U, + 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U, 0xA2U, 0x07U, 0x06U, 0x05U, + 0x29U, 0x01U, 0x87U, 0x67U, 0x01U, 0xA3U, 0x03U, 0x02U, 0x01U, 0x0CU, 0xA6U, 0x06U, + 0x06U, 0x04U, 0x29U, 0x01U, 0x87U, 0x67U, 0xA7U, 0x03U, 0x02U, 0x01U, 0x0CU, 0xBEU, + 0x33U, 0x28U, 0x31U, 0x06U, 0x02U, 0x51U, 0x01U, 0x02U, 0x01U, 0x03U, 0xA0U, 0x28U, + 0xA8U, 0x26U, 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, 0x81U, 0x01U, 0x0AU, 0x82U, 0x01U, + 0x0AU, 0x83U, 0x01U, 0x05U, 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, 0x03U, 0x05U, + 0xF1U, 0x00U, 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, 0x08U, 0x00U, + 0x00U, 0x79U, 0xEFU, 0x18U}; + +constexpr std::array kResponse{ + 0x0EU, 0x88U, 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, 0x14U, 0x02U, + 0x00U, 0x02U, 0x33U, 0x02U, 0x00U, 0x01U, 0x34U, 0x02U, 0x00U, 0x01U, 0xC1U, 0x72U, + 0x31U, 0x70U, 0xA0U, 0x03U, 0x80U, 0x01U, 0x01U, 0xA2U, 0x69U, 0xA5U, 0x12U, 0x30U, + 0x07U, 0x80U, 0x01U, 0x00U, 0x81U, 0x02U, 0x51U, 0x01U, 0x30U, 0x07U, 0x80U, 0x01U, + 0x00U, 0x81U, 0x02U, 0x51U, 0x01U, 0x61U, 0x53U, 0x30U, 0x51U, 0x02U, 0x01U, 0x01U, + 0xA0U, 0x4CU, 0x61U, 0x4AU, 0xA1U, 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, + 0x03U, 0xA2U, 0x03U, 0x02U, 0x01U, 0x00U, 0xA3U, 0x05U, 0xA1U, 0x03U, 0x02U, 0x01U, + 0x00U, 0xBEU, 0x33U, 0x28U, 0x31U, 0x06U, 0x02U, 0x51U, 0x01U, 0x02U, 0x01U, 0x03U, + 0xA0U, 0x28U, 0xA9U, 0x26U, 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, 0x83U, 0x01U, 0x05U, 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, + 0x03U, 0x05U, 0xF1U, 0x00U, 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +constexpr std::array kMmsApplicationContext{ + 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U}; +constexpr std::array kCalledApTitle{ + 0x29U, 0x01U, 0x87U, 0x67U, 0x01U}; +constexpr std::array kCallingApTitle{ + 0x29U, 0x01U, 0x87U, 0x67U}; +constexpr std::array kBerSyntax{0x51U, 0x01U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +} // namespace + +int main() { + acse::AssociationRequestView request; + if (!acse::AcseSpanCodec::try_decode_association_request_view(kRequest, request)) { + return 1; + } + + if (request.session.kind != osi::SessionWireKind::connect || + request.presentation.mode_selector != 1U || + request.presentation.context_count != 2U || + request.acse_presentation_context_id != 1U || + request.mms_presentation_context_id != 3U || + !matches(request.aarq.application_context_name, kMmsApplicationContext) || + !matches(request.aarq.called_ap_title, kCalledApTitle) || + !request.aarq.called_ae_qualifier.has_value() || + *request.aarq.called_ae_qualifier != 12U || + !matches(request.aarq.calling_ap_title, kCallingApTitle) || + !request.aarq.calling_ae_qualifier.has_value() || + *request.aarq.calling_ae_qualifier != 12U || + !matches(request.aarq.user_information.direct_reference, kBerSyntax) || + request.aarq.user_information.indirect_reference != 3U || + !matches( + request.aarq.user_information.single_asn1_type, + std::array{ + 0xA8U, 0x26U, + 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, + 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, + 0x83U, 0x01U, 0x05U, + 0xA4U, 0x16U, + 0x80U, 0x01U, 0x01U, + 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, + 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U})) { + return 2; + } + + std::uint32_t acse_context{}; + std::uint32_t mms_context{}; + if (!request.presentation.try_context_id_for_abstract_syntax( + osi::PresentationSpanCodec::acse_abstract_syntax_name(), acse_context) || + !request.presentation.try_context_id_for_abstract_syntax( + osi::PresentationSpanCodec::mms_abstract_syntax_name(), mms_context) || + acse_context != 1U || mms_context != 3U) { + return 3; + } + + std::array response{}; + const auto required = acse::AcseSpanCodec::accept_response_size(request); + const auto result = acse::AcseSpanCodec::build_accept_response_into(request, response); + if (!required || *required != kResponse.size() || + !result.success() || result.bytes_written != kResponse.size() || + !matches( + std::span{response}.first(result.bytes_written), + kResponse)) { + return 4; + } + + // Confirm the deterministic AARE itself is stable and contains the expected + // MMS Initiate response used by the host association profile. + std::array aare{}; + const auto aare_result = acse::AcseSpanCodec::encode_default_accept_aare_into(aare); + if (!aare_result.success() || + aare_result.bytes_written != acse::AcseSpanCodec::default_accept_aare_size() || + !matches( + acse::AcseSpanCodec::default_mms_initiate_response(), + std::array{ + 0xA9U, 0x26U, + 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, + 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, + 0x83U, 0x01U, 0x05U, + 0xA4U, 0x16U, + 0x80U, 0x01U, 0x01U, + 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, + 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U})) { + return 5; + } + + // Association setup is not a realtime hot path, but repeated construction + // still proves the bounded responder does not require owning protocol trees. + for (std::uint32_t iteration = 0U; iteration < 10'000U; ++iteration) { + const auto repeated = acse::AcseSpanCodec::build_accept_response_into( + request, response); + if (!repeated.success() || repeated.bytes_written != kResponse.size() || + response[0] != osi::SessionSpanCodec::accept_code || + response[1] != 0x88U) { + return 6; + } + } + + constexpr std::array malformed{ + 0x0DU, 0x02U, 0xC1U, 0x00U}; + acse::AssociationRequestView rejected; + if (acse::AcseSpanCodec::try_decode_association_request_view(malformed, rejected)) { + return 7; + } + + return 0; +} diff --git a/embedded/mms_connection_runtime_hard_profile_smoke.cpp b/embedded/mms_connection_runtime_hard_profile_smoke.cpp new file mode 100644 index 000000000..e4202ce0d --- /dev/null +++ b/embedded/mms_connection_runtime_hard_profile_smoke.cpp @@ -0,0 +1,418 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/connection_runtime.hpp" +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kInt32Type{0x85U, 0x01U, 0x20U}; + +constexpr std::array kAssociationRequest{ + 0x0DU, 0xB6U, 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, 0x14U, 0x02U, + 0x00U, 0x02U, 0x33U, 0x02U, 0x00U, 0x01U, 0x34U, 0x02U, 0x00U, 0x01U, 0xC1U, 0xA0U, + 0x31U, 0x81U, 0x9DU, 0xA0U, 0x03U, 0x80U, 0x01U, 0x01U, 0xA2U, 0x81U, 0x95U, 0x81U, + 0x04U, 0x00U, 0x00U, 0x00U, 0x01U, 0x82U, 0x04U, 0x00U, 0x00U, 0x00U, 0x01U, 0xA4U, + 0x23U, 0x30U, 0x0FU, 0x02U, 0x01U, 0x01U, 0x06U, 0x04U, 0x52U, 0x01U, 0x00U, 0x01U, + 0x30U, 0x04U, 0x06U, 0x02U, 0x51U, 0x01U, 0x30U, 0x10U, 0x02U, 0x01U, 0x03U, 0x06U, + 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x01U, 0x30U, 0x04U, 0x06U, 0x02U, 0x51U, 0x01U, + 0x61U, 0x62U, 0x30U, 0x60U, 0x02U, 0x01U, 0x01U, 0xA0U, 0x5BU, 0x60U, 0x59U, 0xA1U, + 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U, 0xA2U, 0x07U, 0x06U, 0x05U, + 0x29U, 0x01U, 0x87U, 0x67U, 0x01U, 0xA3U, 0x03U, 0x02U, 0x01U, 0x0CU, 0xA6U, 0x06U, + 0x06U, 0x04U, 0x29U, 0x01U, 0x87U, 0x67U, 0xA7U, 0x03U, 0x02U, 0x01U, 0x0CU, 0xBEU, + 0x33U, 0x28U, 0x31U, 0x06U, 0x02U, 0x51U, 0x01U, 0x02U, 0x01U, 0x03U, 0xA0U, 0x28U, + 0xA8U, 0x26U, 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, 0x81U, 0x01U, 0x0AU, 0x82U, 0x01U, + 0x0AU, 0x83U, 0x01U, 0x05U, 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, 0x03U, 0x05U, + 0xF1U, 0x00U, 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, 0x08U, 0x00U, + 0x00U, 0x79U, 0xEFU, 0x18U}; + +constexpr std::array kAssociationResponse{ + 0x0EU, 0x88U, 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, 0x14U, 0x02U, + 0x00U, 0x02U, 0x33U, 0x02U, 0x00U, 0x01U, 0x34U, 0x02U, 0x00U, 0x01U, 0xC1U, 0x72U, + 0x31U, 0x70U, 0xA0U, 0x03U, 0x80U, 0x01U, 0x01U, 0xA2U, 0x69U, 0xA5U, 0x12U, 0x30U, + 0x07U, 0x80U, 0x01U, 0x00U, 0x81U, 0x02U, 0x51U, 0x01U, 0x30U, 0x07U, 0x80U, 0x01U, + 0x00U, 0x81U, 0x02U, 0x51U, 0x01U, 0x61U, 0x53U, 0x30U, 0x51U, 0x02U, 0x01U, 0x01U, + 0xA0U, 0x4CU, 0x61U, 0x4AU, 0xA1U, 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, + 0x03U, 0xA2U, 0x03U, 0x02U, 0x01U, 0x00U, 0xA3U, 0x05U, 0xA1U, 0x03U, 0x02U, 0x01U, + 0x00U, 0xBEU, 0x33U, 0x28U, 0x31U, 0x06U, 0x02U, 0x51U, 0x01U, 0x02U, 0x01U, 0x03U, + 0xA0U, 0x28U, 0xA9U, 0x26U, 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, 0x83U, 0x01U, 0x05U, 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, + 0x03U, 0x05U, 0xF1U, 0x00U, 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +constexpr std::array kReadRequest{ + 0xA0U, 0x27U, 0x02U, 0x01U, 0x0CU, + 0xA4U, 0x22U, 0xA1U, 0x20U, 0xA0U, 0x1EU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x4DU, 0x31U}; + +constexpr std::array kReadResponse{ + 0xA1U, 0x0DU, 0x02U, 0x01U, 0x0CU, + 0xA4U, 0x08U, 0xA1U, 0x06U, + 0x83U, 0x01U, 0xFFU, + 0x85U, 0x01U, 0x2AU}; + +constexpr std::array kWriteRequest{ + 0xA0U, 0x1BU, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x16U, + 0xA0U, 0x0FU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0xA0U, 0x03U, 0x83U, 0x01U, 0x00U}; + +constexpr std::array kWriteResponse{ + 0xA1U, 0x07U, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x02U, 0x81U, 0x00U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto value = *static_cast(context); + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult read_small_int32( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto value = *static_cast(context); + if (value > 0x7FU) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + destination[0] = 0x85U; + destination[1] = 0x01U; + destination[2] = value; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] mms::MmsStaticWriteResult write_boolean( + void* context, + const std::span encoded_data) noexcept { + if (context == nullptr || encoded_data.size() != 3U || + encoded_data[0] != 0x83U || encoded_data[1] != 0x01U || + (encoded_data[2] != 0x00U && encoded_data[2] != 0xFFU)) { + return {false, 7U}; + } + *static_cast(context) = encoded_data[2] != 0x00U; + return {true, 0U}; +} + +template +[[nodiscard]] wire::EncodeResult build_data_tpkt( + const std::span session_payload, + std::array& output, + std::array& scratch, + const bool eot = true) noexcept { + const auto cotp = osi::CotpSpanCodec::encode_data_into( + session_payload, scratch, eot, 0U); + if (!cotp.success()) { + return cotp; + } + return osi::TpktSpanCodec::encode_into( + std::span{scratch}.first(cotp.bytes_written), output); +} + +template +[[nodiscard]] wire::EncodeResult build_mms_tpkt( + const std::span mms_pdu, + std::array& output, + std::array& presentation, + std::array& scratch, + const bool eot = true) noexcept { + const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( + mms_pdu, presentation, 3U, true); + if (!p_data.success()) { + return p_data; + } + return build_data_tpkt( + std::span{presentation}.first(p_data.bytes_written), + output, + scratch, + eot); +} + +[[nodiscard]] bool extract_cotp( + const std::span frame, + osi::CotpTpduView& cotp) noexcept { + osi::TpktFrameView tpkt; + return osi::TpktSpanCodec::try_decode_view(frame, tpkt) && + osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp); +} + +[[nodiscard]] bool extract_mms( + const std::span frame, + osi::PresentationPdvView& pdv) noexcept { + osi::CotpTpduView cotp; + osi::SessionDataTransferView session; + return extract_cotp(frame, cotp) && + cotp.kind == osi::CotpWireKind::data && + osi::SessionSpanCodec::try_decode_data_transfer_view(cotp.user_data, session) && + osi::PresentationSpanCodec::try_decode_fully_encoded_data_view( + session.presentation_payload, pdv); +} + +} // namespace + +int main() { + bool relay_state = true; + const std::uint8_t meter_value = 42U; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, + read_boolean, &relay_state, false, + write_boolean, &relay_state}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kInt32Type, + read_small_int32, &meter_value, false}}; + const mms::MmsStaticObjectTable table{objects}; + const mms::MmsStaticApplicationDispatcher dispatcher{table}; + mms::MmsStaticConnectionRuntime runtime{dispatcher}; + if (!table.valid() || + runtime.state() != mms::MmsStaticConnectionState::awaiting_cotp_connect) { + return 1; + } + + constexpr std::array tpdu_size{0x0AU}; + constexpr std::array source_tsap{0x00U, 0x01U}; + constexpr std::array destination_tsap{0x00U, 0x01U}; + const std::array parameters{ + osi::CotpParameterView{osi::CotpSpanCodec::tpdu_size_parameter, tpdu_size}, + osi::CotpParameterView{osi::CotpSpanCodec::source_tsap_parameter, source_tsap}, + osi::CotpParameterView{osi::CotpSpanCodec::destination_tsap_parameter, destination_tsap}}; + + std::array request{}; + std::array response{}; + std::array workspace{}; + std::array scratch{}; + std::array presentation{}; + + const auto cr = osi::CotpSpanCodec::encode_connection_request_into( + 0x0001U, parameters, scratch); + const auto cr_tpkt = cr.success() + ? osi::TpktSpanCodec::encode_into( + std::span{scratch}.first(cr.bytes_written), request) + : wire::EncodeResult{}; + if (!cr.success() || !cr_tpkt.success()) { + return 2; + } + + auto result = runtime.process_tcp_window( + std::span{request}.first(3U), response, workspace); + if (result.status != mms::MmsStaticConnectionStatus::need_more || + result.consumed_bytes != 0U || + runtime.state() != mms::MmsStaticConnectionState::awaiting_cotp_connect) { + return 3; + } + + result = runtime.process_tcp_window( + std::span{request}.first(cr_tpkt.bytes_written), + response, + workspace); + osi::CotpTpduView cc; + if (!result.response_ready() || result.consumed_bytes != cr_tpkt.bytes_written || + runtime.state() != mms::MmsStaticConnectionState::awaiting_association || + !extract_cotp( + std::span{response}.first(result.bytes_written), cc) || + cc.kind != osi::CotpWireKind::connection_confirm || + cc.destination_reference != 0x0001U || cc.source_reference != 0x1001U) { + return 4; + } + + const auto association_tpkt = build_data_tpkt( + kAssociationRequest, request, scratch); + result = runtime.process_tcp_window( + std::span{request}.first(association_tpkt.bytes_written), + response, + workspace); + osi::CotpTpduView association_response; + if (!association_tpkt.success() || !result.response_ready() || + runtime.state() != mms::MmsStaticConnectionState::established || + runtime.mms_presentation_context_id() != 3U || + !extract_cotp( + std::span{response}.first(result.bytes_written), + association_response) || + !matches(association_response.user_data, kAssociationResponse)) { + return 5; + } + + const auto read_tpkt = build_mms_tpkt( + kReadRequest, request, presentation, scratch); + if (!read_tpkt.success()) { + return 6; + } + + std::array coalesced{}; + std::copy_n(request.begin(), read_tpkt.bytes_written, coalesced.begin()); + std::copy_n( + request.begin(), + read_tpkt.bytes_written, + coalesced.begin() + static_cast(read_tpkt.bytes_written)); + const auto coalesced_bytes = read_tpkt.bytes_written * 2U; + + result = runtime.process_tcp_window( + std::span{coalesced}.first(coalesced_bytes), + response, + workspace); + osi::PresentationPdvView pdv; + if (!result.response_ready() || result.consumed_bytes != read_tpkt.bytes_written || + result.application_service != mms::MmsWireConfirmedService::read || + !extract_mms( + std::span{response}.first(result.bytes_written), pdv) || + pdv.context_id != 3U || !matches(pdv.single_asn1_type, kReadResponse)) { + return 7; + } + + const auto second_window = std::span{coalesced} + .subspan(result.consumed_bytes, read_tpkt.bytes_written); + result = runtime.process_tcp_window(second_window, response, workspace); + if (!result.response_ready() || result.consumed_bytes != read_tpkt.bytes_written || + !extract_mms( + std::span{response}.first(result.bytes_written), pdv) || + !matches(pdv.single_asn1_type, kReadResponse)) { + return 8; + } + + for (std::uint32_t iteration = 0U; iteration < 10'000U; ++iteration) { + result = runtime.process_tcp_window( + std::span{request}.first(read_tpkt.bytes_written), + response, + workspace); + if (!result.response_ready() || + result.application_service != mms::MmsWireConfirmedService::read || + result.invoke_id != 12U) { + return 9; + } + } + + std::array identify_mms{}; + const auto identify = mms::MmsPduSpanCodec::encode_confirmed_request_into( + 21U, + static_cast(mms::MmsWireConfirmedService::identify), + false, + {}, + identify_mms); + const auto identify_tpkt = identify.success() + ? build_mms_tpkt( + std::span{identify_mms}.first(identify.bytes_written), + request, + presentation, + scratch) + : wire::EncodeResult{}; + if (!identify.success() || !identify_tpkt.success()) { + return 10; + } + result = runtime.process_tcp_window( + std::span{request}.first(identify_tpkt.bytes_written), + response, + workspace); + if (result.status != mms::MmsStaticConnectionStatus::application_rejected || + result.application_status != mms::MmsStaticDispatchStatus::unsupported_service || + result.application_service != mms::MmsWireConfirmedService::identify || + result.invoke_id != 21U || result.consumed_bytes != identify_tpkt.bytes_written || + runtime.state() != mms::MmsStaticConnectionState::established) { + return 11; + } + + const auto write_tpkt = build_mms_tpkt( + kWriteRequest, request, presentation, scratch); + if (!write_tpkt.success() || !relay_state) { + return 12; + } + std::array small_response{}; + result = runtime.process_tcp_window( + std::span{request}.first(write_tpkt.bytes_written), + small_response, + workspace); + if (result.status != mms::MmsStaticConnectionStatus::response_buffer_too_small || + result.consumed_bytes != 0U || result.required_response_bytes <= small_response.size() || + !relay_state || runtime.state() != mms::MmsStaticConnectionState::established) { + return 13; + } + + result = runtime.process_tcp_window( + std::span{request}.first(write_tpkt.bytes_written), + response, + workspace); + if (!result.response_ready() || relay_state || + result.application_service != mms::MmsWireConfirmedService::write || + !extract_mms( + std::span{response}.first(result.bytes_written), pdv) || + !matches(pdv.single_asn1_type, kWriteResponse)) { + return 14; + } + + const auto segmented_tpkt = build_mms_tpkt( + kReadRequest, request, presentation, scratch, false); + result = runtime.process_tcp_window( + std::span{request}.first(segmented_tpkt.bytes_written), + response, + workspace); + if (!segmented_tpkt.success() || + result.status != mms::MmsStaticConnectionStatus::protocol_violation || + runtime.state() != mms::MmsStaticConnectionState::fault) { + return 15; + } + + runtime.reset(); + if (runtime.state() != mms::MmsStaticConnectionState::awaiting_cotp_connect || + runtime.mms_presentation_context_id() != 0U) { + return 16; + } + + mms::MmsStaticConnectionRuntime wrong_order{dispatcher}; + const auto out_of_order = build_mms_tpkt( + kReadRequest, request, presentation, scratch); + result = wrong_order.process_tcp_window( + std::span{request}.first(out_of_order.bytes_written), + response, + workspace); + if (!out_of_order.success() || + result.status != mms::MmsStaticConnectionStatus::protocol_violation || + wrong_order.state() != mms::MmsStaticConnectionState::fault) { + return 17; + } + + return 0; +} diff --git a/embedded/mms_data_set_hard_profile_smoke.cpp b/embedded/mms_data_set_hard_profile_smoke.cpp new file mode 100644 index 000000000..958e67910 --- /dev/null +++ b/embedded/mms_data_set_hard_profile_smoke.cpp @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/data_set_span.hpp" +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_dispatcher.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kAttributesRequest{ + 0xA0U, 0x19U, 0x02U, 0x01U, 0x01U, + 0xACU, 0x14U, 0xA1U, 0x12U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x0BU, 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, + 0x45U, 0x76U, 0x65U, 0x6EU, 0x74U, 0x73U}; +constexpr std::array kAttributesResponse{ + 0xA1U, 0x28U, 0x02U, 0x01U, 0x01U, + 0xACU, 0x23U, 0x80U, 0x01U, 0x00U, + 0xA1U, 0x1EU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x4DU, 0x31U}; +constexpr std::array kDataSetNameListRequest{ + 0xA0U, 0x11U, 0x02U, 0x01U, 0x02U, + 0xA1U, 0x0CU, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x02U, + 0xA1U, 0x05U, 0x81U, 0x03U, 0x4CU, 0x44U, 0x30U}; +constexpr std::array kDataSetNameListResponse{ + 0xA1U, 0x17U, 0x02U, 0x01U, 0x02U, + 0xA1U, 0x12U, 0xA0U, 0x0DU, + 0x1AU, 0x0BU, 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, + 0x45U, 0x76U, 0x65U, 0x6EU, 0x74U, 0x73U, + 0x81U, 0x01U, 0x00U}; +constexpr std::array kLd0{0x4CU, 0x44U, 0x30U}; +constexpr std::array kR1{0x52U, 0x31U}; +constexpr std::array kM1{0x4DU, 0x31U}; +constexpr std::array kEvents{ + 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, + 0x45U, 0x76U, 0x65U, 0x6EU, 0x74U, 0x73U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +} // namespace + +int main() { + mms::MmsNamedVariableListAttributesRequestView request; + if (!mms::MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_request( + kAttributesRequest, request) || + request.invoke_id != 1U || + request.name.kind != mms::MmsObjectNameViewKind::domain_specific || + !matches(request.name.domain, kLd0) || !matches(request.name.item, kEvents)) { + return 1; + } + + const std::array encoded_members{ + mms::MmsNamedVariableListMemberInput{"LD0", "R1"}, + mms::MmsNamedVariableListMemberInput{"LD0", "M1"}}; + std::array buffer{}; + const auto encoded = + mms::MmsDataSetSpanCodec::encode_get_named_variable_list_attributes_response_into( + 1U, false, encoded_members, buffer); + if (!encoded.success() || encoded.bytes_written != kAttributesResponse.size() || + !matches( + std::span{buffer}.first(encoded.bytes_written), + kAttributesResponse)) { + return 2; + } + + mms::MmsNamedVariableListAttributesResponseView response; + if (!mms::MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_response( + kAttributesResponse, response) || + response.invoke_id != 1U || response.mms_deletable || response.member_count != 2U) { + return 3; + } + mms::MmsObjectNameView member; + if (!response.try_member(0U, member) || + !matches(member.domain, kLd0) || !matches(member.item, kR1) || + !response.try_member(1U, member) || + !matches(member.domain, kLd0) || !matches(member.item, kM1) || + response.try_member(2U, member)) { + return 4; + } + + const bool relay_state = true; + const bool meter_state = false; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, read_boolean, &relay_state, false}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kBooleanType, read_boolean, &meter_state, false}}; + const mms::MmsStaticObjectTable object_table{objects}; + + const std::array event_members{ + mms::MmsStaticDataSetMember{"LD0", "R1"}, + mms::MmsStaticDataSetMember{"LD0", "M1"}}; + const std::array data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", event_members, false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + if (!data_set_table.valid() || !data_set_table.valid_against(object_table)) { + return 5; + } + + const mms::MmsStaticApplicationDispatcher dispatcher{object_table, data_set_table}; + std::array workspace{}; + auto dispatched = dispatcher.dispatch(kAttributesRequest, buffer, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_named_variable_list_attributes || + dispatched.invoke_id != 1U || dispatched.bytes_written != kAttributesResponse.size() || + !matches( + std::span{buffer}.first(dispatched.bytes_written), + kAttributesResponse)) { + return 6; + } + + dispatched = dispatcher.dispatch(kDataSetNameListRequest, buffer, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_name_list || + dispatched.bytes_written != kDataSetNameListResponse.size() || + !matches( + std::span{buffer}.first(dispatched.bytes_written), + kDataSetNameListResponse)) { + return 7; + } + + mms::MmsGetNameListResponseView names; + std::span identifier; + if (!mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + std::span{buffer}.first(dispatched.bytes_written), names) || + names.identifier_count != 1U || names.more_follows || + !names.try_identifier(0U, identifier) || !matches(identifier, kEvents)) { + return 8; + } + + std::array tiny{}; + dispatched = dispatcher.dispatch(kAttributesRequest, tiny, workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::response_buffer_too_small || + dispatched.required_bytes != kAttributesResponse.size()) { + return 9; + } + + auto missing_request = kAttributesRequest; + missing_request[26] = 0x58U; + dispatched = dispatcher.dispatch(missing_request, buffer, workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::object_not_found) { + return 10; + } + + const std::array invalid_members{ + mms::MmsStaticDataSetMember{"LD0", "X1"}}; + const std::array invalid_data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Broken", invalid_members, false}}; + const mms::MmsStaticDataSetTable invalid_table{invalid_data_sets}; + if (!invalid_table.valid() || invalid_table.valid_against(object_table)) { + return 11; + } + + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto invoke = iteration & 0x7FFFU; + const auto repeated = + mms::MmsDataSetSpanCodec::encode_get_named_variable_list_attributes_response_into( + invoke, (iteration & 1U) != 0U, encoded_members, buffer); + mms::MmsNamedVariableListAttributesResponseView decoded; + if (!repeated.success() || + !mms::MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_response( + std::span{buffer}.first(repeated.bytes_written), decoded) || + decoded.invoke_id != invoke || decoded.member_count != 2U || + decoded.mms_deletable != ((iteration & 1U) != 0U)) { + return 12; + } + } + + const std::array invalid_name{ + mms::MmsNamedVariableListMemberInput{"LD0", std::string_view{"A\0B", 3U}}}; + if (mms::MmsDataSetSpanCodec::encode_get_named_variable_list_attributes_response_into( + 1U, false, invalid_name, buffer).success()) { + return 13; + } + + return 0; +} diff --git a/embedded/mms_dispatcher_hard_profile_smoke.cpp b/embedded/mms_dispatcher_hard_profile_smoke.cpp new file mode 100644 index 000000000..37b9eef63 --- /dev/null +++ b/embedded/mms_dispatcher_hard_profile_smoke.cpp @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_dispatcher.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kInt32Type{0x85U, 0x01U, 0x20U}; + +constexpr std::array kDomainListRequest{ + 0xA0U, 0x0EU, 0x02U, 0x01U, 0x07U, + 0xA1U, 0x09U, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x09U, + 0xA1U, 0x02U, 0x80U, 0x00U}; + +constexpr std::array kDomainContinueRequest{ + 0xA0U, 0x13U, 0x02U, 0x01U, 0x08U, + 0xA1U, 0x0EU, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x09U, + 0xA1U, 0x02U, 0x80U, 0x00U, + 0x82U, 0x03U, 0x4CU, 0x44U, 0x30U}; + +constexpr std::array kDomainFirstResponse{ + 0xA1U, 0x0FU, 0x02U, 0x01U, 0x07U, + 0xA1U, 0x0AU, 0xA0U, 0x05U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x81U, 0x01U, 0xFFU}; + +constexpr std::array kDomainSecondResponse{ + 0xA1U, 0x0FU, 0x02U, 0x01U, 0x08U, + 0xA1U, 0x0AU, 0xA0U, 0x05U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x31U, + 0x81U, 0x01U, 0x00U}; + +constexpr std::array kAttributesRequest{ + 0xA0U, 0x12U, 0x02U, 0x01U, 0x0BU, + 0xA6U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U}; + +constexpr std::array kAttributesResponse{ + 0xA1U, 0x0CU, 0x02U, 0x01U, 0x0BU, + 0xA6U, 0x07U, 0x80U, 0x01U, 0x00U, + 0xA2U, 0x02U, 0x83U, 0x00U}; + +constexpr std::array kReadRequest{ + 0xA0U, 0x27U, 0x02U, 0x01U, 0x0CU, + 0xA4U, 0x22U, 0xA1U, 0x20U, 0xA0U, 0x1EU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x4DU, 0x31U}; + +constexpr std::array kReadResponse{ + 0xA1U, 0x0DU, 0x02U, 0x01U, 0x0CU, + 0xA4U, 0x08U, 0xA1U, 0x06U, + 0x83U, 0x01U, 0xFFU, + 0x85U, 0x01U, 0x2AU}; + +constexpr std::array kWriteRequest{ + 0xA0U, 0x1BU, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x16U, + 0xA0U, 0x0FU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0xA0U, 0x03U, 0x83U, 0x01U, 0x00U}; + +constexpr std::array kWriteSuccessResponse{ + 0xA1U, 0x07U, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x02U, 0x81U, 0x00U}; + +constexpr std::array kWriteDeniedResponse{ + 0xA1U, 0x08U, 0x02U, 0x01U, 0x0FU, + 0xA5U, 0x03U, 0x80U, 0x01U, 0x03U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto value = *static_cast(context); + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult read_small_int32( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr || destination.size() < required) { + return context == nullptr + ? wire::EncodeResult{wire::EncodeStatus::value_out_of_range, 0U, required} + : wire::EncodeResult{wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto value = *static_cast(context); + if (value > 0x7FU) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + destination[0] = 0x85U; + destination[1] = 0x01U; + destination[2] = value; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] mms::MmsStaticWriteResult write_boolean( + void* context, + const std::span encoded_data) noexcept { + if (context == nullptr || encoded_data.size() != 3U || + encoded_data[0] != 0x83U || encoded_data[1] != 0x01U || + (encoded_data[2] != 0x00U && encoded_data[2] != 0xFFU)) { + return {false, 7U}; + } + *static_cast(context) = encoded_data[2] != 0x00U; + return {true, 0U}; +} + +} // namespace + +int main() { + bool relay_state = true; + const std::uint8_t meter_value = 42U; + const bool remote_state = false; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, + read_boolean, &relay_state, false, + write_boolean, &relay_state}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kInt32Type, + read_small_int32, &meter_value, false}, + mms::MmsStaticObjectEntry{ + "LD1", "X1", kBooleanType, + read_boolean, &remote_state, false}}; + const mms::MmsStaticObjectTable table{objects}; + if (!table.valid()) { + return 1; + } + + const mms::MmsStaticDispatchPolicy policy{ + 1U, + 1U, + 10U, + 3U, + 10U}; + const mms::MmsStaticApplicationDispatcher dispatcher{table, policy}; + std::array response{}; + std::array workspace{}; + + auto dispatched = dispatcher.dispatch(kDomainListRequest, response, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_name_list || + dispatched.invoke_id != 7U || + dispatched.bytes_written != kDomainFirstResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kDomainFirstResponse)) { + return 2; + } + + dispatched = dispatcher.dispatch(kDomainContinueRequest, response, workspace); + if (!dispatched.success() || dispatched.invoke_id != 8U || + dispatched.bytes_written != kDomainSecondResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kDomainSecondResponse)) { + return 3; + } + + mms::MmsConfirmedPduView attributes_view; + if (!mms::MmsPduSpanCodec::try_decode_confirmed_request_view( + kAttributesRequest, attributes_view)) { + return 4; + } + dispatched = dispatcher.dispatch(attributes_view, response, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_variable_access_attributes || + dispatched.bytes_written != kAttributesResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kAttributesResponse)) { + return 5; + } + + dispatched = dispatcher.dispatch(kReadRequest, response, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::read || + dispatched.bytes_written != kReadResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kReadResponse)) { + return 6; + } + + std::array tiny_workspace{}; + dispatched = dispatcher.dispatch(kReadRequest, response, tiny_workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::workspace_too_small || + dispatched.required_bytes < 3U) { + return 7; + } + + auto missing_read = kReadRequest; + missing_read[39] = 0x5AU; + dispatched = dispatcher.dispatch(missing_read, response, workspace); + if (!dispatched.success()) { + return 8; + } + mms::MmsReadResponseView read_response; + mms::MmsReadAccessResultView read_result; + if (!mms::MmsServiceSpanCodec::try_decode_read_response( + std::span{response}.first(dispatched.bytes_written), + read_response) || + read_response.result_count != 2U || + !read_response.try_result(0U, read_result) || !read_result.success || + !read_response.try_result(1U, read_result) || read_result.success || + read_result.failure_code != 10U) { + return 9; + } + + dispatched = dispatcher.dispatch(kWriteRequest, response, workspace); + if (!dispatched.success() || relay_state || + dispatched.service != mms::MmsWireConfirmedService::write || + dispatched.bytes_written != kWriteSuccessResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kWriteSuccessResponse)) { + return 10; + } + + auto read_only_write = kWriteRequest; + read_only_write[4] = 0x0FU; + read_only_write[22] = 0x4DU; + dispatched = dispatcher.dispatch(read_only_write, response, workspace); + if (!dispatched.success() || + dispatched.bytes_written != kWriteDeniedResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kWriteDeniedResponse) || + meter_value != 42U) { + return 11; + } + + std::array tiny_response{}; + dispatched = dispatcher.dispatch(kDomainListRequest, tiny_response, workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::response_buffer_too_small || + dispatched.required_bytes != kDomainFirstResponse.size()) { + return 12; + } + + auto unknown_continuation = kDomainContinueRequest; + unknown_continuation[18] = 0x5AU; + unknown_continuation[19] = 0x5AU; + unknown_continuation[20] = 0x5AU; + dispatched = dispatcher.dispatch(unknown_continuation, response, workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::object_not_found) { + return 13; + } + + std::array identify_request{}; + const auto identify_encoded = mms::MmsPduSpanCodec::encode_confirmed_request_into( + 21U, + static_cast(mms::MmsWireConfirmedService::identify), + false, + {}, + identify_request); + if (!identify_encoded.success()) { + return 14; + } + dispatched = dispatcher.dispatch( + std::span{identify_request}.first(identify_encoded.bytes_written), + response, + workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::unsupported_service || + dispatched.invoke_id != 21U) { + return 15; + } + + constexpr std::array malformed{0x00U}; + dispatched = dispatcher.dispatch(malformed, response, workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::malformed_request || + dispatched.service != mms::MmsWireConfirmedService::unknown) { + return 16; + } + + for (std::uint32_t iteration = 0U; iteration < 20'000U; ++iteration) { + relay_state = true; + const auto read = dispatcher.dispatch(kReadRequest, response, workspace); + const auto write = dispatcher.dispatch(kWriteRequest, response, workspace); + if (!read.success() || !write.success() || relay_state) { + return 17; + } + } + + return 0; +} diff --git a/embedded/mms_get_name_list_hard_profile_smoke.cpp b/embedded/mms_get_name_list_hard_profile_smoke.cpp new file mode 100644 index 000000000..61c7c5e7d --- /dev/null +++ b/embedded/mms_get_name_list_hard_profile_smoke.cpp @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kDomainListRequest{ + 0xA0U, 0x0EU, 0x02U, 0x01U, 0x07U, + 0xA1U, 0x09U, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x09U, + 0xA1U, 0x02U, 0x80U, 0x00U}; + +constexpr std::array kDomainScopedRequest{ + 0xA0U, 0x14U, 0x02U, 0x01U, 0x08U, + 0xA1U, 0x0FU, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x00U, + 0xA1U, 0x05U, 0x81U, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x82U, 0x01U, 0x58U}; + +constexpr std::array kResponse{ + 0xA1U, 0x14U, 0x02U, 0x01U, 0x07U, + 0xA1U, 0x0FU, + 0xA0U, 0x0AU, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x31U, + 0x81U, 0x01U, 0x00U}; + +constexpr std::array kLd0{0x4CU, 0x44U, 0x30U}; +constexpr std::array kLd1{0x4CU, 0x44U, 0x31U}; +constexpr std::array kContinuation{0x58U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +} // namespace + +int main() { + mms::MmsGetNameListRequestView request; + if (!mms::MmsServiceSpanCodec::try_decode_get_name_list_request( + kDomainListRequest, request) || + request.invoke_id != 7U || + request.object_class != mms::MmsNameListObjectClass::domain || + request.scope != mms::MmsNameScopeKind::vmd_specific || + !request.domain_id.empty() || !request.continue_after.empty()) { + return 1; + } + + if (!mms::MmsServiceSpanCodec::try_decode_get_name_list_request( + kDomainScopedRequest, request) || + request.invoke_id != 8U || + request.object_class != mms::MmsNameListObjectClass::named_variable || + request.scope != mms::MmsNameScopeKind::domain_specific || + !matches(request.domain_id, kLd0) || + !matches(request.continue_after, kContinuation)) { + return 2; + } + + const std::array names{"LD0", "LD1"}; + std::array buffer{}; + const auto encoded = mms::MmsServiceSpanCodec::encode_get_name_list_response_into( + 7U, names, false, buffer); + if (!encoded.success() || encoded.bytes_written != kResponse.size() || + !matches( + std::span{buffer}.first(encoded.bytes_written), + kResponse)) { + return 3; + } + + mms::MmsGetNameListResponseView response; + if (!mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + kResponse, response) || + response.invoke_id != 7U || response.identifier_count != 2U || + response.more_follows) { + return 4; + } + + std::span identifier; + if (!response.try_identifier(0U, identifier) || !matches(identifier, kLd0) || + !response.try_identifier(1U, identifier) || !matches(identifier, kLd1) || + response.try_identifier(2U, identifier)) { + return 5; + } + + // Post-association application flow: response PDU -> Presentation P-DATA -> + // zero-copy Presentation view -> zero-copy GetNameList response view. + std::array p_data{}; + const auto p_data_result = osi::PresentationSpanCodec::encode_p_data_into( + kResponse, p_data, 3U, true); + osi::PresentationPdvView pdv; + if (!p_data_result.success() || + !osi::PresentationSpanCodec::try_decode_p_data_view( + std::span{p_data}.first(p_data_result.bytes_written), + pdv) || + pdv.context_id != 3U || + !mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + pdv.single_asn1_type, response) || + response.identifier_count != 2U) { + return 6; + } + + // Empty lists are valid MMS responses; pagination is expressed separately + // by moreFollows. + const std::span empty_names; + const auto empty = mms::MmsServiceSpanCodec::encode_get_name_list_response_into( + 9U, empty_names, true, buffer); + if (!empty.success() || + !mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + std::span{buffer}.first(empty.bytes_written), + response) || + response.identifier_count != 0U || !response.more_follows) { + return 7; + } + + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto invoke = iteration & 0x7FFFU; + const auto repeated = mms::MmsServiceSpanCodec::encode_get_name_list_response_into( + invoke, names, (iteration & 1U) != 0U, buffer); + mms::MmsGetNameListResponseView decoded; + if (!repeated.success() || + !mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + std::span{buffer}.first(repeated.bytes_written), + decoded) || + decoded.invoke_id != invoke || decoded.identifier_count != 2U || + decoded.more_follows != ((iteration & 1U) != 0U)) { + return 8; + } + } + + constexpr std::array malformed_scope{ + 0xA0U, 0x0EU, 0x02U, 0x01U, 0x07U, + 0xA1U, 0x09U, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x09U, + 0xA1U, 0x02U, 0x80U, 0x01U}; + if (mms::MmsServiceSpanCodec::try_decode_get_name_list_request( + malformed_scope, request)) { + return 9; + } + + const std::array invalid_name{ + std::string_view{"A\0B", 3U}}; + if (mms::MmsServiceSpanCodec::encode_get_name_list_response_into( + 1U, invalid_name, false, buffer).success()) { + return 10; + } + + return 0; +} diff --git a/embedded/mms_pdu_hard_profile_smoke.cpp b/embedded/mms_pdu_hard_profile_smoke.cpp new file mode 100644 index 000000000..e5dc63a72 --- /dev/null +++ b/embedded/mms_pdu_hard_profile_smoke.cpp @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/pdu_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kInitiateRequest{ + 0xA8U, 0x26U, + 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, + 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, + 0x83U, 0x01U, 0x05U, + 0xA4U, 0x16U, + 0x80U, 0x01U, 0x01U, + 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, + 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +constexpr std::array kInitiateResponse{ + 0xA9U, 0x26U, + 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, + 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, + 0x83U, 0x01U, 0x05U, + 0xA4U, 0x16U, + 0x80U, 0x01U, 0x01U, + 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, + 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +constexpr std::array kParameterSupport{ + 0x05U, 0xF1U, 0x00U}; +constexpr std::array kServicesSupported{ + 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] bool valid_initiate( + const mms::MmsInitiateView& value, + const mms::MmsWirePduKind expected_kind) noexcept { + return value.kind == expected_kind && + value.maximum_mms_pdu_size == 65'000U && + value.maximum_outstanding_calling == 10U && + value.maximum_outstanding_called == 10U && + value.data_structure_nesting_level == 5U && + value.detail.version_number == 1U && + matches(value.detail.parameter_support_options, kParameterSupport) && + matches(value.detail.services_supported_calling, kServicesSupported); +} + +} // namespace + +int main() { + mms::MmsInitiateView initiate; + if (!mms::MmsPduSpanCodec::try_decode_initiate_request_view( + kInitiateRequest, initiate) || + !valid_initiate(initiate, mms::MmsWirePduKind::initiate_request)) { + return 1; + } + + if (!mms::MmsPduSpanCodec::try_decode_initiate_response_view( + kInitiateResponse, initiate) || + !valid_initiate(initiate, mms::MmsWirePduKind::initiate_response)) { + return 2; + } + + std::array buffer{}; + const auto initiate_result = + mms::MmsPduSpanCodec::encode_default_initiate_response_into(buffer); + if (!initiate_result.success() || + initiate_result.bytes_written != kInitiateResponse.size() || + !matches( + std::span{buffer}.first( + initiate_result.bytes_written), + kInitiateResponse)) { + return 3; + } + + constexpr std::array confirmed_request_golden{ + 0xA0U, 0x05U, 0x02U, 0x01U, 0x07U, 0xA4U, 0x00U}; + const auto confirmed_request_result = + mms::MmsPduSpanCodec::encode_confirmed_request_into( + 7U, 4, true, {}, buffer); + if (!confirmed_request_result.success() || + confirmed_request_result.bytes_written != confirmed_request_golden.size() || + !matches( + std::span{buffer}.first( + confirmed_request_result.bytes_written), + confirmed_request_golden)) { + return 4; + } + + mms::MmsConfirmedPduView confirmed; + if (!mms::MmsPduSpanCodec::try_decode_confirmed_request_view( + confirmed_request_golden, confirmed) || + confirmed.kind != mms::MmsWirePduKind::confirmed_request || + confirmed.invoke_id != 7U || confirmed.service_tag != 4 || + confirmed.service() != mms::MmsWireConfirmedService::read || + !confirmed.service_constructed || !confirmed.service_value.empty()) { + return 5; + } + + constexpr std::array confirmed_response_golden{ + 0xA1U, 0x05U, 0x02U, 0x01U, 0x07U, 0xA4U, 0x00U}; + const auto confirmed_response_result = + mms::MmsPduSpanCodec::encode_confirmed_response_into( + 7U, 4, true, {}, buffer); + if (!confirmed_response_result.success() || + confirmed_response_result.bytes_written != confirmed_response_golden.size() || + !matches( + std::span{buffer}.first( + confirmed_response_result.bytes_written), + confirmed_response_golden) || + !mms::MmsPduSpanCodec::try_decode_confirmed_response_view( + confirmed_response_golden, confirmed) || + confirmed.kind != mms::MmsWirePduKind::confirmed_response || + confirmed.invoke_id != 7U || confirmed.service_tag != 4) { + return 6; + } + + // Positive ASN.1 INTEGER boundary: max supported invoke ID has no sign + // prefix because its most-significant octet is 0x7F. + constexpr std::array maximum_invoke_golden{ + 0xA0U, 0x08U, 0x02U, 0x04U, + 0x7FU, 0xFFU, 0xFFU, 0xFFU, + 0xA4U, 0x00U}; + const auto maximum_result = mms::MmsPduSpanCodec::encode_confirmed_request_into( + mms::MmsPduSpanCodec::maximum_invoke_id, 4, true, {}, buffer); + if (!maximum_result.success() || + maximum_result.bytes_written != maximum_invoke_golden.size() || + !matches( + std::span{buffer}.first(maximum_result.bytes_written), + maximum_invoke_golden) || + !mms::MmsPduSpanCodec::try_decode_confirmed_request_view( + maximum_invoke_golden, confirmed) || + confirmed.invoke_id != mms::MmsPduSpanCodec::maximum_invoke_id) { + return 7; + } + + // Values beginning with bit 7 set require an explicit positive prefix. + constexpr std::array invoke_128_golden{ + 0xA0U, 0x06U, 0x02U, 0x02U, 0x00U, 0x80U, 0xA4U, 0x00U}; + const auto invoke_128 = mms::MmsPduSpanCodec::encode_confirmed_request_into( + 128U, 4, true, {}, buffer); + if (!invoke_128.success() || invoke_128.bytes_written != invoke_128_golden.size() || + !matches( + std::span{buffer}.first(invoke_128.bytes_written), + invoke_128_golden)) { + return 8; + } + + // Exercise the real post-association path: MMS Confirmed Request wrapped in + // Presentation P-DATA, then recovered as zero-copy spans. + std::array p_data{}; + const auto p_data_result = osi::PresentationSpanCodec::encode_p_data_into( + confirmed_request_golden, p_data, 3U, true); + osi::PresentationPdvView pdv; + if (!p_data_result.success() || + !osi::PresentationSpanCodec::try_decode_p_data_view( + std::span{p_data}.first(p_data_result.bytes_written), + pdv) || + pdv.context_id != 3U || + !mms::MmsPduSpanCodec::try_decode_confirmed_request_view( + pdv.single_asn1_type, confirmed) || + confirmed.invoke_id != 7U || + confirmed.service() != mms::MmsWireConfirmedService::read) { + return 9; + } + + // Repeated application-layer operation must stay inside caller-owned spans. + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto invoke = iteration & 0x7FFFU; + const auto encoded = mms::MmsPduSpanCodec::encode_confirmed_response_into( + invoke, 4, true, {}, buffer); + mms::MmsConfirmedPduView decoded; + if (!encoded.success() || + !mms::MmsPduSpanCodec::try_decode_confirmed_response_view( + std::span{buffer}.first(encoded.bytes_written), + decoded) || + decoded.invoke_id != invoke || decoded.service_tag != 4) { + return 10; + } + } + + constexpr std::array malformed_initiate{ + 0xA8U, 0x03U, 0x80U, 0x01U, 0x01U}; + if (mms::MmsPduSpanCodec::try_decode_initiate_request_view( + malformed_initiate, initiate)) { + return 11; + } + + return 0; +} diff --git a/embedded/mms_static_model_hard_profile_smoke.cpp b/embedded/mms_static_model_hard_profile_smoke.cpp new file mode 100644 index 000000000..f5ccac663 --- /dev/null +++ b/embedded/mms_static_model_hard_profile_smoke.cpp @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kInt32Type{0x85U, 0x01U, 0x20U}; + +constexpr std::array kAttributesRequest{ + 0xA0U, 0x12U, 0x02U, 0x01U, 0x0BU, + 0xA6U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U}; + +constexpr std::array kAttributesResponse{ + 0xA1U, 0x0CU, 0x02U, 0x01U, 0x0BU, + 0xA6U, 0x07U, 0x80U, 0x01U, 0x00U, + 0xA2U, 0x02U, 0x83U, 0x00U}; + +constexpr std::array kReadRequest{ + 0xA0U, 0x27U, 0x02U, 0x01U, 0x0CU, + 0xA4U, 0x22U, 0xA1U, 0x20U, 0xA0U, 0x1EU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x4DU, 0x31U}; + +constexpr std::array kReadResponse{ + 0xA1U, 0x0DU, 0x02U, 0x01U, 0x0CU, + 0xA4U, 0x08U, 0xA1U, 0x06U, + 0x83U, 0x01U, 0xFFU, + 0x85U, 0x01U, 0x2AU}; + +constexpr std::array kLd0{0x4CU, 0x44U, 0x30U}; +constexpr std::array kR1{0x52U, 0x31U}; +constexpr std::array kM1{0x4DU, 0x31U}; +constexpr std::array kBooleanData{0x83U, 0x01U, 0xFFU}; +constexpr std::array kIntData{0x85U, 0x01U, 0x2AU}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto value = *static_cast(context); + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult read_small_int32( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + const auto value = *static_cast(context); + if (value > 0x7FU) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x85U; + destination[1] = 0x01U; + destination[2] = value; + return {wire::EncodeStatus::ok, required, required}; +} + +} // namespace + +int main() { + const bool relay_state = true; + const std::uint8_t meter_value = 42U; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, read_boolean, &relay_state, false}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kInt32Type, read_small_int32, &meter_value, false}}; + const mms::MmsStaticObjectTable table{objects}; + if (!table.valid()) { + return 1; + } + + mms::MmsVariableAccessAttributesRequestView attributes_request; + if (!mms::MmsServiceSpanCodec::try_decode_variable_access_attributes_request( + kAttributesRequest, attributes_request) || + attributes_request.invoke_id != 11U || + attributes_request.name.kind != mms::MmsObjectNameViewKind::domain_specific || + !matches(attributes_request.name.domain, kLd0) || + !matches(attributes_request.name.item, kR1)) { + return 2; + } + const auto* relay = table.find(attributes_request.name); + if (relay == nullptr || relay->type_specification.data() != kBooleanType.data()) { + return 3; + } + + std::array buffer{}; + const auto attributes_encoded = + mms::MmsServiceSpanCodec::encode_variable_access_attributes_response_into( + attributes_request.invoke_id, + relay->mms_deletable, + relay->type_specification, + buffer); + if (!attributes_encoded.success() || + attributes_encoded.bytes_written != kAttributesResponse.size() || + !matches( + std::span{buffer}.first(attributes_encoded.bytes_written), + kAttributesResponse)) { + return 4; + } + + mms::MmsVariableAccessAttributesResponseView attributes_response; + if (!mms::MmsServiceSpanCodec::try_decode_variable_access_attributes_response( + kAttributesResponse, attributes_response) || + attributes_response.invoke_id != 11U || attributes_response.mms_deletable || + !matches(attributes_response.type_specification, kBooleanType)) { + return 5; + } + + mms::MmsReadRequestView read_request; + if (!mms::MmsServiceSpanCodec::try_decode_read_request(kReadRequest, read_request) || + read_request.invoke_id != 12U || read_request.specification_with_result || + read_request.variable_count != 2U) { + return 6; + } + + std::array resolved{}; + std::size_t resolved_count = 0U; + if (!table.try_resolve_read_request(read_request, resolved, resolved_count) || + resolved_count != 2U || resolved[0] != &objects[0] || resolved[1] != &objects[1]) { + return 7; + } + + mms::MmsObjectNameView variable; + if (!read_request.try_variable(0U, variable) || !matches(variable.item, kR1) || + !read_request.try_variable(1U, variable) || !matches(variable.item, kM1) || + read_request.try_variable(2U, variable)) { + return 8; + } + + std::array relay_data{}; + std::array meter_data{}; + const auto relay_read = resolved[0]->read(resolved[0]->context, relay_data); + const auto meter_read = resolved[1]->read(resolved[1]->context, meter_data); + if (!relay_read.success() || !meter_read.success() || + !matches( + std::span{relay_data}.first(relay_read.bytes_written), + kBooleanData) || + !matches( + std::span{meter_data}.first(meter_read.bytes_written), + kIntData)) { + return 9; + } + + const std::array results{ + mms::MmsReadAccessResultInput{ + true, + std::span{relay_data}.first(relay_read.bytes_written), + 0U}, + mms::MmsReadAccessResultInput{ + true, + std::span{meter_data}.first(meter_read.bytes_written), + 0U}}; + const auto read_encoded = mms::MmsServiceSpanCodec::encode_read_response_into( + read_request.invoke_id, results, buffer); + if (!read_encoded.success() || read_encoded.bytes_written != kReadResponse.size() || + !matches( + std::span{buffer}.first(read_encoded.bytes_written), + kReadResponse)) { + return 10; + } + + mms::MmsReadResponseView read_response; + if (!mms::MmsServiceSpanCodec::try_decode_read_response( + kReadResponse, read_response) || + read_response.invoke_id != 12U || read_response.result_count != 2U) { + return 11; + } + mms::MmsReadAccessResultView access; + if (!read_response.try_result(0U, access) || !access.success || + !matches(access.encoded_data, kBooleanData) || + !read_response.try_result(1U, access) || !access.success || + !matches(access.encoded_data, kIntData) || + read_response.try_result(2U, access)) { + return 12; + } + + // Failure results remain bounded and explicit; no exception or owning object + // graph is required to return an MMS DataAccessError. + const std::array failed{ + mms::MmsReadAccessResultInput{false, {}, 10U}}; + const auto failed_encoded = mms::MmsServiceSpanCodec::encode_read_response_into( + 13U, failed, buffer); + if (!failed_encoded.success() || + !mms::MmsServiceSpanCodec::try_decode_read_response( + std::span{buffer}.first(failed_encoded.bytes_written), + read_response) || + read_response.result_count != 1U || + !read_response.try_result(0U, access) || access.success || access.failure_code != 10U) { + return 13; + } + + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto invoke = iteration & 0x7FFFU; + const auto encoded = mms::MmsServiceSpanCodec::encode_read_response_into( + invoke, results, buffer); + mms::MmsReadResponseView decoded; + if (!encoded.success() || + !mms::MmsServiceSpanCodec::try_decode_read_response( + std::span{buffer}.first(encoded.bytes_written), + decoded) || + decoded.invoke_id != invoke || decoded.result_count != 2U) { + return 14; + } + } + + const std::array duplicates{ + objects[0], objects[0]}; + if (mms::MmsStaticObjectTable{duplicates}.valid()) { + return 15; + } + + return 0; +} diff --git a/embedded/mms_write_hard_profile_smoke.cpp b/embedded/mms_write_hard_profile_smoke.cpp new file mode 100644 index 000000000..7be9e886a --- /dev/null +++ b/embedded/mms_write_hard_profile_smoke.cpp @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kInt32Type{0x85U, 0x01U, 0x20U}; + +constexpr std::array kWriteRequest{ + 0xA0U, 0x1BU, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x16U, + 0xA0U, 0x0FU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0xA0U, 0x03U, 0x83U, 0x01U, 0x00U}; + +constexpr std::array kWriteSuccessResponse{ + 0xA1U, 0x07U, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x02U, 0x81U, 0x00U}; + +constexpr std::array kWriteFailureResponse{ + 0xA1U, 0x08U, 0x02U, 0x01U, 0x0FU, + 0xA5U, 0x03U, 0x80U, 0x01U, 0x03U}; + +constexpr std::array kLd0{0x4CU, 0x44U, 0x30U}; +constexpr std::array kR1{0x52U, 0x31U}; +constexpr std::array kM1{0x4DU, 0x31U}; +constexpr std::array kFalseData{0x83U, 0x01U, 0x00U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto value = *static_cast(context); + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult read_small_int32( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + const auto value = *static_cast(context); + if (value > 0x7FU) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x85U; + destination[1] = 0x01U; + destination[2] = value; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] mms::MmsStaticWriteResult write_boolean( + void* context, + const std::span encoded_data) noexcept { + if (context == nullptr || encoded_data.size() != 3U || + encoded_data[0] != 0x83U || encoded_data[1] != 0x01U || + (encoded_data[2] != 0x00U && encoded_data[2] != 0xFFU)) { + return {false, 7U}; + } + *static_cast(context) = encoded_data[2] != 0x00U; + return {true, 0U}; +} + +} // namespace + +int main() { + bool relay_state = true; + const std::uint8_t meter_value = 42U; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, + read_boolean, &relay_state, false, + write_boolean, &relay_state}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kInt32Type, + read_small_int32, &meter_value, false}}; + const mms::MmsStaticObjectTable table{objects}; + if (!table.valid() || !objects[0].writable() || objects[1].writable()) { + return 1; + } + + mms::MmsWriteRequestView request; + if (!mms::MmsServiceSpanCodec::try_decode_write_request(kWriteRequest, request) || + request.invoke_id != 14U || request.variable_count != 1U || request.data_count != 1U) { + return 2; + } + mms::MmsObjectNameView name; + std::span value; + if (!request.try_variable(0U, name) || + name.kind != mms::MmsObjectNameViewKind::domain_specific || + !matches(name.domain, kLd0) || !matches(name.item, kR1) || + !request.try_value(0U, value) || !matches(value, kFalseData) || + request.try_variable(1U, name) || request.try_value(1U, value)) { + return 3; + } + + std::array resolved{}; + std::size_t resolved_count = 0U; + if (!table.try_resolve_write_request(request, resolved, resolved_count) || + resolved_count != 1U || resolved[0] != &objects[0] || !resolved[0]->writable()) { + return 4; + } + if (!request.try_value(0U, value)) { + return 5; + } + const auto write_result = resolved[0]->write(resolved[0]->write_context, value); + if (!write_result.success || relay_state) { + return 6; + } + + std::array buffer{}; + const std::array success{ + mms::MmsWriteAccessResultInput{true, 0U}}; + const auto success_encoded = mms::MmsServiceSpanCodec::encode_write_response_into( + request.invoke_id, success, buffer); + if (!success_encoded.success() || + success_encoded.bytes_written != kWriteSuccessResponse.size() || + !matches( + std::span{buffer}.first(success_encoded.bytes_written), + kWriteSuccessResponse)) { + return 7; + } + + mms::MmsWriteResponseView response; + mms::MmsWriteAccessResultView access; + if (!mms::MmsServiceSpanCodec::try_decode_write_response( + kWriteSuccessResponse, response) || + response.invoke_id != 14U || response.result_count != 1U || + !response.try_result(0U, access) || !access.success || + response.try_result(1U, access)) { + return 8; + } + + auto read_only_request = kWriteRequest; + read_only_request[4] = 0x0FU; + read_only_request[22] = 0x4DU; + if (!mms::MmsServiceSpanCodec::try_decode_write_request( + read_only_request, request) || + !request.try_variable(0U, name) || !matches(name.item, kM1) || + !table.try_resolve_write_request(request, resolved, resolved_count) || + resolved_count != 1U || resolved[0] != &objects[1] || resolved[0]->writable()) { + return 9; + } + + const std::array denied{ + mms::MmsWriteAccessResultInput{false, 3U}}; + const auto denied_encoded = mms::MmsServiceSpanCodec::encode_write_response_into( + request.invoke_id, denied, buffer); + if (!denied_encoded.success() || + denied_encoded.bytes_written != kWriteFailureResponse.size() || + !matches( + std::span{buffer}.first(denied_encoded.bytes_written), + kWriteFailureResponse) || + !mms::MmsServiceSpanCodec::try_decode_write_response( + kWriteFailureResponse, response) || + !response.try_result(0U, access) || access.success || access.failure_code != 3U) { + return 10; + } + + auto bad_type_request = kWriteRequest; + bad_type_request[26] = 0x85U; + if (!mms::MmsServiceSpanCodec::try_decode_write_request( + bad_type_request, request) || + !table.try_resolve_write_request(request, resolved, resolved_count) || + resolved[0] == nullptr || !resolved[0]->writable() || + !request.try_value(0U, value)) { + return 11; + } + const auto rejected = resolved[0]->write(resolved[0]->write_context, value); + if (rejected.success || rejected.failure_code != 7U || relay_state) { + return 12; + } + + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto invoke = iteration & 0x7FFFU; + const auto encoded = mms::MmsServiceSpanCodec::encode_write_response_into( + invoke, (iteration & 1U) == 0U ? success : denied, buffer); + mms::MmsWriteResponseView decoded; + if (!encoded.success() || + !mms::MmsServiceSpanCodec::try_decode_write_response( + std::span{buffer}.first(encoded.bytes_written), + decoded) || + decoded.invoke_id != invoke || decoded.result_count != 1U || + !decoded.try_result(0U, access) || + access.success != ((iteration & 1U) == 0U)) { + return 13; + } + } + + return 0; +} diff --git a/embedded/osi_hard_profile_smoke.cpp b/embedded/osi_hard_profile_smoke.cpp new file mode 100644 index 000000000..cb4ff0d17 --- /dev/null +++ b/embedded/osi_hard_profile_smoke.cpp @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850::osi; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] bool parameter_matches( + const CotpTpduView& tpdu, + const std::uint8_t code, + const std::span expected) noexcept { + std::span value; + return tpdu.try_parameter(code, value) && + value.size() == expected.size() && + std::equal(value.begin(), value.end(), expected.begin()); +} + +} // namespace + +int main() { + // Existing desktop golden vector: TPKT wrapping a COTP Data TPDU prefix. + constexpr std::array tpkt_payload{ + 0x02U, 0xF0U, 0x80U, 0xA8U, 0x01U, 0x00U}; + constexpr std::array tpkt_golden{ + 0x03U, 0x00U, 0x00U, 0x0AU, + 0x02U, 0xF0U, 0x80U, 0xA8U, 0x01U, 0x00U}; + + std::array tpkt_buffer{}; + const auto tpkt_result = TpktSpanCodec::encode_into(tpkt_payload, tpkt_buffer); + if (!tpkt_result.success() || tpkt_result.bytes_written != tpkt_golden.size() || + !matches( + std::span{tpkt_buffer}.first(tpkt_result.bytes_written), + tpkt_golden)) { + return 1; + } + + TpktFrameView tpkt_view; + if (!TpktSpanCodec::try_decode_view(tpkt_golden, tpkt_view) || + tpkt_view.version != TpktSpanCodec::supported_version || + tpkt_view.declared_length != tpkt_golden.size() || + !matches(tpkt_view.payload, tpkt_payload)) { + return 2; + } + + if (TpktSpanCodec::peek_frame( + std::span{tpkt_golden}.first(2U)).status != + TpktPeekStatus::need_more) { + return 3; + } + + std::array coalesced{}; + std::copy(tpkt_golden.begin(), tpkt_golden.end(), coalesced.begin()); + coalesced.back() = 0xFFU; + const auto peek = TpktSpanCodec::peek_frame(coalesced); + if (!peek.ready() || peek.frame_bytes != tpkt_golden.size()) { + return 4; + } + + constexpr std::array invalid_tpkt{ + 0x02U, 0x00U, 0x00U, 0x04U}; + if (TpktSpanCodec::peek_frame(invalid_tpkt).status != TpktPeekStatus::invalid || + TpktSpanCodec::try_decode_view(invalid_tpkt, tpkt_view)) { + return 5; + } + + // Existing desktop golden vector: default COTP Connection Request. + constexpr std::array tpdu_size{0x0AU}; + constexpr std::array source_tsap{0x00U, 0x01U}; + constexpr std::array destination_tsap{0x00U, 0x01U}; + const std::array default_parameters{ + CotpParameterView{CotpSpanCodec::tpdu_size_parameter, tpdu_size}, + CotpParameterView{CotpSpanCodec::source_tsap_parameter, source_tsap}, + CotpParameterView{CotpSpanCodec::destination_tsap_parameter, destination_tsap}, + }; + constexpr std::array cr_golden{ + 0x11U, 0xE0U, 0x00U, 0x00U, 0x00U, 0x01U, 0x00U, + 0xC0U, 0x01U, 0x0AU, + 0xC1U, 0x02U, 0x00U, 0x01U, + 0xC2U, 0x02U, 0x00U, 0x01U}; + + std::array cr_buffer{}; + const auto cr_result = CotpSpanCodec::encode_connection_request_into( + 0x0001U, default_parameters, cr_buffer); + if (!cr_result.success() || cr_result.bytes_written != cr_golden.size() || + !matches( + std::span{cr_buffer}.first(cr_result.bytes_written), + cr_golden)) { + return 6; + } + + CotpTpduView cr_view; + if (!CotpSpanCodec::try_decode_view(cr_golden, cr_view) || + cr_view.kind != CotpWireKind::connection_request || + cr_view.destination_reference != 0x0000U || + cr_view.source_reference != 0x0001U || + !parameter_matches(cr_view, CotpSpanCodec::tpdu_size_parameter, tpdu_size) || + !parameter_matches(cr_view, CotpSpanCodec::source_tsap_parameter, source_tsap) || + !parameter_matches( + cr_view, CotpSpanCodec::destination_tsap_parameter, destination_tsap)) { + return 7; + } + + // Server-side CC must negotiate TPDU size and mirror TSAP values without + // constructing an owning parameter list. + constexpr std::array offered_size{0x09U}; + constexpr std::array offered_source{0x11U, 0x22U}; + constexpr std::array offered_destination{0x33U, 0x44U, 0x55U}; + const std::array offered_parameters{ + CotpParameterView{CotpSpanCodec::tpdu_size_parameter, offered_size}, + CotpParameterView{CotpSpanCodec::source_tsap_parameter, offered_source}, + CotpParameterView{CotpSpanCodec::destination_tsap_parameter, offered_destination}, + }; + + std::array request_buffer{}; + const auto request_result = CotpSpanCodec::encode_connection_request_into( + 0x0001U, offered_parameters, request_buffer); + CotpTpduView request_view; + if (!request_result.success() || + !CotpSpanCodec::try_decode_view( + std::span{request_buffer}.first(request_result.bytes_written), + request_view)) { + return 8; + } + + constexpr std::array cc_golden{ + 0x12U, 0xD0U, 0x00U, 0x01U, 0x10U, 0x01U, 0x00U, + 0xC0U, 0x01U, 0x09U, + 0xC2U, 0x02U, 0x11U, 0x22U, + 0xC1U, 0x03U, 0x33U, 0x44U, 0x55U}; + std::array cc_buffer{}; + const auto cc_result = CotpSpanCodec::encode_connection_confirm_from_request_into( + request_view, 0x1001U, cc_buffer, 0x0AU); + if (!cc_result.success() || cc_result.bytes_written != cc_golden.size() || + !matches( + std::span{cc_buffer}.first(cc_result.bytes_written), + cc_golden)) { + return 9; + } + + CotpTpduView cc_view; + if (!CotpSpanCodec::try_decode_view(cc_golden, cc_view) || + cc_view.kind != CotpWireKind::connection_confirm || + cc_view.destination_reference != 0x0001U || + cc_view.source_reference != 0x1001U || + !parameter_matches(cc_view, CotpSpanCodec::tpdu_size_parameter, offered_size) || + !parameter_matches(cc_view, CotpSpanCodec::source_tsap_parameter, offered_destination) || + !parameter_matches(cc_view, CotpSpanCodec::destination_tsap_parameter, offered_source)) { + return 10; + } + + constexpr std::array cotp_user_data{ + 0x0DU, 0x01U, 0x02U, 0x03U}; + constexpr std::array cotp_data_golden{ + 0x02U, 0xF0U, 0x80U, 0x0DU, 0x01U, 0x02U, 0x03U}; + std::array cotp_data_buffer{}; + const auto data_result = CotpSpanCodec::encode_data_into( + cotp_user_data, cotp_data_buffer); + if (!data_result.success() || data_result.bytes_written != cotp_data_golden.size() || + !matches( + std::span{cotp_data_buffer}.first(data_result.bytes_written), + cotp_data_golden)) { + return 11; + } + + CotpTpduView data_view; + if (!CotpSpanCodec::try_decode_view(cotp_data_golden, data_view) || + data_view.kind != CotpWireKind::data || + !data_view.end_of_transmission || data_view.tpdu_number != 0U || + !matches(data_view.user_data, cotp_user_data)) { + return 12; + } + + // Complete zero-copy transport stack: COTP Data -> TPKT -> view decode -> + // COTP view decode, retaining the same caller-owned storage throughout. + std::array transport_buffer{}; + const auto transport_result = TpktSpanCodec::encode_into( + std::span{cotp_data_buffer}.first(data_result.bytes_written), + transport_buffer); + TpktFrameView transport_view; + CotpTpduView nested_data_view; + if (!transport_result.success() || + !TpktSpanCodec::try_decode_view( + std::span{transport_buffer}.first( + transport_result.bytes_written), + transport_view) || + !CotpSpanCodec::try_decode_view(transport_view.payload, nested_data_view) || + !matches(nested_data_view.user_data, cotp_user_data)) { + return 13; + } + + // Exercise the steady-state bounded path repeatedly. No owning container is + // constructed by either codec during these encode/decode iterations. + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto encoded = CotpSpanCodec::encode_data_into( + cotp_user_data, cotp_data_buffer, true, + static_cast(iteration & 0x7FU)); + if (!encoded.success()) { + return 14; + } + CotpTpduView decoded; + if (!CotpSpanCodec::try_decode_view( + std::span{cotp_data_buffer}.first( + encoded.bytes_written), + decoded) || + decoded.tpdu_number != static_cast(iteration & 0x7FU)) { + return 15; + } + } + + constexpr std::array malformed_parameter{ + 0x09U, 0xE0U, 0x00U, 0x00U, 0x00U, 0x01U, 0x00U, + 0xC0U, 0x02U, 0x0AU}; + if (CotpSpanCodec::try_decode_view(malformed_parameter, cr_view)) { + return 16; + } + + return 0; +} diff --git a/embedded/session_presentation_hard_profile_smoke.cpp b/embedded/session_presentation_hard_profile_smoke.cpp new file mode 100644 index 000000000..c43cde56e --- /dev/null +++ b/embedded/session_presentation_hard_profile_smoke.cpp @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] bool parameter_matches( + const osi::SessionSpduView& spdu, + const std::uint8_t code, + const std::span expected) noexcept { + std::span value; + return spdu.try_parameter(code, value) && + value.size() == expected.size() && + std::equal(value.begin(), value.end(), expected.begin()); +} + +} // namespace + +int main() { + constexpr std::array mms_payload{ + 0xA0U, 0x08U, 0x02U, 0x01U, 0x07U, + 0xA4U, 0x03U, 0x80U, 0x01U, 0x00U}; + constexpr std::array presentation_golden{ + 0x61U, 0x11U, 0x30U, 0x0FU, + 0x02U, 0x01U, 0x03U, + 0xA0U, 0x0AU, + 0xA0U, 0x08U, 0x02U, 0x01U, 0x07U, + 0xA4U, 0x03U, 0x80U, 0x01U, 0x00U}; + + // BER reader stays zero-copy and rejects indefinite-length BER. + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(presentation_golden, outer) || + outer.tag_class != asn1::BerClass::application || + outer.tag_number != 1 || !outer.constructed) { + return 1; + } + constexpr std::array indefinite{ + 0x61U, 0x80U, 0x00U, 0x00U}; + if (asn1::BerSpanReader::try_read_exact(indefinite, outer)) { + return 2; + } + + // Presentation fully-encoded-data must match the existing host encoding. + std::array presentation_buffer{}; + const auto presentation_result = + osi::PresentationSpanCodec::encode_fully_encoded_data_into( + 3U, mms_payload, presentation_buffer); + if (!presentation_result.success() || + presentation_result.bytes_written != presentation_golden.size() || + !matches( + std::span{presentation_buffer}.first( + presentation_result.bytes_written), + presentation_golden)) { + return 3; + } + + osi::PresentationPdvView pdv; + if (!osi::PresentationSpanCodec::try_decode_fully_encoded_data_view( + presentation_golden, pdv) || + pdv.context_id != 3U || !matches(pdv.single_asn1_type, mms_payload)) { + return 4; + } + + // Default Session parameters are byte-identical to the established host + // profile. The bounded encoder appends Presentation bytes as final C1 data. + constexpr std::array parameter05{ + 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U}; + constexpr std::array parameter14{0x00U, 0x02U}; + constexpr std::array parameter33{0x00U, 0x01U}; + constexpr std::array parameter34{0x00U, 0x01U}; + constexpr std::array session_connect_golden{ + 0x0DU, 0x29U, + 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, + 0x14U, 0x02U, 0x00U, 0x02U, + 0x33U, 0x02U, 0x00U, 0x01U, + 0x34U, 0x02U, 0x00U, 0x01U, + 0xC1U, 0x13U, + 0x61U, 0x11U, 0x30U, 0x0FU, + 0x02U, 0x01U, 0x03U, + 0xA0U, 0x0AU, + 0xA0U, 0x08U, 0x02U, 0x01U, 0x07U, + 0xA4U, 0x03U, 0x80U, 0x01U, 0x00U}; + + std::array session_buffer{}; + const auto connect_result = osi::SessionSpanCodec::encode_connect_into( + presentation_golden, session_buffer); + if (!connect_result.success() || + connect_result.bytes_written != session_connect_golden.size() || + !matches( + std::span{session_buffer}.first( + connect_result.bytes_written), + session_connect_golden)) { + return 5; + } + + osi::SessionSpduView connect_view; + if (!osi::SessionSpanCodec::try_decode_view( + session_connect_golden, connect_view) || + connect_view.kind != osi::SessionWireKind::connect || + !matches(connect_view.user_data, presentation_golden) || + !parameter_matches(connect_view, 0x05U, parameter05) || + !parameter_matches(connect_view, 0x14U, parameter14) || + !parameter_matches(connect_view, 0x33U, parameter33) || + !parameter_matches(connect_view, 0x34U, parameter34)) { + return 6; + } + + constexpr std::array session_accept_golden{ + 0x0EU, 0x29U, + 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, + 0x14U, 0x02U, 0x00U, 0x02U, + 0x33U, 0x02U, 0x00U, 0x01U, + 0x34U, 0x02U, 0x00U, 0x01U, + 0xC1U, 0x13U, + 0x61U, 0x11U, 0x30U, 0x0FU, + 0x02U, 0x01U, 0x03U, + 0xA0U, 0x0AU, + 0xA0U, 0x08U, 0x02U, 0x01U, 0x07U, + 0xA4U, 0x03U, 0x80U, 0x01U, 0x00U}; + const auto accept_result = osi::SessionSpanCodec::encode_accept_mirroring_into( + connect_view, presentation_golden, session_buffer); + if (!accept_result.success() || + accept_result.bytes_written != session_accept_golden.size() || + !matches( + std::span{session_buffer}.first( + accept_result.bytes_written), + session_accept_golden)) { + return 7; + } + + // Existing Session data-transfer profile: optional Give-Tokens prefix plus + // the Data-Transfer SPDU, followed directly by Presentation bytes. + constexpr std::array p_data_golden{ + 0x01U, 0x00U, 0x01U, 0x00U, + 0x61U, 0x11U, 0x30U, 0x0FU, + 0x02U, 0x01U, 0x03U, + 0xA0U, 0x0AU, + 0xA0U, 0x08U, 0x02U, 0x01U, 0x07U, + 0xA4U, 0x03U, 0x80U, 0x01U, 0x00U}; + + const auto p_data_result = osi::PresentationSpanCodec::encode_p_data_into( + mms_payload, presentation_buffer, 3U, true); + if (!p_data_result.success() || + p_data_result.bytes_written != p_data_golden.size() || + !matches( + std::span{presentation_buffer}.first( + p_data_result.bytes_written), + p_data_golden)) { + return 8; + } + + if (!osi::PresentationSpanCodec::try_decode_p_data_view(p_data_golden, pdv) || + pdv.context_id != 3U || !matches(pdv.single_asn1_type, mms_payload)) { + return 9; + } + + osi::SessionDataTransferView transfer; + if (!osi::SessionSpanCodec::try_decode_data_transfer_view( + p_data_golden, transfer) || + !transfer.has_give_tokens_prefix || + !matches(transfer.presentation_payload, presentation_golden)) { + return 10; + } + + // Direct Presentation decode remains accepted when the Session wrapper has + // already been removed by the transport state machine. + if (!osi::PresentationSpanCodec::try_decode_p_data_view( + presentation_golden, pdv) || + pdv.context_id != 3U || !matches(pdv.single_asn1_type, mms_payload)) { + return 11; + } + + // Steady-state encode/decode regression: no owning container is created by + // BER, Session, or Presentation bounded APIs. + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto context_id = (iteration & 1U) == 0U ? 3U : 5U; + const auto encoded = osi::PresentationSpanCodec::encode_p_data_into( + mms_payload, presentation_buffer, context_id, true); + if (!encoded.success()) { + return 12; + } + osi::PresentationPdvView decoded; + if (!osi::PresentationSpanCodec::try_decode_p_data_view( + std::span{presentation_buffer}.first( + encoded.bytes_written), + decoded) || + decoded.context_id != context_id || + !matches(decoded.single_asn1_type, mms_payload)) { + return 13; + } + } + + return 0; +} diff --git a/include/ariec61850/acse/association_span.hpp b/include/ariec61850/acse/association_span.hpp new file mode 100644 index 000000000..b18d1d7ea --- /dev/null +++ b/include/ariec61850/acse/association_span.hpp @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::acse { + +struct AcseExternalView final { + std::span direct_reference{}; + std::uint32_t indirect_reference{}; + std::span single_asn1_type{}; +}; + +struct AcseAarqView final { + std::span application_context_name{}; + std::span called_ap_title{}; + std::optional called_ae_qualifier{}; + std::span calling_ap_title{}; + std::optional calling_ae_qualifier{}; + AcseExternalView user_information{}; +}; + +struct AssociationRequestView final { + osi::SessionSpduView session{}; + osi::PresentationCpView presentation{}; + AcseAarqView aarq{}; + std::uint32_t acse_presentation_context_id{}; + std::uint32_t mms_presentation_context_id{}; +}; + +class AcseSpanCodec final { +public: + static constexpr std::size_t maximum_acse_bytes = 1U * 1024U * 1024U; + static constexpr std::size_t maximum_oid_bytes = 64U; + + [[nodiscard]] static std::span + mms_application_context_name() noexcept; + [[nodiscard]] static std::span + default_mms_initiate_request() noexcept; + [[nodiscard]] static std::span + default_mms_initiate_response() noexcept; + + [[nodiscard]] static bool try_decode_aarq_view( + std::span bytes, + AcseAarqView& aarq) noexcept; + + [[nodiscard]] static bool try_decode_association_request_view( + std::span bytes, + AssociationRequestView& request) noexcept; + + [[nodiscard]] static constexpr std::size_t default_accept_aare_size() noexcept { + return 76U; + } + + [[nodiscard]] static wire::EncodeResult encode_default_accept_aare_into( + std::span destination) noexcept; + + [[nodiscard]] static std::optional accept_response_size( + const AssociationRequestView& request) noexcept; + + [[nodiscard]] static wire::EncodeResult build_accept_response_into( + const AssociationRequestView& request, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::acse diff --git a/include/ariec61850/asn1/ber_span_reader.hpp b/include/ariec61850/asn1/ber_span_reader.hpp new file mode 100644 index 000000000..82b58965b --- /dev/null +++ b/include/ariec61850/asn1/ber_span_reader.hpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/asn1/ber_types.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::asn1 { + +struct BerTlvView final { + std::uint8_t encoded_tag{}; + BerClass tag_class{BerClass::universal}; + bool constructed{}; + std::int32_t tag_number{}; + std::size_t header_bytes{}; + std::size_t encoded_bytes{}; + std::span value{}; +}; + +class BerSpanReader final { +public: + // Read one definite-length BER TLV without allocating or copying its value. + // The input offset is advanced only on success. + [[nodiscard]] static bool try_read_tlv( + std::span source, + std::size_t& offset, + BerTlvView& tlv) noexcept; + + [[nodiscard]] static bool try_read_exact( + std::span source, + BerTlvView& tlv) noexcept; + + [[nodiscard]] static std::optional read_unsigned_integer( + const BerTlvView& tlv) noexcept; + + [[nodiscard]] static std::optional read_signed_integer( + const BerTlvView& tlv) noexcept; + + [[nodiscard]] static std::optional read_uint32( + const BerTlvView& tlv) noexcept; +}; + +} // namespace ar::iec61850::asn1 diff --git a/include/ariec61850/asn1/ber_span_writer.hpp b/include/ariec61850/asn1/ber_span_writer.hpp new file mode 100644 index 000000000..f169ea7e5 --- /dev/null +++ b/include/ariec61850/asn1/ber_span_writer.hpp @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/asn1/ber_types.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::asn1 { + +class BerSpanWriter final { +public: + explicit BerSpanWriter(std::span destination) noexcept + : destination_{destination} {} + + [[nodiscard]] static std::optional identifier_octets( + const std::int32_t tag_number) noexcept { + if (tag_number < 0) { + return std::nullopt; + } + if (tag_number <= 30) { + return 1U; + } + + auto remaining = static_cast(tag_number); + std::size_t groups = 0U; + do { + ++groups; + remaining >>= 7U; + } while (remaining != 0U); + return 1U + groups; + } + + [[nodiscard]] static std::optional length_octets( + const std::size_t value_length) noexcept { + if (value_length < 0x80U) { + return 1U; + } + if (value_length > std::numeric_limits::max()) { + return std::nullopt; + } + + auto value = static_cast(value_length); + std::size_t bytes = 0U; + while (value != 0U) { + ++bytes; + value >>= 8U; + } + return 1U + bytes; + } + + [[nodiscard]] static std::optional tlv_size( + const std::int32_t tag_number, + const std::size_t value_length) noexcept { + const auto identifier = identifier_octets(tag_number); + const auto length = length_octets(value_length); + if (!identifier || !length) { + return std::nullopt; + } + if (*length > std::numeric_limits::max() - *identifier) { + return std::nullopt; + } + const auto header = *identifier + *length; + if (value_length > std::numeric_limits::max() - header) { + return std::nullopt; + } + return header + value_length; + } + + [[nodiscard]] bool write_identifier( + const BerClass tag_class, + const bool constructed, + const std::int32_t tag_number) noexcept { + if (tag_number < 0) { + failed_ = true; + return false; + } + + const auto class_bits = static_cast( + static_cast(tag_class) << 6U); + const auto constructed_bit = constructed ? std::uint8_t{0x20U} : std::uint8_t{0U}; + if (tag_number <= 30) { + return write_byte(static_cast( + class_bits | constructed_bit | static_cast(tag_number))); + } + + if (!write_byte(static_cast(class_bits | constructed_bit | 0x1FU))) { + return false; + } + + std::array groups{}; + std::size_t count = 0U; + auto remaining = static_cast(tag_number); + do { + if (count >= groups.size()) { + failed_ = true; + return false; + } + groups[count++] = static_cast(remaining & 0x7FU); + remaining >>= 7U; + } while (remaining != 0U); + + for (std::size_t index = count; index-- > 0U;) { + auto byte = groups[index]; + if (index != 0U) { + byte = static_cast(byte | 0x80U); + } + if (!write_byte(byte)) { + return false; + } + } + return true; + } + + [[nodiscard]] bool write_length(const std::size_t value_length) noexcept { + if (value_length < 0x80U) { + return write_byte(static_cast(value_length)); + } + if (value_length > std::numeric_limits::max()) { + failed_ = true; + return false; + } + + std::array bytes{}; + auto value = static_cast(value_length); + std::size_t count = 0U; + while (value != 0U) { + bytes[bytes.size() - 1U - count] = static_cast(value & 0xFFU); + value >>= 8U; + ++count; + } + if (!write_byte(static_cast(0x80U | count))) { + return false; + } + return write_bytes(std::span{bytes}.last(count)); + } + + [[nodiscard]] bool write_tlv_header( + const BerClass tag_class, + const bool constructed, + const std::int32_t tag_number, + const std::size_t value_length) noexcept { + return write_identifier(tag_class, constructed, tag_number) && + write_length(value_length); + } + + [[nodiscard]] bool write_tlv( + const BerClass tag_class, + const bool constructed, + const std::int32_t tag_number, + const std::span value) noexcept { + return write_tlv_header(tag_class, constructed, tag_number, value.size()) && + write_bytes(value); + } + + [[nodiscard]] bool write_bytes(const std::span value) noexcept { + if (failed_ || value.size() > remaining()) { + failed_ = true; + return false; + } + std::copy(value.begin(), value.end(), destination_.begin() + + static_cast(offset_)); + offset_ += value.size(); + return true; + } + + [[nodiscard]] bool write_byte(const std::uint8_t value) noexcept { + if (failed_ || offset_ >= destination_.size()) { + failed_ = true; + return false; + } + destination_[offset_++] = value; + return true; + } + + [[nodiscard]] std::size_t size() const noexcept { return offset_; } + [[nodiscard]] std::size_t remaining() const noexcept { + return offset_ <= destination_.size() ? destination_.size() - offset_ : 0U; + } + [[nodiscard]] bool good() const noexcept { return !failed_; } + +private: + std::span destination_; + std::size_t offset_{}; + bool failed_{}; +}; + +} // namespace ar::iec61850::asn1 diff --git a/include/ariec61850/asn1/ber_types.hpp b/include/ariec61850/asn1/ber_types.hpp new file mode 100644 index 000000000..b4b3a5c4a --- /dev/null +++ b/include/ariec61850/asn1/ber_types.hpp @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +namespace ar::iec61850::asn1 { + +enum class BerClass : std::uint8_t { + universal = 0, + application = 1, + context_specific = 2, + private_class = 3 +}; + +} // namespace ar::iec61850::asn1 diff --git a/include/ariec61850/mms/connection_runtime.hpp b/include/ariec61850/mms/connection_runtime.hpp new file mode 100644 index 000000000..8cb2ddbc8 --- /dev/null +++ b/include/ariec61850/mms/connection_runtime.hpp @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_dispatcher.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticConnectionState : std::uint8_t { + awaiting_cotp_connect, + awaiting_association, + established, + closed, + fault, +}; + +enum class MmsStaticConnectionStatus : std::uint8_t { + need_more, + response_ready, + consumed_no_response, + malformed_transport, + protocol_violation, + application_rejected, + response_buffer_too_small, + workspace_too_small, + backend_failure, + closed, +}; + +struct MmsStaticConnectionPolicy final { + std::uint16_t cotp_source_reference{0x1001U}; + std::uint8_t maximum_tpdu_size_code{0x0AU}; + bool require_end_of_transmission{true}; +}; + +struct MmsStaticConnectionResult final { + MmsStaticConnectionStatus status{MmsStaticConnectionStatus::need_more}; + MmsStaticConnectionState state{MmsStaticConnectionState::awaiting_cotp_connect}; + std::size_t consumed_bytes{}; + std::size_t bytes_written{}; + std::size_t required_response_bytes{}; + std::size_t required_workspace_bytes{}; + MmsStaticDispatchStatus application_status{MmsStaticDispatchStatus::malformed_request}; + MmsWireConfirmedService application_service{MmsWireConfirmedService::unknown}; + std::uint32_t invoke_id{}; + + [[nodiscard]] constexpr bool response_ready() const noexcept { + return status == MmsStaticConnectionStatus::response_ready; + } +}; + +class MmsStaticConnectionRuntime final { +public: + explicit constexpr MmsStaticConnectionRuntime( + const MmsStaticApplicationDispatcher& dispatcher, + const MmsStaticConnectionPolicy policy = {}) noexcept + : dispatcher_{dispatcher}, policy_{policy} {} + + // Process at most the first complete TPKT frame in a TCP receive window. + // `consumed_bytes` identifies the prefix the transport adapter may remove. + // On need_more or buffer-capacity failures no input is consumed, allowing + // the same frame to be retried once more bytes/capacity are available. + [[nodiscard]] MmsStaticConnectionResult process_tcp_window( + std::span tcp_bytes, + std::span response, + std::span workspace) noexcept; + + constexpr void reset() noexcept { + state_ = MmsStaticConnectionState::awaiting_cotp_connect; + mms_presentation_context_id_ = 0U; + } + + [[nodiscard]] constexpr MmsStaticConnectionState state() const noexcept { + return state_; + } + + [[nodiscard]] constexpr std::uint32_t mms_presentation_context_id() const noexcept { + return mms_presentation_context_id_; + } + +private: + const MmsStaticApplicationDispatcher& dispatcher_; + MmsStaticConnectionPolicy policy_{}; + MmsStaticConnectionState state_{MmsStaticConnectionState::awaiting_cotp_connect}; + std::uint32_t mms_presentation_context_id_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/data_set_span.hpp b/include/ariec61850/mms/data_set_span.hpp new file mode 100644 index 000000000..fd40adbf9 --- /dev/null +++ b/include/ariec61850/mms/data_set_span.hpp @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +struct MmsNamedVariableListAttributesRequestView final { + std::uint32_t invoke_id{}; + MmsObjectNameView name{}; +}; + +struct MmsNamedVariableListAttributesResponseView final { + std::uint32_t invoke_id{}; + bool mms_deletable{}; + std::span variable_list{}; + std::size_t member_count{}; + + [[nodiscard]] bool try_member( + std::size_t index, + MmsObjectNameView& name) const noexcept; +}; + +struct MmsNamedVariableListMemberInput final { + std::string_view domain; + std::string_view item; +}; + +class MmsDataSetSpanCodec final { +public: + static constexpr std::size_t maximum_members = MmsServiceSpanCodec::maximum_variables; + + [[nodiscard]] static bool try_decode_get_named_variable_list_attributes_request( + const MmsConfirmedPduView& confirmed, + MmsNamedVariableListAttributesRequestView& request) noexcept; + + [[nodiscard]] static bool try_decode_get_named_variable_list_attributes_request( + std::span mms_pdu, + MmsNamedVariableListAttributesRequestView& request) noexcept; + + [[nodiscard]] static bool try_decode_get_named_variable_list_attributes_response( + const MmsConfirmedPduView& confirmed, + MmsNamedVariableListAttributesResponseView& response) noexcept; + + [[nodiscard]] static bool try_decode_get_named_variable_list_attributes_response( + std::span mms_pdu, + MmsNamedVariableListAttributesResponseView& response) noexcept; + + [[nodiscard]] static wire::EncodeResult + encode_get_named_variable_list_attributes_response_into( + std::uint32_t invoke_id, + bool mms_deletable, + std::span members, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/data_span_codec.hpp b/include/ariec61850/mms/data_span_codec.hpp new file mode 100644 index 000000000..39c13690a --- /dev/null +++ b/include/ariec61850/mms/data_span_codec.hpp @@ -0,0 +1,340 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/mms/data_value.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +class MmsDataSpanCodec final { +public: + static constexpr std::size_t maximum_nesting_depth = 16U; + + [[nodiscard]] static std::optional encoded_size( + const MmsDataValue& value) noexcept { + return encoded_size_impl(value, 0U); + } + + [[nodiscard]] static std::optional encoded_all_size( + const std::span values) noexcept { + return encoded_all_size_impl(values, 0U); + } + + [[nodiscard]] static wire::EncodeResult encode_into( + const MmsDataValue& value, + const std::span destination) noexcept { + const auto required = encoded_size(value); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!write_value(writer, value, 0U) || writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, writer.size(), *required}; + } + + [[nodiscard]] static wire::EncodeResult encode_all_into( + const std::span values, + const std::span destination) noexcept { + const auto required = encoded_all_size(values); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + for (const auto& value : values) { + if (!write_value(writer, value, 0U)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + if (writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, writer.size(), *required}; + } + +private: + [[nodiscard]] static std::optional tag_for( + const MmsDataValue& value) noexcept { + switch (value.kind()) { + case MmsDataKind::array: return 1; + case MmsDataKind::structure: return 2; + case MmsDataKind::boolean: return 3; + case MmsDataKind::bit_string: return 4; + case MmsDataKind::integer: return 5; + case MmsDataKind::unsigned_integer: return 6; + case MmsDataKind::floating_point: return 7; + case MmsDataKind::octet_string: return 9; + case MmsDataKind::visible_string: return 10; + case MmsDataKind::binary_time: return 12; + case MmsDataKind::mms_string: return 16; + case MmsDataKind::utc_time: return 17; + case MmsDataKind::unknown: + return value.unknown_tag_number(); + default: + return std::nullopt; + } + } + + [[nodiscard]] static std::size_t unsigned_integer_size( + std::uint64_t value) noexcept { + std::size_t size = 1U; + while (value > 0xFFU) { + ++size; + value >>= 8U; + } + return size; + } + + [[nodiscard]] static std::size_t signed_integer_size( + const std::int64_t value) noexcept { + const auto raw = std::bit_cast(value); + std::array bytes{}; + for (std::size_t index = 0U; index < bytes.size(); ++index) { + const auto shift = static_cast((bytes.size() - 1U - index) * 8U); + bytes[index] = static_cast((raw >> shift) & 0xFFU); + } + + std::size_t first = 0U; + while (first < bytes.size() - 1U) { + const auto current = bytes[first]; + const auto next = bytes[first + 1U]; + const bool redundant_positive = current == 0x00U && (next & 0x80U) == 0U; + const bool redundant_negative = current == 0xFFU && (next & 0x80U) != 0U; + if (!redundant_positive && !redundant_negative) { + break; + } + ++first; + } + return bytes.size() - first; + } + + [[nodiscard]] static std::optional content_size( + const MmsDataValue& value, + const std::size_t depth) noexcept { + if (depth > maximum_nesting_depth) { + return std::nullopt; + } + + switch (value.kind()) { + case MmsDataKind::array: + case MmsDataKind::structure: + return encoded_all_size_impl(value.children(), depth + 1U); + case MmsDataKind::boolean: + return std::get_if(&value.value()) != nullptr + ? std::optional{1U} + : std::nullopt; + case MmsDataKind::bit_string: + case MmsDataKind::octet_string: + case MmsDataKind::binary_time: + return value.raw_value().size(); + case MmsDataKind::integer: { + const auto* scalar = std::get_if(&value.value()); + return scalar != nullptr + ? std::optional{signed_integer_size(*scalar)} + : std::nullopt; + } + case MmsDataKind::unsigned_integer: { + const auto* scalar = std::get_if(&value.value()); + return scalar != nullptr + ? std::optional{unsigned_integer_size(*scalar)} + : std::nullopt; + } + case MmsDataKind::floating_point: + return (std::get_if(&value.value()) != nullptr || + std::get_if(&value.value()) != nullptr) + ? std::optional{5U} + : std::nullopt; + case MmsDataKind::visible_string: + case MmsDataKind::mms_string: { + const auto* text = std::get_if(&value.value()); + return text != nullptr + ? std::optional{text->size()} + : std::nullopt; + } + case MmsDataKind::utc_time: { + const auto* utc = std::get_if(&value.value()); + if (utc == nullptr) { + return std::nullopt; + } + std::array bytes{}; + return utc->try_write_bytes(bytes) + ? std::optional{bytes.size()} + : std::nullopt; + } + case MmsDataKind::unknown: + return value.unknown_tag_number().has_value() + ? std::optional{value.raw_value().size()} + : std::nullopt; + default: + return std::nullopt; + } + } + + [[nodiscard]] static std::optional encoded_size_impl( + const MmsDataValue& value, + const std::size_t depth) noexcept { + if (depth > maximum_nesting_depth) { + return std::nullopt; + } + const auto tag = tag_for(value); + const auto content = content_size(value, depth); + if (!tag || !content) { + return std::nullopt; + } + return asn1::BerSpanWriter::tlv_size(*tag, *content); + } + + [[nodiscard]] static std::optional encoded_all_size_impl( + const std::span values, + const std::size_t depth) noexcept { + if (depth > maximum_nesting_depth) { + return std::nullopt; + } + std::size_t total = 0U; + for (const auto& value : values) { + const auto size = encoded_size_impl(value, depth); + if (!size || *size > std::numeric_limits::max() - total) { + return std::nullopt; + } + total += *size; + } + return total; + } + + [[nodiscard]] static bool write_unsigned( + asn1::BerSpanWriter& writer, + const std::uint64_t value) noexcept { + const auto size = unsigned_integer_size(value); + for (std::size_t index = size; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return false; + } + } + return true; + } + + [[nodiscard]] static bool write_signed( + asn1::BerSpanWriter& writer, + const std::int64_t value) noexcept { + const auto raw = std::bit_cast(value); + std::array bytes{}; + for (std::size_t index = 0U; index < bytes.size(); ++index) { + const auto shift = static_cast((bytes.size() - 1U - index) * 8U); + bytes[index] = static_cast((raw >> shift) & 0xFFU); + } + const auto size = signed_integer_size(value); + return writer.write_bytes(std::span{bytes}.last(size)); + } + + [[nodiscard]] static bool write_value( + asn1::BerSpanWriter& writer, + const MmsDataValue& value, + const std::size_t depth) noexcept { + if (depth > maximum_nesting_depth) { + return false; + } + const auto tag = tag_for(value); + const auto content = content_size(value, depth); + if (!tag || !content) { + return false; + } + const bool constructed = + value.kind() == MmsDataKind::array || value.kind() == MmsDataKind::structure; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, + constructed, + *tag, + *content)) { + return false; + } + + switch (value.kind()) { + case MmsDataKind::array: + case MmsDataKind::structure: + for (const auto& child : value.children()) { + if (!write_value(writer, child, depth + 1U)) { + return false; + } + } + return true; + case MmsDataKind::boolean: { + const auto* scalar = std::get_if(&value.value()); + return scalar != nullptr && writer.write_byte(*scalar ? 0x01U : 0x00U); + } + case MmsDataKind::bit_string: + case MmsDataKind::octet_string: + case MmsDataKind::binary_time: + case MmsDataKind::unknown: + return writer.write_bytes(value.raw_value()); + case MmsDataKind::integer: { + const auto* scalar = std::get_if(&value.value()); + return scalar != nullptr && write_signed(writer, *scalar); + } + case MmsDataKind::unsigned_integer: { + const auto* scalar = std::get_if(&value.value()); + return scalar != nullptr && write_unsigned(writer, *scalar); + } + case MmsDataKind::floating_point: { + float scalar = 0.0F; + if (const auto* single = std::get_if(&value.value())) { + scalar = *single; + } else if (const auto* double_value = std::get_if(&value.value())) { + scalar = static_cast(*double_value); + } else { + return false; + } + const auto raw = std::bit_cast(scalar); + return writer.write_byte(0x08U) && + writer.write_byte(static_cast((raw >> 24U) & 0xFFU)) && + writer.write_byte(static_cast((raw >> 16U) & 0xFFU)) && + writer.write_byte(static_cast((raw >> 8U) & 0xFFU)) && + writer.write_byte(static_cast(raw & 0xFFU)); + } + case MmsDataKind::visible_string: + case MmsDataKind::mms_string: { + const auto* text = std::get_if(&value.value()); + if (text == nullptr) { + return false; + } + for (const auto character : *text) { + if (!writer.write_byte(static_cast(character))) { + return false; + } + } + return true; + } + case MmsDataKind::utc_time: { + const auto* utc = std::get_if(&value.value()); + if (utc == nullptr) { + return false; + } + std::array bytes{}; + return utc->try_write_bytes(bytes) && writer.write_bytes(bytes); + } + default: + return false; + } + } +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/pdu_span.hpp b/include/ariec61850/mms/pdu_span.hpp new file mode 100644 index 000000000..5617c756a --- /dev/null +++ b/include/ariec61850/mms/pdu_span.hpp @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsWirePduKind : std::uint8_t { + unknown, + confirmed_request, + confirmed_response, + initiate_request, + initiate_response, +}; + +enum class MmsWireConfirmedService : std::int32_t { + unknown = -1, + get_name_list = 1, + identify = 2, + read = 4, + write = 5, + get_variable_access_attributes = 6, + get_named_variable_list_attributes = 12, +}; + +struct MmsInitiateDetailView final { + std::uint32_t version_number{}; + std::span parameter_support_options{}; + std::span services_supported_calling{}; +}; + +struct MmsInitiateView final { + MmsWirePduKind kind{MmsWirePduKind::unknown}; + std::uint32_t maximum_mms_pdu_size{}; + std::uint32_t maximum_outstanding_calling{}; + std::uint32_t maximum_outstanding_called{}; + std::uint32_t data_structure_nesting_level{}; + MmsInitiateDetailView detail{}; +}; + +struct MmsConfirmedPduView final { + MmsWirePduKind kind{MmsWirePduKind::unknown}; + std::uint32_t invoke_id{}; + std::int32_t service_tag{-1}; + bool service_constructed{}; + std::span service_value{}; + + [[nodiscard]] MmsWireConfirmedService service() const noexcept; +}; + +class MmsPduSpanCodec final { +public: + static constexpr std::size_t maximum_pdu_bytes = 1U * 1024U * 1024U; + static constexpr std::size_t maximum_bit_string_bytes = 256U; + static constexpr std::uint32_t maximum_invoke_id = 0x7FFF'FFFFU; + + [[nodiscard]] static bool try_decode_initiate_request_view( + std::span bytes, + MmsInitiateView& request) noexcept; + + [[nodiscard]] static bool try_decode_initiate_response_view( + std::span bytes, + MmsInitiateView& response) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_default_initiate_response_into( + std::span destination) noexcept; + + [[nodiscard]] static bool try_decode_confirmed_request_view( + std::span bytes, + MmsConfirmedPduView& request) noexcept; + + [[nodiscard]] static bool try_decode_confirmed_response_view( + std::span bytes, + MmsConfirmedPduView& response) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_confirmed_request_into( + std::uint32_t invoke_id, + std::int32_t service_tag, + bool service_constructed, + std::span service_value, + std::span destination) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_confirmed_response_into( + std::uint32_t invoke_id, + std::int32_t service_tag, + bool service_constructed, + std::span service_value, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/services_span.hpp b/include/ariec61850/mms/services_span.hpp new file mode 100644 index 000000000..2f6d172da --- /dev/null +++ b/include/ariec61850/mms/services_span.hpp @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/pdu_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsNameListObjectClass : std::uint32_t { + named_variable = 0U, + named_variable_list = 2U, + domain = 9U, +}; + +enum class MmsNameScopeKind : std::uint8_t { + vmd_specific, + domain_specific, + aa_specific, +}; + +enum class MmsObjectNameViewKind : std::uint8_t { + vmd_specific, + domain_specific, + aa_specific, +}; + +struct MmsObjectNameView final { + MmsObjectNameViewKind kind{MmsObjectNameViewKind::domain_specific}; + std::span domain{}; + std::span item{}; +}; + +struct MmsGetNameListRequestView final { + std::uint32_t invoke_id{}; + MmsNameListObjectClass object_class{MmsNameListObjectClass::domain}; + MmsNameScopeKind scope{MmsNameScopeKind::vmd_specific}; + std::span domain_id{}; + std::span continue_after{}; +}; + +struct MmsGetNameListResponseView final { + std::uint32_t invoke_id{}; + std::span identifier_list{}; + std::size_t identifier_count{}; + bool more_follows{}; + + [[nodiscard]] bool try_identifier( + std::size_t index, + std::span& identifier) const noexcept; +}; + +struct MmsVariableAccessAttributesRequestView final { + std::uint32_t invoke_id{}; + MmsObjectNameView name{}; +}; + +struct MmsVariableAccessAttributesResponseView final { + std::uint32_t invoke_id{}; + bool mms_deletable{}; + std::span type_specification{}; +}; + +struct MmsReadRequestView final { + std::uint32_t invoke_id{}; + bool specification_with_result{}; + std::span variable_list{}; + std::size_t variable_count{}; + + [[nodiscard]] bool try_variable( + std::size_t index, + MmsObjectNameView& name) const noexcept; +}; + +struct MmsReadAccessResultView final { + bool success{}; + std::span encoded_data{}; + std::uint32_t failure_code{}; +}; + +struct MmsReadResponseView final { + std::uint32_t invoke_id{}; + std::span access_result_list{}; + std::size_t result_count{}; + + [[nodiscard]] bool try_result( + std::size_t index, + MmsReadAccessResultView& result) const noexcept; +}; + +struct MmsReadAccessResultInput final { + bool success{}; + std::span encoded_data{}; + std::uint32_t failure_code{}; +}; + +struct MmsWriteRequestView final { + std::uint32_t invoke_id{}; + std::span variable_list{}; + std::size_t variable_count{}; + std::span data_list{}; + std::size_t data_count{}; + + [[nodiscard]] bool try_variable( + std::size_t index, + MmsObjectNameView& name) const noexcept; + + [[nodiscard]] bool try_value( + std::size_t index, + std::span& encoded_data) const noexcept; +}; + +struct MmsWriteAccessResultView final { + bool success{}; + std::uint32_t failure_code{}; +}; + +struct MmsWriteResponseView final { + std::uint32_t invoke_id{}; + std::span result_list{}; + std::size_t result_count{}; + bool implicit_single_success{}; + + [[nodiscard]] bool try_result( + std::size_t index, + MmsWriteAccessResultView& result) const noexcept; +}; + +struct MmsWriteAccessResultInput final { + bool success{}; + std::uint32_t failure_code{}; +}; + +class MmsServiceSpanCodec final { +public: + static constexpr std::size_t maximum_identifier_bytes = 1'024U; + static constexpr std::size_t maximum_identifiers = 128U; + static constexpr std::size_t maximum_variables = 64U; + + [[nodiscard]] static bool try_decode_object_name_view( + std::span encoded_object_name, + MmsObjectNameView& name) noexcept; + + [[nodiscard]] static bool try_decode_get_name_list_request( + const MmsConfirmedPduView& confirmed, + MmsGetNameListRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_get_name_list_request( + std::span mms_pdu, + MmsGetNameListRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_get_name_list_response( + const MmsConfirmedPduView& confirmed, + MmsGetNameListResponseView& response) noexcept; + [[nodiscard]] static bool try_decode_get_name_list_response( + std::span mms_pdu, + MmsGetNameListResponseView& response) noexcept; + [[nodiscard]] static wire::EncodeResult encode_get_name_list_response_into( + std::uint32_t invoke_id, + std::span names, + bool more_follows, + std::span destination) noexcept; + + [[nodiscard]] static bool try_decode_variable_access_attributes_request( + const MmsConfirmedPduView& confirmed, + MmsVariableAccessAttributesRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_variable_access_attributes_request( + std::span mms_pdu, + MmsVariableAccessAttributesRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_variable_access_attributes_response( + const MmsConfirmedPduView& confirmed, + MmsVariableAccessAttributesResponseView& response) noexcept; + [[nodiscard]] static bool try_decode_variable_access_attributes_response( + std::span mms_pdu, + MmsVariableAccessAttributesResponseView& response) noexcept; + [[nodiscard]] static wire::EncodeResult encode_variable_access_attributes_response_into( + std::uint32_t invoke_id, + bool mms_deletable, + std::span encoded_type_specification, + std::span destination) noexcept; + + [[nodiscard]] static bool try_decode_read_request( + const MmsConfirmedPduView& confirmed, + MmsReadRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_read_request( + std::span mms_pdu, + MmsReadRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_read_response( + const MmsConfirmedPduView& confirmed, + MmsReadResponseView& response) noexcept; + [[nodiscard]] static bool try_decode_read_response( + std::span mms_pdu, + MmsReadResponseView& response) noexcept; + [[nodiscard]] static wire::EncodeResult encode_read_response_into( + std::uint32_t invoke_id, + std::span results, + std::span destination) noexcept; + + [[nodiscard]] static bool try_decode_write_request( + const MmsConfirmedPduView& confirmed, + MmsWriteRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_write_request( + std::span mms_pdu, + MmsWriteRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_write_response( + const MmsConfirmedPduView& confirmed, + MmsWriteResponseView& response) noexcept; + [[nodiscard]] static bool try_decode_write_response( + std::span mms_pdu, + MmsWriteResponseView& response) noexcept; + [[nodiscard]] static wire::EncodeResult encode_write_response_into( + std::uint32_t invoke_id, + std::span results, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_data_set_table.hpp b/include/ariec61850/mms/static_data_set_table.hpp new file mode 100644 index 000000000..e7fc7a59d --- /dev/null +++ b/include/ariec61850/mms/static_data_set_table.hpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/data_set_span.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +struct MmsStaticDataSetMember final { + std::string_view domain; + std::string_view item; +}; + +struct MmsStaticDataSetEntry final { + std::string_view domain; + std::string_view item; + std::span members; + bool mms_deletable{}; +}; + +class MmsStaticDataSetTable final { +public: + static constexpr std::size_t maximum_data_sets = 64U; + + constexpr MmsStaticDataSetTable() noexcept = default; + + explicit constexpr MmsStaticDataSetTable( + const std::span data_sets) noexcept + : data_sets_{data_sets} {} + + [[nodiscard]] bool valid() const noexcept; + + [[nodiscard]] bool valid_against( + const MmsStaticObjectTable& objects) const noexcept; + + [[nodiscard]] const MmsStaticDataSetEntry* find( + const MmsObjectNameView& name) const noexcept; + + [[nodiscard]] constexpr std::span data_sets() const noexcept { + return data_sets_; + } + +private: + std::span data_sets_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_dispatcher.hpp b/include/ariec61850/mms/static_dispatcher.hpp new file mode 100644 index 000000000..87c65d580 --- /dev/null +++ b/include/ariec61850/mms/static_dispatcher.hpp @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticDispatchStatus : std::uint8_t { + response_ready, + malformed_request, + unsupported_service, + unsupported_request, + object_not_found, + invalid_object_table, + workspace_too_small, + response_buffer_too_small, + backend_failure, +}; + +struct MmsStaticDispatchPolicy final { + std::size_t maximum_names_per_response{32U}; + std::size_t maximum_write_variables{1U}; + std::uint32_t missing_object_failure_code{10U}; + std::uint32_t access_denied_failure_code{3U}; + std::uint32_t backend_failure_code{10U}; +}; + +struct MmsStaticDispatchResult final { + MmsStaticDispatchStatus status{MmsStaticDispatchStatus::malformed_request}; + MmsWireConfirmedService service{MmsWireConfirmedService::unknown}; + std::uint32_t invoke_id{}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == MmsStaticDispatchStatus::response_ready; + } +}; + +class MmsStaticApplicationDispatcher final { +public: + explicit constexpr MmsStaticApplicationDispatcher( + const MmsStaticObjectTable& objects, + const MmsStaticDispatchPolicy policy = {}) noexcept + : objects_{objects}, policy_{policy} {} + + constexpr MmsStaticApplicationDispatcher( + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, + const MmsStaticDispatchPolicy policy = {}) noexcept + : objects_{objects}, data_sets_{data_sets}, policy_{policy} {} + + [[nodiscard]] MmsStaticDispatchResult dispatch( + std::span mms_request, + std::span response, + std::span workspace) const noexcept; + + [[nodiscard]] MmsStaticDispatchResult dispatch( + const MmsConfirmedPduView& request, + std::span response, + std::span workspace) const noexcept; + + [[nodiscard]] constexpr const MmsStaticDispatchPolicy& policy() const noexcept { + return policy_; + } + + [[nodiscard]] constexpr const MmsStaticDataSetTable& data_sets() const noexcept { + return data_sets_; + } + +private: + const MmsStaticObjectTable& objects_; + MmsStaticDataSetTable data_sets_{}; + MmsStaticDispatchPolicy policy_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_object_table.hpp b/include/ariec61850/mms/static_object_table.hpp new file mode 100644 index 000000000..bf6561aa9 --- /dev/null +++ b/include/ariec61850/mms/static_object_table.hpp @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +using MmsStaticReadCallback = wire::EncodeResult (*)( + const void* context, + std::span destination) noexcept; + +struct MmsStaticWriteResult final { + bool success{}; + std::uint32_t failure_code{}; +}; + +using MmsStaticWriteCallback = MmsStaticWriteResult (*)( + void* context, + std::span encoded_data) noexcept; + +struct MmsStaticObjectEntry final { + std::string_view domain; + std::string_view item; + std::span type_specification; + MmsStaticReadCallback read{}; + const void* context{}; + bool mms_deletable{}; + MmsStaticWriteCallback write{}; + void* write_context{}; + bool name_list_visible{true}; + + [[nodiscard]] constexpr bool writable() const noexcept { + return write != nullptr; + } +}; + +class MmsStaticObjectTable final { +public: + static constexpr std::size_t maximum_objects = 128U; + + explicit constexpr MmsStaticObjectTable( + const std::span objects) noexcept + : objects_{objects} {} + + [[nodiscard]] bool valid() const noexcept; + + [[nodiscard]] const MmsStaticObjectEntry* find( + const MmsObjectNameView& name) const noexcept; + + [[nodiscard]] bool try_resolve_read_request( + const MmsReadRequestView& request, + std::span resolved, + std::size_t& resolved_count) const noexcept; + + [[nodiscard]] bool try_resolve_write_request( + const MmsWriteRequestView& request, + std::span resolved, + std::size_t& resolved_count) const noexcept; + + [[nodiscard]] constexpr std::span objects() const noexcept { + return objects_; + } + +private: + std::span objects_; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_server_session.hpp b/include/ariec61850/mms/static_server_session.hpp new file mode 100644 index 000000000..21ca384e8 --- /dev/null +++ b/include/ariec61850/mms/static_server_session.hpp @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/embedded/io.hpp" +#include "ariec61850/mms/connection_runtime.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticServerSessionStatus : std::uint8_t { + progressed, + response_pending, + would_block, + timed_out, + application_rejected, + peer_closed, + transport_error, + protocol_error, + receive_buffer_full, + invalid_configuration, +}; + +struct MmsStaticServerSessionResult final { + MmsStaticServerSessionStatus status{ + MmsStaticServerSessionStatus::invalid_configuration}; + MmsStaticConnectionStatus connection_status{ + MmsStaticConnectionStatus::need_more}; + std::size_t bytes_received{}; + std::size_t bytes_sent{}; + std::size_t buffered_input_bytes{}; + std::size_t pending_output_bytes{}; + + [[nodiscard]] constexpr bool terminal() const noexcept { + return status == MmsStaticServerSessionStatus::peer_closed || + status == MmsStaticServerSessionStatus::transport_error || + status == MmsStaticServerSessionStatus::protocol_error || + status == MmsStaticServerSessionStatus::receive_buffer_full || + status == MmsStaticServerSessionStatus::invalid_configuration; + } +}; + +struct MmsStaticServerSessionBuffers final { + std::span receive; + std::span response; + std::span workspace; + + [[nodiscard]] constexpr bool valid() const noexcept { + return !receive.empty() && !response.empty() && !workspace.empty(); + } +}; + +// Allocation-free bridge between a connected TCP byte stream and the portable +// MMS connection state machine. Listen/accept/close remain platform-owned. +// One instance is used per accepted connection, which lets desktop servers, +// FreeRTOS tasks and lwIP callbacks choose their own concurrency policy. +class MmsStaticServerSession final { +public: + constexpr MmsStaticServerSession( + MmsStaticConnectionRuntime& runtime, + const embedded::TcpByteStream stream, + const MmsStaticServerSessionBuffers buffers) noexcept + : runtime_{runtime}, stream_{stream}, buffers_{buffers} {} + + // Performs at most one transport send/receive or one protocol dispatch. + // Call repeatedly from the platform event/task loop. Partial sends and + // fragmented/coalesced TPKT input are retained across calls. + [[nodiscard]] MmsStaticServerSessionResult poll_once() noexcept; + + void reset() noexcept; + + [[nodiscard]] constexpr std::size_t buffered_input_bytes() const noexcept { + return receive_size_; + } + + [[nodiscard]] constexpr std::size_t pending_output_bytes() const noexcept { + return response_size_ - response_offset_; + } + +private: + [[nodiscard]] MmsStaticServerSessionResult make_result( + MmsStaticServerSessionStatus status, + MmsStaticConnectionStatus connection_status, + std::size_t bytes_received = 0U, + std::size_t bytes_sent = 0U) const noexcept; + + void consume_input(std::size_t consumed) noexcept; + + MmsStaticConnectionRuntime& runtime_; + embedded::TcpByteStream stream_{}; + MmsStaticServerSessionBuffers buffers_{}; + std::size_t receive_size_{}; + std::size_t response_offset_{}; + std::size_t response_size_{}; + bool terminal_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/osi/cotp_span.hpp b/include/ariec61850/osi/cotp_span.hpp new file mode 100644 index 000000000..99a068250 --- /dev/null +++ b/include/ariec61850/osi/cotp_span.hpp @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include + +namespace ar::iec61850::osi { + +enum class CotpWireKind : std::uint8_t { + unknown, + connection_request, + connection_confirm, + data, + disconnect_request, + error, +}; + +struct CotpParameterView final { + std::uint8_t code{}; + std::span value{}; +}; + +struct CotpTpduView final { + CotpWireKind kind{CotpWireKind::unknown}; + std::uint8_t code{}; + std::uint8_t length_indicator{}; + std::uint16_t destination_reference{}; + std::uint16_t source_reference{}; + std::uint8_t class_or_reason{}; + bool end_of_transmission{}; + std::uint8_t tpdu_number{}; + std::span parameter_bytes{}; + std::span user_data{}; + + [[nodiscard]] bool try_parameter( + std::uint8_t parameter_code, + std::span& value) const noexcept; +}; + +class CotpSpanCodec final { +public: + static constexpr std::uint8_t connection_request_code = 0xE0U; + static constexpr std::uint8_t connection_confirm_code = 0xD0U; + static constexpr std::uint8_t disconnect_request_code = 0x80U; + static constexpr std::uint8_t data_code = 0xF0U; + static constexpr std::uint8_t error_code = 0x70U; + + static constexpr std::uint8_t tpdu_size_parameter = 0xC0U; + static constexpr std::uint8_t source_tsap_parameter = 0xC1U; + static constexpr std::uint8_t destination_tsap_parameter = 0xC2U; + + [[nodiscard]] static CotpWireKind kind_from_code(std::uint8_t code) noexcept; + + // Zero-copy decode. parameter_bytes and user_data point into the supplied + // TPDU storage, which must outlive the returned view. + [[nodiscard]] static bool try_decode_view( + std::span bytes, + CotpTpduView& tpdu) noexcept; + + [[nodiscard]] static bool try_tpdu_size_bytes( + std::uint8_t tpdu_size_code, + std::size_t& bytes) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_data_into( + std::span user_data, + std::span destination, + bool end_of_transmission = true, + std::uint8_t tpdu_number = 0U) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_connection_request_into( + std::uint16_t source_reference, + std::span parameters, + std::span destination, + std::uint16_t destination_reference = 0U, + std::uint8_t transport_class = 0U) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_connection_confirm_into( + std::uint16_t destination_reference, + std::uint16_t source_reference, + std::span destination, + std::uint8_t maximum_tpdu_size_code = 0x0AU) noexcept; + + // Build a server-side CC directly from a zero-copy CR view. The TPDU-size + // offer is negotiated and source/destination TSAP parameters are mirrored + // using the same wire behavior as the host codec, without heap storage. + [[nodiscard]] static wire::EncodeResult encode_connection_confirm_from_request_into( + const CotpTpduView& connection_request, + std::uint16_t source_reference, + std::span destination, + std::uint8_t maximum_tpdu_size_code = 0x0AU) noexcept; +}; + +} // namespace ar::iec61850::osi diff --git a/include/ariec61850/osi/presentation_span.hpp b/include/ariec61850/osi/presentation_span.hpp new file mode 100644 index 000000000..7afb688e1 --- /dev/null +++ b/include/ariec61850/osi/presentation_span.hpp @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::osi { + +struct PresentationPdvView final { + std::uint32_t context_id{}; + std::span single_asn1_type{}; +}; + +struct PresentationCpView final { + std::uint32_t mode_selector{1U}; + std::span calling_selector{}; + std::span called_selector{}; + std::span context_definition_list{}; + std::size_t context_count{}; + PresentationPdvView user_data{}; + + [[nodiscard]] bool try_context_id_for_abstract_syntax( + std::span object_identifier_value, + std::uint32_t& context_id) const noexcept; +}; + +class PresentationSpanCodec final { +public: + static constexpr std::size_t maximum_ppdu_bytes = 1U * 1024U * 1024U; + static constexpr std::size_t maximum_contexts = 64U; + static constexpr std::size_t maximum_oid_bytes = 64U; + static constexpr std::size_t maximum_selector_bytes = 64U; + + [[nodiscard]] static std::span + acse_abstract_syntax_name() noexcept; + [[nodiscard]] static std::span + mms_abstract_syntax_name() noexcept; + [[nodiscard]] static std::span + ber_transfer_syntax_name() noexcept; + + [[nodiscard]] static std::optional fully_encoded_data_size( + std::uint32_t context_id, + std::size_t single_asn1_type_bytes) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_fully_encoded_data_into( + std::uint32_t context_id, + std::span single_asn1_type, + std::span destination) noexcept; + + [[nodiscard]] static bool try_decode_fully_encoded_data_view( + std::span bytes, + PresentationPdvView& pdv) noexcept; + + [[nodiscard]] static bool try_decode_cp_view( + std::span bytes, + PresentationCpView& cp) noexcept; + + [[nodiscard]] static std::optional cpa_accepting_size( + const PresentationCpView& request, + std::size_t acse_aare_bytes) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_cpa_accepting_into( + const PresentationCpView& request, + std::span acse_aare, + std::span destination) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_p_data_into( + std::span abstract_syntax_payload, + std::span destination, + std::uint32_t presentation_context_id = 3U, + bool include_give_tokens_prefix = true) noexcept; + + [[nodiscard]] static bool try_decode_p_data_view( + std::span bytes, + PresentationPdvView& pdv) noexcept; +}; + +} // namespace ar::iec61850::osi diff --git a/include/ariec61850/osi/session_span.hpp b/include/ariec61850/osi/session_span.hpp new file mode 100644 index 000000000..cc0667c90 --- /dev/null +++ b/include/ariec61850/osi/session_span.hpp @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include + +namespace ar::iec61850::osi { + +enum class SessionWireKind : std::uint8_t { + unknown, + connect, + accept, + reject, + refuse, + abort, + data_transfer, +}; + +struct SessionParameterView final { + std::uint8_t code{}; + std::span value{}; +}; + +struct SessionSpduView final { + SessionWireKind kind{SessionWireKind::unknown}; + std::uint8_t code{}; + std::uint8_t length_indicator{}; + std::span parameter_bytes{}; + std::span user_data{}; + + [[nodiscard]] bool try_parameter( + std::uint8_t parameter_code, + std::span& value) const noexcept; +}; + +struct SessionDataTransferView final { + bool has_give_tokens_prefix{}; + std::span presentation_payload{}; +}; + +class SessionSpanCodec final { +public: + static constexpr std::uint8_t connect_code = 0x0DU; + static constexpr std::uint8_t accept_code = 0x0EU; + static constexpr std::uint8_t reject_code = 0x0AU; + static constexpr std::uint8_t refuse_code = 0x0CU; + static constexpr std::uint8_t abort_code = 0x19U; + static constexpr std::uint8_t data_transfer_code = 0x01U; + static constexpr std::uint8_t user_data_parameter = 0xC1U; + static constexpr std::size_t maximum_spdu_bytes = 257U; + static constexpr std::size_t maximum_parameter_count = 64U; + + [[nodiscard]] static SessionWireKind kind_from_code(std::uint8_t code) noexcept; + [[nodiscard]] static std::span default_parameters() noexcept; + + [[nodiscard]] static bool try_decode_prefix_view( + std::span bytes, + SessionSpduView& spdu, + std::size_t& consumed) noexcept; + + [[nodiscard]] static bool try_decode_view( + std::span bytes, + SessionSpduView& spdu) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_into( + std::uint8_t code, + std::span parameters, + std::span user_data, + std::span destination) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_connect_into( + std::span presentation_payload, + std::span destination, + std::span parameters = {}) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_accept_into( + std::span presentation_payload, + std::span destination, + std::span parameters = {}) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_accept_mirroring_into( + const SessionSpduView& connect, + std::span presentation_payload, + std::span destination) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_data_transfer_into( + std::span presentation_payload, + std::span destination, + bool include_give_tokens_prefix = true) noexcept; + + [[nodiscard]] static bool try_decode_data_transfer_view( + std::span bytes, + SessionDataTransferView& transfer) noexcept; +}; + +} // namespace ar::iec61850::osi diff --git a/include/ariec61850/osi/tpkt_span.hpp b/include/ariec61850/osi/tpkt_span.hpp new file mode 100644 index 000000000..07327ebe8 --- /dev/null +++ b/include/ariec61850/osi/tpkt_span.hpp @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include + +namespace ar::iec61850::osi { + +enum class TpktPeekStatus : std::uint8_t { + need_more, + ready, + invalid, +}; + +struct TpktPeekResult final { + TpktPeekStatus status{TpktPeekStatus::need_more}; + std::size_t frame_bytes{}; + + [[nodiscard]] constexpr bool ready() const noexcept { + return status == TpktPeekStatus::ready; + } +}; + +struct TpktFrameView final { + std::uint8_t version{}; + std::uint16_t declared_length{}; + std::span payload{}; +}; + +class TpktSpanCodec final { +public: + static constexpr std::uint8_t supported_version = 0x03U; + static constexpr std::size_t header_length = 4U; + static constexpr std::size_t maximum_frame_bytes = 65'535U; + static constexpr std::size_t maximum_payload_bytes = + maximum_frame_bytes - header_length; + + [[nodiscard]] static wire::EncodeResult encode_into( + std::span payload, + std::span destination) noexcept; + + // Inspect the first frame in a TCP receive window without copying it. + // `ready` may be returned even when bytes contains coalesced frames; + // frame_bytes identifies the exact prefix belonging to the first TPKT. + [[nodiscard]] static TpktPeekResult peek_frame( + std::span bytes) noexcept; + + // Decode one exact TPKT frame into non-owning spans. The supplied storage + // must outlive the returned view. + [[nodiscard]] static bool try_decode_view( + std::span bytes, + TpktFrameView& frame) noexcept; +}; + +} // namespace ar::iec61850::osi diff --git a/src/acse/association_span.cpp b/src/acse/association_span.cpp new file mode 100644 index 000000000..c7f1ea4d4 --- /dev/null +++ b/src/acse/association_span.cpp @@ -0,0 +1,417 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/acse/association_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::acse { +namespace { + +constexpr std::array kMmsApplicationContext{ + 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U}; + +constexpr std::array kDefaultMmsInitiateRequest{ + 0xA8U, 0x26U, + 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, + 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, + 0x83U, 0x01U, 0x05U, + 0xA4U, 0x16U, + 0x80U, 0x01U, 0x01U, + 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, + 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +constexpr std::array kDefaultMmsInitiateResponse{ + 0xA9U, 0x26U, + 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, + 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, + 0x83U, 0x01U, 0x05U, + 0xA4U, 0x16U, + 0x80U, 0x01U, 0x01U, + 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, + 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +// Byte-identical to AcseAssociationCodec::encode_aare(default_accept_aare()). +constexpr std::array kDefaultAcceptAare{ + 0x61U, 0x4AU, + 0xA1U, 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U, + 0xA2U, 0x03U, 0x02U, 0x01U, 0x00U, + 0xA3U, 0x05U, 0xA1U, 0x03U, 0x02U, 0x01U, 0x00U, + 0xBEU, 0x33U, + 0x28U, 0x31U, + 0x06U, 0x02U, 0x51U, 0x01U, + 0x02U, 0x01U, 0x03U, + 0xA0U, 0x28U, + 0xA9U, 0x26U, + 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, + 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, + 0x83U, 0x01U, 0x05U, + 0xA4U, 0x16U, + 0x80U, 0x01U, 0x01U, + 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, + 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +[[nodiscard]] bool valid_oid( + const std::span value, + const bool allow_empty = false) noexcept { + return (allow_empty || !value.empty()) && value.size() <= AcseSpanCodec::maximum_oid_bytes; +} + +[[nodiscard]] bool decode_explicit_oid( + const asn1::BerTlvView& field, + std::span& oid, + const bool allow_empty = false) noexcept { + oid = {}; + if (!field.constructed) { + return false; + } + asn1::BerTlvView inner; + if (!asn1::BerSpanReader::try_read_exact(field.value, inner) || + inner.tag_class != asn1::BerClass::universal || + inner.tag_number != 6 || inner.constructed || + !valid_oid(inner.value, allow_empty)) { + return false; + } + oid = inner.value; + return true; +} + +[[nodiscard]] bool decode_explicit_integer( + const asn1::BerTlvView& field, + std::uint32_t& value) noexcept { + value = 0U; + if (!field.constructed) { + return false; + } + asn1::BerTlvView inner; + if (!asn1::BerSpanReader::try_read_exact(field.value, inner) || + inner.tag_class != asn1::BerClass::universal || + inner.tag_number != 2 || inner.constructed) { + return false; + } + const auto decoded = asn1::BerSpanReader::read_uint32(inner); + if (!decoded) { + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool decode_external( + const asn1::BerTlvView& user_information, + AcseExternalView& external) noexcept { + external = {}; + if (!user_information.constructed) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(user_information.value, outer) || + outer.tag_class != asn1::BerClass::universal || + outer.tag_number != 8 || !outer.constructed) { + return false; + } + + bool saw_direct = false; + bool saw_indirect = false; + bool saw_encoding = false; + std::size_t offset = 0U; + while (offset < outer.value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, field)) { + external = {}; + return false; + } + + if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 6 && !field.constructed) { + if (saw_direct || !valid_oid(field.value)) { + external = {}; + return false; + } + external.direct_reference = field.value; + saw_direct = true; + } else if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 2 && !field.constructed) { + if (saw_indirect) { + external = {}; + return false; + } + const auto value = asn1::BerSpanReader::read_uint32(field); + if (!value) { + external = {}; + return false; + } + external.indirect_reference = *value; + saw_indirect = true; + } else if (field.tag_class == asn1::BerClass::context_specific && + field.tag_number == 0 && field.constructed) { + if (saw_encoding || field.value.size() > AcseSpanCodec::maximum_acse_bytes) { + external = {}; + return false; + } + external.single_asn1_type = field.value; + saw_encoding = true; + } else { + external = {}; + return false; + } + } + return saw_direct && saw_indirect && saw_encoding; +} + +[[nodiscard]] bool request_is_valid_for_response( + const AssociationRequestView& request) noexcept { + return request.session.kind == osi::SessionWireKind::connect && + request.presentation.mode_selector != 0U && + request.presentation.context_count != 0U && + request.acse_presentation_context_id != 0U && + request.mms_presentation_context_id != 0U && + request.acse_presentation_context_id == request.presentation.user_data.context_id; +} + +} // namespace + +std::span AcseSpanCodec::mms_application_context_name() noexcept { + return kMmsApplicationContext; +} + +std::span AcseSpanCodec::default_mms_initiate_request() noexcept { + return kDefaultMmsInitiateRequest; +} + +std::span AcseSpanCodec::default_mms_initiate_response() noexcept { + return kDefaultMmsInitiateResponse; +} + +bool AcseSpanCodec::try_decode_aarq_view( + const std::span bytes, + AcseAarqView& aarq) noexcept { + aarq = {}; + if (bytes.empty() || bytes.size() > maximum_acse_bytes) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(bytes, outer) || + outer.tag_class != asn1::BerClass::application || + outer.tag_number != 0 || !outer.constructed) { + return false; + } + + bool saw_application = false; + bool saw_called_ap = false; + bool saw_called_ae = false; + bool saw_calling_ap = false; + bool saw_calling_ae = false; + bool saw_user_information = false; + std::size_t offset = 0U; + while (offset < outer.value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + aarq = {}; + return false; + } + + switch (field.tag_number) { + case 1: + if (saw_application || + !decode_explicit_oid(field, aarq.application_context_name)) { + aarq = {}; + return false; + } + saw_application = true; + break; + case 2: + if (saw_called_ap || + !decode_explicit_oid(field, aarq.called_ap_title, true)) { + aarq = {}; + return false; + } + saw_called_ap = true; + break; + case 3: { + if (saw_called_ae) { + aarq = {}; + return false; + } + std::uint32_t value{}; + if (!decode_explicit_integer(field, value)) { + aarq = {}; + return false; + } + aarq.called_ae_qualifier = value; + saw_called_ae = true; + break; + } + case 6: + if (saw_calling_ap || + !decode_explicit_oid(field, aarq.calling_ap_title, true)) { + aarq = {}; + return false; + } + saw_calling_ap = true; + break; + case 7: { + if (saw_calling_ae) { + aarq = {}; + return false; + } + std::uint32_t value{}; + if (!decode_explicit_integer(field, value)) { + aarq = {}; + return false; + } + aarq.calling_ae_qualifier = value; + saw_calling_ae = true; + break; + } + case 30: + if (saw_user_information || !decode_external(field, aarq.user_information)) { + aarq = {}; + return false; + } + saw_user_information = true; + break; + default: + aarq = {}; + return false; + } + } + + if (!saw_application || !saw_user_information) { + aarq = {}; + return false; + } + return true; +} + +bool AcseSpanCodec::try_decode_association_request_view( + const std::span bytes, + AssociationRequestView& request) noexcept { + request = {}; + if (!osi::SessionSpanCodec::try_decode_view(bytes, request.session) || + request.session.kind != osi::SessionWireKind::connect || + !osi::PresentationSpanCodec::try_decode_cp_view( + request.session.user_data, request.presentation) || + !try_decode_aarq_view( + request.presentation.user_data.single_asn1_type, request.aarq)) { + request = {}; + return false; + } + + if (!request.presentation.try_context_id_for_abstract_syntax( + osi::PresentationSpanCodec::acse_abstract_syntax_name(), + request.acse_presentation_context_id) || + !request.presentation.try_context_id_for_abstract_syntax( + osi::PresentationSpanCodec::mms_abstract_syntax_name(), + request.mms_presentation_context_id) || + !request_is_valid_for_response(request)) { + request = {}; + return false; + } + return true; +} + +wire::EncodeResult AcseSpanCodec::encode_default_accept_aare_into( + const std::span destination) noexcept { + if (destination.size() < kDefaultAcceptAare.size()) { + return { + wire::EncodeStatus::buffer_too_small, + 0U, + kDefaultAcceptAare.size()}; + } + std::copy( + kDefaultAcceptAare.begin(), + kDefaultAcceptAare.end(), + destination.begin()); + return { + wire::EncodeStatus::ok, + kDefaultAcceptAare.size(), + kDefaultAcceptAare.size()}; +} + +std::optional AcseSpanCodec::accept_response_size( + const AssociationRequestView& request) noexcept { + if (!request_is_valid_for_response(request)) { + return std::nullopt; + } + + const auto cpa_size = osi::PresentationSpanCodec::cpa_accepting_size( + request.presentation, default_accept_aare_size()); + if (!cpa_size || *cpa_size > std::numeric_limits::max()) { + return std::nullopt; + } + + const auto user_parameter_bytes = 2U + *cpa_size; + if (user_parameter_bytes > std::numeric_limits::max() || + request.session.parameter_bytes.size() > + std::numeric_limits::max() - user_parameter_bytes) { + return std::nullopt; + } + const auto body_bytes = request.session.parameter_bytes.size() + user_parameter_bytes; + return body_bytes + 2U; +} + +wire::EncodeResult AcseSpanCodec::build_accept_response_into( + const AssociationRequestView& request, + const std::span destination) noexcept { + const auto required = accept_response_size(request); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + std::array aare{}; + const auto aare_result = encode_default_accept_aare_into(aare); + const auto cpa_size = osi::PresentationSpanCodec::cpa_accepting_size( + request.presentation, aare_result.bytes_written); + if (!aare_result.success() || !cpa_size || + *cpa_size > std::numeric_limits::max()) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + const auto body_bytes = *required - 2U; + destination[0] = osi::SessionSpanCodec::accept_code; + destination[1] = static_cast(body_bytes); + std::copy( + request.session.parameter_bytes.begin(), + request.session.parameter_bytes.end(), + destination.begin() + 2); + + std::size_t offset = 2U + request.session.parameter_bytes.size(); + destination[offset++] = osi::SessionSpanCodec::user_data_parameter; + destination[offset++] = static_cast(*cpa_size); + + const auto cpa_result = osi::PresentationSpanCodec::encode_cpa_accepting_into( + request.presentation, + std::span{aare}.first(aare_result.bytes_written), + destination.subspan(offset, *cpa_size)); + if (!cpa_result.success() || cpa_result.bytes_written != *cpa_size) { + return {cpa_result.status, 0U, *required}; + } + offset += cpa_result.bytes_written; + + if (offset != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + +} // namespace ar::iec61850::acse diff --git a/src/asn1/ber_span_reader.cpp b/src/asn1/ber_span_reader.cpp new file mode 100644 index 000000000..65b4e5aa6 --- /dev/null +++ b/src/asn1/ber_span_reader.cpp @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::asn1 { + +bool BerSpanReader::try_read_tlv( + const std::span source, + std::size_t& offset, + BerTlvView& tlv) noexcept { + tlv = {}; + if (offset >= source.size()) { + return false; + } + + auto cursor = offset; + const auto start = cursor; + const auto encoded_tag = source[cursor++]; + std::int32_t tag_number = static_cast(encoded_tag & 0x1FU); + + if (tag_number == 0x1F) { + tag_number = 0; + bool read_any = false; + bool terminated = false; + while (cursor < source.size()) { + const auto byte = source[cursor++]; + read_any = true; + if (tag_number > (1'000'000 >> 7)) { + return false; + } + const auto next_tag = + (static_cast(tag_number) << 7U) | + static_cast(byte & 0x7FU); + tag_number = static_cast(next_tag); + if (tag_number > 1'000'000) { + return false; + } + if ((byte & 0x80U) == 0U) { + terminated = true; + break; + } + } + if (!read_any || !terminated) { + return false; + } + } + + if (cursor >= source.size()) { + return false; + } + + const auto length_byte = source[cursor++]; + std::size_t length = 0U; + if ((length_byte & 0x80U) == 0U) { + length = static_cast(length_byte); + } else { + const auto length_bytes = static_cast(length_byte & 0x7FU); + if (length_bytes == 0U || length_bytes > 4U || + length_bytes > source.size() - cursor) { + return false; + } + for (std::size_t index = 0U; index < length_bytes; ++index) { + length = (length << 8U) | + static_cast(source[cursor++]); + } + } + + if (length > source.size() - cursor) { + return false; + } + + const auto header_bytes = cursor - start; + const auto encoded_bytes = header_bytes + length; + tlv.encoded_tag = encoded_tag; + tlv.tag_class = static_cast((encoded_tag >> 6U) & 0x03U); + tlv.constructed = (encoded_tag & 0x20U) != 0U; + tlv.tag_number = tag_number; + tlv.header_bytes = header_bytes; + tlv.encoded_bytes = encoded_bytes; + tlv.value = source.subspan(cursor, length); + offset = cursor + length; + return true; +} + +bool BerSpanReader::try_read_exact( + const std::span source, + BerTlvView& tlv) noexcept { + std::size_t offset = 0U; + return try_read_tlv(source, offset, tlv) && offset == source.size(); +} + +std::optional BerSpanReader::read_unsigned_integer( + const BerTlvView& tlv) noexcept { + if (tlv.value.size() > sizeof(std::uint64_t)) { + return std::nullopt; + } + + std::uint64_t value = 0U; + for (const auto byte : tlv.value) { + value = (value << 8U) | static_cast(byte); + } + return value; +} + +std::optional BerSpanReader::read_signed_integer( + const BerTlvView& tlv) noexcept { + if (tlv.value.empty()) { + return std::int64_t{0}; + } + if (tlv.value.size() > sizeof(std::int64_t)) { + return std::nullopt; + } + + std::uint64_t raw = 0U; + for (const auto byte : tlv.value) { + raw = (raw << 8U) | static_cast(byte); + } + if ((tlv.value.front() & 0x80U) != 0U && + tlv.value.size() < sizeof(std::int64_t)) { + const auto used_bits = static_cast(tlv.value.size() * 8U); + raw |= (~std::uint64_t{0}) << used_bits; + } + return std::bit_cast(raw); +} + +std::optional BerSpanReader::read_uint32( + const BerTlvView& tlv) noexcept { + const auto value = read_unsigned_integer(tlv); + if (!value || *value > std::numeric_limits::max()) { + return std::nullopt; + } + return static_cast(*value); +} + +} // namespace ar::iec61850::asn1 diff --git a/src/mms/connection_runtime.cpp b/src/mms/connection_runtime.cpp new file mode 100644 index 000000000..88e18abfe --- /dev/null +++ b/src/mms/connection_runtime.cpp @@ -0,0 +1,408 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/connection_runtime.hpp" + +#include "ariec61850/acse/association_span.hpp" +#include "ariec61850/mms/pdu_span.hpp" +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] MmsStaticConnectionResult make_result( + const MmsStaticConnectionStatus status, + const MmsStaticConnectionState state, + const std::size_t consumed = 0U, + const std::size_t written = 0U) noexcept { + MmsStaticConnectionResult result; + result.status = status; + result.state = state; + result.consumed_bytes = consumed; + result.bytes_written = written; + return result; +} + +[[nodiscard]] MmsStaticConnectionResult make_response_capacity( + const MmsStaticConnectionState state, + const std::size_t required) noexcept { + auto result = make_result( + MmsStaticConnectionStatus::response_buffer_too_small, state); + result.required_response_bytes = required; + return result; +} + +[[nodiscard]] MmsStaticConnectionResult make_workspace_capacity( + const MmsStaticConnectionState state, + const std::size_t required) noexcept { + auto result = make_result( + MmsStaticConnectionStatus::workspace_too_small, state); + result.required_workspace_bytes = required; + return result; +} + +[[nodiscard]] bool add_overhead( + const std::size_t base, + const std::size_t overhead, + std::size_t& total) noexcept { + if (base > std::numeric_limits::max() - overhead) { + total = 0U; + return false; + } + total = base + overhead; + return true; +} + +[[nodiscard]] MmsStaticConnectionResult wrap_cotp_data_response( + const std::span session_or_presentation, + const std::size_t consumed, + const MmsStaticConnectionState state, + const std::span response, + const std::span workspace) noexcept { + // COTP Data adds three bytes and TPKT adds four bytes. Preflight both + // caller-owned buffers so a capacity retry never advances connection state. + std::size_t required{}; + if (!add_overhead(session_or_presentation.size(), 7U, required)) { + return make_result(MmsStaticConnectionStatus::backend_failure, state); + } + if (response.size() < required) { + return make_response_capacity(state, required); + } + if (workspace.size() < required) { + return make_workspace_capacity(state, required); + } + + const auto cotp = osi::CotpSpanCodec::encode_data_into( + session_or_presentation, + response.first(required - osi::TpktSpanCodec::header_length)); + if (!cotp.success()) { + if (cotp.status == wire::EncodeStatus::buffer_too_small) { + return make_response_capacity( + state, cotp.required_bytes + osi::TpktSpanCodec::header_length); + } + return make_result(MmsStaticConnectionStatus::backend_failure, state); + } + + const auto tpkt = osi::TpktSpanCodec::encode_into( + response.first(cotp.bytes_written), workspace.first(required)); + if (!tpkt.success() || tpkt.bytes_written != required) { + if (tpkt.status == wire::EncodeStatus::buffer_too_small) { + return make_workspace_capacity(state, tpkt.required_bytes); + } + return make_result(MmsStaticConnectionStatus::backend_failure, state); + } + std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin()); + return make_result( + MmsStaticConnectionStatus::response_ready, + state, + consumed, + tpkt.bytes_written); +} + +[[nodiscard]] MmsStaticConnectionResult application_rejected( + const MmsStaticConnectionState state, + const std::size_t consumed, + const MmsStaticDispatchResult& application) noexcept { + auto result = make_result( + MmsStaticConnectionStatus::application_rejected, state, consumed); + result.application_status = application.status; + result.application_service = application.service; + result.invoke_id = application.invoke_id; + return result; +} + +[[nodiscard]] bool write_outer_capacity( + const MmsConfirmedPduView& confirmed, + const MmsStaticApplicationDispatcher& dispatcher, + const std::uint32_t presentation_context_id, + std::size_t& required) noexcept { + required = 0U; + if (confirmed.service() != MmsWireConfirmedService::write) { + return false; + } + + MmsWriteRequestView write; + if (!MmsServiceSpanCodec::try_decode_write_request(confirmed, write) || + write.variable_count == 0U || + write.variable_count > dispatcher.policy().maximum_write_variables) { + return false; + } + + std::array worst{}; + for (std::size_t index = 0U; index < write.variable_count; ++index) { + worst[index] = MmsWriteAccessResultInput{ + false, + std::numeric_limits::max()}; + } + const auto probe = MmsServiceSpanCodec::encode_write_response_into( + confirmed.invoke_id, + std::span{worst}.first(write.variable_count), + {}); + if (probe.status != wire::EncodeStatus::buffer_too_small || + probe.required_bytes == 0U) { + return false; + } + const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( + presentation_context_id, probe.required_bytes); + return fully_encoded && add_overhead(*fully_encoded, 11U, required); +} + +} // namespace + +MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( + const std::span tcp_bytes, + const std::span response, + const std::span workspace) noexcept { + if (state_ == MmsStaticConnectionState::closed) { + return make_result(MmsStaticConnectionStatus::closed, state_); + } + if (state_ == MmsStaticConnectionState::fault) { + return make_result(MmsStaticConnectionStatus::protocol_violation, state_); + } + + const auto peek = osi::TpktSpanCodec::peek_frame(tcp_bytes); + if (peek.status == osi::TpktPeekStatus::need_more) { + return make_result(MmsStaticConnectionStatus::need_more, state_); + } + if (peek.status != osi::TpktPeekStatus::ready || peek.frame_bytes == 0U) { + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::malformed_transport, state_); + } + + const auto frame_bytes = tcp_bytes.first(peek.frame_bytes); + osi::TpktFrameView tpkt; + osi::CotpTpduView cotp; + if (!osi::TpktSpanCodec::try_decode_view(frame_bytes, tpkt) || + !osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp)) { + state_ = MmsStaticConnectionState::fault; + return make_result( + MmsStaticConnectionStatus::malformed_transport, state_, peek.frame_bytes); + } + + if (cotp.kind == osi::CotpWireKind::disconnect_request) { + state_ = MmsStaticConnectionState::closed; + return make_result( + MmsStaticConnectionStatus::closed, state_, peek.frame_bytes); + } + + if (state_ == MmsStaticConnectionState::awaiting_cotp_connect) { + if (cotp.kind != osi::CotpWireKind::connection_request) { + state_ = MmsStaticConnectionState::fault; + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + const auto cc = osi::CotpSpanCodec::encode_connection_confirm_from_request_into( + cotp, + policy_.cotp_source_reference, + workspace, + policy_.maximum_tpdu_size_code); + if (!cc.success()) { + if (cc.status == wire::EncodeStatus::buffer_too_small) { + return make_workspace_capacity(state_, cc.required_bytes); + } + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + + std::size_t required{}; + if (!add_overhead(cc.bytes_written, osi::TpktSpanCodec::header_length, required)) { + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + if (response.size() < required) { + return make_response_capacity(state_, required); + } + const auto encoded = osi::TpktSpanCodec::encode_into( + workspace.first(cc.bytes_written), response.first(required)); + if (!encoded.success()) { + if (encoded.status == wire::EncodeStatus::buffer_too_small) { + return make_response_capacity(state_, encoded.required_bytes); + } + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + + state_ = MmsStaticConnectionState::awaiting_association; + return make_result( + MmsStaticConnectionStatus::response_ready, + state_, + peek.frame_bytes, + encoded.bytes_written); + } + + if (cotp.kind != osi::CotpWireKind::data || + (policy_.require_end_of_transmission && !cotp.end_of_transmission)) { + state_ = MmsStaticConnectionState::fault; + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + if (state_ == MmsStaticConnectionState::awaiting_association) { + acse::AssociationRequestView association; + if (!acse::AcseSpanCodec::try_decode_association_request_view( + cotp.user_data, association)) { + state_ = MmsStaticConnectionState::fault; + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + MmsInitiateView initiate; + if (!MmsPduSpanCodec::try_decode_initiate_request_view( + association.aarq.user_information.single_asn1_type, initiate) || + association.mms_presentation_context_id == 0U) { + state_ = MmsStaticConnectionState::fault; + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + const auto accept = acse::AcseSpanCodec::build_accept_response_into( + association, workspace); + if (!accept.success()) { + if (accept.status == wire::EncodeStatus::buffer_too_small) { + return make_workspace_capacity(state_, accept.required_bytes); + } + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + + const auto wrapped = wrap_cotp_data_response( + workspace.first(accept.bytes_written), + peek.frame_bytes, + MmsStaticConnectionState::established, + response, + workspace); + if (!wrapped.response_ready()) { + return wrapped; + } + mms_presentation_context_id_ = association.mms_presentation_context_id; + state_ = MmsStaticConnectionState::established; + auto result = wrapped; + result.state = state_; + return result; + } + + if (state_ != MmsStaticConnectionState::established) { + state_ = MmsStaticConnectionState::fault; + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + osi::SessionDataTransferView session; + osi::PresentationPdvView pdv; + if (!osi::SessionSpanCodec::try_decode_data_transfer_view(cotp.user_data, session) || + !osi::PresentationSpanCodec::try_decode_fully_encoded_data_view( + session.presentation_payload, pdv) || + pdv.context_id != mms_presentation_context_id_) { + state_ = MmsStaticConnectionState::fault; + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + MmsConfirmedPduView confirmed; + if (MmsPduSpanCodec::try_decode_confirmed_request_view( + pdv.single_asn1_type, confirmed)) { + std::size_t write_required{}; + if (write_outer_capacity( + confirmed, dispatcher_, mms_presentation_context_id_, write_required)) { + if (response.size() < write_required) { + return make_response_capacity(state_, write_required); + } + if (workspace.size() < write_required) { + return make_workspace_capacity(state_, write_required); + } + } + } + + const auto application = dispatcher_.dispatch( + pdv.single_asn1_type, response, workspace); + if (!application.success()) { + if (application.status == MmsStaticDispatchStatus::response_buffer_too_small) { + const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( + mms_presentation_context_id_, application.required_bytes); + std::size_t required{}; + if (fully_encoded && add_overhead(*fully_encoded, 11U, required)) { + auto result = make_response_capacity(state_, required); + result.application_status = application.status; + result.application_service = application.service; + result.invoke_id = application.invoke_id; + return result; + } + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + if (application.status == MmsStaticDispatchStatus::workspace_too_small) { + auto result = make_workspace_capacity(state_, application.required_bytes); + result.application_status = application.status; + result.application_service = application.service; + result.invoke_id = application.invoke_id; + return result; + } + if (application.status == MmsStaticDispatchStatus::backend_failure || + application.status == MmsStaticDispatchStatus::invalid_object_table) { + state_ = MmsStaticConnectionState::fault; + auto result = make_result( + MmsStaticConnectionStatus::backend_failure, + state_, + peek.frame_bytes); + result.application_status = application.status; + result.application_service = application.service; + result.invoke_id = application.invoke_id; + return result; + } + return application_rejected(state_, peek.frame_bytes, application); + } + + const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( + response.first(application.bytes_written), + workspace, + mms_presentation_context_id_, + true); + if (!p_data.success()) { + if (p_data.status == wire::EncodeStatus::buffer_too_small) { + std::size_t required{}; + if (add_overhead(p_data.required_bytes, 7U, required)) { + return make_workspace_capacity(state_, required); + } + } + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + + auto wrapped = wrap_cotp_data_response( + workspace.first(p_data.bytes_written), + peek.frame_bytes, + state_, + response, + workspace); + if (wrapped.response_ready()) { + wrapped.application_status = application.status; + wrapped.application_service = application.service; + wrapped.invoke_id = application.invoke_id; + } + return wrapped; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/data_set_span.cpp b/src/mms/data_set_span.cpp new file mode 100644 index 000000000..4deb74bc9 --- /dev/null +++ b/src/mms/data_set_span.cpp @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/data_set_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool valid_identifier(const std::string_view value) noexcept { + if (value.empty() || value.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const char ch : value) { + const auto byte = static_cast(ch); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] bool write_positive_integer( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + if (needs_positive_prefix(value, minimal) && !writer.write_byte(0x00U)) { + return false; + } + for (std::size_t index = minimal; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional checked_add( + const std::optional left, + const std::optional right) noexcept { + if (!left || !right || *right > std::numeric_limits::max() - *left) { + return std::nullopt; + } + return *left + *right; +} + +[[nodiscard]] std::optional member_encoded_size( + const MmsNamedVariableListMemberInput& member) noexcept { + if (!valid_identifier(member.domain) || !valid_identifier(member.item)) { + return std::nullopt; + } + const auto domain = asn1::BerSpanWriter::tlv_size(26, member.domain.size()); + const auto item = asn1::BerSpanWriter::tlv_size(26, member.item.size()); + const auto object_content = checked_add(domain, item); + if (!object_content) { + return std::nullopt; + } + const auto object_name = asn1::BerSpanWriter::tlv_size(1, *object_content); + if (!object_name) { + return std::nullopt; + } + const auto specification = asn1::BerSpanWriter::tlv_size(0, *object_name); + if (!specification) { + return std::nullopt; + } + return asn1::BerSpanWriter::tlv_size(16, *specification); +} + +struct ResponseSizing final { + std::size_t invoke_value{}; + std::size_t list_content{}; + std::size_t service_content{}; + std::size_t outer_content{}; + std::size_t required{}; +}; + +[[nodiscard]] std::optional response_sizing( + const std::uint32_t invoke_id, + const std::span members) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id || + members.size() > MmsDataSetSpanCodec::maximum_members) { + return std::nullopt; + } + + std::size_t list_content = 0U; + for (const auto& member : members) { + const auto encoded = member_encoded_size(member); + if (!encoded || *encoded > std::numeric_limits::max() - list_content) { + return std::nullopt; + } + list_content += *encoded; + } + + const auto list_tlv = asn1::BerSpanWriter::tlv_size(1, list_content); + const auto deletable_tlv = asn1::BerSpanWriter::tlv_size(0, 1U); + const auto service_content = checked_add(deletable_tlv, list_tlv); + if (!list_tlv || !deletable_tlv || !service_content) { + return std::nullopt; + } + + const auto invoke_value = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(2, invoke_value); + const auto service_tlv = asn1::BerSpanWriter::tlv_size(12, *service_content); + const auto outer_content = checked_add(invoke_tlv, service_tlv); + if (!invoke_tlv || !service_tlv || !outer_content) { + return std::nullopt; + } + const auto required = asn1::BerSpanWriter::tlv_size(1, *outer_content); + if (!required || *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return std::nullopt; + } + + return ResponseSizing{ + invoke_value, + list_content, + *service_content, + *outer_content, + *required}; +} + +[[nodiscard]] bool decode_member_definition( + const asn1::BerTlvView& definition, + MmsObjectNameView& name) noexcept { + name = {}; + if (definition.tag_class != asn1::BerClass::universal || + definition.tag_number != 16 || !definition.constructed) { + return false; + } + asn1::BerTlvView specification; + if (!asn1::BerSpanReader::try_read_exact(definition.value, specification) || + specification.tag_class != asn1::BerClass::context_specific || + specification.tag_number != 0 || !specification.constructed) { + return false; + } + if (!MmsServiceSpanCodec::try_decode_object_name_view(specification.value, name)) { + return false; + } + return name.kind == MmsObjectNameViewKind::domain_specific && + !name.domain.empty() && !name.item.empty(); +} + +[[nodiscard]] bool validate_member_list( + const std::span bytes, + std::size_t& count) noexcept { + count = 0U; + std::size_t offset = 0U; + while (offset < bytes.size()) { + if (count >= MmsDataSetSpanCodec::maximum_members) { + count = 0U; + return false; + } + asn1::BerTlvView definition; + MmsObjectNameView name; + if (!asn1::BerSpanReader::try_read_tlv(bytes, offset, definition) || + !decode_member_definition(definition, name)) { + count = 0U; + return false; + } + ++count; + } + return true; +} + +} // namespace + +bool MmsNamedVariableListAttributesResponseView::try_member( + const std::size_t index, + MmsObjectNameView& name) const noexcept { + name = {}; + if (index >= member_count) { + return false; + } + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < variable_list.size()) { + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv(variable_list, offset, definition)) { + return false; + } + if (current == index) { + return decode_member_definition(definition, name); + } + ++current; + } + return false; +} + +bool MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_request( + const MmsConfirmedPduView& confirmed, + MmsNamedVariableListAttributesRequestView& request) noexcept { + request = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_request || + confirmed.service_tag != 12 || !confirmed.service_constructed) { + return false; + } + MmsObjectNameView name; + if (!MmsServiceSpanCodec::try_decode_object_name_view(confirmed.service_value, name) || + name.kind != MmsObjectNameViewKind::domain_specific) { + return false; + } + request.invoke_id = confirmed.invoke_id; + request.name = name; + return true; +} + +bool MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_request( + const std::span mms_pdu, + MmsNamedVariableListAttributesRequestView& request) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_request_view(mms_pdu, confirmed) && + try_decode_get_named_variable_list_attributes_request(confirmed, request); +} + +bool MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_response( + const MmsConfirmedPduView& confirmed, + MmsNamedVariableListAttributesResponseView& response) noexcept { + response = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_response || + confirmed.service_tag != 12 || !confirmed.service_constructed) { + return false; + } + + std::size_t offset = 0U; + asn1::BerTlvView deletable; + asn1::BerTlvView list; + if (!asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, deletable) || + !asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, list) || + offset != confirmed.service_value.size() || + deletable.tag_class != asn1::BerClass::context_specific || + deletable.tag_number != 0 || deletable.constructed || deletable.value.size() != 1U || + list.tag_class != asn1::BerClass::context_specific || + list.tag_number != 1 || !list.constructed) { + return false; + } + + std::size_t member_count = 0U; + if (!validate_member_list(list.value, member_count)) { + return false; + } + response.invoke_id = confirmed.invoke_id; + response.mms_deletable = deletable.value.front() != 0U; + response.variable_list = list.value; + response.member_count = member_count; + return true; +} + +bool MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_response( + const std::span mms_pdu, + MmsNamedVariableListAttributesResponseView& response) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_response_view(mms_pdu, confirmed) && + try_decode_get_named_variable_list_attributes_response(confirmed, response); +} + +wire::EncodeResult MmsDataSetSpanCodec::encode_get_named_variable_list_attributes_response_into( + const std::uint32_t invoke_id, + const bool mms_deletable, + const std::span members, + const std::span destination) noexcept { + const auto sizing = response_sizing(invoke_id, members); + if (!sizing) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizing->required) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizing->required}; + } + + asn1::BerSpanWriter writer{destination.first(sizing->required)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, sizing->outer_content) || + !writer.write_tlv_header( + asn1::BerClass::universal, false, 2, sizing->invoke_value) || + !write_positive_integer(writer, invoke_id) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 12, sizing->service_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, 1U) || + !writer.write_byte(mms_deletable ? 0xFFU : 0x00U) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, sizing->list_content)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + + for (const auto& member : members) { + const auto domain_tlv = asn1::BerSpanWriter::tlv_size(26, member.domain.size()); + const auto item_tlv = asn1::BerSpanWriter::tlv_size(26, member.item.size()); + const auto object_content = checked_add(domain_tlv, item_tlv); + const auto object_name = object_content + ? asn1::BerSpanWriter::tlv_size(1, *object_content) + : std::nullopt; + const auto specification = object_name + ? asn1::BerSpanWriter::tlv_size(0, *object_name) + : std::nullopt; + if (!domain_tlv || !item_tlv || !object_content || !object_name || !specification || + !writer.write_tlv_header( + asn1::BerClass::universal, true, 16, *specification) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *object_name) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, *object_content) || + !writer.write_tlv( + asn1::BerClass::universal, + false, + 26, + std::span{ + reinterpret_cast(member.domain.data()), + member.domain.size()}) || + !writer.write_tlv( + asn1::BerClass::universal, + false, + 26, + std::span{ + reinterpret_cast(member.item.data()), + member.item.size()})) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + } + + if (writer.size() != sizing->required) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + return {wire::EncodeStatus::ok, sizing->required, sizing->required}; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/pdu_span.cpp b/src/mms/pdu_span.cpp new file mode 100644 index 000000000..e0236d03c --- /dev/null +++ b/src/mms/pdu_span.cpp @@ -0,0 +1,399 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/pdu_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::array kDefaultInitiateResponse{ + 0xA9U, 0x26U, + 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, + 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, + 0x83U, 0x01U, 0x05U, + 0xA4U, 0x16U, + 0x80U, 0x01U, 0x01U, + 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, + 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +[[nodiscard]] bool read_u32( + const asn1::BerTlvView& tlv, + std::uint32_t& value) noexcept { + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded) { + value = 0U; + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool decode_detail( + const asn1::BerTlvView& field, + MmsInitiateDetailView& detail) noexcept { + detail = {}; + if (field.tag_class != asn1::BerClass::context_specific || + field.tag_number != 4 || !field.constructed) { + return false; + } + + std::array seen{}; + std::size_t count = 0U; + std::size_t offset = 0U; + while (offset < field.value.size()) { + if (count >= 3U) { + detail = {}; + return false; + } + ++count; + asn1::BerTlvView child; + if (!asn1::BerSpanReader::try_read_tlv(field.value, offset, child) || + child.tag_class != asn1::BerClass::context_specific || + child.constructed || child.tag_number < 0 || child.tag_number > 2) { + detail = {}; + return false; + } + const auto index = static_cast(child.tag_number); + if (seen[index]) { + detail = {}; + return false; + } + seen[index] = true; + + if (index == 0U) { + if (!read_u32(child, detail.version_number)) { + detail = {}; + return false; + } + } else if (index == 1U) { + if (child.value.empty() || child.value.size() > + MmsPduSpanCodec::maximum_bit_string_bytes) { + detail = {}; + return false; + } + detail.parameter_support_options = child.value; + } else { + if (child.value.empty() || child.value.size() > + MmsPduSpanCodec::maximum_bit_string_bytes) { + detail = {}; + return false; + } + detail.services_supported_calling = child.value; + } + } + return count == 3U && seen[0] && seen[1] && seen[2]; +} + +[[nodiscard]] bool decode_initiate( + const std::span bytes, + const std::int32_t expected_tag, + const MmsWirePduKind kind, + MmsInitiateView& result) noexcept { + result = {}; + if (bytes.empty() || bytes.size() > MmsPduSpanCodec::maximum_pdu_bytes) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(bytes, outer) || + outer.tag_class != asn1::BerClass::context_specific || + outer.tag_number != expected_tag || !outer.constructed) { + return false; + } + + std::array seen{}; + std::array values{}; + MmsInitiateDetailView detail; + std::size_t count = 0U; + std::size_t offset = 0U; + while (offset < outer.value.size()) { + if (count >= 5U) { + result = {}; + return false; + } + ++count; + asn1::BerTlvView child; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, child) || + child.tag_class != asn1::BerClass::context_specific || + child.tag_number < 0 || child.tag_number > 4) { + result = {}; + return false; + } + const auto index = static_cast(child.tag_number); + if (seen[index]) { + result = {}; + return false; + } + seen[index] = true; + + if (index < 4U) { + if (child.constructed || !read_u32(child, values[index])) { + result = {}; + return false; + } + } else if (!decode_detail(child, detail)) { + result = {}; + return false; + } + } + + if (count != 5U || + !std::all_of(seen.begin(), seen.end(), [](const bool value) { return value; }) || + values[0] < 64U || values[0] > MmsPduSpanCodec::maximum_pdu_bytes || + values[1] == 0U || values[2] == 0U || values[3] == 0U) { + result = {}; + return false; + } + + result.kind = kind; + result.maximum_mms_pdu_size = values[0]; + result.maximum_outstanding_calling = values[1]; + result.maximum_outstanding_called = values[2]; + result.data_structure_nesting_level = values[3]; + result.detail = detail; + return true; +} + +[[nodiscard]] bool decode_invoke( + const asn1::BerTlvView& tlv, + std::uint32_t& invoke_id) noexcept { + const bool universal_integer = + tlv.tag_class == asn1::BerClass::universal && + tlv.tag_number == 2 && !tlv.constructed; + const bool implicit_invoke = + tlv.tag_class == asn1::BerClass::context_specific && + tlv.tag_number == 0 && !tlv.constructed; + if ((!universal_integer && !implicit_invoke) || !read_u32(tlv, invoke_id) || + invoke_id > MmsPduSpanCodec::maximum_invoke_id) { + invoke_id = 0U; + return false; + } + return true; +} + +[[nodiscard]] bool decode_confirmed( + const std::span bytes, + const std::int32_t expected_outer_tag, + const MmsWirePduKind kind, + MmsConfirmedPduView& result) noexcept { + result = {}; + if (bytes.empty() || bytes.size() > MmsPduSpanCodec::maximum_pdu_bytes) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(bytes, outer) || + outer.tag_class != asn1::BerClass::context_specific || + outer.tag_number != expected_outer_tag || !outer.constructed) { + return false; + } + + asn1::BerTlvView first; + asn1::BerTlvView last; + std::size_t count = 0U; + std::size_t offset = 0U; + while (offset < outer.value.size()) { + if (count >= 4U) { + result = {}; + return false; + } + asn1::BerTlvView child; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, child)) { + result = {}; + return false; + } + if (count == 0U) { + first = child; + } + last = child; + ++count; + } + + if (count < 2U || !decode_invoke(first, result.invoke_id) || + last.tag_class != asn1::BerClass::context_specific || + last.tag_number < 0) { + result = {}; + return false; + } + + result.kind = kind; + result.service_tag = last.tag_number; + result.service_constructed = last.constructed; + result.service_value = last.value; + return true; +} + +[[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] bool write_positive_integer( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + if (needs_positive_prefix(value, minimal) && !writer.write_byte(0x00U)) { + return false; + } + for (std::size_t index = minimal; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast( + (value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] wire::EncodeResult encode_confirmed( + const std::int32_t outer_tag, + const std::uint32_t invoke_id, + const std::int32_t service_tag, + const bool service_constructed, + const std::span service_value, + const std::span destination) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id || + service_tag < 0 || service_tag > 1'000'000 || + service_value.size() > MmsPduSpanCodec::maximum_pdu_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto invoke_value = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(2, invoke_value); + const auto service_tlv = asn1::BerSpanWriter::tlv_size( + service_tag, service_value.size()); + if (!invoke_tlv || !service_tlv || + *service_tlv > std::numeric_limits::max() - *invoke_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto content = *invoke_tlv + *service_tlv; + const auto required = asn1::BerSpanWriter::tlv_size(outer_tag, content); + if (!required || *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, outer_tag, content) || + !writer.write_tlv_header( + asn1::BerClass::universal, false, 2, invoke_value) || + !write_positive_integer(writer, invoke_id) || + !writer.write_tlv( + asn1::BerClass::context_specific, + service_constructed, + service_tag, + service_value) || + writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + +} // namespace + +MmsWireConfirmedService MmsConfirmedPduView::service() const noexcept { + switch (service_tag) { + case 1: return MmsWireConfirmedService::get_name_list; + case 2: return MmsWireConfirmedService::identify; + case 4: return MmsWireConfirmedService::read; + case 5: return MmsWireConfirmedService::write; + case 6: return MmsWireConfirmedService::get_variable_access_attributes; + case 12: return MmsWireConfirmedService::get_named_variable_list_attributes; + default: return MmsWireConfirmedService::unknown; + } +} + +bool MmsPduSpanCodec::try_decode_initiate_request_view( + const std::span bytes, + MmsInitiateView& request) noexcept { + return decode_initiate(bytes, 8, MmsWirePduKind::initiate_request, request); +} + +bool MmsPduSpanCodec::try_decode_initiate_response_view( + const std::span bytes, + MmsInitiateView& response) noexcept { + return decode_initiate(bytes, 9, MmsWirePduKind::initiate_response, response); +} + +wire::EncodeResult MmsPduSpanCodec::encode_default_initiate_response_into( + const std::span destination) noexcept { + if (destination.size() < kDefaultInitiateResponse.size()) { + return { + wire::EncodeStatus::buffer_too_small, + 0U, + kDefaultInitiateResponse.size()}; + } + std::copy( + kDefaultInitiateResponse.begin(), + kDefaultInitiateResponse.end(), + destination.begin()); + return { + wire::EncodeStatus::ok, + kDefaultInitiateResponse.size(), + kDefaultInitiateResponse.size()}; +} + +bool MmsPduSpanCodec::try_decode_confirmed_request_view( + const std::span bytes, + MmsConfirmedPduView& request) noexcept { + return decode_confirmed(bytes, 0, MmsWirePduKind::confirmed_request, request); +} + +bool MmsPduSpanCodec::try_decode_confirmed_response_view( + const std::span bytes, + MmsConfirmedPduView& response) noexcept { + return decode_confirmed(bytes, 1, MmsWirePduKind::confirmed_response, response); +} + +wire::EncodeResult MmsPduSpanCodec::encode_confirmed_request_into( + const std::uint32_t invoke_id, + const std::int32_t service_tag, + const bool service_constructed, + const std::span service_value, + const std::span destination) noexcept { + return encode_confirmed( + 0, invoke_id, service_tag, service_constructed, service_value, destination); +} + +wire::EncodeResult MmsPduSpanCodec::encode_confirmed_response_into( + const std::uint32_t invoke_id, + const std::int32_t service_tag, + const bool service_constructed, + const std::span service_value, + const std::span destination) noexcept { + return encode_confirmed( + 1, invoke_id, service_tag, service_constructed, service_value, destination); +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/services_span.cpp b/src/mms/services_span.cpp new file mode 100644 index 000000000..e68152280 --- /dev/null +++ b/src/mms/services_span.cpp @@ -0,0 +1,971 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/services_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool visible_ascii( + const std::span value, + const bool allow_empty = false) noexcept { + if ((!allow_empty && value.empty()) || + value.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const auto byte : value) { + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool visible_ascii( + const std::string_view value, + const bool allow_empty = false) noexcept { + if ((!allow_empty && value.empty()) || + value.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const auto character : value) { + const auto byte = static_cast(character); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool read_u32( + const asn1::BerTlvView& tlv, + std::uint32_t& value) noexcept { + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded) { + value = 0U; + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool valid_identifier_tlv( + const asn1::BerTlvView& tlv) noexcept { + return !tlv.constructed && + tlv.tag_class == asn1::BerClass::universal && + (tlv.tag_number == 22 || tlv.tag_number == 26) && + visible_ascii(tlv.value); +} + +[[nodiscard]] bool valid_mms_data_tlv( + const asn1::BerTlvView& tlv) noexcept { + if (tlv.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (tlv.tag_number == 1 || tlv.tag_number == 2) { + return tlv.constructed; + } + return tlv.tag_number >= 3 && tlv.tag_number <= 17 && !tlv.constructed; +} + +[[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] bool write_positive_integer( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + if (needs_positive_prefix(value, minimal) && !writer.write_byte(0x00U)) { + return false; + } + for (std::size_t index = minimal; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast( + (value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional add_size( + const std::optional left, + const std::optional right) noexcept { + if (!left || !right || *right > std::numeric_limits::max() - *left) { + return std::nullopt; + } + return *left + *right; +} + +struct ConfirmedResponseSizing final { + std::size_t invoke_value{}; + std::size_t outer_content{}; + std::size_t required{}; +}; + +[[nodiscard]] std::optional confirmed_response_sizing( + const std::uint32_t invoke_id, + const std::int32_t service_tag, + const std::size_t service_content) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id || + service_tag < 0 || service_tag > 1'000'000) { + return std::nullopt; + } + const auto invoke_value = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(2, invoke_value); + const auto service_tlv = asn1::BerSpanWriter::tlv_size(service_tag, service_content); + const auto outer_content = add_size(invoke_tlv, service_tlv); + if (!outer_content) { + return std::nullopt; + } + const auto required = asn1::BerSpanWriter::tlv_size(1, *outer_content); + if (!required || *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return std::nullopt; + } + return ConfirmedResponseSizing{invoke_value, *outer_content, *required}; +} + +[[nodiscard]] bool write_confirmed_response_prefix( + asn1::BerSpanWriter& writer, + const ConfirmedResponseSizing& sizing, + const std::uint32_t invoke_id, + const std::int32_t service_tag, + const std::size_t service_content) noexcept { + return writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, sizing.outer_content) && + writer.write_tlv_header( + asn1::BerClass::universal, false, 2, sizing.invoke_value) && + write_positive_integer(writer, invoke_id) && + writer.write_tlv_header( + asn1::BerClass::context_specific, true, service_tag, service_content); +} + +[[nodiscard]] bool try_read_variable_definition( + const std::span variable_list, + const std::size_t wanted, + MmsObjectNameView& name) noexcept { + name = {}; + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < variable_list.size()) { + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv(variable_list, offset, definition) || + definition.tag_class != asn1::BerClass::universal || + definition.tag_number != 16 || !definition.constructed) { + return false; + } + asn1::BerTlvView wrapper; + if (!asn1::BerSpanReader::try_read_exact(definition.value, wrapper) || + wrapper.tag_class != asn1::BerClass::context_specific || + wrapper.tag_number != 0 || !wrapper.constructed) { + return false; + } + MmsObjectNameView decoded; + if (!MmsServiceSpanCodec::try_decode_object_name_view(wrapper.value, decoded)) { + return false; + } + if (current == wanted) { + name = decoded; + return true; + } + ++current; + } + return false; +} + +[[nodiscard]] bool try_read_access_result( + const std::span list, + const std::size_t wanted, + MmsReadAccessResultView& result) noexcept { + result = {}; + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < list.size()) { + const auto start = offset; + asn1::BerTlvView access; + if (!asn1::BerSpanReader::try_read_tlv(list, offset, access)) { + return false; + } + MmsReadAccessResultView decoded; + if (access.tag_class == asn1::BerClass::context_specific && + access.tag_number == 0 && !access.constructed) { + if (!read_u32(access, decoded.failure_code)) { + return false; + } + decoded.success = false; + } else if (valid_mms_data_tlv(access)) { + decoded.success = true; + decoded.encoded_data = list.subspan(start, offset - start); + } else { + return false; + } + if (current == wanted) { + result = decoded; + return true; + } + ++current; + } + return false; +} + +} // namespace + +bool MmsServiceSpanCodec::try_decode_object_name_view( + const std::span encoded_object_name, + MmsObjectNameView& name) noexcept { + name = {}; + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(encoded_object_name, outer) || + outer.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (outer.tag_number == 0 && !outer.constructed) { + if (!visible_ascii(outer.value)) { + return false; + } + name.kind = MmsObjectNameViewKind::vmd_specific; + name.item = outer.value; + return true; + } + if (outer.tag_number == 2 && !outer.constructed) { + if (!visible_ascii(outer.value)) { + return false; + } + name.kind = MmsObjectNameViewKind::aa_specific; + name.item = outer.value; + return true; + } + if (outer.tag_number != 1 || !outer.constructed) { + return false; + } + + std::size_t offset = 0U; + asn1::BerTlvView domain; + asn1::BerTlvView item; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, domain) || + !asn1::BerSpanReader::try_read_tlv(outer.value, offset, item) || + offset != outer.value.size() || + !valid_identifier_tlv(domain) || !valid_identifier_tlv(item)) { + return false; + } + name.kind = MmsObjectNameViewKind::domain_specific; + name.domain = domain.value; + name.item = item.value; + return true; +} + +bool MmsGetNameListResponseView::try_identifier( + const std::size_t index, + std::span& identifier) const noexcept { + identifier = {}; + if (index >= identifier_count) { + return false; + } + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < identifier_list.size()) { + asn1::BerTlvView item; + if (!asn1::BerSpanReader::try_read_tlv(identifier_list, offset, item) || + !valid_identifier_tlv(item)) { + return false; + } + if (current == index) { + identifier = item.value; + return true; + } + ++current; + } + return false; +} + +bool MmsReadRequestView::try_variable( + const std::size_t index, + MmsObjectNameView& name) const noexcept { + if (index >= variable_count) { + name = {}; + return false; + } + return try_read_variable_definition(variable_list, index, name); +} + +bool MmsReadResponseView::try_result( + const std::size_t index, + MmsReadAccessResultView& result) const noexcept { + if (index >= result_count) { + result = {}; + return false; + } + return try_read_access_result(access_result_list, index, result); +} + +bool MmsServiceSpanCodec::try_decode_get_name_list_request( + const MmsConfirmedPduView& confirmed, + MmsGetNameListRequestView& request) noexcept { + request = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_request || + confirmed.service_tag != 1 || !confirmed.service_constructed) { + return false; + } + + bool have_class = false; + bool have_scope = false; + bool have_continuation = false; + std::size_t count = 0U; + std::size_t offset = 0U; + while (offset < confirmed.service_value.size()) { + if (count >= 3U) { + request = {}; + return false; + } + ++count; + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv( + confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + request = {}; + return false; + } + + if (field.tag_number == 0 && field.constructed) { + if (have_class) { + request = {}; + return false; + } + asn1::BerTlvView inner; + std::uint32_t value{}; + if (!asn1::BerSpanReader::try_read_exact(field.value, inner) || + inner.tag_class != asn1::BerClass::context_specific || + inner.tag_number != 0 || inner.constructed || + !read_u32(inner, value) || + (value != 0U && value != 2U && value != 9U)) { + request = {}; + return false; + } + request.object_class = static_cast(value); + have_class = true; + } else if (field.tag_number == 1 && field.constructed) { + if (have_scope) { + request = {}; + return false; + } + asn1::BerTlvView inner; + if (!asn1::BerSpanReader::try_read_exact(field.value, inner) || + inner.tag_class != asn1::BerClass::context_specific || + inner.constructed) { + request = {}; + return false; + } + switch (inner.tag_number) { + case 0: + if (!inner.value.empty()) { + request = {}; + return false; + } + request.scope = MmsNameScopeKind::vmd_specific; + break; + case 1: + if (!visible_ascii(inner.value)) { + request = {}; + return false; + } + request.scope = MmsNameScopeKind::domain_specific; + request.domain_id = inner.value; + break; + case 2: + if (!inner.value.empty()) { + request = {}; + return false; + } + request.scope = MmsNameScopeKind::aa_specific; + break; + default: + request = {}; + return false; + } + have_scope = true; + } else if (field.tag_number == 2 && !field.constructed) { + if (have_continuation || !visible_ascii(field.value)) { + request = {}; + return false; + } + request.continue_after = field.value; + have_continuation = true; + } else { + request = {}; + return false; + } + } + + if (!have_class || !have_scope) { + request = {}; + return false; + } + request.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_get_name_list_request( + const std::span mms_pdu, + MmsGetNameListRequestView& request) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_request_view(mms_pdu, confirmed) && + try_decode_get_name_list_request(confirmed, request); +} + +bool MmsServiceSpanCodec::try_decode_get_name_list_response( + const MmsConfirmedPduView& confirmed, + MmsGetNameListResponseView& response) noexcept { + response = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_response || + confirmed.service_tag != 1 || !confirmed.service_constructed) { + return false; + } + + bool have_list = false; + bool have_more = false; + std::size_t offset = 0U; + while (offset < confirmed.service_value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv( + confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + response = {}; + return false; + } + + if (field.tag_number == 0 && field.constructed) { + if (have_list) { + response = {}; + return false; + } + std::size_t name_offset = 0U; + std::size_t name_count = 0U; + while (name_offset < field.value.size()) { + if (name_count >= maximum_identifiers) { + response = {}; + return false; + } + asn1::BerTlvView name; + if (!asn1::BerSpanReader::try_read_tlv( + field.value, name_offset, name) || + !valid_identifier_tlv(name)) { + response = {}; + return false; + } + ++name_count; + } + response.identifier_list = field.value; + response.identifier_count = name_count; + have_list = true; + } else if (field.tag_number == 1 && !field.constructed) { + if (have_more || field.value.size() != 1U) { + response = {}; + return false; + } + response.more_follows = field.value[0] != 0U; + have_more = true; + } else { + response = {}; + return false; + } + } + + if (!have_list) { + response = {}; + return false; + } + response.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_get_name_list_response( + const std::span mms_pdu, + MmsGetNameListResponseView& response) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_response_view(mms_pdu, confirmed) && + try_decode_get_name_list_response(confirmed, response); +} + +wire::EncodeResult MmsServiceSpanCodec::encode_get_name_list_response_into( + const std::uint32_t invoke_id, + const std::span names, + const bool more_follows, + const std::span destination) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id || + names.size() > maximum_identifiers) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::size_t names_content = 0U; + for (const auto name : names) { + if (!visible_ascii(name)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto encoded = asn1::BerSpanWriter::tlv_size(26, name.size()); + if (!encoded || *encoded > std::numeric_limits::max() - names_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + names_content += *encoded; + } + + const auto list_tlv = asn1::BerSpanWriter::tlv_size(0, names_content); + const auto more_tlv = asn1::BerSpanWriter::tlv_size(1, 1U); + const auto service_content = add_size(list_tlv, more_tlv); + if (!service_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto service_tlv = asn1::BerSpanWriter::tlv_size(1, *service_content); + + const auto invoke_value = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(2, invoke_value); + const auto outer_content = add_size(invoke_tlv, service_tlv); + if (!outer_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto required = asn1::BerSpanWriter::tlv_size(1, *outer_content); + if (!required || *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, *outer_content) || + !writer.write_tlv_header( + asn1::BerClass::universal, false, 2, invoke_value) || + !write_positive_integer(writer, invoke_id) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, *service_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, names_content)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + for (const auto name : names) { + if (!writer.write_tlv_header( + asn1::BerClass::universal, false, 26, name.size())) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + for (const auto character : name) { + if (!writer.write_byte(static_cast( + static_cast(character)))) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + } + + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 1, 1U) || + !writer.write_byte(more_follows ? 0xFFU : 0x00U) || + writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + +bool MmsServiceSpanCodec::try_decode_variable_access_attributes_request( + const MmsConfirmedPduView& confirmed, + MmsVariableAccessAttributesRequestView& request) noexcept { + request = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_request || + confirmed.service_tag != 6 || !confirmed.service_constructed) { + return false; + } + asn1::BerTlvView wrapper; + if (!asn1::BerSpanReader::try_read_exact(confirmed.service_value, wrapper) || + wrapper.tag_class != asn1::BerClass::context_specific || + wrapper.tag_number != 0 || !wrapper.constructed || + !try_decode_object_name_view(wrapper.value, request.name)) { + request = {}; + return false; + } + request.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_variable_access_attributes_request( + const std::span mms_pdu, + MmsVariableAccessAttributesRequestView& request) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_request_view(mms_pdu, confirmed) && + try_decode_variable_access_attributes_request(confirmed, request); +} + +bool MmsServiceSpanCodec::try_decode_variable_access_attributes_response( + const MmsConfirmedPduView& confirmed, + MmsVariableAccessAttributesResponseView& response) noexcept { + response = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_response || + confirmed.service_tag != 6 || !confirmed.service_constructed) { + return false; + } + bool have_deletable = false; + bool have_type = false; + std::size_t offset = 0U; + while (offset < confirmed.service_value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + response = {}; + return false; + } + if (field.tag_number == 0 && !field.constructed) { + if (have_deletable || field.value.size() != 1U) { + response = {}; + return false; + } + response.mms_deletable = field.value[0] != 0U; + have_deletable = true; + } else if (field.tag_number == 2 && field.constructed) { + if (have_type) { + response = {}; + return false; + } + asn1::BerTlvView type; + if (!asn1::BerSpanReader::try_read_exact(field.value, type) || + type.tag_class != asn1::BerClass::context_specific) { + response = {}; + return false; + } + response.type_specification = field.value; + have_type = true; + } else { + response = {}; + return false; + } + } + if (!have_type) { + response = {}; + return false; + } + response.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_variable_access_attributes_response( + const std::span mms_pdu, + MmsVariableAccessAttributesResponseView& response) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_response_view(mms_pdu, confirmed) && + try_decode_variable_access_attributes_response(confirmed, response); +} + +wire::EncodeResult MmsServiceSpanCodec::encode_variable_access_attributes_response_into( + const std::uint32_t invoke_id, + const bool mms_deletable, + const std::span encoded_type_specification, + const std::span destination) noexcept { + asn1::BerTlvView type; + if (!asn1::BerSpanReader::try_read_exact(encoded_type_specification, type) || + type.tag_class != asn1::BerClass::context_specific) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto deletable_tlv = asn1::BerSpanWriter::tlv_size(0, 1U); + const auto type_wrapper = asn1::BerSpanWriter::tlv_size( + 2, encoded_type_specification.size()); + const auto service_content = add_size(deletable_tlv, type_wrapper); + if (!service_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto sizing = confirmed_response_sizing(invoke_id, 6, *service_content); + if (!sizing) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizing->required) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizing->required}; + } + + asn1::BerSpanWriter writer{destination.first(sizing->required)}; + if (!write_confirmed_response_prefix( + writer, *sizing, invoke_id, 6, *service_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, 1U) || + !writer.write_byte(mms_deletable ? 0xFFU : 0x00U) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 2, + encoded_type_specification.size()) || + !writer.write_bytes(encoded_type_specification) || + writer.size() != sizing->required) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + return {wire::EncodeStatus::ok, sizing->required, sizing->required}; +} + +bool MmsServiceSpanCodec::try_decode_read_request( + const MmsConfirmedPduView& confirmed, + MmsReadRequestView& request) noexcept { + request = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_request || + confirmed.service_tag != 4 || !confirmed.service_constructed) { + return false; + } + + bool have_flag = false; + bool have_specification = false; + std::size_t offset = 0U; + while (offset < confirmed.service_value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + request = {}; + return false; + } + if (field.tag_number == 0 && !field.constructed) { + if (have_flag || field.value.size() != 1U) { + request = {}; + return false; + } + request.specification_with_result = field.value[0] != 0U; + have_flag = true; + } else if (field.tag_number == 1 && field.constructed) { + if (have_specification) { + request = {}; + return false; + } + asn1::BerTlvView list; + if (!asn1::BerSpanReader::try_read_exact(field.value, list) || + list.tag_class != asn1::BerClass::context_specific || + list.tag_number != 0 || !list.constructed) { + request = {}; + return false; + } + std::size_t variable_offset = 0U; + std::size_t count = 0U; + while (variable_offset < list.value.size()) { + if (count >= maximum_variables) { + request = {}; + return false; + } + const auto before = variable_offset; + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv( + list.value, variable_offset, definition)) { + request = {}; + return false; + } + MmsObjectNameView ignored; + if (!try_read_variable_definition( + list.value.subspan(before, variable_offset - before), 0U, ignored)) { + request = {}; + return false; + } + ++count; + } + if (count == 0U) { + request = {}; + return false; + } + request.variable_list = list.value; + request.variable_count = count; + have_specification = true; + } else { + request = {}; + return false; + } + } + if (!have_specification) { + request = {}; + return false; + } + request.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_read_request( + const std::span mms_pdu, + MmsReadRequestView& request) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_request_view(mms_pdu, confirmed) && + try_decode_read_request(confirmed, request); +} + +bool MmsServiceSpanCodec::try_decode_read_response( + const MmsConfirmedPduView& confirmed, + MmsReadResponseView& response) noexcept { + response = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_response || + confirmed.service_tag != 4 || !confirmed.service_constructed) { + return false; + } + bool have_list = false; + bool have_optional_specification = false; + std::size_t offset = 0U; + while (offset < confirmed.service_value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + response = {}; + return false; + } + if (field.tag_number == 0 && field.constructed) { + if (have_optional_specification) { + response = {}; + return false; + } + have_optional_specification = true; + } else if (field.tag_number == 1 && field.constructed) { + if (have_list) { + response = {}; + return false; + } + std::size_t result_offset = 0U; + std::size_t count = 0U; + while (result_offset < field.value.size()) { + if (count >= maximum_variables) { + response = {}; + return false; + } + const auto start = result_offset; + asn1::BerTlvView result; + if (!asn1::BerSpanReader::try_read_tlv( + field.value, result_offset, result)) { + response = {}; + return false; + } + if (result.tag_class == asn1::BerClass::context_specific && + result.tag_number == 0 && !result.constructed) { + std::uint32_t ignored{}; + if (!read_u32(result, ignored)) { + response = {}; + return false; + } + } else if (!valid_mms_data_tlv(result)) { + response = {}; + return false; + } + if (result_offset <= start) { + response = {}; + return false; + } + ++count; + } + if (count == 0U) { + response = {}; + return false; + } + response.access_result_list = field.value; + response.result_count = count; + have_list = true; + } else { + response = {}; + return false; + } + } + if (!have_list) { + response = {}; + return false; + } + response.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_read_response( + const std::span mms_pdu, + MmsReadResponseView& response) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_response_view(mms_pdu, confirmed) && + try_decode_read_response(confirmed, response); +} + +wire::EncodeResult MmsServiceSpanCodec::encode_read_response_into( + const std::uint32_t invoke_id, + const std::span results, + const std::span destination) noexcept { + if (results.empty() || results.size() > maximum_variables) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::size_t list_content = 0U; + for (const auto& result : results) { + std::size_t encoded_size = 0U; + if (result.success) { + asn1::BerTlvView data; + if (!asn1::BerSpanReader::try_read_exact(result.encoded_data, data) || + !valid_mms_data_tlv(data)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + encoded_size = result.encoded_data.size(); + } else { + const auto failure_value = positive_integer_size(result.failure_code); + const auto failure_tlv = asn1::BerSpanWriter::tlv_size(0, failure_value); + if (!failure_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + encoded_size = *failure_tlv; + } + if (encoded_size > std::numeric_limits::max() - list_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + list_content += encoded_size; + } + + const auto list_tlv = asn1::BerSpanWriter::tlv_size(1, list_content); + if (!list_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto sizing = confirmed_response_sizing(invoke_id, 4, *list_tlv); + if (!sizing) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizing->required) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizing->required}; + } + + asn1::BerSpanWriter writer{destination.first(sizing->required)}; + if (!write_confirmed_response_prefix( + writer, *sizing, invoke_id, 4, *list_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, list_content)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + for (const auto& result : results) { + if (result.success) { + if (!writer.write_bytes(result.encoded_data)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + } else { + const auto failure_value = positive_integer_size(result.failure_code); + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, failure_value) || + !write_positive_integer(writer, result.failure_code)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + } + } + if (writer.size() != sizing->required) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + return {wire::EncodeStatus::ok, sizing->required, sizing->required}; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_data_set_table.cpp b/src/mms/static_data_set_table.cpp new file mode 100644 index 000000000..3fa764748 --- /dev/null +++ b/src/mms/static_data_set_table.cpp @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_data_set_table.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool valid_name(const std::string_view value) noexcept { + if (value.empty() || value.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const char ch : value) { + const auto byte = static_cast(ch); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool equals( + const std::span bytes, + const std::string_view text) noexcept { + if (bytes.size() != text.size()) { + return false; + } + for (std::size_t index = 0U; index < bytes.size(); ++index) { + if (bytes[index] != static_cast( + static_cast(text[index]))) { + return false; + } + } + return true; +} + +[[nodiscard]] bool same_member( + const MmsStaticDataSetMember& left, + const MmsStaticDataSetMember& right) noexcept { + return left.domain == right.domain && left.item == right.item; +} + +} // namespace + +bool MmsStaticDataSetTable::valid() const noexcept { + if (data_sets_.size() > maximum_data_sets) { + return false; + } + for (std::size_t index = 0U; index < data_sets_.size(); ++index) { + const auto& data_set = data_sets_[index]; + if (!valid_name(data_set.domain) || !valid_name(data_set.item) || + data_set.members.empty() || + data_set.members.size() > MmsDataSetSpanCodec::maximum_members) { + return false; + } + for (std::size_t earlier = 0U; earlier < index; ++earlier) { + if (data_sets_[earlier].domain == data_set.domain && + data_sets_[earlier].item == data_set.item) { + return false; + } + } + for (std::size_t member = 0U; member < data_set.members.size(); ++member) { + const auto& current = data_set.members[member]; + if (!valid_name(current.domain) || !valid_name(current.item)) { + return false; + } + for (std::size_t earlier = 0U; earlier < member; ++earlier) { + if (same_member(data_set.members[earlier], current)) { + return false; + } + } + } + } + return true; +} + +bool MmsStaticDataSetTable::valid_against( + const MmsStaticObjectTable& objects) const noexcept { + if (!valid() || !objects.valid()) { + return false; + } + for (const auto& data_set : data_sets_) { + for (const auto& member : data_set.members) { + const MmsObjectNameView name{ + MmsObjectNameViewKind::domain_specific, + std::span{ + reinterpret_cast(member.domain.data()), + member.domain.size()}, + std::span{ + reinterpret_cast(member.item.data()), + member.item.size()}}; + if (objects.find(name) == nullptr) { + return false; + } + } + } + return true; +} + +const MmsStaticDataSetEntry* MmsStaticDataSetTable::find( + const MmsObjectNameView& name) const noexcept { + if (name.kind != MmsObjectNameViewKind::domain_specific) { + return nullptr; + } + for (const auto& data_set : data_sets_) { + if (equals(name.domain, data_set.domain) && equals(name.item, data_set.item)) { + return &data_set; + } + } + return nullptr; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_dispatcher.cpp b/src/mms/static_dispatcher.cpp new file mode 100644 index 000000000..ba983de05 --- /dev/null +++ b/src/mms/static_dispatcher.cpp @@ -0,0 +1,415 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_dispatcher.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool span_equals( + const std::span bytes, + const std::string_view text) noexcept { + if (bytes.size() != text.size()) { + return false; + } + for (std::size_t index = 0U; index < bytes.size(); ++index) { + if (bytes[index] != static_cast( + static_cast(text[index]))) { + return false; + } + } + return true; +} + +[[nodiscard]] bool valid_mms_data( + const std::span encoded) noexcept { + asn1::BerTlvView data; + if (!asn1::BerSpanReader::try_read_exact(encoded, data) || + data.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (data.tag_number == 1 || data.tag_number == 2) { + return data.constructed; + } + return data.tag_number >= 3 && data.tag_number <= 17 && !data.constructed; +} + +[[nodiscard]] MmsStaticDispatchResult make_status( + const MmsStaticDispatchStatus status, + const MmsConfirmedPduView& request, + const std::size_t required_bytes = 0U) noexcept { + return MmsStaticDispatchResult{ + status, + request.service(), + request.invoke_id, + 0U, + required_bytes}; +} + +[[nodiscard]] MmsStaticDispatchResult make_encoded( + const MmsConfirmedPduView& request, + const wire::EncodeResult encoded) noexcept { + if (encoded.success()) { + return MmsStaticDispatchResult{ + MmsStaticDispatchStatus::response_ready, + request.service(), + request.invoke_id, + encoded.bytes_written, + encoded.required_bytes}; + } + if (encoded.status == wire::EncodeStatus::buffer_too_small) { + return make_status( + MmsStaticDispatchStatus::response_buffer_too_small, + request, + encoded.required_bytes); + } + return make_status(MmsStaticDispatchStatus::backend_failure, request); +} + +[[nodiscard]] bool policy_valid(const MmsStaticDispatchPolicy& policy) noexcept { + return policy.maximum_names_per_response > 0U && + policy.maximum_names_per_response <= MmsServiceSpanCodec::maximum_identifiers && + policy.maximum_write_variables > 0U && + policy.maximum_write_variables <= MmsServiceSpanCodec::maximum_variables; +} + +[[nodiscard]] bool append_unique( + std::array& names, + std::size_t& count, + const std::string_view value) noexcept { + for (std::size_t index = 0U; index < count; ++index) { + if (names[index] == value) { + return true; + } + } + if (count >= names.size()) { + return false; + } + names[count++] = value; + return true; +} + +[[nodiscard]] std::size_t collect_names( + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, + const MmsGetNameListRequestView& request, + std::array& names) noexcept { + std::size_t count = 0U; + if (request.object_class == MmsNameListObjectClass::domain && + request.scope == MmsNameScopeKind::vmd_specific) { + for (const auto& object : objects.objects()) { + if (!append_unique(names, count, object.domain)) { + return names.size() + 1U; + } + } + for (const auto& data_set : data_sets.data_sets()) { + if (!append_unique(names, count, data_set.domain)) { + return names.size() + 1U; + } + } + return count; + } + + if (request.object_class == MmsNameListObjectClass::named_variable && + request.scope == MmsNameScopeKind::domain_specific) { + for (const auto& object : objects.objects()) { + if (object.name_list_visible && + span_equals(request.domain_id, object.domain)) { + if (count >= names.size()) { + return names.size() + 1U; + } + names[count++] = object.item; + } + } + return count; + } + + if (request.object_class == MmsNameListObjectClass::named_variable_list && + request.scope == MmsNameScopeKind::domain_specific) { + for (const auto& data_set : data_sets.data_sets()) { + if (span_equals(request.domain_id, data_set.domain)) { + if (count >= names.size()) { + return names.size() + 1U; + } + names[count++] = data_set.item; + } + } + return count; + } + + return names.size() + 1U; +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_get_name_list( + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, + const MmsStaticDispatchPolicy& policy, + const MmsConfirmedPduView& confirmed, + const std::span response) noexcept { + MmsGetNameListRequestView request; + if (!MmsServiceSpanCodec::try_decode_get_name_list_request(confirmed, request)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + + std::array names{}; + const auto name_count = collect_names(objects, data_sets, request, names); + if (name_count > names.size()) { + return make_status(MmsStaticDispatchStatus::unsupported_request, confirmed); + } + + std::size_t start = 0U; + if (!request.continue_after.empty()) { + bool found = false; + for (std::size_t index = 0U; index < name_count; ++index) { + if (span_equals(request.continue_after, names[index])) { + start = index + 1U; + found = true; + break; + } + } + if (!found) { + return make_status(MmsStaticDispatchStatus::object_not_found, confirmed); + } + } + + const auto available = name_count - start; + auto page_count = std::min(available, policy.maximum_names_per_response); + if (available == 0U) { + const std::span empty; + return make_encoded( + confirmed, + MmsServiceSpanCodec::encode_get_name_list_response_into( + confirmed.invoke_id, empty, false, response)); + } + + while (page_count > 0U) { + const auto more_follows = start + page_count < name_count; + const auto encoded = MmsServiceSpanCodec::encode_get_name_list_response_into( + confirmed.invoke_id, + std::span{names}.subspan(start, page_count), + more_follows, + response); + if (encoded.success()) { + return make_encoded(confirmed, encoded); + } + if (encoded.status != wire::EncodeStatus::buffer_too_small || page_count == 1U) { + return make_encoded(confirmed, encoded); + } + --page_count; + } + return make_status(MmsStaticDispatchStatus::backend_failure, confirmed); +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_attributes( + const MmsStaticObjectTable& objects, + const MmsConfirmedPduView& confirmed, + const std::span response) noexcept { + MmsVariableAccessAttributesRequestView request; + if (!MmsServiceSpanCodec::try_decode_variable_access_attributes_request( + confirmed, request)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + const auto* object = objects.find(request.name); + if (object == nullptr) { + return make_status(MmsStaticDispatchStatus::object_not_found, confirmed); + } + return make_encoded( + confirmed, + MmsServiceSpanCodec::encode_variable_access_attributes_response_into( + confirmed.invoke_id, + object->mms_deletable, + object->type_specification, + response)); +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_data_set_attributes( + const MmsStaticDataSetTable& data_sets, + const MmsConfirmedPduView& confirmed, + const std::span response) noexcept { + MmsNamedVariableListAttributesRequestView request; + if (!MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_request( + confirmed, request)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + const auto* data_set = data_sets.find(request.name); + if (data_set == nullptr) { + return make_status(MmsStaticDispatchStatus::object_not_found, confirmed); + } + + std::array members{}; + for (std::size_t index = 0U; index < data_set->members.size(); ++index) { + members[index] = MmsNamedVariableListMemberInput{ + data_set->members[index].domain, + data_set->members[index].item}; + } + return make_encoded( + confirmed, + MmsDataSetSpanCodec::encode_get_named_variable_list_attributes_response_into( + confirmed.invoke_id, + data_set->mms_deletable, + std::span{members}.first( + data_set->members.size()), + response)); +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_read( + const MmsStaticObjectTable& objects, + const MmsStaticDispatchPolicy& policy, + const MmsConfirmedPduView& confirmed, + const std::span response, + const std::span workspace) noexcept { + MmsReadRequestView request; + if (!MmsServiceSpanCodec::try_decode_read_request(confirmed, request)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + + std::array results{}; + std::size_t workspace_offset = 0U; + for (std::size_t index = 0U; index < request.variable_count; ++index) { + MmsObjectNameView name; + if (!request.try_variable(index, name)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + const auto* object = objects.find(name); + if (object == nullptr) { + results[index] = MmsReadAccessResultInput{ + false, {}, policy.missing_object_failure_code}; + continue; + } + + const auto remaining = workspace.subspan(workspace_offset); + const auto read = object->read(object->context, remaining); + if (read.status == wire::EncodeStatus::buffer_too_small) { + return make_status( + MmsStaticDispatchStatus::workspace_too_small, + confirmed, + workspace_offset + read.required_bytes); + } + if (!read.success()) { + results[index] = MmsReadAccessResultInput{ + false, {}, policy.backend_failure_code}; + continue; + } + if (read.bytes_written > remaining.size() || + !valid_mms_data(remaining.first(read.bytes_written))) { + return make_status(MmsStaticDispatchStatus::backend_failure, confirmed); + } + results[index] = MmsReadAccessResultInput{ + true, + remaining.first(read.bytes_written), + 0U}; + workspace_offset += read.bytes_written; + } + + return make_encoded( + confirmed, + MmsServiceSpanCodec::encode_read_response_into( + confirmed.invoke_id, + std::span{results}.first(request.variable_count), + response)); +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_write( + const MmsStaticObjectTable& objects, + const MmsStaticDispatchPolicy& policy, + const MmsConfirmedPduView& confirmed, + const std::span response) noexcept { + MmsWriteRequestView request; + if (!MmsServiceSpanCodec::try_decode_write_request(confirmed, request)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + if (request.variable_count > policy.maximum_write_variables) { + return make_status(MmsStaticDispatchStatus::unsupported_request, confirmed); + } + + std::array resolved{}; + std::array, MmsServiceSpanCodec::maximum_variables> values{}; + for (std::size_t index = 0U; index < request.variable_count; ++index) { + MmsObjectNameView name; + if (!request.try_variable(index, name) || !request.try_value(index, values[index])) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + resolved[index] = objects.find(name); + } + + std::array results{}; + for (std::size_t index = 0U; index < request.variable_count; ++index) { + const auto* object = resolved[index]; + if (object == nullptr) { + results[index] = MmsWriteAccessResultInput{ + false, policy.missing_object_failure_code}; + continue; + } + if (!object->writable()) { + results[index] = MmsWriteAccessResultInput{ + false, policy.access_denied_failure_code}; + continue; + } + const auto applied = object->write(object->write_context, values[index]); + results[index] = MmsWriteAccessResultInput{ + applied.success, + applied.success ? 0U : applied.failure_code}; + } + + return make_encoded( + confirmed, + MmsServiceSpanCodec::encode_write_response_into( + confirmed.invoke_id, + std::span{results}.first(request.variable_count), + response)); +} + +} // namespace + +MmsStaticDispatchResult MmsStaticApplicationDispatcher::dispatch( + const std::span mms_request, + const std::span response, + const std::span workspace) const noexcept { + MmsConfirmedPduView request; + if (!MmsPduSpanCodec::try_decode_confirmed_request_view(mms_request, request)) { + return MmsStaticDispatchResult{ + MmsStaticDispatchStatus::malformed_request, + MmsWireConfirmedService::unknown, + 0U, + 0U, + 0U}; + } + return dispatch(request, response, workspace); +} + +MmsStaticDispatchResult MmsStaticApplicationDispatcher::dispatch( + const MmsConfirmedPduView& request, + const std::span response, + const std::span workspace) const noexcept { + if (!objects_.valid() || !data_sets_.valid_against(objects_) || !policy_valid(policy_)) { + return make_status(MmsStaticDispatchStatus::invalid_object_table, request); + } + if (request.kind != MmsWirePduKind::confirmed_request) { + return make_status(MmsStaticDispatchStatus::malformed_request, request); + } + + switch (request.service()) { + case MmsWireConfirmedService::get_name_list: + return dispatch_get_name_list(objects_, data_sets_, policy_, request, response); + case MmsWireConfirmedService::get_variable_access_attributes: + return dispatch_attributes(objects_, request, response); + case MmsWireConfirmedService::get_named_variable_list_attributes: + return dispatch_data_set_attributes(data_sets_, request, response); + case MmsWireConfirmedService::read: + return dispatch_read(objects_, policy_, request, response, workspace); + case MmsWireConfirmedService::write: + return dispatch_write(objects_, policy_, request, response); + default: + return make_status(MmsStaticDispatchStatus::unsupported_service, request); + } +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_object_table.cpp b/src/mms/static_object_table.cpp new file mode 100644 index 000000000..8bff516b0 --- /dev/null +++ b/src/mms/static_object_table.cpp @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_object_table.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool visible_ascii(const std::string_view value) noexcept { + if (value.empty() || value.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const auto character : value) { + const auto byte = static_cast(character); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool span_equals( + const std::span bytes, + const std::string_view text) noexcept { + if (bytes.size() != text.size()) { + return false; + } + for (std::size_t index = 0U; index < bytes.size(); ++index) { + if (bytes[index] != static_cast( + static_cast(text[index]))) { + return false; + } + } + return true; +} + +[[nodiscard]] bool valid_type_specification( + const std::span encoded) noexcept { + asn1::BerTlvView type; + return !encoded.empty() && + asn1::BerSpanReader::try_read_exact(encoded, type) && + type.tag_class == asn1::BerClass::context_specific; +} + +} // namespace + +bool MmsStaticObjectTable::valid() const noexcept { + if (objects_.empty() || objects_.size() > maximum_objects) { + return false; + } + for (std::size_t index = 0U; index < objects_.size(); ++index) { + const auto& object = objects_[index]; + if (!visible_ascii(object.domain) || !visible_ascii(object.item) || + !valid_type_specification(object.type_specification) || + object.read == nullptr) { + return false; + } + for (std::size_t other = index + 1U; other < objects_.size(); ++other) { + if (objects_[other].domain == object.domain && + objects_[other].item == object.item) { + return false; + } + } + } + return true; +} + +const MmsStaticObjectEntry* MmsStaticObjectTable::find( + const MmsObjectNameView& name) const noexcept { + if (name.kind != MmsObjectNameViewKind::domain_specific || + name.domain.empty() || name.item.empty()) { + return nullptr; + } + for (const auto& object : objects_) { + if (span_equals(name.domain, object.domain) && + span_equals(name.item, object.item)) { + return &object; + } + } + return nullptr; +} + +bool MmsStaticObjectTable::try_resolve_read_request( + const MmsReadRequestView& request, + const std::span resolved, + std::size_t& resolved_count) const noexcept { + resolved_count = 0U; + if (request.variable_count == 0U || + request.variable_count > resolved.size() || + request.variable_count > MmsServiceSpanCodec::maximum_variables) { + return false; + } + for (std::size_t index = 0U; index < request.variable_count; ++index) { + MmsObjectNameView name; + if (!request.try_variable(index, name)) { + resolved_count = 0U; + return false; + } + resolved[index] = find(name); + ++resolved_count; + } + return true; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_server_session.cpp b/src/mms/static_server_session.cpp new file mode 100644 index 000000000..b65d079a9 --- /dev/null +++ b/src/mms/static_server_session.cpp @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_server_session.hpp" + +#include + +namespace ar::iec61850::mms { + +MmsStaticServerSessionResult MmsStaticServerSession::make_result( + const MmsStaticServerSessionStatus status, + const MmsStaticConnectionStatus connection_status, + const std::size_t bytes_received, + const std::size_t bytes_sent) const noexcept { + return { + status, + connection_status, + bytes_received, + bytes_sent, + receive_size_, + response_size_ - response_offset_}; +} + +void MmsStaticServerSession::consume_input(const std::size_t consumed) noexcept { + if (consumed == 0U || consumed > receive_size_) { + return; + } + const auto remaining = receive_size_ - consumed; + if (remaining != 0U) { + std::memmove( + buffers_.receive.data(), + buffers_.receive.data() + consumed, + remaining); + } + receive_size_ = remaining; +} + +void MmsStaticServerSession::reset() noexcept { + receive_size_ = 0U; + response_offset_ = 0U; + response_size_ = 0U; + terminal_ = false; + runtime_.reset(); +} + +MmsStaticServerSessionResult MmsStaticServerSession::poll_once() noexcept { + if (!buffers_.valid() || stream_.send_callback == nullptr || + stream_.receive_callback == nullptr) { + terminal_ = true; + return make_result( + MmsStaticServerSessionStatus::invalid_configuration, + MmsStaticConnectionStatus::backend_failure); + } + if (terminal_) { + return make_result( + MmsStaticServerSessionStatus::protocol_error, + MmsStaticConnectionStatus::closed); + } + + if (response_offset_ < response_size_) { + const auto remaining = std::span{buffers_.response} + .subspan(response_offset_, response_size_ - response_offset_); + const auto sent = stream_.send(remaining); + if (sent.status == embedded::IoStatus::would_block) { + return make_result( + MmsStaticServerSessionStatus::would_block, + MmsStaticConnectionStatus::response_ready); + } + if (sent.status == embedded::IoStatus::timeout) { + return make_result( + MmsStaticServerSessionStatus::timed_out, + MmsStaticConnectionStatus::response_ready); + } + if (sent.status == embedded::IoStatus::closed) { + terminal_ = true; + return make_result( + MmsStaticServerSessionStatus::peer_closed, + MmsStaticConnectionStatus::closed); + } + if (!sent.success() || sent.transferred == 0U || + sent.transferred > remaining.size()) { + terminal_ = true; + return make_result( + MmsStaticServerSessionStatus::transport_error, + MmsStaticConnectionStatus::backend_failure); + } + + response_offset_ += sent.transferred; + if (response_offset_ == response_size_) { + response_offset_ = 0U; + response_size_ = 0U; + } + return make_result( + MmsStaticServerSessionStatus::progressed, + MmsStaticConnectionStatus::response_ready, + 0U, + sent.transferred); + } + + if (receive_size_ != 0U) { + const auto connection = runtime_.process_tcp_window( + std::span{buffers_.receive}.first(receive_size_), + buffers_.response, + buffers_.workspace); + + switch (connection.status) { + case MmsStaticConnectionStatus::response_ready: + consume_input(connection.consumed_bytes); + response_offset_ = 0U; + response_size_ = connection.bytes_written; + return make_result( + MmsStaticServerSessionStatus::response_pending, + connection.status); + case MmsStaticConnectionStatus::consumed_no_response: + consume_input(connection.consumed_bytes); + return make_result( + MmsStaticServerSessionStatus::progressed, + connection.status); + case MmsStaticConnectionStatus::application_rejected: + consume_input(connection.consumed_bytes); + return make_result( + MmsStaticServerSessionStatus::application_rejected, + connection.status); + case MmsStaticConnectionStatus::closed: + consume_input(connection.consumed_bytes); + terminal_ = true; + return make_result( + MmsStaticServerSessionStatus::peer_closed, + connection.status); + case MmsStaticConnectionStatus::need_more: + break; + case MmsStaticConnectionStatus::malformed_transport: + case MmsStaticConnectionStatus::protocol_violation: + case MmsStaticConnectionStatus::response_buffer_too_small: + case MmsStaticConnectionStatus::workspace_too_small: + case MmsStaticConnectionStatus::backend_failure: + terminal_ = true; + return make_result( + MmsStaticServerSessionStatus::protocol_error, + connection.status); + } + } + + if (receive_size_ == buffers_.receive.size()) { + terminal_ = true; + return make_result( + MmsStaticServerSessionStatus::receive_buffer_full, + MmsStaticConnectionStatus::need_more); + } + + const auto destination = buffers_.receive.subspan(receive_size_); + const auto received = stream_.receive(destination); + if (received.status == embedded::IoStatus::would_block) { + return make_result( + MmsStaticServerSessionStatus::would_block, + MmsStaticConnectionStatus::need_more); + } + if (received.status == embedded::IoStatus::timeout) { + return make_result( + MmsStaticServerSessionStatus::timed_out, + MmsStaticConnectionStatus::need_more); + } + if (received.status == embedded::IoStatus::closed) { + terminal_ = true; + return make_result( + MmsStaticServerSessionStatus::peer_closed, + MmsStaticConnectionStatus::closed); + } + if (!received.success() || received.transferred == 0U || + received.transferred > destination.size()) { + terminal_ = true; + return make_result( + MmsStaticServerSessionStatus::transport_error, + MmsStaticConnectionStatus::backend_failure); + } + + receive_size_ += received.transferred; + return make_result( + MmsStaticServerSessionStatus::progressed, + MmsStaticConnectionStatus::need_more, + received.transferred, + 0U); +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_write.cpp b/src/mms/static_write.cpp new file mode 100644 index 000000000..5ffa21614 --- /dev/null +++ b/src/mms/static_write.cpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include + +namespace ar::iec61850::mms { + +bool MmsStaticObjectTable::try_resolve_write_request( + const MmsWriteRequestView& request, + const std::span resolved, + std::size_t& resolved_count) const noexcept { + resolved_count = 0U; + if (request.variable_count == 0U || + request.variable_count != request.data_count || + request.variable_count > resolved.size() || + request.variable_count > MmsServiceSpanCodec::maximum_variables) { + return false; + } + for (std::size_t index = 0U; index < request.variable_count; ++index) { + MmsObjectNameView name; + if (!request.try_variable(index, name)) { + resolved_count = 0U; + return false; + } + resolved[index] = find(name); + ++resolved_count; + } + return true; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/write_span.cpp b/src/mms/write_span.cpp new file mode 100644 index 000000000..1871f0852 --- /dev/null +++ b/src/mms/write_span.cpp @@ -0,0 +1,458 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/services_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool read_u32( + const asn1::BerTlvView& tlv, + std::uint32_t& value) noexcept { + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded) { + value = 0U; + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool valid_mms_data_tlv( + const asn1::BerTlvView& tlv) noexcept { + if (tlv.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (tlv.tag_number == 1 || tlv.tag_number == 2) { + return tlv.constructed; + } + return tlv.tag_number >= 3 && tlv.tag_number <= 17 && !tlv.constructed; +} + +[[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] bool write_positive_integer( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + if (needs_positive_prefix(value, minimal) && !writer.write_byte(0x00U)) { + return false; + } + for (std::size_t index = minimal; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast( + (value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional add_size( + const std::optional left, + const std::optional right) noexcept { + if (!left || !right || *right > std::numeric_limits::max() - *left) { + return std::nullopt; + } + return *left + *right; +} + +struct ConfirmedResponseSizing final { + std::size_t invoke_value{}; + std::size_t outer_content{}; + std::size_t required{}; +}; + +[[nodiscard]] std::optional confirmed_response_sizing( + const std::uint32_t invoke_id, + const std::size_t service_content) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id) { + return std::nullopt; + } + const auto invoke_value = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(2, invoke_value); + const auto service_tlv = asn1::BerSpanWriter::tlv_size(5, service_content); + const auto outer_content = add_size(invoke_tlv, service_tlv); + if (!outer_content) { + return std::nullopt; + } + const auto required = asn1::BerSpanWriter::tlv_size(1, *outer_content); + if (!required || *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return std::nullopt; + } + return ConfirmedResponseSizing{invoke_value, *outer_content, *required}; +} + +[[nodiscard]] bool write_confirmed_response_prefix( + asn1::BerSpanWriter& writer, + const ConfirmedResponseSizing& sizing, + const std::uint32_t invoke_id, + const std::size_t service_content) noexcept { + return writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, sizing.outer_content) && + writer.write_tlv_header( + asn1::BerClass::universal, false, 2, sizing.invoke_value) && + write_positive_integer(writer, invoke_id) && + writer.write_tlv_header( + asn1::BerClass::context_specific, true, 5, service_content); +} + +[[nodiscard]] bool try_read_variable_definition( + const std::span variable_list, + const std::size_t wanted, + MmsObjectNameView& name) noexcept { + name = {}; + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < variable_list.size()) { + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv(variable_list, offset, definition) || + definition.tag_class != asn1::BerClass::universal || + definition.tag_number != 16 || !definition.constructed) { + return false; + } + asn1::BerTlvView wrapper; + if (!asn1::BerSpanReader::try_read_exact(definition.value, wrapper) || + wrapper.tag_class != asn1::BerClass::context_specific || + wrapper.tag_number != 0 || !wrapper.constructed) { + return false; + } + MmsObjectNameView decoded; + if (!MmsServiceSpanCodec::try_decode_object_name_view(wrapper.value, decoded)) { + return false; + } + if (current == wanted) { + name = decoded; + return true; + } + ++current; + } + return false; +} + +[[nodiscard]] bool try_read_data_value( + const std::span data_list, + const std::size_t wanted, + std::span& encoded_data) noexcept { + encoded_data = {}; + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < data_list.size()) { + const auto start = offset; + asn1::BerTlvView value; + if (!asn1::BerSpanReader::try_read_tlv(data_list, offset, value) || + !valid_mms_data_tlv(value)) { + return false; + } + if (current == wanted) { + encoded_data = data_list.subspan(start, offset - start); + return true; + } + ++current; + } + return false; +} + +[[nodiscard]] bool try_read_write_result( + const std::span result_list, + const std::size_t wanted, + MmsWriteAccessResultView& result) noexcept { + result = {}; + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < result_list.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(result_list, offset, field) || + field.tag_class != asn1::BerClass::context_specific || field.constructed) { + return false; + } + MmsWriteAccessResultView decoded; + if (field.tag_number == 1) { + if (!field.value.empty()) { + return false; + } + decoded.success = true; + } else if (field.tag_number == 0) { + if (!read_u32(field, decoded.failure_code)) { + return false; + } + decoded.success = false; + } else { + return false; + } + if (current == wanted) { + result = decoded; + return true; + } + ++current; + } + return false; +} + +} // namespace + +bool MmsWriteRequestView::try_variable( + const std::size_t index, + MmsObjectNameView& name) const noexcept { + if (index >= variable_count) { + name = {}; + return false; + } + return try_read_variable_definition(variable_list, index, name); +} + +bool MmsWriteRequestView::try_value( + const std::size_t index, + std::span& encoded_data) const noexcept { + if (index >= data_count) { + encoded_data = {}; + return false; + } + return try_read_data_value(data_list, index, encoded_data); +} + +bool MmsWriteResponseView::try_result( + const std::size_t index, + MmsWriteAccessResultView& result) const noexcept { + if (implicit_single_success) { + if (index != 0U) { + result = {}; + return false; + } + result = MmsWriteAccessResultView{true, 0U}; + return true; + } + if (index >= result_count) { + result = {}; + return false; + } + return try_read_write_result(result_list, index, result); +} + +bool MmsServiceSpanCodec::try_decode_write_request( + const MmsConfirmedPduView& confirmed, + MmsWriteRequestView& request) noexcept { + request = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_request || + confirmed.service_tag != 5 || !confirmed.service_constructed) { + return false; + } + + std::size_t offset = 0U; + asn1::BerTlvView variables; + asn1::BerTlvView data; + if (!asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, variables) || + !asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, data) || + offset != confirmed.service_value.size() || + variables.tag_class != asn1::BerClass::context_specific || + variables.tag_number != 0 || !variables.constructed || + data.tag_class != asn1::BerClass::context_specific || + data.tag_number != 0 || !data.constructed) { + return false; + } + + std::size_t variable_offset = 0U; + std::size_t variable_count = 0U; + while (variable_offset < variables.value.size()) { + if (variable_count >= maximum_variables) { + return false; + } + const auto before = variable_offset; + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv( + variables.value, variable_offset, definition)) { + return false; + } + MmsObjectNameView ignored; + if (!try_read_variable_definition( + variables.value.subspan(before, variable_offset - before), 0U, ignored)) { + return false; + } + ++variable_count; + } + + std::size_t data_offset = 0U; + std::size_t data_count = 0U; + while (data_offset < data.value.size()) { + if (data_count >= maximum_variables) { + return false; + } + asn1::BerTlvView value; + if (!asn1::BerSpanReader::try_read_tlv(data.value, data_offset, value) || + !valid_mms_data_tlv(value)) { + return false; + } + ++data_count; + } + + if (variable_count == 0U || variable_count != data_count) { + return false; + } + request.invoke_id = confirmed.invoke_id; + request.variable_list = variables.value; + request.variable_count = variable_count; + request.data_list = data.value; + request.data_count = data_count; + return true; +} + +bool MmsServiceSpanCodec::try_decode_write_request( + const std::span mms_pdu, + MmsWriteRequestView& request) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_request_view(mms_pdu, confirmed) && + try_decode_write_request(confirmed, request); +} + +bool MmsServiceSpanCodec::try_decode_write_response( + const MmsConfirmedPduView& confirmed, + MmsWriteResponseView& response) noexcept { + response = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_response || + confirmed.service_tag != 5) { + return false; + } + response.invoke_id = confirmed.invoke_id; + if (!confirmed.service_constructed) { + if (!confirmed.service_value.empty()) { + response = {}; + return false; + } + response.implicit_single_success = true; + response.result_count = 1U; + return true; + } + + std::size_t offset = 0U; + std::size_t count = 0U; + while (offset < confirmed.service_value.size()) { + if (count >= maximum_variables) { + response = {}; + return false; + } + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv( + confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific || field.constructed) { + response = {}; + return false; + } + if (field.tag_number == 1) { + if (!field.value.empty()) { + response = {}; + return false; + } + } else if (field.tag_number == 0) { + std::uint32_t ignored{}; + if (!read_u32(field, ignored)) { + response = {}; + return false; + } + } else { + response = {}; + return false; + } + ++count; + } + if (count == 0U) { + response = {}; + return false; + } + response.result_list = confirmed.service_value; + response.result_count = count; + return true; +} + +bool MmsServiceSpanCodec::try_decode_write_response( + const std::span mms_pdu, + MmsWriteResponseView& response) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_response_view(mms_pdu, confirmed) && + try_decode_write_response(confirmed, response); +} + +wire::EncodeResult MmsServiceSpanCodec::encode_write_response_into( + const std::uint32_t invoke_id, + const std::span results, + const std::span destination) noexcept { + if (results.empty() || results.size() > maximum_variables) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::size_t service_content = 0U; + for (const auto& result : results) { + std::optional encoded; + if (result.success) { + encoded = asn1::BerSpanWriter::tlv_size(1, 0U); + } else { + encoded = asn1::BerSpanWriter::tlv_size( + 0, positive_integer_size(result.failure_code)); + } + if (!encoded || *encoded > std::numeric_limits::max() - service_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + service_content += *encoded; + } + + const auto sizing = confirmed_response_sizing(invoke_id, service_content); + if (!sizing) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizing->required) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizing->required}; + } + + asn1::BerSpanWriter writer{destination.first(sizing->required)}; + if (!write_confirmed_response_prefix( + writer, *sizing, invoke_id, service_content)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + for (const auto& result : results) { + if (result.success) { + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 1, 0U)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + } else { + const auto failure_value = positive_integer_size(result.failure_code); + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, failure_value) || + !write_positive_integer(writer, result.failure_code)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + } + } + if (writer.size() != sizing->required) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + return {wire::EncodeStatus::ok, sizing->required, sizing->required}; +} + +} // namespace ar::iec61850::mms diff --git a/src/osi/cotp_span.cpp b/src/osi/cotp_span.cpp new file mode 100644 index 000000000..56cdd5375 --- /dev/null +++ b/src/osi/cotp_span.cpp @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/osi/cotp_span.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::osi { +namespace { + +[[nodiscard]] std::uint16_t read_be_u16( + const std::span bytes, + const std::size_t offset) noexcept { + return static_cast( + (static_cast(bytes[offset]) << 8U) | + static_cast(bytes[offset + 1U])); +} + +void write_be_u16( + const std::span bytes, + const std::size_t offset, + const std::uint16_t value) noexcept { + bytes[offset] = static_cast((value >> 8U) & 0xFFU); + bytes[offset + 1U] = static_cast(value & 0xFFU); +} + +[[nodiscard]] bool encoded_parameters_size( + const std::span parameters, + std::size_t& total) noexcept { + total = 0U; + for (const auto& parameter : parameters) { + if (parameter.value.size() > 248U) { + total = 0U; + return false; + } + const auto encoded = 2U + parameter.value.size(); + if (encoded > 248U - total) { + total = 0U; + return false; + } + total += encoded; + } + return true; +} + +[[nodiscard]] bool write_parameter( + const std::span destination, + std::size_t& offset, + const std::uint8_t code, + const std::span value) noexcept { + if (value.size() > 248U || destination.size() - offset < value.size() + 2U) { + return false; + } + destination[offset++] = code; + destination[offset++] = static_cast(value.size()); + std::copy( + value.begin(), + value.end(), + destination.begin() + static_cast(offset)); + offset += value.size(); + return true; +} + +[[nodiscard]] wire::EncodeResult encode_reference_into( + const std::uint8_t code, + const std::uint16_t destination_reference, + const std::uint16_t source_reference, + const std::uint8_t class_or_reason, + const std::span parameters, + const std::span destination) noexcept { + std::size_t parameter_bytes{}; + if (!encoded_parameters_size(parameters, parameter_bytes)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto length_indicator = 6U + parameter_bytes; + const auto required = length_indicator + 1U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = static_cast(length_indicator); + destination[1] = code; + write_be_u16(destination, 2U, destination_reference); + write_be_u16(destination, 4U, source_reference); + destination[6] = class_or_reason; + + std::size_t offset = 7U; + for (const auto& parameter : parameters) { + if (!write_parameter(destination, offset, parameter.code, parameter.value)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + } + if (offset != required) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] bool validate_parameter_bytes( + const std::span bytes) noexcept { + std::size_t offset = 0U; + while (offset < bytes.size()) { + if (bytes.size() - offset < 2U) { + return false; + } + const auto length = static_cast(bytes[offset + 1U]); + offset += 2U; + if (length > bytes.size() - offset) { + return false; + } + offset += length; + } + return offset == bytes.size(); +} + +[[nodiscard]] bool selected_tpdu_size( + const CotpTpduView& request, + const std::uint8_t maximum_tpdu_size_code, + std::uint8_t& selected) noexcept { + std::size_t ignored{}; + if (!CotpSpanCodec::try_tpdu_size_bytes(maximum_tpdu_size_code, ignored)) { + return false; + } + + std::span offered; + if (!request.try_parameter(CotpSpanCodec::tpdu_size_parameter, offered) || + offered.size() != 1U) { + selected = maximum_tpdu_size_code; + return true; + } + + if (!CotpSpanCodec::try_tpdu_size_bytes(offered[0], ignored)) { + return false; + } + selected = std::min(offered[0], maximum_tpdu_size_code); + return true; +} + +[[nodiscard]] bool mirrored_parameter_size( + const CotpTpduView& request, + std::size_t& total) noexcept { + // The CC always starts with C0 / TPDU size (3 encoded bytes). + total = 3U; + std::size_t offset = 0U; + while (offset < request.parameter_bytes.size()) { + if (request.parameter_bytes.size() - offset < 2U) { + total = 0U; + return false; + } + const auto code = request.parameter_bytes[offset]; + const auto length = static_cast(request.parameter_bytes[offset + 1U]); + offset += 2U; + if (length > request.parameter_bytes.size() - offset) { + total = 0U; + return false; + } + + if ((code == CotpSpanCodec::source_tsap_parameter || + code == CotpSpanCodec::destination_tsap_parameter) && + length != 0U) { + const auto encoded = length + 2U; + if (length > 248U || encoded > 248U - total) { + total = 0U; + return false; + } + total += encoded; + } + offset += length; + } + return true; +} + +} // namespace + +bool CotpTpduView::try_parameter( + const std::uint8_t parameter_code, + std::span& value) const noexcept { + value = {}; + std::size_t offset = 0U; + while (offset < parameter_bytes.size()) { + if (parameter_bytes.size() - offset < 2U) { + return false; + } + const auto current_code = parameter_bytes[offset]; + const auto length = static_cast(parameter_bytes[offset + 1U]); + offset += 2U; + if (length > parameter_bytes.size() - offset) { + return false; + } + if (current_code == parameter_code) { + value = parameter_bytes.subspan(offset, length); + return true; + } + offset += length; + } + return false; +} + +CotpWireKind CotpSpanCodec::kind_from_code(const std::uint8_t code) noexcept { + switch (code) { + case connection_request_code: + return CotpWireKind::connection_request; + case connection_confirm_code: + return CotpWireKind::connection_confirm; + case data_code: + return CotpWireKind::data; + case disconnect_request_code: + return CotpWireKind::disconnect_request; + case error_code: + return CotpWireKind::error; + default: + return CotpWireKind::unknown; + } +} + +bool CotpSpanCodec::try_decode_view( + const std::span bytes, + CotpTpduView& tpdu) noexcept { + tpdu = {}; + if (bytes.size() < 2U) { + return false; + } + + const auto length_indicator = static_cast(bytes[0]); + const auto header_end = length_indicator + 1U; + if (length_indicator < 2U || header_end > bytes.size()) { + return false; + } + + const auto kind = kind_from_code(bytes[1]); + if (kind == CotpWireKind::unknown) { + return false; + } + + tpdu.code = bytes[1]; + tpdu.kind = kind; + tpdu.length_indicator = bytes[0]; + + if (kind == CotpWireKind::data) { + if (length_indicator != 2U || header_end != 3U) { + tpdu = {}; + return false; + } + tpdu.end_of_transmission = (bytes[2] & 0x80U) != 0U; + tpdu.tpdu_number = static_cast(bytes[2] & 0x7FU); + tpdu.user_data = bytes.subspan(3U); + return true; + } + + if (length_indicator < 6U || bytes.size() < 7U) { + tpdu = {}; + return false; + } + + tpdu.destination_reference = read_be_u16(bytes, 2U); + tpdu.source_reference = read_be_u16(bytes, 4U); + tpdu.class_or_reason = bytes[6]; + tpdu.end_of_transmission = true; + tpdu.parameter_bytes = bytes.subspan(7U, header_end - 7U); + tpdu.user_data = bytes.subspan(header_end); + + if (!validate_parameter_bytes(tpdu.parameter_bytes) || + ((kind == CotpWireKind::connection_request || + kind == CotpWireKind::connection_confirm) && + !tpdu.user_data.empty())) { + tpdu = {}; + return false; + } + return true; +} + +bool CotpSpanCodec::try_tpdu_size_bytes( + const std::uint8_t tpdu_size_code, + std::size_t& bytes) noexcept { + bytes = 0U; + if (tpdu_size_code < 0x07U || tpdu_size_code > 0x0FU) { + return false; + } + bytes = static_cast(1U) << tpdu_size_code; + return true; +} + +wire::EncodeResult CotpSpanCodec::encode_data_into( + const std::span user_data, + const std::span destination, + const bool end_of_transmission, + const std::uint8_t tpdu_number) noexcept { + if (tpdu_number > 0x7FU || + user_data.size() > std::numeric_limits::max() - 3U) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto required = user_data.size() + 3U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = 0x02U; + destination[1] = data_code; + destination[2] = static_cast( + (end_of_transmission ? 0x80U : 0x00U) | tpdu_number); + std::copy( + user_data.begin(), + user_data.end(), + destination.begin() + 3); + return {wire::EncodeStatus::ok, required, required}; +} + +wire::EncodeResult CotpSpanCodec::encode_connection_request_into( + const std::uint16_t source_reference, + const std::span parameters, + const std::span destination, + const std::uint16_t destination_reference, + const std::uint8_t transport_class) noexcept { + return encode_reference_into( + connection_request_code, + destination_reference, + source_reference, + transport_class, + parameters, + destination); +} + +wire::EncodeResult CotpSpanCodec::encode_connection_confirm_into( + const std::uint16_t destination_reference, + const std::uint16_t source_reference, + const std::span destination, + const std::uint8_t maximum_tpdu_size_code) noexcept { + std::size_t ignored{}; + if (!try_tpdu_size_bytes(maximum_tpdu_size_code, ignored)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const std::uint8_t value = maximum_tpdu_size_code; + const CotpParameterView parameter{ + tpdu_size_parameter, + std::span{&value, 1U}}; + return encode_reference_into( + connection_confirm_code, + destination_reference, + source_reference, + 0U, + std::span{¶meter, 1U}, + destination); +} + +wire::EncodeResult CotpSpanCodec::encode_connection_confirm_from_request_into( + const CotpTpduView& connection_request, + const std::uint16_t source_reference, + const std::span destination, + const std::uint8_t maximum_tpdu_size_code) noexcept { + if (connection_request.kind != CotpWireKind::connection_request || + !connection_request.user_data.empty()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::uint8_t selected{}; + std::size_t parameter_bytes{}; + if (!selected_tpdu_size( + connection_request, maximum_tpdu_size_code, selected) || + !mirrored_parameter_size(connection_request, parameter_bytes)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto length_indicator = 6U + parameter_bytes; + const auto required = length_indicator + 1U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = static_cast(length_indicator); + destination[1] = connection_confirm_code; + write_be_u16(destination, 2U, connection_request.source_reference); + write_be_u16(destination, 4U, source_reference); + destination[6] = 0U; + + std::size_t output_offset = 7U; + const std::span selected_span{&selected, 1U}; + if (!write_parameter( + destination, + output_offset, + tpdu_size_parameter, + selected_span)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + + std::size_t input_offset = 0U; + while (input_offset < connection_request.parameter_bytes.size()) { + if (connection_request.parameter_bytes.size() - input_offset < 2U) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + const auto code = connection_request.parameter_bytes[input_offset]; + const auto length = static_cast( + connection_request.parameter_bytes[input_offset + 1U]); + input_offset += 2U; + if (length > connection_request.parameter_bytes.size() - input_offset) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + const auto value = connection_request.parameter_bytes.subspan( + input_offset, length); + + if (code == source_tsap_parameter && !value.empty()) { + if (!write_parameter( + destination, + output_offset, + destination_tsap_parameter, + value)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + } else if (code == destination_tsap_parameter && !value.empty()) { + if (!write_parameter( + destination, + output_offset, + source_tsap_parameter, + value)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + } + input_offset += length; + } + + if (output_offset != required) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + return {wire::EncodeStatus::ok, required, required}; +} + +} // namespace ar::iec61850::osi diff --git a/src/osi/presentation_span.cpp b/src/osi/presentation_span.cpp new file mode 100644 index 000000000..b0d535793 --- /dev/null +++ b/src/osi/presentation_span.cpp @@ -0,0 +1,728 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/osi/presentation_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/osi/session_span.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::osi { +namespace { + +constexpr std::array kAcseAbstractSyntax{ + 0x52U, 0x01U, 0x00U, 0x01U}; +constexpr std::array kMmsAbstractSyntax{ + 0x28U, 0xCAU, 0x22U, 0x02U, 0x01U}; +constexpr std::array kBerTransferSyntax{0x51U, 0x01U}; + +[[nodiscard]] std::size_t unsigned_integer_size(std::uint32_t value) noexcept { + std::size_t size = 1U; + while (value > 0xFFU) { + ++size; + value >>= 8U; + } + return size; +} + +[[nodiscard]] bool write_unsigned_integer( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto size = unsigned_integer_size(value); + for (std::size_t index = size; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast( + (value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional add_size( + const std::optional left, + const std::optional right) noexcept { + if (!left || !right || *right > std::numeric_limits::max() - *left) { + return std::nullopt; + } + return *left + *right; +} + +[[nodiscard]] bool valid_oid( + const std::span value) noexcept { + return !value.empty() && value.size() <= PresentationSpanCodec::maximum_oid_bytes; +} + +[[nodiscard]] bool read_positive_integer( + const asn1::BerTlvView& tlv, + std::uint32_t& value) noexcept { + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded || *decoded == 0U) { + value = 0U; + return false; + } + value = *decoded; + return true; +} + +struct ContextDefinitionView final { + std::uint32_t id{}; + std::span abstract_syntax{}; + std::span first_transfer_syntax{}; + std::size_t transfer_syntax_count{}; +}; + +[[nodiscard]] bool parse_context_definition( + const asn1::BerTlvView& definition, + ContextDefinitionView& context) noexcept { + context = {}; + if (definition.tag_class != asn1::BerClass::universal || + definition.tag_number != 16 || !definition.constructed) { + return false; + } + + bool saw_id = false; + bool saw_abstract = false; + bool saw_transfers = false; + std::size_t offset = 0U; + while (offset < definition.value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(definition.value, offset, field)) { + return false; + } + + if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 2 && !field.constructed) { + if (saw_id || !read_positive_integer(field, context.id)) { + return false; + } + saw_id = true; + } else if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 6 && !field.constructed) { + if (saw_abstract || !valid_oid(field.value)) { + return false; + } + context.abstract_syntax = field.value; + saw_abstract = true; + } else if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 16 && field.constructed) { + if (saw_transfers) { + return false; + } + std::size_t transfer_offset = 0U; + while (transfer_offset < field.value.size()) { + if (context.transfer_syntax_count >= + PresentationSpanCodec::maximum_contexts) { + return false; + } + asn1::BerTlvView syntax; + if (!asn1::BerSpanReader::try_read_tlv( + field.value, transfer_offset, syntax) || + syntax.tag_class != asn1::BerClass::universal || + syntax.tag_number != 6 || syntax.constructed || + !valid_oid(syntax.value)) { + return false; + } + if (context.transfer_syntax_count == 0U) { + context.first_transfer_syntax = syntax.value; + } + ++context.transfer_syntax_count; + } + if (context.transfer_syntax_count == 0U) { + return false; + } + saw_transfers = true; + } else { + return false; + } + } + return saw_id && saw_abstract && saw_transfers; +} + +[[nodiscard]] bool validate_context_list( + const std::span list, + std::size_t& count) noexcept { + count = 0U; + std::array ids{}; + std::size_t offset = 0U; + while (offset < list.size()) { + if (count >= PresentationSpanCodec::maximum_contexts) { + count = 0U; + return false; + } + asn1::BerTlvView definition; + ContextDefinitionView context; + if (!asn1::BerSpanReader::try_read_tlv(list, offset, definition) || + !parse_context_definition(definition, context)) { + count = 0U; + return false; + } + for (std::size_t index = 0U; index < count; ++index) { + if (ids[index] == context.id) { + count = 0U; + return false; + } + } + ids[count++] = context.id; + } + return count != 0U; +} + +[[nodiscard]] bool find_context_by_abstract_syntax( + const std::span list, + const std::span oid, + std::uint32_t& id) noexcept { + id = 0U; + std::size_t offset = 0U; + while (offset < list.size()) { + asn1::BerTlvView definition; + ContextDefinitionView context; + if (!asn1::BerSpanReader::try_read_tlv(list, offset, definition) || + !parse_context_definition(definition, context)) { + return false; + } + if (context.abstract_syntax.size() == oid.size() && + std::equal( + context.abstract_syntax.begin(), + context.abstract_syntax.end(), + oid.begin())) { + id = context.id; + return true; + } + } + return false; +} + +[[nodiscard]] bool context_id_exists( + const std::span list, + const std::uint32_t id) noexcept { + std::size_t offset = 0U; + while (offset < list.size()) { + asn1::BerTlvView definition; + ContextDefinitionView context; + if (!asn1::BerSpanReader::try_read_tlv(list, offset, definition) || + !parse_context_definition(definition, context)) { + return false; + } + if (context.id == id) { + return true; + } + } + return false; +} + +struct FullyEncodedSizes final { + std::size_t id_value{}; + std::size_t id_tlv{}; + std::size_t payload_tlv{}; + std::size_t sequence_content{}; + std::size_t sequence_tlv{}; + std::size_t outer_tlv{}; +}; + +[[nodiscard]] std::optional calculate_fully_encoded_sizes( + const std::uint32_t context_id, + const std::size_t payload_bytes) noexcept { + if (context_id == 0U || payload_bytes > PresentationSpanCodec::maximum_ppdu_bytes) { + return std::nullopt; + } + + FullyEncodedSizes sizes; + sizes.id_value = unsigned_integer_size(context_id); + const auto id_tlv = asn1::BerSpanWriter::tlv_size(2, sizes.id_value); + const auto payload_tlv = asn1::BerSpanWriter::tlv_size(0, payload_bytes); + const auto sequence_content = add_size(id_tlv, payload_tlv); + if (!id_tlv || !payload_tlv || !sequence_content) { + return std::nullopt; + } + const auto sequence_tlv = asn1::BerSpanWriter::tlv_size(16, *sequence_content); + if (!sequence_tlv) { + return std::nullopt; + } + const auto outer_tlv = asn1::BerSpanWriter::tlv_size(1, *sequence_tlv); + if (!outer_tlv || *outer_tlv > PresentationSpanCodec::maximum_ppdu_bytes) { + return std::nullopt; + } + + sizes.id_tlv = *id_tlv; + sizes.payload_tlv = *payload_tlv; + sizes.sequence_content = *sequence_content; + sizes.sequence_tlv = *sequence_tlv; + sizes.outer_tlv = *outer_tlv; + return sizes; +} + +[[nodiscard]] bool decode_fully_encoded_outer( + const asn1::BerTlvView& outer, + PresentationPdvView& pdv) noexcept { + pdv = {}; + if (outer.tag_class != asn1::BerClass::application || + outer.tag_number != 1 || !outer.constructed) { + return false; + } + + asn1::BerTlvView sequence; + if (!asn1::BerSpanReader::try_read_exact(outer.value, sequence) || + sequence.tag_class != asn1::BerClass::universal || + sequence.tag_number != 16 || !sequence.constructed) { + return false; + } + + bool saw_context_id = false; + bool saw_payload = false; + std::size_t offset = 0U; + while (offset < sequence.value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(sequence.value, offset, field)) { + pdv = {}; + return false; + } + + if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 2 && !field.constructed) { + if (saw_context_id || !read_positive_integer(field, pdv.context_id)) { + pdv = {}; + return false; + } + saw_context_id = true; + } else if (field.tag_class == asn1::BerClass::context_specific && + field.tag_number == 0 && field.constructed) { + if (saw_payload) { + pdv = {}; + return false; + } + pdv.single_asn1_type = field.value; + saw_payload = true; + } else { + pdv = {}; + return false; + } + } + return saw_context_id && saw_payload; +} + +[[nodiscard]] bool decode_mode_selector( + const asn1::BerTlvView& item, + std::uint32_t& mode) noexcept { + if (item.tag_class != asn1::BerClass::context_specific || + item.tag_number != 0 || !item.constructed) { + return false; + } + asn1::BerTlvView value; + return asn1::BerSpanReader::try_read_exact(item.value, value) && + value.tag_class == asn1::BerClass::context_specific && + value.tag_number == 0 && !value.constructed && + read_positive_integer(value, mode); +} + +[[nodiscard]] std::optional context_results_content_size( + const PresentationCpView& request) noexcept { + std::size_t total = 0U; + std::size_t offset = 0U; + while (offset < request.context_definition_list.size()) { + asn1::BerTlvView definition; + ContextDefinitionView context; + if (!asn1::BerSpanReader::try_read_tlv( + request.context_definition_list, offset, definition) || + !parse_context_definition(definition, context)) { + return std::nullopt; + } + const auto result_tlv = asn1::BerSpanWriter::tlv_size(0, 1U); + const auto syntax_tlv = asn1::BerSpanWriter::tlv_size( + 1, context.first_transfer_syntax.size()); + const auto sequence_content = add_size(result_tlv, syntax_tlv); + if (!sequence_content) { + return std::nullopt; + } + const auto sequence_tlv = asn1::BerSpanWriter::tlv_size( + 16, *sequence_content); + if (!sequence_tlv || *sequence_tlv > + std::numeric_limits::max() - total) { + return std::nullopt; + } + total += *sequence_tlv; + } + return total; +} + +struct CpaSizes final { + std::size_t mode_value{}; + std::size_t mode_inner_tlv{}; + std::size_t mode_outer_tlv{}; + std::size_t results_content{}; + std::size_t results_tlv{}; + std::size_t user_tlv{}; + std::size_t normal_content{}; + std::size_t normal_tlv{}; + std::size_t outer_content{}; + std::size_t outer_tlv{}; +}; + +[[nodiscard]] std::optional calculate_cpa_sizes( + const PresentationCpView& request, + const std::size_t acse_aare_bytes) noexcept { + if (request.mode_selector == 0U || request.context_count == 0U) { + return std::nullopt; + } + + CpaSizes sizes; + sizes.mode_value = unsigned_integer_size(request.mode_selector); + const auto mode_inner = asn1::BerSpanWriter::tlv_size(0, sizes.mode_value); + if (!mode_inner) { + return std::nullopt; + } + const auto mode_outer = asn1::BerSpanWriter::tlv_size(0, *mode_inner); + const auto results_content = context_results_content_size(request); + if (!mode_outer || !results_content) { + return std::nullopt; + } + const auto results_tlv = asn1::BerSpanWriter::tlv_size(5, *results_content); + const auto user_tlv = PresentationSpanCodec::fully_encoded_data_size( + request.user_data.context_id, acse_aare_bytes); + const auto normal_content = add_size(results_tlv, user_tlv); + if (!results_tlv || !user_tlv || !normal_content) { + return std::nullopt; + } + const auto normal_tlv = asn1::BerSpanWriter::tlv_size(2, *normal_content); + const auto outer_content = add_size(mode_outer, normal_tlv); + if (!normal_tlv || !outer_content) { + return std::nullopt; + } + const auto outer_tlv = asn1::BerSpanWriter::tlv_size(17, *outer_content); + if (!outer_tlv || *outer_tlv > PresentationSpanCodec::maximum_ppdu_bytes) { + return std::nullopt; + } + + sizes.mode_inner_tlv = *mode_inner; + sizes.mode_outer_tlv = *mode_outer; + sizes.results_content = *results_content; + sizes.results_tlv = *results_tlv; + sizes.user_tlv = *user_tlv; + sizes.normal_content = *normal_content; + sizes.normal_tlv = *normal_tlv; + sizes.outer_content = *outer_content; + sizes.outer_tlv = *outer_tlv; + return sizes; +} + +} // namespace + +bool PresentationCpView::try_context_id_for_abstract_syntax( + const std::span object_identifier_value, + std::uint32_t& context_id) const noexcept { + return find_context_by_abstract_syntax( + context_definition_list, object_identifier_value, context_id); +} + +std::span +PresentationSpanCodec::acse_abstract_syntax_name() noexcept { + return kAcseAbstractSyntax; +} + +std::span +PresentationSpanCodec::mms_abstract_syntax_name() noexcept { + return kMmsAbstractSyntax; +} + +std::span +PresentationSpanCodec::ber_transfer_syntax_name() noexcept { + return kBerTransferSyntax; +} + +std::optional PresentationSpanCodec::fully_encoded_data_size( + const std::uint32_t context_id, + const std::size_t single_asn1_type_bytes) noexcept { + const auto sizes = calculate_fully_encoded_sizes(context_id, single_asn1_type_bytes); + return sizes ? std::optional{sizes->outer_tlv} : std::nullopt; +} + +wire::EncodeResult PresentationSpanCodec::encode_fully_encoded_data_into( + const std::uint32_t context_id, + const std::span single_asn1_type, + const std::span destination) noexcept { + const auto sizes = calculate_fully_encoded_sizes(context_id, single_asn1_type.size()); + if (!sizes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizes->outer_tlv) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizes->outer_tlv}; + } + + asn1::BerSpanWriter writer{destination.first(sizes->outer_tlv)}; + if (!writer.write_tlv_header( + asn1::BerClass::application, true, 1, sizes->sequence_tlv) || + !writer.write_tlv_header( + asn1::BerClass::universal, true, 16, sizes->sequence_content) || + !writer.write_tlv_header( + asn1::BerClass::universal, false, 2, sizes->id_value) || + !write_unsigned_integer(writer, context_id) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, + true, + 0, + single_asn1_type.size()) || + !writer.write_bytes(single_asn1_type) || + writer.size() != sizes->outer_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizes->outer_tlv}; + } + return {wire::EncodeStatus::ok, sizes->outer_tlv, sizes->outer_tlv}; +} + +bool PresentationSpanCodec::try_decode_fully_encoded_data_view( + const std::span bytes, + PresentationPdvView& pdv) noexcept { + pdv = {}; + if (bytes.empty() || bytes.size() > maximum_ppdu_bytes) { + return false; + } + asn1::BerTlvView outer; + return asn1::BerSpanReader::try_read_exact(bytes, outer) && + decode_fully_encoded_outer(outer, pdv); +} + +bool PresentationSpanCodec::try_decode_cp_view( + const std::span bytes, + PresentationCpView& cp) noexcept { + cp = {}; + if (bytes.empty() || bytes.size() > maximum_ppdu_bytes) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(bytes, outer) || + outer.tag_class != asn1::BerClass::universal || + outer.tag_number != 17 || !outer.constructed) { + return false; + } + + bool saw_mode = false; + bool saw_normal = false; + std::size_t offset = 0U; + while (offset < outer.value.size()) { + asn1::BerTlvView item; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, item)) { + cp = {}; + return false; + } + + if (item.tag_class == asn1::BerClass::context_specific && + item.tag_number == 0 && item.constructed) { + if (saw_mode || !decode_mode_selector(item, cp.mode_selector)) { + cp = {}; + return false; + } + saw_mode = true; + } else if (item.tag_class == asn1::BerClass::context_specific && + item.tag_number == 2 && item.constructed) { + if (saw_normal) { + cp = {}; + return false; + } + bool saw_calling = false; + bool saw_called = false; + bool saw_contexts = false; + bool saw_user_data = false; + std::size_t normal_offset = 0U; + while (normal_offset < item.value.size()) { + asn1::BerTlvView normal; + if (!asn1::BerSpanReader::try_read_tlv( + item.value, normal_offset, normal)) { + cp = {}; + return false; + } + + if (normal.tag_class == asn1::BerClass::context_specific && + normal.tag_number == 1 && !normal.constructed) { + if (saw_calling || normal.value.size() > maximum_selector_bytes) { + cp = {}; + return false; + } + cp.calling_selector = normal.value; + saw_calling = true; + } else if (normal.tag_class == asn1::BerClass::context_specific && + normal.tag_number == 2 && !normal.constructed) { + if (saw_called || normal.value.size() > maximum_selector_bytes) { + cp = {}; + return false; + } + cp.called_selector = normal.value; + saw_called = true; + } else if (normal.tag_class == asn1::BerClass::context_specific && + normal.tag_number == 4 && normal.constructed) { + if (saw_contexts || !validate_context_list( + normal.value, cp.context_count)) { + cp = {}; + return false; + } + cp.context_definition_list = normal.value; + saw_contexts = true; + } else if (normal.tag_class == asn1::BerClass::application && + normal.tag_number == 1 && normal.constructed) { + if (saw_user_data || !decode_fully_encoded_outer( + normal, cp.user_data)) { + cp = {}; + return false; + } + saw_user_data = true; + } else { + cp = {}; + return false; + } + } + if (!saw_contexts || !saw_user_data || + !context_id_exists( + cp.context_definition_list, cp.user_data.context_id)) { + cp = {}; + return false; + } + saw_normal = true; + } else { + cp = {}; + return false; + } + } + + if (!saw_mode || !saw_normal) { + cp = {}; + return false; + } + return true; +} + +std::optional PresentationSpanCodec::cpa_accepting_size( + const PresentationCpView& request, + const std::size_t acse_aare_bytes) noexcept { + const auto sizes = calculate_cpa_sizes(request, acse_aare_bytes); + return sizes ? std::optional{sizes->outer_tlv} : std::nullopt; +} + +wire::EncodeResult PresentationSpanCodec::encode_cpa_accepting_into( + const PresentationCpView& request, + const std::span acse_aare, + const std::span destination) noexcept { + const auto sizes = calculate_cpa_sizes(request, acse_aare.size()); + if (!sizes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizes->outer_tlv) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizes->outer_tlv}; + } + + asn1::BerSpanWriter writer{destination.first(sizes->outer_tlv)}; + if (!writer.write_tlv_header( + asn1::BerClass::universal, true, 17, sizes->outer_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, sizes->mode_inner_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, sizes->mode_value) || + !write_unsigned_integer(writer, request.mode_selector) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 2, sizes->normal_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 5, sizes->results_content)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizes->outer_tlv}; + } + + std::size_t context_offset = 0U; + while (context_offset < request.context_definition_list.size()) { + asn1::BerTlvView definition; + ContextDefinitionView context; + if (!asn1::BerSpanReader::try_read_tlv( + request.context_definition_list, context_offset, definition) || + !parse_context_definition(definition, context)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizes->outer_tlv}; + } + const auto result_tlv = asn1::BerSpanWriter::tlv_size(0, 1U); + const auto syntax_tlv = asn1::BerSpanWriter::tlv_size( + 1, context.first_transfer_syntax.size()); + const auto sequence_content = add_size(result_tlv, syntax_tlv); + if (!sequence_content || + !writer.write_tlv_header( + asn1::BerClass::universal, true, 16, *sequence_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, 1U) || + !writer.write_byte(0x00U) || + !writer.write_tlv( + asn1::BerClass::context_specific, + false, + 1, + context.first_transfer_syntax)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizes->outer_tlv}; + } + } + + const auto user_result = encode_fully_encoded_data_into( + request.user_data.context_id, + acse_aare, + destination.first(sizes->outer_tlv).subspan(writer.size(), sizes->user_tlv)); + if (!user_result.success() || user_result.bytes_written != sizes->user_tlv) { + return {user_result.status, 0U, sizes->outer_tlv}; + } + const auto written = writer.size() + user_result.bytes_written; + if (written != sizes->outer_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizes->outer_tlv}; + } + return {wire::EncodeStatus::ok, written, sizes->outer_tlv}; +} + +wire::EncodeResult PresentationSpanCodec::encode_p_data_into( + const std::span abstract_syntax_payload, + const std::span destination, + const std::uint32_t presentation_context_id, + const bool include_give_tokens_prefix) noexcept { + const auto fully_encoded = fully_encoded_data_size( + presentation_context_id, abstract_syntax_payload.size()); + if (!fully_encoded) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto prefix = include_give_tokens_prefix ? 4U : 2U; + if (*fully_encoded > std::numeric_limits::max() - prefix) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto required = prefix + *fully_encoded; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + std::size_t output_offset = 0U; + if (include_give_tokens_prefix) { + destination[output_offset++] = SessionSpanCodec::data_transfer_code; + destination[output_offset++] = 0x00U; + } + destination[output_offset++] = SessionSpanCodec::data_transfer_code; + destination[output_offset++] = 0x00U; + + const auto result = encode_fully_encoded_data_into( + presentation_context_id, + abstract_syntax_payload, + destination.subspan(output_offset, *fully_encoded)); + if (!result.success() || result.bytes_written != *fully_encoded) { + return {result.status, 0U, required}; + } + return {wire::EncodeStatus::ok, required, required}; +} + +bool PresentationSpanCodec::try_decode_p_data_view( + const std::span bytes, + PresentationPdvView& pdv) noexcept { + pdv = {}; + std::span presentation = bytes; + SessionDataTransferView transfer; + if (!bytes.empty() && bytes.front() == SessionSpanCodec::data_transfer_code) { + if (!SessionSpanCodec::try_decode_data_transfer_view(bytes, transfer)) { + return false; + } + presentation = transfer.presentation_payload; + } + return try_decode_fully_encoded_data_view(presentation, pdv); +} + +} // namespace ar::iec61850::osi diff --git a/src/osi/session_span.cpp b/src/osi/session_span.cpp new file mode 100644 index 000000000..43353322d --- /dev/null +++ b/src/osi/session_span.cpp @@ -0,0 +1,346 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/osi/session_span.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::osi { +namespace { + +constexpr std::array kParameter05{ + 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U}; +constexpr std::array kParameter14{0x00U, 0x02U}; +constexpr std::array kParameter33{0x00U, 0x01U}; +constexpr std::array kParameter34{0x00U, 0x01U}; + +const std::array kDefaultParameters{ + SessionParameterView{0x05U, kParameter05}, + SessionParameterView{0x14U, kParameter14}, + SessionParameterView{0x33U, kParameter33}, + SessionParameterView{0x34U, kParameter34}, +}; + +[[nodiscard]] bool encoded_body_size( + const std::span parameters, + const std::span user_data, + std::size_t& body_length) noexcept { + body_length = 0U; + if (parameters.size() > SessionSpanCodec::maximum_parameter_count || + user_data.size() > std::numeric_limits::max()) { + return false; + } + + if (!user_data.empty()) { + const auto encoded_user = user_data.size() + 2U; + if (encoded_user > std::numeric_limits::max()) { + return false; + } + body_length = encoded_user; + } + for (const auto& parameter : parameters) { + if (parameter.code == SessionSpanCodec::user_data_parameter || + parameter.value.size() > std::numeric_limits::max()) { + body_length = 0U; + return false; + } + const auto encoded = parameter.value.size() + 2U; + if (body_length > std::numeric_limits::max() || + encoded > std::numeric_limits::max() - body_length) { + body_length = 0U; + return false; + } + body_length += encoded; + } + return body_length <= std::numeric_limits::max(); +} + +[[nodiscard]] bool write_parameter( + const std::span destination, + std::size_t& offset, + const std::uint8_t code, + const std::span value) noexcept { + if (offset > destination.size() || + value.size() > std::numeric_limits::max() || + value.size() + 2U > destination.size() - offset) { + return false; + } + destination[offset++] = code; + destination[offset++] = static_cast(value.size()); + std::copy( + value.begin(), + value.end(), + destination.begin() + static_cast(offset)); + offset += value.size(); + return true; +} + +} // namespace + +bool SessionSpduView::try_parameter( + const std::uint8_t parameter_code, + std::span& value) const noexcept { + value = {}; + std::size_t offset = 0U; + while (offset < parameter_bytes.size()) { + if (parameter_bytes.size() - offset < 2U) { + return false; + } + const auto current_code = parameter_bytes[offset]; + const auto length = static_cast(parameter_bytes[offset + 1U]); + offset += 2U; + if (length > parameter_bytes.size() - offset) { + return false; + } + if (current_code == parameter_code) { + value = parameter_bytes.subspan(offset, length); + return true; + } + offset += length; + } + return false; +} + +SessionWireKind SessionSpanCodec::kind_from_code(const std::uint8_t code) noexcept { + switch (code) { + case connect_code: + return SessionWireKind::connect; + case accept_code: + return SessionWireKind::accept; + case reject_code: + return SessionWireKind::reject; + case refuse_code: + return SessionWireKind::refuse; + case abort_code: + return SessionWireKind::abort; + case data_transfer_code: + return SessionWireKind::data_transfer; + default: + return SessionWireKind::unknown; + } +} + +std::span SessionSpanCodec::default_parameters() noexcept { + return kDefaultParameters; +} + +bool SessionSpanCodec::try_decode_prefix_view( + const std::span bytes, + SessionSpduView& spdu, + std::size_t& consumed) noexcept { + spdu = {}; + consumed = 0U; + if (bytes.size() < 2U) { + return false; + } + + const auto kind = kind_from_code(bytes[0]); + if (kind == SessionWireKind::unknown) { + return false; + } + + const auto body_length = static_cast(bytes[1]); + const auto total_length = body_length + 2U; + if (total_length > maximum_spdu_bytes || total_length > bytes.size()) { + return false; + } + + std::size_t offset = 2U; + std::size_t parameter_end = total_length; + std::size_t parameter_count = 0U; + bool saw_user_data = false; + std::span user_data; + + while (offset < total_length) { + if (parameter_count >= maximum_parameter_count || + total_length - offset < 2U) { + return false; + } + ++parameter_count; + const auto parameter_start = offset; + const auto code = bytes[offset++]; + const auto length = static_cast(bytes[offset++]); + if (length > total_length - offset) { + return false; + } + + if (code == user_data_parameter) { + if (saw_user_data || offset + length != total_length) { + return false; + } + saw_user_data = true; + parameter_end = parameter_start; + user_data = bytes.subspan(offset, length); + } + offset += length; + } + + spdu.kind = kind; + spdu.code = bytes[0]; + spdu.length_indicator = bytes[1]; + spdu.parameter_bytes = bytes.subspan(2U, parameter_end - 2U); + spdu.user_data = user_data; + consumed = total_length; + return true; +} + +bool SessionSpanCodec::try_decode_view( + const std::span bytes, + SessionSpduView& spdu) noexcept { + std::size_t consumed = 0U; + return try_decode_prefix_view(bytes, spdu, consumed) && consumed == bytes.size(); +} + +wire::EncodeResult SessionSpanCodec::encode_into( + const std::uint8_t code, + const std::span parameters, + const std::span user_data, + const std::span destination) noexcept { + if (kind_from_code(code) == SessionWireKind::unknown) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::size_t body_length{}; + if (!encoded_body_size(parameters, user_data, body_length)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto required = body_length + 2U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = code; + destination[1] = static_cast(body_length); + std::size_t offset = 2U; + for (const auto& parameter : parameters) { + if (!write_parameter(destination, offset, parameter.code, parameter.value)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + } + if (!user_data.empty() && + !write_parameter(destination, offset, user_data_parameter, user_data)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (offset != required) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + return {wire::EncodeStatus::ok, required, required}; +} + +wire::EncodeResult SessionSpanCodec::encode_connect_into( + const std::span presentation_payload, + const std::span destination, + const std::span parameters) noexcept { + const auto effective = parameters.empty() ? default_parameters() : parameters; + return encode_into(connect_code, effective, presentation_payload, destination); +} + +wire::EncodeResult SessionSpanCodec::encode_accept_into( + const std::span presentation_payload, + const std::span destination, + const std::span parameters) noexcept { + const auto effective = parameters.empty() ? default_parameters() : parameters; + return encode_into(accept_code, effective, presentation_payload, destination); +} + +wire::EncodeResult SessionSpanCodec::encode_accept_mirroring_into( + const SessionSpduView& connect, + const std::span presentation_payload, + const std::span destination) noexcept { + if (connect.kind != SessionWireKind::connect || + presentation_payload.size() > std::numeric_limits::max()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto user_bytes = presentation_payload.empty() + ? 0U + : presentation_payload.size() + 2U; + if (user_bytes > std::numeric_limits::max() || + connect.parameter_bytes.size() > + std::numeric_limits::max() - user_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto body_length = connect.parameter_bytes.size() + user_bytes; + const auto required = body_length + 2U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = accept_code; + destination[1] = static_cast(body_length); + std::copy( + connect.parameter_bytes.begin(), + connect.parameter_bytes.end(), + destination.begin() + 2); + std::size_t offset = 2U + connect.parameter_bytes.size(); + if (!presentation_payload.empty() && + !write_parameter( + destination, + offset, + user_data_parameter, + presentation_payload)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + return offset == required + ? wire::EncodeResult{wire::EncodeStatus::ok, required, required} + : wire::EncodeResult{wire::EncodeStatus::value_out_of_range, 0U, required}; +} + +wire::EncodeResult SessionSpanCodec::encode_data_transfer_into( + const std::span presentation_payload, + const std::span destination, + const bool include_give_tokens_prefix) noexcept { + const auto prefix = include_give_tokens_prefix ? 4U : 2U; + if (presentation_payload.size() > + std::numeric_limits::max() - prefix) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto required = presentation_payload.size() + prefix; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + std::size_t offset = 0U; + if (include_give_tokens_prefix) { + destination[offset++] = data_transfer_code; + destination[offset++] = 0x00U; + } + destination[offset++] = data_transfer_code; + destination[offset++] = 0x00U; + std::copy( + presentation_payload.begin(), + presentation_payload.end(), + destination.begin() + static_cast(offset)); + return {wire::EncodeStatus::ok, required, required}; +} + +bool SessionSpanCodec::try_decode_data_transfer_view( + const std::span bytes, + SessionDataTransferView& transfer) noexcept { + transfer = {}; + if (bytes.size() < 2U) { + return false; + } + + std::size_t offset = 0U; + if (bytes.size() >= 4U && + bytes[0] == data_transfer_code && bytes[1] == 0x00U && + bytes[2] == data_transfer_code && bytes[3] == 0x00U) { + transfer.has_give_tokens_prefix = true; + offset = 4U; + } else if (bytes[0] == data_transfer_code && bytes[1] == 0x00U) { + transfer.has_give_tokens_prefix = false; + offset = 2U; + } else { + return false; + } + + transfer.presentation_payload = bytes.subspan(offset); + return true; +} + +} // namespace ar::iec61850::osi diff --git a/src/osi/tpkt_span.cpp b/src/osi/tpkt_span.cpp new file mode 100644 index 000000000..aefec4b99 --- /dev/null +++ b/src/osi/tpkt_span.cpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::osi { +namespace { + +[[nodiscard]] std::uint16_t read_be_u16( + const std::span bytes, + const std::size_t offset) noexcept { + return static_cast( + (static_cast(bytes[offset]) << 8U) | + static_cast(bytes[offset + 1U])); +} + +void write_be_u16( + const std::span bytes, + const std::size_t offset, + const std::uint16_t value) noexcept { + bytes[offset] = static_cast((value >> 8U) & 0xFFU); + bytes[offset + 1U] = static_cast(value & 0xFFU); +} + +} // namespace + +wire::EncodeResult TpktSpanCodec::encode_into( + const std::span payload, + const std::span destination) noexcept { + if (payload.size() > maximum_payload_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto required = payload.size() + header_length; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = supported_version; + destination[1] = 0x00U; + write_be_u16(destination, 2U, static_cast(required)); + std::copy( + payload.begin(), + payload.end(), + destination.begin() + static_cast(header_length)); + return {wire::EncodeStatus::ok, required, required}; +} + +TpktPeekResult TpktSpanCodec::peek_frame( + const std::span bytes) noexcept { + if (bytes.size() < header_length) { + return {TpktPeekStatus::need_more, 0U}; + } + if (bytes[0] != supported_version || bytes[1] != 0x00U) { + return {TpktPeekStatus::invalid, 0U}; + } + + const auto declared = static_cast(read_be_u16(bytes, 2U)); + if (declared < header_length) { + return {TpktPeekStatus::invalid, 0U}; + } + if (bytes.size() < declared) { + return {TpktPeekStatus::need_more, declared}; + } + return {TpktPeekStatus::ready, declared}; +} + +bool TpktSpanCodec::try_decode_view( + const std::span bytes, + TpktFrameView& frame) noexcept { + frame = {}; + const auto peek = peek_frame(bytes); + if (!peek.ready() || peek.frame_bytes != bytes.size()) { + return false; + } + + frame.version = bytes[0]; + frame.declared_length = static_cast(peek.frame_bytes); + frame.payload = bytes.subspan(header_length); + return true; +} + +} // namespace ar::iec61850::osi diff --git a/tests/test_mms_static_server_session.cpp b/tests/test_mms_static_server_session.cpp new file mode 100644 index 000000000..f30185f14 --- /dev/null +++ b/tests/test_mms_static_server_session.cpp @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_server_session.hpp" +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +struct FakeTcp final { + std::span inbound; + std::span outbound; + std::size_t inbound_offset{}; + std::size_t outbound_size{}; + std::size_t maximum_receive_chunk{2U}; + std::size_t maximum_send_chunk{3U}; + bool close_when_empty{true}; +}; + +[[nodiscard]] embedded::IoResult fake_receive( + void* context, + const std::span destination) noexcept { + auto& tcp = *static_cast(context); + if (tcp.inbound_offset == tcp.inbound.size()) { + return { + tcp.close_when_empty ? embedded::IoStatus::closed + : embedded::IoStatus::would_block, + 0U}; + } + const auto count = std::min({ + destination.size(), + tcp.maximum_receive_chunk, + tcp.inbound.size() - tcp.inbound_offset}); + std::copy_n( + tcp.inbound.begin() + static_cast(tcp.inbound_offset), + count, + destination.begin()); + tcp.inbound_offset += count; + return {embedded::IoStatus::ok, count}; +} + +[[nodiscard]] embedded::IoResult fake_send( + void* context, + const std::span bytes) noexcept { + auto& tcp = *static_cast(context); + if (tcp.outbound_size == tcp.outbound.size()) { + return {embedded::IoStatus::would_block, 0U}; + } + const auto count = std::min({ + bytes.size(), + tcp.maximum_send_chunk, + tcp.outbound.size() - tcp.outbound_size}); + std::copy_n( + bytes.begin(), + count, + tcp.outbound.begin() + static_cast(tcp.outbound_size)); + tcp.outbound_size += count; + return {embedded::IoStatus::ok, count}; +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] bool fragmented_connection_request_is_pumped() noexcept { + constexpr std::array boolean_type{0x83U, 0x00U}; + const bool value = true; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "ESP32S3IOLD0", + "GGIO1$ST$Ind1$stVal", + boolean_type, + read_boolean, + &value}}; + const mms::MmsStaticObjectTable table{objects}; + const mms::MmsStaticApplicationDispatcher dispatcher{table}; + mms::MmsStaticConnectionRuntime runtime{dispatcher}; + + constexpr std::array tpdu_size{0x0AU}; + constexpr std::array source_tsap{0x00U, 0x01U}; + constexpr std::array destination_tsap{0x00U, 0x01U}; + const std::array parameters{ + osi::CotpParameterView{osi::CotpSpanCodec::tpdu_size_parameter, tpdu_size}, + osi::CotpParameterView{osi::CotpSpanCodec::source_tsap_parameter, source_tsap}, + osi::CotpParameterView{ + osi::CotpSpanCodec::destination_tsap_parameter, + destination_tsap}}; + + std::array cotp_bytes{}; + std::array inbound{}; + const auto cotp = osi::CotpSpanCodec::encode_connection_request_into( + 0x0001U, parameters, cotp_bytes); + const auto tpkt = cotp.success() + ? osi::TpktSpanCodec::encode_into( + std::span{cotp_bytes}.first(cotp.bytes_written), + inbound) + : wire::EncodeResult{}; + if (!cotp.success() || !tpkt.success()) { + return false; + } + + std::array outbound{}; + FakeTcp fake{ + std::span{inbound}.first(tpkt.bytes_written), + outbound}; + const embedded::TcpByteStream stream{&fake, fake_send, fake_receive}; + std::array receive{}; + std::array response{}; + std::array workspace{}; + mms::MmsStaticServerSession session{ + runtime, + stream, + {receive, response, workspace}}; + + for (std::size_t iteration = 0U; iteration < 256U; ++iteration) { + const auto result = session.poll_once(); + if (result.terminal()) { + return false; + } + const auto peek = osi::TpktSpanCodec::peek_frame( + std::span{outbound}.first(fake.outbound_size)); + if (peek.status == osi::TpktPeekStatus::ready) { + osi::TpktFrameView response_tpkt; + osi::CotpTpduView response_cotp; + return fake.inbound_offset == tpkt.bytes_written && + session.pending_output_bytes() == 0U && + runtime.state() == + mms::MmsStaticConnectionState::awaiting_association && + osi::TpktSpanCodec::try_decode_view( + std::span{outbound}.first(peek.frame_bytes), + response_tpkt) && + osi::CotpSpanCodec::try_decode_view( + response_tpkt.payload, + response_cotp) && + response_cotp.kind == osi::CotpWireKind::connection_confirm && + response_cotp.destination_reference == 0x0001U; + } + } + return false; +} + +[[nodiscard]] bool oversized_frame_is_bounded() noexcept { + const bool value = false; + constexpr std::array boolean_type{0x83U, 0x00U}; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "X", boolean_type, read_boolean, &value}}; + const mms::MmsStaticObjectTable table{objects}; + const mms::MmsStaticApplicationDispatcher dispatcher{table}; + mms::MmsStaticConnectionRuntime runtime{dispatcher}; + + constexpr std::array declared_large_tpkt{ + 0x03U, 0x00U, 0x04U, 0x00U, 0x02U, 0xF0U, 0x80U, 0x00U}; + std::array discarded_output{}; + FakeTcp fake{declared_large_tpkt, discarded_output}; + fake.maximum_receive_chunk = declared_large_tpkt.size(); + const embedded::TcpByteStream stream{&fake, fake_send, fake_receive}; + std::array receive{}; + std::array response{}; + std::array workspace{}; + mms::MmsStaticServerSession session{ + runtime, + stream, + {receive, response, workspace}}; + + const auto first = session.poll_once(); + const auto second = session.poll_once(); + return first.status == mms::MmsStaticServerSessionStatus::progressed && + second.status == + mms::MmsStaticServerSessionStatus::receive_buffer_full && + second.terminal(); +} + +[[nodiscard]] bool peer_close_is_reported() noexcept { + const bool value = false; + constexpr std::array boolean_type{0x83U, 0x00U}; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "X", boolean_type, read_boolean, &value}}; + const mms::MmsStaticObjectTable table{objects}; + const mms::MmsStaticApplicationDispatcher dispatcher{table}; + mms::MmsStaticConnectionRuntime runtime{dispatcher}; + std::array empty_inbound{}; + std::array outbound{}; + FakeTcp fake{std::span{empty_inbound}.first(0U), outbound}; + const embedded::TcpByteStream stream{&fake, fake_send, fake_receive}; + std::array receive{}; + std::array response{}; + std::array workspace{}; + mms::MmsStaticServerSession session{ + runtime, + stream, + {receive, response, workspace}}; + const auto result = session.poll_once(); + return result.status == mms::MmsStaticServerSessionStatus::peer_closed && + result.terminal(); +} + +} // namespace + +int main() { + if (!fragmented_connection_request_is_pumped()) { + return 1; + } + if (!oversized_frame_is_bounded()) { + return 2; + } + if (!peer_close_is_reported()) { + return 3; + } + return 0; +} diff --git a/tools/static_ied_server.cpp b/tools/static_ied_server.cpp new file mode 100644 index 000000000..6173baaa5 --- /dev/null +++ b/tools/static_ied_server.cpp @@ -0,0 +1,638 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_server_session.hpp" +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/mms/services.hpp" + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#else +#include +#include +#include +#include +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +#ifdef _WIN32 +using SocketHandle = SOCKET; +constexpr SocketHandle kInvalidSocket = INVALID_SOCKET; +#else +using SocketHandle = int; +constexpr SocketHandle kInvalidSocket = -1; +#endif + +struct Options final { + std::string bind_address{"0.0.0.0"}; + std::uint16_t port{8102U}; + std::uint8_t digital_input_mask{}; + bool serve_once{}; +}; + +class SocketSystem final { +public: + SocketSystem() noexcept { +#ifdef _WIN32 + WSADATA data{}; + ready_ = WSAStartup(MAKEWORD(2, 2), &data) == 0; +#else + ready_ = true; +#endif + } + + ~SocketSystem() { +#ifdef _WIN32 + if (ready_) { + WSACleanup(); + } +#endif + } + + SocketSystem(const SocketSystem&) = delete; + SocketSystem& operator=(const SocketSystem&) = delete; + + [[nodiscard]] bool ready() const noexcept { + return ready_; + } + +private: + bool ready_{}; +}; + +class OwnedSocket final { +public: + OwnedSocket() noexcept = default; + explicit OwnedSocket(const SocketHandle handle) noexcept : handle_{handle} {} + + ~OwnedSocket() { + reset(); + } + + OwnedSocket(const OwnedSocket&) = delete; + OwnedSocket& operator=(const OwnedSocket&) = delete; + + OwnedSocket(OwnedSocket&& other) noexcept : handle_{other.release()} {} + + OwnedSocket& operator=(OwnedSocket&& other) noexcept { + if (this != &other) { + reset(other.release()); + } + return *this; + } + + [[nodiscard]] SocketHandle get() const noexcept { + return handle_; + } + + [[nodiscard]] bool valid() const noexcept { + return handle_ != kInvalidSocket; + } + + [[nodiscard]] SocketHandle release() noexcept { + const auto handle = handle_; + handle_ = kInvalidSocket; + return handle; + } + + void reset(const SocketHandle replacement = kInvalidSocket) noexcept { + if (valid()) { +#ifdef _WIN32 + closesocket(handle_); +#else + close(handle_); +#endif + } + handle_ = replacement; + } + +private: + SocketHandle handle_{kInvalidSocket}; +}; + +[[nodiscard]] int last_socket_error() noexcept { +#ifdef _WIN32 + return WSAGetLastError(); +#else + return errno; +#endif +} + +[[nodiscard]] bool is_would_block(const int error) noexcept { +#ifdef _WIN32 + return error == WSAEWOULDBLOCK; +#else + return error == EAGAIN || error == EWOULDBLOCK; +#endif +} + +[[nodiscard]] bool is_timeout(const int error) noexcept { +#ifdef _WIN32 + return error == WSAETIMEDOUT; +#else + return error == ETIMEDOUT; +#endif +} + +[[nodiscard]] bool is_interrupted(const int error) noexcept { +#ifdef _WIN32 + return error == WSAEINTR; +#else + return error == EINTR; +#endif +} + +[[nodiscard]] embedded::IoResult socket_receive( + void* context, + const std::span destination) noexcept { + const auto socket = *static_cast(context); +#ifdef _WIN32 + const auto requested = static_cast(std::min( + destination.size(), + static_cast(std::numeric_limits::max()))); +#else + const auto requested = destination.size(); +#endif + const auto received = recv( + socket, + reinterpret_cast(destination.data()), + requested, + 0); + if (received > 0) { + return { + embedded::IoStatus::ok, + static_cast(received)}; + } + if (received == 0) { + return {embedded::IoStatus::closed, 0U}; + } + const auto error = last_socket_error(); + if (is_would_block(error)) { + return {embedded::IoStatus::would_block, 0U}; + } + if (is_timeout(error) || is_interrupted(error)) { + return {embedded::IoStatus::timeout, 0U}; + } + return {embedded::IoStatus::io_error, 0U}; +} + +[[nodiscard]] embedded::IoResult socket_send( + void* context, + const std::span bytes) noexcept { + const auto socket = *static_cast(context); +#ifdef _WIN32 + const auto requested = static_cast(std::min( + bytes.size(), + static_cast(std::numeric_limits::max()))); +#else + const auto requested = bytes.size(); +#endif +#ifdef MSG_NOSIGNAL + constexpr int flags = MSG_NOSIGNAL; +#else + constexpr int flags = 0; +#endif + const auto sent = send( + socket, + reinterpret_cast(bytes.data()), + requested, + flags); + if (sent > 0) { + return {embedded::IoStatus::ok, static_cast(sent)}; + } + if (sent == 0) { + return {embedded::IoStatus::closed, 0U}; + } + const auto error = last_socket_error(); + if (is_would_block(error)) { + return {embedded::IoStatus::would_block, 0U}; + } + if (is_timeout(error) || is_interrupted(error)) { + return {embedded::IoStatus::timeout, 0U}; + } + return {embedded::IoStatus::io_error, 0U}; +} + +void print_help() { + std::cout + << "arstack61850 static IEC 61850 IED server\n\n" + << "Usage:\n" + << " ariec61850_static_ied_server [options]\n\n" + << "Options:\n" + << " --bind ADDRESS Bind address (default 0.0.0.0)\n" + << " --port PORT TCP port (default 8102; use 102 for IEC 61850)\n" + << " --di-mask VALUE Initial GGIO1 Ind1..Ind8 bit mask, decimal or 0xHEX\n" + << " --once Serve one TCP connection, then exit\n" + << " --help Show this help\n\n" + << "Read-only MMS services: association, Initiate, GetNameList, " + "GetVariableAccessAttributes, Read.\n"; +} + +template +[[nodiscard]] bool parse_integer( + const std::string_view text, + Integer& output) noexcept { + auto digits = text; + int base = 10; + if (digits.size() > 2U && digits[0] == '0' && + (digits[1] == 'x' || digits[1] == 'X')) { + digits.remove_prefix(2U); + base = 16; + } + unsigned int parsed{}; + const auto result = std::from_chars( + digits.data(), digits.data() + digits.size(), parsed, base); + if (result.ec != std::errc{} || result.ptr != digits.data() + digits.size() || + parsed > static_cast(std::numeric_limits::max())) { + return false; + } + output = static_cast(parsed); + return true; +} + +[[nodiscard]] bool parse_options( + const int argc, + char** argv, + Options& options, + bool& help) { + help = false; + for (int index = 1; index < argc; ++index) { + const std::string_view argument{argv[index]}; + if (argument == "--help" || argument == "-h") { + help = true; + return true; + } + if (argument == "--once") { + options.serve_once = true; + continue; + } + if (index + 1 >= argc) { + std::cerr << "Missing value after " << argument << "\n"; + return false; + } + const std::string_view value{argv[++index]}; + if (argument == "--bind") { + options.bind_address.assign(value); + } else if (argument == "--port") { + if (!parse_integer(value, options.port) || options.port == 0U) { + std::cerr << "Invalid TCP port: " << value << "\n"; + return false; + } + } else if (argument == "--di-mask") { + if (!parse_integer(value, options.digital_input_mask)) { + std::cerr << "Invalid DI mask: " << value << "\n"; + return false; + } + } else { + std::cerr << "Unknown option: " << argument << "\n"; + return false; + } + } + return true; +} + +[[nodiscard]] OwnedSocket create_listener(const Options& options) { + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_protocol = IPPROTO_TCP; + hints.ai_flags = AI_PASSIVE; + + const auto service = std::to_string(options.port); + addrinfo* addresses{}; + const auto address_status = getaddrinfo( + options.bind_address.empty() ? nullptr : options.bind_address.c_str(), + service.c_str(), + &hints, + &addresses); + if (address_status != 0) { + std::cerr << "Unable to resolve bind address: " + << options.bind_address << "\n"; + return {}; + } + + OwnedSocket listener; + for (auto* address = addresses; address != nullptr; address = address->ai_next) { + OwnedSocket candidate{socket( + address->ai_family, + address->ai_socktype, + address->ai_protocol)}; + if (!candidate.valid()) { + continue; + } + constexpr int enabled = 1; + static_cast(setsockopt( + candidate.get(), + SOL_SOCKET, + SO_REUSEADDR, + reinterpret_cast(&enabled), + static_cast(sizeof(enabled)))); +#ifdef _WIN32 + const auto address_length = static_cast(address->ai_addrlen); +#else + const auto address_length = static_cast(address->ai_addrlen); +#endif + if (bind( + candidate.get(), + address->ai_addr, + address_length) == 0 && + listen(candidate.get(), 8) == 0) { + listener = std::move(candidate); + break; + } + } + freeaddrinfo(addresses); + return listener; +} + +void set_client_timeouts(const SocketHandle socket) noexcept { +#ifdef _WIN32 + constexpr DWORD timeout_ms = 1000U; + static_cast(setsockopt( + socket, + SOL_SOCKET, + SO_RCVTIMEO, + reinterpret_cast(&timeout_ms), + static_cast(sizeof(timeout_ms)))); + static_cast(setsockopt( + socket, + SOL_SOCKET, + SO_SNDTIMEO, + reinterpret_cast(&timeout_ms), + static_cast(sizeof(timeout_ms)))); +#else + constexpr timeval timeout{1, 0}; + static_cast(setsockopt( + socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout))); + static_cast(setsockopt( + socket, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout))); +#endif +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) != 0U + ? 0xFFU + : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +struct EncodedValue final { + std::span bytes; +}; + +[[nodiscard]] wire::EncodeResult read_encoded( + const void* context, + const std::span destination) noexcept { + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto bytes = static_cast(context)->bytes; + if (destination.size() < bytes.size()) { + return {wire::EncodeStatus::buffer_too_small, 0U, bytes.size()}; + } + std::copy(bytes.begin(), bytes.end(), destination.begin()); + return {wire::EncodeStatus::ok, bytes.size(), bytes.size()}; +} + +[[nodiscard]] mms::MmsTypeSpecification boolean_component( + const std::string& name) { + mms::MmsTypeSpecification component; + component.kind = mms::MmsTypeKind::boolean; + component.name = name; + return component; +} + +[[nodiscard]] mms::MmsTypeSpecification structure_component( + const std::string& name, + std::vector children) { + mms::MmsTypeSpecification component; + component.kind = mms::MmsTypeKind::structure; + component.name = name; + component.children = std::move(children); + return component; +} + +[[nodiscard]] std::vector build_single_status_ln_type( + const std::string& data_object) { + mms::MmsTypeSpecification root; + root.kind = mms::MmsTypeKind::structure; + root.children.push_back(structure_component( + "ST", + {structure_component( + data_object, + {boolean_component("stVal")})})); + return mms::MmsServiceCodec::encode_type_specification(root); +} + +[[nodiscard]] std::vector build_ggio_type() { + std::vector indications; + indications.reserve(8U); + for (std::size_t index = 0U; index < 8U; ++index) { + indications.push_back(structure_component( + "Ind" + std::to_string(index + 1U), + {boolean_component("stVal")})); + } + mms::MmsTypeSpecification root; + root.kind = mms::MmsTypeKind::structure; + root.children.push_back(structure_component("ST", std::move(indications))); + return mms::MmsServiceCodec::encode_type_specification(root); +} + +[[nodiscard]] bool serve_client( + SocketHandle client, + const mms::MmsStaticApplicationDispatcher& dispatcher) { + mms::MmsStaticConnectionRuntime runtime{dispatcher}; + embedded::TcpByteStream stream{&client, socket_send, socket_receive}; + std::array receive{}; + std::array response{}; + std::array workspace{}; + mms::MmsStaticServerSession session{ + runtime, + stream, + {receive, response, workspace}}; + + for (;;) { + const auto result = session.poll_once(); + switch (result.status) { + case mms::MmsStaticServerSessionStatus::progressed: + case mms::MmsStaticServerSessionStatus::response_pending: + case mms::MmsStaticServerSessionStatus::would_block: + case mms::MmsStaticServerSessionStatus::timed_out: + continue; + case mms::MmsStaticServerSessionStatus::application_rejected: + std::cerr << "MMS request rejected; connection remains open.\n"; + continue; + case mms::MmsStaticServerSessionStatus::peer_closed: + return true; + case mms::MmsStaticServerSessionStatus::transport_error: + std::cerr << "Client transport failed.\n"; + return false; + case mms::MmsStaticServerSessionStatus::protocol_error: + std::cerr << "Malformed or unsupported connection sequence.\n"; + return false; + case mms::MmsStaticServerSessionStatus::receive_buffer_full: + std::cerr << "Client TPKT exceeds the bounded receive capacity.\n"; + return false; + case mms::MmsStaticServerSessionStatus::invalid_configuration: + std::cerr << "Server session configuration is invalid.\n"; + return false; + } + } +} + +} // namespace + +int main(int argc, char** argv) { + Options options; + bool help{}; + if (!parse_options(argc, argv, options, help)) { + return 2; + } + if (help) { + print_help(); + return 0; + } + + SocketSystem socket_system; + if (!socket_system.ready()) { + std::cerr << "Unable to initialize the platform socket API.\n"; + return 3; + } + + constexpr std::array boolean_type{0x83U, 0x00U}; + constexpr std::array leaf_items{ + "GGIO1$ST$Ind1$stVal", + "GGIO1$ST$Ind2$stVal", + "GGIO1$ST$Ind3$stVal", + "GGIO1$ST$Ind4$stVal", + "GGIO1$ST$Ind5$stVal", + "GGIO1$ST$Ind6$stVal", + "GGIO1$ST$Ind7$stVal", + "GGIO1$ST$Ind8$stVal"}; + std::array values{}; + values[0] = 1U; + values[1] = 1U; + for (std::size_t index = 0U; index < 8U; ++index) { + values[index + 2U] = static_cast( + (options.digital_input_mask >> index) & 0x01U); + } + + const auto lln0_type = build_single_status_ln_type("Mod"); + const auto lphd1_type = build_single_status_ln_type("PhyHealth"); + const auto ggio1_type = build_ggio_type(); + constexpr std::array healthy_ln_data{ + 0xA2U, 0x07U, 0xA2U, 0x05U, 0xA2U, 0x03U, 0x83U, 0x01U, 0xFFU}; + std::array ggio_data{}; + ggio_data[0] = 0xA2U; + ggio_data[1] = 0x2AU; + ggio_data[2] = 0xA2U; + ggio_data[3] = 0x28U; + for (std::size_t index = 0U; index < 8U; ++index) { + const auto offset = 4U + index * 5U; + ggio_data[offset] = 0xA2U; + ggio_data[offset + 1U] = 0x03U; + ggio_data[offset + 2U] = 0x83U; + ggio_data[offset + 3U] = 0x01U; + ggio_data[offset + 4U] = values[index + 2U] != 0U ? 0xFFU : 0x00U; + } + const std::array root_values{ + EncodedValue{healthy_ln_data}, + EncodedValue{healthy_ln_data}, + EncodedValue{ggio_data}}; + + std::array objects{}; + objects[0] = mms::MmsStaticObjectEntry{ + "ESP32S3IOLD0", "LLN0", lln0_type, + read_encoded, &root_values[0], false, nullptr, nullptr, false}; + objects[1] = mms::MmsStaticObjectEntry{ + "ESP32S3IOLD0", "LPHD1", lphd1_type, + read_encoded, &root_values[1], false, nullptr, nullptr, false}; + objects[2] = mms::MmsStaticObjectEntry{ + "ESP32S3IOLD0", "GGIO1", ggio1_type, + read_encoded, &root_values[2], false, nullptr, nullptr, false}; + objects[3] = mms::MmsStaticObjectEntry{ + "ESP32S3IOLD0", "LLN0$ST$Mod$stVal", boolean_type, + read_boolean, &values[0]}; + objects[4] = mms::MmsStaticObjectEntry{ + "ESP32S3IOLD0", "LPHD1$ST$PhyHealth$stVal", boolean_type, + read_boolean, &values[1]}; + for (std::size_t index = 0U; index < leaf_items.size(); ++index) { + objects[index + 5U] = mms::MmsStaticObjectEntry{ + "ESP32S3IOLD0", + leaf_items[index], + boolean_type, + read_boolean, + &values[index + 2U]}; + } + + const mms::MmsStaticObjectTable object_table{objects}; + if (!object_table.valid()) { + std::cerr << "Static MMS model is invalid.\n"; + return 4; + } + const mms::MmsStaticApplicationDispatcher dispatcher{object_table}; + auto listener = create_listener(options); + if (!listener.valid()) { + std::cerr << "Unable to bind/listen on " << options.bind_address + << ':' << options.port << ".\n"; + return 5; + } + + std::cout << "STATIC_IED_SERVER_READY bind=" << options.bind_address + << " port=" << options.port + << " domain=ESP32S3IOLD0 listed_variables=10 root_types=3" + " readable_leaves=10\n"; + do { + sockaddr_storage peer{}; + socklen_t peer_size = sizeof(peer); + OwnedSocket client{accept( + listener.get(), + reinterpret_cast(&peer), + &peer_size)}; + if (!client.valid()) { + const auto error = last_socket_error(); + if (is_interrupted(error)) { + continue; + } + std::cerr << "TCP accept failed with platform error " << error << ".\n"; + return 6; + } + set_client_timeouts(client.get()); + std::cout << "STATIC_IED_CLIENT_ACCEPTED\n"; + const auto clean_close = serve_client(client.get(), dispatcher); + std::cout << (clean_close ? "STATIC_IED_CLIENT_CLOSED" + : "STATIC_IED_CLIENT_ABORTED") + << "\n"; + } while (!options.serve_once); + + return 0; +}