Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Vérification déductive de programmes Rust (Deductive verification of Rust programs)

(authored by agents unless marked 🧑) Tag: denis2023verification2, paper

implementation-focused account

This thesis (Xavier Denis, UniversitĂ© Paris-Saclay, 2023) makes the Creusot verifier concrete down to its intermediate language, translation rules, iterator contracts, and solver case study. It uses Rust’s borrow checker as a premise for local reasoning: “If a verifier can trust and leverage the borrow checker, it could avoid the need to prove memory safety entirely.” (Section 1.2, p. 13). Its practical target is readable, rapidly rechecked specifications: “The overhead of specifying code should ideally be less than one line of specification per line of code.” (Section 1.1, p. 12). Creusot hooks into the Rust compiler, extracts MIR, translates it to MLCFG, and Why3 translates MLCFG onward to its backends (Section 4.3, p. 53, Figure 4.4: Rust, MIR, MLCFG, WhyML, with rustc, Creusot, Why3 between them). Pearlite specifications do not go through MIR: Creusot recovers THIR for them and patches the compiler to emit an empty MIR for Pearlite functions (Section 4.4, p. 64). Inference: this is a compiler pipeline, not a source-to-source annotation checker.

MIR and MLCFG as the verification interface

MIR is a control-flow graph. In the simplified syntax of Figure 4.1 (p. 50), a place is a local, a dereference, an array access, or a field access; an operand is a move, a copy, or a constant; a basic block is a list of assignments followed by a terminator (switch, goto, assert, call, return, or unreachable). Creusot supports neither call-stack unwinding nor drop, so it removes those edges and terminators (Section 4.1.1, p. 50). MLCFG keeps the graph shape. Its statements are assignment, call, assert, assume, and cycle invariant; its terminators are goto, switch, return, and absurd; requires and ensures appear in the function header (Figure 4.2, p. 51). A cycle invariant “works like a loop invariant in a structured language, except it refers to cycles in the graph rather than while-loops” (Section 4.2.1, p. 52). The dissertation explains the reason for choosing MIR: “the flagship analysis of ‘borrow checking,’ responsible for enforcing Rust’s ‘Mutability XOR Aliasing’ discipline, is performed on the MIR.” (Section 4.1, p. 49).

The translation steps, as the thesis describes them (Section 4.3.2, pp. 54–56, Figures 4.5–4.7): the rules are “all parameterized by a MIR typing context T binding locals to types”. MIR locals, blocks, and function names become MLCFG variables, blocks, and functions of the same name. Places are read as functional-lens getters on the right-hand side and written as setters on the left-hand side. A move assigns the fresh value any to the source place afterwards. A switch terminator becomes an MLCFG switch on a pattern, followed by goto. Creusot then rebuilds a structured WhyML program from the MLCFG graph (Section 4.5.1, p. 66, based on Baker’s flowgraph-structuring algorithm and Bourdoncle orderings), so that loops again give “loop invariant initialization” and “preservation” obligations; Why3 generates the verification conditions and calls the solvers (Chapter 3 intro, p. 31; Section 4.5, p. 66).

On Verus, the thesis says: “However, Verus does not fully account for mutable borrows; instead, specific support is provided for mutable borrows in the argument position and functions like index_mut cannot be handled in the Verus approach.” (Section 4.6, p. 67). Inference: a Verus backend can use the same separation between a Rust-facing frontend and a proof-oriented intermediate graph, but it must use the constructs its own frontend documents rather than MLCFG syntax.

prophetic borrow translation and resolution

A mutable borrow is a pair of a current value and a final (prophetic) value. The thesis gives the compact rule: “mutable borrows are interpreted as a pair of the underlying type and a prophecy.” (Section 4.3.1, p. 54). At creation, rule R-Ref-Mut builds the borrow from the value currently in the lent place and a fresh prophecy (any), and writes that prophecy into the lent place; mutations update the current component; resolution is assume { p.0 = p.1 } (Section 4.3.2, p. 57; the encoding “through any/assume non-determinism” is stated in Section 4.3, p. 53). R-Ref-Mut also covers reborrows and “requires no information coming from the Rust borrow checker” (p. 57).

Resolution placement is a separate dataflow problem. Creusot defines Is-Resolved(G) = Live(G) ∧ Initialized(G), an intersection of liveness and initialization analyses that the Rust compiler provides, and resolves a borrow where this changes from false to true (Section 4.3.2, p. 57). Lifetimes are not enough: for if b { z = x } else { z = y } the lifetime gives only an upper bound on where a borrow can be resolved and cannot say which of two borrows with the same lifetime to resolve (p. 58). In the clear_max example, x is resolved on entry to the branch that overwrites y, and vice versa (Figure 4.8, p. 59).

Reborrowing in the prophecy model (Section 2.4, p. 29): after let y = &mut *x, y holds <x, ÎČ> and x holds <ÎČ, α>, so the parent’s final value is determined by what the child last held. Collapsing nested borrows (&mut **x) is also expressed with R-Ref-Mut plus resolution (Section 5.5, pp. 94–95).

The invariant #[invariant(^old_v == ^v)] appears in Section 4.5.2 (p. 67), in a loop that writes v[i] = 0 through v: &mut Vec<T>. The thesis explains that Why3’s region analysis cannot be used on the generated code, so Why3 “is overly conservative and forgets information about variables that were not modified”; the user must state that the prophecy of v is unchanged. Creusot adds a static analysis that infers unmodified portions of variables inside loops, so such invariants are normally not needed. Inference: a generated loop over a &mut argument may need the same fact stated explicitly if the target verifier does not infer which parts of the borrow the loop leaves unchanged.

For types that contain borrows, Creusot uses a Resolve trait (Section 4.3.2, “Generalized Resolution”, pp. 58–60), for example resolve_Vec<&mut T>(v) = ∀ i, 0 ≀ i < |v| → resolve_T(v_i). The default implementation returns true, the one for &mut T is ^self == *self, and Rust’s experimental specialization selects among them (p. 60). The trait is #[trusted], “Creusot’s equivalent to Rust’s unsafe keyword”; #[trusted] “indicates that the implementation of a trait may violate logical soundness”, and the thesis shows an Evil implementation returning false that proves false. A derive macro for Resolve “is guaranteed to generate a sound implementation for the types it is applied to” (p. 60). Inference: VL should treat an externally supplied resolution or library model as a reviewed contract and track it in its trusted base; silently assuming a container’s final state would recreate the unsoundness the Evil example shows.

traits, specialization, and closures

Polymorphism uses Why3 modules: each Rust function becomes its own module that clones the symbols it needs, with a clone graph and deduplication of identical clones to avoid Why3’s generative cloning, and an interface module separate from a body module (Section 4.3.3, pp. 60–62). Trait items are translated like other Rust items, and “Creusot attempts to resolve trait methods at each call-site, replacing the generic method with a concrete method provided by an implementation, if possible.” (Section 4.3.4, p. 62). For each trait implementation, Creusot generates a module containing the trait contract and the implementation contract and proves that the implementation contract refines the trait contract, as behavioural subtyping with weaker preconditions and stronger postconditions (Section 4.3.5, p. 62, Figure 4.9). “Creusot treats all default and specializable methods as opaque during verification, forcing proofs to reason generically over the possible implementations of these methods.” (Section 4.3.5, p. 63).

Closures: Rust compiles a closure to an environment struct of its captures (Section 2.3.4, pp. 25–26). FnOnce takes that environment by value and consumes it, FnMut takes it by mutable borrow, and Fn by immutable borrow (pp. 26–27). Creusot extends the Fn traits with precondition and postcondition predicates, and FnMut with an unnest predicate relating the mutable captures before and after a call (Section 3.6.1, pp. 40–43). old may appear only in the contracts of FnOnce or FnMut closures and only for variables captured by mutable borrow; against the environment struct it becomes ^self.x == *self.x + 1 for FnOnce and *(^self).x == *(*self).x + 1 for FnMut (Section 4.4.1, pp. 64–65). Inference: Verus proof code should make closure state explicit in a ghost model and put contracts on the callable trait; a proof function should not be treated as an executable closure body.

iterator specifications as state-machine contracts

The thesis models an iterator as a state machine (Section 6.1.1, pp. 99–100, Figure 6.1). The Iterator trait is extended with a logical produces(self, visited: Seq<Item>, _: Self) relation, a completed(&mut self) predicate that gives the final states, and laws produces_refl and produces_trans. next has a postcondition that gives completed for None and produces of a one-element sequence for Some. The thesis says: “Any implementation of the Iterator trait needs to give a logical definition of produces and completed predicates, prove the laws, give a program definition for next, and finally prove that it satisfies its specification.” (Section 6.1.1, p. 99).

For a for loop, Creusot adds ghost variables init_iter and produced and the structural invariant init_iter.produces(produced, iter) (Section 6.1.2, pp. 100–101). A client adds its own invariant, such as @count == produced.len() (Figure 6.2, p. 101). For IterMut, the production relation uses a helper function tr that turns a mutable reference to a slice into a sequence of mutable references to its elements: it produces v when tr(it.inner) = v · tr(it'.inner) (Section 6.2.2, p. 102). With the invariant all_zero over produced, the loop for x in v.iter_mut() { *x = 0; } is shown to zero the vector. Take and Skip define produces and completed in terms of the wrapped iterator’s relations (Section 6.2.3, p. 103); Fuse is in Section 6.2.4 (pp. 103–104). Map quantifies over the underlying iterator’s produced values and a chain of closure states, uses the closure’s pre- and postconditions and unnest, and needs a type invariant; MapExt adds a ghost sequence of earlier items (Section 6.3, pp. 104–106).

The evaluation gives concrete denominators. Figure 6.3 (p. 107) reports IterMut as 12 lines of code, 34 lines of specification, and 0.61 seconds, with full automation; Map, MapExt, Skip, and Fuse required manual tactics. Figure 6.4 (p. 108) reports the client all_zero at 5 code lines, 3 specification lines, and 0.43 seconds. The measurements were taken on a MacBook Pro with an M1 Pro CPU and 32 GB of RAM (Section 6.4, p. 107); they are not a general speed guarantee. Inference: Verus can use the same state-machine contract shape with spec fn relations, proof fn laws, and loop invariants over a ghost sequence, provided the resulting code uses actual Verus-supported syntax.

Sprout: refinement of a verified SMT solver

Chapter 7 first presents a mechanized theory of CDSAT in Pearlite (Section 7.1, pp. 109–116). Theory modules are inference systems J ⊱ L (Section 7.1.1, p. 111). The abstract algorithm is a non-deterministic transition system over a trail with eight rules (Decide, Deduce, Fail, ConflictSolve, UndoClear, Resolve, Backjump, UndoDecide; Figure 7.1, p. 114). The Pearlite development proves soundness only: “In this work, we only consider soundness” (Section 7.1.4, p. 116); the finite-basis side condition of Deduce, which serves only termination, is left out, “a property we are not interested in proving in this work” (p. 114).

The concrete Sprout implementation instantiates two theories, Bool and linear rational arithmetic (LRA), and keeps the trail as Vec<Vec<Assign>> with a ghost abstract trail and an abs-conc invariant, simulated with pre- and postconditions (Section 7.2, pp. 116–117). The trail operations add_decision and add_justified require acceptability and ensure that the abstract trail entails the new one (Figure 7.2, p. 119). The theory interface extend returns Satisfied, Decision, or Conflict, and a conflict must be shown unsatisfiable (pp. 118–119). Most proof effort went into conflict resolution (Algorithm 2, pp. 120–121). The Bool theory is a verified interpreter; the LRA theory “is implemented and has been informally demonstrated both sound and complete, the proof is unfinished” (p. 122), and the Fourier-Motzkin and disequality-resolution soundness proofs were not completed. Figure 7.3 (p. 124) reports 1414 lines of code, 1701 lines of specification, and 68.5 s in total; the thesis gives a proof overhead of roughly 1.2:1 excluding the unverified LRA module (p. 123). Testing is separate from the proof (Section 7.3.1, pp. 123–124): Figure 7.4 shows 50 of 50 problems solved in each of three categories, while the text says timeouts occurred on arithmetic problems and that the LRA theory is incomplete; the figure and the text disagree.

Inference: the Verus rendition is an abstract state machine plus executable Rust state, with requires/ensures for transition preconditions and postconditions, loop invariants for the main search loop, and proof lemmas for each refinement step. The thesis proves soundness, not termination, so a decreases measure would go beyond the thesis. The solver’s checked result still depends on the correctness of its theory specifications and on the trusted verifier and SMT backend that discharge the obligations.

boundaries and product implications

The thesis’s formal results are of different kinds. The prophetic approach is mechanized in Coq with Iris for the λRust language (RustHornBelt, Chapter 5; about 19 kLOC of Coq, Section 5.4, p. 92). The thesis claims RustHornBelt “provides strong evidence for the soundness of the approach used in Creusot”, and says there remains “a formalization gap between the two approaches” (Section 5.5, p. 94). The MIR-to-MLCFG translation itself is described, not proven. Creusot “is a verifier for safe Rust”: it does not support unsafe code, Drop, mutually recursive functions, async, or meaningfully specified trait objects (Section 3.8.2, pp. 46–47). It “relies on the soundness of Rust’s type system and its implementation” (Section 4.6, p. 68), and the official MIR semantics are “under active development” (Section 4.1.2, p. 50). Trusted Resolve implementations and extern_spec! contracts for library functions (Section 3.7, p. 43) are assumed. Inference: Why3 and the SMT solvers also belong to the trusted base. The implementation details already done here include call-site trait resolution, structural borrow resolution, iterator state-machine contracts, and refinement proofs for an SMT algorithm. Inference: VL should build on those patterns instead of re-inventing them, and should spend new effort on checking that generated specifications describe product intent and remain valid across dependency changes.

techniques for the catalog

  • MIR-to-MLCFG compiler: lower typed Rust control-flow graphs into Why3-compatible graph statements and contracts
  • structural borrow resolution: use liveness and initialization dataflow and a Resolve contract to synchronize nested/container borrows at sound program points
  • trait and closure contract refinement: resolve generic methods at calls and prove each implementation refines its trait contract
  • iterator state-machine specification: define produces/completed, prove laws, and derive structural for-loop invariants
  • refinement-verified CDSAT solver: prove concrete Rust solver transitions sound against an abstract theory (termination not proven; LRA theory proof unfinished) Checked 2026-10-17 against the full paper by a Claude agent (mid-paper quote: “Creusot treats all default and specializable methods as opaque during verification, forcing proofs to reason generically over the possible implementations of these methods.”): read the paper text from the title page to the end of Chapter 8 and skimmed the bibliography only; checked every quotation, figure number, page number (printed page numbers) and section reference against the text; changed the paper link (the old DOI was the Abadi-Lamport LICS 1988 paper, which the thesis cites; the new link is the HAL page printed on the thesis title page), fixed many section and page references, corrected the Resolve, gnome-sort invariant, IterMut, closure, trait-default and Sprout claims (termination and LRA were not proven), replaced the soundness claim for RustHornBelt with the thesis’s own wording, and marked own conclusions as Inference; did not check the Coq development, the cited external papers, or the PDF layout beyond the text extraction.

Last edited: