Repository navigation
Conversation
…nd does not underflow A for-loop over a symbolic Range with unconstrained bounds failed with "attempt to subtract with overflow" in <Range as KaniIter>::len: the unchecked (end - start) underflows when loop-contract havoc picks start > end. Return 0 when start >= end, and compute the span with abs_diff so it never overflows the element type either (the macro also instantiates signed types). This matches Rust Range semantics where an empty range has length zero. Adds a regression test over a symbolic Range<usize>.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Large signed spans can truncate to zero when cast to usize, unsoundly modeling non-empty ranges as empty.
Review effort: Balanced
Findings: 1
Open (1)
What changed in this PR
Makes loop-contract range length calculation total for symbolic empty ranges, resolving #4955.
Changes:
- Uses
abs_difffor non-empty range lengths. - Adds a symbolic-range regression test.
| File | Description |
|---|---|
library/kani_core/src/iter.rs |
Updates Range length modeling. |
tests/expected/loop-contract/for_loop_symbolic_range.rs |
Adds the regression harness. |
tests/expected/loop-contract/for_loop_symbolic_range.expected |
Records successful verification. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: Felipe R. Monteiro <rms.felipe@gmail.com>
The abs_diff result was cast with `as usize`, which silently truncates for 128-bit ranges: `i128::MIN..0` spans 2^127 elements, truncates to len 0, and the loop-contract model then verifies the loop vacuously (measured on the previous head: a harness over that range with a post-loop assertion no sound analysis could prove came back SUCCESSFUL). The old `end - start` failed conservatively on these ranges via the overflow check; the abs_diff fix had changed that failure into a silent wrong answer. len() now widens the span to u128 (lossless for every instantiation) and asserts it fits usize, so an unrepresentable span fails the proof instead. Regression harnesses cover i128::MIN..0 and 0..u128::MAX / 2, both failing on the span check. Also applies the review suggestion on for_loop_symbolic_range (any_where instead of assume) and adds a signed symbolic-range companion test so a signed instantiation of the model is exercised.
feliperodri
left a comment
There was a problem hiding this comment.
Thanks for tracking this down. Today a contracted for loop over a range with start > end fails inside Kani's own model, even with concrete bounds (5u32..3 reproduces it on main), so the model rejects valid code that is common in practice. The new len matches Range semantics: I checked symbolic usize and u64 ranges against if end > start { end - start } else { 0 }, plus last-iteration and empty-range assertions that must fail, and they do. The fix also covers rev, step_by, chain, enumerate and take over ranges, which all went through the same len.
The span check closes a soundness hole on main as well: for _ in 0u128..(1u128 << 64) { assert!(false) } verifies there today because the as usize cast truncates the length to 0. With this PR it fails.
One gap remains in the signed case. abs_diff gives the right length for signed spans wider than T::MAX, but nth still computes self.start + i as $t, so -100i8..100 or a fully symbolic Range<i32> now fails in nth instead of len. I tried self.start.wrapping_add(i as $t) and it fixes those without hiding any of the negative probes; details inline. I'm requesting changes for this one, since without it the signed half of the change has no effect for wide spans. The other suggestions (unsupported instead of assert!, a test harness that shows the truncation hole) are small.
The PR description still shows the first version of len and says it relies on havoc to reach start > end; worth refreshing before merge since it becomes the squash commit message.
What I ran: the loop-contract expected suite on the PR (66 passed) and on main's iter.rs (the three new tests fail, everything else passes), fmt and clippy, and about 30 probe harnesses across signed and unsigned widths, extremes, empty ranges and adapters. I did not check the core instantiation used by verify-rust-std or a 32-bit target.
nth computed self.start + i as $t, which overflows for a signed span wider than T::MAX (the loop then failed in nth instead of len, so the abs_diff fix had no effect for those ranges). i < len(), so start + i is representable; wrapping_add computes it exactly even when i as $t wraps. len now reports the over-usize span as an unsupported construct instead of asserting, so the user sees a message naming the limit rather than an assertion into Kani's library (matches mem.rs). Tests: the signed symbolic range is now a fully symbolic i8 range, which exercises the abs_diff span and the nth wrapping (the old -8..=8 bounds did neither); add unsigned_span_truncates_to_zero (span 2^64) so the truncation hole is visible directly; drop the inaccurate 'havoc' wording.
The three span harnesses fail with the same message, and expected matching is per-line presence, so one harness regressing to a vacuous SUCCESS would still match on the siblings' output. The Complete line pins 0 successes and 3 failures.
|
Pushed new changes and refreshed the PR description to the current |
feliperodri
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround. Everything from the last round is in, and I re-ran each point on 8120f0675 (merged with current main) instead of relying on the replies. Wide signed ranges now iterate correctly through nth, including isize::MIN..isize::MAX and fully symbolic i8/i16/i64 ranges. The over-usize case reports a named unsupported check. Pinning 0 successfully verified harnesses in the span test's .expected file is a good catch: each harness there fails with the same message, so without that line a single harness passing vacuously would still have matched.
I also probed shapes the tests don't cover, and every one agreed with native Range: empty and start == end ranges, ranges at the top and bottom of u128/i128, a span of exactly usize::MAX (accepted) and exactly 2^64 (rejected), rev, step_by, enumerate, take, nested loops, and the new loop_decreases over a symbolic range. Each "must fail" probe failed on the intended assertion and nothing else. One of these turned up another vacuous case on main: for _ in -(1i128 << 63)..(1i128 << 63) { assert!(false) } verifies there today, and fails with this PR.
The one inline suggestion is a post-loop count assertion in for_loop_symbolic_range.rs, so the unsigned test checks the length and not only the absence of an overflow. It doesn't block.
What I ran: the loop-contract expected suite (75 passed on the PR; the three new tests fail with main's iter.rs), fmt and clippy, and about 30 probe harnesses. The red perf-benchcomp job is a symex_runtime jump on hashset/check_contains, a benchmark that doesn't use loop contracts. I did not test a 32-bit target.
The harness only checked that the loop model raised no failure, so a regression returning 0 from len() for every range would pass it vacuously. Add the post-loop native-count assertion, matching the signed companion, so that mutation now fails the harness.
|
@feliperodri Thanks for the review. I added the native-count assertion you suggested (146b8b2). The new head just needs a CI run-approval. |

Resolves #4955.
A
for i in a..bloop with a loop contract fails withattempt to subtract with overflowinside Kani's own loop model whenever the range is empty withstart > end.<Range<_> as KaniIter>::lencomputed(self.end - self.start) as usize, an unchecked subtraction, so any empty range underflows; a concretefor _ in 5u32..3reproduces it, not only symbolic bounds. It is the sole failing check.This makes the
Rangemodel total and correct across the full value range, matching RustRangesemantics:start >= end) have length zero, solen()returns 0 instead of underflowing.len()usesabs_diff(thegenerate_impl_range!macro also instantiates signed types, where a wideend - startwould overflow).usizeare reported as unsupported rather than truncated. A 128-bit range can hold more thanusize::MAXelements;as usizewould truncate the span (i128::MIN..0to 0,0..2^64to 0) and verify the loop vacuously.len()widens tou128and callskani::unsupportedwhen the span does not fitusize.nthuseswrapping_add:start + i as $toverflowed for a signed span wider thanT::MAX, so those loops failed innthone step after thelenfix. Sincei < len(), the truestart + iis representable and wrapping arithmetic computes it exactly.assumption()is deliberately left astrue: constraining it (self.start <= self.end) was measured ineffective, sincelen()is computed once from the user's range before the loop, so the overflow is reached independently of the injectedkani::assume.Tests (
tests/expected/loop-contract/):for_loop_symbolic_range.rs/for_loop_symbolic_range_signed.rs: symbolicRange<usize>/ fully-symbolicRange<i8>withstart <= endnot assumed; the signed one exercises theabs_diffspan and thenthwrapping.for_loop_range_span_exceeds_usize.rs: spans overusize::MAX(including0..2^64, which truncates to 0 and verifies vacuously onmain) must fail the span check; the expected file pins 0 successes, 3 failures.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 and MIT licenses.