diff --git a/.github/workflows/security-evidence.yml b/.github/workflows/security-evidence.yml
index 7c376364d..0dc275095 100644
--- a/.github/workflows/security-evidence.yml
+++ b/.github/workflows/security-evidence.yml
@@ -61,6 +61,8 @@ jobs:
run: ./build-fuzz/ariec61850_fuzz_sampled_values fuzz/corpus/sampled_values -runs=1000 -timeout=5
- name: Run PCAP corpus
run: ./build-fuzz/ariec61850_fuzz_pcap fuzz/corpus/pcap -runs=1000 -timeout=5
+ - name: Run SCL corpus
+ run: ./build-fuzz/ariec61850_fuzz_scl fuzz/corpus/scl -runs=1000 -timeout=5
- name: Upload crash evidence
if: failure()
uses: actions/upload-artifact@v4
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 5e843617a..049b88ea9 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.20)
project(ARIEC61850Cpp
- VERSION 0.6.0
+ VERSION 0.7.0
DESCRIPTION "Incremental C++20 port of the ARIEC61850 protocol stack"
LANGUAGES CXX)
@@ -29,7 +29,9 @@ add_library(ariec61850_core
src/sampled_values/payload_inspector.cpp
src/sampled_values/sample_counter.cpp
src/sampled_values/stream_supervisor.cpp
- src/evidence/pcap_equivalence.cpp)
+ src/evidence/pcap_equivalence.cpp
+ src/scl/dataset_reference.cpp
+ src/scl/parser.cpp)
add_library(ARIEC61850::core ALIAS ariec61850_core)
@@ -107,13 +109,22 @@ if(ARIEC61850_BUILD_TESTS)
add_executable(ariec61850_evidence_tests tests/test_evidence.cpp)
target_link_libraries(ariec61850_evidence_tests PRIVATE ARIEC61850::core)
- target_compile_features(ariec61850_evidence_tests PRIVATE cxx_std_20)
target_compile_definitions(ariec61850_evidence_tests PRIVATE
ARIEC61850_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}")
+ target_compile_features(ariec61850_evidence_tests PRIVATE cxx_std_20)
ariec61850_apply_warnings(ariec61850_evidence_tests)
ariec61850_apply_sanitizers(ariec61850_evidence_tests)
add_test(NAME ariec61850_evidence_tests COMMAND ariec61850_evidence_tests)
+ add_executable(ariec61850_scl_tests tests/test_scl.cpp)
+ target_link_libraries(ariec61850_scl_tests PRIVATE ARIEC61850::core)
+ target_compile_definitions(ariec61850_scl_tests PRIVATE
+ ARIEC61850_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}")
+ target_compile_features(ariec61850_scl_tests PRIVATE cxx_std_20)
+ ariec61850_apply_warnings(ariec61850_scl_tests)
+ ariec61850_apply_sanitizers(ariec61850_scl_tests)
+ add_test(NAME ariec61850_scl_tests COMMAND ariec61850_scl_tests)
+
add_executable(ariec61850_fuzz_smoke_tests tests/test_fuzz_smoke.cpp)
target_link_libraries(ariec61850_fuzz_smoke_tests PRIVATE ARIEC61850::core)
target_include_directories(ariec61850_fuzz_smoke_tests PRIVATE
@@ -155,4 +166,5 @@ if(ARIEC61850_BUILD_FUZZERS)
ariec61850_add_fuzzer(ariec61850_fuzz_goose fuzz/fuzz_goose.cpp)
ariec61850_add_fuzzer(ariec61850_fuzz_sampled_values fuzz/fuzz_sampled_values.cpp)
ariec61850_add_fuzzer(ariec61850_fuzz_pcap fuzz/fuzz_pcap.cpp)
+ ariec61850_add_fuzzer(ariec61850_fuzz_scl fuzz/fuzz_scl.cpp)
endif()
diff --git a/fuzz/corpus/scl/minimal-station.scd b/fuzz/corpus/scl/minimal-station.scd
new file mode 100644
index 000000000..2c5a68116
--- /dev/null
+++ b/fuzz/corpus/scl/minimal-station.scd
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+ 01-0C-CD-04-00-01
+ 4001
+ 200
+ 4
+
+
+
+
+ 01-0C-CD-01-00-01
+ 1001
+ 100
+ 4
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fuzz/fuzz_scl.cpp b/fuzz/fuzz_scl.cpp
new file mode 100644
index 000000000..f2f13a46b
--- /dev/null
+++ b/fuzz/fuzz_scl.cpp
@@ -0,0 +1,9 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#include "fuzz_targets.hpp"
+#include
+#include
+#include
+extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, const std::size_t size) {
+ ar::iec61850::fuzzing::exercise_scl(std::span{data, size});
+ return 0;
+}
diff --git a/fuzz/fuzz_targets.hpp b/fuzz/fuzz_targets.hpp
index ae3467688..b209f323c 100644
--- a/fuzz/fuzz_targets.hpp
+++ b/fuzz/fuzz_targets.hpp
@@ -8,6 +8,7 @@
#include "ariec61850/goose/pdu_codec.hpp"
#include "ariec61850/sampled_values/frame_codec.hpp"
#include "ariec61850/sampled_values/pdu_codec.hpp"
+#include "ariec61850/scl/parser.hpp"
#include
#include
@@ -88,4 +89,20 @@ inline void exercise_pcap(const std::span bytes) {
}
}
+inline void exercise_scl(const std::span bytes) {
+ std::string input;
+ if (!bytes.empty()) {
+ input.assign(reinterpret_cast(bytes.data()), bytes.size());
+ }
+ try {
+ const auto document = scl::SclParser{}.parse(input, "fuzz-input.scl");
+ static_cast(document.ieds.size());
+ static_cast(document.data_sets.size());
+ static_cast(document.goose_streams.size());
+ static_cast(document.sampled_values_streams.size());
+ static_cast(document.report_controls.size());
+ } catch (...) {
+ }
+}
+
} // namespace ar::iec61850::fuzzing
diff --git a/include/ariec61850/scl/dataset_reference.hpp b/include/ariec61850/scl/dataset_reference.hpp
new file mode 100644
index 000000000..c24883b9a
--- /dev/null
+++ b/include/ariec61850/scl/dataset_reference.hpp
@@ -0,0 +1,29 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include "ariec61850/scl/model.hpp"
+
+#include
+#include
+
+namespace ar::iec61850::scl {
+
+struct SclDataSetBindingResolution final {
+ SclDataSetBindingStatus status{SclDataSetBindingStatus::not_specified};
+ const SclDataSet* data_set{};
+ std::string canonical_reference;
+ std::string local_name;
+};
+
+class SclDataSetReferenceResolver final {
+public:
+ [[nodiscard]] static SclDataSetBindingResolution resolve(
+ std::span data_sets,
+ const std::string& ied_name,
+ const std::string& ld_inst,
+ const std::string& logical_node_path,
+ const std::string& raw_reference);
+};
+
+} // namespace ar::iec61850::scl
diff --git a/include/ariec61850/scl/model.hpp b/include/ariec61850/scl/model.hpp
new file mode 100644
index 000000000..5b7d3a8ce
--- /dev/null
+++ b/include/ariec61850/scl/model.hpp
@@ -0,0 +1,160 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace ar::iec61850::scl {
+
+enum class SclEdition {
+ unknown,
+ edition1,
+ edition2,
+ edition21,
+};
+
+struct SclIed final {
+ std::string name;
+ std::string manufacturer;
+ std::string type;
+ std::string config_version;
+
+ friend bool operator==(const SclIed&, const SclIed&) = default;
+};
+
+struct SclDataSetEntry final {
+ std::size_t index{};
+ std::string signal_reference;
+ std::string ied_name;
+ std::string ld_inst;
+ std::string prefix;
+ std::string ln_class;
+ std::string ln_inst;
+ std::string do_name;
+ std::string da_name;
+ std::string functional_constraint;
+ std::string cdc;
+ std::string basic_type;
+ std::string type_id;
+ std::string enum_type;
+ bool is_quality{};
+ bool is_timestamp{};
+
+ friend bool operator==(const SclDataSetEntry&, const SclDataSetEntry&) = default;
+};
+
+struct SclDataSet final {
+ std::string key;
+ std::string ied_name;
+ std::string ld_inst;
+ std::string logical_node_path;
+ std::string name;
+ std::string reference;
+ std::vector entries;
+
+ friend bool operator==(const SclDataSet&, const SclDataSet&) = default;
+};
+
+struct SclStreamAddress final {
+ std::string app_id_text;
+ std::optional app_id;
+ std::string destination_mac_text;
+ std::optional> destination_mac;
+ std::optional vlan_id;
+ std::optional vlan_priority;
+
+ friend bool operator==(const SclStreamAddress&, const SclStreamAddress&) = default;
+};
+
+struct SclProcessBusStream {
+ std::string kind;
+ std::string ied_name;
+ std::string ld_inst;
+ std::string control_name;
+ std::string control_block_reference;
+ std::string data_set_name;
+ std::string data_set_reference;
+ std::uint32_t configuration_revision{};
+ SclStreamAddress address;
+ std::vector entries;
+
+ friend bool operator==(const SclProcessBusStream&, const SclProcessBusStream&) = default;
+};
+
+struct SclGooseStream final : SclProcessBusStream {
+ std::string go_id;
+ std::uint32_t min_time_milliseconds{};
+ std::uint32_t max_time_milliseconds{};
+
+ friend bool operator==(const SclGooseStream&, const SclGooseStream&) = default;
+};
+
+struct SclSampledValuesStream final : SclProcessBusStream {
+ std::string sv_id;
+ std::string smv_id;
+ std::uint16_t sample_rate{};
+ std::string sample_mode;
+ std::uint16_t no_asdu{1U};
+
+ friend bool operator==(const SclSampledValuesStream&, const SclSampledValuesStream&) = default;
+};
+
+enum class SclDataSetBindingStatus {
+ not_specified,
+ unresolved,
+ resolved_empty,
+ resolved,
+};
+
+struct SclReportControl final {
+ std::string ied_name;
+ std::string ld_inst;
+ std::string logical_node_path;
+ std::string name;
+ std::string report_id;
+ std::string data_set_name;
+ std::string data_set_reference;
+ SclDataSetBindingStatus data_set_binding_status{SclDataSetBindingStatus::not_specified};
+ std::string control_block_reference;
+ bool buffered{};
+ bool indexed{true};
+ std::uint32_t configuration_revision{};
+ std::uint32_t buffer_time_milliseconds{};
+ std::uint32_t integrity_period_milliseconds{};
+ std::vector entries;
+
+ friend bool operator==(const SclReportControl&, const SclReportControl&) = default;
+};
+
+struct SclConflict final {
+ std::string kind;
+ std::string key;
+ std::string description;
+
+ friend bool operator==(const SclConflict&, const SclConflict&) = default;
+};
+
+struct SclDocument final {
+ std::string source_name;
+ std::string namespace_uri;
+ std::string header_id;
+ std::string header_version;
+ std::string header_revision;
+ SclEdition edition{SclEdition::unknown};
+ std::vector ieds;
+ std::vector data_sets;
+ std::vector goose_streams;
+ std::vector sampled_values_streams;
+ std::vector report_controls;
+ std::vector warnings;
+ std::vector conflicts;
+
+ friend bool operator==(const SclDocument&, const SclDocument&) = default;
+};
+
+} // namespace ar::iec61850::scl
diff --git a/include/ariec61850/scl/parser.hpp b/include/ariec61850/scl/parser.hpp
new file mode 100644
index 000000000..6dd98cd48
--- /dev/null
+++ b/include/ariec61850/scl/parser.hpp
@@ -0,0 +1,21 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include "ariec61850/scl/model.hpp"
+
+#include
+#include
+#include
+
+namespace ar::iec61850::scl {
+
+class SclParser final {
+public:
+ [[nodiscard]] SclDocument load(const std::filesystem::path& file_path) const;
+ [[nodiscard]] SclDocument parse(
+ std::string_view xml,
+ std::string source_name = {}) const;
+};
+
+} // namespace ar::iec61850::scl
diff --git a/src/scl/dataset_reference.cpp b/src/scl/dataset_reference.cpp
new file mode 100644
index 000000000..3495c09c1
--- /dev/null
+++ b/src/scl/dataset_reference.cpp
@@ -0,0 +1,198 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "ariec61850/scl/dataset_reference.hpp"
+
+#include
+#include
+#include
+#include
+
+namespace ar::iec61850::scl {
+namespace {
+
+std::string trim_copy(std::string_view text) {
+ auto first = text.begin();
+ while (first != text.end() && std::isspace(static_cast(*first)) != 0) {
+ ++first;
+ }
+ auto last = text.end();
+ while (last != first && std::isspace(static_cast(*(last - 1))) != 0) {
+ --last;
+ }
+ return {first, last};
+}
+
+std::string lower_copy(std::string_view text) {
+ std::string result;
+ result.reserve(text.size());
+ for (const char ch : text) {
+ result.push_back(static_cast(std::tolower(static_cast(ch))));
+ }
+ return result;
+}
+
+bool same(std::string_view left, std::string_view right) {
+ return lower_copy(trim_copy(left)) == lower_copy(trim_copy(right));
+}
+
+std::vector split_path(const std::string& text, const char delimiter) {
+ std::vector result;
+ std::size_t offset{};
+ while (offset <= text.size()) {
+ const auto end = text.find(delimiter, offset);
+ const auto part = trim_copy(std::string_view{text}.substr(
+ offset,
+ end == std::string::npos ? std::string::npos : end - offset));
+ if (!part.empty()) {
+ result.push_back(part);
+ }
+ if (end == std::string::npos) {
+ break;
+ }
+ offset = end + 1U;
+ }
+ return result;
+}
+
+struct ParsedReference final {
+ std::string ld_inst;
+ std::string logical_node_path;
+ std::string local_name;
+};
+
+std::string resolve_ld_inst(
+ const std::vector& path,
+ const std::string& ied_name,
+ const std::string& fallback_ld_inst) {
+ if (path.size() < 2U) {
+ return fallback_ld_inst;
+ }
+
+ auto domain = trim_copy(path[path.size() - 2U]);
+ if (same(domain, ied_name) && path.size() >= 3U) {
+ domain = trim_copy(path[path.size() - 3U]);
+ }
+
+ const auto lower_domain = lower_copy(domain);
+ const auto lower_ied = lower_copy(ied_name);
+ if (!ied_name.empty() && lower_domain.starts_with(lower_ied) && domain.size() > ied_name.size()) {
+ domain = domain.substr(ied_name.size());
+ }
+
+ return domain.empty() ? fallback_ld_inst : domain;
+}
+
+ParsedReference parse_reference(
+ const std::string& raw_reference,
+ const std::string& ied_name,
+ const std::string& fallback_ld_inst,
+ const std::string& fallback_logical_node_path) {
+ auto normalized = trim_copy(raw_reference);
+ std::replace(normalized.begin(), normalized.end(), '\\', '/');
+
+ auto lower = lower_copy(normalized);
+ std::size_t position{};
+ while ((position = lower.find("$ds$", position)) != std::string::npos) {
+ normalized.replace(position, 4U, "$");
+ lower.replace(position, 4U, "$");
+ ++position;
+ }
+
+ const auto path = split_path(normalized, '/');
+ auto leaf = path.empty() ? normalized : path.back();
+ auto ld_inst = resolve_ld_inst(path, ied_name, fallback_ld_inst);
+ auto logical_node_path = fallback_logical_node_path;
+ auto local_name = leaf;
+
+ const auto dollar_parts = split_path(leaf, '$');
+ if (dollar_parts.size() >= 2U) {
+ logical_node_path = dollar_parts.front();
+ local_name = dollar_parts.back();
+ } else {
+ const auto dot = leaf.find_last_of('.');
+ if (dot != std::string::npos && dot > 0U && dot + 1U < leaf.size()) {
+ logical_node_path = trim_copy(std::string_view{leaf}.substr(0U, dot));
+ local_name = trim_copy(std::string_view{leaf}.substr(dot + 1U));
+ }
+ }
+
+ if (logical_node_path.empty()) {
+ logical_node_path = fallback_logical_node_path;
+ }
+ if (ld_inst.empty()) {
+ ld_inst = fallback_ld_inst;
+ }
+
+ return {ld_inst, logical_node_path, trim_copy(local_name)};
+}
+
+std::string build_canonical_reference(
+ const std::string& ied_name,
+ const std::string& ld_inst,
+ const std::string& logical_node_path,
+ const std::string& local_name) {
+ if (local_name.empty()) {
+ return {};
+ }
+ return ied_name + ld_inst + "/" + logical_node_path + "$" + local_name;
+}
+
+} // namespace
+
+SclDataSetBindingResolution SclDataSetReferenceResolver::resolve(
+ const std::span data_sets,
+ const std::string& ied_name,
+ const std::string& ld_inst,
+ const std::string& logical_node_path,
+ const std::string& raw_reference) {
+ if (trim_copy(raw_reference).empty()) {
+ return {};
+ }
+
+ const auto parts = parse_reference(
+ raw_reference,
+ ied_name,
+ ld_inst,
+ logical_node_path);
+
+ const SclDataSet* match{};
+ std::size_t match_count{};
+ for (const auto& data_set : data_sets) {
+ if (same(data_set.ied_name, ied_name) &&
+ same(data_set.ld_inst, parts.ld_inst) &&
+ same(data_set.logical_node_path, parts.logical_node_path) &&
+ same(data_set.name, parts.local_name)) {
+ ++match_count;
+ if (match_count == 1U) {
+ match = &data_set;
+ }
+ if (match_count > 1U) {
+ break;
+ }
+ }
+ }
+
+ if (match_count == 1U && match != nullptr) {
+ return {
+ match->entries.empty()
+ ? SclDataSetBindingStatus::resolved_empty
+ : SclDataSetBindingStatus::resolved,
+ match,
+ match->reference,
+ match->name,
+ };
+ }
+
+ return {
+ SclDataSetBindingStatus::unresolved,
+ nullptr,
+ build_canonical_reference(
+ ied_name,
+ parts.ld_inst,
+ parts.logical_node_path,
+ parts.local_name),
+ parts.local_name,
+ };
+}
+
+} // namespace ar::iec61850::scl
diff --git a/src/scl/parser.cpp b/src/scl/parser.cpp
new file mode 100644
index 000000000..deb0301cf
--- /dev/null
+++ b/src/scl/parser.cpp
@@ -0,0 +1,8 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "parser_part_00.inc"
+#include "parser_part_01.inc"
+#include "parser_part_02.inc"
+#include "parser_part_03.inc"
+#include "parser_part_04.inc"
+#include "parser_part_05.inc"
diff --git a/src/scl/parser_part_00.inc b/src/scl/parser_part_00.inc
new file mode 100644
index 000000000..c66e19412
--- /dev/null
+++ b/src/scl/parser_part_00.inc
@@ -0,0 +1,225 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "ariec61850/scl/parser.hpp"
+
+#include "ariec61850/scl/dataset_reference.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace ar::iec61850::scl {
+namespace {
+
+constexpr std::size_t max_xml_bytes = 64U * 1024U * 1024U;
+constexpr std::size_t max_xml_depth = 128U;
+constexpr std::size_t max_xml_nodes = 500000U;
+constexpr std::size_t max_attributes_per_element = 256U;
+
+std::string trim_copy(const std::string_view text) {
+ auto first = text.begin();
+ while (first != text.end() && std::isspace(static_cast(*first)) != 0) {
+ ++first;
+ }
+ auto last = text.end();
+ while (last != first && std::isspace(static_cast(*(last - 1))) != 0) {
+ --last;
+ }
+ return {first, last};
+}
+
+std::string lower_copy(const std::string_view text) {
+ std::string result;
+ result.reserve(text.size());
+ for (const char ch : text) {
+ result.push_back(static_cast(std::tolower(static_cast(ch))));
+ }
+ return result;
+}
+
+std::string upper_copy(const std::string_view text) {
+ std::string result;
+ result.reserve(text.size());
+ for (const char ch : text) {
+ result.push_back(static_cast(std::toupper(static_cast(ch))));
+ }
+ return result;
+}
+
+bool same(const std::string_view left, const std::string_view right) {
+ return lower_copy(trim_copy(left)) == lower_copy(trim_copy(right));
+}
+
+std::string_view local_name(const std::string_view qualified_name) {
+ const auto separator = qualified_name.find_last_of(':');
+ return separator == std::string_view::npos
+ ? qualified_name
+ : qualified_name.substr(separator + 1U);
+}
+
+struct XmlAttribute final {
+ std::string name;
+ std::string value;
+};
+
+struct XmlNode final {
+ std::string name;
+ std::vector attributes;
+ std::string text;
+ std::vector children;
+};
+
+class SecureXmlParser final {
+public:
+ explicit SecureXmlParser(const std::string_view input) : input_(input) {
+ if (input_.size() > max_xml_bytes) {
+ throw std::runtime_error("SCL XML exceeds the 64 MiB parser limit.");
+ }
+ }
+
+ [[nodiscard]] XmlNode parse_document() {
+ skip_utf8_bom();
+ skip_misc();
+ if (position_ >= input_.size()) {
+ throw std::runtime_error("SCL XML is empty.");
+ }
+ auto root = parse_element(0U);
+ skip_misc();
+ if (position_ != input_.size()) {
+ fail("Unexpected data after the root element.");
+ }
+ return root;
+ }
+
+private:
+ std::string_view input_;
+ std::size_t position_{};
+ std::size_t node_count_{};
+
+ [[noreturn]] void fail(const std::string& message) const {
+ throw std::runtime_error(message + " XML byte offset " + std::to_string(position_) + ".");
+ }
+
+ [[nodiscard]] bool starts_with(const std::string_view text) const noexcept {
+ return input_.substr(position_).starts_with(text);
+ }
+
+ void skip_utf8_bom() noexcept {
+ if (input_.size() >= 3U &&
+ static_cast(input_[0]) == 0xEFU &&
+ static_cast(input_[1]) == 0xBBU &&
+ static_cast(input_[2]) == 0xBFU) {
+ position_ = 3U;
+ }
+ }
+
+ void skip_whitespace() noexcept {
+ while (position_ < input_.size() &&
+ std::isspace(static_cast(input_[position_])) != 0) {
+ ++position_;
+ }
+ }
+
+ void skip_misc() {
+ while (true) {
+ skip_whitespace();
+ if (starts_with("")) {
+ skip_processing_instruction();
+ continue;
+ }
+ if (starts_with("", position_ + 4U);
+ if (end == std::string_view::npos) {
+ fail("Unterminated XML comment");
+ }
+ position_ = end + 3U;
+ }
+
+ [[nodiscard]] static bool is_name_start(const char ch) noexcept {
+ const auto value = static_cast(ch);
+ return std::isalpha(value) != 0 || ch == '_' || ch == ':';
+ }
+
+ [[nodiscard]] static bool is_name_char(const char ch) noexcept {
+ const auto value = static_cast(ch);
+ return std::isalnum(value) != 0 || ch == '_' || ch == ':' || ch == '-' || ch == '.';
+ }
+
+ [[nodiscard]] std::string parse_name() {
+ if (position_ >= input_.size() || !is_name_start(input_[position_])) {
+ fail("Expected XML name");
+ }
+ const auto start = position_++;
+ while (position_ < input_.size() && is_name_char(input_[position_])) {
+ ++position_;
+ }
+ return std::string{input_.substr(start, position_ - start)};
+ }
+
+ [[nodiscard]] static char32_t parse_code_point(
+ const std::string_view digits,
+ const int base) {
+ if (digits.empty()) {
+ throw std::runtime_error("Empty numeric XML entity.");
+ }
+ std::uint32_t value{};
+ const auto result = std::from_chars(digits.data(), digits.data() + digits.size(), value, base);
+ if (result.ec != std::errc{} || result.ptr != digits.data() + digits.size() ||
+ value > 0x10FFFFU || (value >= 0xD800U && value <= 0xDFFFU) || value == 0U) {
+ throw std::runtime_error("Invalid numeric XML entity.");
+ }
+ return static_cast(value);
+ }
+
+ static void append_utf8(std::string& output, const char32_t code_point) {
+ const auto value = static_cast(code_point);
+ if (value <= 0x7FU) {
+ output.push_back(static_cast(value));
+ } else if (value <= 0x7FFU) {
+ output.push_back(static_cast(0xC0U | (value >> 6U)));
+ output.push_back(static_cast(0x80U | (value & 0x3FU)));
+ } else if (value <= 0xFFFFU) {
+ output.push_back(static_cast(0xE0U | (value >> 12U)));
+ output.push_back(static_cast(0x80U | ((value >> 6U) & 0x3FU)));
+ output.push_back(static_cast(0x80U | (value & 0x3FU)));
+ } else {
+ output.push_back(static_cast(0xF0U | (value >> 18U)));
+ output.push_back(static_cast(0x80U | ((value >> 12U) & 0x3FU)));
+ output.push_back(static_cast(0x80U | ((value >> 6U) & 0x3FU)));
+ output.push_back(static_cast(0x80U | (value & 0x3FU)));
+ }
+ }
+
diff --git a/src/scl/parser_part_01.inc b/src/scl/parser_part_01.inc
new file mode 100644
index 000000000..4985a59c8
--- /dev/null
+++ b/src/scl/parser_part_01.inc
@@ -0,0 +1,225 @@
+ [[nodiscard]] std::string decode_entities(const std::string_view raw) const {
+ std::string output;
+ output.reserve(raw.size());
+ std::size_t offset{};
+ while (offset < raw.size()) {
+ if (raw[offset] != '&') {
+ output.push_back(raw[offset++]);
+ continue;
+ }
+
+ const auto semicolon = raw.find(';', offset + 1U);
+ if (semicolon == std::string_view::npos) {
+ throw std::runtime_error("Unterminated XML entity reference.");
+ }
+ const auto entity = raw.substr(offset + 1U, semicolon - offset - 1U);
+ if (entity == "amp") {
+ output.push_back('&');
+ } else if (entity == "lt") {
+ output.push_back('<');
+ } else if (entity == "gt") {
+ output.push_back('>');
+ } else if (entity == "quot") {
+ output.push_back('"');
+ } else if (entity == "apos") {
+ output.push_back('\'');
+ } else if (entity.starts_with("#x") || entity.starts_with("#X")) {
+ append_utf8(output, parse_code_point(entity.substr(2U), 16));
+ } else if (entity.starts_with('#')) {
+ append_utf8(output, parse_code_point(entity.substr(1U), 10));
+ } else {
+ throw std::runtime_error(
+ "Named XML entities other than the five predefined entities are prohibited.");
+ }
+ offset = semicolon + 1U;
+ }
+ return output;
+ }
+
+ [[nodiscard]] std::string parse_quoted_value() {
+ if (position_ >= input_.size() ||
+ (input_[position_] != '"' && input_[position_] != '\'')) {
+ fail("Expected quoted XML attribute value");
+ }
+ const char quote = input_[position_++];
+ const auto start = position_;
+ while (position_ < input_.size() && input_[position_] != quote) {
+ if (input_[position_] == '<') {
+ fail("XML attribute value contains '<'");
+ }
+ ++position_;
+ }
+ if (position_ >= input_.size()) {
+ fail("Unterminated XML attribute value");
+ }
+ const auto raw = input_.substr(start, position_ - start);
+ ++position_;
+ return decode_entities(raw);
+ }
+
+ [[nodiscard]] XmlNode parse_element(const std::size_t depth) {
+ if (depth > max_xml_depth) {
+ fail("XML nesting exceeds the supported depth");
+ }
+ if (++node_count_ > max_xml_nodes) {
+ fail("XML document exceeds the supported node count");
+ }
+ if (position_ >= input_.size() || input_[position_] != '<' || starts_with("")) {
+ fail("Expected XML start element");
+ }
+ ++position_;
+
+ if (position_ < input_.size() && input_[position_] == '!') {
+ fail("DTD, entity declarations, and unsupported markup are prohibited");
+ }
+
+ XmlNode node;
+ node.name = parse_name();
+
+ while (true) {
+ skip_whitespace();
+ if (starts_with("/>")) {
+ position_ += 2U;
+ return node;
+ }
+ if (position_ < input_.size() && input_[position_] == '>') {
+ ++position_;
+ break;
+ }
+ if (position_ >= input_.size()) {
+ fail("Unterminated XML start element");
+ }
+ if (node.attributes.size() >= max_attributes_per_element) {
+ fail("XML element exceeds the supported attribute count");
+ }
+
+ XmlAttribute attribute;
+ attribute.name = parse_name();
+ skip_whitespace();
+ if (position_ >= input_.size() || input_[position_] != '=') {
+ fail("Expected '=' after XML attribute name");
+ }
+ ++position_;
+ skip_whitespace();
+ attribute.value = parse_quoted_value();
+
+ const auto duplicate = std::find_if(
+ node.attributes.begin(),
+ node.attributes.end(),
+ [&attribute](const XmlAttribute& existing) {
+ return existing.name == attribute.name;
+ });
+ if (duplicate != node.attributes.end()) {
+ fail("Duplicate XML attribute '" + attribute.name + "'");
+ }
+ node.attributes.push_back(std::move(attribute));
+ }
+
+ while (true) {
+ if (position_ >= input_.size()) {
+ fail("Unterminated XML element '" + node.name + "'");
+ }
+ if (starts_with("")) {
+ position_ += 2U;
+ const auto closing_name = parse_name();
+ skip_whitespace();
+ if (position_ >= input_.size() || input_[position_] != '>') {
+ fail("Expected '>' after XML closing element");
+ }
+ ++position_;
+ if (closing_name != node.name) {
+ fail("Mismatched XML closing element '" + closing_name + "' for '" + node.name + "'");
+ }
+ return node;
+ }
+ if (starts_with("