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
23 changes: 21 additions & 2 deletions src/serialize.h
Original file line number Diff line number Diff line change
Expand Up @@ -435,12 +435,31 @@ void WriteFixedBitSet(Stream& s, const std::vector<bool>& vec, size_t size)
s.write(AsBytes(Span{vBytes}));
}

template<typename Stream>
/** A stream that can report how many bytes are still available to read.
*
* size() must mean bytes *remaining*, not the total the stream ever held. ReadFixedBitSet
* relies on that to bound a wire-declared bit count before allocating, so a stream whose
* size() means anything else would silently weaken the bound rather than fail to compile.
*/
template<typename S>
concept SizedStream = requires(const S& s) { { s.size() } -> std::convertible_to<size_t>; };

template<SizedStream Stream>
void ReadFixedBitSet(Stream& s, std::vector<bool>& vec, size_t size)
{
const size_t nbytes = (size + 7) / 8;
// Bound the wire-declared length against the bytes actually left in the stream before
// allocating anything. Otherwise a handful of bytes declaring millions of bits forces a
// multi-megabyte resize and zero-fill that is only abandoned when the short read throws.
// A well-formed message always carries exactly the required bytes, so this rejects only
// claims that could never have been satisfied.
if (nbytes > s.size()) {
throw std::ios_base::failure("ReadFixedBitSet(): declared size exceeds remaining bytes");
}

vec.resize(size);

std::vector<uint8_t> vBytes((size + 7) / 8);
std::vector<uint8_t> vBytes(nbytes);
s.read(AsWritableBytes(Span{vBytes}));
for (size_t p = 0; p < size; p++)
vec[p] = (vBytes[p / 8] & (1 << (p % 8))) != 0;
Expand Down
48 changes: 48 additions & 0 deletions src/test/serialize_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@

#include <stdint.h>

#include <ios>
#include <limits>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>

Expand Down Expand Up @@ -183,6 +185,52 @@ BOOST_AUTO_TEST_CASE(vector_bool)
BOOST_CHECK(SerializeHash(vec1) == SerializeHash(vec2));
}


//! The message the bound throws. Matching it exactly keeps these tests from passing on an
//! unrelated short read, which is the very failure mode the bound replaces.
static constexpr std::string_view BOUND_REJECTION{"declared size exceeds remaining bytes"};

/**
* DYNBITSET must not allocate from an attacker-declared CompactSize when the remaining stream
* is far too small to hold the claimed bit payload. A handful of bytes claiming ~1e6 bits is
* the amplification primitive: ReadCompactSize permits up to 33,554,432, which would resize a
* std::vector<bool> to ~4 MiB and allocate another ~4 MiB byte buffer before the short read
* throws. The claim below is deliberately modest so the pre-fix path also stays safe on CI.
*/
BOOST_AUTO_TEST_CASE(dynbitset_rejects_oversized_declared_length)
{
constexpr uint64_t kClaimedBits = 1'000'000;

CDataStream s(SER_NETWORK, PROTOCOL_VERSION);
WriteCompactSize(s, kClaimedBits);
// No bit payload follows, so the remaining size is zero.

std::vector<bool> bits;
BOOST_CHECK_EXCEPTION(s >> DYNBITSET(bits), std::ios_base::failure,
HasReason(std::string{BOUND_REJECTION}));
// Rejection has to precede the resize, so the destination must still hold its exact
// pre-deserialization state. Merely falling short of the declared size would also be
// satisfied by an allocation that happened and was then abandoned.
BOOST_CHECK(bits.empty());
}

/** The largest bit count accepted by ReadCompactSize must still round-trip unchanged. */
BOOST_AUTO_TEST_CASE(dynbitset_accepts_maximum_size)
{
constexpr size_t kSize = MAX_SIZE;
std::vector<bool> original(kSize, false);
for (size_t i = 0; i < kSize; i += 3) {
original[i] = true;
}

CDataStream s(SER_NETWORK, PROTOCOL_VERSION);
s << DYNBITSET(original);

std::vector<bool> decoded;
s >> DYNBITSET(decoded);
BOOST_CHECK(decoded == original);
}
Comment on lines +217 to +232

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💬 Nitpick: Testing documentation no longer matches the revised cases

The latest delta intentionally replaces the LLMQ-sized round trip with a MAX_SIZE round trip and removes the CFinalCommitment deserialization case following maintainer feedback. The PR's testing section still claims both removed cases, while the current suite contains only the raw DYNBITSET rejection and maximum-size round trip. Commit aa1447f also describes only the SizedStream constraint despite containing these test-scope revisions. Update the PR testing section to match the current cases and, if the commit remains structured this way, note the test cleanup in its body so neither the PR nor the history claims integration coverage that is no longer present.

source: ['claude', 'codex']


BOOST_AUTO_TEST_CASE(noncanonical)
{
// Write some non-canonical CompactSize encodings, and
Expand Down
Loading