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("", position_ + 2U); + if (end == std::string_view::npos) { + fail("Unterminated processing instruction"); + } + position_ = end + 2U; + } + + void skip_comment() { + const auto end = input_.find("-->", 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("")) { + 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("= 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("