From 44abf02a36e503812a8e94e2db431b9c5ba5558d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 05:19:26 +0700 Subject: [PATCH 1/8] fix(reporting): fail closed on foreign RCB InformationReport identity and stale DataSet order --- apps/ied_simulator/qml/BrowserReportPane.qml | 13 +++-- .../ied_simulator/src/MmsReportController.cpp | 5 ++ .../src/ied_report_workspace_qa.cpp | 1 + .../mms/report_subscription_runtime.hpp | 4 ++ include/ariec61850/mms/reporting.hpp | 10 ++++ src/mms/report_subscription_runtime.cpp | 21 ++++++++ src/mms/reporting.cpp | 40 ++++++++++++++ tests/test_reporting.cpp | 52 +++++++++++++++++++ 8 files changed, 142 insertions(+), 4 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserReportPane.qml b/apps/ied_simulator/qml/BrowserReportPane.qml index 50bf7b37e..1c3ed6cad 100644 --- a/apps/ied_simulator/qml/BrowserReportPane.qml +++ b/apps/ied_simulator/qml/BrowserReportPane.qml @@ -51,13 +51,18 @@ Rectangle { function newestMatchingReport() { // A DataSet can be shared by multiple RCBs. Never attribute a received // frame to an RCB using only its DataSet or its list position. - if (!hasSelection || !rcb.reportId || !reports.receivedReports) + if (!hasSelection || !rcb.reference || !reports.receivedReports) return ({}) - const wanted = String(rcb.reportId) + const expectedRcb = String(rcb.reference) + const expectedRptId = rcb.reportId ? String(rcb.reportId) : "" for (let index = 0; index < reports.receivedReports.length; ++index) { const frame = reports.receivedReports[index] - if (frame.reportId && String(frame.reportId) === wanted) - return frame + // Do not attribute a frame by RptID alone: indexed RCBs can share it. + if (!frame.rcbReference || String(frame.rcbReference) !== expectedRcb) + continue + if (expectedRptId.length > 0 && String(frame.reportId) !== expectedRptId) + continue + return frame } return ({}) } diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 67103f62d..079e0a269 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -442,6 +442,9 @@ ReportUi buildSubscriptionUi( ui.active = active; ui.receivedCount = subscription.received_reports; ui.cleanupRequired = subscription.cleanup_required; + if (subscription.rejected_reports != 0U) + ui.events.push_back(QStringLiteral("%1 foreign/stale InformationReport(s) withheld from values.") + .arg(subscription.rejected_reports)); for (const auto& event : subscription.events) { ui.events.push_back(QString::fromStdString(event.message)); } @@ -456,6 +459,8 @@ ReportUi buildSubscriptionUi( const auto& frame = *frameIt; QVariantMap map; map.insert(QStringLiteral("reportId"), QString::fromStdString(frame.header.report_id)); + map.insert(QStringLiteral("rcbReference"), + QString::fromStdString(subscription.active_rcb_reference)); map.insert(QStringLiteral("dataSet"), QString::fromStdString(frame.header.data_set_reference)); map.insert(QStringLiteral("sequence"), numberText(frame.header.sequence_number)); map.insert(QStringLiteral("subSequence"), numberText(frame.header.sub_sequence_number)); diff --git a/apps/ied_simulator/src/ied_report_workspace_qa.cpp b/apps/ied_simulator/src/ied_report_workspace_qa.cpp index 6ac3bc718..89e12060c 100644 --- a/apps/ied_simulator/src/ied_report_workspace_qa.cpp +++ b/apps/ied_simulator/src/ied_report_workspace_qa.cpp @@ -164,6 +164,7 @@ bool reportProjectionPresent(const MmsReportController& reports) { if (frames.isEmpty()) return false; const auto frame = frames.first().toMap(); return !frame.value(QStringLiteral("reportId")).toString().isEmpty() && + !frame.value(QStringLiteral("rcbReference")).toString().isEmpty() && !frame.value(QStringLiteral("dataSet")).toString().isEmpty() && !frame.value(QStringLiteral("values")).toStringList().isEmpty() && frame.contains(QStringLiteral("entryId")) && diff --git a/include/ariec61850/mms/report_subscription_runtime.hpp b/include/ariec61850/mms/report_subscription_runtime.hpp index 15d186d64..a749200ba 100644 --- a/include/ariec61850/mms/report_subscription_runtime.hpp +++ b/include/ariec61850/mms/report_subscription_runtime.hpp @@ -40,6 +40,7 @@ enum class MmsReportSubscriptionEventKind : std::uint8_t { enabled, general_interrogation_sent, report_received, + report_rejected, report_decode_failed, disabled, reservation_released, @@ -73,7 +74,9 @@ struct MmsReportSubscriptionSnapshot final { bool reservation_touched{}; bool cleanup_required{}; std::uint64_t received_reports{}; + std::uint64_t rejected_reports{}; std::uint64_t decode_failures{}; + std::string active_rcb_reference; std::optional last_rcb_state; std::vector streams; std::vector events; @@ -129,6 +132,7 @@ class MmsReportSubscriptionRuntime final { bool restore_enabled_required_{}; bool cleanup_required_{}; std::uint64_t received_reports_{}; + std::uint64_t rejected_reports_{}; std::uint64_t decode_failures_{}; }; diff --git a/include/ariec61850/mms/reporting.hpp b/include/ariec61850/mms/reporting.hpp index 7f9c7910f..26411d200 100644 --- a/include/ariec61850/mms/reporting.hpp +++ b/include/ariec61850/mms/reporting.hpp @@ -250,6 +250,16 @@ struct MmsReportControlState final { std::string availability_reason; }; +// Secondary RCB authority validation. BER validity alone cannot assign +// process values to the selected DataSet/RCB. +class MmsReportFrameBindingValidator final { +public: + [[nodiscard]] static std::string rejection_reason( + const MmsReportFrame& frame, + const MmsReportControlState& verified_rcb, + const MmsDataSetDirectoryResponse& directory); +}; + class MmsReportControlStateMapper final { public: [[nodiscard]] static MmsReadRequest build_read_request( diff --git a/src/mms/report_subscription_runtime.cpp b/src/mms/report_subscription_runtime.cpp index 750d73a90..b751a4af8 100644 --- a/src/mms/report_subscription_runtime.cpp +++ b/src/mms/report_subscription_runtime.cpp @@ -214,6 +214,7 @@ void MmsReportSubscriptionRuntime::start( cleanup_required_ = false; monitor_.clear(); received_reports_ = 0U; + rejected_reports_ = 0U; decode_failures_ = 0U; try { @@ -524,7 +525,25 @@ void MmsReportSubscriptionRuntime::ingest_report( const std::span payload) { try { const auto report = MmsInformationReportCodec::decode(payload); + // Check publisher identity before using the selected RCB's directory + // to assign semantic member names to untrusted report values. + const auto header = MmsReportFrameMapper::decode_header(report); + if (!last_rcb_state_ || last_rcb_state_->report_id.empty() || + header.report_id != last_rcb_state_->report_id) { + ++rejected_reports_; + add_event(MmsReportSubscriptionEventKind::report_rejected, + "InformationReport RptID not verified for selected RCB."); + return; + } const auto frame = MmsReportFrameMapper::map(report, directory_.members); + const auto reason = MmsReportFrameBindingValidator::rejection_reason( + frame, *last_rcb_state_, directory_); + if (!reason.empty()) { + ++rejected_reports_; + add_event(MmsReportSubscriptionEventKind::report_rejected, + "InformationReport values withheld: " + reason); + return; + } static_cast(monitor_.ingest(frame)); ++received_reports_; add_event(MmsReportSubscriptionEventKind::report_received, @@ -565,7 +584,9 @@ MmsReportSubscriptionSnapshot MmsReportSubscriptionRuntime::snapshot() const { result.reservation_touched = reservation_touched_; result.cleanup_required = cleanup_required_; result.received_reports = received_reports_; + result.rejected_reports = rejected_reports_; result.decode_failures = decode_failures_; + result.active_rcb_reference = candidate_.reference; result.last_rcb_state = last_rcb_state_; result.streams = monitor_.snapshots(); result.events = events_; diff --git a/src/mms/reporting.cpp b/src/mms/reporting.cpp index 2d67cd21d..b2ee8cabb 100644 --- a/src/mms/reporting.cpp +++ b/src/mms/reporting.cpp @@ -622,6 +622,46 @@ MmsReportFrame MmsReportFrameMapper::map( return frame; } +std::string MmsReportFrameBindingValidator::rejection_reason( + const MmsReportFrame& frame, + const MmsReportControlState& verified_rcb, + const MmsDataSetDirectoryResponse& directory) { + if (verified_rcb.report_id.empty()) + return "Selected RCB has no verified report ID."; + if (frame.header.report_id != verified_rcb.report_id) + return "InformationReport RptID does not belong to selected live RCB."; + if (verified_rcb.data_set_reference.empty() || directory.members.empty()) + return "Selected RCB has no verified nonempty DataSet directory."; + if (!frame.header.data_set_reference.empty()) { + try { + if (MmsDataSetDirectoryCodec::parse_data_set_reference( + frame.header.data_set_reference) != + MmsDataSetDirectoryCodec::parse_data_set_reference( + verified_rcb.data_set_reference)) + return "InformationReport DatSet differs from selected live RCB."; + } catch (const std::exception&) { + return "Malformed report or selected-RCB DataSet identity."; + } + } + if (frame.header.configuration_revision && verified_rcb.configuration_revision && + *frame.header.configuration_revision != *verified_rcb.configuration_revision) + return "InformationReport ConfRev differs from selected live RCB."; + if (frame.included_data_set_indexes.size() != frame.values.size()) + return "Inclusion and report value counts differ."; + std::optional previous; + for (std::size_t row = 0U; row < frame.values.size(); ++row) { + const auto index = frame.included_data_set_indexes[row]; + const auto& value = frame.values[row]; + if (index >= directory.members.size() || + (previous && index <= *previous) || + value.data_set_index != index || !value.member || + value.member->object_name != directory.members[index].object_name) + return "Report inclusion order or DataSet member identity differs."; + previous = index; + } + return {}; +} + std::string MmsReportFrame::routing_key() const { return header.report_id; } diff --git a/tests/test_reporting.cpp b/tests/test_reporting.cpp index 8bb3a40ad..15802a946 100644 --- a/tests/test_reporting.cpp +++ b/tests/test_reporting.cpp @@ -421,6 +421,57 @@ void offline_monitor_enforces_frame_and_stream_limits() { }), "Monitor frame limit was not enforced."); } + +void binding_validator_rejects_cross_rcb_and_stale_reports() { + auto directory = MmsDataSetDirectoryCodec::decode_response( + MmsDataSetDirectoryCodec::encode_response_pdu(directory_fixture()), 9U); + const auto frame = MmsReportFrameMapper::map(realistic_report(17U), directory.members); + MmsReportControlState selected; + selected.report_id = "LD0/LLN0.RP.urcb01"; + selected.data_set_reference = "LD0/LLN0$Events"; + selected.configuration_revision = 1U; + const auto reject = [&](const MmsReportFrame& observed) { + return MmsReportFrameBindingValidator::rejection_reason(observed, selected, directory); + }; + require(reject(frame).empty(), "Verified RCB report rejected."); + auto changed = frame; + changed.header.report_id = "LD0/LLN0.RP.other"; + require(!reject(changed).empty(), "Foreign RptID accepted."); + changed = frame; + changed.header.data_set_reference = "LD0/LLN0.Other"; + require(!reject(changed).empty(), "Foreign DataSet accepted."); + changed = frame; + changed.header.configuration_revision = 2U; + require(!reject(changed).empty(), "Stale ConfRev accepted."); + changed = frame; + changed.included_data_set_indexes = {1U, 0U}; + require(!reject(changed).empty(), "Descending indexes accepted."); + changed = frame; + changed.included_data_set_indexes = {0U, 0U}; + require(!reject(changed).empty(), "Duplicate index accepted."); + changed = frame; + changed.values[1].data_set_index = 0U; + require(!reject(changed).empty(), "Inconsistent member index accepted."); + changed = frame; + changed.values[1].member = directory.members[0]; + require(!reject(changed).empty(), "Wrong DataSet member accepted."); + changed = frame; + changed.values.pop_back(); + require(!reject(changed).empty(), "Incorrect value count accepted."); + selected.report_id.clear(); + require(!reject(frame).empty(), "Unverified RptID accepted."); + selected.report_id = "LD0/LLN0.RP.urcb01"; + selected.data_set_reference.clear(); + require(!reject(frame).empty(), "Unverified RCB DataSet accepted."); + selected.data_set_reference = "LD0/LLN0.Events"; + changed = frame; + changed.header.data_set_reference.clear(); + changed.header.configuration_revision.reset(); + require(reject(changed).empty(), "Permitted missing optional fields rejected."); + directory.members.clear(); + require(!reject(frame).empty(), "Missing DataSet directory accepted."); +} + void malformed_and_bound_paths_are_rejected() { require_throws([] { static_cast(MmsInformationReportCodec::decode(std::array{0xA3U, 0x00U})); @@ -453,6 +504,7 @@ int main() { {"sequence continuity", sequence_tracker_detects_gap_duplicate_and_changes}, {"segmentation continuity", segmentation_continuity_is_tracked}, {"bounded monitor", offline_monitor_enforces_frame_and_stream_limits}, + {"RCB binding and stale report guard", binding_validator_rejects_cross_rcb_and_stale_reports}, {"malformed and bounds", malformed_and_bound_paths_are_rejected}, }; std::size_t passed = 0U; From ec65e94e50cc1427113c013fe61d754e8a9ec33e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 05:21:40 +0700 Subject: [PATCH 2/8] feat(reports): expose rejected-frame and decode-failure counters in native Browser inspector --- apps/ied_simulator/qml/BrowserReportPane.qml | 22 +++++++++++++++++++ .../ied_simulator/src/MmsReportController.cpp | 20 +++++++++++++++++ .../ied_simulator/src/MmsReportController.hpp | 6 +++++ .../src/ied_report_workspace_qa.cpp | 7 ++++++ 4 files changed, 55 insertions(+) diff --git a/apps/ied_simulator/qml/BrowserReportPane.qml b/apps/ied_simulator/qml/BrowserReportPane.qml index 1c3ed6cad..c88c68461 100644 --- a/apps/ied_simulator/qml/BrowserReportPane.qml +++ b/apps/ied_simulator/qml/BrowserReportPane.qml @@ -479,6 +479,28 @@ Rectangle { } } + Rectangle { + visible: reports.rejectedReportCount > 0 || reports.reportDecodeFailureCount > 0 + Layout.fillWidth: true + implicitHeight: reportRejections.implicitHeight + 16 + color: theme.surface + border.width: 1 + border.color: theme.amber + Label { + id: reportRejections + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 8 + font.pixelSize: 10 + wrapMode: Text.WordWrap + color: theme.amber + text: reports.rejectedReportCount + " foreign/stale report(s) withheld from process values · " + + reports.reportDecodeFailureCount + " report decode failure(s). " + + "Inspect report events for exact diagnostics." + } + } + Rectangle { visible: reports.cleanupRequired || (reports.lastError && reports.lastError.length > 0) Layout.fillWidth: true diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 079e0a269..fd7f77428 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -431,6 +431,8 @@ struct ReportUi final { QVariantList reports; QStringList events; qulonglong receivedCount{}; + qulonglong rejectedCount{}; + qulonglong decodeFailureCount{}; bool active{}; bool cleanupRequired{}; }; @@ -441,6 +443,8 @@ ReportUi buildSubscriptionUi( ReportUi ui; ui.active = active; ui.receivedCount = subscription.received_reports; + ui.rejectedCount = subscription.rejected_reports; + ui.decodeFailureCount = subscription.decode_failures; ui.cleanupRequired = subscription.cleanup_required; if (subscription.rejected_reports != 0U) ui.events.push_back(QStringLiteral("%1 foreign/stale InformationReport(s) withheld from values.") @@ -622,6 +626,8 @@ void MmsReportController::clearInventory() { receivedReports_.clear(); events_.clear(); receivedReportCount_ = 0; + rejectedReportCount_ = 0; + reportDecodeFailureCount_ = 0; emit inventoryChanged(); emit selectionChanged(); emit reportsChanged(); @@ -669,6 +675,8 @@ void MmsReportController::adoptEngineeringInventory() { receivedReports_.clear(); events_.clear(); receivedReportCount_ = 0; + rejectedReportCount_ = 0; + reportDecodeFailureCount_ = 0; refreshSelection(); emit inventoryChanged(); emit reportsChanged(); @@ -1515,6 +1523,8 @@ bool MmsReportController::enableSelectedAuthored( self->receivedReports_ = ui.reports; self->events_ = ui.events; self->receivedReportCount_ = ui.receivedCount; + self->rejectedReportCount_ = ui.rejectedCount; + self->reportDecodeFailureCount_ = ui.decodeFailureCount; if (selectedRow >= 0 && selectedRow < self->reportControls_.size()) { auto map = @@ -1646,6 +1656,8 @@ bool MmsReportController::enableSelected(const bool requestGeneralInterrogation) self->receivedReports_ = ui.reports; self->events_ = ui.events; self->receivedReportCount_ = ui.receivedCount; + self->rejectedReportCount_ = ui.rejectedCount; + self->reportDecodeFailureCount_ = ui.decodeFailureCount; self->state_ = ui.cleanupRequired ? State::cleanup_required : State::active; self->appendDiagnostic(requestGeneralInterrogation ? QStringLiteral("RCB enabled; GI requested explicitly.") @@ -1708,6 +1720,8 @@ bool MmsReportController::disableSelected() { self->receivedReports_ = ui.reports; self->events_ = ui.events; self->receivedReportCount_ = ui.receivedCount; + self->rejectedReportCount_ = ui.rejectedCount; + self->reportDecodeFailureCount_ = ui.decodeFailureCount; self->state_ = ui.cleanupRequired ? State::cleanup_required : State::ready; self->appendDiagnostic(ui.cleanupRequired ? QStringLiteral("RCB release incomplete; explicit cleanup retry required.") @@ -1774,6 +1788,8 @@ bool MmsReportController::retryCleanup() { self->receivedReports_ = ui.reports; self->events_ = ui.events; self->receivedReportCount_ = ui.receivedCount; + self->rejectedReportCount_ = ui.rejectedCount; + self->reportDecodeFailureCount_ = ui.decodeFailureCount; self->state_ = ui.cleanupRequired ? State::cleanup_required : State::ready; self->lastError_.clear(); self->appendDiagnostic(ui.cleanupRequired @@ -1841,6 +1857,8 @@ void MmsReportController::schedulePoll() { self->receivedReports_ = ui.reports; self->events_ = ui.events; self->receivedReportCount_ = ui.receivedCount; + self->rejectedReportCount_ = ui.rejectedCount; + self->reportDecodeFailureCount_ = ui.decodeFailureCount; emit self->reportsChanged(); }, Qt::QueuedConnection); } @@ -1871,6 +1889,8 @@ void MmsReportController::schedulePoll() { self->receivedReports_ = ui.reports; self->events_ = ui.events; self->receivedReportCount_ = ui.receivedCount; + self->rejectedReportCount_ = ui.rejectedCount; + self->reportDecodeFailureCount_ = ui.decodeFailureCount; self->lastError_ = message; self->state_ = ui.cleanupRequired ? State::cleanup_required : State::faulted; self->appendDiagnostic(QStringLiteral("Report association/poll failed · %1").arg(message)); diff --git a/apps/ied_simulator/src/MmsReportController.hpp b/apps/ied_simulator/src/MmsReportController.hpp index 601f6f3ad..39be521ca 100644 --- a/apps/ied_simulator/src/MmsReportController.hpp +++ b/apps/ied_simulator/src/MmsReportController.hpp @@ -40,6 +40,8 @@ class MmsReportController : public QObject { Q_PROPERTY(QVariantList receivedReports READ receivedReports NOTIFY reportsChanged) Q_PROPERTY(QStringList events READ events NOTIFY reportsChanged) Q_PROPERTY(qulonglong receivedReportCount READ receivedReportCount NOTIFY reportsChanged) + Q_PROPERTY(qulonglong rejectedReportCount READ rejectedReportCount NOTIFY reportsChanged) + Q_PROPERTY(qulonglong reportDecodeFailureCount READ reportDecodeFailureCount NOTIFY reportsChanged) Q_PROPERTY(qulonglong generation READ generation NOTIFY stateChanged) public: @@ -73,6 +75,8 @@ class MmsReportController : public QObject { [[nodiscard]] QVariantList receivedReports() const { return receivedReports_; } [[nodiscard]] QStringList events() const { return events_; } [[nodiscard]] qulonglong receivedReportCount() const noexcept { return receivedReportCount_; } + [[nodiscard]] qulonglong rejectedReportCount() const noexcept { return rejectedReportCount_; } + [[nodiscard]] qulonglong reportDecodeFailureCount() const noexcept { return reportDecodeFailureCount_; } [[nodiscard]] qulonglong generation() const noexcept { return generation_; } Q_INVOKABLE bool connectToIed(); @@ -149,6 +153,8 @@ class MmsReportController : public QObject { QStringList events_; QStringList diagnostics_; qulonglong receivedReportCount_{}; + qulonglong rejectedReportCount_{}; + qulonglong reportDecodeFailureCount_{}; qulonglong generation_{}; QThreadPool ioPool_; QTimer pollTimer_; diff --git a/apps/ied_simulator/src/ied_report_workspace_qa.cpp b/apps/ied_simulator/src/ied_report_workspace_qa.cpp index 89e12060c..e0e7840e4 100644 --- a/apps/ied_simulator/src/ied_report_workspace_qa.cpp +++ b/apps/ied_simulator/src/ied_report_workspace_qa.cpp @@ -357,6 +357,13 @@ int main(int argc, char* argv[]) { return 12; } const auto reportCountAfterGi = reports.receivedReportCount(); + if (reports.rejectedReportCount() != 0U || + reports.reportDecodeFailureCount() != 0U) { + qCritical() << "REPORTS_WORKBENCH_FAIL accepted_gi_report_authority" + << reports.rejectedReportCount() + << reports.reportDecodeFailureCount(); + return 31; + } if (!reportProjectionPresent(reports)) { qCritical() << "REPORTS_WORKBENCH_FAIL report_projection"; return 13; From acc3fba9511f6d26e5c29784e4a13321f3c5facf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 05:24:06 +0700 Subject: [PATCH 3/8] fix(reporting): pin static RCB DataSet through enable and report GI outcomes truthfully --- apps/ied_simulator/qml/BrowserReportPane.qml | 23 +++++++++++ .../ied_simulator/src/MmsReportController.cpp | 2 +- .../mms/report_subscription_runtime.hpp | 5 +++ src/mms/report_subscription_runtime.cpp | 40 +++++++++++++------ src/mms/static_report_session.cpp | 4 +- 5 files changed, 60 insertions(+), 14 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserReportPane.qml b/apps/ied_simulator/qml/BrowserReportPane.qml index c88c68461..1a1effaee 100644 --- a/apps/ied_simulator/qml/BrowserReportPane.qml +++ b/apps/ied_simulator/qml/BrowserReportPane.qml @@ -479,6 +479,29 @@ Rectangle { } } + SectionHeading { + heading: "RCB lifecycle & InformationReport events" + evidence: reports.events.length + " bounded event(s)" + opened: reports.events.length > 0 + visible: reports.events.length > 0 + onToggleRequested: {} + } + Repeater { + model: reports.events.slice(Math.max(0, reports.events.length - 6)) + delegate: Label { + required property string modelData + Layout.fillWidth: true + leftPadding: 9 + rightPadding: 9 + topPadding: 3 + bottomPadding: 3 + text: modelData + color: theme.textSoft + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + } + Rectangle { visible: reports.rejectedReportCount > 0 || reports.reportDecodeFailureCount > 0 Layout.fillWidth: true diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index fd7f77428..49ba9aa53 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -373,7 +373,7 @@ mms::MmsLiveDiscoveryResult buildContextDiscoverySeed( static const std::vector reportAttributes{ "DatSet", "RptID", "ConfRev", "IntgPd", "BufTm", "SqNum", "RptEna", "Resv", "ResvTms", "Owner", "EntryID", "TimeOfEntry", - "TrgOps", "OptFlds"}; + "TrgOps", "OptFlds", "GI"}; for (const auto& control : model.report_controls) { mms::MmsReportControlCandidate candidate; diff --git a/include/ariec61850/mms/report_subscription_runtime.hpp b/include/ariec61850/mms/report_subscription_runtime.hpp index a749200ba..e660f6851 100644 --- a/include/ariec61850/mms/report_subscription_runtime.hpp +++ b/include/ariec61850/mms/report_subscription_runtime.hpp @@ -39,6 +39,7 @@ enum class MmsReportSubscriptionEventKind : std::uint8_t { attribute_written, enabled, general_interrogation_sent, + general_interrogation_not_sent, report_received, report_rejected, report_decode_failed, @@ -64,6 +65,10 @@ struct MmsReportSubscriptionOptions final { std::vector trigger_options; std::vector optional_fields; std::string data_set_reference; + // Pin the DataSet whose directory labels the values. Used by static + // subscriptions so another client cannot silently rebind between + // discovery and the first RCB enable. + std::string expected_data_set_reference; std::size_t maximum_events{1'024U}; MmsOfflineReportMonitorOptions monitor_options{}; }; diff --git a/src/mms/report_subscription_runtime.cpp b/src/mms/report_subscription_runtime.cpp index b751a4af8..32809d09d 100644 --- a/src/mms/report_subscription_runtime.cpp +++ b/src/mms/report_subscription_runtime.cpp @@ -102,9 +102,11 @@ MmsReportSubscriptionRuntime::MmsReportSubscriptionRuntime( } std::vector MmsReportSubscriptionRuntime::probe_attributes() const { - static const std::array preferred{ + // GI is a trigger command, not required as a readable state field. + // Some IEDs reject a Read(GI) even when Write(GI=true) is valid. + static const std::array preferred{ "RptID", "RptEna", "DatSet", "ConfRev", "OptFlds", - "BufTm", "SqNum", "TrgOps", "IntgPd", "GI", + "BufTm", "SqNum", "TrgOps", "IntgPd", "Resv", "ResvTms", "Owner", "EntryID", "TimeOfEntry"}; std::vector attributes; for (const auto* name : preferred) { @@ -221,6 +223,16 @@ void MmsReportSubscriptionRuntime::start( set_state(MmsReportSubscriptionState::probing, "Probing RCB state before any write."); const auto current = probe(stop_token); + if (!options_.expected_data_set_reference.empty()) { + const auto expected = MmsDataSetDirectoryCodec::parse_data_set_reference( + options_.expected_data_set_reference); + const auto observed = MmsDataSetDirectoryCodec::parse_data_set_reference( + current.data_set_reference); + if (expected != observed) { + throw MmsReportSubscriptionError( + "RCB DataSet changed after discovery; refusing stale directory."); + } + } const auto was_enabled = current.report_enabled.value_or(false); const auto caller_owned_enabled = was_enabled && options_.allow_reenable_caller_owned; @@ -368,16 +380,20 @@ void MmsReportSubscriptionRuntime::start( "Caller-owned RCB was already enabled; redundant RptEna write skipped."); } - if (options_.trigger_general_interrogation && - contains_attribute(candidate_, "GI")) { - try { - write_attribute("GI", MmsDataValue::boolean(true), stop_token); - add_event(MmsReportSubscriptionEventKind::general_interrogation_sent, - "General interrogation requested after verified RCB enable."); - } catch (const std::exception& exception) { - add_event(MmsReportSubscriptionEventKind::cleanup_deferred, - "RCB remains enabled, but GI write failed: " + - std::string{exception.what()}); + if (options_.trigger_general_interrogation) { + if (!contains_attribute(candidate_, "GI")) { + add_event(MmsReportSubscriptionEventKind::general_interrogation_not_sent, + "GI not sent: selected RCB has no exposed GI write attribute."); + } else { + try { + write_attribute("GI", MmsDataValue::boolean(true), stop_token); + add_event(MmsReportSubscriptionEventKind::general_interrogation_sent, + "GI Write confirmed after verified RCB enable; awaiting report evidence."); + } catch (const std::exception& exception) { + add_event(MmsReportSubscriptionEventKind::general_interrogation_not_sent, + "GI Write failed; RCB remains enabled, GI report not guaranteed: " + + std::string{exception.what()}); + } } } diff --git a/src/mms/static_report_session.cpp b/src/mms/static_report_session.cpp index b11bc104d..c7de39764 100644 --- a/src/mms/static_report_session.cpp +++ b/src/mms/static_report_session.cpp @@ -147,9 +147,11 @@ void MmsStaticReportSessionRuntime::start( throw MmsStaticReportSessionError( "Static report subscription has already been started."); } + auto pinnedOptions = options_.subscription; + pinnedOptions.expected_data_set_reference = selected_data_set_reference_; subscription_ = std::make_unique( association_, *selected_candidate_, *selected_directory_, - options_.subscription); + std::move(pinnedOptions)); subscription_->start(stop_token); } From f399d8ccffa2fe694a44131b28ba6ffe14e7991b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 05:24:29 +0700 Subject: [PATCH 4/8] fix(qml): wire report-event disclosure state instead of inert inspector toggle --- apps/ied_simulator/qml/BrowserReportPane.qml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserReportPane.qml b/apps/ied_simulator/qml/BrowserReportPane.qml index 1a1effaee..dc00bb66f 100644 --- a/apps/ied_simulator/qml/BrowserReportPane.qml +++ b/apps/ied_simulator/qml/BrowserReportPane.qml @@ -18,6 +18,7 @@ Rectangle { property bool attributesExpanded: true property bool informationExpanded: true property bool dataExpanded: true + property bool eventsExpanded: true property int selectedReportMemberIndex: -1 // Preserve selection through repeat snapshots of the same report frame; // reset only when the actual ReportID/sequence/entry identity changes. @@ -482,12 +483,14 @@ Rectangle { SectionHeading { heading: "RCB lifecycle & InformationReport events" evidence: reports.events.length + " bounded event(s)" - opened: reports.events.length > 0 + opened: root.eventsExpanded visible: reports.events.length > 0 - onToggleRequested: {} + onToggleRequested: root.eventsExpanded = !root.eventsExpanded } Repeater { - model: reports.events.slice(Math.max(0, reports.events.length - 6)) + model: root.eventsExpanded + ? reports.events.slice(Math.max(0, reports.events.length - 6)) + : [] delegate: Label { required property string modelData Layout.fillWidth: true From 6032d5ce2e02915bdebbe0ec9d873631e6c3d087 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 05:25:36 +0700 Subject: [PATCH 5/8] fix(reporting): validate optional wire DataRef against exact selected directory identity --- src/mms/reporting.cpp | 16 ++++++++++++++++ tests/test_reporting.cpp | 13 +++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/mms/reporting.cpp b/src/mms/reporting.cpp index b2ee8cabb..c1ff75647 100644 --- a/src/mms/reporting.cpp +++ b/src/mms/reporting.cpp @@ -657,6 +657,22 @@ std::string MmsReportFrameBindingValidator::rejection_reason( value.data_set_index != index || !value.member || value.member->object_name != directory.members[index].object_name) return "Report inclusion order or DataSet member identity differs."; + // Wire DataRef is an independent assertion of member identity. + // In particular, the mapper's member field is projected from the + // directory and is not proof that a *reported* DataRef agreed. + if (frame.header.optional_fields.has("data-reference")) { + if (value.data_reference.empty()) + return "InformationReport has empty mandatory DataRef."; + const auto& expected = directory.members[index]; + const auto canonical = expected.user_reference.empty() + ? normalize_member(expected.object_name).user_reference + : expected.user_reference; + if (value.data_reference != canonical && + value.data_reference != expected.mms_reference && + value.data_reference != expected.object_name.reference() && + value.data_reference != normalize_member(expected.object_name).user_reference) + return "InformationReport wire DataRef contradicts selected DataSet member."; + } previous = index; } return {}; diff --git a/tests/test_reporting.cpp b/tests/test_reporting.cpp index 15802a946..c4ee27bb4 100644 --- a/tests/test_reporting.cpp +++ b/tests/test_reporting.cpp @@ -450,6 +450,19 @@ void binding_validator_rejects_cross_rcb_and_stale_reports() { changed.included_data_set_indexes = {0U, 0U}; require(!reject(changed).empty(), "Duplicate index accepted."); changed = frame; + changed.values[0].data_reference = "LD0/GGIO1.Ind2.stVal"; + changed.values[1].data_reference = "LD0/GGIO1.Ind1.stVal"; + require(!reject(changed).empty(), "Swapped wire DataRefs accepted."); + changed = frame; + changed.values[0].data_reference = "LD0/OTHER.Ind1.stVal"; + require(!reject(changed).empty(), "Foreign wire DataRef accepted."); + changed = frame; + changed.values[0].data_reference.clear(); + require(!reject(changed).empty(), "Missing enabled DataRef accepted."); + changed = frame; + changed.values[0].data_reference = "LD0/GGIO1$ST$Ind1$stVal"; + require(reject(changed).empty(), "Exact MMS typed DataRef was rejected."); + changed = frame; changed.values[1].data_set_index = 0U; require(!reject(changed).empty(), "Inconsistent member index accepted."); changed = frame; From 2f160e93c84262f109daea4c0ca6e0fdca63bce0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 05:29:24 +0700 Subject: [PATCH 6/8] test(reporting): update readable RCB fixtures and prove foreign RptID/live DataSet rebind refusal --- tests/test_mms_runtime.cpp | 50 +++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/tests/test_mms_runtime.cpp b/tests/test_mms_runtime.cpp index f970163da..8f6279056 100644 --- a/tests/test_mms_runtime.cpp +++ b/tests/test_mms_runtime.cpp @@ -590,7 +590,6 @@ void queue_probe_response( {mms::MmsDataValue::unsigned_integer(1U), std::nullopt}, {mms::MmsDataValue::bit_string(0U, ByteVector{0x5AU, 0x00U}), std::nullopt}, {mms::MmsDataValue::bit_string(0U, ByteVector{0x40U}), std::nullopt}, - {mms::MmsDataValue::boolean(false), std::nullopt}, {mms::MmsDataValue::boolean(reserved), std::nullopt}, }; transport.push_receive(wrap_application( @@ -672,15 +671,15 @@ void queue_static_preclaim_response( void queue_static_subscription_probe_response( ScriptedTransport& transport, const std::uint32_t invoke_id, - const bool enabled = false) { + const bool enabled = false, + const std::string& data_set = "LD0/LLN0.DataSet") { mms::MmsReadResponse response; response.invoke_id = invoke_id; response.results = { {mms::MmsDataValue::visible_string("RPT-FALLBACK"), std::nullopt}, {mms::MmsDataValue::boolean(enabled), std::nullopt}, - {mms::MmsDataValue::visible_string("LD0/LLN0.DataSet"), std::nullopt}, + {mms::MmsDataValue::visible_string(data_set), 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( @@ -731,8 +730,10 @@ void static_session_skips_contended_preferred_and_subscribes_fallback() { CHECK(active.subscription->enabled_by_runtime); CHECK(!active.subscription->reservation_touched); + auto matched = make_report(); + matched.items[0].value = mms::MmsDataValue::visible_string("RPT-FALLBACK"); transport.push_receive(wrap_application( - mms::MmsInformationReportCodec::encode_p_data(make_report()))); + mms::MmsInformationReportCodec::encode_p_data(matched))); CHECK(session.poll_once()); CHECK(session.snapshot().subscription->received_reports == 1U); @@ -761,6 +762,34 @@ void static_session_rejects_data_set_rebinding() { }); } + +void static_session_fails_closed_on_live_dataset_rebind() { + 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); + // Another client rebound the RCB between directory discovery and enable. + queue_static_subscription_probe_response( + transport, 3U, false, "LD0/LLN0.UnrelatedDataSet"); + 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(); + CHECK(session.snapshot().selected_rcb_reference == fallback.reference); + check_throws([&] { session.start(); }); + CHECK(!session.active()); + CHECK(transport.sent().size() == 5U); // CR, AARQ, 3 Read; no unsafe Write +} + void subscription_runtime_reserves_enables_receives_and_cleans_up() { ScriptedTransport transport; queue_handshake(transport); @@ -782,10 +811,19 @@ void subscription_runtime_reserves_enables_receives_and_cleans_up() { CHECK(active.enabled_by_runtime); CHECK(active.reservation_touched); + auto foreign = make_report(); + foreign.items[0].value = mms::MmsDataValue::visible_string("ANOTHER-RCB"); + transport.push_receive(wrap_application( + mms::MmsInformationReportCodec::encode_p_data(foreign))); + CHECK(subscription.poll_once()); + CHECK(subscription.snapshot().rejected_reports == 1U); + CHECK(subscription.snapshot().received_reports == 0U); + transport.push_receive(wrap_application( mms::MmsInformationReportCodec::encode_p_data(make_report()))); CHECK(subscription.poll_once()); const auto observed = subscription.snapshot(); + CHECK(observed.rejected_reports == 1U); CHECK(observed.received_reports == 1U); CHECK(observed.decode_failures == 0U); CHECK(observed.streams.size() == 1U); @@ -841,7 +879,6 @@ void subscription_does_not_take_over_an_enabled_rcb() { {mms::MmsDataValue::bit_string(0U, ByteVector{0x5AU, 0x00U}), std::nullopt}, {mms::MmsDataValue::bit_string(0U, ByteVector{0x40U}), std::nullopt}, {mms::MmsDataValue::boolean(false), std::nullopt}, - {mms::MmsDataValue::boolean(false), std::nullopt}, }; transport.push_receive(wrap_application( mms::MmsServiceCodec::encode_read_response_p_data(response))); @@ -867,6 +904,7 @@ int main() { {"batch out-of-order response and interleaved report", confirmed_batch_dispatches_out_of_order_with_report}, {"batch unexpected invoke and timeout fault containment", confirmed_batch_unregistered_response_faults_and_receipts_survive}, {"report subscription lifecycle", subscription_runtime_reserves_enables_receives_and_cleans_up}, + {"static RCB live DataSet rebind refusal", static_session_fails_closed_on_live_dataset_rebind}, {"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}, From d4441480d8bfa48230b6f5cd4a191a58adc865b0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 05:30:16 +0700 Subject: [PATCH 7/8] fix(reporting): exclude write-only GI from canonical report attach attribute reads --- apps/ied_simulator/src/MmsReportController.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 49ba9aa53..407e04959 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -405,7 +405,14 @@ void probeContextReportControls( } mms::MmsReportControlEvidence evidence; evidence.candidate = candidate; - evidence.requested_attributes = candidate.attributes; + // The canonical inventory lists readable fields AND supported + // trigger/write surfaces. GI is a command, not a mandatory Read. + // Do not fail live attach on IEDs that expose GI as write-only. + evidence.requested_attributes.reserve(candidate.attributes.size()); + for (const auto& attribute : candidate.attributes) { + if (attribute != "GI") + evidence.requested_attributes.push_back(attribute); + } try { const auto invokeId = association.next_invoke_id(); const auto request = mms::MmsReportControlStateMapper::build_read_request( From 723ad3f46728b864c91e553820e410ab4171ad9a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 05:38:29 +0700 Subject: [PATCH 8/8] test(workbench): require exact active RCB provenance instead of legacy RptID-only inspector routing --- apps/ied_simulator/test_browser_convergence.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 289a8a100..1cfac6dba 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -252,12 +252,15 @@ 'BrowserEngineeringEvidence {', 'root.latestReport.valuesTruncated === true', 'root.latestReport.valueTextTruncated === true', - 'String(frame.reportId) === wanted', + 'String(frame.rcbReference) !== expectedRcb', + 'String(frame.reportId) !== expectedRptId', 'Offline values are not fabricated.', ] missing_report = [term for term in report_required if term not in report_inspector] if missing_report: raise SystemExit("BROWSER_REPORT_INSPECTOR_FAIL missing=" + ",".join(missing_report)) +if 'String(frame.reportId) === wanted' in report_inspector: + raise SystemExit("BROWSER_REPORT_INSPECTOR_FAIL report ID alone is not exact RCB provenance") if 'title: modelData[0]' in report_inspector or 'rawValue: modelData[1]' in report_inspector: raise SystemExit("BROWSER_REPORT_INSPECTOR_FAIL nested-array attribute labels") if "ReportsWorkspace {" in workspace: