diff --git a/.github/workflows/brcb-hard-profile-ci.yml b/.github/workflows/brcb-hard-profile-ci.yml index 9abeb1fd2..75e4974d9 100644 --- a/.github/workflows/brcb-hard-profile-ci.yml +++ b/.github/workflows/brcb-hard-profile-ci.yml @@ -52,6 +52,7 @@ jobs: build-brcb-hard/ariec61850_hard_mms_brcb_smoke \ build-brcb-hard/ariec61850_hard_mms_brcb_control_smoke \ build-brcb-hard/ariec61850_hard_mms_brcb_objects_smoke \ + build-brcb-hard/ariec61850_hard_mms_brcb_lifecycle_smoke \ build-brcb-hard/ariec61850_hard_mms_brcb_connection_smoke \ build-brcb-hard/ariec61850_hard_mms_brcb_persistence_smoke; do if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then @@ -71,6 +72,7 @@ jobs: build-brcb-hard/ariec61850_hard_mms_brcb_smoke \ build-brcb-hard/ariec61850_hard_mms_brcb_control_smoke \ build-brcb-hard/ariec61850_hard_mms_brcb_objects_smoke \ + build-brcb-hard/ariec61850_hard_mms_brcb_lifecycle_smoke \ build-brcb-hard/ariec61850_hard_mms_brcb_connection_smoke \ build-brcb-hard/ariec61850_hard_mms_brcb_persistence_smoke } | tee build-brcb-hard/brcb-footprint.txt diff --git a/embedded/brcb_hard_profile/CMakeLists.txt b/embedded/brcb_hard_profile/CMakeLists.txt index d7c286808..f12c76b19 100644 --- a/embedded/brcb_hard_profile/CMakeLists.txt +++ b/embedded/brcb_hard_profile/CMakeLists.txt @@ -71,6 +71,29 @@ add_executable(ariec61850_hard_mms_brcb_objects_smoke ${ARIEC61850_ROOT}/src/mms/static_brcb_objects.cpp) ariec61850_configure_brcb_hard_target(ariec61850_hard_mms_brcb_objects_smoke) +add_executable(ariec61850_hard_mms_brcb_lifecycle_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_brcb_lifecycle_hard_profile_smoke.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/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/information_report_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/buffered_selective_information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_brcb_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/static_brcb_control.cpp) +ariec61850_configure_brcb_hard_target(ariec61850_hard_mms_brcb_lifecycle_smoke) + add_executable(ariec61850_hard_mms_brcb_connection_smoke ${CMAKE_CURRENT_LIST_DIR}/../mms_brcb_connection_hard_profile_smoke.cpp ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp @@ -116,6 +139,9 @@ add_test( add_test( NAME ariec61850_hard_mms_brcb_objects_smoke COMMAND ariec61850_hard_mms_brcb_objects_smoke) +add_test( + NAME ariec61850_hard_mms_brcb_lifecycle_smoke + COMMAND ariec61850_hard_mms_brcb_lifecycle_smoke) add_test( NAME ariec61850_hard_mms_brcb_connection_smoke COMMAND ariec61850_hard_mms_brcb_connection_smoke) @@ -126,6 +152,7 @@ add_test( message(STATUS "arstack61850 BRCB hard profile: bounded RAM queue + EntryID + BufOvfl=ON") message(STATUS "arstack61850 BRCB hard profile: association ownership + ResvTms gate=ON") message(STATUS "arstack61850 BRCB hard profile: MMS RptEna/EntryID/PurgeBuf/ResvTms/Owner=ON") +message(STATUS "arstack61850 BRCB hard profile: transport association lifecycle bridge=ON") message(STATUS "arstack61850 BRCB hard profile: retry-safe reconnect delivery=ON") message(STATUS "arstack61850 BRCB hard profile: dual-bank checkpoint + reboot recovery oracle=ON") message(STATUS "arstack61850 BRCB hard profile: platform non-volatile adapter=NOT_CLAIMED") diff --git a/embedded/mms_brcb_lifecycle_hard_profile_smoke.cpp b/embedded/mms_brcb_lifecycle_hard_profile_smoke.cpp new file mode 100644 index 000000000..21aae5e2f --- /dev/null +++ b/embedded/mms_brcb_lifecycle_hard_profile_smoke.cpp @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/connection_runtime.hpp" +#include "ariec61850/mms/static_brcb_control.hpp" +#include "ariec61850/mms/static_dispatcher.hpp" +#include "ariec61850/osi/cotp_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 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}; + +[[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]] std::uint64_t read_now(const void* context) noexcept { + return context == nullptr ? 0U : *static_cast(context); +} + +struct LifecycleSink final { + mms::MmsStaticBrcbControl* control{}; + std::size_t calls{}; + std::uint64_t last_association{}; + std::uint64_t last_now{}; +}; + +void on_association_closed( + void* raw_context, + const std::uint64_t association_id, + const std::uint64_t now_ms) noexcept { + auto* sink = static_cast(raw_context); + if (sink == nullptr) { + return; + } + ++sink->calls; + sink->last_association = association_id; + sink->last_now = now_ms; + if (sink->control != nullptr) { + sink->control->on_association_closed(association_id, now_ms); + } +} + +[[nodiscard]] mms::MmsStaticBrcbClientIdentity identity( + const std::uint64_t association, + const std::array& owner) noexcept { + mms::MmsStaticBrcbClientIdentity result; + result.association_id = association; + result.owner[0] = owner[0]; + result.owner[1] = owner[1]; + result.owner_size = owner.size(); + return result; +} + +[[nodiscard]] mms::MmsStaticConnectionPolicy policy( + const std::uint64_t association, + const std::array& owner, + std::uint64_t& now, + LifecycleSink& sink) noexcept { + mms::MmsStaticConnectionPolicy result; + result.association_id = association; + result.owner[0] = owner[0]; + result.owner[1] = owner[1]; + result.owner_size = owner.size(); + result.now_ms = read_now; + result.now_context = &now; + result.association_closed = on_association_closed; + result.association_closed_context = &sink; + return result; +} + +[[nodiscard]] bool establish( + mms::MmsStaticConnectionRuntime& connection, + std::span request, + std::span response, + std::span workspace, + std::span scratch) noexcept { + 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}}; + + const auto cr = osi::CotpSpanCodec::encode_connection_request_into( + 0x0001U, parameters, scratch); + if (!cr.success()) { + return false; + } + const auto cr_tpkt = osi::TpktSpanCodec::encode_into( + scratch.first(cr.bytes_written), request); + if (!cr_tpkt.success()) { + return false; + } + auto result = connection.process_tcp_window( + request.first(cr_tpkt.bytes_written), response, workspace); + if (!result.response_ready() || + connection.state() != mms::MmsStaticConnectionState::awaiting_association) { + return false; + } + + const auto cotp = osi::CotpSpanCodec::encode_data_into(kAssociationRequest, scratch); + if (!cotp.success()) { + return false; + } + const auto association = osi::TpktSpanCodec::encode_into( + scratch.first(cotp.bytes_written), request); + if (!association.success()) { + return false; + } + result = connection.process_tcp_window( + request.first(association.bytes_written), response, workspace); + return result.response_ready() && + connection.state() == mms::MmsStaticConnectionState::established && + connection.association_active() && + connection.mms_presentation_context_id() == 3U; +} + +[[nodiscard]] bool send_disconnect_request( + mms::MmsStaticConnectionRuntime& connection, + std::span request, + std::span response, + std::span workspace) noexcept { + constexpr std::array cotp_disconnect{ + 0x06U, 0x80U, 0x00U, 0x00U, 0x00U, 0x01U, 0x00U}; + const auto tpkt = osi::TpktSpanCodec::encode_into(cotp_disconnect, request); + if (!tpkt.success()) { + return false; + } + const auto result = connection.process_tcp_window( + request.first(tpkt.bytes_written), response, workspace); + return result.status == mms::MmsStaticConnectionStatus::closed && + result.state == mms::MmsStaticConnectionState::closed && + result.consumed_bytes == tpkt.bytes_written && + !connection.association_active(); +} + +} // namespace + +int main() { + bool value = true; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "X1", kBooleanType, read_boolean, &value, false}}; + const mms::MmsStaticObjectTable object_table{objects}; + const std::array members{ + mms::MmsStaticDataSetMember{"LD0", "X1"}}; + const std::array data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + const mms::MmsStaticApplicationDispatcher dispatcher{object_table, data_set_table}; + + const mms::MmsStaticBrcbDefinition definition{ + "LD0", + "LLN0$BR$Events", + "LD0/LLN0$BR$Events", + "LD0", + "LLN0$Events", + 1U, + {0x7FU, 0x80U}, + 0U, + 0x70U}; + std::array slot_storage{}; + std::array slots{ + mms::MmsStaticBrcbSlot{slot_storage}}; + mms::MmsStaticBrcbPendingState pending{}; + mms::MmsStaticBrcbRuntime reports{ + definition, pending, slots, object_table, data_set_table}; + if (!reports.initialize()) { + return 1; + } + mms::MmsStaticBrcbControl control{reports}; + LifecycleSink sink{&control}; + + const std::array owner_a{0xAAU, 0x01U}; + const std::array owner_b{0xBBU, 0x01U}; + const auto a1 = identity(101U, owner_a); + const auto a2 = identity(102U, owner_a); + const auto a3 = identity(103U, owner_a); + const auto b1 = identity(201U, owner_b); + + std::array request{}; + std::array response{}; + std::array workspace{}; + std::array scratch{}; + + std::uint64_t now = 100U; + mms::MmsStaticConnectionRuntime first{ + dispatcher, policy(a1.association_id, owner_a, now, sink)}; + if (!establish(first, request, response, workspace, scratch) || + control.reserve(a1, 5U, now) != mms::MmsStaticBrcbControlStatus::ok || + control.set_report_enabled(a1, true, now) != + mms::MmsStaticBrcbControlStatus::ok) { + return 2; + } + + // TCP EOF/socket/local close must notify exactly once and retain a timed + // reservation while always disabling report transmission. + now = 1'000U; + first.close_transport(); + auto state = control.state(now); + if (first.state() != mms::MmsStaticConnectionState::closed || + first.association_active() || sink.calls != 1U || + sink.last_association != 101U || sink.last_now != now || + reports.enabled() || !state.reserved || state.owner_connected || + state.association_id != 0U || state.reservation_expires_at_ms != 6'000U) { + return 3; + } + first.close_transport(); + if (sink.calls != 1U) { + return 4; + } + + now = 2'000U; + if (control.reserve(b1, 5U, now) != + mms::MmsStaticBrcbControlStatus::object_access_denied) { + return 5; + } + + // The same stable Owner may reconnect with a new ephemeral association. + now = 2'500U; + if (control.reserve(a2, 5U, now) != mms::MmsStaticBrcbControlStatus::ok) { + return 6; + } + mms::MmsStaticConnectionRuntime second{ + dispatcher, policy(a2.association_id, owner_a, now, sink)}; + if (!establish(second, request, response, workspace, scratch) || + control.set_report_enabled(a2, true, now) != + mms::MmsStaticBrcbControlStatus::ok) { + return 7; + } + + // Runtime reuse/reset is also a transport teardown and must deliver one + // lifecycle event before allowing a fresh COTP connection. + now = 3'000U; + second.reset(); + state = control.state(now); + if (sink.calls != 2U || sink.last_association != 102U || + sink.last_now != now || reports.enabled() || + second.state() != mms::MmsStaticConnectionState::awaiting_cotp_connect || + second.association_active() || !state.reserved || state.owner_connected || + state.reservation_expires_at_ms != 8'000U) { + return 8; + } + second.reset(); + if (sink.calls != 2U) { + return 9; + } + + // Reclaim with ResvTms=0 and prove an incoming COTP DR drives immediate + // release through the exact same generic lifecycle bridge. + now = 3'500U; + if (control.reserve(a3, 0U, now) != mms::MmsStaticBrcbControlStatus::ok) { + return 10; + } + mms::MmsStaticConnectionRuntime third{ + dispatcher, policy(a3.association_id, owner_a, now, sink)}; + if (!establish(third, request, response, workspace, scratch) || + control.set_report_enabled(a3, true, now) != + mms::MmsStaticBrcbControlStatus::ok) { + return 11; + } + + now = 3'600U; + if (!send_disconnect_request(third, request, response, workspace)) { + return 12; + } + state = control.state(now); + if (sink.calls != 3U || sink.last_association != 103U || + sink.last_now != now || reports.enabled() || state.reserved || + state.owner_connected || !state.owner.empty()) { + return 13; + } + third.close_transport(); + if (sink.calls != 3U) { + return 14; + } + + return 0; +} diff --git a/include/ariec61850/mms/connection_runtime.hpp b/include/ariec61850/mms/connection_runtime.hpp index 2223a94b3..85689d2b4 100644 --- a/include/ariec61850/mms/connection_runtime.hpp +++ b/include/ariec61850/mms/connection_runtime.hpp @@ -31,6 +31,14 @@ enum class MmsStaticConnectionStatus : std::uint8_t { closed, }; +using MmsStaticConnectionNowMsCallback = std::uint64_t (*)( + const void* context) noexcept; + +using MmsStaticAssociationClosedCallback = void (*)( + void* context, + std::uint64_t association_id, + std::uint64_t now_ms) noexcept; + struct MmsStaticConnectionPolicy final { static constexpr std::size_t maximum_owner_bytes = 16U; @@ -45,6 +53,15 @@ struct MmsStaticConnectionPolicy final { std::array owner{}; std::size_t owner_size{}; + // Optional lifecycle bridge. The connection runtime remains independent of + // reporting/control classes: a server adapter may forward this event to a + // BRCB control block, session registry, audit sink, or several fan-out + // targets. `now_ms` must use the same monotonic clock as ResvTms handling. + MmsStaticConnectionNowMsCallback now_ms{}; + const void* now_context{}; + MmsStaticAssociationClosedCallback association_closed{}; + void* association_closed_context{}; + [[nodiscard]] constexpr MmsStaticRequestAccessContext access_context() const noexcept { return { association_id, @@ -86,10 +103,14 @@ class MmsStaticConnectionRuntime final { std::span response, std::span workspace) noexcept; - constexpr void reset() noexcept { - state_ = MmsStaticConnectionState::awaiting_cotp_connect; - mms_presentation_context_id_ = 0U; - } + // Notify the lifecycle bridge exactly once for an established association + // and mark the transport closed. Call this for TCP EOF, socket error, local + // close, or any fatal transport teardown not represented by a COTP DR TPDU. + void close_transport() noexcept; + + // Reuse this runtime for a fresh transport. If an association was still + // active, reset first emits the same single association-closed event. + void reset() noexcept; [[nodiscard]] constexpr MmsStaticConnectionState state() const noexcept { return state_; @@ -103,11 +124,19 @@ class MmsStaticConnectionRuntime final { return policy_.access_context(); } + [[nodiscard]] constexpr bool association_active() const noexcept { + return association_active_; + } + private: + void notify_association_closed() noexcept; + const MmsStaticApplicationDispatcher& dispatcher_; MmsStaticConnectionPolicy policy_{}; MmsStaticConnectionState state_{MmsStaticConnectionState::awaiting_cotp_connect}; std::uint32_t mms_presentation_context_id_{}; + bool association_active_{}; + bool association_close_notified_{}; }; } // namespace ar::iec61850::mms diff --git a/src/mms/connection_runtime.cpp b/src/mms/connection_runtime.cpp index d0246f11b..523981ffd 100644 --- a/src/mms/connection_runtime.cpp +++ b/src/mms/connection_runtime.cpp @@ -159,6 +159,40 @@ namespace { } // namespace +void MmsStaticConnectionRuntime::notify_association_closed() noexcept { + if (!association_active_ || association_close_notified_) { + return; + } + + association_close_notified_ = true; + association_active_ = false; + if (policy_.association_closed == nullptr || policy_.association_id == 0U) { + return; + } + + const auto now = policy_.now_ms == nullptr + ? std::uint64_t{0U} + : policy_.now_ms(policy_.now_context); + policy_.association_closed( + policy_.association_closed_context, + policy_.association_id, + now); +} + +void MmsStaticConnectionRuntime::close_transport() noexcept { + notify_association_closed(); + state_ = MmsStaticConnectionState::closed; + mms_presentation_context_id_ = 0U; +} + +void MmsStaticConnectionRuntime::reset() noexcept { + notify_association_closed(); + state_ = MmsStaticConnectionState::awaiting_cotp_connect; + mms_presentation_context_id_ = 0U; + association_active_ = false; + association_close_notified_ = false; +} + MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( const std::span tcp_bytes, const std::span response, @@ -190,7 +224,7 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( } if (cotp.kind == osi::CotpWireKind::disconnect_request) { - state_ = MmsStaticConnectionState::closed; + close_transport(); return make_result( MmsStaticConnectionStatus::closed, state_, peek.frame_bytes); } @@ -295,6 +329,8 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( } mms_presentation_context_id_ = association.mms_presentation_context_id; state_ = MmsStaticConnectionState::established; + association_active_ = true; + association_close_notified_ = false; auto result = wrapped; result.state = state_; return result;