Skip to content

Challenge 29: Verify the safety of Box and ThinBox in alloc::boxed - #669

Open
kasimte wants to merge 1 commit into
model-checking:mainfrom
kasimte:challenge-29
Open

Challenge 29: Verify the safety of Box and ThinBox in alloc::boxed#669
kasimte wants to merge 1 commit into
model-checking:mainfrom
kasimte:challenge-29

Conversation

@kasimte

@kasimte kasimte commented Sep 2, 2026

Copy link
Copy Markdown

Towards #526. Solves Challenge 29: Safety of boxed. The challenge asks for safety contracts on 9 unsafe functions and verification of at least 75% of the 46 safe functions; this covers all 9 and all 46, including the ThinBox/WithHeader family that has no Box analog. Generics are instantiated at primitive types, which the challenge allows, with the Global allocator. The work is 74 Kani harnesses in one mod verify per file, all passing via scripts/run-kani.sh, as a purely additive change to three files. On every one, Kani also checks the challenge's four listed undefined behaviors — access through a dangling or misaligned pointer, misuse of intrinsics, mutation of immutable bytes, and production of an invalid value.

The 9 unsafe functions

All nine carry safety contracts, and all nine are verified — six with proof_for_contract, three by construction. The four raw-pointer constructors — from_raw, from_non_null, and their _in variants — are discharged at a sized instantiation and, for the _in pair, an unsized [u8] one. Both assume_inits are discharged as well, with the target spelled through the impl's own generic parameters (Box::<MaybeUninit<T>, A>::assume_init): concrete turbofish arguments do not resolve against that impl's structured self-type, but the generic-parameter form does. The remaining three — the downcast_uncheckeds — are verified by running the real function body on symbolic inputs and asserting the whole postcondition, the returned value and its pointer identity: at this repository's Kani pin, proof_for_contract resolves none of the three same-named dyn-self impls, across five path spellings including the resolver's own printed implementation forms. Their contracts are annotated and checked in the harness rather than machine-linked, with an in-code note at each site. The root cause is that those impl blocks live in a different module than Box, so the resolver renders them in an <impl …> path form no spelling can match; a resolver fix developed alongside this work (model-checking/kani#4777, fix in model-checking/kani#4778) verifies all three as proof_for_contract targets under a patched Kani, and each note marks the mechanical upgrade for a pin that includes it.

The challenge's function table also lists <dyn Error>::downcast_unchecked three times, but no such method exists: impl dyn Error exposes only the safe downcast. The three real downcast_uncheckeds are on Box<dyn Any (+ Send)(+ Sync), A>, and those are the ones contracted here.

The 46 safe functions

Most are heap round-trips — allocate, write, hand out a pointer, reconstruct — where the harness checks that the value and its allocation come back intact. A few carry a property worth verifying on its own terms, and the ThinBox/WithHeader family is the genuinely new work.

Group What the harnesses establish
Constructors and ownership handoffs — new_in/try_new_*, write, into_boxed_slice, into_raw/into_non_null/into_unique/leak, into_pin the value and its allocation survive the round-trip
Slice constructors the same, at a fully symbolic length bounded only by what Layout::array will accept
Conversions — into_array, from_slice, From<&str>, From<Box<str>>, and both TryFroms content preserved, or both the matching- and mismatched-length arms; the spec's TryFrom<Box<T>> row has no impl in the tree, so the two real TryFrom sources stand in for it
downcast on Box<dyn Any…> and dyn Error… the success and the failure arm, with pointer identity on success
Drop, Default, Clone including the Box<str> clone, which asserts a fresh allocation for a non-empty string and the shared dangling pointer for an empty one
ThinBox / WithHeader the full family; new_unsize_zst is proven with no assumptions on a slice-metadata instantiation

Two habits keep the proofs honest. First, covers: each input-bearing harness carries a kani::cover confirming it reaches the operation under test rather than passing on a setup that silently failed, and where a function has two reachable outcomes, both arms are covered — so no proof passes while checking a dead or unreachable path. No harness constrains its inputs with kani::assume; every input restriction is a visible any_where domain. Second, panics are proven rather than assumed away: should_panic harnesses send a panicking-drop sentinel through Box<T> and Box<[T]> drop glue, and drive all four non-try slice constructors past isize::MAX into their capacity-overflow guard.

What this Kani pin can't reach

Two behaviors sit outside the model here, both noted in-code where they occur. Allocation never fails in Kani, so the Err arm of every try_new* is unreachable — those harnesses verify the success arm and mark the dead branch rather than covering it. And new_unsize_zst's dyn Any form fails inside its const-allocated metadata block, on a missing drop_in_place::<dyn Any> and pointer-liveness checks on the const pointer, so that function is proven on its slice-metadata route instead. The last overflow guard, in WithHeader layout arithmetic, is reachable only by a near-isize::MAX type and is covered through the slice constructors above.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 and MIT licenses.

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.

1 participant