From 62964899bd72eee84e54690157258524847cd066 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:25:52 +0700 Subject: [PATCH 1/6] 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 b436e41108a682666ccad083e725c6a5fcb6752a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:26:56 +0700 Subject: [PATCH 2/6] 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 d3c0254ba60796bd94c1d6ec545bb0a9c5ed6f48 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:29:16 +0700 Subject: [PATCH 3/6] 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 944bd9e16c17db52ed83fd884a07185fd625e610 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:30:02 +0700 Subject: [PATCH 4/6] 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 7fdd263eac937220f36de4921fef4e88b80ab603 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:30:19 +0700 Subject: [PATCH 5/6] 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 9043c9ac05cc5556ed8f94e9ed8ec5827b970465 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:30:42 +0700 Subject: [PATCH 6/6] 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