From 76749d5718affb738a45cd6845c120ce9363da0f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 07:35:30 +0700 Subject: [PATCH] P1.3: preserve explicit nofASDU invalidity across SCL pathways Default to one ASDU only when absent; retain invalid explicit XML provenance and reject it in compilation, canonical export, and workspace semantic equivalence. Cover omitted, valid, zero, malformed, overflow and empty cases. Tracks #147 and #139. --- .../src/SclWorkspaceController_part_00.inc | 1 + .../sampled_values/publisher_profile.hpp | 5 ++ include/ariec61850/scl/exporter_part_01.inc | 5 ++ include/ariec61850/scl/model.hpp | 2 + src/scl/parser_part_05_02.inc | 24 +++++++- tests/test_scl.cpp | 55 +++++++++++++++++++ 6 files changed, 89 insertions(+), 3 deletions(-) diff --git a/apps/ied_simulator/src/SclWorkspaceController_part_00.inc b/apps/ied_simulator/src/SclWorkspaceController_part_00.inc index 8742b2fe1..75733c4b6 100644 --- a/apps/ied_simulator/src/SclWorkspaceController_part_00.inc +++ b/apps/ied_simulator/src/SclWorkspaceController_part_00.inc @@ -176,6 +176,7 @@ bool sampledValuesEquivalent( lhs.smv_id != rhs.smv_id || lhs.multicast != rhs.multicast || lhs.multicast_valid != rhs.multicast_valid || lhs.sample_rate != rhs.sample_rate || lhs.sample_mode != rhs.sample_mode || lhs.no_asdu != rhs.no_asdu || + lhs.no_asdu_valid != rhs.no_asdu_valid || !(lhs.smv_options == rhs.smv_options)) { return false; } diff --git a/include/ariec61850/sampled_values/publisher_profile.hpp b/include/ariec61850/sampled_values/publisher_profile.hpp index 0e57cfdb9..410a134bc 100644 --- a/include/ariec61850/sampled_values/publisher_profile.hpp +++ b/include/ariec61850/sampled_values/publisher_profile.hpp @@ -238,6 +238,11 @@ class SvPublisherProfileCompiler final { } profile.data_set_reference = stream.data_set_reference; profile.configuration_revision = stream.configuration_revision; + if (!stream.no_asdu_valid) { + result.errors.push_back( + "SV SampledValueControl has invalid explicit nofASDU; " + "packetization cannot be inferred from malformed engineering input."); + } profile.timing = resolve_sv_publication_timing( parse_sample_mode(stream.sample_mode), stream.sample_rate, diff --git a/include/ariec61850/scl/exporter_part_01.inc b/include/ariec61850/scl/exporter_part_01.inc index 46f512da1..171124a3f 100644 --- a/include/ariec61850/scl/exporter_part_01.inc +++ b/include/ariec61850/scl/exporter_part_01.inc @@ -45,6 +45,11 @@ inline bool validate_model( ": SampledValueControl@multicast was explicitly malformed in the source."; return false; } + if (!stream.no_asdu_valid || stream.no_asdu == 0U) { + error = "Canonical SCL export rejected " + stream.control_block_reference + + ": SampledValueControl@nofASDU was explicitly invalid in the source."; + return false; + } } for (const auto& entry : document.model_entries) { if (entry.ied_name.empty() || entry.ld_inst.empty() || entry.ln_class.empty() || diff --git a/include/ariec61850/scl/model.hpp b/include/ariec61850/scl/model.hpp index dbfcff88e..97b14e3c1 100644 --- a/include/ariec61850/scl/model.hpp +++ b/include/ariec61850/scl/model.hpp @@ -180,6 +180,8 @@ struct SclSampledValuesStream final : SclProcessBusStream { std::uint16_t sample_rate{}; std::string sample_mode; std::uint16_t no_asdu{1U}; + // Only omitted nofASDU may use the SCL default of 1. + bool no_asdu_valid{true}; SclSmvOptions smv_options; friend bool operator==(const SclSampledValuesStream&, const SclSampledValuesStream&) = default; diff --git a/src/scl/parser_part_05_02.inc b/src/scl/parser_part_05_02.inc index acfc83e10..aaa9df8f3 100644 --- a/src/scl/parser_part_05_02.inc +++ b/src/scl/parser_part_05_02.inc @@ -26,9 +26,27 @@ stream.sample_rate = ushort_attribute(*control, "smpRate"); stream.sample_mode = attribute(control, "smpMod"); - stream.no_asdu = std::max( - 1U, - ushort_attribute(*control, "nofASDU")); + // Apply the default only for omission; explicit malformed + // nofASDU must never silently become a valid one-ASDU stream. + const bool no_asdu_present = std::any_of( + control->attributes.begin(), control->attributes.end(), + [](const XmlAttribute& item) { + return local_name(item.name) == "nofASDU"; + }); + if (no_asdu_present) { + const auto count = optional_uint_attribute(*control, "nofASDU"); + if (!count.has_value() || *count == 0U || + *count > std::numeric_limits::max()) { + stream.no_asdu_valid = false; + stream.no_asdu = 0U; + document.warnings.push_back( + "SV " + stream.control_block_reference + + " has invalid explicit nofASDU='" + attribute(control, "nofASDU") + + "'; deployment/export is blocked."); + } else { + stream.no_asdu = static_cast(*count); + } + } if (const auto* options = first_child(*control, "SmvOpts"); options != nullptr) { stream.smv_options.element_present = true; stream.smv_options.refresh_time = bool_attribute(*options, "refreshTime"); diff --git a/tests/test_scl.cpp b/tests/test_scl.cpp index acf64bfa6..241492021 100644 --- a/tests/test_scl.cpp +++ b/tests/test_scl.cpp @@ -870,6 +870,60 @@ void parser_preserves_sampled_value_multicast_semantics() { CHECK(invalid_export.error.find("multicast was explicitly malformed") != std::string::npos); } +void parser_fails_closed_on_explicit_invalid_nof_asdu() { + using namespace ar::iec61850::sampled_values; + using namespace ar::iec61850::scl; + + constexpr std::string_view xml = R"xml( + + + + + + + + + + +)xml"; + const auto document = SclParser{}.parse(xml, "invalid-nofasdu.scd"); + CHECK(document.sampled_values_streams.size() == 7U); + for (std::size_t index = 0U; index < 3U; ++index) { + CHECK(document.sampled_values_streams[index].no_asdu_valid); + } + CHECK(document.sampled_values_streams[0].no_asdu == 1U); + CHECK(document.sampled_values_streams[1].no_asdu == 1U); + CHECK(document.sampled_values_streams[2].no_asdu == 2U); + for (std::size_t index = 3U; index < 7U; ++index) { + const auto& stream = document.sampled_values_streams[index]; + CHECK(!stream.no_asdu_valid); + CHECK(stream.no_asdu == 0U); + SvPublisherProfileCompileContext context; + context.profile_family = SvProfileFamily::iec61850_9_2; + const auto compiled = SvPublisherProfileCompiler::compile(stream, context); + CHECK(!compiled.ok()); + CHECK(std::any_of(compiled.errors.begin(), compiled.errors.end(), + [](const std::string& error) { + return error.find("invalid explicit nofASDU") != std::string::npos; + })); + } + CHECK(std::count_if(document.warnings.begin(), document.warnings.end(), + [](const std::string& warning) { + return warning.find("invalid explicit nofASDU=") != std::string::npos; + }) == 4); + + auto modeled = SclParser{}.load(fixture("sv-4800-structured-4i4v.scd")); + CHECK(modeled.sampled_values_streams.size() == 1U); + SclCanonicalExportOptions options; + options.edition = modeled.edition; + options.profile = SclExportProfile::scd; + CHECK(SclExporter::canonical(modeled, options).success); + modeled.sampled_values_streams.front().no_asdu_valid = false; + const auto blocked = SclExporter::canonical(modeled, options); + CHECK(!blocked.success); + CHECK(blocked.error.find("nofASDU was explicitly invalid") != std::string::npos); +} + void dataset_reference_resolver_accepts_canonical_and_local_forms() { using namespace ar::iec61850::scl; @@ -1154,6 +1208,7 @@ int main() { {"SV profile family and transport semantics", sampled_values_profile_family_and_transport_are_explicit}, {"IEC 61869-9 variant dataset and scaling semantics", iec61869_9_profile_resolves_variant_dataset_and_exact_scaling}, {"SCL SV multicast semantics", parser_preserves_sampled_value_multicast_semantics}, + {"SCL SV nofASDU provenance", parser_fails_closed_on_explicit_invalid_nof_asdu}, {"SCL dataset references", dataset_reference_resolver_accepts_canonical_and_local_forms}, {"SCL configured control model", parser_preserves_configured_control_model_value}, {"Simulator semantic defaults and indexed RCBs", simulator_compiles_semantic_defaults_and_indexed_report_instances},