A Hoare Logic for Rust

Lately, I've been working on a Hoare-logic-based model of the Rust MIR, which I will introduce in the post. This is a minor step towards a memory model of Rust, and it allows formalization of programs and their behavior.

This project was born out of the effort to formalize the Redox kernel and the ralloc memory allocator as well as coming up with a Rust memory model.

Here I will walk through the techniques, axioms, and transformations in detail. I've divided this post into three parts:

  1. An introduction to Hoare logic: An gentle introduction for the beginners (can be skipped if you're already familiar with Hoare logic).
  2. Applying Hoare logic to the Rust MIR: Notably dropping structured programming in favour of a lower-level goto-based representation, and how it helps simplifying certain things.
  3. Reasoning about pointers: Pointers are notoriously hard to reason about. Here we try to formalize their behavior and give various insight on how they can be reasoned about. Priory to this part, we assume that pointers doesn't exist.

This blog post is not a formal specification or a paper, but rather a mere introduction to the subject and proposed axioms.

If the math doesn't show up properly, reload the page.

An introduction to Hoare logic

So, what is Hoare logic? Well, it's a set of axioms and inference rules allowing one to reason about imperative programs in a rigorous manner.

The program is divided into so called Hoare triples, denoted \(\{P\} \ S \ \{Q\}\). \(P\) is called the "precondition". Informally, if \(P\) is satisfied, then after \(S\) (the statement or instruction) has been executed, \(Q\) (the postcondition) should be true. In other words, \(P\) is true before \(S\), and \(Q\) should be true after.

In fact, we can view \(S\) as a function on the state space, going from \(\sigma\) satisfying property \(P(\sigma)\) to a state \(S(\sigma) = \sigma'\) satisfying the postcondition, \(Q(\sigma')\).

Thus a Hoare triple can be seen as a 3-tuple

\[(P, f, Q)\]

satisfying:

\[P(\sigma) \to Q(f(\sigma))\]

It turns out that this interpretation is a strong one, and we will use it throughout the post to derive the Hoare rules, some of which follows directly from this interpretation.

\[\frac{\forall \sigma.P(\sigma) \to Q(f(\sigma))}{\{P\}\ f\ \{Q\}} \qquad \frac{\{P\}\ f\ \{Q\}}{\forall \sigma.P(\sigma) \to Q(f(\sigma))}\]

(on a side note, this notation should be understood as: what is below the line is true if what is above is true)

An example

Suppose we have the program,

// a = 4
a += 2;
// a = 6

This is expressed by the Hoare triple

\[\{a = 4\} \ a \gets a + 2 \ \{a = 6\}\]

So far, we have only introduced the notation, which in itself is worthless, what's really the core is the rules that allows us to reason about valid Hoare triples. We need a way to essentially construct new Hoare triples from old ones.

Rules and axioms

Empty statement rule

The empty statement rule states that: Let \(S\) be any statement which carries no side-effect, then \(\{P\} \ S \ \{P\}\), or in inference line notation:

\[\frac{S \text{ is pure}}{\{P\} \ S \ \{P\}}\]

This rule is relatively simple: If the state is not changed, the invariants are neither. Note that this is only true for effect-less statements, since the statement could otherwise change variables or in other ways invalidate the postcondition.

In fact, we can express it in terms of the identity function, \(f(x)=x\). Then,

\[P(x) \to P(f(x)) = P(x)\]

Hence, the triple is valid.

Composition rule

The composition rule allows you to concatenate two statements (into a Hoare triple) if the first statement's postcondition is equal to the second statement's precondition:

\[\frac{\{P\}\ S\ \{Q\}, \quad \{Q\}\ T\ \{R\}}{\{P\}\ S;T\ \{R\}}\]

It is left as an exercise for the reader to verify the correctness of the rule above.

Strengthening and weakening conditions

\[\frac{P_1 \to P_2,\quad \{P_2\}\ S\ \{Q_2\},\quad Q_2 \to Q_1}{\{P_1\}\ S\ \{Q_1\}}\]

So, what's going on here? Well, \(P_1\) implies \(P_2\), so we can replace the precondition by a stronger version which implies the old one. The same cannot be applied to postcondition, because the strengthened precondition might not yield the strengthened postcondition after the statement. We can however replace it by a weaker postcondition (i.e. one which is implied by original postcondition).

We can always weaken guarantees, but never assumptions, since the assumption is what the guarantee relies on. Assumptions can be made stronger, however.

It is left as an exercise for the reader to verify the correctness of the rule above.

The assignment axiom

This axiom is the most important. It allows for reasoning about preconditions in the case of assignments. It is absolutely essential to Hoare logic.

\[\frac{}{\{P[x \gets E]\}\ x \gets E\ \{P\}}\]

\(P[x \gets E]\) denotes replacing every free (unbound) \(x\) with \(E\).

Let's say \(P\) involves some assertion about \(x\), then we can move it over the assignment (to the precondition) replacing \(x\) with the right-hand-side of the assignment, because every occurence of \(x\) represents said value anyway, so substituting the value \(x\) represents for \(x\) won't change the structure.

Let's say we have the statement, \(x \gets x + 2\), with the postcondition \(\{x = 6\}\), we can then derive the Hoare triple:

\[\{x + 2 = 6\}\ x \gets x + 2\ \{x = 6\}\]

One thing that is surprising, but also incredibly important, is that you substitute it into the precondition and not the postcondition. To see why such a rule (\(\{P\}\ x \gets E\ \{P[x \gets E]\}\)) would be wrong, observe how you could derive \(\{x = 1\}\ x \gets 2\ \{2 = 1\}\), which is clearly false.

It is also worth noting that, in this context, expressions cannot carry side-effects. We'll cover this in detail in part two.

Conditional rule

So far, we have only covered a simple language without loops, conditionals, and other forms of branches.

The first (and simplest) form of branches is a conditional non-cyclic branch (if). These behaves in a very simple way:

\[\frac{\{C \land P\}\ B\ \{Q\},\quad \{\neg C \land P\}\ E\ \{Q\}}{\{P\}\ \textbf{if } C \textbf{ then } B \textbf{ else } E \textbf{ end}\ \{Q\}}\]

As complex this looks, it's actually relatively simple:

  1. In your if statement's body, you can safely assume the if condition to be true.
  2. If both branches shares their postcondition (\(Q\)), then the if statement does as well.

As an example, consider the code,

if x == 4 {
    // I can safely assume that x = 4 here.
    ...
    x = 2;
    // Now x = 2.
} else {
    // I can safely assume that x ≠ 4 here.
    ...
    x = 2;
    // Now x = 2.
}
// All branches share postcondition, so the whole if-statement does as well: x = 2

The loop rule

The loop rule reads,

\[\frac{\{I \land C\}\ B\ \{I\}}{\{I\}\ \textbf{while } C \textbf{ do } B \textbf{ done}\ \{I \land \neg C\}}\]

\(I\) is called the loop invariant, i.e. the condition which is true before and after the loop. The loop will terminate when \(\neg C\), hence the postcondition of the loop.

As a simple example, take the following code:

let mut x = 3;
let mut y = 4;
// Precondition: x == 3 (loop invariant)
while y < 100 {
    // Precondition: y < 100 && x == 3
    y += 1;
    // Posttcondition: x == 3 (loop invariant)
}
// Postcondition: !(y < 100) ⇒ y >= 100

Applying Hoare logic to the MIR

The Rust MIR is in many ways an interesting language. It can be seen as an extremely stripped-down version of Rust. What we'll work with is the MIR from the last compiler pass.

The Rust MIR

The Rust MIR has no structural control flow. It directly resembles the CFG of the program.

There are three concepts we must be familiar with to understand the Rust MIR:

  1. Functions: A graph.
  2. Basic blocks: The nodes in the graph.
  3. Terminators: The edges in the graph.

We'll not get into the representation of scopes and type information the MIR contains.

Functions

Taking aside the type information, functions have two components: A set of variables and a Control Flow Graph.

The function starts with a bunch of variable declarations (arguments, temporaries, and variables). There's one implicit variable, the return variable, which contains the return values.

Secondly, there's a set of basic blocks, as well as a starting block.

Basic blocks

Basic blocks are the nodes of the CFG. They each represent a series of statements. In our model, we can wlog. assume that a statement is simply an assignment, \(x \gets y\), where \(y\) is an operand. In other words, a basic block is of the form \((x_1 \gets y_1; x_2 \gets y_2; \ldots; x_n \gets y_n, t)\) with \(t\) being the terminator.

In fact, we can go even further: A statement is a single assignment. This can be shown by simply constructing a map between the two graphs (by using the goto terminator to chain).

Note that there are two kinds of assignments. Up until now, we have only considered the simple assignment \(x \gets y\) with \(y\) being a simple, effectless expression. There's actually a second form of assignment, the function call assignment, \(x \gets f(y)\).

In such an assignment, the function can change the state of the program, and thus care must be taken, since you cannot always use the assignment axiom. We'll get back to that later on.

Terminators

Terminators are what binds basic blocks together. Every basic block has an associated terminator, which takes one of the following forms:

  1. Return from the current function: \(\textbf{return}\). The return value is stored in the return variable.
  2. Calling a diverging function ("transferring" to the function), \(f(x)\).
  3. Non-conditionally jumping to another block \(\textbf{goto}(b)\).
  4. Jumping to another block if a condition is true, \(\textbf{if}_c(b_1, b_2)\).

(there is a few - in the implementation - we ignore in our model for simplification purposes)

Notice how none of these are structural. All are based around gotos. Not only does this simplify our analysis, but it's also more near to the machine representation.

As an example, let's write a program that finds the 10th Fibonacci number:

Tenth Fibonacci number

First of all, the program starts by assigning starting values. Then it enters a loop with a conditional branch in the end (is 10 reached yet?). In this loop we do the classic, add the two numbers and shift one down. When the loops ends, we assign the return value, and then return from the function.

Reasoning about the MIR

Unconditional gotos

The first rule is the non-structural equivalent of the composition rule. All it says is that for a goto-statement to be valid, the precondition of the target basic block must be true:

\[\frac{\{P\}\ b_1\ \{Q\}, \quad \{Q\}\ b_2\ \{R\}}{\{P\}\ b_1; \textbf{goto}(b_2)\ \{R\}}\]

Conditional gotos

Conditional gotos are interesting in that it allows us to reason about both while-loops and if-statements in only run rule.

\[\frac{\{P \land C\}\ b_1\ \{Q\},\quad \{P \land \neg C\}\ b_2\ \{Q\}}{\{P\}\ \textbf{if}_C(b_1, b_2)\ \{Q\}}\]

It is the non-structural equivalent of the conditional rule, we described earlier.

Function calls

Functions take the form \(f(x) \stackrel{\text{def}}{=} \{P(x)\}\ b\ \{Q(x)\}\), i.e. an initial starting block, \(b\), and a precondition and postcondition, respectively.

The rule of correctness for function calls reads,

\[\frac{f(x) = \{P(x)\}\ b\ \{Q(x, \textbf{return})\}}{\{P(y) \land R[x \gets f(y)]\}\ x \gets f(y)\ \{Q(y, x) \land R\}}\]

This one is a big one. Let's break it up:

  • The assumption (above the inference line) states that \(f(x)\) is a Hoare triple with the precondition and postcondition being terms depending on the argument.
  • The postcondition depends on the return value of \(f(x)\) as well.
  • The conclusion (below the inference line) consists of a Hoare triple with an assignment to \(x\).
  • The postcondition of the assignment is \(Q(y, x)\) which express that the return value of the function is assigned to \(x\), and the argument is \(y\). This is logically joined with \(R\), which is carried over to the other side:
  • The precondition consists of \(R[x \gets f(y)]\), in a similar manner to the assignment axiom, as well as \(P(y)\), the precondition of the function.

Note that this rule will be modified later when we introduce pointers into our model.

Take this imaginary program:

fn subtract_without_overflow(a: u32, b: u32) -> u32 {
    // Precondition: b ≤ a
    a - b
    // Postcondition: return ≤ a
}

fn main() {
    let mut n = 0;
    let mut res;
    while n < 10 {
        res = subtract_without_overflow(10, n);
        // Postcondition: res < 10 (this is what we're going to prove!)
        n += 1;
    }
}

We know here that the condition for the loop is \(x < 10\), as such we set:

\[\begin{align*} x &= \mathtt{res}\\ y &= (10, n)\\ R &= [\mathtt{res} < 10]\\ P((a, b)) &= [b \leq a]\\ Q((a, b), r) &= [r \leq a] \end{align*}\]

Plug it all in, and get:

\[\frac{f(a, b) = \{n \leq a\}\ S\ \{f(a, b) \leq a\}}{\{n \leq 10 \land f(10, n) < 10\}\ x \gets f(10, n)\ \{f(10, n) \leq 10 \land \mathtt{res} < 10\}}\]

The desired result is obtained: the precondition implies that \(n < 10\), which is also the loop condition.

Thus, we can conclude that there is no overflow in the program. Cool, no?

Don't repeat yourself!

The rest of the rules are exactly matching the "classical" Hoare logic axioms. In other words, the assignment axiom, skip axiom, and consequence axiom remains unchanged.

Reasoning about pointers

This is a tricky subject. Pointers are notorious for being hard to reason about. In fact, they are probably the single hardest subject in program verification.

Approach 1: Global reasoning

We could simply consider memory as one big array, in which pointers are indexes, but it turns out such a model is not only non-local, but also very messy, as such we need to derive a more expressive and convenient model to be able to reason about pointers without too much hassle.

Approach 2: Relational alias analysis

To start with, I'll introduce a model I call "relational alias analysis". We define an equivalence relation, \(\sim\), on the set of variables. This equivalence relation tells if two variables are aliased (i.e. pointers to the same location).

Aliasing variables

The first axiom reads,

\[\frac{\{x \sim y\}}{\{x = y\}}\]

i.e. if two variables are aliased, they're equal.

This is perhaps more of a definition than an axiom. None the less, it describes the semantics of our alias relation.

Then we describe the behavior of alias asignments:

\[\frac{}{\{A = \textbf{alias}(a)\}\ a \stackrel{\text{alias}}{\gets} b\ \{\textbf{alias}(a) = A \cup \{b\}\}}\]

(\(\textbf{alias}(x)\) defines the equivalence class of \(x\) under \(\sim\))

This allows for declaring a variable to be aliased with another variable.

Assignment axiom for aliased values

Preconditions and postconditions can contain statements on the value behind the pointer, which has the unfortunate consequence that the old assignment axiom schema is no longer valid.

In fact, we simply need to observe that previously, we had \(\textbf{alias}(x) = \{x\}\). Now that we introduced aliased values, the situation changed, and the equivalence class can be arbitrarily large.

We put,

\[\frac{}{\{P[\textbf{alias}(x) \gets E]\}\ x \gets E\ \{P\}}\]

Note that \(P[A \gets E]\) means that we replace every element \(a \in A\) with \(E\).

In other words, we do the same as before except that we assign the value to all the aliased variables.

Insufficiency

This model allows reasoning about aliases, but not pointers in general. In fact, it cannot reason about noalias pointers, deallocation, and pointer arithmetics.

Approach 3: Separation logic

Separation logic was originally introduced by JC Reynolds in one of the most cited program verification papers ever. It is more complex than the alternative model we just presented, but also more expressive in some cases.

Modeling memory

Our model of memory consists of multiple new notations. First of all, the model becomes memory aware. We use \(p \mapsto x\) to denote that some pointer, \(p\), maps to the value \(x\).

We use the notation \(\mathcal{H}(p)\) to denote pointer reads. The reason we keep the notation function-like is because it is, in fact, just a function! It simply maps pointers to values. We can define,

\[\frac{p \mapsto x}{\mathcal{H}(p) = x}\]

We denote pointer writes by \(p \stackrel{\text{ptr}}{\gets} x\).

Disjointness

The first feature of separation logic is the notion of "separate conjunction", denotes \(P * Q\).

This asserts that \(P\) and \(Q\) are both true and independent, i.e. their "heaps" are disjointed and not affected by the statement of the Hoare triple. In particular, let \(A\) be the domain of \(\mathcal{H}\), then let \(\{A_1, A_2\}\) be some semipartition of \(A\) (\(A_1 \cap A_2 = \emptyset\)), then put \(A_1 = \textbf{ref}(P)\) and \(A_b = \textbf{ref}(Q)\) (\(\textbf{ref}(P)\) denotes all the locations that are referenced in \(P\), e.g. \(\textbf{ref}([\mathcal{H}(x) = 3]) = \{x\}\))

We can then put \(P * Q\). This might seem useless at first (how much different from \(\land\) is it?), but it is incredibly important: If \(P\) and \(Q\) are dependent, not by sharing a free variable, but instead share a variable through aliasing (say \(P\) has \(x\) free and \(Q\) has \(y\) free, and \(x \sim y\)).

All this will be formally defined in the next subsection.

The frame rule

The frame rule is the most important component of separation logic. It reads,

\[\frac{\textbf{mut}(C) \cap \textbf{free}(R) = \emptyset,\quad \{P\}\ C\ \{Q\}}{\{P * R\}\ C\ \{Q * R\}}\]

\(\textbf{mut}(C)\) means the set of variables \(C\) "mutates" (changes) when executed. For example, \(\textbf{mut}(a \gets b) = \{a\}\).

What the rule says is that if \(C\) never changes the "environment" from \(R\), then you can safely join the precondition and postcondition with \(R\) of some Hoare triple with \(C\).

The behavior of by-reference assignments

The next thing we need is a way to reason about assignments behind pointers, or "pointer writes". We use the term "by-reference assignments" to signify the similarities between normal assignments.

Starting by defining by-reference assignment, we add a rule allowing us to write to valid pointers:

\[\frac{}{\{P * p \mapsto \bullet\}\ p \stackrel{\text{ptr}}{\gets} x\ \{P * p \mapsto x\}}\]

Next, we need to specify the semantics of reading from a pointer:

\[\frac{\{P \land p \mapsto x\}\ k \gets \mathcal{H}(p)\ \{Q\}}{\{P \land p \mapsto x\}\ k \gets x\ \{Q\}}\]

In other words, writing the data read from a pointer to a variable is equivalent to writing the value it's pointing to. This is more of a definition than an actual rule, because it is obvious, ignoring the notation.

Allocation

Allocation is what introduces a new heap store/pointer into the heap. And its behavior is relatively straight-forward:

\[\frac{p \notin \textbf{free}(P)}{\{P\}\ p \gets \textbf{alloc}(s)\ \{P * p \to \bullet\}}\]

Namely, if \(p\) is not contained in \(P\), allocation creates a new, separate pointer. \(\bullet\) denotes that the pointer is uninitialized or the value is unknown.

Deallocation

As an example, take the dealloc function. This function obviously requires that there is no usage of the pointer later on (i.e. no use-after-free). We can express this in a relatively simple way:

\[\frac{}{\{P * p \mapsto x\}\ \textbf{dealloc}(p)\ \{P\}}\]

The \(*\) here express the independence of the content and validity of the pointer \(p\), which is really where separation logic shines: We can express pointer relation, and in this case, make sure that there is no usage of \(p\) after the free.

Pointers on the stack

In a formal model, the stack and the heap are not semantically different. In fact, we can interpret function calls as allocating the arguments onto the heap and deallocating them again when returning.

Detecting memory leaks

In this model, it is surprisingly easy to prove your program leak-free. You simply have to put that the heap is empty in the postcondition and propagate it forward.

Future work and what's next

Currently, I am writing a theorem extractor, which will generate the statement of correctness for some arbitrary program. This can then be fed into SMT solver and shown to be true.

Another aspect is the compilation itself, which must be a verified process, as such I am working on a compiler and formal proof of correctness of said compiler.

Lastly, I can formally verify Ralloc and Redox.

Conclusion and final words

We have seen how a modest set of rules can create an elegant way to reason about the complex behavior of programs. Rust already has a very interesting form of static analysis, but it is decidable and much simpler, as a result, there is a lot of things it can not reason about, like raw pointers. We need a more advanced model (like the one we proposed in this post) to reason about such things.