Describe the bug
Store::load never terminates when an entry decodes to a collection of zero-sized elements and its length prefix is corrupt. For example a Vec<()> entry whose length prefix claims u64::MAX elements: the decoder reads the length, then constructs that many zero-byte elements, consuming no input per element. The loop runs inside a single deserialize_from call, so it never returns and memory does not grow, it simply spins.
This is distinct from #2307. There the changeset type itself decoded to zero bytes, the file offset never advanced between entries, and the fix in #2352 detects that from the outside. Here the non-termination happens within one entry, before control returns to EntryIter::next, so an offset check between entries cannot observe it.
Neither fix currently in flight catches it, and I verified both:
This is not resolved by the postcard migration in #2258. I tested postcard directly, encoding u64::MAX as a sequence-length varint (10 bytes) and deserializing Vec<()> from it. It hangs the same way. Length-prefixing bounds an entry's byte count, not its element count, so a format that permits zero-sized elements is unbounded regardless of which of the two serializers is used.
The shape is reachable rather than hypothetical. impl<T> Merge for Vec<T> in bdk_core is unconditional, so Store<Vec<()>> is a valid type, and BTreeSet<()> qualifies too since () is Ord. No bdk_chain changeset has this shape today, so the practical exposure is limited to downstream users of the generic API.
Credit to @Manuel1234477, who found this and the with_limit interaction while reviewing #2352.
To Reproduce
use bdk_file_store::Store;
const MAGIC: &[u8] = b"bdk_test_magic";
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("db");
let mut bytes = MAGIC.to_vec();
bytes.push(0xfd); // varint tag: a u64 follows
bytes.extend_from_slice(&u64::MAX.to_le_bytes());
std::fs::write(&path, &bytes).unwrap();
let _ = Store::<Vec<()>>::load(MAGIC, &path); // never returns
Run it on a background thread with a timeout, otherwise the test harness hangs with it.
Expected behavior
Store::load should return an error for an entry whose declared element count cannot be satisfied, rather than spinning indefinitely.
Notes on fixing
I could not find a clean fix at the file_store layer, and would value input on whether one exists:
- Wrapping the reader in
io::Take does not help. Deserializing () never calls read, so a byte limit is never reached.
- bincode 1.x exposes no cap on sequence length.
Options covers endianness, varint/fixint, trailing-byte policy and with_limit, none of which bound element count. bincode is unmaintained, so no upstream option is coming.
- Capping element count against the remaining file length works for sized elements but cannot work here by construction, since zero-sized elements consume no bytes and so have no ratio to check against.
- A wrapping
serde::Deserializer that intercepts deserialize_seq and bounds SeqAccess could work, but needs an arbitrary cap and would have to wrap every nested sequence.
Rejecting a declared element count that exceeds the remaining bytes, while treating zero-sized element types as a special case, may be the only tractable option, but it is a behaviour change for the generic API rather than a pure bug fix.
Build environment
Describe the bug
Store::loadnever terminates when an entry decodes to a collection of zero-sized elements and its length prefix is corrupt. For example aVec<()>entry whose length prefix claimsu64::MAXelements: the decoder reads the length, then constructs that many zero-byte elements, consuming no input per element. The loop runs inside a singledeserialize_fromcall, so it never returns and memory does not grow, it simply spins.This is distinct from #2307. There the changeset type itself decoded to zero bytes, the file offset never advanced between entries, and the fix in #2352 detects that from the outside. Here the non-termination happens within one entry, before control returns to
EntryIter::next, so an offset check between entries cannot observe it.Neither fix currently in flight catches it, and I verified both:
deserialize_fromdoes not return.with_limit(256 MiB)in fix(file_store): return error instead of panicking on oversized entry #2351 does not trip. bincode's limit counts bytes read, and zero-sized elements read zero bytes, so the counter never advances no matter how many elements are constructed. I confirmed this by applying that limit to the fix(file_store): prevent infinite loop on zero-width changeset types #2352 branch, where the hang persists.This is not resolved by the postcard migration in #2258. I tested
postcarddirectly, encodingu64::MAXas a sequence-length varint (10 bytes) and deserializingVec<()>from it. It hangs the same way. Length-prefixing bounds an entry's byte count, not its element count, so a format that permits zero-sized elements is unbounded regardless of which of the two serializers is used.The shape is reachable rather than hypothetical.
impl<T> Merge for Vec<T>inbdk_coreis unconditional, soStore<Vec<()>>is a valid type, andBTreeSet<()>qualifies too since()isOrd. Nobdk_chainchangeset has this shape today, so the practical exposure is limited to downstream users of the generic API.Credit to @Manuel1234477, who found this and the
with_limitinteraction while reviewing #2352.To Reproduce
Run it on a background thread with a timeout, otherwise the test harness hangs with it.
Expected behavior
Store::loadshould return an error for an entry whose declared element count cannot be satisfied, rather than spinning indefinitely.Notes on fixing
I could not find a clean fix at the
file_storelayer, and would value input on whether one exists:io::Takedoes not help. Deserializing()never callsread, so a byte limit is never reached.Optionscovers endianness, varint/fixint, trailing-byte policy andwith_limit, none of which bound element count. bincode is unmaintained, so no upstream option is coming.serde::Deserializerthat interceptsdeserialize_seqand boundsSeqAccesscould work, but needs an arbitrary cap and would have to wrap every nested sequence.Rejecting a declared element count that exceeds the remaining bytes, while treating zero-sized element types as a special case, may be the only tractable option, but it is a behaviour change for the generic API rather than a pure bug fix.
Build environment
bdk_file_store0.22.0