Verus

Verusは、Rustを仕様記述言語とその証明のための言語で拡張した形式検証のための言語である。 Verusで実装したコードは最終的に同等のRustコードとしてコンパイルされるため、 Rustコードベースに対する静的な保証を拡大するために利用できる。 低レイヤのシステムプログラムに関する機能的正当性の検証を目標としており、 メモリ操作に関する低レイヤのプラクティスを扱える他、SMTによって証明の大部分が自動化される設計となっており、 証明にかかる工数を重視しているという特長がある。 システムプログラムの形式検証に関する実績が多く、 OS[atmosphere][nros]/メモリアロケータ[verus_mimalloc]/分散KVS[verified_ironkv]/シリアライザ[vest]などの検証に利用されている。

検証ツールの導入

オンラインでVerusを試すことができるplaygroundがある。 https://play.verus-lang.org/?version=stable&mode=basic&edition=2021

検査器のバイナリをビルドするには以下に従う。Windows/macOS/Linuxに対応している。

Rustのコード内での利用

VerusのコードをRustのコード内に埋め込むには verus! マクロを用いる。

use vstd::prelude::*;

verus! {
...
}

Cargoプロジェクトの場合、以下を追記することで通常のRustコードと同様にビルド・実行できる。

[dependencies]
vstd = { git = "https://github.com/verus-lang/verus.git" }

検証機のバイナリ(verus/source/target-verus/release/verus)のコマンドラインオプションについて --crate-type=lib と指定すれば、 main 関数を要求されない。 また、 --expand-errors とすると詳細なエラーの説明が得られる。 [1]

Verusの基礎

Verusを使った検証は大まかに次の仕事からなる、まず(1)保証したい性質を表す述語を定義し、 (2)その性質を満たしてほしいRustのオブジェクト(e.g. 関数, struct)に対してアノテーションする、 最後に検査器を実行し、制約が満たされていない場合は(3)証明が成功するように検査器にヒントを与える。 これらはコンパイル時に消去される"ghost code"によって実現されるため、Verusの構文にはモードとして以下のような区別・役割が存在する。

  • spec: プログラムに関する性質を表すためのコード

  • proof: 検査器にヒントを与え、コードが与えた仕様を満たしていることを証明するためのコード

  • exec: 実際に実行されるコード

以下にspecコードの例と目新しい構文の意味を示す。

  • forall|t: T| …​tを使ったbool式…​ という構文で任意のT型の変数tに関して成り立つべき性質を記述できる

  • b1 =⇒ b2 で含意を表現できる。(i.e. !b1 || b2)

  • Seq,int: Verusは仕様記述のために無限長の数(int, nat)やコレクション(Seq, Map, Set)といった 数学的な 対象を表現する型を提供している

  • seq!: Seq 型の値を生成するためのマクロ(vec! 相当)

  • assert: SMTソルバに挿入された時点の環境下でアサーションを証明するように要請する。 Rustの assert! とは異なり、静的に解決される。

spec fn gt_any(x: nat, ls: Seq<nat>) -> bool
{
    forall|i: int| 0 <= i && i < ls.len() ==> x >= ls[i]
}

fn main() {
    assert(!gt_any(1, seq![1, 2, 3]));
    assert(gt_any(3, seq![1, 2, 3]));
}

上で main はexecモードの関数だが、specモードの関数 gt_any を呼び出している。 このように各モードは必ず別々に書かれる訳ではなく、以下のような制約のもとで混ぜて書くことが許されている。

mode

spec

proof

exec

spec内に存在可

o

x

x

proof内に存在可

o

o

x

exec内に存在可

o

o

o

各モードに対応して、変数定義にも let tracked x = …​; のようにしてモードを指定できる。 tracked(ghost)な変数は、Verusの標準ライブラリにある Tracked<T>(Ghost<T>)でも表現できる。

各mode内のコードに依存できるか

ghost

tracked

exec

spec

o

x

x

proof

o

o

x

exec

o

o

o

以下は、メソッドにアノテーションする例である。

  • requires …​ によって関数を呼び出す前に成り立っていて欲しい条件(事前条件)を記述する

  • ensures …​ によって関数を呼び出したあとの状態について成り立っていて欲しい条件(事後条件)を記述する

  • old(self) によってメソッド呼び出し前の self に言及できる

  • 事後条件で返り値に言及したい場合は、返り値に名前をつけることができる e.g. fn f() → (r: bool)

struct S { x: usize }

impl S {
    // 古いself.xがインクリメントしてもオーバフローしない範囲ならば、
    // self.inc()の呼び出し後には、正しくインクリメントされて返り値もその値になっている
    fn safe_inc(&mut self) -> (r: usize)
        requires
            0 <= old(self).x, // カンマ区切りで並べることができる(.. && .. 相当)
            old(self).x < usize::MAX
        ensures r == self.x == old(self).x + 1
    {
        self.x = self.x + 1; self.x
    }
}

Verusを使った検証では度々、データ構造に対応する数学的な表現を使うことがある。(e.g. リンクリストを要素の列として考える) こうした場合には、viewメソッドを定義する。上記の例に対しては例えばこのように定義できる。

impl S {
    spec fn view(&self) -> int {
        self.x as int
    }
}

viewメソッドはspecモード内で variable_name@ のようにして呼び出せる。

検証フローの例: 単方向リンクリスト

Verusによって低レイヤのプラクティスを扱えることを示すために、 (ヒープ内のデータへの)raw pointerを用いた単方向リンクリストの実装と検証の例を取り上げる。

Verusでヒープ内に領域を確保された値を指すraw pointerを扱うための仕組みである PPtr について説明する。 PPtr<V>*mut u8 と同様の意味で値の格納されているアドレスを示す以上の意味はない。 しかし、 PPtr<V> はデリファレンスの際に トークン (PointsTo<V>)を要求する。 このトークン PointsTo<V> はtrackedオブジェクトでありコンパイル時に消去されるが、 Rustのボローチェッカには検査されるためメモリセーフなアクセスが強制される。

// allocation
let (p, Tracked(mut points_to)) = PPtr::<u64>::empty();
// free
p.free(Tracked(points_to)); // points_to moved here
// use after free
p.write(Tracked(&mut points_to), 5); // fails because points_to moved above

このようにraw pointerの読み書きに関する権限情報を分離して追跡することが、 以下に示すリンクリストのようなraw pointerを多用するコードをVerusで検証する際の基本的なアイデアになる。

以下に単方向リンクリストの実装と検証の例を示す。 [2]

use vstd::prelude::*;
use vstd::simple_pptr::*;
#[derive(Clone, Copy)]
pub struct Node {
    next: Option<PPtr<Node>>,
    x: usize
}

pub struct LList {
    first: Option<PPtr<Node>>,
    // すべてのノードへのポインタとトークンを追跡
    gs: Tracked<GhostState>
}

pub struct GhostState {
    // 追加時のリストの長さをキーにして各ノードへのポインタのトークンを格納している
    tracked perms: Map<nat, PointsTo<Node>>,
    // 追加された順に各ノードへのポインタが格納されている
    ghost ptrs: Seq<PPtr<Node>>
}

impl LList {
    // リンクリストのノードを追加順に並べたもの
    // (self.firstから辿っていったときの要素のリストを*逆順にしたもの*)
    pub closed spec fn view(&self) -> Seq<usize> {
        // 添字を引数に取るクロージャでSeqを生成する
        Seq::<usize>::new(
            self.gs@.ptrs.len(), // 長さ
            // permsはノードの追加時のリストの長さがキーなため、最後に追加した要素が先頭になる
            |i: int| { self.gs@.perms[i as nat].value().x },
        )
    }

    // ノードへのポインタを追跡しているSeqを使って、与えられた添字から次のノードを返す
    pub closed spec fn next_of(&self, i: nat) -> Option<PPtr<Node>> {
        if i == 0 {
            None
        } else {
            // 添字が若いほど追加した時点が古い(i.e. リストの後の方にある)ためi-1が次のノードとなる
            Some(self.gs@.ptrs[i as int - 1])
        }
    }

    // 与えられた添字に対して対応するノードのGhostState内の状態とメモリ上の表現が
    // 整合していることを示す
    pub closed spec fn wf_node(&self, i: nat) -> bool {
        &&& self.gs@.perms.dom().contains(i)
            &&& self.gs@.perms[i].pptr() == self.gs@.ptrs[i as int]
            &&& self.gs@.perms[i].mem_contents() matches MemContents::Init(node)
            && node.next == self.next_of(i)
    }

    pub closed spec fn wf(&self) -> bool {
        &&& forall|i: nat| 0 <= i && i < self.gs@.ptrs.len() ==> self.wf_node(i)
            &&& if self.gs@.ptrs.len() == 0 {
                self.first.is_none()
            } else {
                self.first == Some(self.gs@.ptrs[self.gs@.ptrs.len() - 1])
            }
    }

    pub fn push_front(&mut self, v: usize)
        // 実行の前後で内部構造の整合性が保たれること、
        // viewによる表現が期待通り更新されることを保証する
        requires old(self).wf(),
        ensures
            self.wf(),
            // リストに要素が追加される
            self@ =~= old(self)@.push(v) // =~= はコンテナのための等号
    {
        if let Some(old_first) = self.first {
            proof {
                assert(self.wf_node((self.gs@.ptrs.len() - 1) as nat));
            }
            let (node, Tracked(mut perm)) = PPtr::<Node>::new(Node { next: Some(old_first.clone()) , x: v});
            self.first = Some(node);

            proof {
                // ghost stateの更新: ノードへのポインタとその権限を追加
                self.gs@.ptrs = self.gs@.ptrs.push(node);
                self.gs.borrow_mut().perms.tracked_insert((self.gs@.ptrs.len() - 1) as nat, perm);

                assert(forall|i: nat| i < self.gs@.ptrs.len() && old(self).wf_node(i)
                    ==> self.wf_node(i));
                assert forall|i: int| 0 <= i && i  < old(self)@.len()
                    implies old(self)@[i] == self@[i]
                by {
                    assert(old(self).wf_node(i as nat));
                }
                assert(self@ =~= old(self)@.push(v));
            }
        } else {
            // まだリストが空な場合
            let (node, Tracked(mut perm)) = PPtr::<Node>::new(Node { next: None, x: v });
            self.first = Some(node);
            proof {
                // ghost stateの更新: ノードへのポインタとその権限を追加
                self.gs@.ptrs = self.gs@.ptrs.push(node);
                self.gs.borrow_mut().perms.tracked_insert(
                    (self.gs@.ptrs.len() - 1) as nat,
                    perm,
                );
            }
        }
    }


    pub fn pop_front(&mut self) -> (r: Option<usize>)
        requires
            old(self).wf()
        ensures
            self.wf(),
            // リストが空ならNone
            old(self)@.len() == 0 ==> r == None::<usize>,
            // さもなければ先頭ノードの値を返して、リストが縮小する
            old(self)@.len() > 0 ==> r == Some(old(self)@.last())
                && self@ =~= old(self)@.drop_last()
    {
        if let Some(old_first) = self.first {
            assert(self.wf_node((self.gs@.ptrs.len() - 1) as nat));
            // ghost stateの更新: 一番目のノードへの権限を削除
            let tracked old_first_perm = self.gs.borrow_mut()
                .perms.tracked_remove((self.gs@.ptrs.len() - 1) as nat);
            // ノードへのポインタをデリファレンス、領域を解放
            let old_first_node = old_first.into_inner(Tracked(old_first_perm));
            self.first = old_first_node.next;
            proof {
                // ghost stateの更新: ノードへのポインタを削除
                self.gs@.ptrs = self.gs@.ptrs.drop_last();
                assert(forall|i: nat|
                    i < self@.len() && old(self).wf_node(i) ==> self.wf_node(i));

                assert forall|i: int| 0 <= i && i  < self@.len()
                    implies old(self)@[i] == self@[i]
                by {
                    assert(old(self).wf_node(i as nat));
                }
            }
            Some(old_first_node.x)
        } else {
            None
        }
    }
}

TODO: raw pointer を使ったリンクリスト検証例 verus-ex/dll-ex.rs を使った解説

Verusによる並行プログラムの検証

mimallocの検証

mimallocの概観

各スレッドごとに複数の Segment からなる Heap という構造が対応する。 Segment は複数の Page を管理しており、各Pageのメタデータの列がSegmentヘッダに格納されている。 各Pageは、 Block に分割されており、これがアロケーションの単位となる。 BlockのサイズはPage内で固定されており、Pageごとに異なることができる。 (こちらのFig. 1がわかりやすい[mimalloc])

未割り当てなBlockはサイズ別にリンクリストになっているため、 割当処理は要求サイズに対して適切なリストから取り出す処理、解放時は適切なリストへ挿入する処理となる。 割当処理はthread localだが、解放処理は他スレッドで割り当てられたBlockを解放することができる。

challenges

mimallocの検証コードベースのうち、特に挑戦的な目標に絞って紹介する。

raw pointer

先程raw pointerを扱うための仕組みとして PPtr を紹介したが、メモリアロケータの検証ではヒープ領域が適切に管理されていることを仮定できないため利用できない。 このような問題領域のために、Verusは PPtr より一般的なポインタを扱うための道具(vstd::raw_ptr)を提供しており PPtr もこれを利用した実装になっている。[verus_raw_ptr]

単方向リンクリストの例を一般のraw pointerに翻訳すると以下のようになる。 [verus_raw_ptr]でも読み書きの際に PointsTo<T> を要求するため、基本的な扱いは PPtr と同様となる。 大きな違いとして、 PPtr はアロケーション (PPtr::<T>::new)の際に PointsTo を発行するが、これに相当するインタフェースが存在しないことである。 従って、raw pointerを読み書きするには他の手段で PointsTo トークンを取得しなければならない。

struct Node { next: *mut Node }
struct LL {
    first: *mut Node,
    tracked perms: Tracked<Map<nat,PointsTo<Node>>
}

vstd::raw_ptr::PointsToRaw は、このための手段を提供している。 PointsToRaw は、(未初期化な領域の)任意のアドレスの集合(Set<int>)に対する権限を表している。 また、 PointsToRaw は対象の領域を 分割 できる。以下に興味深いインタフェースを示す。

// 分割
pub proof fn split(tracked self, range: Set<int>) -> tracked res : (Self, Self)
requires
    range.subset_of(self.dom()),
ensures
    res.0.provenance() == self.provenance(),
    res.1.provenance() == self.provenance(),
    res.0.dom() == range,
    res.1.dom() == self.dom().difference(range),

// 結合
pub proof fn join(tracked self, tracked other: Self) -> tracked joined : Self
requires
    self.provenance() == other.provenance(),
ensures
    joined.provenance() == self.provenance(),
    joined.dom() == self.dom() + other.dom(),

// 型付の領域へのキャスト
pub proof fn into_typed<V>(tracked self, start: usize) -> tracked points_to : PointsTo<V>
requires
    is_sized::<V>(),
    start as int % align_of::<V>() as int == 0, // アラインメント
    self.is_range(start as int, size_of::<V>() as int),
ensures
    points_to.ptr()
        == ptr_mut_from_data::<V>(PtrData {
            addr: start,
            provenance: self.provenance(),
            metadata: Metadata::Thin,
        }),
    points_to.is_uninit(), // キャスト後も未初期化

メモリアロケータの実装では、OSから提供されるなどして取得した利用可能なメモリ領域を分割して利用するため、 メモリアロケータの機能的正当性を証明するにはこのメモリ領域から分割されたブロックの 正統性 を追跡する必要がある。 PointsToRaw はこのための道具を提供している。(e.g. split, join)

[verus_mimalloc]の`PointsToRaw` を利用したリンクリストの実装は以下のようになっている。 トークンを持って回っている以外は、 PPtr によるリンクリストと方針は同じであることがわかる。 (verified-memory-allocator/verus-mimalloc/linked_list.rs)

pub fn insert_block(&mut self, ptr: *mut u8, Tracked(points_to_raw): Tracked<PointsToRaw>, Tracked(block_token): Tracked<Mim::block>)
    requires old(self).wf(),
        points_to_raw.is_range(ptr as int, block_token@.key.block_size as int),
        points_to_raw.provenance() == ptr@.provenance,
        block_token@.instance == old(self).instance(),
        is_block_ptr(ptr, block_token@.key),
        old(self).fixed_page() ==> (
            block_token@.key.page_id == old(self).page_id()
            && block_token@.key.block_size == old(self).block_size()
        ),
        old(self).heap_id().is_none(),
    ensures
        self.wf(),
        self.block_size() == old(self).block_size(),
        self.len() == old(self).len() + 1, // リストの長さ
        self.instance() == old(self).instance(),
        self.page_id() == old(self).page_id(),
        self.fixed_page() == old(self).fixed_page(),
        self.heap_id() == old(self).heap_id(),
{
    let tracked mut mem1;
    let tracked mut mem2;
    proof {
        block_size_ge_word();
        block_ptr_aligned_to_word();

        // ノード+パディングに分割
        let tracked (m1, m2) = points_to_raw.split(set_int_range(ptr as int, ptr as int + size_of::<Node>() as int));
        mem1 = m1.into_typed::<Node>(ptr.addr());
        mem2 = m2;
    }

    let ptr = ptr as *mut Node;
    // ノードに書き込み
    ptr_mut_write(ptr, Tracked(&mut mem1), Node { ptr: self.first });
    self.first = ptr;
    let Tracked(is_exposed) = expose_provenance(ptr);

    proof {
        // 証明用のデータを更新
        let tracked tuple = (mem1, mem2, block_token, is_exposed);
        self.perms.borrow_mut().tracked_insert(self.data@.len, tuple);
        self.data@.len = self.data@.len + 1;

        ...
    }

axiomization

上述のようにメモリアロケータは始めに利用可能なメモリ領域を渡され、アロケータの実装はこの領域が利用可能であると仮定する。 こうした仮定を表現するために、未検証なコードとの相互運用のための仕組みが必要となる。

Verusでは未検証のコードであることを示すための属性が用意されており、Rustのオブジェクトにアノテーションすることで未検証であることを検証器に指示する。

verifier::external_body はexecモードの関数につけることで、実装を検証せずに関数定義の仕様を正しいと仮定するように検証器に指示する。 通常 external_body を付与した関数で検証なRustコードを呼び出し実装に対して 公理的な仕様 を記述する。 以下に例を示す。

impl<A> MyVec<A> {
    #[verifier::external_body]
    pub fn index(&self, i: usize) -> (r: &A)
        requires
            i < self.len(),
        ensures
            *r === self[i as int],
    {
        // RustのVec
        unsafe { self.vec.get_unchecked(i) }
    }
}

この仕組みを使ってアロケータの与えられたメモリ領域に対する仮定を例えば以下のように表すことができる。

impl Allocator {
    #[verifier::external_body]
    fn give_me_buffer(&mut self, mem: *mut u8)
    ensures
        self.buf_perm@.dom() != Set::empty(),
    {
        ...
    }
}

上記は非常に単純化されているが、mimallocの検証コードベースではmmapを使ってメモリ領域を確保しており、 mmapによって設定したメモリアクセスの権限に関する部分も形式化しているため、より複雑になっている。 ([verus_mimalloc], verified-memory-allocator/verus-mimalloc/os_mem.rs)

  • Verusではアロケータからしか PPtr を作れないためアロケータのメモリプールをブートストラップ出来ないが、 先行研究では配列から作れるようなworkaroundがされている(cf. atmosphere kernel)

concurrency

  • mimallocでは他のスレッドに対して割り当てられたブロックを別のスレッドで開放できる仕組みがある

  • VerusSync/tokenized_state_machine!

  • storage protocol/Resource Algebra

TCBと検証済みコードの保証について

Verusによって検証されたコードの正当性は、以下を仮定した上でのものとなる。

  • rustcの型検査・ライフタイム検査器・コード生成の正当性 [3]

  • SMTLIBへのエンコードの適切さ

  • SMTソルバと追加のソルバ(e.g. Singular[singular])の正当性

  • Verusのプリミティブの形式仕様と実装の適切さ

メモリセーフティに関する注意

通常のRustコードでは、メモリセーフでない可能性がある操作はunsafeブロックで囲み、 適切にバリデーションを行って十分なドキュメンテーションをした上でユーザーに責任を委譲する。 一方、Verusでメモリセーフティをrequire/ensuresなどを使って仕様を記述・証明することで保証している。 例えば、以下はverusからRustの検証されていないコードを呼び出している。[verus_guide] このような場合Rustのコードでは境界チェックが必要になるがVerusでは呼び出し前に事前条件の成立が保証されるため省略できる。

impl<A> MyVec<A> {
    #[verifier::external_body]
    pub fn index(&self, i: usize) -> (r: &A)
        requires
            i < self.len(), // 境界チェック相当の条件
        ensures
            *r === self[i as int],
    {
        unsafe { self.vec.get_unchecked(i) }
    }
}

つまり、Verusではメモリセーフティは 事前条件が満たされている限り 保証されていることになる。 これはRustの未検証なコードからVerusの検証済みコードを呼び出す際に、 事前条件の成立を別の方法で保証しない限りメモリセーフティ(勿論そのほかの性質も)が成立しないことを意味するため、 こうした場合には注意が必要である。

limitations

  • raw pointer越しに構造体のフィールドを部分的に更新できない

参考文献


1. 証明のデバッグ時は、これをつけないと何もわからない
2. このコードは証明が通る最小限のアサーションに削ってある(9個)が、試行錯誤中はアサーションが40個程度あった
3. verusはrustcのIRのうちHIRからRustコードの情報を抽出している https://github.com/verus-lang/verus/blob/main/source/CODE.md