Skip to content

file_store: Store::load never terminates for a collection of zero-sized elements with a corrupt length prefix #2364

Description

@luhrhenz

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

  • bdk_file_store 0.22.0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    • Status
      Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions