Proving Absence of Overflow

Whenever Verus executable code performs a mathematical operation on concrete (non-ghost) variables, Verus makes sure that it doesn’t overflow. This is useful because it prevents unexpected overflow (a common bug) and because it allows simpler reasoning, e.g., not forcing the SMT solver to reason about the possibility that x + y results in (x + y) % pow(2, 32). However, it creates an obligation on the developer to prove that every one of their operations can’t overflow. We saw in the previous section about Devising loop invariants that this necessitates additional proof work.

One way to deal with this is to place explicit bounds on variables’ values so that the solver can infer that the code can’t overflow. For instance, the following simple function to compute a sum fails to verify because it might overflow:

fn compute_sum_fails(x: u64, y: u64) -> (result: u64) ensures result == x + y, { x + y // error: possible arithmetic underflow/overflow }

But this version succeeds at verification because the solver can easily tell that the bounds prevent overflow:

fn compute_sum_limited(x: u64, y: u64) -> (result: u64) requires x < 1000000, y < 1000000, ensures result == x + y, { x + y }

Another way to deal with overflow is to explicitly check for it at runtime. For this, one can use operations from the Rust standard library like checked_add and checked_mul. These operations return an Option, with the value None indicating that an overflow would have resulted. Verus includes specifications for these, enabling their direct use. This example illustrates how:

fn compute_sum_runtime_check(x: u64, y: u64) -> (result: Option<u64>) ensures match result { Some(z) => z == x + y, None => x + y > u64::MAX, }, { x.checked_add(y) } fn main() { } } // verus!

This works well for single operations, but performing multiple chained operations of addition and/or multiplication is trickier. This is because whenever overflow occurs one has to stop, and it’s not clear that the entire chain of operations collectively would have overflowed. For this circumstance, the Verus standard library includes structs named CheckedU8, CheckedU16, etc. Each of these can continue operating even after it overflows, maintaining its true non-overflowing value in ghost state. To see the value in this, consider the Fibonacci example from the previous section about Devising loop invariants. If we use CheckedU64, as in the following example, we don’t need to invoke lemma_fib_is_monotonic to prove that the result can’t overflow:

fn fib_checked(n: u64) -> (result: u64) requires fib(n as nat) <= u64::MAX ensures result == fib(n as nat), { if n == 0 { return 0; } let mut prev: CheckedU64 = CheckedU64::new(0); let mut cur: CheckedU64 = CheckedU64::new(1); let mut i: u64 = 1; while i < n invariant 0 < i <= n, fib(n as nat) <= u64::MAX, cur@ == fib(i as nat), prev@ == fib((i - 1) as nat), { i = i + 1; let new_cur = cur.add_checked(&prev); prev = cur; cur = new_cur; } cur.unwrap() }

There is a small cost in performance and memory footprint, since the checked versions consist of a runtime Option<u64> instead of a u64, but in return the code is simpler. Another advantage is that we can remove the precondition mandating that the result must fit in a u64, and correctly handle the possibility of overflow:

fn fib_checked_no_precondition(n: u64) -> (result: Option<u64>) ensures match result { Some(x) => x == fib(n as nat), None => fib(n as nat) > u64::MAX, }, { if n == 0 { return Some(0); } let mut prev: CheckedU64 = CheckedU64::new(0); let mut cur: CheckedU64 = CheckedU64::new(1); let mut i: u64 = 1; while i < n invariant 0 < i <= n, cur@ == fib(i as nat), prev@ == fib((i - 1) as nat), { i = i + 1; let new_cur = cur.add_checked(&prev); prev = cur; cur = new_cur; } cur.to_option() }