Files
scylladb/test/boost/managed_bytes_test.cc
Botond Dénes a2fff12bcd utils: add chunked_string
A thin facade over managed_bytes[_view], offering some extra
convenience for working with strings, as well as a strong type
communicating the purpose (storing text instead of a blob).

Also introduces utils::from_hex(chunked_string_view), a fragmented
hex-decode that operates directly on a chunked_string_view without
requiring linearization. Hex pairs straddling fragment boundaries
are handled via a carry-over nibble.
2026-05-26 09:08:05 +03:00

658 lines
23 KiB
C++

/*
* Copyright (C) 2021-present ScyllaDB
*/
/*
* SPDX-License-Identifier: LicenseRef-ScyllaDB-Source-Available-1.1
*/
#define BOOST_TEST_MODULE core
#include "utils/managed_bytes.hh"
#include "utils/chunked_string.hh"
#include "utils/serialization.hh"
#include "test/lib/random_utils.hh"
#include <boost/test/unit_test.hpp>
#include <iterator>
#include <memory>
#include <unordered_set>
struct fragmenting_allocation_strategy : standard_allocation_strategy {
size_t allocated_bytes = 0;
std::unordered_set<void*> allocations;
fragmenting_allocation_strategy(size_t n) {
_preferred_max_contiguous_allocation = n;
}
virtual void* alloc(migrate_fn mf, size_t size, size_t alignment) override {
BOOST_CHECK_LE(size, _preferred_max_contiguous_allocation);
void* addr = standard_allocation_strategy::alloc(mf, size, alignment);
allocated_bytes += size;
allocations.insert(addr);
return addr;
}
virtual void free(void* ptr, size_t size) override {
BOOST_CHECK_EQUAL(allocations.erase(ptr), 1);
standard_allocation_strategy::free(ptr, size);
}
virtual void free(void* ptr) override {
BOOST_CHECK_EQUAL(allocations.erase(ptr), 1);
standard_allocation_strategy::free(ptr);
}
~fragmenting_allocation_strategy() {
BOOST_CHECK_EQUAL(allocations.size(), 0);
}
};
constexpr size_t alloc_size = 63;
const std::vector<size_t> sizes = {
0, 1, 15, 16,
alloc_size - 1, alloc_size, alloc_size + 1,
alloc_size * 4 - 1, alloc_size * 4, alloc_size * 4 + 1,
};
using mbv = managed_bytes_view;
BOOST_AUTO_TEST_CASE(test_uses_current_allocator) {
fragmenting_allocation_strategy fragmenting_allocator(alloc_size);
with_allocator(fragmenting_allocator, [&] {
auto m = managed_bytes(managed_bytes::initialized_later(), 123);
BOOST_CHECK_GT(fragmenting_allocator.allocated_bytes, 0);
for (bytes_view frag : fragment_range(mbv(m))) {
BOOST_CHECK_LE(frag.size(), alloc_size);
}
});
}
BOOST_AUTO_TEST_CASE(test_default_constructor) {
fragmenting_allocation_strategy fragmenting_allocator(alloc_size);
with_allocator(fragmenting_allocator, [] {
auto m = managed_bytes();
BOOST_CHECK_EQUAL(to_bytes(m), bytes());
});
}
BOOST_AUTO_TEST_CASE(test_constructor_from_bytes) {
fragmenting_allocation_strategy fragmenting_allocator(alloc_size);
with_allocator(fragmenting_allocator, [] {
for (size_t size : sizes) {
auto b = tests::random::get_bytes(size);
auto m = managed_bytes(b);
BOOST_CHECK_EQUAL(to_bytes(m), b);
}
});
}
BOOST_AUTO_TEST_CASE(test_copy_constructor) {
fragmenting_allocation_strategy alloc_1(alloc_size);
fragmenting_allocation_strategy alloc_2(alloc_size + 1);
for (size_t size : sizes) {
auto b = tests::random::get_bytes(size);
with_allocator(alloc_1, [&] {
auto m = managed_bytes(b);
auto m_copy_same_allocator = m;
with_allocator(alloc_2, [&] {
auto m_copy_different_allocator = m;
BOOST_CHECK_EQUAL(m, m_copy_same_allocator);
BOOST_CHECK_EQUAL(m, m_copy_different_allocator);
});
});
}
}
// Reproducer for #23781.
// Simulates a copy of a large cell from the standard allocator to lsa.
BOOST_AUTO_TEST_CASE(test_copy_constructor_standard_to_lsa) {
fragmenting_allocation_strategy alloc_1(128 * 1024);
fragmenting_allocation_strategy alloc_2(128 * 1024 / 10);
for (size_t size : {16*1024, 32*1024, 64*1024, 128*1024, 256*1024}) {
auto b = tests::random::get_bytes(size);
with_allocator(alloc_1, [&] {
auto m = managed_bytes(b);
with_allocator(alloc_2, [&] {
auto m_copy_different_allocator = m;
BOOST_CHECK_EQUAL(m, m_copy_different_allocator);
});
});
}
}
BOOST_AUTO_TEST_CASE(test_move_constructor) {
fragmenting_allocation_strategy alloc_1(alloc_size);
fragmenting_allocation_strategy alloc_2(alloc_size + 1);
for (size_t size : sizes) {
auto b = tests::random::get_bytes(size);
with_allocator(alloc_1, [&] {
auto m = managed_bytes(b);
auto m_copy_same_allocator = m;
with_allocator(alloc_2, [&] {
auto m_copy_different_allocator = m;
BOOST_CHECK_EQUAL(m, m_copy_same_allocator);
BOOST_CHECK_EQUAL(m, m_copy_different_allocator);
});
});
}
}
BOOST_AUTO_TEST_CASE(test_copy_assignment) {
fragmenting_allocation_strategy fragmenting_allocator(alloc_size);
with_allocator(fragmenting_allocator, [] {
for (size_t size : sizes) {
auto b = tests::random::get_bytes(size);
auto m1 = managed_bytes(b);
auto m2 = m1;
BOOST_CHECK_EQUAL(to_bytes(m2), b);
}
});
}
BOOST_AUTO_TEST_CASE(test_move_assigment) {
fragmenting_allocation_strategy fragmenting_allocator(alloc_size);
with_allocator(fragmenting_allocator, [] {
for (size_t size : sizes) {
auto b = tests::random::get_bytes(size);
auto m1 = managed_bytes(b);
managed_bytes m2;
m2 = std::move(m1);
BOOST_CHECK_EQUAL(to_bytes(m2), b);
}
});
}
BOOST_AUTO_TEST_CASE(test_operator_bracket) {
fragmenting_allocation_strategy fragmenting_allocator(alloc_size);
with_allocator(fragmenting_allocator, [] {
for (size_t size : sizes) {
if (size == 0) {
continue;
}
auto b = tests::random::get_bytes(size);
auto m = managed_bytes(b);
BOOST_CHECK_EQUAL(m[0], b[0]);
BOOST_CHECK_EQUAL(m[b.size() - 1], b[b.size() - 1]);
BOOST_CHECK_EQUAL(mbv(m)[0], b[0]);
BOOST_CHECK_EQUAL(mbv(m)[b.size() - 1], b[b.size() - 1]);
}
});
}
BOOST_AUTO_TEST_CASE(test_size) {
fragmenting_allocation_strategy fragmenting_allocator(alloc_size);
with_allocator(fragmenting_allocator, [] {
for (size_t size : sizes) {
auto b = tests::random::get_bytes(size);
auto m = managed_bytes(b);
BOOST_CHECK_EQUAL(m.size(), b.size());
BOOST_CHECK_EQUAL(mbv(m).size(), b.size());
}
});
}
BOOST_AUTO_TEST_CASE(test_external_memory_usage) {
for (size_t size : sizes) {
fragmenting_allocation_strategy fragmenting_allocator(alloc_size);
with_allocator(fragmenting_allocator, [&] {
auto b = tests::random::get_bytes(size);
auto m = managed_bytes(b);
BOOST_CHECK_EQUAL(m.external_memory_usage(), fragmenting_allocator.allocated_bytes);
});
}
}
BOOST_AUTO_TEST_CASE(test_with_linearized) {
fragmenting_allocation_strategy fragmenting_allocator(alloc_size);
for (size_t size : sizes) {
with_allocator(fragmenting_allocator, [&] {
auto b = tests::random::get_bytes(size);
auto m = managed_bytes(b);
m.with_linearized([&] (bytes_view bv) {
BOOST_CHECK_EQUAL(bv, b);
});
});
}
}
BOOST_AUTO_TEST_CASE(test_equality) {
fragmenting_allocation_strategy alloc_1(alloc_size);
fragmenting_allocation_strategy alloc_2(alloc_size + 1);
for (size_t size : sizes) {
auto b = tests::random::get_bytes(size);
with_allocator(alloc_1, [&] {
auto m1 = managed_bytes(b);
with_allocator(alloc_2, [&] {
auto m2 = managed_bytes(b);
using mbv = managed_bytes_view;
BOOST_CHECK_EQUAL(m1, m1);
BOOST_CHECK_EQUAL(mbv(m1), mbv(m1));
BOOST_CHECK_EQUAL(m1, m2);
BOOST_CHECK_EQUAL(mbv(m1), mbv(m2));
BOOST_CHECK_EQUAL(m2, m1);
BOOST_CHECK_EQUAL(mbv(m2), mbv(m1));
});
});
}
}
BOOST_AUTO_TEST_CASE(test_inequality) {
for (size_t size : sizes) {
if (size == 0) {
continue;
}
auto b1 = tests::random::get_bytes(size);
// Same size, different content
auto b2 = b1;
b2.back() ^= 1;
// Different size, same prefix
auto b3 = b1;
b3.append(reinterpret_cast<const bytes::value_type*>("$"), 1);
fragmenting_allocation_strategy fragmenting_allocator(alloc_size);
with_allocator(fragmenting_allocator, [&] {
auto m1 = managed_bytes(b1);
auto m2 = managed_bytes(b2);
auto m3 = managed_bytes(b3);
BOOST_CHECK_NE(m1, m2);
BOOST_CHECK_NE(m2, m1);
BOOST_CHECK_NE(m1, m3);
BOOST_CHECK_NE(m3, m1);
BOOST_CHECK_NE(mbv(m1), mbv(m2));
BOOST_CHECK_NE(mbv(m2), mbv(m1));
BOOST_CHECK_NE(mbv(m1), mbv(m3));
BOOST_CHECK_NE(mbv(m3), mbv(m1));
});
}
}
BOOST_AUTO_TEST_CASE(test_prefix) {
fragmenting_allocation_strategy fragmenting_allocator(alloc_size);
with_allocator(fragmenting_allocator, [&] {
for (size_t bytes_size : sizes) {
auto b = tests::random::get_bytes(bytes_size);
auto m = managed_bytes(b);
for (size_t prefix_size : sizes) {
if (prefix_size > bytes_size) {
continue;
}
mbv prefix = mbv(m).prefix(prefix_size);
mbv suffix = mbv(m);
suffix.remove_prefix(prefix_size);
BOOST_CHECK_EQUAL(prefix.size(), prefix_size);
BOOST_CHECK_EQUAL(suffix.size(), bytes_size - prefix_size);
BOOST_CHECK_EQUAL(to_bytes(prefix) + to_bytes(suffix), b);
}
}
});
}
BOOST_AUTO_TEST_CASE(test_remove_current) {
fragmenting_allocation_strategy fragmenting_allocator(alloc_size);
with_allocator(fragmenting_allocator, [&] {
for (size_t bytes_size : sizes) {
if (bytes_size == 0) {
continue;
}
auto b = tests::random::get_bytes(bytes_size);
auto m = managed_bytes(b);
auto v = mbv(m);
size_t prefix_size = v.current_fragment().size();
mbv prefix = v.current_fragment();
mbv suffix = v;
suffix.remove_current();
BOOST_CHECK_EQUAL(prefix.size(), prefix_size);
BOOST_CHECK_EQUAL(suffix.size(), bytes_size - prefix_size);
BOOST_CHECK_EQUAL(to_bytes(prefix) + to_bytes(suffix), b);
}
});
}
BOOST_AUTO_TEST_CASE(test_empty_mbv) {
mbv v;
BOOST_CHECK_EQUAL(to_bytes(v), bytes());
BOOST_CHECK_EQUAL(v.current_fragment(), bytes_view());
}
BOOST_AUTO_TEST_CASE(test_hash) {
fragmenting_allocation_strategy fragmenting_allocator(alloc_size);
with_allocator(fragmenting_allocator, [&] {
for (size_t size : sizes) {
auto b = tests::random::get_bytes(size);
auto m = managed_bytes(b);
auto v = managed_bytes_view(m);
auto hash = std::hash<bytes>()(b);
BOOST_CHECK_EQUAL(std::hash<bytes_view>()(bytes_view(b)), hash);
BOOST_CHECK_EQUAL(std::hash<managed_bytes>()(m), hash);
BOOST_CHECK_EQUAL(std::hash<managed_bytes_view>()(v), hash);
}
});
}
BOOST_AUTO_TEST_CASE(test_write_fragmented) {
fragmenting_allocation_strategy fragmenting_allocator(alloc_size);
with_allocator(fragmenting_allocator, [&] {
for (size_t size : sizes) {
auto b = tests::random::get_bytes(size);
auto m1 = managed_bytes(b);
auto m1v = managed_bytes_view(m1);
auto m2 = managed_bytes(managed_bytes::initialized_later(), b.size());
auto m2v = managed_bytes_mutable_view(m2);
write_fragmented(m2v, m1v);
BOOST_CHECK_EQUAL(m1, m2);
}
});
}
BOOST_AUTO_TEST_CASE(test_write) {
bytes b1(bytes::initialized_later(), 64);
{
auto out = b1.begin();
for (uint64_t i = 0; i < 8; ++i) {
write<uint64_t>(out, i * 0x0101010101010101ull);
}
}
// As of now, managed_bytes has 24 bytes of overhead per fragment.
for (int fragment_size : {25, 32, 48, 128}) {
fragmenting_allocation_strategy fragmenting_allocator(fragment_size);
with_allocator(fragmenting_allocator, [&] {
managed_bytes b2(managed_bytes::initialized_later(), 64);
auto out = managed_bytes_mutable_view(b2);
for (uint64_t i = 0; i < 8; ++i) {
write<uint64_t>(out, i * 0x0101010101010101ull);
}
BOOST_CHECK_EQUAL(b1, to_bytes(b2));
});
}
}
BOOST_AUTO_TEST_CASE(test_appending_hash) {
struct identity_hasher {
bytes b;
void update(const char *ptr, size_t size) noexcept {
b += bytes(reinterpret_cast<const bytes::value_type*>(ptr), size);
}
bytes finalize() {
return b;
}
};
fragmenting_allocation_strategy fragmenting_allocator(alloc_size);
with_allocator(fragmenting_allocator, [&] {
for (size_t size : sizes) {
auto b = tests::random::get_bytes(size);
managed_bytes m(b);
identity_hasher h1;
feed_hash(h1, mbv(m));
identity_hasher h2;
feed_hash(h2, b);
BOOST_CHECK_EQUAL(h1.finalize(), h2.finalize());
}
});
}
BOOST_AUTO_TEST_CASE(test_to_hex) {
fragmenting_allocation_strategy fragmenting_allocator(alloc_size);
with_allocator(fragmenting_allocator, [&] {
for (size_t size : sizes) {
auto b = tests::random::get_bytes(size);
managed_bytes m(b);
BOOST_CHECK_EQUAL(to_hex(b), to_hex(m));
}
});
}
static constexpr bool constexpr_managed_bytes() {
managed_bytes mb1;
auto mb2 = std::move(mb1);
return std::is_constant_evaluated();
}
static_assert(constexpr_managed_bytes());
// Factory for managed_bytes instances whose backing memory is allocated
// through a fragmenting_allocation_strategy. The factory must outlive
// all managed_bytes it produces. Returned pointers use a custom deleter
// that frees through the factory's allocator.
class managed_bytes_factory {
fragmenting_allocation_strategy _alloc;
struct deleter {
fragmenting_allocation_strategy* alloc;
void operator()(managed_bytes* mb) const {
with_allocator(*alloc, [&] {
delete mb;
});
}
};
public:
using pointer = std::unique_ptr<managed_bytes, deleter>;
// frag_size is the desired *data payload* per fragment. The allocator
// limit must also account for the per-fragment metadata overhead so that
// managed_bytes' internal max_seg() computes back to frag_size.
managed_bytes_factory(size_t frag_size)
: _alloc(frag_size + std::max(sizeof(multi_chunk_blob_storage), sizeof(single_chunk_blob_storage)))
{}
// Build a managed_bytes holding `data`, fragmented so that each
// fragment is exactly the configured frag_size bytes (the last
// fragment may be shorter).
pointer make(bytes_view data) {
managed_bytes* mb;
with_allocator(_alloc, [&] {
mb = new managed_bytes(data);
});
return pointer(mb, deleter{&_alloc});
}
};
// Fragment size used by byte_iterator tests: small enough to guarantee many
// chunk boundaries for 100-byte inputs (4-byte payload → 25 fragments, 24
// cross-chunk transitions).
static constexpr size_t iter_frag_size = 4;
// Convenience: cast a managed_bytes value_type (int8_t) to uint8_t for
// comparison against bytes literals without sign-extension surprises.
static uint8_t as_u8(managed_bytes_view::value_type v) {
return static_cast<uint8_t>(v);
}
BOOST_AUTO_TEST_CASE(test_byte_iterator_empty) {
// An empty managed_bytes view has begin() == end() and yields no elements.
managed_bytes mb;
auto v = managed_bytes_view(mb);
BOOST_CHECK(v.begin() == v.end());
BOOST_CHECK(!(v.begin() != v.end()));
}
BOOST_AUTO_TEST_CASE(test_byte_iterator_forward_traversal) {
// Forward iteration with pre-increment visits every byte in order,
// correctly crossing chunk boundaries.
managed_bytes_factory factory(iter_frag_size);
auto b = tests::random::get_bytes(100);
auto mb = factory.make(b);
auto v = managed_bytes_view(*mb);
size_t idx = 0;
for (auto it = v.begin(); it != v.end(); ++it, ++idx) {
BOOST_CHECK_EQUAL(as_u8(*it), as_u8(b[idx]));
}
BOOST_CHECK_EQUAL(idx, b.size());
}
BOOST_AUTO_TEST_CASE(test_byte_iterator_post_increment) {
// Post-increment returns a copy of the iterator before advancing, while
// the original moves forward.
managed_bytes_factory factory(iter_frag_size);
auto b = tests::random::get_bytes(100);
auto mb = factory.make(b);
auto v = managed_bytes_view(*mb);
auto it = v.begin();
for (size_t i = 0; i < b.size(); ++i) {
auto prev = it++;
BOOST_CHECK_EQUAL(as_u8(*prev), as_u8(b[i]));
}
BOOST_CHECK(it == v.end());
}
BOOST_AUTO_TEST_CASE(test_byte_iterator_equality) {
managed_bytes_factory factory(iter_frag_size);
auto b = tests::random::get_bytes(100);
auto mb = factory.make(b);
auto v = managed_bytes_view(*mb);
// begin() == begin(), end() == end()
BOOST_CHECK(v.begin() == v.begin());
BOOST_CHECK(v.end() == v.end());
// begin() != end() for non-empty view
BOOST_CHECK(v.begin() != v.end());
// Two iterators advanced the same number of steps compare equal.
auto it1 = v.begin();
auto it2 = v.begin();
for (size_t i = 0; i < 13; ++i) { ++it1; ++it2; }
BOOST_CHECK(it1 == it2);
++it1;
BOOST_CHECK(it1 != it2);
}
BOOST_AUTO_TEST_CASE(test_byte_iterator_distance) {
// operator- returns the signed distance between two iterators.
managed_bytes_factory factory(iter_frag_size);
auto b = tests::random::get_bytes(100);
auto mb = factory.make(b);
auto v = managed_bytes_view(*mb);
auto begin = v.begin();
auto end = v.end();
// Distance from end to begin equals -(size). Note the subtraction is
// defined as (lhs._pos - rhs._pos) cast to ptrdiff_t — see the
// implementation — so end - begin == size.
BOOST_CHECK_EQUAL(end - begin, static_cast<std::ptrdiff_t>(b.size()));
BOOST_CHECK_EQUAL(begin - end, -static_cast<std::ptrdiff_t>(b.size()));
// Advance to a mid-point and verify partial distances.
auto mid = v.begin();
for (size_t i = 0; i < 37; ++i) {
++mid;
}
BOOST_CHECK_EQUAL(mid - begin, 37);
BOOST_CHECK_EQUAL(end - mid, static_cast<std::ptrdiff_t>(b.size()) - 37);
}
BOOST_AUTO_TEST_CASE(test_byte_iterator_mutable) {
// The mutable iterator (iterator, not const_iterator) allows writing
// through operator* and must traverse correctly too.
managed_bytes_factory factory(iter_frag_size);
auto b = tests::random::get_bytes(100);
auto mb = factory.make(b);
auto mv = managed_bytes_mutable_view(*mb);
// Increment every byte by 1 using the mutable forward iterator.
for (auto it = mv.begin(); it != mv.end(); ++it) {
++(*it);
}
// Verify via the immutable view.
auto v = managed_bytes_view(*mb);
size_t idx = 0;
for (auto it = v.begin(); it != v.end(); ++it, ++idx) {
BOOST_CHECK_EQUAL(as_u8(*it), static_cast<uint8_t>(as_u8(b[idx]) + 1));
}
}
// Round-trip helper: encode `data` as hex, store it as a managed_bytes
// fragmented at `frag_size`, run the fragmented from_hex(), and check the
// result equals the original bytes.
static void check_fragmented_from_hex(bytes_view data, size_t frag_size) {
sstring hex = to_hex(data);
bytes_view hex_bv(reinterpret_cast<const int8_t*>(hex.data()), hex.size());
managed_bytes_factory factory(frag_size);
auto fragmented_hex = factory.make(hex_bv);
managed_bytes result = utils::from_hex(utils::chunked_string_view(managed_bytes_view(*fragmented_hex)));
BOOST_CHECK_EQUAL(to_bytes(result), bytes(data));
}
BOOST_AUTO_TEST_CASE(test_fragmented_from_hex_empty) {
// Empty input should produce an empty managed_bytes.
managed_bytes result = utils::from_hex(utils::chunked_string_view());
BOOST_CHECK(result.empty());
}
BOOST_AUTO_TEST_CASE(test_fragmented_from_hex_single_fragment) {
// The entire hex string lives in one fragment — baseline correctness.
for (size_t size : sizes) {
auto b = tests::random::get_bytes(size);
// Large fragment so it never splits.
check_fragmented_from_hex(b, 8192);
}
}
BOOST_AUTO_TEST_CASE(test_fragmented_from_hex_even_fragment_size) {
// Fragment size is even: hex pairs always land inside a single fragment.
for (size_t size : sizes) {
auto b = tests::random::get_bytes(size);
// Fragment size of 4 hex chars == 2 decoded bytes.
check_fragmented_from_hex(b, 4);
check_fragmented_from_hex(b, 8);
check_fragmented_from_hex(b, 64);
}
}
BOOST_AUTO_TEST_CASE(test_fragmented_from_hex_odd_fragment_size) {
// Fragment size is odd: hex pairs regularly straddle fragment boundaries.
// This is the critical case fixed by the carry-over logic.
for (size_t size : sizes) {
auto b = tests::random::get_bytes(size);
check_fragmented_from_hex(b, 1); // every nibble is its own fragment
check_fragmented_from_hex(b, 3);
check_fragmented_from_hex(b, 5);
check_fragmented_from_hex(b, 7);
check_fragmented_from_hex(b, alloc_size); // 63 — odd
check_fragmented_from_hex(b, alloc_size + 2); // 65 — odd
}
}
BOOST_AUTO_TEST_CASE(test_fragmented_from_hex_nibble_per_fragment) {
// Extreme case: fragment size = 1, so every character is its own fragment.
// All pairs straddle boundaries.
auto b = tests::random::get_bytes(16);
check_fragmented_from_hex(b, 1);
}
BOOST_AUTO_TEST_CASE(test_fragmented_from_hex_known_value) {
// Sanity check against a known hex/bytes pair.
// "deadbeef" -> {0xde, 0xad, 0xbe, 0xef}
const sstring hex = "deadbeef";
const bytes expected = {'\xde', '\xad', '\xbe', '\xef'};
for (size_t frag_size : {1, 2, 3, 4, 7, 8, 16}) {
bytes_view hex_bv(reinterpret_cast<const int8_t*>(hex.data()), hex.size());
managed_bytes_factory factory(frag_size);
auto fragmented_hex = factory.make(hex_bv);
managed_bytes result = utils::from_hex(utils::chunked_string_view(managed_bytes_view(*fragmented_hex)));
BOOST_CHECK_EQUAL(to_bytes(result), expected);
}
}
BOOST_AUTO_TEST_CASE(test_fragmented_from_hex_odd_length_throws) {
// A hex string with odd total length must always throw, regardless of
// fragmentation.
const sstring hex = "abc"; // 3 chars — odd
bytes_view hex_bv(reinterpret_cast<const int8_t*>(hex.data()), hex.size());
for (size_t frag_size : {1, 2, 3, 8}) {
managed_bytes_factory factory(frag_size);
auto fragmented_hex = factory.make(hex_bv);
BOOST_CHECK_THROW(utils::from_hex(utils::chunked_string_view(managed_bytes_view(*fragmented_hex))), std::invalid_argument);
}
}