From c6cf52c2b8c37d1f5ca55c9e7399c3a5ad4035aa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 05:15:13 +0700 Subject: [PATCH 1/2] perf(workbench): bound untrusted RCB report metadata before Qt string conversion M2G: cap member reference and Reason-for-Inclusion UTF-8 before converting to QString, keep final rendered report text within 1024 chars including truncation marker, and stop processing reason lists at the budget. Preserve full authoritative MMS references in runtime; skip typed q/t evidence when reference identity is truncated rather than attributing it to a false DA. Extend native large-model QA with 1.5MB references, 1.5MB reason text, 1500 reason entries and normal q/t provenance, plus Browser contract against eager conversion. --- .../src/BrowserReportMemberProjection.hpp | 78 +++++++++++++++---- .../src/ied_browser_large_model_qa.cpp | 45 +++++++++++ .../ied_simulator/test_browser_convergence.py | 12 ++- 3 files changed, 116 insertions(+), 19 deletions(-) diff --git a/apps/ied_simulator/src/BrowserReportMemberProjection.hpp b/apps/ied_simulator/src/BrowserReportMemberProjection.hpp index 9364d505d..2f03d202d 100644 --- a/apps/ied_simulator/src/BrowserReportMemberProjection.hpp +++ b/apps/ied_simulator/src/BrowserReportMemberProjection.hpp @@ -13,6 +13,8 @@ #include #include #include +#include +#include #include #include @@ -32,6 +34,29 @@ struct BrowserReportMemberProjection final { inline constexpr int kBrowserReportVisibleMemberLimit = 256; inline constexpr int kBrowserReportTextLimit = 1024; +// Bound untrusted IEC member reference and reason strings BEFORE conversion to +// QString. No full-sized temporary allocation for oversized MMS metadata. +[[nodiscard]] inline QString boundedReportMetadata( + const std::string_view source, const std::size_t maximumBytes, bool& truncated) { + std::size_t count = std::min(maximumBytes, source.size()); + if (count < source.size()) { + // Never split a UTF-8 continuation byte at the preview boundary. + while (count > 0 && (static_cast(source[count]) & 0xC0U) == 0x80U) + --count; + truncated = true; + } + return QString::fromUtf8(source.data(), static_cast(count)); +} + +[[nodiscard]] inline bool hasReportTypedLeaf( + const std::string_view reference, const mms::MmsDataKind kind) noexcept { + if (reference.size() < 2U) return false; + const char separator = reference[reference.size() - 2U]; + if (separator != '$' && separator != '.') return false; + return (reference.back() == 'q' && kind == mms::MmsDataKind::bit_string) || + (reference.back() == 't' && kind == mms::MmsDataKind::utc_time); +} + [[nodiscard]] inline BrowserReportMemberProjection projectBrowserReportMembers( const std::vector& members, int& remainingTypedEvidence, @@ -44,42 +69,67 @@ inline constexpr int kBrowserReportTextLimit = 1024; for (std::size_t ordinal = 0; ordinal < visibleCount; ++ordinal) { const auto& member = members[ordinal]; - QString reference = QString::fromStdString(member.data_reference); - if (reference.isEmpty() && member.member) - reference = QString::fromStdString(member.member->user_reference); - if (reference.isEmpty() && member.member) - reference = QString::fromStdString(member.member->mms_reference); + const std::string* rawReference = &member.data_reference; + if (rawReference->empty() && member.member) + rawReference = &member.member->user_reference; + if (rawReference->empty() && member.member) + rawReference = &member.member->mms_reference; + const std::string_view fullReference(*rawReference); + bool referenceTruncated = false; + const QString reference = boundedReportMetadata( + fullReference, kBrowserReportTextLimit, referenceTruncated); QString displayReference = reference; if (displayReference.isEmpty()) displayReference = QStringLiteral("member[%1]").arg( static_cast(member.data_set_index)); QString displayValue = QStringLiteral(""); + bool valueTruncated = false; if (member.value) { // Never allocate a full string/hex/array representation and then // truncate it. Serialize directly into a 1024-character budget. const auto preview = previewMmsValue(*member.value, kBrowserReportTextLimit); displayValue = QString::fromStdString(preview.text); - projection.textTruncated |= preview.truncated; + valueTruncated = preview.truncated; } QString reasons; + bool reasonsTruncated = false; for (const auto& reason : member.reason_for_inclusion.names) { + if (reasons.size() >= kBrowserReportTextLimit) { + reasonsTruncated = true; + break; + } if (!reasons.isEmpty()) reasons += QStringLiteral(", "); - reasons += QString::fromStdString(reason); + reasons += boundedReportMetadata( + reason, static_cast( + std::max(0, kBrowserReportTextLimit - int(reasons.size()))), + reasonsTruncated); + if (reasonsTruncated) break; } QString text = QStringLiteral("%1 = %2%3").arg( displayReference, displayValue, reasons.isEmpty() ? QString{} : QStringLiteral(" · ") + reasons); - if (text.size() > kBrowserReportTextLimit) { + const bool rowTruncated = referenceTruncated || reasonsTruncated || + (member.value && valueTruncated) || + text.size() > kBrowserReportTextLimit; + if (rowTruncated) { projection.textTruncated = true; - text = text.left(kBrowserReportTextLimit) + QStringLiteral(" … [preview truncated]"); + const QString marker = QStringLiteral(" … [preview truncated]"); + text = text.left(kBrowserReportTextLimit - marker.size()) + marker; } projection.values.push_back(std::move(text)); // Only read successful typed q/t values, and only when the retained // presentation row matches the member ordinal (never DataSet position). - if (!member.value || reference.isEmpty()) continue; + if (!member.value || fullReference.empty()) continue; + if (referenceTruncated) { + // A shortened IEC reference must never be treated as the identity + // of a report q/t DA. The runtime retains the complete reference. + if (hasReportTypedLeaf(fullReference, member.value->kind())) + projection.evidenceTruncated = true; + continue; + } if (remainingTypedEvidence > 0) { const auto decoded = decodeEngineeringEvidence(reference, *member.value); if (!decoded.isEmpty()) { @@ -90,13 +140,7 @@ inline constexpr int kBrowserReportTextLimit = 1024; } } else { // Do not decode (allocate rows) once the shared budget is empty. - const auto separator = std::max( - reference.lastIndexOf(QLatin1Char('$')), - reference.lastIndexOf(QLatin1Char('.'))); - const QString leaf = reference.mid(separator + 1); - const auto kind = member.value->kind(); - if ((leaf == QStringLiteral("q") && kind == mms::MmsDataKind::bit_string) || - (leaf == QStringLiteral("t") && kind == mms::MmsDataKind::utc_time)) + if (hasReportTypedLeaf(fullReference, member.value->kind())) projection.evidenceTruncated = true; } } diff --git a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp index 1f3d37671..20a92b26a 100644 --- a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp +++ b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp @@ -422,6 +422,51 @@ int main(int argc, char** argv) { return 32; } + using ar::iec61850::mms::MmsReportValue; + // M2G: bounded value formatting alone is insufficient if an IED returns + // oversized IEC member references or Reason-for-Inclusion names. + std::vector giantMetadata(4); + giantMetadata[0].data_reference = std::string(1'500'000, 'R'); + giantMetadata[0].value = MmsDataValue::boolean(true); + giantMetadata[1].data_reference = "LD4/LLN19.DO99.stVal"; + giantMetadata[1].value = MmsDataValue::boolean(false); + giantMetadata[1].reason_for_inclusion.names.push_back(std::string(1'500'000, 'N')); + giantMetadata[2].data_reference = std::string(1'500'000, 'R') + ".q"; + giantMetadata[2].value = MmsDataValue::bit_string(3U, bits); + giantMetadata[3].data_reference = "LD4/LLN19.DO99.stVal"; + giantMetadata[3].value = MmsDataValue::boolean(true); + giantMetadata[3].reason_for_inclusion.names.assign(1500, "data-change"); + int metadataBudget = 8; + const auto safeMetadata = arstack::iedsim::projectBrowserReportMembers( + giantMetadata, metadataBudget); + if (safeMetadata.values.size() != 4 || !safeMetadata.textTruncated || + !safeMetadata.evidenceTruncated || !safeMetadata.evidence.isEmpty() || + metadataBudget != 8 || + safeMetadata.values.at(0).size() > 1024 || + safeMetadata.values.at(1).size() > 1024 || + safeMetadata.values.at(2).size() > 1024 || + safeMetadata.values.at(3).size() > 1024 || + !safeMetadata.values.at(0).contains(QStringLiteral("preview truncated")) || + !safeMetadata.values.at(1).contains(QStringLiteral("preview truncated")) || + !safeMetadata.values.at(3).contains(QStringLiteral("preview truncated"))) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL bounded_report_metadata\n"; + return 34; + } + // Normal IEC references remain exact so q/t evidence can still be + // associated with the original received member ordinal. + std::vector normalMetadata(1); + normalMetadata[0].data_reference = "LD4/LLN19.DO99.q"; + normalMetadata[0].value = MmsDataValue::bit_string(3U, bits); + const auto normalProjected = arstack::iedsim::projectBrowserReportMembers( + normalMetadata, metadataBudget); + if (normalProjected.evidence.size() != 1 || + normalProjected.evidence.constFirst().toMap() + .value(QStringLiteral("index")).toInt() != 0 || + normalProjected.textTruncated) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL report_metadata_provenance\n"; + return 35; + } + const bool passes = applyMs <= 5000 && filterMs <= 1500 && selectMs <= 500; std::cout << "IED_BROWSER_LARGE_MODEL_" << (passes ? "PASS" : "FAIL") << " nodes=" << model.totalNodeCount() diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 4f0499ff0..f60189090 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -108,9 +108,14 @@ 'kBrowserReportTextLimit = 1024', 'std::min(members.size(),', 'projection.rowsTruncated = members.size() > visibleCount', - 'if (!member.value || reference.isEmpty()) continue', + 'if (!member.value || fullReference.empty()) continue', 'decodeEngineeringEvidence(', - '#include "BrowserBoundedMmsPreview.hpp"', + 'boundedReportMetadata(', + 'const std::string_view fullReference(*rawReference)', + 'if (referenceTruncated) {', + 'hasReportTypedLeaf(', + 'text.left(kBrowserReportTextLimit - marker.size()) + marker', + '#include "BrowserBoundedMmsPreview.hpp"' 'previewMmsValue(*member.value', ]: if token not in projection_header: @@ -121,6 +126,9 @@ raise SystemExit("BROWSER_REPORT_BOUNDED_PROJECTION_FAIL duplicate inline decoding") if 'MmsDataCodec::to_display_string(*member.value)' in projection_header: raise SystemExit("BROWSER_VALUE_BOUNDED_FAIL unbounded intermediary in report GUI") +if ('QString::fromStdString(member.data_reference)' in projection_header or + 'QString::fromStdString(reason)' in projection_header): + raise SystemExit("BROWSER_REPORT_METADATA_BOUNDED_FAIL eager untrusted metadata conversion") value_preview = (workspace_path.parent.parent / "src" / "BrowserBoundedMmsPreview.hpp").read_text(encoding="utf-8") for token in [ From dee1f8fa2816273a7328db232257afe81a2b5621 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 05:44:19 +0700 Subject: [PATCH 2/2] test(workbench): separate M2F preview source-contract assertions --- apps/ied_simulator/test_browser_convergence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index f60189090..163d1440d 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -115,7 +115,7 @@ 'if (referenceTruncated) {', 'hasReportTypedLeaf(', 'text.left(kBrowserReportTextLimit - marker.size()) + marker', - '#include "BrowserBoundedMmsPreview.hpp"' + '#include "BrowserBoundedMmsPreview.hpp"', 'previewMmsValue(*member.value', ]: if token not in projection_header: