diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c3ce5c..41766bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,36 @@ Versioning](https://semver.org/). > had little or no release-note detail, the entry is intentionally terse > rather than inferring unsupported intent. +## [0.10.1] - 2026-08-20 + +### Fixed + +- Hardened `BinaryArchive::Load()` against malformed and adversarial ESPB v2 + payloads by bounding nesting depth, total decoded nodes, object members, + array elements, property-name lengths, and string lengths. +- Added overflow-safe remaining-buffer checks before copying decoded names and + string values. +- Converted allocation/decoder exceptions during BinaryArchive loading into a + clean invalid-archive result rather than allowing diagnostic or transport + callers to be destabilized. + +### Added + +- Added `BinaryArchiveDecodeLimits` and explicit `Load(..., limits)` overloads + for applications that need tighter or broader decode policies. +- Added `BinaryArchiveVisitor`, `TraverseBinaryArchive()`, and + `ValidateBinaryArchive()` for bounded, allocation-free ESPB v2 inspection + without constructing an intermediate `SerializationNode` tree. +- Added regression and stress-oriented malformed-input coverage for deep, + broad, oversized-name, oversized-string, aggregate-node, and arbitrary-byte + payloads, including the allocation-free traversal path. + +### Compatibility + +- The existing `Load()` overloads remain source-compatible and use safe + defaults. +- The ESPB v2 wire format is unchanged. + ## [0.10.0] - 2026-08-20 ### Added @@ -85,4 +115,4 @@ milestones before the first published GitHub Release at 0.9.0. > release. Earlier 0.x version numbers appeared during repository > development, but are grouped here rather than assigning release dates > or exact contents that are not fully supported by the published -> release record. +> release record. \ No newline at end of file diff --git a/README.md b/README.md index 97a89cf..6f49daa 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,66 @@ Development Platform. ## Latest Stable Version -**0.10.0** +**0.10.1** + +### 0.10.1 bounded BinaryArchive decoding + +Version 0.10.1 hardens `BinaryArchive` when decoding untrusted or malformed +ESPB v2 payloads. The default `Load()` overload now applies embedded-friendly +limits for nesting depth, aggregate node count, object members, array elements, +property-name length, and string length. Allocation or decoder exceptions are +converted into a clean invalid-archive result rather than escaping into the +application. + +Applications with different requirements can supply explicit limits: + +```cpp +ESPressio::Serializable::BinaryArchive archive; +ESPressio::Serializable::BinaryArchiveDecodeLimits limits; + +limits.MaximumDepth = 16; +limits.MaximumTotalNodes = 1024; +limits.MaximumObjectMembers = 256; +limits.MaximumArrayElements = 1024; +limits.MaximumNameLength = 256; +limits.MaximumStringLength = 16 * 1024; + +if (!archive.Load(data, size, limits)) { + // Malformed, truncated, unsupported, or outside the configured limits. +} +``` + +The no-options overload remains source-compatible and uses the library defaults. +The ESPB v2 wire format is unchanged. + +### Allocation-free BinaryArchive validation and traversal + +For diagnostics, protocol inspection, and other cases that do not require an +owned `SerializationNode` tree, 0.10.1 also adds an allocation-free ESPB v2 +traversal API: + +```cpp +ESPressio::Serializable::BinaryArchiveDecodeLimits limits; +limits.MaximumDepth = 12; +limits.MaximumTotalNodes = 1024; + +if (ESPressio::Serializable::ValidateBinaryArchive( + data, + size, + limits + )) { + // Structurally valid and within the configured limits. +} +``` + +`TraverseBinaryArchive()` accepts a `BinaryArchiveVisitor` and streams object, +array, property, and scalar callbacks directly from the encoded bytes. The +traversal uses `std::string_view` for borrowed names/string values and does not +construct a second tree or copy payload strings merely to inspect them. + +This is particularly useful on ESP32 for diagnostic paths where attempting to +build another heap-backed tree during low-memory conditions would itself be +undesirable. ### 0.10.0 direct Binary fast path @@ -162,6 +221,7 @@ Ordinary usage of those libraries remains serialization-free. - Representation-neutral metadata. - Embedded-friendly archives. - Direct CBOR/Binary support. +- Bounded and allocation-free BinaryArchive inspection where appropriate. - Validation and schema evolution. - Compile-time diagnostics where possible. -- Optional rather than ecosystem-wide dependency. +- Optional rather than ecosystem-wide dependency. \ No newline at end of file diff --git a/library.json b/library.json index bd5b9e9..f8bec72 100644 --- a/library.json +++ b/library.json @@ -1,6 +1,6 @@ { "name": "ESPressio-Serializable", - "version": "0.10.0", + "version": "0.10.1", "description": "Compile-time declarative serialization components for the Flowduino ESPressio Development Platform.", "keywords": [ "serialization", diff --git a/library.properties b/library.properties index faffae5..58e9cf7 100644 --- a/library.properties +++ b/library.properties @@ -1,5 +1,5 @@ name=ESPressio Serializable -version=0.10.0 +version=0.10.1 author=Flowduino maintainer=Flowduino sentence=Compile-time declarative serialization components for the ESPressio Development Platform. diff --git a/src/ESPressio_BinaryArchive.hpp b/src/ESPressio_BinaryArchive.hpp index 4980cca..fd0a600 100644 --- a/src/ESPressio_BinaryArchive.hpp +++ b/src/ESPressio_BinaryArchive.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -9,10 +10,40 @@ namespace ESPressio::Serializable { + struct BinaryArchiveDecodeLimits { + std::size_t MaximumDepth = 32; + std::size_t MaximumTotalNodes = 4096; + uint32_t MaximumObjectMembers = 1024; + uint32_t MaximumArrayElements = 4096; + std::size_t MaximumNameLength = 1024; + std::size_t MaximumStringLength = 64u * 1024u; + }; + + class BinaryArchive : public TreeArchive { private: + struct DecodeState { + const BinaryArchiveDecodeLimits& Limits; + std::size_t TotalNodes = 0; + }; + bool _valid = true; + static std::size_t Remaining( + const uint8_t* cursor, + const uint8_t* end + ) noexcept { + if ( + cursor == nullptr || + end == nullptr || + cursor > end + ) { + return 0; + } + + return static_cast(end - cursor); + } + static void AppendU16( std::vector& output, uint16_t value @@ -67,7 +98,7 @@ namespace ESPressio::Serializable { const uint8_t* end, uint16_t& value ) { - if (end - cursor < 2) { + if (Remaining(cursor, end) < 2) { return false; } @@ -87,7 +118,7 @@ namespace ESPressio::Serializable { const uint8_t* end, uint32_t& value ) { - if (end - cursor < 4) { + if (Remaining(cursor, end) < 4) { return false; } @@ -107,7 +138,7 @@ namespace ESPressio::Serializable { const uint8_t* end, uint64_t& value ) { - if (end - cursor < 8) { + if (Remaining(cursor, end) < 8) { return false; } @@ -276,12 +307,23 @@ namespace ESPressio::Serializable { static bool DecodeNode( const uint8_t*& cursor, const uint8_t* end, - SerializationNode& node + SerializationNode& node, + DecodeState& state, + std::size_t depth ) { - if (cursor >= end) { + if ( + cursor == nullptr || + end == nullptr || + cursor >= end || + depth > state.Limits.MaximumDepth || + state.TotalNodes >= + state.Limits.MaximumTotalNodes + ) { return false; } + ++state.TotalNodes; + const auto type = static_cast( *cursor++ @@ -301,7 +343,9 @@ namespace ESPressio::Serializable { cursor, end, count - ) + ) || + count > + state.Limits.MaximumObjectMembers ) { return false; } @@ -319,7 +363,9 @@ namespace ESPressio::Serializable { end, nameLength ) || - end - cursor < + nameLength > + state.Limits.MaximumNameLength || + Remaining(cursor, end) < nameLength ) { return false; @@ -340,7 +386,9 @@ namespace ESPressio::Serializable { !DecodeNode( cursor, end, - child + child, + state, + depth + 1 ) ) { return false; @@ -363,7 +411,9 @@ namespace ESPressio::Serializable { cursor, end, count - ) + ) || + count > + state.Limits.MaximumArrayElements ) { return false; } @@ -379,7 +429,9 @@ namespace ESPressio::Serializable { !DecodeNode( cursor, end, - child + child, + state, + depth + 1 ) ) { return false; @@ -394,7 +446,7 @@ namespace ESPressio::Serializable { } case SerializationNodeType::Boolean: - if (cursor >= end) { + if (Remaining(cursor, end) < 1) { return false; } @@ -498,9 +550,8 @@ namespace ESPressio::Serializable { size ) || size > - static_cast( - end - cursor - ) + state.Limits.MaximumStringLength || + Remaining(cursor, end) < size ) { return false; } @@ -556,54 +607,90 @@ namespace ESPressio::Serializable { bool Load( const uint8_t* data, - size_t size - ) { - Clear(); - - if ( - data == nullptr || - size < 6 || - data[0] != 'E' || - data[1] != 'S' || - data[2] != 'P' || - data[3] != 'B' || - data[4] != 2u - ) { - _valid = false; - return false; - } + size_t size, + const BinaryArchiveDecodeLimits& limits + ) noexcept { + try { + Clear(); + + if ( + data == nullptr || + size < 6 || + limits.MaximumTotalNodes == 0 || + data[0] != 'E' || + data[1] != 'S' || + data[2] != 'P' || + data[3] != 'B' || + data[4] != 2u + ) { + _valid = false; + return false; + } - const uint8_t* cursor = - data + 5; + const uint8_t* cursor = + data + 5; - const uint8_t* end = - data + size; + const uint8_t* end = + data + size; - SerializationNode root; + SerializationNode root; + DecodeState state{limits}; - _valid = - DecodeNode( - cursor, - end, - root - ) && - cursor == end && - root.GetType() == - SerializationNodeType::Object; + _valid = + DecodeNode( + cursor, + end, + root, + state, + 0 + ) && + cursor == end && + root.GetType() == + SerializationNodeType::Object; + + if (_valid) { + _root = std::move(root); + } else { + Clear(); + } - if (_valid) { - _root = std::move(root); + return _valid; + } catch (...) { + Clear(); + _valid = false; + return false; } + } - return _valid; + bool Load( + const uint8_t* data, + size_t size + ) noexcept { + return Load( + data, + size, + BinaryArchiveDecodeLimits{} + ); + } + + bool Load( + const std::vector& data, + const BinaryArchiveDecodeLimits& limits + ) noexcept { + return Load( + data.data(), + data.size(), + limits + ); } bool Load( const std::vector& data - ) { + ) noexcept { return Load( data.data(), - data.size() + data.size(), + BinaryArchiveDecodeLimits{} ); } }; diff --git a/src/ESPressio_BinaryArchiveTraversal.hpp b/src/ESPressio_BinaryArchiveTraversal.hpp new file mode 100644 index 0000000..8eed818 --- /dev/null +++ b/src/ESPressio_BinaryArchiveTraversal.hpp @@ -0,0 +1,386 @@ +#pragma once + +#include +#include +#include +#include + +#include "ESPressio_BinaryArchive.hpp" + +namespace ESPressio::Serializable { + +class BinaryArchiveVisitor { +public: + virtual ~BinaryArchiveVisitor() = default; + + virtual bool OnObjectBegin( + uint32_t, + std::size_t + ) noexcept { return true; } + + virtual bool OnObjectProperty( + std::string_view, + uint32_t, + uint32_t, + std::size_t + ) noexcept { return true; } + + virtual bool OnObjectEnd( + uint32_t, + std::size_t + ) noexcept { return true; } + + virtual bool OnArrayBegin( + uint32_t, + std::size_t + ) noexcept { return true; } + + virtual bool OnArrayElement( + uint32_t, + uint32_t, + std::size_t + ) noexcept { return true; } + + virtual bool OnArrayEnd( + uint32_t, + std::size_t + ) noexcept { return true; } + + virtual bool OnNull( + std::size_t + ) noexcept { return true; } + + virtual bool OnBoolean( + bool, + std::size_t + ) noexcept { return true; } + + virtual bool OnSignedInteger( + int64_t, + std::size_t + ) noexcept { return true; } + + virtual bool OnUnsignedInteger( + uint64_t, + std::size_t + ) noexcept { return true; } + + virtual bool OnFloat32( + float, + std::size_t + ) noexcept { return true; } + + virtual bool OnFloat64( + double, + std::size_t + ) noexcept { return true; } + + virtual bool OnString( + std::string_view, + std::size_t + ) noexcept { return true; } +}; + + +namespace BinaryArchiveTraversalDetail { + +struct State { + const BinaryArchiveDecodeLimits& Limits; + std::size_t TotalNodes = 0; +}; + +inline std::size_t Remaining( + const uint8_t* cursor, + const uint8_t* end +) noexcept { + if ( + cursor == nullptr || + end == nullptr || + cursor > end + ) { + return 0; + } + + return static_cast(end - cursor); +} + +inline bool ReadU16( + const uint8_t*& cursor, + const uint8_t* end, + uint16_t& value +) noexcept { + if (Remaining(cursor, end) < 2) { + return false; + } + + value = + static_cast(cursor[0]) | + (static_cast(cursor[1]) << 8u); + + cursor += 2; + return true; +} + +inline bool ReadU32( + const uint8_t*& cursor, + const uint8_t* end, + uint32_t& value +) noexcept { + if (Remaining(cursor, end) < 4) { + return false; + } + + value = 0; + for (int shift = 0; shift < 32; shift += 8) { + value |= static_cast(*cursor++) << shift; + } + return true; +} + +inline bool ReadU64( + const uint8_t*& cursor, + const uint8_t* end, + uint64_t& value +) noexcept { + if (Remaining(cursor, end) < 8) { + return false; + } + + value = 0; + for (int shift = 0; shift < 64; shift += 8) { + value |= static_cast(*cursor++) << shift; + } + return true; +} + +inline bool VisitNode( + const uint8_t*& cursor, + const uint8_t* end, + BinaryArchiveVisitor& visitor, + State& state, + std::size_t depth +) noexcept { + if ( + cursor == nullptr || + end == nullptr || + cursor >= end || + depth > state.Limits.MaximumDepth || + state.TotalNodes >= state.Limits.MaximumTotalNodes + ) { + return false; + } + + ++state.TotalNodes; + + const auto type = + static_cast(*cursor++); + + switch (type) { + case SerializationNodeType::Null: + return visitor.OnNull(depth); + + case SerializationNodeType::Object: { + uint16_t count = 0; + if ( + !ReadU16(cursor, end, count) || + count > state.Limits.MaximumObjectMembers || + !visitor.OnObjectBegin(count, depth) + ) { + return false; + } + + for (uint16_t index = 0; index < count; ++index) { + uint16_t nameLength = 0; + if ( + !ReadU16(cursor, end, nameLength) || + nameLength > state.Limits.MaximumNameLength || + Remaining(cursor, end) < nameLength + ) { + return false; + } + + const std::string_view name( + reinterpret_cast(cursor), + nameLength + ); + cursor += nameLength; + + if ( + !visitor.OnObjectProperty( + name, + index, + count, + depth + ) || + !VisitNode( + cursor, + end, + visitor, + state, + depth + 1 + ) + ) { + return false; + } + } + + return visitor.OnObjectEnd(count, depth); + } + + case SerializationNodeType::Array: { + uint32_t count = 0; + if ( + !ReadU32(cursor, end, count) || + count > state.Limits.MaximumArrayElements || + !visitor.OnArrayBegin(count, depth) + ) { + return false; + } + + for (uint32_t index = 0; index < count; ++index) { + if ( + !visitor.OnArrayElement( + index, + count, + depth + ) || + !VisitNode( + cursor, + end, + visitor, + state, + depth + 1 + ) + ) { + return false; + } + } + + return visitor.OnArrayEnd(count, depth); + } + + case SerializationNodeType::Boolean: + if (Remaining(cursor, end) < 1) { + return false; + } + return visitor.OnBoolean(*cursor++ != 0, depth); + + case SerializationNodeType::SignedInteger: { + uint64_t raw = 0; + if (!ReadU64(cursor, end, raw)) { + return false; + } + int64_t value = 0; + std::memcpy(&value, &raw, sizeof(value)); + return visitor.OnSignedInteger(value, depth); + } + + case SerializationNodeType::UnsignedInteger: { + uint64_t value = 0; + return + ReadU64(cursor, end, value) && + visitor.OnUnsignedInteger(value, depth); + } + + case SerializationNodeType::Float32: { + uint32_t raw = 0; + if (!ReadU32(cursor, end, raw)) { + return false; + } + float value = 0.0f; + std::memcpy(&value, &raw, sizeof(value)); + return visitor.OnFloat32(value, depth); + } + + case SerializationNodeType::Float64: { + uint64_t raw = 0; + if (!ReadU64(cursor, end, raw)) { + return false; + } + double value = 0.0; + std::memcpy(&value, &raw, sizeof(value)); + return visitor.OnFloat64(value, depth); + } + + case SerializationNodeType::String: { + uint32_t length = 0; + if ( + !ReadU32(cursor, end, length) || + length > state.Limits.MaximumStringLength || + Remaining(cursor, end) < length + ) { + return false; + } + + const std::string_view value( + reinterpret_cast(cursor), + length + ); + cursor += length; + return visitor.OnString(value, depth); + } + } + + return false; +} + +} // namespace BinaryArchiveTraversalDetail + + +inline bool TraverseBinaryArchive( + const uint8_t* data, + std::size_t size, + BinaryArchiveVisitor& visitor, + const BinaryArchiveDecodeLimits& limits = {} +) noexcept { + if ( + data == nullptr || + size < 6 || + limits.MaximumTotalNodes == 0 || + data[0] != 'E' || + data[1] != 'S' || + data[2] != 'P' || + data[3] != 'B' || + data[4] != 2u + ) { + return false; + } + + const uint8_t* cursor = data + 5; + const uint8_t* end = data + size; + BinaryArchiveTraversalDetail::State state{limits}; + + if ( + cursor >= end || + static_cast(*cursor) != + SerializationNodeType::Object || + !BinaryArchiveTraversalDetail::VisitNode( + cursor, + end, + visitor, + state, + 0 + ) + ) { + return false; + } + + return cursor == end; +} + + +inline bool ValidateBinaryArchive( + const uint8_t* data, + std::size_t size, + const BinaryArchiveDecodeLimits& limits = {} +) noexcept { + BinaryArchiveVisitor visitor; + return TraverseBinaryArchive( + data, + size, + visitor, + limits + ); +} + +} // namespace ESPressio::Serializable diff --git a/src/ESPressio_Serializable.hpp b/src/ESPressio_Serializable.hpp index 0643a26..1aba706 100644 --- a/src/ESPressio_Serializable.hpp +++ b/src/ESPressio_Serializable.hpp @@ -16,3 +16,4 @@ #include "ESPressio_SerializationResult.hpp" #include "ESPressio_SchemaIntrospection.hpp" #include "ESPressio_DirectBinaryArchive.hpp" +#include "ESPressio_BinaryArchiveTraversal.hpp" diff --git a/tests/test_malformed.cpp b/tests/test_malformed.cpp index 6bf753d..0b8b179 100644 --- a/tests/test_malformed.cpp +++ b/tests/test_malformed.cpp @@ -1,9 +1,275 @@ #include #include #include +#include #include + #include +#include #include + using namespace ESPressio; -template void Mutate(const std::vector& seed){std::mt19937 rng(0x45535052);for(int n=0;n<2000;++n){auto d=seed;if(d.empty())d.push_back(0);int edits=1+(rng()%4);for(int e=0;e1)d.erase(d.begin()+(rng()%d.size()));break;case 2:if(d.size()<2048)d.insert(d.begin()+(rng()%(d.size()+1)),uint8_t(rng()));break;}}A a; (void)a.Load(d);}} -int main(){Serializable::BinaryArchive b;b.Write("x",uint32_t(42));auto bd=b.GetData();Serializable::CborArchive c;c.Write("x",uint32_t(42));auto cd=c.GetData();Mutate(bd);Mutate(cd);std::vector truncated={'E','S','P','B',2};Serializable::BinaryArchive bad;assert(!bad.Load(truncated));return 0;} + +template +void Mutate(const std::vector& seed) { + std::mt19937 rng(0x45535052); + for (int n = 0; n < 2000; ++n) { + auto d = seed; + if (d.empty()) d.push_back(0); + const int edits = 1 + (rng() % 4); + for (int e = 0; e < edits; ++e) { + switch (rng() % 3) { + case 0: + d[rng() % d.size()] ^= uint8_t(1u << (rng() % 8)); + break; + case 1: + if (d.size() > 1) d.erase(d.begin() + (rng() % d.size())); + break; + case 2: + if (d.size() < 2048) { + d.insert( + d.begin() + (rng() % (d.size() + 1)), + uint8_t(rng()) + ); + } + break; + } + } + A a; + (void)a.Load(d); + } +} + +static std::vector Header() { + return {'E', 'S', 'P', 'B', 2u}; +} + +static void AppendU16(std::vector& data, uint16_t value) { + data.push_back(static_cast(value & 0xffu)); + data.push_back(static_cast((value >> 8u) & 0xffu)); +} + +static void AppendU32(std::vector& data, uint32_t value) { + for (unsigned shift = 0; shift < 32; shift += 8) { + data.push_back(static_cast((value >> shift) & 0xffu)); + } +} + +static std::vector DeepObject(unsigned depth) { + auto data = Header(); + for (unsigned level = 0; level < depth; ++level) { + data.push_back(static_cast( + Serializable::SerializationNodeType::Object + )); + AppendU16(data, 1); + AppendU16(data, 1); + data.push_back('x'); + } + data.push_back(static_cast( + Serializable::SerializationNodeType::Null + )); + return data; +} + +class CountingVisitor final : + public Serializable::BinaryArchiveVisitor { +public: + unsigned Properties = 0; + unsigned UnsignedValues = 0; + + bool OnObjectProperty( + std::string_view, + uint32_t, + uint32_t, + std::size_t + ) noexcept override { + ++Properties; + return true; + } + + bool OnUnsignedInteger( + uint64_t, + std::size_t + ) noexcept override { + ++UnsignedValues; + return true; + } +}; + +int main() { + Serializable::BinaryArchive b; + b.Write("x", uint32_t(42)); + const auto bd = b.GetData(); + + Serializable::CborArchive c; + c.Write("x", uint32_t(42)); + const auto cd = c.GetData(); + + Mutate(bd); + Mutate(cd); + + std::vector truncated = {'E', 'S', 'P', 'B', 2}; + Serializable::BinaryArchive bad; + assert(!bad.Load(truncated)); + + // Regression for #2: recursive payloads must be rejected before they can + // consume the task stack or build an unbounded intermediate tree. + { + Serializable::BinaryArchiveDecodeLimits limits; + limits.MaximumDepth = 8; + auto data = DeepObject(16); + Serializable::BinaryArchive archive; + assert(!archive.Load(data.data(), data.size(), limits)); + assert(!archive.IsValid()); + assert(!Serializable::ValidateBinaryArchive( + data.data(), data.size(), limits + )); + } + + // Default traversal policy also rejects unreasonable nesting without + // constructing a SerializationNode tree. + { + auto data = DeepObject(64); + assert(!Serializable::ValidateBinaryArchive( + data.data(), data.size() + )); + } + + // Reject collection counts before attempting to construct their children. + { + auto data = Header(); + data.push_back(static_cast( + Serializable::SerializationNodeType::Object + )); + AppendU16(data, 64); + + Serializable::BinaryArchiveDecodeLimits limits; + limits.MaximumObjectMembers = 8; + Serializable::BinaryArchive archive; + assert(!archive.Load(data.data(), data.size(), limits)); + assert(!Serializable::ValidateBinaryArchive( + data.data(), data.size(), limits + )); + } + + { + auto data = Header(); + data.push_back(static_cast( + Serializable::SerializationNodeType::Array + )); + AppendU32(data, 1024); + + Serializable::BinaryArchiveDecodeLimits limits; + limits.MaximumArrayElements = 16; + Serializable::BinaryArchive archive; + assert(!archive.Load(data.data(), data.size(), limits)); + assert(!Serializable::ValidateBinaryArchive( + data.data(), data.size(), limits + )); + } + + // Reject oversized names and values before allocating/copying them. + { + auto data = Header(); + data.push_back(static_cast( + Serializable::SerializationNodeType::Object + )); + AppendU16(data, 1); + AppendU16(data, 32); + data.insert(data.end(), 32, 'n'); + data.push_back(static_cast( + Serializable::SerializationNodeType::Null + )); + + Serializable::BinaryArchiveDecodeLimits limits; + limits.MaximumNameLength = 8; + Serializable::BinaryArchive archive; + assert(!archive.Load(data.data(), data.size(), limits)); + assert(!Serializable::ValidateBinaryArchive( + data.data(), data.size(), limits + )); + } + + { + auto data = Header(); + data.push_back(static_cast( + Serializable::SerializationNodeType::Object + )); + AppendU16(data, 1); + AppendU16(data, 1); + data.push_back('s'); + data.push_back(static_cast( + Serializable::SerializationNodeType::String + )); + AppendU32(data, 128); + data.insert(data.end(), 128, 'x'); + + Serializable::BinaryArchiveDecodeLimits limits; + limits.MaximumStringLength = 16; + Serializable::BinaryArchive archive; + assert(!archive.Load(data.data(), data.size(), limits)); + assert(!Serializable::ValidateBinaryArchive( + data.data(), data.size(), limits + )); + } + + // Aggregate node budget catches broad-but-individually-valid trees. + { + auto data = Header(); + data.push_back(static_cast( + Serializable::SerializationNodeType::Object + )); + AppendU16(data, 4); + for (int i = 0; i < 4; ++i) { + AppendU16(data, 1); + data.push_back(static_cast('a' + i)); + data.push_back(static_cast( + Serializable::SerializationNodeType::Null + )); + } + + Serializable::BinaryArchiveDecodeLimits limits; + limits.MaximumTotalNodes = 3; + Serializable::BinaryArchive archive; + assert(!archive.Load(data.data(), data.size(), limits)); + assert(!Serializable::ValidateBinaryArchive( + data.data(), data.size(), limits + )); + } + + // Normal data remains accepted under default limits by both tree-building + // loading and the allocation-free traversal API. + { + Serializable::BinaryArchive archive; + assert(archive.Load(bd)); + uint32_t value = 0; + assert(archive.Read("x", value)); + assert(value == 42); + + CountingVisitor visitor; + assert(Serializable::TraverseBinaryArchive( + bd.data(), bd.size(), visitor + )); + assert(visitor.Properties == 1); + assert(visitor.UnsignedValues == 1); + } + + // Stress allocation-free validation with arbitrary bytes. Rejection is + // expected for almost all inputs; bounded completion without state + // construction is the property under test. + { + std::mt19937 rng(0x42545256u); + std::vector bytes(512); + for (unsigned iteration = 0; iteration < 5000; ++iteration) { + const std::size_t size = 1 + (rng() % bytes.size()); + for (std::size_t i = 0; i < size; ++i) { + bytes[i] = static_cast(rng()); + } + (void)Serializable::ValidateBinaryArchive( + bytes.data(), size + ); + } + } + + return 0; +}