collections: check DynamicBitSetList index and set-op lengths in release builds - #39055
collections: check DynamicBitSetList index and set-op lengths in release builds#39055robobun wants to merge 1 commit into
Conversation
…ase builds DynamicBitSetList::at only debug_assert!ed its index. In release builds an out-of-range index produced a view past the end of the list's buffer, and set() / set_union() wrote through it. DynamicBitSetUnmanaged::zip_masks_raw reads num_masks(self.bit_length) words out of the other operand; set_union / set_intersection / set_exclude guarded that with debug_assert!s and copy_into with nothing, so copying a smaller set into a larger one (DynamicBitSet::copy_into, as used by PackageInstaller::fix_cached_lockfile_package_slices when the lockfile grows during an install) read past the end of the source in every build. - at() asserts the index in all builds and returns a DynamicBitSetListEntry that borrows the list and only derefs immutably, so an entry can neither outlive the list nor resize or free the list's buffer. - zip_masks_raw asserts that the lengths are equal; the callers' debug assertions are removed. - copy_into copies the words both sets have and clears the rest of the destination, so operands of different lengths are well defined. - init_empty sizes the buffer with checked multiplication so the in-bounds argument in at() holds for every n. - Unit tests for the above (cargo test, also run by the Miri job) and a source lint pinning the two checks as release-mode assertions.
|
Updated 12:05 PM PT - Aug 15th, 2026
❌ @robobun, your commit 2dfbe5a has some failures in 🧪 To try this PR locally: bunx bun-pr 39055That installs a local version of the PR into your bun-39055 --bun |
|
Warning Review limit reached
Next review available in: 27 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Status: fix pushed, waiting for CI. Reproduced with the unit tests added in this PR against main's |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it reworks unsafe raw-pointer code (new DynamicBitSetListEntry<'_> abstraction, changed at() return type, and a semantic change to copy_into from UB-on-shorter-source to zero-extend), a maintainer look would still be worthwhile.
What was reviewed:
at()bounds check,checked_mulininit_empty, and thebit_length == 0slot case (one-past-end pointer, zero-length slice — sound).copy_intorewrite:ptr::copyoverlap-safety, padding-zero invariant preserved for both same-word-shorter-source and longer-source paths, and the aliasing case wheremasks_slice_mut()is formed whileother: &Selfis live (no post-copy read throughother.masks).- All
bun_installcall sites (PackageInstaller.rs,isolated_install.rs,hoisted_install.rs) — Deref coercion covers the read paths and the entry's owncopy_into/set_unioncover the write paths;fix_cached_lockfile_package_slicesis exactly the shorter-source shape this fixes. - Source-lint regexes:
HARD_CHECK's negative lookbehind correctly excludesdebug_assert!;fnBodiesbrace-matching handles the nested-braces / doc-comment cases in its self-test.
Extended reasoning...
Overview
This PR hardens src/collections/bit_set.rs against release-build UB by upgrading two debug_assert!-only preconditions to assert!: the index bound in DynamicBitSetList::at and the equal-length precondition in zip_masks_raw (which set_union/set_intersection/set_exclude route through). It also fixes an unconditional out-of-bounds read in DynamicBitSetUnmanaged::copy_into when the source is shorter than the destination — a shape that PackageInstaller::fix_cached_lockfile_package_slices reaches in every build. The at() return type changes from ManuallyDrop<DynamicBitSetUnmanaged> to a new DynamicBitSetListEntry<'_> that ties the view's lifetime to the list and hides resize/deinit behind a curated write surface. 19 Rust unit tests (Miri-run in CI), a compile_fail doctest, and a source-lint test are added.
Security risks
None introduced. The change strictly reduces attack surface: preconditions that guard unsafe pointer arithmetic in safe pub fns now hold in release builds, so an out-of-range tree/package id from a lockfile becomes a panic rather than a heap out-of-bounds write. init_empty gains checked_mul so buf_len cannot silently wrap.
Level of scrutiny
High. This is unsafe Rust manipulating raw heap pointers on a hot install path, with a public API change (new return type for at()), a new public type, and a deliberate semantic change to copy_into (shorter source now zero-extends instead of reading garbage). The reasoning is sound and Miri-verified under both borrow models, but the DynamicBitSetListEntry design (Deref-for-read, curated write methods, PhantomData<&'a List>) and the decision to make copy_into length-tolerant rather than length-asserting are choices a maintainer should ratify.
Other factors
Test coverage is unusually thorough: unit tests cover every panic path, every copy_into length combination, self-aliasing, and the exact DynamicBitSet::copy_into grow shape from fix_cached_lockfile_package_slices. I traced every bun_install .at() caller and confirmed they compile via Deref coercion or the entry's own methods with no behavior change. I checked the bit_length == 0 list case: slot_words(0) == 1, at(n-1) computes buf.add(n) which is a valid one-past-end pointer, and num_masks(0) == 0 so it's never dereferenced. I also verified the copy_into aliasing path is SB-sound because masks_slice_mut() is only formed after the last read through other.masks. DynamicBitSetListEntry is reachable via bun_collections::bit_set:: even though it's not in the top-level re-export list, and no caller needs to name it. The source-lint test follows the existing test/internal/source-lints/ pattern.
Problem
DynamicBitSetList::at(i)(src/collections/bit_set.rs) checkedi < nwith adebug_assert!only. The release profile has debug assertions off, so an out-of-rangeiproduced a view whosemaskspointer lies past the end of the list's heap buffer;DynamicBitSetList::set/set_unionthen write through it (heap out-of-bounds write) and readers such ascount/subset_ofread past it. Miri with debug assertions off reports the.add(offset)inatitself:Undefined Behavior: in-bounds pointer arithmetic failed ... at or beyond the end of the allocationatbit_set.rs:913(main). The callers are the install pipeline (hoisted_install.rs,isolated_install.rs,PackageInstaller::can_run_scripts), indexing by tree id and package id.DynamicBitSetUnmanaged::zip_masks_rawreadsnum_masks(self.bit_length)words out ofotherthrough raw pointers.set_union/set_intersection/set_excludeguarded the lengths with adebug_assert!;copy_intohad no check.DynamicBitSet::copy_intodocuments copying into a larger set andPackageInstaller::fix_cached_lockfile_package_slices(src/install/PackageInstaller.rs:1089) uses it that way when packages get appended to the lockfile while the installer runs, so whenever the new size needs more words than the old one the copy reads past the end of the old bitset's allocation, in every build, and the bytes that happen to follow it becomesuccessfully_installedbits. Miri on main:Undefined Behavior: memory access failed: attempting to access 8 bytes, but got alloc+0x10 which is at or beyond the end of the allocation of size 16 bytesatbit_set.rs:513.atalso said that its return value (aManuallyDrop<DynamicBitSetUnmanaged>holding a raw pointer, no lifetime) must not outlive the list and must not bedeinited; both compiled fine and were enforced by the comment only (resize/deiniton it would free the middle of the list's buffer).atfrom a lockfile on disk depends on the install side (install: validate bun.lockb slice descriptors and ids at load time #32753 is about that); the collection has to be sound either way.Fix
atusesassert!(one compare; the SAFETY comment already claimed the index was asserted).init_emptysizes the buffer withchecked_mul, sobuf_len == slot_words * nholds for everynand the in-bounds argument inatis complete.atreturnsDynamicBitSetListEntry<'_>: the sameManuallyDrop<DynamicBitSetUnmanaged>plus aPhantomData<&'a DynamicBitSetList>. It derefs toDynamicBitSetUnmanagedfor reading and exposes onlyset/set_union/copy_intofor writing, so an entry that outlives the list is a compile error (compile_fail,E0597doctest) andresize/deinitcannot be reached. The threebun_installcallers compile unchanged; they use the entry through deref coercion.zip_masks_rawasserts that the lengths are equal, and the now redundant per-callerdebug_assert!s are removed. The check that keeps a safe fn's unsafe block in bounds is not debug-only, and it belongs in the shared helper rather than in each caller.copy_intono longer goes throughzip_masks_raw: it copies the words both sets have withptr::copy(two entries of one list may alias, so the copy has to tolerate overlap), clears the remaining destination words and re-masks the padding bits. Equal lengths behave exactly as before; a shorter source now zero-extends, which is whatDynamicBitSet::copy_into's doc promises andfix_cached_lockfile_package_slicesneeds; a longer source truncates, as before.is_set/set/unset/set_range_valuekeep theirdebug_assert!s, because they index a slice ofnum_maskswords, so a bad bit index panics or touches a padding bit and cannot leave the allocation.bit_lengthbeing apubfield is the remaining way safe code could break the invariant; nothing outside the crate writes it, and it is not touched here.test/internal/source-lints/bit-set-release-checks.test.tsextracts the bodies ofatandzip_masks_rawand requires a release-mode assertion and nodebug_assert!in each. On main both entries fail (at: debug_assert only;zip_masks_raw: no check); with this branch 3 pass, underbun testandbun bd test. It is a source lint becausebun bdbuilds with debug assertions on, so no test that runs the binary can tell the two kinds of assertion apart.bit_set.rs(cargo test -p bun_collections; thecargo miri testCI job runs them too): out-of-rangeat/set/set_unionpanic, length mismatch panics for the three set ops directly and through the list,copy_intowith a shorter / same-word-count-shorter / longer / equal / empty operand, theDynamicBitSet::copy_intogrow shape (64 to 65 bits), entries writing through to the list and an entry copied onto itself, plus thecompile_faildoctest. Against main's implementation: 5 fail in the dev profile (the shorter-source copy produces garbage bits like[1, 67, 68, 69, ...]), 9 fail with--release(nothing panics), and Miri reports the UB quoted above. With the fix: all pass undercargo test,cargo test --release,bun run rust:miri -p bun_collections(54 tests + the doctest), Miri's default Stacked Borrows model, and Miri with--release.cargo check -p bun_install,cargo clippy -p bun_collections,cargo fmt --checkclean.bun bd, thenbun bd testontest/cli/install/isolated-install.test.ts(66 pass),hoist.test.ts,bun-lockb.test.ts, andbun-install-lifecycle-scripts.test.ts(119 pass; the 3 failures are the tests that neednode/bunonPATH, which this container lacks, and do not involve bitsets).Background
DynamicBitSetUnmanagedstoresbit_lengthbits asnum_masks = ceil(bit_length / 64)words behind a rawmaskspointer; the word beforemasksholds the allocation length forresizeand free. Padding bits pastbit_lengthin the last word must stay zero (countand iteration rely on it). Its binary set operations use raw pointers rather than slices because the two operands may be views of the same memory.DynamicBitSetListpacksnbitsets of one length into a single allocation:nslots of one header word plusnum_masksmask words.at(i)builds aDynamicBitSetUnmanagedpointing into sloti; it is a view, not an owner, which is what theManuallyDropis for. The list hands these out from&selfand keeps its buffer as a raw allocation on purpose (see the struct doc), which is why writing through an entry while holding only&DynamicBitSetListis sound.debug_assert!only produces code whendebug-assertionsis on: in this repo that is thedevprofile (bun bd) and the asan / assertions release variants; the shipped release profile has it off. A precondition that a safe function'sunsafeblock depends on therefore needsassert!.bun run rust:miriruns it overbun_collectionsin CI.Miri output against main's bit_set.rs (new tests only)