unsafe ブロックと健全性(Soundness)契約

15 分で読了
このページの目次

unsafe は Rust の型チェックや借用チェッカーを無効にするものではありません。それは、コンパイラが完全に検証できない操作を表現することを許可しつつ、プログラマがそれらが安全な契約に準拠していることを証明することを要求します。unsafe を含むライブラリが健全かどうかは、公開された安全なインターフェースが、通常の安全なコードから呼び出された際に未定義動作(UB)を引き起こさないかどうかにかかります。

ここで言う「通常の安全なコード」とは、奇妙な入力、早期リターン、パニック、mem::forget、または予期しないトレイト実装を含むものも含みます。安全なインターフェースは、型やチェックに明記されていない前提条件を呼び出し側に密かに転嫁してはなりません。

異なる unsafe の位置が異なる責任を負う

形式意味
unsafe fn呼び出し側が追加の安全前提条件を満たす必要がある。Safety ドキュメントを明記すべき
unsafe ブロック実装者がここで関連する操作の前提条件が満たされていることを証明する
unsafe trait実装者が追加の契約を維持しなければならない
unsafe implその trait の契約を満たすことに対する実装の約束を行う
unsafe extern / unsafe 属性外部宣言、シンボルなど、別の種類の大域的義務

unsafe ブロックが「unsafe trait を実装する能力を有効にする」と言うのは誤りです。unsafe impl はそれ自体が独自の構文位置です。Rust 2024 では、unsafe fn 内で明示的に unsafe ブロックで囲まれていない危険な操作に対してデフォルトで警告が出力され、呼び出し側の義務と実装者の証明を分離する目的があります。unsafe_op_in_unsafe_fn

通常の参照コードは unsafe 内でも借用チェッカーによって制限されます。しかし、裸ポインタから人為的に競合する参照を作成した場合、コンパイラが必ずしも拒否するとは限りません。そのため、「エイリアス条件に違反することは依然として誤りである」ということと、「コンパイラが必ず発見してくれる」ということは異なります。

split_at_mut の考え方を借りて証明を構築する

本番コードでは標準ライブラリの split_at_mut を直接使用すべきです。ここでは教育的な関数を独自に再実装し、安全なインターフェースがどのように動的なインデックス制約を unsafe 操作の前提条件に変換するかを示します。

pub fn split_checked<T>(slice: &mut [T], mid: usize)
    -> (&mut [T], &mut [T])
{
    let len = slice.len();
    assert!(mid <= len);
    let pointer = slice.as_mut_ptr();
    // SAFETY: 入力スライスによって、全体のストレージが有効、整列済み、初期化済み、かつ排他的借用されていることが保証されています。
    // mid <= len はオフセットと長さが合法であることを保証し、2つのセグメントの要素範囲は重複しません。
    // 返される参照のライフタイムは入力スライスによって制限され、その後元のポインタを介してアクセスすることはありません。
    unsafe {
        (
            std::slice::from_raw_parts_mut(pointer, mid),
            std::slice::from_raw_parts_mut(pointer.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut values = [1, 2, 3];
    let (left, right) = split_checked(&mut values, 1);
    left[0] = 10;
    right[0] = 20;
    assert_eq!(values, [10, 20, 3]);
    for mid in 0..=values.len() {
        let (left, right) = split_checked(&mut values, mid);
        assert_eq!(left.len(), mid);
        assert_eq!(left.len() + right.len(), 3);
    }
    let mut empty: [u8; 0] = [];
    let (left, right) = split_checked(&mut empty, 0);
    assert!(left.is_empty() && right.is_empty());
    let mut zst = [(); 3];
    let (left, right) = split_checked(&mut zst, 2);
    assert_eq!((left.len(), right.len()), (2, 1));
    let rejected = std::panic::catch_unwind(|| {
        let mut bytes = [1u8, 2];
        let _ = split_checked(&mut bytes, 3);
    });
    assert!(rejected.is_err());
}
012345left: [0, mid)right: [mid, len)mid = 2安全な分割の根拠:同じ借用における2つの重複しない要素範囲有効な &mut [T] に由来し、mid をチェックした後、返される借用は元の借用のライフタイムを超えてはならない。

assert だけではすべてが保証されません。証明には、有効な入力 &mut [T] の性質、ポインタの由来、要素の初期化、およびライフタイムも利用されています。外部の裸ポインタに対しては、mid <= len であっても、この議論をそのまま適用することはできません。from_raw_parts_mut

mid が 0 または len の場合、空のスライスが生成されますが、これは依然としてインターフェースの要件を満たします。サイズ 0 の型(ZST)の要素アドレスは同じになる可能性があり、証明は要素範囲とアクセスセマンティクスに基づいて行われるべきであり、単に2つの異なる数値アドレスを描画しただけでは証明完了とは言えません。

証明の前提条件を具体的なコードに対応させる

前提条件この例での根拠修正時に壊れやすい箇所
有効なストレージ範囲パラメータは &mut [T] から来る任意の裸ポインタに変更し、呼び出し側の契約を追加しなかった場合
インデックスと長さの範囲外アクセス防止mid <= len、その後 len - mid を使用チェックをポインタ演算の後に移動した場合
返される2つの排他的範囲の競合回避[0, mid) と [mid, len)2番目のセグメントの開始点を mid - 1 とした場合
参照がストレージの寿命を超えない返されるライフタイムは入力借用から推論される所有者の制約を受けない任意のライフタイムを露出させた場合
返却後に元のエントリを介してアクセスしない元の借用は返されたサブ借用によって占有される元のポインタをキャッシュし、競合するアクセス用の別のエントリを開いた場合

テストにおいて mid = len は、末尾の要素を越えてデリファレンスできない右側の空スライス(one-past 位置)を生成します。長さがゼロのスライスでも、対応するポインタの契約を満たす必要があります。ZST の例は、「ポインタの数値が異なること」を「要素範囲に関する議論」に置き換えてはならないことを示しています。

安全な関数でも unsafe 依存条件を壊す可能性がある

以下の関数は、チェックと危険な操作の関連性を示すためのものです。通常のコードでは slice.get を直接使用すべきです。

fn lookup(slice: &[u8], index: usize) -> Option<u8> {
    if index >= slice.len() { return None; }
    // SAFETY: 上記のチェックにより、index が現在のスライスの長さより厳密に小さいことが保証されます。
    Some(unsafe { *slice.get_unchecked(index) })
}
fn main() {
    assert_eq!(lookup(&[7, 8], 1), Some(8));
    assert_eq!(lookup(&[7, 8], 2), None);
    assert_eq!(lookup(&[], 0), None);
    assert_eq!(lookup(&[7], usize::MAX), None);
}

index >= len を index > len に変更すると、変更は安全なコード内で発生しますが、index = len がチェックを通過してしまいます。危険な操作が依存していた証明はすでに無効になっており、unsafe ブロックの行数が動いていないからといって審査をスキップしてはいけません。永続コンテナの場合、長さ、容量、ポインタを変更できる内部メソッドはすべて、同じ証明範囲内にあります。Working with Unsafe

開始位置の 1 つのずれはどの証明を壊すか

右の開始を mid−1 にすると独占スライスが要素を共有し、長さ len−mid のまま mid+1 にすると末端を越え得ます。入力参照が既にストレージの有効性を与えており、ここで調べるのは範囲だけで、任意の生ポインタの安全検証ではありません。

図を準備しています
開始位置の 1 つのずれはどの証明を壊すか

正しい分割は [0,mid) と [mid,len) で、境界条件と非重複が両方必要です。

不変条件は unsafe ブロックの境界を越える

あるコンテナ内部で len が初期化済み要素の数を表している場合、len を変更するすべての安全なメソッドは証明範囲に含まれます。拡張、挿入、クローン、デストラクション、およびエラー分岐は、初期化されていないスロットが T として読み込まれたりデストラクトされたりせず、同じリソースが二度解放されないことを保証しなければなりません。

部分的な初期化後にパニックが発生した場合、どの要素が存在し、どのリソースを回収する必要があるかを把握していなければなりません。通常のパスでテストが通過したからといって、Drop や中断パスが安全であると仮定してはいけません。逆に、安全なコードはリソースのリークを許可する可能性があるため、抽象化は「呼び出し側が必ずデストラクタを実行する」ことを UB を避けるための唯一の条件として扱ってはなりません。

構築と検証有効な状態を確立するすべての変更エントリ長さ・所有・別名を維持unsafe 操作前述で確立された条件に依存するエラー・panic・再入・Drop の省略でも有効な状態を保つ証明範囲は不変条件に沿って伝播し、通常は unsafe ブロックより大きい非公開フィールドで表現を守る。安全な trait でも不正な実装はあり得るコードレビューの単位は、完全な安全な抽象化とその状態遷移であり、unsafe キーワードの数を数えるだけでは不十分である。

通常のトレイトの「誠実さ」をメモリ安全性の前提条件としてはいけない

trait Position { fn index(&self) -> usize; }
struct Outside;
impl Position for Outside {
    fn index(&self) -> usize { usize::MAX }
}
fn selected<P: Position>(values: &[u8], position: &P) -> Option<u8> {
    let index = position.index();
    values.get(index).copied()
}
fn main() {
    assert_eq!(selected(&[1, 2], &Outside), None);
}

Position は安全なトレイトですが、外部実装は巨大なインデックスを返すことができます。ドキュメントで合法なインデックスを返すよう推奨されていても、安全なラッパーはそれを直接 get_unchecked に渡すことはできません。実際の返値をチェックする必要があります。index を2回呼び出し、1回目で検証し、2回目でアクセスする場合、内部可変性により2回の結果が異なる可能性があるため、検証された値を保存する必要があります。

同様の問題はソーター、イテレーター、比較器、およびコールバックで見られます。論理契約が違反された場合、ライブラリはエラーを報告したり、パニックしたり、定義上許容される論理的結果を生じさせたりできますが、安全なトレイトの誤った実装が直接 UB を引き起こしてはいけません。もしある保証が実装者にメモリ安全性の責任を負わせるものである場合、明確な unsafe trait を設計し、それが呼び出しエコシステムの証明負担を増やす価値があるかどうかを評価すべきです。

ガードが忘れられた場合でも、下層オブジェクトは安全にアクセス可能でなければならない

drain ガードがコンテナ内の一部の String を移動し、Drop で len を修正する予定だと想像してください。もし安全な呼び出し側がこのガードを mem::forget した場合、元のコンテナは古い len でデストラクトされ、移動済みの文字列を重複解放する可能性があります。安全な設計では、ガードが離れる前に、ガードが決してデストラクトされなくても UB にならない状態に元のコンテナを入れる必要があります。通常の Drop は、可能な限り完全な論理的結果を回復しようとします。デストラクションリークと代理オブジェクト

これにより、最悪の場合でも一部のリソースがリークすることは許容されますが、解放後の使用は許されません。また、これは「RAII を使えば失敗パスを考える必要がない」という考えが、自作の unsafe コンテナには適用できないことを示しています。RAII のデストラクションパス自体が、証明すべき実装の一つなのです。

未定義動作(UB)と通常のエラーを区別する

典型的な UB には、不正なメモリアクセス、データ競合、参照エイリアス契約の違反、無効な bool/char/参照値の生成、および間違った ABI での関数呼び出しなどがあります。空の裸ポインタを作成するだけでは UB ではありません。非空が要求される参照を形成するか、条件を満たさないアクセスを実行して初めて境界を越えます。

パニック、デッドロック、メモリリーク、およびエラー結果の返却は、通常、言語レベルの UB ではありませんが、深刻なビジネス上の問題となり得ます。整数オーバーフローは、操作とコンパイル設定に応じて判断する必要があり、C の符号付きオーバーフローの規則をすべての Rust 演算に適用してはいけません。Rust 未定義動作

FFI の展開は ABI に応じて区別する必要があります。通常の C ABI バoundary での Rust パニックはプロセスを終了させますが、展開を許可する C-unwind や外部例外とは異なる状況であり、すべてを同じ UB 項目として列挙すべきではありません。

Safety コメントを書く際には具体的な質問に答える

有用なコメントは、現在のポインタが誰によって保持されているか、なぜこの範囲が有効なのか、初期化されているかどうか、アクセス時に排他または同期されているかどうか、そして関数が返った後に誰が解放の責任を負うかを指摘すべきです。「ここでは安全である」や「コンパイラは知らない」とだけ書くだけでは、後の保守担当者がチェックするのに役立ちません。

unsafe fn のドキュメントは呼び出し側を対象としており、合法的な呼び出し方を記述します。unsafe ブロックのコメントは実装の審査担当者向けであり、ここでなぜ前提条件がすでに満たされているかを説明します。危険な操作の範囲をできるだけ狭くするべきですが、行数を最小限に抑えるために真の依存不変条件を隠してはいけません。

Miri:実行されたパスのチェックであり、健全性(soundness)の証明ではない

Miri は MIR を解釈実行し、多くの境界外アクセス、解放後使用、未アライン、無効な値、およびエイリアス/並行性の問題を検出できます。一般的なプロジェクトのワークフローは、miri コンポーネントを含む nightly ツールチェーンを構成して実行することです。

cargo +nightly miri test

このコマンドは対応するツールチェーン管理とコンポーネントに依存しており、rustc がインストールされている環境すべてが備えているわけではありません。Miri はネイティブ FFI、インラインアセンブリ、および一部のシステムインターフェースに対して能力の限界があり、実行されていない外部操作を検証済みとして扱うことはできません。

テストには、空の入力、境界インデックス、サイズ 0 の型、Drop を持つオブジェクト、および失敗パスをカバーする必要があります。すべてのテストが通過しても、それらはこれらの実行と現在のモデルのみをチェックしているに過ぎません。公開された安全な API の不変条件とすべての呼び出しパスを人手でチェックする必要があります。Miri

テスト結果を再検証可能な証拠として記録する

検査方法提供できる証拠カバーされていない部分
通常入力と境界単体テスト選択された入力に対する結果と拒否動作全入力および全ディスパッチ
Drop カウント付きオブジェクトこれらのパスにおける解放回数ポインタの由来とすべてのエイリアス状況
Miri現在の実行とサポートモデルにおけるメモリエラー任意のネイティブ FFI、すべての未実行パス
並行モデル検査モデル化された状態とディスパッチの組み合わせモデルに組み込まれていない外部リソース
人手による契約審査前提条件、状態遷移、責任が閉じているかどうか実装の詳細を見逃す可能性がある

既知の UB 例を実行して「クラッシュしなかった」からといって安全であると判断しないでください。拒否可能な入力に対しては、危険な操作の前に拒否されることをアサートします。ライフタイムに違反する安全な用法に対しては、コンパイル失敗ケースで検証します。満たす必要がある unsafe 呼び出しの前提条件に対しては、ドキュメントと有効な入力のテストで明確な境界を設定します。

パフォーマンス最適化に由来する変更の場合、なぜ unsafe が必要なのか、そして標準の安全インターフェースが対象の負荷で何を提供していないのかを記録すべきです。最適化で利益が測定できない場合、追加の証明コストを維持する価値は通常ありません。利益がある場合でも、パフォーマンスデータで健全性(soundness)の議論を置き換えてはいけません。