diff --git a/CMakeLists.txt b/CMakeLists.txt index d03e22d5d..d4262e7d4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,7 @@ add_library(ariec61850_core src/control/mms_association_control_transport.cpp src/sampled_values/pdu_codec.cpp src/sampled_values/frame_codec.cpp + src/sampled_values/compiled_device_profile.cpp src/sampled_values/quality.cpp src/sampled_values/payload_inspector.cpp src/sampled_values/sample_counter.cpp diff --git a/include/ariec61850/integrity/crc32.hpp b/include/ariec61850/integrity/crc32.hpp new file mode 100644 index 000000000..723eeb7f6 --- /dev/null +++ b/include/ariec61850/integrity/crc32.hpp @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +namespace ar::iec61850::integrity { + +inline constexpr std::uint32_t crc32_initial_state = 0xFFFF'FFFFU; + +[[nodiscard]] inline std::uint32_t crc32_update( + std::uint32_t state, + const std::span bytes) noexcept { + for (const auto byte : bytes) { + state ^= byte; + for (std::uint8_t bit = 0U; bit < 8U; ++bit) { + const auto mask = static_cast( + 0U - static_cast(state & 1U)); + state = (state >> 1U) ^ (0xEDB88320U & mask); + } + } + return state; +} + +[[nodiscard]] constexpr std::uint32_t crc32_finalize( + const std::uint32_t state) noexcept { + return ~state; +} + +[[nodiscard]] inline std::uint32_t crc32( + const std::span bytes) noexcept { + return crc32_finalize(crc32_update(crc32_initial_state, bytes)); +} + +} // namespace ar::iec61850::integrity diff --git a/include/ariec61850/sampled_values/compiled_device_profile.hpp b/include/ariec61850/sampled_values/compiled_device_profile.hpp new file mode 100644 index 000000000..09f77849d --- /dev/null +++ b/include/ariec61850/sampled_values/compiled_device_profile.hpp @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/publisher_profile.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +inline constexpr std::uint16_t compiled_sv_device_profile_version = 1U; +inline constexpr std::size_t compiled_sv_device_profile_max_sv_id_bytes = 95U; +inline constexpr std::size_t compiled_sv_device_profile_max_dataset_bytes = 159U; +inline constexpr std::size_t compiled_sv_device_profile_max_leaves = 64U; + +enum class SvDeviceWireType : std::uint8_t { + boolean = 1U, + int8 = 2U, + uint8 = 3U, + int16 = 4U, + uint16 = 5U, + int32 = 6U, + uint32 = 7U, + int64 = 8U, + uint64 = 9U, + float32 = 10U, + float64 = 11U, + quality = 12U, + timestamp = 13U, +}; + +struct SvDeviceLeafDescriptor final { + SvDeviceWireType wire_type{SvDeviceWireType::int32}; + std::uint16_t wire_width_bytes{}; + bool quality{}; + bool timestamp{}; + + friend bool operator==(const SvDeviceLeafDescriptor&, + const SvDeviceLeafDescriptor&) = default; +}; + +struct CompiledSvDeviceProfile final { + std::uint16_t schema_version{compiled_sv_device_profile_version}; + SvProfileFamily profile_family{SvProfileFamily::unspecified}; + SvTransportMode transport_mode{SvTransportMode::multicast}; + + std::array destination_mac{}; + std::uint16_t app_id{}; + bool vlan_present{}; + std::uint16_t vlan_id{}; + std::uint8_t vlan_priority{}; + + std::uint32_t configuration_revision{}; + SvSampleMode sampling_basis{SvSampleMode::unknown}; + std::uint32_t configured_sample_rate{}; + std::uint32_t frame_rate_hz{}; + std::uint16_t sample_counter_modulus{}; + std::uint16_t no_asdu{}; + + SvAsduOptions asdu_options{}; + std::uint32_t payload_size_bytes{}; + + std::uint16_t sv_id_length{}; + std::array sv_id{}; + std::uint16_t data_set_reference_length{}; + std::array + data_set_reference{}; + + std::uint16_t leaf_count{}; + std::array leaves{}; + + friend bool operator==(const CompiledSvDeviceProfile&, + const CompiledSvDeviceProfile&) = default; +}; + +struct SvDeviceProfileCompileResult final { + std::optional profile; + std::vector errors; + + [[nodiscard]] bool ok() const noexcept { + return profile.has_value() && errors.empty(); + } +}; + +[[nodiscard]] SvDeviceProfileCompileResult +compile_esp32p4_device_profile(const SvPublisherProfile& profile); + +enum class SvDeviceProfileCodecStatus : std::uint8_t { + ok, + buffer_too_small, + invalid_value, + invalid_magic, + unsupported_version, + invalid_length, + checksum_mismatch, +}; + +struct SvDeviceProfileCodecResult final { + SvDeviceProfileCodecStatus status{SvDeviceProfileCodecStatus::invalid_value}; + std::size_t bytes{}; + + [[nodiscard]] bool success() const noexcept { + return status == SvDeviceProfileCodecStatus::ok; + } +}; + +class SvDeviceProfileBinaryCodec final { +public: + static constexpr std::size_t header_bytes = 20U; + static constexpr std::size_t fixed_payload_bytes = 42U; + static constexpr std::size_t leaf_descriptor_bytes = 4U; + + [[nodiscard]] static std::optional encoded_size( + const CompiledSvDeviceProfile& profile) noexcept; + + [[nodiscard]] static SvDeviceProfileCodecResult encode_into( + const CompiledSvDeviceProfile& profile, + std::span destination) noexcept; + + [[nodiscard]] static SvDeviceProfileCodecResult decode( + std::span source, + CompiledSvDeviceProfile& profile) noexcept; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/src/mms/static_brcb_checkpoint_store.cpp b/src/mms/static_brcb_checkpoint_store.cpp index 0afc180f2..29987156b 100644 --- a/src/mms/static_brcb_checkpoint_store.cpp +++ b/src/mms/static_brcb_checkpoint_store.cpp @@ -2,6 +2,7 @@ #include "ariec61850/mms/static_brcb_checkpoint_store.hpp" +#include "ariec61850/integrity/crc32.hpp" #include "ariec61850/mms/static_brcb_runtime.hpp" #include @@ -84,25 +85,6 @@ void write_u64( return value; } -[[nodiscard]] std::uint32_t crc32_update( - std::uint32_t state, - const std::span bytes) noexcept { - for (const auto byte : bytes) { - state ^= byte; - for (std::uint8_t bit = 0U; bit < 8U; ++bit) { - const auto mask = static_cast( - 0U - static_cast(state & 1U)); - state = (state >> 1U) ^ (0xEDB88320U & mask); - } - } - return state; -} - -[[nodiscard]] std::uint32_t crc32( - const std::span bytes) noexcept { - return ~crc32_update(0xFFFF'FFFFU, bytes); -} - [[nodiscard]] bool generation_newer( const std::uint64_t left, const std::uint64_t right) noexcept { @@ -131,7 +113,7 @@ void write_u64( std::uint32_t& result) noexcept { std::array chunk{}; std::size_t consumed = 0U; - std::uint32_t state = 0xFFFF'FFFFU; + std::uint32_t state = integrity::crc32_initial_state; while (consumed < payload_bytes) { const auto remaining = payload_bytes - consumed; const auto bytes = std::min(remaining, chunk.size()); @@ -141,12 +123,12 @@ void write_u64( std::span{chunk}.first(bytes))) { return false; } - state = crc32_update( + state = integrity::crc32_update( state, std::span{chunk}.first(bytes)); consumed += bytes; } - result = ~state; + result = integrity::crc32_finalize(state); return true; } @@ -168,9 +150,9 @@ void write_u64( if (!std::equal(kHeaderMagic.begin(), kHeaderMagic.end(), header.begin()) || read_u16(header, 8U) != MmsStaticBrcbCheckpointStore::format_version || read_u16(header, 10U) != MmsStaticBrcbCheckpointStore::record_header_bytes || - crc32(std::span{header}.first(36U)) != read_u32(header, 36U) || + integrity::crc32(std::span{header}.first(36U)) != read_u32(header, 36U) || !std::equal(kFooterMagic.begin(), kFooterMagic.end(), footer.begin()) || - crc32(std::span{footer}.first(28U)) != read_u32(footer, 28U)) { + integrity::crc32(std::span{footer}.first(28U)) != read_u32(footer, 28U)) { return info; } for (std::size_t index = 28U; index < 36U; ++index) { @@ -232,7 +214,7 @@ void build_header( write_u64(header, 12U, generation); write_u32(header, 20U, payload_bytes); write_u32(header, 24U, payload_crc); - write_u32(header, 36U, crc32(std::span{header}.first(36U))); + write_u32(header, 36U, integrity::crc32(std::span{header}.first(36U))); } void build_footer( @@ -245,7 +227,7 @@ void build_footer( write_u64(footer, 8U, generation); write_u32(footer, 16U, payload_bytes); write_u32(footer, 20U, payload_crc); - write_u32(footer, 28U, crc32(std::span{footer}.first(28U))); + write_u32(footer, 28U, integrity::crc32(std::span{footer}.first(28U))); } } // namespace @@ -303,7 +285,7 @@ MmsStaticBrcbCheckpointResult MmsStaticBrcbCheckpointStore::checkpoint( } const auto generation = next_generation(current_generation); const auto payload_bytes = static_cast(state.bytes_written); - const auto payload_crc = crc32(state_buffer.first(state.bytes_written)); + const auto payload_crc = integrity::crc32(state_buffer.first(state.bytes_written)); std::array header{}; std::array footer{}; @@ -395,7 +377,7 @@ MmsStaticBrcbCheckpointResult MmsStaticBrcbCheckpointStore::restore( backend_.context, base + record_header_bytes, payload) || - crc32(payload) != info.payload_crc) { + integrity::crc32(payload) != info.payload_crc) { return MmsStaticBrcbCheckpointStatus::storage_failure; } return MmsStaticBrcbCheckpointStatus::ok; diff --git a/src/sampled_values/compiled_device_profile.cpp b/src/sampled_values/compiled_device_profile.cpp new file mode 100644 index 000000000..c25647959 --- /dev/null +++ b/src/sampled_values/compiled_device_profile.cpp @@ -0,0 +1,588 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/sampled_values/compiled_device_profile.hpp" + +#include "ariec61850/integrity/crc32.hpp" +#include "ariec61850/sampled_values/esp32p4_profile_support.hpp" +#include "ariec61850/sampled_values/profile_family.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { +namespace { + +constexpr std::array kMagic{'A', 'R', 'S', 'V'}; +constexpr std::size_t kCrcOffset = 16U; +constexpr std::uint16_t kFlagVlan = 1U << 0U; +constexpr std::uint16_t kFlagDataSet = 1U << 1U; +constexpr std::uint16_t kFlagSampleRate = 1U << 2U; +constexpr std::uint16_t kFlagSampleSynchronized = 1U << 3U; +constexpr std::uint16_t kFlagRefreshTime = 1U << 4U; +constexpr std::uint16_t kFlagSecurity = 1U << 5U; +constexpr std::uint16_t kFlagSynchSourceId = 1U << 6U; +constexpr std::uint16_t kFlagSmvOptsPresent = 1U << 7U; +constexpr std::uint16_t kKnownFlags = + kFlagVlan | kFlagDataSet | kFlagSampleRate | kFlagSampleSynchronized | + kFlagRefreshTime | kFlagSecurity | kFlagSynchSourceId | kFlagSmvOptsPresent; + +void write_u16( + const std::span out, + const std::size_t offset, + const std::uint16_t value) noexcept { + out[offset] = static_cast((value >> 8U) & 0xFFU); + out[offset + 1U] = static_cast(value & 0xFFU); +} + +void write_u32( + const std::span out, + const std::size_t offset, + const std::uint32_t value) noexcept { + for (std::size_t index = 0U; index < 4U; ++index) { + const auto shift = static_cast((3U - index) * 8U); + out[offset + index] = + static_cast((value >> shift) & 0xFFU); + } +} + +[[nodiscard]] std::uint16_t read_u16( + const std::span in, + const std::size_t offset) noexcept { + return static_cast( + (static_cast(in[offset]) << 8U) | + static_cast(in[offset + 1U])); +} + +[[nodiscard]] std::uint32_t read_u32( + const std::span in, + const std::size_t offset) noexcept { + std::uint32_t result{}; + for (std::size_t index = 0U; index < 4U; ++index) { + result = static_cast( + (result << 8U) | static_cast(in[offset + index])); + } + return result; +} + +[[nodiscard]] std::uint16_t flags_for( + const CompiledSvDeviceProfile& profile) noexcept { + std::uint16_t flags{}; + if (profile.vlan_present) flags |= kFlagVlan; + if (profile.asdu_options.data_set) flags |= kFlagDataSet; + if (profile.asdu_options.sample_rate) flags |= kFlagSampleRate; + if (profile.asdu_options.sample_synchronized) flags |= kFlagSampleSynchronized; + if (profile.asdu_options.refresh_time) flags |= kFlagRefreshTime; + if (profile.asdu_options.security) flags |= kFlagSecurity; + if (profile.asdu_options.synch_source_id) flags |= kFlagSynchSourceId; + if (profile.asdu_options.element_present) flags |= kFlagSmvOptsPresent; + return flags; +} + +[[nodiscard]] bool contains_nul(const std::string_view text) noexcept { + return text.find('\0') != std::string_view::npos; +} + +[[nodiscard]] bool valid_wire_type(const SvDeviceWireType type) noexcept { + const auto raw = static_cast(type); + return raw >= static_cast(SvDeviceWireType::boolean) && + raw <= static_cast(SvDeviceWireType::timestamp); +} + +[[nodiscard]] std::optional canonical_wire_width( + const SvDeviceWireType type) noexcept { + switch (type) { + case SvDeviceWireType::boolean: + case SvDeviceWireType::int8: + case SvDeviceWireType::uint8: + return static_cast(1U); + case SvDeviceWireType::int16: + case SvDeviceWireType::uint16: + return static_cast(2U); + case SvDeviceWireType::int32: + case SvDeviceWireType::uint32: + case SvDeviceWireType::float32: + case SvDeviceWireType::quality: + return static_cast(4U); + case SvDeviceWireType::int64: + case SvDeviceWireType::uint64: + case SvDeviceWireType::float64: + case SvDeviceWireType::timestamp: + return static_cast(8U); + } + return std::nullopt; +} + +[[nodiscard]] bool valid_profile_shape( + const CompiledSvDeviceProfile& profile) noexcept { + // Fail closed on unknown values; never recode an unknown mode as unicast. + if ((profile.profile_family != SvProfileFamily::iec61850_9_2 && + profile.profile_family != SvProfileFamily::legacy_9_2le && + profile.profile_family != SvProfileFamily::iec61869_9) || + (profile.transport_mode != SvTransportMode::multicast && + profile.transport_mode != SvTransportMode::unicast) || + (profile.sampling_basis != SvSampleMode::samples_per_second && + profile.sampling_basis != SvSampleMode::samples_per_period && + profile.sampling_basis != SvSampleMode::seconds_per_sample)) { + return false; + } + if (profile.schema_version != compiled_sv_device_profile_version || + profile.profile_family == SvProfileFamily::unspecified || + profile.app_id == 0U || + profile.frame_rate_hz == 0U || + profile.sample_counter_modulus == 0U || + profile.no_asdu == 0U || + profile.sv_id_length == 0U || + profile.sv_id_length > compiled_sv_device_profile_max_sv_id_bytes || + profile.data_set_reference_length > + compiled_sv_device_profile_max_dataset_bytes || + profile.leaf_count == 0U || + profile.leaf_count > compiled_sv_device_profile_max_leaves) { + return false; + } + if (profile.asdu_options.data_set && + profile.data_set_reference_length == 0U) { + return false; + } + if (!profile.asdu_options.element_present && + (profile.asdu_options.refresh_time || + profile.asdu_options.sample_synchronized || + profile.asdu_options.sample_rate || + profile.asdu_options.data_set || + profile.asdu_options.security || + profile.asdu_options.synch_source_id)) { + return false; + } + if (!profile.vlan_present && + (profile.vlan_id != 0U || profile.vlan_priority != 0U)) { + return false; + } + const std::string_view sv_id{ + profile.sv_id.data(), profile.sv_id_length}; + const std::string_view data_set{ + profile.data_set_reference.data(), profile.data_set_reference_length}; + if (contains_nul(sv_id) || contains_nul(data_set)) { + return false; + } + if (!is_valid_sv_app_id(profile.app_id)) return false; + if (profile.transport_mode == SvTransportMode::multicast) { + if (!is_iec_sv_multicast_mac(profile.destination_mac)) return false; + } else if (!is_valid_sv_unicast_mac(profile.destination_mac)) { + return false; + } + if (profile.vlan_present && + (!ethernet::is_valid_vlan_id(profile.vlan_id) || + !ethernet::is_valid_vlan_priority(profile.vlan_priority))) { + return false; + } + if (profile.sampling_basis == SvSampleMode::unknown || + profile.configured_sample_rate == 0U) { + return false; + } + + std::uint64_t payload{}; + for (std::size_t index = 0U; index < profile.leaf_count; ++index) { + const auto& leaf = profile.leaves[index]; + const auto expected_width = canonical_wire_width(leaf.wire_type); + if (!expected_width.has_value() || + leaf.wire_width_bytes != *expected_width || + leaf.quality != (leaf.wire_type == SvDeviceWireType::quality) || + leaf.timestamp != (leaf.wire_type == SvDeviceWireType::timestamp)) { + return false; + } + payload += leaf.wire_width_bytes; + if (payload > std::numeric_limits::max()) return false; + } + return payload == profile.payload_size_bytes; +} + +[[nodiscard]] std::uint32_t record_crc( + const std::span bytes) noexcept { + if (bytes.size() < SvDeviceProfileBinaryCodec::header_bytes) return 0U; + std::uint32_t state = integrity::crc32_initial_state; + state = integrity::crc32_update(state, bytes.first(kCrcOffset)); + constexpr std::array zero_crc{}; + state = integrity::crc32_update(state, zero_crc); + state = integrity::crc32_update( + state, bytes.subspan(kCrcOffset + zero_crc.size())); + return integrity::crc32_finalize(state); +} + +[[nodiscard]] std::optional wire_type_for( + const SvPublisherChannel& channel) { + if (channel.is_quality) return SvDeviceWireType::quality; + if (channel.is_timestamp) return SvDeviceWireType::timestamp; + + std::string type; + type.reserve(channel.basic_type.size()); + for (const char ch : channel.basic_type) { + type.push_back(static_cast( + std::tolower(static_cast(ch)))); + } + if (type == "boolean") return SvDeviceWireType::boolean; + if (type == "int8") return SvDeviceWireType::int8; + if (type == "int8u") return SvDeviceWireType::uint8; + if (type == "int16") return SvDeviceWireType::int16; + if (type == "int16u") return SvDeviceWireType::uint16; + if (type == "int32") return SvDeviceWireType::int32; + if (type == "int32u") return SvDeviceWireType::uint32; + if (type == "int64") return SvDeviceWireType::int64; + if (type == "int64u") return SvDeviceWireType::uint64; + if (type == "float32") return SvDeviceWireType::float32; + if (type == "float64") return SvDeviceWireType::float64; + return std::nullopt; +} + +template +bool copy_text( + const std::string_view source, + std::array& destination, + std::uint16_t& length) noexcept { + if (source.size() >= N || + source.size() > std::numeric_limits::max() || + contains_nul(source)) { + return false; + } + destination.fill('\0'); + std::copy(source.begin(), source.end(), destination.begin()); + length = static_cast(source.size()); + return true; +} + +[[nodiscard]] bool decode_profile_family( + const std::uint8_t raw, + SvProfileFamily& value) noexcept { + switch (raw) { + case 1U: value = SvProfileFamily::iec61850_9_2; return true; + case 2U: value = SvProfileFamily::legacy_9_2le; return true; + case 3U: value = SvProfileFamily::iec61869_9; return true; + default: return false; + } +} + +[[nodiscard]] std::uint8_t encode_profile_family( + const SvProfileFamily value) noexcept { + switch (value) { + case SvProfileFamily::iec61850_9_2: return 1U; + case SvProfileFamily::legacy_9_2le: return 2U; + case SvProfileFamily::iec61869_9: return 3U; + case SvProfileFamily::unspecified: return 0U; + } + return 0U; +} + +[[nodiscard]] bool decode_sampling_basis( + const std::uint8_t raw, + SvSampleMode& value) noexcept { + switch (raw) { + case 1U: value = SvSampleMode::samples_per_second; return true; + case 2U: value = SvSampleMode::samples_per_period; return true; + case 3U: value = SvSampleMode::seconds_per_sample; return true; + default: return false; + } +} + +[[nodiscard]] std::uint8_t encode_sampling_basis( + const SvSampleMode value) noexcept { + switch (value) { + case SvSampleMode::samples_per_second: return 1U; + case SvSampleMode::samples_per_period: return 2U; + case SvSampleMode::seconds_per_sample: return 3U; + case SvSampleMode::unknown: return 0U; + } + return 0U; +} + +} // namespace + +SvDeviceProfileCompileResult compile_esp32p4_device_profile( + const SvPublisherProfile& source) { + SvDeviceProfileCompileResult result; + if (classify_esp32p4_sv_profile(source) != Esp32P4SvProfileSupport::ready) { + result.errors.emplace_back( + "SV profile is not deployable on the current ESP32-P4 capability contract."); + return result; + } + + const auto frame_rate = source.timing.exact_frame_rate_hz(); + if (!frame_rate.has_value() || *frame_rate == 0U) { + result.errors.emplace_back( + "ESP32-P4 device profile requires a resolved integral frame cadence."); + return result; + } + if (source.sample_counter_policy != SvSampleCounterPolicy::explicit_modulus || + !source.sample_counter_modulus.has_value() || + *source.sample_counter_modulus == 0U) { + result.errors.emplace_back( + "ESP32-P4 device profile requires an explicit validated sample-counter modulus."); + return result; + } + if (source.channels.size() > compiled_sv_device_profile_max_leaves || + source.payload_size_bytes > std::numeric_limits::max()) { + result.errors.emplace_back( + "SV profile exceeds the bounded device-profile layout capacity."); + return result; + } + + CompiledSvDeviceProfile profile; + profile.profile_family = source.profile_family; + profile.transport_mode = source.transport_mode; + profile.destination_mac = source.destination_mac; + profile.app_id = source.app_id; + profile.vlan_present = source.vlan_present; + profile.vlan_id = source.vlan_id; + profile.vlan_priority = source.vlan_priority; + profile.configuration_revision = source.configuration_revision; + profile.sampling_basis = source.timing.sampling_basis; + profile.configured_sample_rate = source.timing.configured_sample_rate; + profile.frame_rate_hz = *frame_rate; + profile.sample_counter_modulus = *source.sample_counter_modulus; + profile.no_asdu = source.timing.asdus_per_frame; + profile.asdu_options = source.asdu_options; + profile.payload_size_bytes = + static_cast(source.payload_size_bytes); + + if (!copy_text(source.sv_id, profile.sv_id, profile.sv_id_length) || + !copy_text( + source.data_set_reference, + profile.data_set_reference, + profile.data_set_reference_length)) { + result.errors.emplace_back( + "SV identifiers exceed the bounded device-profile string capacity or contain NUL."); + return result; + } + + profile.leaf_count = static_cast(source.channels.size()); + for (std::size_t index = 0U; index < source.channels.size(); ++index) { + const auto type = wire_type_for(source.channels[index]); + if (!type.has_value()) { + result.errors.push_back( + "SV device profile cannot encode leaf type '" + + source.channels[index].basic_type + "'."); + return result; + } + profile.leaves[index] = { + *type, + source.channels[index].wire_width_bytes, + source.channels[index].is_quality, + source.channels[index].is_timestamp, + }; + } + + if (!valid_profile_shape(profile)) { + result.errors.emplace_back( + "Compiled ESP32-P4 profile failed the canonical binary-profile invariant."); + return result; + } + result.profile = profile; + return result; +} + +std::optional SvDeviceProfileBinaryCodec::encoded_size( + const CompiledSvDeviceProfile& profile) noexcept { + if (!valid_profile_shape(profile)) return std::nullopt; + + const std::size_t leaf_bytes = + static_cast(profile.leaf_count) * leaf_descriptor_bytes; + const std::size_t payload = + fixed_payload_bytes + leaf_bytes + + profile.sv_id_length + profile.data_set_reference_length; + if (payload > std::numeric_limits::max() || + payload > std::numeric_limits::max() - header_bytes) { + return std::nullopt; + } + return header_bytes + payload; +} + +SvDeviceProfileCodecResult SvDeviceProfileBinaryCodec::encode_into( + const CompiledSvDeviceProfile& profile, + const std::span destination) noexcept { + const auto required = encoded_size(profile); + if (!required.has_value()) { + return {SvDeviceProfileCodecStatus::invalid_value, 0U}; + } + if (destination.size() < *required) { + return {SvDeviceProfileCodecStatus::buffer_too_small, *required}; + } + + auto out = destination.first(*required); + std::fill(out.begin(), out.end(), std::uint8_t{0U}); + std::copy(kMagic.begin(), kMagic.end(), out.begin()); + write_u16(out, 4U, compiled_sv_device_profile_version); + write_u16(out, 6U, static_cast(header_bytes)); + write_u32(out, 8U, static_cast(*required)); + write_u32( + out, 12U, + static_cast(*required - header_bytes)); + write_u32(out, kCrcOffset, 0U); + + const std::size_t base = header_bytes; + out[base + 0U] = encode_profile_family(profile.profile_family); + out[base + 1U] = profile.transport_mode == SvTransportMode::multicast ? 1U : 2U; + write_u16(out, base + 2U, flags_for(profile)); + std::copy( + profile.destination_mac.begin(), profile.destination_mac.end(), + out.begin() + static_cast(base + 4U)); + write_u16(out, base + 10U, profile.app_id); + write_u16(out, base + 12U, profile.vlan_id); + out[base + 14U] = profile.vlan_priority; + out[base + 15U] = encode_sampling_basis(profile.sampling_basis); + write_u16(out, base + 16U, profile.no_asdu); + write_u32(out, base + 18U, profile.configuration_revision); + write_u32(out, base + 22U, profile.configured_sample_rate); + write_u32(out, base + 26U, profile.frame_rate_hz); + write_u16(out, base + 30U, profile.sample_counter_modulus); + write_u16(out, base + 32U, profile.leaf_count); + write_u32(out, base + 34U, profile.payload_size_bytes); + write_u16(out, base + 38U, profile.sv_id_length); + write_u16(out, base + 40U, profile.data_set_reference_length); + + std::size_t offset = base + fixed_payload_bytes; + for (std::size_t index = 0U; index < profile.leaf_count; ++index) { + const auto& leaf = profile.leaves[index]; + out[offset] = static_cast(leaf.wire_type); + out[offset + 1U] = + static_cast((leaf.quality ? 1U : 0U) | + (leaf.timestamp ? 2U : 0U)); + write_u16(out, offset + 2U, leaf.wire_width_bytes); + offset += leaf_descriptor_bytes; + } + std::copy_n( + reinterpret_cast(profile.sv_id.data()), + profile.sv_id_length, + out.begin() + static_cast(offset)); + offset += profile.sv_id_length; + std::copy_n( + reinterpret_cast(profile.data_set_reference.data()), + profile.data_set_reference_length, + out.begin() + static_cast(offset)); + + write_u32(out, kCrcOffset, record_crc(out)); + return {SvDeviceProfileCodecStatus::ok, *required}; +} + +SvDeviceProfileCodecResult SvDeviceProfileBinaryCodec::decode( + const std::span source, + CompiledSvDeviceProfile& profile) noexcept { + profile = {}; + // Only commit a decoded profile when all validation has succeeded. + CompiledSvDeviceProfile candidate{}; + if (source.size() < header_bytes) { + return {SvDeviceProfileCodecStatus::invalid_length, 0U}; + } + if (!std::equal(kMagic.begin(), kMagic.end(), source.begin())) { + return {SvDeviceProfileCodecStatus::invalid_magic, 0U}; + } + if (read_u16(source, 4U) != compiled_sv_device_profile_version) { + return {SvDeviceProfileCodecStatus::unsupported_version, 0U}; + } + if (read_u16(source, 6U) != header_bytes) { + return {SvDeviceProfileCodecStatus::invalid_length, 0U}; + } + const auto total_length = read_u32(source, 8U); + const auto payload_length = read_u32(source, 12U); + if (total_length != source.size() || + payload_length != total_length - header_bytes || + payload_length < fixed_payload_bytes) { + return {SvDeviceProfileCodecStatus::invalid_length, 0U}; + } + if (record_crc(source) != read_u32(source, kCrcOffset)) { + return {SvDeviceProfileCodecStatus::checksum_mismatch, 0U}; + } + + const std::size_t base = header_bytes; + if (!decode_profile_family(source[base], candidate.profile_family)) { + return {SvDeviceProfileCodecStatus::invalid_value, 0U}; + } + if (source[base + 1U] == 1U) { + candidate.transport_mode = SvTransportMode::multicast; + } else if (source[base + 1U] == 2U) { + candidate.transport_mode = SvTransportMode::unicast; + } else { + return {SvDeviceProfileCodecStatus::invalid_value, 0U}; + } + const auto flags = read_u16(source, base + 2U); + if ((flags & static_cast(~kKnownFlags)) != 0U) { + return {SvDeviceProfileCodecStatus::invalid_value, 0U}; + } + candidate.vlan_present = (flags & kFlagVlan) != 0U; + candidate.asdu_options.data_set = (flags & kFlagDataSet) != 0U; + candidate.asdu_options.sample_rate = (flags & kFlagSampleRate) != 0U; + candidate.asdu_options.sample_synchronized = + (flags & kFlagSampleSynchronized) != 0U; + candidate.asdu_options.refresh_time = (flags & kFlagRefreshTime) != 0U; + candidate.asdu_options.security = (flags & kFlagSecurity) != 0U; + candidate.asdu_options.synch_source_id = (flags & kFlagSynchSourceId) != 0U; + candidate.asdu_options.element_present = (flags & kFlagSmvOptsPresent) != 0U; + + std::copy_n(source.begin() + static_cast(base + 4U), + candidate.destination_mac.size(), candidate.destination_mac.begin()); + candidate.app_id = read_u16(source, base + 10U); + candidate.vlan_id = read_u16(source, base + 12U); + candidate.vlan_priority = source[base + 14U]; + if (!decode_sampling_basis(source[base + 15U], candidate.sampling_basis)) { + return {SvDeviceProfileCodecStatus::invalid_value, 0U}; + } + candidate.no_asdu = read_u16(source, base + 16U); + candidate.configuration_revision = read_u32(source, base + 18U); + candidate.configured_sample_rate = read_u32(source, base + 22U); + candidate.frame_rate_hz = read_u32(source, base + 26U); + candidate.sample_counter_modulus = read_u16(source, base + 30U); + candidate.leaf_count = read_u16(source, base + 32U); + candidate.payload_size_bytes = read_u32(source, base + 34U); + candidate.sv_id_length = read_u16(source, base + 38U); + candidate.data_set_reference_length = read_u16(source, base + 40U); + + if (candidate.leaf_count > compiled_sv_device_profile_max_leaves || + candidate.sv_id_length > compiled_sv_device_profile_max_sv_id_bytes || + candidate.data_set_reference_length > + compiled_sv_device_profile_max_dataset_bytes) { + return {SvDeviceProfileCodecStatus::invalid_length, 0U}; + } + + const std::size_t descriptor_bytes = + static_cast(candidate.leaf_count) * leaf_descriptor_bytes; + const std::size_t expected_payload = + fixed_payload_bytes + descriptor_bytes + + candidate.sv_id_length + candidate.data_set_reference_length; + if (expected_payload != payload_length) { + return {SvDeviceProfileCodecStatus::invalid_length, 0U}; + } + + std::size_t offset = base + fixed_payload_bytes; + for (std::size_t index = 0U; index < candidate.leaf_count; ++index) { + const auto type = static_cast(source[offset]); + const auto leaf_flags = source[offset + 1U]; + if (!valid_wire_type(type) || (leaf_flags & 0xFCU) != 0U) { + return {SvDeviceProfileCodecStatus::invalid_value, 0U}; + } + candidate.leaves[index] = { + type, + read_u16(source, offset + 2U), + (leaf_flags & 1U) != 0U, + (leaf_flags & 2U) != 0U, + }; + offset += leaf_descriptor_bytes; + } + + std::copy_n( + source.begin() + static_cast(offset), + candidate.sv_id_length, + reinterpret_cast(candidate.sv_id.data())); + offset += candidate.sv_id_length; + std::copy_n( + source.begin() + static_cast(offset), + candidate.data_set_reference_length, + reinterpret_cast(candidate.data_set_reference.data())); + + if (!valid_profile_shape(candidate)) { + return {SvDeviceProfileCodecStatus::invalid_value, 0U}; + } + profile = candidate; + return {SvDeviceProfileCodecStatus::ok, source.size()}; +} + +} // namespace ar::iec61850::sampled_values diff --git a/tests/test_sampled_values.cpp b/tests/test_sampled_values.cpp index f7326cc08..956025f8b 100644 --- a/tests/test_sampled_values.cpp +++ b/tests/test_sampled_values.cpp @@ -1,8 +1,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/integrity/crc32.hpp" #include "ariec61850/mms/utc_time.hpp" #include "ariec61850/sampled_values/asdu.hpp" +#include "ariec61850/sampled_values/compiled_device_profile.hpp" #include "ariec61850/sampled_values/frame.hpp" #include "ariec61850/sampled_values/frame_codec.hpp" #include "ariec61850/sampled_values/payload_inspector.hpp" @@ -276,6 +278,137 @@ void sampled_values_codec_handles_multiple_asdus_and_rejects_malformed_input() { CHECK(!SampledValuesPduCodec::try_decode(bad_timestamp, decoded)); } +void compiled_device_profile_binary_is_canonical_and_integrity_checked() { + using namespace ar::iec61850::sampled_values; + + constexpr std::array crc_reference{ + '1','2','3','4','5','6','7','8','9'}; + CHECK(ar::iec61850::integrity::crc32(crc_reference) == 0xCBF43926U); + + CompiledSvDeviceProfile profile; + profile.profile_family = SvProfileFamily::iec61850_9_2; + profile.transport_mode = SvTransportMode::multicast; + profile.destination_mac = {0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; + profile.app_id = 0x4000U; + profile.vlan_present = true; + profile.vlan_id = 100U; + profile.vlan_priority = 4U; + profile.configuration_revision = 3U; + profile.sampling_basis = SvSampleMode::samples_per_second; + profile.configured_sample_rate = 4000U; + profile.frame_rate_hz = 4000U; + profile.sample_counter_modulus = 4000U; + profile.no_asdu = 1U; + profile.asdu_options.element_present = true; + profile.asdu_options.data_set = true; + profile.asdu_options.sample_synchronized = true; + profile.payload_size_bytes = 8U; + + constexpr std::string_view sv_id{"SV1"}; + std::copy(sv_id.begin(), sv_id.end(), profile.sv_id.begin()); + profile.sv_id_length = static_cast(sv_id.size()); + constexpr std::string_view data_set{"DS"}; + std::copy(data_set.begin(), data_set.end(), profile.data_set_reference.begin()); + profile.data_set_reference_length = + static_cast(data_set.size()); + + profile.leaf_count = 2U; + profile.leaves[0] = {SvDeviceWireType::int32, 4U, false, false}; + profile.leaves[1] = {SvDeviceWireType::quality, 4U, true, false}; + + const auto size = SvDeviceProfileBinaryCodec::encoded_size(profile); + CHECK(size == std::optional{75U}); + ByteVector encoded(*size); + const auto encoded_result = + SvDeviceProfileBinaryCodec::encode_into(profile, encoded); + CHECK(encoded_result.success()); + CHECK(encoded_result.bytes == encoded.size()); + CHECK(to_hex(encoded) == + "41525356000100140000004B00000037AAEB211E" + "0101008B010CCD040001400000640401000100000003" + "00000FA000000FA00FA000020000000800030002" + "060000040C0100045356314453"); + + CompiledSvDeviceProfile decoded; + const auto decoded_result = SvDeviceProfileBinaryCodec::decode(encoded, decoded); + CHECK(decoded_result.success()); + CHECK(decoded_result.bytes == encoded.size()); + CHECK(decoded == profile); + + // No unknown enum may be silently encoded into a valid different mode. + auto unknown_family = profile; + unknown_family.profile_family = static_cast(0xFFU); + CHECK(!SvDeviceProfileBinaryCodec::encoded_size(unknown_family).has_value()); + CHECK(SvDeviceProfileBinaryCodec::encode_into(unknown_family, encoded).status == + SvDeviceProfileCodecStatus::invalid_value); + auto unknown_transport = profile; + unknown_transport.transport_mode = static_cast(0xFFU); + CHECK(!SvDeviceProfileBinaryCodec::encoded_size(unknown_transport).has_value()); + CHECK(SvDeviceProfileBinaryCodec::encode_into(unknown_transport, encoded).status == + SvDeviceProfileCodecStatus::invalid_value); + auto unknown_basis = profile; + unknown_basis.sampling_basis = static_cast(0xFFU); + CHECK(!SvDeviceProfileBinaryCodec::encoded_size(unknown_basis).has_value()); + CHECK(SvDeviceProfileBinaryCodec::encode_into(unknown_basis, encoded).status == + SvDeviceProfileCodecStatus::invalid_value); + + // Valid-CRC, late-invalid descriptor must never expose a partial output. + auto late_invalid = encoded; + constexpr std::size_t first_leaf_flags = + SvDeviceProfileBinaryCodec::header_bytes + + SvDeviceProfileBinaryCodec::fixed_payload_bytes + 1U; + late_invalid[first_leaf_flags] = 0x80U; + std::fill(late_invalid.begin() + 16U, late_invalid.begin() + 20U, + std::uint8_t{0U}); + const auto repaired_crc = ar::iec61850::integrity::crc32(late_invalid); + for (std::size_t i = 0; i < 4U; ++i) { + late_invalid[16U + i] = + static_cast(repaired_crc >> ((3U - i) * 8U)); + } + decoded = profile; + CHECK(SvDeviceProfileBinaryCodec::decode(late_invalid, decoded).status == + SvDeviceProfileCodecStatus::invalid_value); + CHECK(decoded == CompiledSvDeviceProfile{}); + + ByteVector short_buffer(encoded.size() - 1U); + const auto short_result = + SvDeviceProfileBinaryCodec::encode_into(profile, short_buffer); + CHECK(short_result.status == SvDeviceProfileCodecStatus::buffer_too_small); + CHECK(short_result.bytes == encoded.size()); + + auto invalid_width = profile; + invalid_width.leaves[1].wire_width_bytes = 2U; + CHECK(!SvDeviceProfileBinaryCodec::encoded_size(invalid_width).has_value()); + + auto embedded_nul = profile; + embedded_nul.sv_id[1] = '\0'; + CHECK(!SvDeviceProfileBinaryCodec::encoded_size(embedded_nul).has_value()); + + auto inconsistent_options = profile; + inconsistent_options.asdu_options.element_present = false; + CHECK(!SvDeviceProfileBinaryCodec::encoded_size(inconsistent_options).has_value()); + + auto corrupted = encoded; + corrupted.back() ^= 0x01U; + CHECK(SvDeviceProfileBinaryCodec::decode(corrupted, decoded).status == + SvDeviceProfileCodecStatus::checksum_mismatch); + + auto wrong_magic = encoded; + wrong_magic[0] = 'X'; + CHECK(SvDeviceProfileBinaryCodec::decode(wrong_magic, decoded).status == + SvDeviceProfileCodecStatus::invalid_magic); + + auto wrong_version = encoded; + wrong_version[5] = 2U; + CHECK(SvDeviceProfileBinaryCodec::decode(wrong_version, decoded).status == + SvDeviceProfileCodecStatus::unsupported_version); + + auto wrong_length = encoded; + wrong_length[11] = static_cast(wrong_length[11] - 1U); + CHECK(SvDeviceProfileBinaryCodec::decode(wrong_length, decoded).status == + SvDeviceProfileCodecStatus::invalid_length); +} + void sample_counter_tracker_distinguishes_wrap_gap_duplicate_and_order() { using namespace ar::iec61850::sampled_values; @@ -395,6 +528,7 @@ int main() { {"SV PDU golden vector", sampled_values_pdu_matches_csharp_golden_vector}, {"SV Ethernet frame", sampled_values_frame_round_trips_vlan_process_bus_header}, {"SV malformed input", sampled_values_codec_handles_multiple_asdus_and_rejects_malformed_input}, + {"SV compiled device profile binary", compiled_device_profile_binary_is_canonical_and_integrity_checked}, {"SV sample counter", sample_counter_tracker_distinguishes_wrap_gap_duplicate_and_order}, {"SV stream supervisor", stream_supervisor_tracks_identity_configuration_and_statistics}, {"SV quality and payload diagnostics", quality_and_generic_payload_diagnostics_preserve_wire_evidence}}; diff --git a/tests/test_scl.cpp b/tests/test_scl.cpp index 241492021..84b16404c 100644 --- a/tests/test_scl.cpp +++ b/tests/test_scl.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/compiled_device_profile.hpp" #include "ariec61850/sampled_values/esp32p4_profile_support.hpp" #include "ariec61850/sampled_values/publisher_profile.hpp" #include "ariec61850/sampled_values/rational_schedule.hpp" @@ -809,6 +810,71 @@ void iec61869_9_profile_resolves_variant_dataset_and_exact_scaling() { })); } +void class_a_sv_profile_compiles_to_bounded_device_envelope() { + using namespace ar::iec61850::sampled_values; + using namespace ar::iec61850::scl; + + const auto document = + SclParser{}.load(fixture("sv-4800-structured-4i4v.scd")); + CHECK(document.sampled_values_streams.size() == 1U); + + SvPublisherProfileCompileContext context; + context.profile_family = SvProfileFamily::iec61850_9_2; + context.sample_counter_modulus = static_cast(4800U); + const auto host = SvPublisherProfileCompiler::compile( + document.sampled_values_streams.front(), context); + CHECK(host.ok()); + CHECK(host.profile.has_value()); + CHECK(classify_esp32p4_sv_profile(*host.profile) == + Esp32P4SvProfileSupport::ready); + + const auto device = compile_esp32p4_device_profile(*host.profile); + CHECK(device.ok()); + CHECK(device.profile.has_value()); + CHECK(device.profile->profile_family == SvProfileFamily::iec61850_9_2); + CHECK(device.profile->frame_rate_hz == 4800U); + CHECK(device.profile->sample_counter_modulus == 4800U); + CHECK(device.profile->no_asdu == 1U); + CHECK(device.profile->payload_size_bytes == 64U); + CHECK(device.profile->leaf_count == 16U); + + const auto bytes = + SvDeviceProfileBinaryCodec::encoded_size(*device.profile); + CHECK(bytes.has_value()); + std::vector encoded(*bytes); + CHECK(SvDeviceProfileBinaryCodec::encode_into( + *device.profile, encoded).success()); + CompiledSvDeviceProfile decoded; + CHECK(SvDeviceProfileBinaryCodec::decode(encoded, decoded).success()); + CHECK(decoded == *device.profile); + + const auto document_61869 = + SclParser{}.load(fixture("sv-61869-f4800s2-i4u4.scd")); + SvPublisherProfileCompileContext context_61869; + context_61869.profile_family = SvProfileFamily::iec61869_9; + context_61869.sample_counter_modulus = + static_cast(4800U); + const auto host_61869 = SvPublisherProfileCompiler::compile( + document_61869.sampled_values_streams.front(), context_61869); + CHECK(host_61869.ok()); + CHECK(host_61869.profile.has_value()); + CHECK(host_61869.profile->profile_family_resolution == + SvProfileFamilyResolution::resolved); + CHECK(classify_esp32p4_sv_profile(*host_61869.profile) == + Esp32P4SvProfileSupport::unsupported_profile_family); + + const auto blocked = + compile_esp32p4_device_profile(*host_61869.profile); + CHECK(!blocked.ok()); + CHECK(!blocked.profile.has_value()); + CHECK(std::any_of( + blocked.errors.begin(), blocked.errors.end(), + [](const std::string& error) { + return error.find("not deployable on the current ESP32-P4") != + std::string::npos; + })); +} + void parser_preserves_sampled_value_multicast_semantics() { using namespace ar::iec61850::sampled_values; using namespace ar::iec61850::scl; @@ -1207,6 +1273,7 @@ int main() { {"SV sampling and frame cadence semantics", publisher_profile_separates_sampling_from_frame_cadence}, {"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}, + {"SV Class-A compiled device envelope", class_a_sv_profile_compiles_to_bounded_device_envelope}, {"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},