Skip to content

Fix soundness, correctness, and panic bugs found in review#41

Merged
vigna merged 9 commits into
WanderLanz:mainfrom
zommiommy:review-bugfixes
Jul 10, 2026
Merged

Fix soundness, correctness, and panic bugs found in review#41
vigna merged 9 commits into
WanderLanz:mainfrom
zommiommy:review-bugfixes

Conversation

@zommiommy

@zommiommy zommiommy commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Fixes a set of soundness, correctness, panic, and documentation bugs found while reviewing the crate. Each fix is its own commit and ships with a regression test in the same commit; every fix is applied to both the adapters/sources version and its fallible_* twin.

Soundness

  • peekable: make peek_mut unsafe. Peekable::peek_mut / FalliblePeekable::peek_mut returned Option<&mut Lend<'this, L>> with the lender's full storage lifetime, so safe code could copy out a lend that outlives the &mut self borrow (a Peekable<'static, _> yields a dangling reference). There is no sound safe signature, so both are now unsafe fn with a # Safety contract; a trybuild case proves the safe call is rejected (E0133).
  • peekable/flatten: drop the cached lend before freeing the lender allocation. into_inner/count/into_parts moved the AliasableBox<L> out (freeing L) while the sibling MaybeDangling cache still held a lend/sub-lender borrowing into it, dropping that cache afterward — a use-after-free on drop. The cache is now cleared while the box is alive.

Correctness

  • step_by: nth off-by-one on a fresh StepBy. step_by(2).nth(1) returned index 1 instead of 2; the first element is now consumed before decrementing n, matching std::iter::StepBy.
  • fuse: last() did not set the fused flag. Since last() drives the inner lender to None, a non-fused inner could resurrect on a later next(); the flag is now set.
  • take_while: inner-None was not terminal despite the unconditional FusedLender/FusedFallibleLender impl; the done flag is now set on the inner-None path.
  • take: zero-width back operations corrupted the inner lender. advance_back_by(0) trimmed the tail and take(0).nth_back(_) drained the inner lender (unlike next_back); advance_back_by(0) is now a no-op and nth_back is guarded with self.n != 0.

Panics

  • chunky: nth overflow panic. The exhaust branch did len.checked_mul(chunk_size).expect(...), panicking for an ExactSizeLender with len() near usize::MAX; it now uses saturating_mul (the product only feeds advance_by).
  • from_iter_ref: double-borrow panic/deadlock. next/nth/next_back/nth_back ran self.current = self.iter.next() with the previous item still owned, so an iterator whose items hold an exclusive borrow/guard (RefMut, MutexGuard) re-acquired the same resource; self.current is now cleared before fetching, as last() already did.

Docs

  • next_chunk: corrected the doc that claimed it "is equivalent to cloning the lender and calling take"; it borrows &mut self and advances the original lender.

Testing

cargo test -p lender passes: 34 lib unit tests, all integration suites, the trybuild fail harness, and 214 doctests. cargo build and cargo clippy -p lender --all-targets -- -D warnings are clean. The two soundness fixes are correct by construction; Miri validation of them is left for CI.

zommiommy added 9 commits July 9, 2026 13:24
What: Peekable::peek_mut and FalliblePeekable::peek_mut returned a mutable
reference to the cached lend with the lender's full storage lifetime ('this),
not the &mut self borrow, so entirely safe code could copy out a lend that
outlives the borrow (e.g. a Peekable<'static, _> yields a dangling &'static
reference).
Why: use-after-free reachable from 100% safe code -- a soundness hole.
How: no sound safe signature exists (shrinking the lend lifetime re-enables an
unsound write-back of a shorter-lived lend), so gate both methods behind
`unsafe fn` with a documented `# Safety` contract; wrap the doc examples and
the three test call sites in `unsafe {}`, and add a trybuild case proving the
safe call is rejected (E0133).
…ocation

What: into_inner/count/into_parts on Peekable and Flatten/FlatMap moved the
AliasableBox<L> out (freeing the L allocation) while the sibling MaybeDangling
cache field still held a lend or sub-lender borrowing into that allocation; the
cache was then dropped afterward.
Why: a covariant lend or sub-lender with drop glue is dropped after its backing
allocation is freed -- use-after-free on drop, reachable from the safe API.
How: clear the cache (dropping any held lend/sub-lender while the box is still
alive) before extracting the box, and in count inspect the cache before freeing
it; fold/rfold already extract the box only in the empty-cache arm and are left
untouched. Adds behavioral regression tests (the UAF itself is a Miri check).
What: StepBy::nth (and the fallible twin), on a fresh iterator (first_take)
with n > 0, decremented n without consuming the first element, so it returned
an element one step too early -- e.g. step_by(2).nth(1) yielded index 1 instead
of index 2.
Why: wrong result, disagreeing with std::iter::StepBy and with the crate's own
next() path (which consumes the first element).
How: consume and drop the first element (self.lender.next()) before the n -= 1
in the first_take branch, mirroring std. Adds regression tests asserting parity
with std::iter::StepBy.
What: Fuse::last (and the fallible twin) returned Some without setting the
fused flag; since last() drives the inner lender to None, a non-fused inner
could then be re-polled by Fuse::next and resurrect, and size_hint stayed
non-zero.
Why: violates Fuse's guarantee that once the inner yields None, the Fuse yields
None forever.
How: set flag = true unconditionally before delegating to the inner last(), so
a subsequent next() short-circuits to None. Adds regression tests with a
resurrecting inner lender.
What: TakeWhile::next (and the fallible twin) returned None on inner-None via
`self.lender.next()?` / an early `return Ok(None)` without setting the done
flag, yet TakeWhile unconditionally implements FusedLender /
FusedFallibleLender, so a non-fused inner could resurrect on a later next().
Why: the advertised fused guarantee (None is terminal) was broken.
How: set flag = true on the inner-None path so subsequent next() calls
short-circuit to None. Adds regression tests with a resurrecting inner lender.
What: Take::advance_back_by(0) trimmed the untaken tail of the inner lender
instead of being a no-op, and Take::nth_back on an empty take(0) drained the
whole inner lender, whereas Take::next_back on take(0) correctly returns None
untouched. Both apply to the fallible twin.
Why: advance_back_by(0) must be a no-op, and an empty Take must not consume the
inner lender; both corrupt the lender later returned by into_inner.
How: early-return Ok on advance_back_by(0), and guard the nth_back tail-drain
with `self.n != 0` so an empty Take leaves the inner untouched (mirroring the
next_back guard). Adds regression tests inspecting into_inner after each op.
…low panic

What: Chunky::nth's exhaust branch (and the fallible twin) computed
`self.len.checked_mul(self.chunk_size).expect("overflow in Chunky::nth")`;
with a valid ExactSizeLender reporting len() near usize::MAX, the chunk count
(len().div_ceil(chunk_size)) times chunk_size overflows and the expect panics.
Why: a public method panics on a valid (if extreme) lender; the exhaust branch
only needs to drive the inner lender to its end.
How: use saturating_mul in the exhaust branch -- the product only feeds
advance_by, whose overshoot is harmless. The in-range branch keeps checked_mul
(there n * chunk_size < total <= usize::MAX and never overflows). Adds a
regression test with a usize::MAX-length lender.
What: FromIterRef::{next,nth,next_back,nth_back} (and the fallible twin's
next/nth/next_back) ran `self.current = self.iter.next()` with the previous
item still owned in self.current while the right-hand side executed, so an
iterator whose items hold an exclusive borrow or guard (RefMut, MutexGuard,
...) re-acquired the same resource and panicked or deadlocked.
Why: a valid iterator makes FromIterRef panic (e.g. RefCell "already mutably
borrowed").
How: assign self.current = None before fetching, dropping the previous item
first -- exactly as the existing last() already does. Adds regression tests
with an iterator yielding RefCell RefMut guards.
What: Lender::next_chunk and FallibleLender::next_chunk documented "This is
equivalent to cloning the lender and calling take(chunk_size) on it", but
next_chunk borrows &mut self (Chunk::new(self, ...)) and advances the original
lender -- it neither clones nor consumes it.
Why: the documented ownership/state model is wrong and misleads callers about
the lender's state after taking a chunk.
How: replace the false sentence with an accurate description -- the returned
Chunk borrows the lender mutably and advances it as consumed, and unconsumed
elements remain for later calls (Chunk has no Drop that skips them).
@vigna
vigna merged commit f8ffc03 into WanderLanz:main Jul 10, 2026
3 checks passed
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