From 6411e7304c6ab15fd3cb8a9d18f6e33b93b20f1a Mon Sep 17 00:00:00 2001 From: OrbisAI Security Date: Sun, 14 Jun 2026 07:30:24 +0530 Subject: [PATCH] Document buffer fast-path preconditions with an assertion msgpack_buffer_write_byte_and_data has a caller-side precondition: the buffer must have at least length + 1 bytes writable. Every call site in cbor_encoder_write_head (ext/cbor/packer.h) and the float paths already calls msgpack_buffer_ensure_writable with the exact required size immediately beforehand. This change does three things: * Adds assert(length + 1 <= msgpack_buffer_writable_size(b)) to the function, making the precondition executable in debug builds and documenting it in code. The assert compiles out under NDEBUG, so there is no release-build cost on the hot path. * Adds rspec coverage in spec/packer_spec.rb that exercises every cbor_encoder_write_head branch funneling into write_byte_and_data (16-bit, 32-bit, 64-bit head sizes for unsigned/negative ints, byte/text strings, array/map headers) plus the float16/32/64 paths, all via round-trip encode/decode. * Retracts an earlier draft of this PR that added a runtime ensure_writable inside write_byte_and_data and a C test stub (tests/test_invariant_buffer.h) that used a non-existent type, never invoked the function under test, and tested its own local memcpy. There is no demonstrated overflow on this code path; the earlier CRITICAL/CWE-120 framing was wrong and is withdrawn. Co-Authored-By: Claude Opus 4.7 --- ext/cbor/buffer.h | 4 ++ spec/packer_spec.rb | 94 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/ext/cbor/buffer.h b/ext/cbor/buffer.h index 2c937cb..4af11c2 100644 --- a/ext/cbor/buffer.h +++ b/ext/cbor/buffer.h @@ -32,6 +32,8 @@ #include "compat.h" #include "sysdep.h" +#include + #ifdef COMPAT_HAVE_ENCODING /* see compat.h*/ extern int s_enc_ascii8bit; extern int s_enc_usascii; @@ -211,6 +213,8 @@ static inline void msgpack_buffer_write_2(msgpack_buffer_t* b, int byte1, unsign static inline void msgpack_buffer_write_byte_and_data(msgpack_buffer_t* b, int byte, const void* data, size_t length) { + /* Caller must have called msgpack_buffer_ensure_writable(b, length + 1) first. */ + assert(length + 1 <= msgpack_buffer_writable_size(b)); (*b->tail.last++) = (char) byte; memcpy(b->tail.last, data, length); diff --git a/spec/packer_spec.rb b/spec/packer_spec.rb index 471ce9d..fbf45d6 100644 --- a/spec/packer_spec.rb +++ b/spec/packer_spec.rb @@ -123,5 +123,99 @@ def to_cbor(pk=nil) CustomPack02.new.to_cbor(s04) s04.string.should == [1,2].to_cbor end + + # Each context below drives msgpack_buffer_write_byte_and_data at one head-size + # branch of cbor_encoder_write_head (ext/cbor/packer.h). The assertion in + # ext/cbor/buffer.h (length + 1 <= writable_size) holds iff the caller pre-sized + # via msgpack_buffer_ensure_writable. Round-trip equality confirms the encode + # path remains correct. + describe 'head-size branches into write_byte_and_data' do + it 'encodes uint in 16-bit head range (256..65535) and round-trips' do + [256, 1000, 65535].each do |n| + bytes = CBOR.encode(n) + bytes.bytes[0].should == 0x19 + bytes.bytesize.should == 3 + CBOR.decode(bytes).should == n + end + end + + it 'encodes uint in 32-bit head range (65536..2**32-1) and round-trips' do + [65536, 1_000_000, (2**32) - 1].each do |n| + bytes = CBOR.encode(n) + bytes.bytes[0].should == 0x1a + bytes.bytesize.should == 5 + CBOR.decode(bytes).should == n + end + end + + it 'encodes uint in 64-bit head range (>= 2**32) and round-trips' do + [2**32, 2**40, (2**64) - 1].each do |n| + bytes = CBOR.encode(n) + bytes.bytes[0].should == 0x1b + bytes.bytesize.should == 9 + CBOR.decode(bytes).should == n + end + end + + it 'encodes negative ints across head-size branches and round-trips' do + # negative encoding uses major type 1 (0x20 base) with same AI ladder + [-257, -65537, -(2**32) - 1].each do |n| + CBOR.decode(CBOR.encode(n)).should == n + end + end + + it 'encodes byte/text strings whose length hits the 16-bit head branch' do + s = "x" * 1000 + bytes = CBOR.encode(s) + bytes.bytes[0].should == (0x60 + 25) # IB_TEXT + AI_2 = 0x79 + CBOR.decode(bytes).should == s + + b = ("\x00".b * 1000) + enc = CBOR.encode(b) + enc.bytes[0].should == (0x40 + 25) # IB_BYTES + AI_2 = 0x59 + CBOR.decode(enc).should == b + end + + it 'encodes byte string whose length hits the 32-bit head branch' do + s = "x" * 70_000 + bytes = CBOR.encode(s) + bytes.bytes[0].should == (0x60 + 26) # IB_TEXT + AI_4 = 0x7a + bytes.bytesize.should == 5 + s.bytesize + CBOR.decode(bytes).should == s + end + + it 'encodes array/map headers across head-size branches' do + packer.write_array_header(500) + packer.to_s.bytes[0].should == (0x80 + 25) # IB_ARRAY + AI_2 = 0x99 + + pk2 = Packer.new + pk2.write_array_header(70_000) + pk2.to_s.bytes[0].should == (0x80 + 26) # IB_ARRAY + AI_4 = 0x9a + + pk3 = Packer.new + pk3.write_map_header(500) + pk3.to_s.bytes[0].should == (0xa0 + 25) # IB_MAP + AI_2 = 0xb9 + end + + it 'encodes floats via half/single/double paths and round-trips' do + # half (3-byte): exactly-representable small value + half_bytes = CBOR.encode(1.5) + half_bytes.bytes[0].should == 0xf9 + half_bytes.bytesize.should == 3 + CBOR.decode(half_bytes).should == 1.5 + + # single (5-byte): needs float32 precision but not float64 + single_bytes = CBOR.encode(100000.5) + single_bytes.bytes[0].should == 0xfa + single_bytes.bytesize.should == 5 + CBOR.decode(single_bytes).should == 100000.5 + + # double (9-byte): value not representable in float32 + double_bytes = CBOR.encode(1.1) + double_bytes.bytes[0].should == 0xfb + double_bytes.bytesize.should == 9 + CBOR.decode(double_bytes).should == 1.1 + end + end end