Bit vectors and bitwise operations

In its default prover mode, Verus treats bitwise operations like &, |, ^, << and >> as uninterpreted functions. Even basic facts like x & y == y & x are not exported by Verus’s default solver mode.

To handle these situations, Verus provides the specialized solver mode bit_vector. This solver is great for properties about bitwise operators, and it can also handle some bounded integer arithmetic, though for this, its efficacy varies.

Invoking the bit_vector prover mode.

The bit_vector prover mode can be invoked similarly to nonlinear_arith, with by(bit_vector) either on an assert or a proof fn.

For example, we can shorts and context-free bit-manipulation properties:

fn test_passes(b: u32) {
    assert(b & 7 == b % 8) by (bit_vector);
    assert(b & 0xff < 0x100) by (bit_vector);
}

Again, as with nonlinear_arith, assertions that use by(bit_vector) do not include any ambient facts from the surrounding context (e.g., from the surrounding function’s requires clause or from previous variable assignments).

Currently, assertions expressed via assert(...) by(bit_vector) do not include any ambient facts from the surrounding context (e.g., from the surrounding function’s requires clause or from previous variable assignments). For example, the following example will fail:

fn test_fails(x: u32, y: u32)
  requires x == y
{
  assert(x & 3 == y & 3) by(bit_vector);  // Fails
}

But context can be imported explicitly with a requires clause:

fn test_success(x: u32, y: u32)
    requires
        x == y,
{
    assert(x & 3 == y & 3) by (bit_vector)
        requires
            x == y,
    ;  // now x == y is available for the bit_vector proof
}

And by(bit_vector) is also supported on proof functions:

proof fn de_morgan_auto()
    by (bit_vector)
    ensures
        forall|a: u32, b: u32| #[trigger] (!(a & b)) == !a | !b,
        forall|a: u32, b: u32| #[trigger] (!(a | b)) == !a & !b,
{
}

Again, this will use the bit_vector solver to prove the lemma, but all calls to the lemma will use the normal solver to prove the precondition.

How the bit_vector solver works and what it’s good at

The bitvector solver uses a different SMT encoding, though one where all arithmetic operations have the same semantic meaning. Specifically, it encodes all integers into the Z3 bv type and encodes arithmetic via the built-in bit-vector operations. Internally, the SMT solver uses a technique called “bit blasting”.

In order to implement this encoding, Verus needs to choose an appropriate bit width to represent any given integer. For symbolic, fixed-width integer values (e.g., u64) it can just choose the appropriate bitwidth (e.g., 64 bits). For the results of arithmetic operations, Verus chooses an appropriate bitwidth automatically. However, for this reason, the bitvector solver cannot reason over symbolic integer values.

The bitvector solver is ideal for proofs about bitwise operations (&, |, ^, << and >>). However, it is also decent at arithmetic (+, -, *, /, %) over bounded integers.

Examples and tips

Functions vs macros

The bit-vector solver doesn’t allow arbitrary functions. However, you can use macros. This is useful when certain operations need a common shorthand, like “get the ith bit of an integer”.

macro_rules! get_bit_macro {
    ($a:expr, $b:expr) => {{
        (0x1u32 & ($a >> $b)) == 1
    }};
}

macro_rules! get_bit {
    ($($a:tt)*) => {
        verus_proof_macro_exprs!(get_bit_macro!($($a)*))
    }
}

Overflow checking

Though the bit_vector solver does not handle symbolic int values, it does support many arithmetic operations that return int values. This makes it possible to write conditions about overflow:

proof fn test_overflow_check(a: u8, b: u8) {
    // `a` and `b` are both `u8` integers, but we can test if their addition
    // overflows a `u8` by simply writing `a + b < 256`.
    assert((a & b) == 0 ==> (a | b) == (a + b) && (a + b) < 256) by(bit_vector);
}

Integer wrapping and truncation

The bit_vector solver is one of the easiest ways to reason about truncation, which can be naturally expressed through bit operations.

proof fn test_truncation(a: u64) {
    assert(a as u32 == a & 0xffff_ffff) by(bit_vector);

    // You can write an identity with modulus as well:
    assert(a as u32 == a % 0x1_0000_0000) by(bit_vector);
}

You may also find it convenient to use add, sub, and mul, which (unlike +, -, and *) automatically truncate.

proof fn test_truncating_add(a: u64, b: u64) {
    assert(add(a, b) == (a + b) as u64) by(bit_vector);
}

Working with usize and isize

If you use variables of type usize or isize, the bitvector solver (by default) assumes they might be either 32-bit or 64-bit, which affects the encoding. In that case, the solver will generate 2 different queries and verifies both.

However, the solver can also be configured to assume a particular platform size.

Bit-width dependence and independence

For many operations, their results are independent of the input bit-widths. This is true of &, |, ^, and >>. In fact, we don’t even need the bit-vector to prove this; the normal solver mode is “aware” of this fact as well.

proof fn test_xor_u32_vs_u64(x: u32, y: u32) {
    assert((x as u64) ^ (y as u64) == (x ^ y) as u64) by(bit_vector);

    // XOR operation is independent of bitwidth so we don't even
    // need the `bit_vector` solver to do this:
    assert((x as u64) ^ (y as u64) == (x ^ y) as u64);
}

However, this is not true of left shift, <<. With left shift, you always need to be careful of the bitwidth of the left operand.

proof fn test_left_shift_u32_vs_u64(y: u32) {
    assert(1u32 << y == 1u64 << y); // FAILS (in either mode) because it's not true
}

More examples

Some larger examples to browse: