Skip to content

Commit caa3362

Browse files
committed
Stress tests and safer behavior on footguns
1 parent 778e6dd commit caa3362

5 files changed

Lines changed: 314 additions & 31 deletions

File tree

src/lib.rs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -361,20 +361,31 @@ pub struct GenericArrayImplOdd<T, U> {
361361
data: T,
362362
}
363363

364+
// NOTE: These `Clone` impls are intentionally never reached in normal use:
365+
// `GenericArray<T, N>::clone` delegates to `self.map(Clone::clone)`, so the recursive
366+
// container's own `Clone` is never invoked. Bodied as `unreachable!()` (rather than the
367+
// recursive clone) to avoid emitting the recursive-clone codegen that would otherwise be
368+
// dead. The `GenericArrayImpl*` types are `#[doc(hidden)]` internals; they must remain
369+
// nameable via `<N as ArrayLength>::ArrayType<T>` for `typenum` reasons, so a caller can
370+
// technically construct one and call `.clone()` on it. That misuse now panics
371+
// deterministically instead of hitting `unreachable_unchecked()` (UB).
364372
impl<T: Clone, U: Clone> Clone for GenericArrayImplEven<T, U> {
365373
#[inline(always)]
366374
fn clone(&self) -> GenericArrayImplEven<T, U> {
367-
// Clone is never called on the GenericArrayImpl types,
368-
// as we use `self.map(clone)` elsewhere. This helps avoid
369-
// extra codegen for recursive clones when they are never used.
370-
unsafe { core::hint::unreachable_unchecked() }
375+
unreachable!(
376+
"GenericArrayImplEven::clone should never be called; \
377+
clone a GenericArray<T, N> instead of its internal ArrayType<T>"
378+
)
371379
}
372380
}
373381

374382
impl<T: Clone, U: Clone> Clone for GenericArrayImplOdd<T, U> {
375383
#[inline(always)]
376384
fn clone(&self) -> GenericArrayImplOdd<T, U> {
377-
unsafe { core::hint::unreachable_unchecked() }
385+
unreachable!(
386+
"GenericArrayImplOdd::clone should never be called; \
387+
clone a GenericArray<T, N> instead of its internal ArrayType<T>"
388+
)
378389
}
379390
}
380391

tests/alloc.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,3 +430,96 @@ fn test_length_error_display() {
430430
let msg = alloc::format!("{}", LengthError);
431431
assert!(msg.contains("LengthError"));
432432
}
433+
434+
// Drop-counting element for the panic-unwind tests below.
435+
#[derive(Clone)]
436+
struct Tracked<'a>(i32, &'a Cell<u32>);
437+
438+
impl Drop for Tracked<'_> {
439+
fn drop(&mut self) {
440+
self.1.set(self.1.get() + 1);
441+
}
442+
}
443+
444+
// The Gemini audit's finding #2 worries that the by-value consumer paths
445+
// (`ArrayConsumer` / `IntrusiveArrayConsumer`, used by `map`/`zip`) leave moved-out
446+
// memory in a state that is unsound to touch. The real soundness contract those Drop
447+
// impls uphold is *unwind safety*: if the user closure panics partway through, every
448+
// element created is dropped exactly once - the elements already moved into the closure
449+
// are dropped there, and the still-unconsumed tail is dropped by the consumer's Drop.
450+
// Never zero, never twice. These tests assert "drops == elements created" after catching
451+
// a mid-iteration panic. Living in `alloc.rs` (rather than the `#![no_std]` `mod.rs`)
452+
// because `catch_unwind` needs `std`.
453+
//
454+
// Worth running under Miri, both aliasing models, to also rule out a double-free or
455+
// aliasing violation on the unwind path:
456+
// cargo +nightly miri test --test alloc panic_unwind
457+
// MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test --test alloc panic_unwind
458+
#[test]
459+
fn panic_unwind_map_drops_all_once() {
460+
extern crate std;
461+
use core::panic::AssertUnwindSafe;
462+
463+
// `map` consumes `self` via the consumer. Panicking while handling element 2 means
464+
// elements 0 and 1 were moved into (and dropped by) the closure, while 2 and 3 remain
465+
// for the consumer's Drop. All 4 must be dropped, none twice.
466+
let counter = Cell::new(0u32);
467+
let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
468+
let a: GenericArray<Tracked, U4> = GenericArray::generate(|i| Tracked(i as i32, &counter));
469+
let _mapped: GenericArray<i32, U4> = a.map(|x| {
470+
if x.0 == 2 {
471+
panic!("boom in map");
472+
}
473+
x.0
474+
});
475+
}));
476+
assert!(result.is_err(), "closure should have panicked");
477+
assert_eq!(counter.get(), 4, "every created element dropped exactly once");
478+
}
479+
480+
#[test]
481+
fn panic_unwind_zip_drops_all_once() {
482+
extern crate std;
483+
use core::panic::AssertUnwindSafe;
484+
485+
// `zip` drives the two-array consumer path (inverted_zip / inverted_zip2). Panicking
486+
// mid-iteration must still account for all 8 created elements (4 per array) exactly once.
487+
let counter = Cell::new(0u32);
488+
let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
489+
let a: GenericArray<Tracked, U4> = GenericArray::generate(|i| Tracked(i as i32, &counter));
490+
let b: GenericArray<Tracked, U4> =
491+
GenericArray::generate(|i| Tracked(i as i32 * 10, &counter));
492+
let _summed: GenericArray<i32, U4> = a.zip(b, |x, y| {
493+
if x.0 == 2 {
494+
panic!("boom in zip");
495+
}
496+
x.0 + y.0
497+
});
498+
}));
499+
assert!(result.is_err(), "closure should have panicked");
500+
assert_eq!(counter.get(), 8, "every created element dropped exactly once");
501+
}
502+
503+
#[test]
504+
fn panic_unwind_from_iter_drops_all_once() {
505+
extern crate std;
506+
use core::panic::AssertUnwindSafe;
507+
508+
// `FromIterator` uses the builder (not the consumer), but the same exactly-once
509+
// accounting must hold if the *source iterator* panics partway: the initialized prefix
510+
// is dropped, the not-yet-written slots are not. The panic fires before element 3 is
511+
// created, so exactly 3 drops occur.
512+
let counter = Cell::new(0u32);
513+
let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
514+
let _: GenericArray<Tracked, U4> = (0..4)
515+
.map(|i| {
516+
if i == 3 {
517+
panic!("boom in source iterator");
518+
}
519+
Tracked(i, &counter)
520+
})
521+
.collect();
522+
}));
523+
assert!(result.is_err(), "iterator should have panicked");
524+
assert_eq!(counter.get(), 3, "initialized prefix dropped exactly once");
525+
}

tests/clone_unreachable_repro.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//! Regression test for the Gemini audit's finding #1.
2+
//!
3+
//! The internal `GenericArrayImpl*` containers are `#[doc(hidden)]` but must remain
4+
//! nameable via `<N as ArrayLength>::ArrayType<T>` (a `typenum` limitation). They carry
5+
//! a safe `Clone` impl whose body is unreachable in normal use, because
6+
//! `GenericArray<T, N>::clone` delegates to `self.map(Clone::clone)` and never invokes
7+
//! the container's own `Clone`.
8+
//!
9+
//! Previously that body was `unreachable_unchecked()`, so a caller who explicitly named
10+
//! the internal type and called `.clone()` on it hit UB from 100% safe code (reachable
11+
//! via the `const-default` feature's `ConstDefault::DEFAULT`). It is now `unreachable!()`,
12+
//! turning that misuse into a deterministic panic instead. This test pins that behavior.
13+
//!
14+
//! Requires `const-default` for a fully-safe way to construct the internal type.
15+
#![cfg(feature = "const-default")]
16+
17+
use const_default::ConstDefault;
18+
use generic_array::ArrayLength;
19+
20+
#[test]
21+
#[should_panic(expected = "should never be called")]
22+
fn cloning_internal_array_type_panics_not_ub() {
23+
type Inner = <typenum::U2 as ArrayLength>::ArrayType<i32>;
24+
25+
// Fully safe construction of an internal container type.
26+
let arr = <Inner as ConstDefault>::DEFAULT;
27+
28+
// Fully safe call into the never-meant-to-be-called Clone impl. Must panic
29+
// deterministically (was UB via unreachable_unchecked before the fix).
30+
let _cloned = arr.clone();
31+
}

tests/iter.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,151 @@ fn test_into_iter_drops() {
213213
assert_eq!(i.get(), 5);
214214
}
215215

216+
// Targeted reproduction attempt for the Gemini audit's "slice fabrication over
217+
// partially-moved arrays" finding. The claim: every `get_unchecked(index..index_back)`
218+
// in the iterator auto-derefs through the whole-array `slice::from_raw_parts` over
219+
// `0..N`, and once leading/trailing elements have been moved out, forming that
220+
// whole-array reference is claimed to be UB.
221+
//
222+
// To give that claim the strongest possible chance to fire under Miri, the element
223+
// type is `Niche(NonZeroU32)`: a moved-out slot left as a zeroed bit pattern would be
224+
// a *validity-invalid* `NonZeroU32`, not merely uninitialized - so a reference spanning
225+
// it would be UB that Tree Borrows + validity checking must catch. A `Cell`-backed Drop
226+
// counter additionally proves exactly-once drop accounting through each path.
227+
//
228+
// The helpers below exercise *every* slice-forming call site the report named
229+
// (`as_slice`, `as_mut_slice`, `next`, `nth`, `next_back`, `nth_back`, `fold`, `rfold`,
230+
// `Drop`, and `Clone`) while the backing array is in a partially-moved state.
231+
//
232+
// Run under both aliasing models to adjudicate:
233+
// cargo +nightly miri test --test iter partial_move
234+
// MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test --test iter partial_move
235+
236+
use std::num::NonZeroU32;
237+
238+
struct Niche<'a>(NonZeroU32, &'a Cell<u32>);
239+
240+
impl Clone for Niche<'_> {
241+
fn clone(&self) -> Self {
242+
Niche(self.0, self.1)
243+
}
244+
}
245+
246+
impl Drop for Niche<'_> {
247+
fn drop(&mut self) {
248+
// Touch the value so a moved-out/zeroed slot is observable as a validity bug,
249+
// and count the drop so callers can assert exactly-once semantics.
250+
assert!(self.0.get() != 0, "dropped a moved-out / invalid Niche");
251+
self.1.set(self.1.get() + 1);
252+
}
253+
}
254+
255+
// Build a fresh 5-element iterator whose elements all count drops into `c`.
256+
fn mk_iter(c: &Cell<u32>) -> generic_array::GenericArrayIter<Niche<'_>, U5> {
257+
GenericArray::<Niche, U5>::from_iter(
258+
(1..=5).map(|n| Niche(NonZeroU32::new(n).unwrap(), c)),
259+
)
260+
.into_iter()
261+
}
262+
263+
#[test]
264+
fn test_partial_move_as_slice_both_ends() {
265+
let c = Cell::new(0);
266+
{
267+
let mut iter = mk_iter(&c);
268+
let _front = iter.next().unwrap(); // index advances; slot 0 moved out
269+
let _back = iter.next_back().unwrap(); // index_back retreats; slot 4 moved out
270+
// as_slice / as_mut_slice now form the whole-array ref with slots 0 and 4 dead.
271+
assert_eq!(iter.as_slice().len(), 3);
272+
for n in iter.as_mut_slice() {
273+
assert!(n.0.get() != 0);
274+
}
275+
// Debug also routes through as_slice().
276+
let _ = format!("{:?} {:?}", iter.as_slice().len(), c.get());
277+
drop(iter); // Drop forms the remaining [1..4] slice and drop_in_place's it.
278+
}
279+
assert_eq!(c.get(), 5, "all 5 elements dropped exactly once");
280+
}
281+
282+
#[test]
283+
fn test_partial_move_drain_to_empty() {
284+
// Every slot moved out before Drop: Drop must form a zero-length slice, not touch
285+
// any of the (now invalid) backing memory.
286+
let c = Cell::new(0);
287+
{
288+
let mut iter = mk_iter(&c);
289+
while iter.next().is_some() {}
290+
assert_eq!(iter.as_slice().len(), 0);
291+
drop(iter);
292+
}
293+
assert_eq!(c.get(), 5);
294+
}
295+
296+
#[test]
297+
fn test_partial_move_nth_and_nth_back() {
298+
// nth() drop_in_place's the skipped prefix slice, then next() reads through the
299+
// whole-array deref; nth_back() does the mirror on the suffix.
300+
let c = Cell::new(0);
301+
{
302+
let mut iter = mk_iter(&c);
303+
let _ = iter.nth(1).unwrap(); // drops slots [0..1], returns slot 1
304+
let _ = iter.nth_back(1).unwrap(); // drops slots [4..5)->[3..4], returns slot 3
305+
assert_eq!(iter.as_slice().len(), 1); // only slot 2 remains live
306+
drop(iter);
307+
}
308+
assert_eq!(c.get(), 5);
309+
}
310+
311+
#[test]
312+
fn test_partial_move_fold_after_consume() {
313+
// fold() forms get_unchecked(index..index_back) and ptr::reads through it while
314+
// mutating the index, with both outer ends already moved out.
315+
let c = Cell::new(0);
316+
{
317+
let mut iter = mk_iter(&c);
318+
let _ = iter.next().unwrap();
319+
let _ = iter.next_back().unwrap();
320+
let sum = iter.fold(0u32, |acc, n| acc + n.0.get());
321+
assert_eq!(sum, 2 + 3 + 4);
322+
}
323+
assert_eq!(c.get(), 5);
324+
}
325+
326+
#[test]
327+
fn test_partial_move_rfold_after_consume() {
328+
let c = Cell::new(0);
329+
{
330+
let mut iter = mk_iter(&c);
331+
let _ = iter.next().unwrap();
332+
let _ = iter.next_back().unwrap();
333+
let sum = iter.rfold(0u32, |acc, n| acc + n.0.get());
334+
assert_eq!(sum, 2 + 3 + 4);
335+
}
336+
assert_eq!(c.get(), 5);
337+
}
338+
339+
#[test]
340+
fn test_partial_move_clone_after_consume() {
341+
// Clone is the spiciest path: it ptr::read's the *entire* partially-moved backing
342+
// array (bitwise) into a new iter, then writes clones into the live prefix via
343+
// as_mut_slice(). If forming a ref over moved-out slots were UB, this is where it
344+
// would bite hardest.
345+
let c = Cell::new(0);
346+
{
347+
let mut iter = mk_iter(&c);
348+
let _ = iter.next().unwrap(); // slot 0 dead
349+
let _ = iter.next_back().unwrap(); // slot 4 dead
350+
let cloned = iter.clone(); // bitwise-copies [_, 2, 3, 4, _], clones live 2,3,4
351+
assert_eq!(cloned.as_slice().len(), 3);
352+
// Iterator::map (lazy) over the cloned by-value iter, consuming the 3 clones.
353+
let s: u32 = cloned.map(|n| n.0.get()).sum();
354+
assert_eq!(s, 2 + 3 + 4);
355+
drop(iter);
356+
}
357+
// 5 originals + 3 clones = 8 drops.
358+
assert_eq!(c.get(), 8);
359+
}
360+
216361
#[test]
217362
fn test_from_failing_iter() {
218363
let res: Result<GenericArray<_, U5>, ()> = GenericArray::from_fallible_iter(

0 commit comments

Comments
 (0)