Conversation
Adds a #[cfg(kani)] mod verify to library/alloc/src/string.rs with 16 harnesses covering all 15 functions of Challenge 10. No production code is changed. - Any length (symbolic len/cap up to CBMC's object limit, loop-free): pop, remove, insert, insert_str, split_off, drain (next and drop), into_boxed_str, leak. - Any receiver length, bounded edit/match count: replace_range (<= 4 bytes removed and inserted), remove_matches (<= 4 matches, most-general Searcher). - Bounded because of current Kani limitations (documented in the PR): retain (<= 12 chars), from_utf16le/from_utf16be/from_utf16be_lossy (<= 12 bytes), from_utf16le_lossy (<= 8 bytes). Written with AI assistance.
|
A note on the two failing Kani Metrics jobs. The ubuntu-latest job was stopped by a runner shutdown ( This does not look related to the change. The PR only adds a Could the failed jobs be re-run when convenient? Thanks! |
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
Review effort: Lite
Findings: 3
Open (4)
String::from_utf8_unchecked(v)is invoked onvwhose bytes are never proven (or constructed) to… · New This constructs an&strfrom arbitrary bytes viafrom_utf8_unchecked, which is UB if the slice… · NewAnySearcheris intended to satisfy theunsafe trait Searchercontract (non-overlapping matches… · New The module-level documentation claims every harness is loop-free and that no#[kani::unwind]… · New
What changed in this PR
Adds Kani verification harnesses for Challenge 10 to check memory safety of String methods (including the “(U)” set) by running harnesses over real stdlib bodies under #[cfg(kani)].
Changes:
- Appends a new
#[cfg(kani)] mod verifycontaining proof harnesses for 15Stringfunctions. - Introduces symbolic input generators (
any_string_bytes,with_char_at) and coverage points for non-vacuity. - Adds specialized harnessing for
replace_range/remove_matchesand bounded harnesses forretain/from_utf16*with documented tool limitations.
| File | Description |
|---|---|
| library/alloc/src/string.rs | Adds a #[cfg(kani)] verification module with Kani harnesses, helpers, and a custom Pattern/Searcher for remove_matches. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…urate docs - String generators: build the buffer with vec![0u8; cap] (a zeroed allocation, still loop-free and of symbolic size) and truncate it, and in with_char_at encode an arbitrary char at an arbitrary offset. Every generated String is now initialized, valid UTF-8; before, set_len exposed uninitialized capacity as string bytes. A ptr::write_bytes fill was tried first, but it made CBMC run out of memory while Kani generated traces. - replace_range: the replacement is a checked &str (str::from_utf8, invalid input assumed away), i.e. any valid UTF-8 of <= 4 bytes. - remove_matches: AnySearcher never repeats an empty match at the same position; its doc and SAFETY comment now follow the Searcher contract. - Module doc: bounds of each harness, and the content-independence argument for the generators. Written with AI assistance.
…nger covers - any_valid_string (retain): encode the chars into a zeroed vec![0u8; 4 * N] and truncate it, instead of writing through a slice over uninitialized spare capacity. - AnySearcher: drop the assumption that an empty match never repeats at one position. The Searcher contract allows it (adjacent, zero-length ranges), so the searcher is the most-general one again; its doc and SAFETY comment now state exactly what is generated. - remove_matches: up to 5 matches (unwind 7), so the rejections Vec grows past its initial capacity of 4. - Covers: remove_matches shows a real removal leaving a long string and the 5-match case; retain shows a multi-byte char kept after a deletion (the unsafe re-encode path). - Comment fixes: check_pop SAFETY (UTF-8 validity), with_char_at capacity bound uses the char's width, panic exclusions. Written with AI assistance.
|
Thanks for the review. ef9133a and 277b53e address the Copilot comments and a further review pass:
All 16 harnesses pass with Kani at 8 GiB and with plain CBMC, and every cover is satisfied. For each changed harness, flipping its assertion makes CBMC report exactly that failure. |
|
CI on 277b53e: all four
|
- Replace the char-boundary assumes with assert!s: on the all-NUL strings every index up to the length is a boundary, and in check_drain_next the end lies in the NUL suffix. The Searcher of check_remove_matches asserts its boundary requirement instead of assuming it. - Express the MAX_ALLOC size limits as any_where domains of the generators (any_string_max_len, with_char_at), so the module has no kani::assume. - replace_range: build the replacement constructively (up to 4 encoded chars in a zeroed buffer) instead of from_utf8 plus assume(false); unwind bound 5 (4 iterations plus the exit test). - Share an any_bounds generator between replace_range and drain, with a cover per Bound variant; covers for the reallocation paths. - retain: assert that the new length equals the bytes of the kept chars. - from_utf16*: covers for a leading surrogate pair that decodes and a leading unpaired surrogate, assert Err for the latter in the strict variants; unwind 7 (6 code units plus the exit test), which also lets from_utf16le_lossy run on inputs of up to 12 bytes like the others. - Add should_panic harnesses for insert, insert_str, split_off, drain and replace_range at indices strictly inside a multi-byte char or past the end. - Document each unwind bound and update the module comment.
A should_panic harness passes as soon as some input panics. With an index that is either inside a multi-byte char or past the end, the past-the-end inputs alone made the harnesses for insert, insert_str, split_off, drain and replace_range pass even without the char-boundary checks (reported by rajath-mk in review). Each of these harnesses is now two: one with an index strictly inside a multi-byte char (for ranges, the other end in bounds, so only a char-boundary check can panic) and one with an index past the end.
|
@rajath-mk thanks again for the review. While you are looking at this PR, could the committee help with two scope questions? The answers decide where I spend the next weeks.
|


Partially addresses #61 (Challenge 10): all 15 functions have harnesses over the real bodies; of the 9 "(U)" functions, 2 are verified for arbitrary lengths, 2 for arbitrary receiver length with a bounded edit/match count, and 5 are bounded because of the Kani limitations documented below.
Summary
This PR adds Kani harnesses in
library/alloc/src/string.rs(#[cfg(kani)] mod verify) for all 15 functions listed in Challenge 10. Every harness runs over the real function bodies. There are nocfg(kani)body swaps, no stubs of the functions under test, nokani::assumein the module, and nothing is assumed about the results of the functions under test.insert_strandsplit_off. The strings have symbolic length and capacity, limited only by the largest object CBMC can represent under--object-bits 12.replace_rangeandremove_matches. The receiver has arbitrary length. Only the number of edited bytes or matches is bounded, because those are the only loop trip counts.retainand the fourfrom_utf16*functions. The reasons and minimal reproducers are below, with links to the upstream Kani issues.pop,remove,insert,drain,into_boxed_strandleak. These are also verified for arbitrary lengths, since their harnesses are loop-free.#[kani::should_panic]harnesses, two per function, show that the documented panic conditions the other harnesses stay out of do panic: an index strictly inside a multi-byte char, and separately an index past the end (insert,insert_str,split_off,drain,replace_range).Status per function
insert_strcheck_insert_str,check_insert_str_panics_inside_char,check_insert_str_panics_past_endsplit_offcheck_split_off,check_split_off_panics_inside_char,check_split_off_panics_past_endreplace_rangecheck_replace_range,check_replace_range_panics_inside_char,check_replace_range_panics_past_endremove_matchescheck_remove_matchesretaincheck_retain_boundedfrom_utf16lecheck_from_utf16le_boundedfrom_utf16le_lossycheck_from_utf16le_lossy_boundedfrom_utf16becheck_from_utf16be_boundedfrom_utf16be_lossycheck_from_utf16be_lossy_boundedpopcheck_popremovecheck_removeinsertcheck_insert,check_insert_panics_inside_char,check_insert_panics_past_enddrainnext, and drop)check_drain_next,check_drain_drop,check_drain_panics_inside_char,check_drain_panics_past_endinto_boxed_strcheck_into_boxed_strleakcheck_leakApproach
Symbolic strings.
any_string()builds aStringwith symbolic capacitycap <= MAX_ALLOCand symbolic lengthlen <= capfrom a zeroed buffer (vec![0u8; cap], thentruncate(len)), so every byte is initialized and the string is valid UTF-8 (any_string_max_len(m)additionally limits the length tom). No loop runs, so there is no unwinding bound.MAX_ALLOC = 2^51 - 1is the largest single object under--object-bits 12, the settingscripts/run-kani.shuses. It is a limit of the memory model, not a bound chosen for tractability; #681 (Challenge 25) limits its symbolic capacity by the CBMC object model in the same way.Contents. Memory safety of these functions depends only on the lengths, the capacity, the given indices, the char-boundary checks at those indices, and at most one decoded char (
pop,removeand theDrainiterator decode exactly one). So the harnesses use NUL-filled strings, and where a char is decoded,with_char_at()places a well-formed encoding of an arbitrarycharat an arbitrary offset inside the zeroed buffer. On an all-NUL string every index up to the length is a char boundary, so the harnesses reach every combination of these quantities for which the boundary checks pass; the combinations for which they fail are the documented panics (see below). All inputs are initialized, valid UTF-8 by construction.No assumptions beyond input domains. The module has no
kani::assume. Every restriction of an input is akani::any_wheredomain at the point where the input is generated: sizes that fit in one CBMC object, indices inside the string (idx <= len, andstart <= end <= lenfor ranges, which also keeps the main harnesses out of the out-of-range panics), the bounds in the table above, and, in the panic harnesses, indices without a char boundary. The documented preconditions (indices on char boundaries) are not assumed either: they hold by construction. The harnesses that pick an indexassert!that it is a char boundary before the call;removeremoves at the start of the encoded char by construction, andcheck_poptruncates the string right after the char fromwith_char_at, sopopdecodes exactly that char. The replacement string ofreplace_rangeis built constructively (up to 4 arbitrary chars encoded into a zeroed buffer, so every valid UTF-8 string of at most 4 bytes), not filtered throughfrom_utf8.Panics. Panics are not UB, so the main harnesses stay out of the documented panic conditions by construction. Ten
#[kani::should_panic]harnesses, two per function, show thatinsert,insert_str,split_off,drainandreplace_rangepanic for an index strictly inside a multi-byte char (*_panics_inside_char) and, separately, for an index past the end (*_panics_past_end). In the first, the string has an arbitrary char at an arbitrary position and the index is strictly inside it; fordrainandreplace_range, one end of the rangea..b(a <= b) is inside the char and the other end is in bounds, so only a char-boundary check can panic. The two cases are separate harnesses because ashould_panicharness passes as soon as some input panics: with both kinds of index in one harness, the past-the-end inputs alone would make it pass without the char-boundary checks (found in review). Reversed ranges andBoundoverflow (..=usize::MAX) are not exercised. Kani accepts such a harness only if a panic-class failure is reachable and every failing check is of that class (assertions, which also include arithmetic overflow and out-of-bounds indexing), so no UB is reachable before the panic (Kani usespanic=abort). That the failing checks are exactly the documented panics (theis_char_boundaryassertions, except incheck_drain_panics_past_endandcheck_replace_range_panics_past_end, where it is the end-past-length panic ofslice::range) was checked in the logs. As an extra local check (not part of this PR), akani::coverplaced after the call is UNREACHABLE in all ten harnesses, so every input in these domains panics.pophas no panic condition.removehas no panic harness: for an index inside a char, its panic path formats the error message with loops over the string (see the next paragraph).check_removeand#[kani::unwind(2)].self[idx..]has a panic path (str::slice_error_fail) that formats the error message with loops over the string. Under the harness that path is infeasible (idxis the start of a well-formed char), but symbolic execution would otherwise unfold it without end. The bound only cuts that path: unwinding assertions stay enabled, so a reachable loop needing more iterations would fail the proof, and no loop is reachable on the verified path.Bounded harnesses.
retainruns on every valid string of up to 12 chars (arbitrary chars encoded back to back into a zeroed buffer) with an arbitrary keep-predicate, and asserts that the new length is exactly the total UTF-8 length of the chars the predicate kept (counted inside the predicate). A cover shows a multi-byte char kept after a deletion, i.e. the unsafe re-encode path. Thefrom_utf16*harnesses run on every sub-slice of an arbitrary byte array, so odd lengths and both odd and even start addresses (thealign_tofast path and the unaligned path) are covered. They check an output-length bound and that the strict variants returnErrfor an input that starts with a low surrogate (which is always unpaired there); covers show a leading surrogate pair that decodes and a leading unpaired low surrogate. Each bounded harness has a matching#[kani::unwind], with a comment on how it follows from the input size, and an insufficient bound fails the unwinding assertion.replace_range/remove_matches. Every loop reachable from these functions iterates once per edited byte or per match:replace_range: theSplicedrop, drain, fill and extend loops;remove_matches: the collection of matches and the compaction loop.No loop iterates once per byte of the receiver. The receiver-sized work is done by
ptr::copy/copy_nonoverlappingcalls (moving the tail, and the reallocation), which are loop-free;remove_matchesdoes one copy per kept segment. So the receiver has arbitrary length and capacity, and only the edit size or match count is bounded, with a matching#[kani::unwind]. An insufficient bound fails the unwinding assertion.(Bound, Bound)encoding of a valid rangestart..end, which covers everyRangeBoundsshape of it; a cover perBoundvariant shows each is reached (thedrainharnesses use the same generator).remove_matches, the pattern is a most-generalSearcher(AnyPattern): it may return any sequence of matches allowed by theSearchercontract ("adjacent, non-overlapping, covering the whole haystack, and laying on utf8 boundaries"; ranges may have zero length). The boundary requirement is asserted inside the searcher, not assumed; it holds for every index of the all-NUL receiver, which is therefore the haystack on which the contract allows the most match sequences (remove_matchesitself only moves the bytes, it never inspects them). With up to 5 matches theVecof matches grows past its initial capacity, and a cover shows that reallocation path is reached.How the challenge's list of UB is checked
ptr::copyandptr::copy_nonoverlappingare checked for aligned, in-bounds source and destination (and non-overlap for the latter); pointer-to-reference casts are checked for alignment. This covers the unsafe code of these functions: theptr::copys ofinsert,insert_str,removeandremove_matches, theget_unchecked/from_raw_parts_mutaccesses and the re-encode ofretain, the rawStringpointer inDrain, and theVec/RawVecreallocation andSplicecode underneath.kani::any()arrays, encoded chars). Kani's-Z uninit-checkscannot be added as an extra check: at the pinned Kani it does not work for 20 of the 21 harnesses. 12 hit an internal compiler error on the function pointers in the reachable formatting code (Tracking Issue: Automatically Verify Memory Initialization kani#3300), 5 hit another internal error in its layout code (ty_layout.rs:190), and 3 did not finish compiling within 30 minutes.check_leakpasses with it.&mut self(self.vec.as_mut_ptr(),as_mut_vec()), or, inDrain::drop, from the*mut Stringthatdraincreates from&mut self;leakandinto_boxed_strhand out the owned buffer without writing to it.pop,removeandDrain::nextdecode are asserted to equal the encodedchar, so an invalidcharwould fail the assertion. Reachingunreachable_unchecked(through theunwrap_uncheckedof the decoded char inretain) would be reported by Kani'sunreachable codechecks. The CI configuration does not check the validity of other intermediate values. As an extra check outside CI, 13 of the 21 harnesses, including every harness that decodes UTF-8 from theString(check_pop,check_remove,check_drain_next,check_retain_bounded), also pass with-Z valid-value-checksadded, with every cover satisfied. The other 8 each stop at one construct this checker does not support yet (copy_nonoverlapping::<u8>, ordrop_in_placeof some types), which Kani reports as their only failure; since that cuts the paths behind it, the extra check is incomplete for those 8. They include the fourfrom_utf16*harnesses, whose chars come fromchar::from_u32_uncheckedinDecodeUtf16::next.Why
retainandfrom_utf16*are boundedAn unbounded proof of these functions needs a loop contract on every loop whose trip count grows with the input. At the pinned Kani (0.67.0, commit 152c6a8c, CBMC 6.10.0) none of these loops can carry one:
retain. An unbounded proof of the real body needs two things the pinned tools do not provide:Charsbuilt byget_unchecked(..).chars().next(). Kani declares every MIR local at function entry, so writes to them inside callees fail "Check that self->ptr is assignable" (Loop contracts: explicitloop_modifiesrejects writes to temporaries declared in the loop body (Check that var_N is assignable) kani#4906). With a prototype Kani fix (not part of this PR), a verbatim copy of the body passes with a loop contract whose invariant is quantified over a constant range (0 of 755 checks failed), so this part is fixable in Kani.guard.idx..len, which is what justifies the unchecked unwrap of the char thatnext_code_pointdecodes: aforallwith symbolic bounds. The SAT backend drops such quantifiers (current Kani main reports this as an error, Fail verification when the solver backend drops quantifiers kani#4719), and the SMT backends (z3, cvc5) crash while encoding a harness with this invariant. So even with macro definitions escape from local scopes rust-lang/rust#4906 fixed, an unboundedretainproof needs quantifier support in CBMC.loop_invariantclosures become nondeterministic (#[kani::loop_invariant]with a method call lowers the call with "not enough arguments", substituting a non-deterministic value kani#4796, with our analysis in a comment there).from_utf16*. The four functions reach three different loops:from_utf16leon the aligned path reaches thefor c in char::decode_utf16(..)loop inString::from_utf16. Kani's loop-contract macro needsKaniIntoIter, which is not implemented for this iterator (Loop contracts:forloops over iterators without aKaniIntoIterimpl (e.g.char::DecodeUtf16) cannot carry a contract kani#4907).from_utf16leand all offrom_utf16beloop in the defaultIterator::try_fold(while let, reached throughcollect::<Result<String, _>>(),String::extend,for_eachandGenericShunt::try_fold), and both lossy variants loop in the defaultIterator::fold(throughcollect::<String>(),extendandMap::fold). These generic default loops cannot carry a useful invariant, because the iterator fields and theStringcaptured by the closure cannot be named, and an annotation incorewould apply to every harness that reachesfold. Stubbing or contracting<String as FromIterator<char>>::from_iterinstead is rejected, because Kani does not support stubs or contracts on generic trait functions (Support stubs and function contracts on trait functions with their own generic parameters (e.g.<String as FromIterator<char>>::from_iter) kani#4908).Stringand may reallocate, but CBMC's contract instrumentation (DFCC), which Kani uses for loop contracts, creates loop write sets with allocation and deallocation disabled: a minimal loop whose body isBox::new(i)and its drop fails exactly "dynamic allocation is allowed" and "Check that ptr is freeable" under a loop contract, with CBMC 6.10 and 6.11. So a fix for Loop contracts:forloops over iterators without aKaniIntoIterimpl (e.g.char::DecodeUtf16) cannot carry a contract kani#4907 alone would not make these proofs go through.On #537 (Challenge 20), @feliperodri asked whether the scan loops could carry loop contracts, and otherwise: "If that is not yet feasible, please document this as an explicit, accepted limitation." This PR does that for these five functions, with minimal reproducers in the linked issues. We are happy to replace the bounded harnesses with unbounded ones once Kani supports these patterns.
Verification
CI command.
scripts/run-kani.sh(kani verify-std ... --output-format=terse --cbmc-args --object-bits 12, with the script's-Zflags). Every harness was run in exactly that form, one container per harness (2 vCPU, 8 GiB). All 26 pass:check_drain_dropcheck_drain_nextcheck_drain_panics_inside_charcheck_drain_panics_past_endcheck_from_utf16be_boundedcheck_from_utf16be_lossy_boundedcheck_from_utf16le_boundedcheck_from_utf16le_lossy_boundedcheck_insertcheck_insert_panics_inside_charcheck_insert_panics_past_endcheck_insert_strcheck_insert_str_panics_inside_charcheck_insert_str_panics_past_endcheck_into_boxed_strcheck_leakcheck_popcheck_removecheck_remove_matchescheck_replace_rangecheck_replace_range_panics_inside_charcheck_replace_range_panics_past_endcheck_retain_boundedcheck_split_offcheck_split_off_panics_inside_charcheck_split_off_panics_past_endcheck_remove_matches). CBMC times vary between identical runs by up to about 1.5x (312 s and 458 s forcheck_remove_matchesin two runs). Peak memory (largest process) is at most 4.3 GiB (check_from_utf16le_lossy_bounded); every other harness stays at or below 2.5 GiB.kani::coverof the harness was reported satisfied and no out-of-memory message appeared. The ten panic harnesses reportSUCCESSFUL (encountered one or more panics as expected), and their failing checks are only the documented panics: theis_char_boundaryassertions of the function, except incheck_drain_panics_past_endandcheck_replace_range_panics_past_end, where it is the out-of-bounds panic ofslice::range.kani::covers on its inputs (long strings, interior indices, a full buffer so thatinsert,insert_strandreplace_rangereallocate, grow and shrink cases, eachBoundvariant, aligned, unaligned and odd-length UTF-16 input, a leading surrogate pair that decodes and a leading unpaired low surrogate, the reallocation of the matchVecinremove_matches, a multi-byte char moved byretain). All are satisfied, so no harness is vacuous.assert!s that replaced the former assumptions (incheck_insert, incheck_drain_nextand inside theremove_matchessearcher) fails as well, so they are evaluated. Incheck_retain_bounded, counting chars instead of bytes in the predicate makes the exact-length assertion fail. In the strictfrom_utf16*harnesses, asserting that a leading low surrogate never yieldsErrfails, so that path is reached. For the panic harnesses, replacing the char-boundary checks ofinsert,insert_strandsplit_offbyidx <= lenand removing those ofdrainandreplace_rangemakes each of the five*_panics_inside_charharnesses fail, while the five*_panics_past_endharnesses still pass.-Z valid-value-checksand-Z uninit-checks, see the UB section above.Questions for the committee
retain(<= 12 chars) and the fourfrom_utf16*functions (inputs of <= 12 bytes), or should these five stay open until Kani supports the needed loop contracts?replace_rangeandremove_matches. We read the criterion as the length of the receiver, which is arbitrary here; the replaced range and the replacement are at most 4 bytes each, and the searcher returns at most 5 matches. Is that the intended reading, or should the replacement length and the number of matches be unbounded too?Notes for reviewers
#[cfg(kani)](amod verifyappended tolibrary/alloc/src/string.rs) and changes no production lines;lib.rsis untouched.loop_modifiesrejects writes to temporaries declared in the loop body (Check that var_N is assignable) kani#4906, Loop contracts:forloops over iterators without aKaniIntoIterimpl (e.g.char::DecodeUtf16) cannot carry a contract kani#4907, Support stubs and function contracts on trait functions with their own generic parameters (e.g.<String as FromIterator<char>>::from_iter) kani#4908, and reproducers or analysis added to Kani reports VERIFICATION SUCCESSFUL when CBMC runs out of memory while writing its JSON results kani#4905,#[kani::loop_invariant]with a method call lowers the call with "not enough arguments", substituting a non-deterministic value kani#4796 and High memory consumption for interior mutability function contract test kani#3611.VERIFICATION:- SUCCESSFULwhen CBMC runs out of memory while writing traces) is why the harnesses keep covers on the inputs and why a result was only trusted when every cover was reported satisfied. We fixed it on Kani main in Fail verification when CBMC exits abnormally after reporting results kani#4910 (merged), with a follow-up in Only use CBMC results that are followed by CBMC's overall status kani#4928; the pinned Kani 0.67 does not have the fix.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 and MIT licenses.