diff --git a/apps/ied_simulator/qml/BrowserReportPane.qml b/apps/ied_simulator/qml/BrowserReportPane.qml index 50bf7b37e..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. @@ -51,13 +52,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 ({}) } @@ -474,6 +480,53 @@ Rectangle { } } + SectionHeading { + heading: "RCB lifecycle & InformationReport events" + evidence: reports.events.length + " bounded event(s)" + opened: root.eventsExpanded + visible: reports.events.length > 0 + onToggleRequested: root.eventsExpanded = !root.eventsExpanded + } + Repeater { + model: root.eventsExpanded + ? 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 + 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 67103f62d..407e04959 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; @@ -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( @@ -431,6 +438,8 @@ struct ReportUi final { QVariantList reports; QStringList events; qulonglong receivedCount{}; + qulonglong rejectedCount{}; + qulonglong decodeFailureCount{}; bool active{}; bool cleanupRequired{}; }; @@ -441,7 +450,12 @@ 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.") + .arg(subscription.rejected_reports)); for (const auto& event : subscription.events) { ui.events.push_back(QString::fromStdString(event.message)); } @@ -456,6 +470,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)); @@ -617,6 +633,8 @@ void MmsReportController::clearInventory() { receivedReports_.clear(); events_.clear(); receivedReportCount_ = 0; + rejectedReportCount_ = 0; + reportDecodeFailureCount_ = 0; emit inventoryChanged(); emit selectionChanged(); emit reportsChanged(); @@ -664,6 +682,8 @@ void MmsReportController::adoptEngineeringInventory() { receivedReports_.clear(); events_.clear(); receivedReportCount_ = 0; + rejectedReportCount_ = 0; + reportDecodeFailureCount_ = 0; refreshSelection(); emit inventoryChanged(); emit reportsChanged(); @@ -1510,6 +1530,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 = @@ -1641,6 +1663,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.") @@ -1703,6 +1727,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.") @@ -1769,6 +1795,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 @@ -1836,6 +1864,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); } @@ -1866,6 +1896,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 6ac3bc718..e0e7840e4 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")) && @@ -356,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; 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: diff --git a/include/ariec61850/mms/report_subscription_runtime.hpp b/include/ariec61850/mms/report_subscription_runtime.hpp index 15d186d64..e660f6851 100644 --- a/include/ariec61850/mms/report_subscription_runtime.hpp +++ b/include/ariec61850/mms/report_subscription_runtime.hpp @@ -39,7 +39,9 @@ enum class MmsReportSubscriptionEventKind : std::uint8_t { attribute_written, enabled, general_interrogation_sent, + general_interrogation_not_sent, report_received, + report_rejected, report_decode_failed, disabled, reservation_released, @@ -63,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{}; }; @@ -73,7 +79,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 +137,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..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) { @@ -214,12 +216,23 @@ void MmsReportSubscriptionRuntime::start( cleanup_required_ = false; monitor_.clear(); received_reports_ = 0U; + rejected_reports_ = 0U; decode_failures_ = 0U; try { 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; @@ -367,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()}); + } } } @@ -524,7 +541,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 +600,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..c1ff75647 100644 --- a/src/mms/reporting.cpp +++ b/src/mms/reporting.cpp @@ -622,6 +622,62 @@ 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."; + // 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 {}; +} + std::string MmsReportFrame::routing_key() const { return header.report_id; } 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); } 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}, diff --git a/tests/test_reporting.cpp b/tests/test_reporting.cpp index 8bb3a40ad..c4ee27bb4 100644 --- a/tests/test_reporting.cpp +++ b/tests/test_reporting.cpp @@ -421,6 +421,70 @@ 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[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; + 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 +517,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;