Skip to content

fix(io/time_index): cap declared track length to prevent OOM on malformed file#223

Open
itsuzef wants to merge 2 commits into
memvid:mainfrom
itsuzef:fix/time-index-length-cap
Open

fix(io/time_index): cap declared track length to prevent OOM on malformed file#223
itsuzef wants to merge 2 commits into
memvid:mainfrom
itsuzef:fix/time-index-length-cap

Conversation

@itsuzef

@itsuzef itsuzef commented Apr 25, 2026

Copy link
Copy Markdown

Summary

io::time_index::read_track and io::temporal_index::read_track accept an on-disk length and count/entry_count and use them to size up-front allocations before validating any payload. A self-consistent but adversarially constructed .mv2 can declare values that satisfy the structural checks but drive ~16 GiB of allocation per call.

This PR closes that gap on both readers. The 16 GiB ceiling (MAX_TIME_INDEX_BYTES / MAX_TEMPORAL_TRACK_BYTES) is preserved as a defence-in-depth upper bound — the actual fix is to bound the eager allocation to what the file has actually delivered.

Changes

src/io/time_index.rs

  • Add MAX_TIME_INDEX_BYTES = 1 << 34 (16 GiB) and reject declared lengths above it (mirrors the temporal-index reader).
  • Clamp Vec::with_capacity(count) to INITIAL_ENTRIES_CAPACITY = 1024. The vector still grows naturally as read_exact succeeds, so the committed memory tracks the payload actually read.

src/io/temporal_index.rs

  • Drop the body-slurp (vec![0u8; length - HEADER_SIZE]) — records are now streamed one at a time into a fixed-size stack buffer ([u8; MENTION_RECORD_SIZE], [u8; ANCHOR_RECORD_SIZE]), hashed incrementally, and decoded directly into the output vectors.
  • Clamp Vec::with_capacity(entry_count) / Vec::with_capacity(anchor_count) to INITIAL_RECORDS_CAPACITY = 1024. Same growth semantics as above.

Why this matters (response to review)

The previous revision of this PR only enforced the ceiling. A near-cap aligned input (declared_length = 17_179_869_180, count = 1_073_741_823) passed both the ceiling check and the count * 16 == payload_bytes check, so Vec::with_capacity(count) still asked the allocator for ~16 GiB before any payload byte was read. With this revision, the same input commits only INITIAL_ENTRIES_CAPACITY slots up front (~16 KiB) and then fails at the first read_exact with UnexpectedEof.

The same shape existed — and is now fixed — in the temporal-index reader.

Tests

Two new near-cap boundary tests, one per reader. Each constructs the largest aligned, accepted declared length, provides only the header bytes, and asserts that read_track returns Io { UnexpectedEof, .. } rather than completing or OOMing. If the eager-allocation path were still wide open, the test process would have requested ~16 GiB up front and the test would be unrunnable on a normal machine.

  • cargo test --lib — 340/340 pass
  • cargo test --lib --features temporal_track — 346/346 pass
  • cargo clippy --lib --no-deps — clean
  • cargo fmt -- --check — clean

Notes

  • No public API change.
  • The Cargo.lock bump (2.0.1352.0.139) is a lockfile sync with workspace version on main (released in a79dfdd), not an incidental dep upgrade.
  • AI-assistance disclosure: parts of this revision were drafted with the help of an AI coding assistant; all changes were reviewed and tested locally before commit.

…rmed file

read_track allocates Vec<TimeIndexEntry> with capacity derived from the
on-disk count, gated only by `count * 16 == payload_bytes`. A crafted .mv2
with a self-consistent but absurd `bytes_length` in TimeIndexManifest passes
that check and triggers an unbounded allocation before any read_exact would
fail. Mirror the safety ceiling the sibling temporal_index reader already
enforces (16 GiB) and reject oversized lengths up front.

@ForeverAngry ForeverAngry left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I like this patch — the cap is a strict improvement over main. The reason I went request-changes rather than approve is that I think the fix as written closes a narrower window than the PR description implies, and I'd like to talk through that before it lands.

What I saw when I tried to reproduce

Built a small harness using a custom GlobalAlloc that intercepts allocations ≥ 1 GiB and returns a fake aligned pointer (so the test process doesn't actually commit memory), then called memvid_core::io::time_index::read_track with a synthesized 12-byte header and a declared length = 12 + count * 16.

Branch declared_length count Result large_allocations max_alloc (bytes)
main (178e277) 17,179,869,212 1,073,741,825 Io { UnexpectedEof } 1 17,179,869,200
main 17,179,869,180 1,073,741,823 Io { UnexpectedEof } 1 17,179,869,168
this PR (2cc7ea1) 17,179,869,212 InvalidTimeIndex { "length exceeds supported limit" } 0 0
this PR 17,179,869,180 1,073,741,823 Io { UnexpectedEof } 1 17,179,869,168

Results were stable across runs.

Where I think the gap is

MAX_TIME_INDEX_BYTES = 1 << 34 = 17,179,869,184. The reader still does, at src/io/time_index.rs#L112:

let mut entries = Vec::with_capacity(count as usize);

after only checking payload_bytes == count * 16. With length = 17,179,869,180 (≤ cap, payload aligned), count = 1,073,741,823, and sizeof(TimeIndexEntry) = 16, that with_capacity asks the allocator for ~16 GiB before any payload byte has been read. So the property the PR claims to establish — that count-proportional allocation can no longer be driven by file metadata — still holds only for declared lengths over the ceiling. Anything aligned and just under the ceiling exercises the same path, just bounded at 16 GiB instead of unbounded.

The new test (read_rejects_oversized_declared_length) covers MAX_TIME_INDEX_BYTES + 1, which is the easy side. The interesting boundary for a malformed-file invariant is the largest aligned value strictly under the cap.

Things worth considering

A few options, ordered roughly by how invasive they are:

  1. Bound the allocation rather than the declared length — either stream entries into the Vec without with_capacity(count), or clamp the initial capacity (count.min(INITIAL_CAPACITY) as usize) and let Vec grow as read_exact succeeds. That way bogus count only allocates what the file actually delivers.
  2. Add a boundary test at MAX_TIME_INDEX_BYTES - 16 (or whatever the largest aligned, accepted value works out to) that asserts either an early error or bounded allocation. The current test would still pass if the underlying path were wide open.
  3. If the temporal-index reader is the model, it's worth double-checking — src/io/temporal_index.rs does a vec![0u8; (length - HEADER_SIZE) as usize] body read before parsing, so the same near-cap amplifier exists there. Whichever shape we pick, it'd be good to apply it to both readers in one pass.
  4. Minor: the Cargo.lock 2.0.135 → 2.0.139 bump looks incidental to this change. Keeping it out of this diff makes the security-relevant change easier to review on its own and avoids merge churn.

Totally possible the intent here is "raise the ceiling, accept up to 16 GiB of pre-read allocation as the cost of avoiding streaming" — that's a defensible call and I'm happy to revisit on that framing. If so, I think it's worth saying so explicitly in the PR description and dropping a comment near the with_capacity call, because today's framing reads as "prevents OOM" and the data above shows it narrows rather than prevents.

Repro, for reference

// custom GlobalAlloc that intercepts allocations >= 1 GiB and returns a fake aligned
// pointer + AtomicUsize counters for n_calls and max requested size.
let count: u64 = std::env::var("COUNT").ok()
    .and_then(|s| s.parse().ok())
    .unwrap_or((1u64 << 30) + 1);
let declared_length = 12 + count * 16;
let mut buf = Vec::with_capacity(12);
buf.extend_from_slice(&memvid_core::io::time_index::TIME_INDEX_MAGIC);
buf.extend_from_slice(&count.to_le_bytes());
let mut cursor = std::io::Cursor::new(buf);
let err = memvid_core::io::time_index::read_track(&mut cursor, 0, declared_length).unwrap_err();

COUNT=1073741823 against this branch is the case that exercises the gap.

The existing length cap rejects declared lengths above 16 GiB but, for any
self-consistent header whose declared length sits just under the cap, the
readers still reserved count-proportional memory before validating a single
byte of payload:

  - io::time_index::read_track used Vec::with_capacity(count) — up to ~16 GiB
    of pre-read capacity for a hostile count aligned under the ceiling.
  - io::temporal_index::read_track slurped the entire body into a single
    Vec<u8> sized by (length - header) and then allocated two more vectors
    sized by entry_count / anchor_count.

Both readers now bound the eager allocation:

  - time_index: initial Vec capacity clamped to INITIAL_ENTRIES_CAPACITY (1024);
    the vector grows naturally as read_exact succeeds.
  - temporal_index: body is streamed one record at a time into fixed-size
    stack buffers, hashed incrementally, and decoded directly into vectors
    whose capacity is clamped to INITIAL_RECORDS_CAPACITY (1024). The body
    Vec<u8> is gone entirely.

New near-cap tests construct the largest aligned declared length that passes
both the ceiling and the count-consistency checks, provide only the header
bytes, and assert that the reader fails fast at the first body read with
UnexpectedEof. If the eager-allocation path had not been bounded, the test
process would have requested ~16 GiB up front.

Tests:
  - cargo test --lib                                  → 340/340 pass
  - cargo test --lib --features temporal_track        → 346/346 pass
  - cargo clippy --lib --no-deps                      → clean
  - cargo fmt -- --check                              → clean
@itsuzef

itsuzef commented May 24, 2026

Copy link
Copy Markdown
Author

Thanks for the careful repro and the table — that was exactly the right way to push on this. New commit (7a53949) addresses the four bullets:

  1. Bound allocation rather than declared length. Vec::with_capacity(count) in time_index.rs is now clamped to INITIAL_ENTRIES_CAPACITY = 1024; the vector grows naturally as read_exact succeeds. Your COUNT=1073741823 repro now commits ~16 KiB up front instead of ~16 GiB.

  2. Boundary test. Both time_index.rs and temporal_index.rs now have a read_at_cap_does_not_eagerly_allocate_count_proportional_memory test that constructs the largest aligned, accepted declared length and asserts UnexpectedEof from the first body read. If the eager allocation were still wide open, the test process would have requested ~16 GiB and we'd notice immediately.

  3. Temporal-index reader. Same fix applied. The vec![0u8; length - HEADER_SIZE] body slurp is gone — records are now streamed into fixed-size stack buffers ([u8; MENTION_RECORD_SIZE], [u8; ANCHOR_RECORD_SIZE]) and hashed incrementally. The two Vec::with_capacity(count) calls are clamped to 1024 with the same growth semantics.

  4. Cargo.lock bump. Not incidental — it's a lockfile sync with the workspace version on main (2.0.139 shipped in a79dfdd). Leaving it in place so the branch builds cleanly against current main.

PR description updated to reframe the change as "bound allocation to actual payload read" rather than "prevent OOM" — your data showed the original framing was overclaiming, and the new framing matches what the code actually does.

Test results:

  • cargo test --lib — 340/340
  • cargo test --lib --features temporal_track — 346/346
  • cargo clippy --lib --no-deps — clean
  • cargo fmt -- --check — clean

@ForeverAngry

Copy link
Copy Markdown

Thanks for the careful repro and the table — that was exactly the right way to push on this. New commit (7a53949) addresses the four bullets:

  1. Bound allocation rather than declared length. Vec::with_capacity(count) in time_index.rs is now clamped to INITIAL_ENTRIES_CAPACITY = 1024; the vector grows naturally as read_exact succeeds. Your COUNT=1073741823 repro now commits ~16 KiB up front instead of ~16 GiB.
  2. Boundary test. Both time_index.rs and temporal_index.rs now have a read_at_cap_does_not_eagerly_allocate_count_proportional_memory test that constructs the largest aligned, accepted declared length and asserts UnexpectedEof from the first body read. If the eager allocation were still wide open, the test process would have requested ~16 GiB and we'd notice immediately.
  3. Temporal-index reader. Same fix applied. The vec![0u8; length - HEADER_SIZE] body slurp is gone — records are now streamed into fixed-size stack buffers ([u8; MENTION_RECORD_SIZE], [u8; ANCHOR_RECORD_SIZE]) and hashed incrementally. The two Vec::with_capacity(count) calls are clamped to 1024 with the same growth semantics.
  4. Cargo.lock bump. Not incidental — it's a lockfile sync with the workspace version on main (2.0.139 shipped in a79dfdd). Leaving it in place so the branch builds cleanly against current main.

PR description updated to reframe the change as "bound allocation to actual payload read" rather than "prevent OOM" — your data showed the original framing was overclaiming, and the new framing matches what the code actually does.

Test results:

  • cargo test --lib — 340/340
  • cargo test --lib --features temporal_track — 346/346
  • cargo clippy --lib --no-deps — clean
  • cargo fmt -- --check — clean

Great PR though! This is a great catch!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants