Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ add_library(ariec61850_core
src/sampled_values/frame_codec.cpp
src/sampled_values/compiled_device_profile.cpp
src/sampled_values/m4_three_sv_roster.cpp
src/sampled_values/m4_three_sv_binary.cpp
src/sampled_values/quality.cpp
src/sampled_values/payload_inspector.cpp
src/sampled_values/sample_counter.cpp
Expand Down Expand Up @@ -324,6 +325,16 @@ if(ARIEC61850_BUILD_TESTS)
add_test(NAME ariec61850_m4_three_sv_roster_tests
COMMAND ariec61850_m4_three_sv_roster_tests)

add_executable(ariec61850_m4_three_sv_binary_tests tests/test_m4_three_sv_binary.cpp)
target_link_libraries(ariec61850_m4_three_sv_binary_tests PRIVATE ARIEC61850::core)
target_compile_definitions(ariec61850_m4_three_sv_binary_tests PRIVATE
ARIEC61850_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}")
target_compile_features(ariec61850_m4_three_sv_binary_tests PRIVATE cxx_std_20)
ariec61850_apply_warnings(ariec61850_m4_three_sv_binary_tests)
ariec61850_apply_sanitizers(ariec61850_m4_three_sv_binary_tests)
add_test(NAME ariec61850_m4_three_sv_binary_tests
COMMAND ariec61850_m4_three_sv_binary_tests)

add_executable(ariec61850_simulation_tests tests/test_simulation.cpp)
target_link_libraries(ariec61850_simulation_tests PRIVATE ARIEC61850::core)
target_compile_definitions(ariec61850_simulation_tests PRIVATE
Expand Down
56 changes: 56 additions & 0 deletions include/ariec61850/sampled_values/m4_three_sv_binary.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once

#include "ariec61850/sampled_values/m4_three_sv_roster.hpp"

#include <array>
#include <cstddef>
#include <cstdint>
#include <optional>
#include <span>

namespace ar::iec61850::sampled_values {

// Distinct from the existing single-publisher 'ARSV' V1 codec.
// The V2 envelope nests exactly THREE complete, byte-bounded ARSV V1 records.
// No C++ object-layout serialization, XML, dynamic allocation, or optional
// unbounded stream count on the device receiving path.
inline constexpr std::uint16_t m4_three_sv_binary_version = 2U;

enum class M4ThreeSvBinaryStatus : std::uint8_t {
ok,
buffer_too_small,
invalid_magic,
unsupported_version,
invalid_length,
checksum_mismatch,
invalid_profile,
};

struct M4ThreeSvBinaryResult final {
M4ThreeSvBinaryStatus status{M4ThreeSvBinaryStatus::invalid_profile};
std::size_t bytes{};
[[nodiscard]] bool success() const noexcept {
return status == M4ThreeSvBinaryStatus::ok;
}
};

class M4ThreeSvBinaryCodec final {
public:
static constexpr std::size_t header_bytes = 28U;

// The caller owns and bounds every buffer. encode_into writes exactly the
// required number of bytes; decode commits the destination only on success.
[[nodiscard]] static std::optional<std::size_t> encoded_size(
const std::array<CompiledSvDeviceProfile, m4_three_sv_capacity>& profiles) noexcept;

[[nodiscard]] static M4ThreeSvBinaryResult encode_into(
const std::array<CompiledSvDeviceProfile, m4_three_sv_capacity>& profiles,
std::span<std::uint8_t> destination) noexcept;

[[nodiscard]] static M4ThreeSvBinaryResult decode(
std::span<const std::uint8_t> source,
std::array<CompiledSvDeviceProfile, m4_three_sv_capacity>& destination) noexcept;
};

} // namespace ar::iec61850::sampled_values
191 changes: 191 additions & 0 deletions src/sampled_values/m4_three_sv_binary.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ariec61850/sampled_values/m4_three_sv_binary.hpp"
#include "ariec61850/integrity/crc32.hpp"

#include <array>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <span>
#include <string_view>

namespace ar::iec61850::sampled_values {
namespace {
constexpr std::array<std::uint8_t, 4> kMagic{'A', 'R', 'M', '3'};
constexpr std::size_t kCrcOffset = 12U;
constexpr std::size_t kLengthOffset = 20U;

void write_u16(const std::span<std::uint8_t> data,
const std::size_t pos, const std::uint16_t value) noexcept {
data[pos] = static_cast<std::uint8_t>(value >> 8U);
data[pos + 1U] = static_cast<std::uint8_t>(value);
}
void write_u32(const std::span<std::uint8_t> data,
const std::size_t pos, const std::uint32_t value) noexcept {
data[pos] = static_cast<std::uint8_t>(value >> 24U);
data[pos + 1U] = static_cast<std::uint8_t>(value >> 16U);
data[pos + 2U] = static_cast<std::uint8_t>(value >> 8U);
data[pos + 3U] = static_cast<std::uint8_t>(value);
}
[[nodiscard]] std::uint16_t read_u16(const std::span<const std::uint8_t> data,
const std::size_t pos) noexcept {
return static_cast<std::uint16_t>((static_cast<std::uint16_t>(data[pos]) << 8U) |
static_cast<std::uint16_t>(data[pos + 1U]));
}
[[nodiscard]] std::uint32_t read_u32(const std::span<const std::uint8_t> data,
const std::size_t pos) noexcept {
return (static_cast<std::uint32_t>(data[pos]) << 24U) |
(static_cast<std::uint32_t>(data[pos + 1U]) << 16U) |
(static_cast<std::uint32_t>(data[pos + 2U]) << 8U) |
static_cast<std::uint32_t>(data[pos + 3U]);
}
[[nodiscard]] std::uint32_t envelope_crc(
const std::span<const std::uint8_t> data) noexcept {
std::uint32_t state = integrity::crc32_initial_state;
state = integrity::crc32_update(state, data.first(kCrcOffset));
constexpr std::array<std::uint8_t, 4> zero{};
state = integrity::crc32_update(state, zero);
state = integrity::crc32_update(state, data.subspan(kCrcOffset + zero.size()));
return integrity::crc32_finalize(state);
}

[[nodiscard]] bool compatible(
const CompiledSvDeviceProfile& p) noexcept {
return (p.profile_family == SvProfileFamily::legacy_9_2le ||
p.profile_family == SvProfileFamily::iec61850_9_2) &&
p.frame_rate_hz == m4_three_sv_rate_hz &&
p.configured_sample_rate == m4_three_sv_rate_hz &&
p.sampling_basis == SvSampleMode::samples_per_second &&
p.sample_counter_modulus == m4_three_sv_rate_hz &&
p.no_asdu == 1U &&
p.leaf_count == 16U &&
p.payload_size_bytes == 64U &&
p.asdu_options.sample_synchronized;
Comment on lines +61 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce the supported 4I+4V layout at the envelope boundary

A roster containing sixteen INT32 leaves per stream passes both encode and decode successfully, despite having no Quality leaves: these count/size checks do not establish the required eight ordered INT32+Quality pairs. The nested V1 codec intentionally accepts broader layouts, whereas the existing deployment gates in esp32p4_profile_support.hpp and compiled_profile_adapter.hpp reject them, along with unsupported ASDU options. Consequently, the new envelope can publish a supposedly validated M4 roster that the device rejects. Reuse a shared device-capability validator so this boundary preserves the existing layout and option restrictions.

AGENTS.md reference: AGENTS.md:L30-L31

Useful? React with 👍 / 👎.

}

[[nodiscard]] bool distinct_identity(
const CompiledSvDeviceProfile& lhs,
const CompiledSvDeviceProfile& rhs) noexcept {
const auto name = [](const CompiledSvDeviceProfile& p) {
return std::string_view{p.sv_id.data(), p.sv_id_length};
};
return !(lhs.app_id == rhs.app_id &&
lhs.destination_mac == rhs.destination_mac &&
lhs.transport_mode == rhs.transport_mode &&
lhs.vlan_present == rhs.vlan_present &&
(!lhs.vlan_present ||
(lhs.vlan_id == rhs.vlan_id &&
lhs.vlan_priority == rhs.vlan_priority)) &&
name(lhs) == name(rhs));
}

[[nodiscard]] bool valid_roster(
const std::array<CompiledSvDeviceProfile, m4_three_sv_capacity>& p) noexcept {
for (std::size_t i = 0U; i < p.size(); ++i) {
if (!compatible(p[i])) return false;
for (std::size_t j = 0U; j < i; ++j) {
if (!distinct_identity(p[i], p[j])) return false;
}
}
return true;
}
} // namespace

std::optional<std::size_t> M4ThreeSvBinaryCodec::encoded_size(
const std::array<CompiledSvDeviceProfile, m4_three_sv_capacity>& p) noexcept {
if (!valid_roster(p)) return std::nullopt;
std::size_t total = header_bytes;
for (const auto& profile : p) {
const auto size = SvDeviceProfileBinaryCodec::encoded_size(profile);
Comment on lines +96 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate profile lengths before comparing stream identities

When two input profiles have matching MAC/APPID/VLAN fields and equally oversized sv_id_length values, valid_roster() compares their IDs before the nested V1 codec validates those lengths. The resulting string_view comparison reads beyond the fixed 96-byte arrays instead of returning invalid_profile; an AddressSanitizer reproduction with lengths of 65535 reports a stack-buffer-overflow. Validate each profile through the existing V1 validation path before performing identity comparisons.

AGENTS.md reference: AGENTS.md:L57-L64

Useful? React with 👍 / 👎.

if (!size.has_value() ||
*size == 0U || *size > std::numeric_limits<std::uint16_t>::max() ||
total > std::numeric_limits<std::uint32_t>::max() - *size) {
return std::nullopt;
}
total += *size;
}
return total;
}

M4ThreeSvBinaryResult M4ThreeSvBinaryCodec::encode_into(
const std::array<CompiledSvDeviceProfile, m4_three_sv_capacity>& p,
const std::span<std::uint8_t> destination) noexcept {
const auto total = encoded_size(p);
if (!total.has_value()) return {M4ThreeSvBinaryStatus::invalid_profile, 0U};
if (destination.size() < *total)
return {M4ThreeSvBinaryStatus::buffer_too_small, *total};
const auto out = destination.first(*total);
for (std::size_t i = 0U; i < kMagic.size(); ++i) out[i] = kMagic[i];
write_u16(out, 4U, m4_three_sv_binary_version);
out[6U] = static_cast<std::uint8_t>(m4_three_sv_capacity);
out[7U] = 0U; // reserved
write_u32(out, 8U, static_cast<std::uint32_t>(*total));
write_u32(out, kCrcOffset, 0U);
write_u32(out, 16U, m4_three_sv_rate_hz);
out[26U] = 0U;
out[27U] = 0U;
std::size_t offset = header_bytes;
for (std::size_t i = 0U; i < p.size(); ++i) {
const auto size = SvDeviceProfileBinaryCodec::encoded_size(p[i]);
if (!size.has_value()) return {M4ThreeSvBinaryStatus::invalid_profile, 0U};
write_u16(out, kLengthOffset + 2U * i, static_cast<std::uint16_t>(*size));
const auto encoded = SvDeviceProfileBinaryCodec::encode_into(
p[i], out.subspan(offset, *size));
if (!encoded.success() || encoded.bytes != *size)
return {M4ThreeSvBinaryStatus::invalid_profile, 0U};
offset += *size;
}
write_u32(out, kCrcOffset, envelope_crc(out));
return {M4ThreeSvBinaryStatus::ok, *total};
}

M4ThreeSvBinaryResult M4ThreeSvBinaryCodec::decode(
const std::span<const std::uint8_t> source,
std::array<CompiledSvDeviceProfile, m4_three_sv_capacity>& destination) noexcept {
if (source.size() < header_bytes)
return {M4ThreeSvBinaryStatus::invalid_length, 0U};
for (std::size_t i = 0U; i < kMagic.size(); ++i)
if (source[i] != kMagic[i])
return {M4ThreeSvBinaryStatus::invalid_magic, 0U};
if (read_u16(source, 4U) != m4_three_sv_binary_version)
return {M4ThreeSvBinaryStatus::unsupported_version, 0U};
if (source[6U] != m4_three_sv_capacity || source[7U] != 0U ||
read_u32(source, 8U) != source.size() ||
read_u32(source, 16U) != m4_three_sv_rate_hz ||
source[26U] != 0U || source[27U] != 0U) {
return {M4ThreeSvBinaryStatus::invalid_length, 0U};
}
std::size_t sum = header_bytes;
for (std::size_t i = 0U; i < m4_three_sv_capacity; ++i) {
const std::size_t record_length = read_u16(source, kLengthOffset + 2U * i);
if (record_length == 0U ||
record_length > source.size() - sum) {
return {M4ThreeSvBinaryStatus::invalid_length, 0U};
}
sum += record_length;
}
if (sum != source.size())
return {M4ThreeSvBinaryStatus::invalid_length, 0U};
if (read_u32(source, kCrcOffset) != envelope_crc(source))
return {M4ThreeSvBinaryStatus::checksum_mismatch, 0U};

// Validate all nested V1 records and identities before publishing any
// externally-visible state. Failed decode never edits the caller's roster.
std::array<CompiledSvDeviceProfile, m4_three_sv_capacity> candidate{};
std::size_t offset = header_bytes;
for (std::size_t i = 0U; i < candidate.size(); ++i) {
const auto length = read_u16(source, kLengthOffset + 2U * i);
const auto inner = SvDeviceProfileBinaryCodec::decode(
source.subspan(offset, length), candidate[i]);
if (!inner.success() || inner.bytes != length)
return {M4ThreeSvBinaryStatus::invalid_profile, 0U};
offset += length;
}
if (!valid_roster(candidate))
return {M4ThreeSvBinaryStatus::invalid_profile, 0U};

destination = candidate;
return {M4ThreeSvBinaryStatus::ok, source.size()};
}

} // namespace ar::iec61850::sampled_values
110 changes: 110 additions & 0 deletions tests/test_m4_three_sv_binary.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ariec61850/sampled_values/m4_three_sv_binary.hpp"
#include "ariec61850/scl/parser.hpp"

#include <array>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

namespace {
#define CHECK(condition) do { if (!(condition)) { \
throw std::runtime_error(std::string{"M4 binary CHECK failed: "} + #condition + \
" at " + std::to_string(__LINE__)); \
} } while (false)

using namespace ar::iec61850::sampled_values;
using Profiles = std::array<CompiledSvDeviceProfile, m4_three_sv_capacity>;

Profiles reference_profiles() {
const auto path = std::filesystem::path{ARIEC61850_SOURCE_DIR} /
"tests/fixtures/scl/m4-three-9-2le-reference.scd";
const auto document = ar::iec61850::scl::SclParser{}.load(path);
SvPublisherProfileCompileContext context;
context.profile_family = SvProfileFamily::legacy_9_2le;
context.sample_counter_modulus = static_cast<std::uint16_t>(4000U);
const auto compiled = M4ThreeSvRosterCompiler::compile(document, context);
CHECK(compiled.ok());
return {compiled.roster->streams[0].device_profile,
compiled.roster->streams[1].device_profile,
compiled.roster->streams[2].device_profile};
}

void three_v1_profiles_inside_v2_envelope() {
const auto profiles = reference_profiles();
const auto required = M4ThreeSvBinaryCodec::encoded_size(profiles);
CHECK(required.has_value());
CHECK(*required > M4ThreeSvBinaryCodec::header_bytes);

std::vector<std::uint8_t> too_small(*required - 1U);
const auto shortage = M4ThreeSvBinaryCodec::encode_into(profiles, too_small);
CHECK(shortage.status == M4ThreeSvBinaryStatus::buffer_too_small);
CHECK(shortage.bytes == *required);

std::vector<std::uint8_t> bytes(*required);
const auto encoded = M4ThreeSvBinaryCodec::encode_into(profiles, bytes);
CHECK(encoded.success());
CHECK(encoded.bytes == *required);
CHECK(bytes[0] == 'A' && bytes[1] == 'R' &&
bytes[2] == 'M' && bytes[3] == '3');
CHECK(bytes[6] == 3U);
CHECK(bytes[16] == 0U && bytes[17] == 0U &&
bytes[18] == 0x0FU && bytes[19] == 0xA0U); // 4000 big endian

Profiles decoded{};
CHECK(M4ThreeSvBinaryCodec::decode(bytes, decoded).success());
CHECK(decoded == profiles);

// Successful decode is repeatable and byte-stable.
std::vector<std::uint8_t> reencoded(*required);
CHECK(M4ThreeSvBinaryCodec::encode_into(decoded, reencoded).success());
CHECK(reencoded == bytes);

// No partial mutation even when the last nested V1 record is damaged.
const auto original = decoded;
bytes.back() ^= 1U;
CHECK(M4ThreeSvBinaryCodec::decode(bytes, decoded).status ==
M4ThreeSvBinaryStatus::checksum_mismatch);
CHECK(decoded == original);
bytes.back() ^= 1U;

bytes[5] = 3U; // version 2 -> 3
CHECK(M4ThreeSvBinaryCodec::decode(bytes, decoded).status ==
M4ThreeSvBinaryStatus::unsupported_version);
CHECK(decoded == original);
bytes[5] = 2U;

bytes[23] ^= 1U; // length of second nested record
CHECK(!M4ThreeSvBinaryCodec::decode(bytes, decoded).success());
CHECK(decoded == original);
bytes[23] ^= 1U;

// Repeated APPID is allowed but complete duplicate wire identity is not.
auto duplicate = profiles;
duplicate[2].destination_mac = duplicate[0].destination_mac;
duplicate[2].sv_id = duplicate[0].sv_id;
duplicate[2].sv_id_length = duplicate[0].sv_id_length;
CHECK(!M4ThreeSvBinaryCodec::encoded_size(duplicate).has_value());

auto wrong_rate = profiles;
wrong_rate[1].frame_rate_hz = 4800U;
CHECK(!M4ThreeSvBinaryCodec::encoded_size(wrong_rate).has_value());

std::cout << "PASS: V2 three-stream envelope: CRC32, all-or-none "
"readback, V1 nested profiles, replay-safe byte format\n";
}
} // namespace

int main() {
try {
three_v1_profiles_inside_v2_envelope();
return 0;
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
return 1;
}
}