HN 日本語サマリー

← 一覧へ戻る
プログラミング

Call Graph Analyzer でパニックを見つける

Finding Panics with Call Graph Analyzer (tweedegolf.nl)

29 pointsby ndr0 コメント

要約

Open Device PartnershipとMicrosoftが共同開発したCall Graph Analyzer (CGA) は、Rustコード内のパニックを引き起こす可能性のある関数を特定するためのツールです。CGAは、コンパイルされたバイナリからコールグラフを抽出するために、モノモルフィゼーションされたMIR(Mid-level Intermediate Representation)を解析します。この記事では、パニックを排除する理由、既存の手法との比較、CGAの仕組み、そしてその利点と注意点について解説しています。

全文翻訳

Call Graph Analyzer でパニックを見つける

私たちはよく「Rustコードのパニックをなくすにはどうすればいいですか?」と尋ねられます。この問題に取り組む試みはいくつかありました。Open Device Partnership の下で、Microsoftと協力して、モノモルフィゼーションされたMIRからコールグラフを抽出するツールである Call Graph Analyzer (CGA) を開発・リリースしました。このコールグラフは、コンパイルされたバイナリ内のどの関数がパニックを引き起こす可能性があるかを追跡するために使用できます。

例えば、コードに対して cargo cga check を実行すると、以下のような結果が得られます。

error: call to method `core::num::<impl u64>::strict_sub` which can panic
  --> src/main.rs:18:17
   |
18 |         let _ = 2u64.strict_sub(u64::MAX);
   |                 ^^^^^^^^^^^^^^^^^^^^^^^^^

この記事では、この新しいアプローチが、既存のアプローチと比較してパニックを見つける上で、どのように優れており、また劣っているのかを議論します。

なぜパニックを排除するのか?

Call Graph Analyzer について議論する前に、まずパニックがなぜ有用なのか、そしてどのような場合に可能なパニックを最小限に抑える、あるいは完全に排除することが有益であるかを検討する必要があります。このトピックは他の人々によって広範に探求されていますが、ここで要約することは有用です。まず簡単な説明から始めましょう。

Rust は、関数が正常に返るのを防ぎ、代わりに「パニック」するというメカニズムを提供します。これは、通常は回復不能なエラー条件に対する応答です。

The Rust Reference - Panic

これらの回復不能なエラー条件は、関数の実装におけるバグ、または関数のエントリ条件の違反(つまり、関数自体のバグ、または呼び出し元の間違い)によって引き起こされる可能性があります。

例としては、1.strict_add(u32::MAX) を呼び出すことが挙げられます。これは、呼び出し元が無効な引数を操作に渡したことに対する応答としてパニックを引き起こします。同様に、Rust で通常の加算 1 + u32::MAX は、overflow-checks が有効になっている場合、パニックを引き起こします。

パニックの代わりに、エラー条件は以下のように処理できます。

  1. 関数が Result<T, E> または Option<T> を返すようにすることで、回復可能にします。加算の場合、これは u32::checked_add によって提供されます。
  2. 関数を暗黙的または静かに失敗させ、その動作を関数ドキュメントで説明します。算術演算は、例えば u32::wrapping_add でラップすることができます。
  3. 関数が決して返らないようにします。例えば、無限の loop {} を使用します。タイムアウトや最大反復回数なしの再試行スキームを採用している場合に、このアプローチが誤って使用されることがあります。

しかし、ゼロパニックを目指すことは非常に困難で、むしろ愚かです。コードに仮定がないことは稀です。どこでも Result<T, AssumptionViolated> を返すことは、単に呼び出しスタックの上位に負担を押し付けるだけで、結局はルートでアボートすることになります。これは、パニックさせる場合と実質的に同じ動作になります。

しかし、パニックを 明示的 にすることは有用です。パニックパスが存在することを知り、それについて推論する方が、単に誤ってパニックを含めてしまい、それらが存在することを無視するよりも良いです。パニックパスに対する回帰分析も非常に役立ち、コードに導入された新しいパニックパスを通知してくれます。

とはいえ、以下のケースでは、一部のパニックを排除することが正当化されるかもしれません。

  • コードサイズが本当に重要なコンテキスト、例えば組み込みデバイスなど。パニックは、必要な文字列とその文字列をフォーマットするための関連コードのために、比較的多くのコードを生成します。代わりに、以下を行うことができます。
    • defmt::panic を使用して、この文字列の肥大化を削減します。これには、すべてのパニックがマクロでラップされている必要があります。ライブラリの依存関係の場合、これは通常、ライブラリが特に組み込みプラットフォームで使用されることを意図している場合にのみ行われます。Defmt は、Rust の標準ライブラリを含む他のライブラリでは使用されていません。
    • -Cpanic=immediate-abort を使用して、パニックがまったく生成されないようにし、代わりにアプリケーションをクラッシュさせます。ただし、デバッグに使用できる情報はあまり残りません。
  • 絶対に失敗してはならないクリティカルなコードパス、例えば:
    • サービス拒否 (DOS) 攻撃によるサービスクラッシュを防ぐ必要がある、外部からアクセス可能なサービス。無効な入力は、ユーザーに表示されるエラーとして処理されるべきです。
    • 組み込みデバイスのファームウェア更新パス、ブートローダーを含む。
  • パニックハンドリングコード自体。Rust では、Drop の実装 でパニックを アンワインド すると、「ダブルドロップ」が発生し、プログラムがアボートする可能性があります。次に、カスタム #[panic_handler] でのパニックは許可されていませんが、その場合に何が起こるべきかは不明確です。

回復不能なエラー条件をゼロにすることは、実際には非常に困難です。一般的には、トップレベルの回復メカニズムを実装する方が良いです。組み込みでは、デバイスがスタックした場合にデバイスをリセットするウォッチドッグタイマーと、デバイスを正常な状態に復元するためのリカバリイメージが必要になります。ホストマシンでは、catch_unwind を使用してアンワインドパニックをキャッチすることが考えられます。ただし、後者は万能ではなく、いくつかの注意点があります。外部からアクセス可能なサービスは、冗長で迅速に回復できる必要があります。

結論として、意図しないパニックを見つけることは有用です。

先行研究

現在、コールグラフを抽出したり、パニックを検出したりする方法には以下のようなものがあります。

これらのプロジェクトのいずれかをダウンロードして、Charon (このユースケースには明示的に意図されていない) を除き、すぐに開始するのは非常に簡単です。

リンカーベースのパニック検出方法は、特殊なパニックハンドラをリンクしようとするときにリンカーでエラーを発生させることによって機能します。この検出アプローチは、バイナリに1つのパニックも含まれていない場合にのみ機能します。また、バイナリに対してのみ機能し、ライブラリには機能しません。

ELF デバッグ情報解析は、バイナリや Rust ライブラリなどの ELF アーティファクトに含まれる DWARF デバッグ情報を調べます。提供できるコールグラフ情報には限界があります。パニック機構は完全にインライン化されたり、リンカーフェーズ中に消えたりすることがあります。

そして最後に、HIR、MIR、rust-analyzer ベースのアプローチがあります。これらは、コンパイラ (および rust-analyzer) によって生成されるコードの中間表現 (IR) を使用して、コールグラフを抽出します。ある意味では、これらのアプローチはすべて、元の Rust コードを取得し、コンパイラで処理し、何が起こっているかを吐き出します。理論的には、これらのアプローチはどの Rust クレートでも機能します。

HIR ベースの分析は、特定のクレート内のコードのみを分析できます。この分析では、自分のクレート内で明示的に panic! と書いているかどうかを確認できます。しかし、標準ライブラリなどの依存関係にあるコードは認識できません。したがって、依存関係内で発生するパニックを検出することはできません。コンパイラは、コンパイラフラグによっては、非常に遅い段階までパニックにならないアサーション(境界チェックなど)を挿入することもできます。

MIR ベースの分析は理想的です。なぜなら、理論的には、最適化される前の依存関係のすべてのコード、標準ライブラリに由来するパニックを含むコードを見ることができるからです。したがって、Call Graph Analyzer はこのアプローチを使用します。唯一の欠点は、LLVM が到達不能なコードを削除できるようになる前に行われるため、いくつかの偽陽性が発生することです。

これらの以前のアプローチはすべて、コンパイラがクレートをコンパイルするときにすでに行っていることを取得し、コンパイルプロセスを中断して、コンパイル状態のスナップショットを見て、パニックを呼び出しているかどうかを確認します。

rust-analyzer のアプローチは少し異なります。コンパイラにまったく依存しないからです。Rust-analyzer は、コンパイラから Rust-HIR-MIR パイプラインを複製し、それをプロセスに合わせて適応させています。rust-analyzer のこの独立したパイプラインは非常に高速で、メモリに保存でき、アクティブにコードを記述・変更しながら状態を更新することをサポートします。しかし、rustc がコンパイルできるコードを常に分析できるわけではありません。特に、Rust Embedded で多用する複雑な proc_macro には苦労することがよくあります。エンドユーザーとしては、コードエディタが役立つ提案を提供しなくなったときにこれを認識します。(rust-analyzer のチームはこれを防ぐために懸命に努力しています!)rust-analyzer の利点は、Rust コンパイラに依存しないことです。

CGA の仕組み

私たちのユースケースでは、以下の要件を満たす方法が必要でした。

私たちは MIR ベースのコールグラフエクスポーターを選択しました。これが Call Graph Analyzer (CGA) となりました。次に、CGA の設計と機能について説明します。

rustc-driver

CGA は、rustc-driver から直接コールグラフを抽出します。Clippy も同様に rustc-driver です。Clippy と同様に、CGA も特定のバージョンの Rust コンパイラに依存します。これが CGA の最初の実際の欠点です。

注意:CGA は、特定の(おそらく異なる)バージョンの Rust コンパイラ、標準ライブラリ、および可能性として異なるコンパイラフラグを使用してクレートをコンパイルします。したがって、バイナリに含まれる 正確な コールグラフは得られません。

rustc-private を使用して特定の(nightly)バージョンの Rustc を依存関係として持つことは、CGA を crates.io からクレートとしてインストールできないことも意味します。代わりに、リポジトリをローカルでチェックアウトし、cargo install --path . を実行する必要があります(これは特定のバージョンの Rust ツールチェーンを自動的に使用します)。

rustc_public が CGA に必要なすべてを含むようになれば、このユーザーエクスペリエンスは改善される可能性があります。ただし、そのシナリオは実際には可能性が高くありません。代わりに、CGA が十分に人気が出れば、Clippy が現在持っているような特別なステータスと同様に、rustc と並行して維持される可能性があります。rustc の破壊的な変更は Clippy にも適用され、Rustup を使用して特定のツールチェーン用に Clippy をインストールできます。これに関する詳細については、Alona Enraght-Moony による「Out of tree access to the compiler state」の優れた講演をご覧ください。

rustc-driver として、CGA バイナリは rustc 自体と同じ API を持ちます。cargo を使用して cargo cga check を実行することで CGA を実行できます。内部的には、cargo check を実行する場合と同様に rustc が実行されます。ただし、CGA はコールグラフを収集するために追加の作業を行います。典型的なクレートの場合、CGA でのコンパイルは cargo check の実行よりも約 2〜3 倍遅くなります。

MIR トラバーサル

CGA が cargo check の通常のコンパイラパスをすべて実行し終えると、コンパイラにクレートのすべてのいわゆる ルート または エントリポイント を生成するように強制します。直感的には、これはコンパイル時に外部からアクセス可能なクレートの部分すべてです。これには以下が含まれます。

  • fn main およびそのバリアント
  • 言語の _start シンボル
  • extern でマークされ、no_mangle または export_name を使用して安定した名前を持つ関数、組み込み Rust の割り込みハンドラを含む。

この分析では、const ブロックの一部としてコンパイルされるすべての関数、および static 値の初期化には、最終バイナリにはこれらの計算の結果のみが含まれるため、明示的に関心はありません。

これらのルートから外側に向かって、MIR ツリーを歩くことによって到達可能性分析を実行します。関数が呼び出されるたびに、その関数も探索されます。したがって、使用されていない関数はすべて訪問されません。Rust では、C++ と同様に、ジェネリック引数を持つ関数は、それを呼び出すために使用される具体的な型で呼び出されるときにモノモルフィゼーションされます。

したがって、呼び出された関数だけでなく、各関数の具体的なインスタンス化も収集します。なぜなら、実装は渡された型引数によって大きく異なる可能性があるからです。

このプロセスは、以下を考慮すると少し手間がかかります。

  • dyn オブジェクト。どのトレイト実装が実際に呼び出されているかが不明確です。CGA は、関数を見つけたときに、すべての vtable エントリがその関数に対して呼び出されると仮定します。これは、特に標準ライブラリの Debug トレイトのような dyn オブジェクトが構築されると、非常に大きくなります。
  • 関数ポインタ は Rust 関数から作成できます。これらのポインタがどうなるかを必ずしも推論することはできません。したがって、作成された関数ポインタは後で呼び出されると仮定します。
  • 各 async ブロックには暗黙的なパニックパスがあります。これについては、Dion によるブログ記事「Async Rust never left the MVP state」を参照してください。

クレートとその依存関係の MIR ツリーを歩き終えると、コールグラフのコレクションが得られます。これで、JSON または GraphViz dot ファイルに extract するか、パニックまたはブラックリスト関数を check するかを選択できます。

抽出する場合、すべてのコールグラフは /target フォルダに <crate>.callgraph.json として配置されます。最後にそれらをマージし、グローバルコールグラフを個別のルートでエクスポートします。

cargo cga extract -Tdot -o callgraph.dot

これがどのように見えるかを簡単に示すために、dyn オブジェクトを使用した次の例を検討してください。

trait Test {
    fn test(&self) {
        panic!("Default impl")
    }
}

struct StrictAdd;
impl Test for StrictAdd {
    fn test(&self) {
        let _ = 2u64.strict_add(u64::MAX);
    }
}
struct StrictSub;
impl Test for StrictSub {
    fn test(&self) {
        let _ = 2u64.strict_sub(u64::MAX);
    }
}

struct DefaultImpl;
impl Test for DefaultImpl {}

fn main() {
    let object = StrictAdd;
    let dyn_object: &dyn Test = &object;
    dyn_object.test();
}

これは、vtable の各エントリに矢印が付いたグラフになります。

[画像: Graphviz Callgraph example]

画像 1: Graphviz Callgraph 例 (読みやすくするためにズームインできます)

想像できるように、これはすぐに非常に大きく、読みにくくなります。コールグラフを意味のある方法で表示できる GUI アプリケーションの提案があれば、ご連絡ください。それは非常に役立つでしょう!

リンティングと診断

次に、Clippy と同様に、cargo cga check を実行してコードのパニックパスをチェックするように CGA に依頼できます。

cargo cga check

これはコールグラフを取得し、パニックパスを見つけ、このパニックパスがクレートにどのように関連しているかについての役立つ洞察を提供しようとします。上記のコードに対して発行されるリンティングの 1 つは次のようになります。

error: call to method `core::num::<impl u64>::strict_sub` which can panic
  --> src/main.rs:18:17
   |
18 |         let _ = 2u64.strict_sub(u64::MAX);
   |                 ^^^^^^^^^^^^^^^^^^^^^^^^^ 
   |
   = note: 3 different panics from this location
   = note: backtrace (1 of 2):
           0: std::rt::panic_fmt (called)
           1: core::num::imp::overflow_panic::sub (called)
           2: core::num::<impl u64>::strict_sub (called) < --
           3: <totallysafe::StrictSub as totallysafe::Test>::test (possible vtable resolution)
           4: <dyn totallysafe::Test as totallysafe::Test>::test - virtual#3 (called)
           5: totallysafe::main (entrypoint)

バックトレース、可能な vtable の解決、およびローカルクレートのコンテキストからの最後の呼び出しが表示されていることに注意してください。これらの診断は ヒューリスティック に生成されます。つまり、すべてのパニックパスの中から、ローカルクレートの各コールサイトに対して 1 つを選択します。スコアリングメカニズムを使用して、これらのパスの 最良 のものを選択しようとします。

ホワイトリストとブラックリスト

パニックを最小限に抑えることを念頭に置いて開発されなかった真剣なクレートの場合、CGA によって生成されるリンティングの量は実際には管理できません。これを支援するために、CGA にはいくつかの設定可能なパラメータがあります。

例えば、関数がパニックしないと信頼できる場合(パニックパスが生成されているにもかかわらず)、その関数をホワイトリストに追加できます。その関数を含むパニックパスは無視されます。これらのルールは、単純化された関数名と、すべてのジェネリックパラメータを含む完全な正規 Rust パスの両方に一致させることができます。

あるいは、関数をブラックリストに登録することもできます。パニックに加えて、クレートとその依存関係が特定の関数を絶対に使用しないようにしたい場合は、それを除外することが可能です。例えば、ファイルシステム、ネットワーク、またはプロセスとの対話を避けたい場合があります。

blacklist = [
    { path = "^std::process::" },
    { path = "^std::fs::" },
    { path = "^std::sys::" }
]

cga.toml 設定ファイルの例

次に、以下のプログラムを CGA に渡すと、次のようになります。

fn test() {
    let _ = std::process::Command::new("evil");
}

fn main() {
    test();
}

次のようになります。

error: call to associated function `std::process::Command::new` which is blacklisted
 --> src/main.rs:7:13
  |
7 |     let _ = std::process::Command::new("evil");
  |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: 101 different blacklisted functions from this location
  = note: backtrace:
          0: std::process::Command::new::<&str> (called) < --
          1: fail_blacklist::test (called)
          2: fail_blacklist::main (entrypoint)
note: the lint level is defined here
 --> src/main.rs:3:11
  |
3 | #![forbid(cga::blacklisted)]
  |           ^^^^^^^^^^^^^^^^

error: call to drop of `std::process::Command` which can call blacklisted functions
 --> src/main.rs:7:47
  |
7 |     let _ = std::process::Command::new("evil");
  |                                               ^^
  |
  = note: 101 different blacklisted functions from this location
  = note: backtrace (1 of 20283553):
          0: std::sys::fs::unix::debug_assert_fd_is_open (called)
          1: <std::os::fd::OwnedFd as std::ops::Drop>::drop (called)
          2: drop of `std::os::fd::OwnedFd` (called)
          3: drop of `std::sys::fd::unix::FileDesc` (called)
          4: drop of `std::sys::process::unix::common::Stdio` (called)
          5: drop of `std::option::Option<std::sys::process::unix::common::Stdio>` (called)
          6: drop of `std::sys::process::unix::common::Command` (called)
          7: drop of `std::process::Command` (called) < --
          8: fail_blacklist::test (called)
          9: fail_blacklist::main (entrypoint)

この機能は、実際にはプロセスがクレートや依存関係のいずれかで起動されるのを妨げないことに注意してください。Rust 内では、外部オブジェクトファイルにリンクしたり、アセンブリを直接使用したりすることは常に可能であり、CGA はそれを分析できません。しかし、CGA によるブラックリスティングは興味深い機能です。

注意点

CGA ができることとできないことを詳細に説明したので、CGA が使用するアプローチにはいくつかの注意点と欠点があることを知っておくと役立ちます。

壊れやすい

モノモルフィゼーションされた MIR をトラバースすることは簡単ではありません。vtable の解決は非常に複雑です。コンパイラは時々シムを挿入し、実際の呼び出しは後で挿入されます。コルーチンは非同期パスを見つけるために展開する必要があります。そして、最終的にパニックになる暗黙的なアサーションがあります。これらすべてを言うと、分析は困難であり、その一部が完全に正しくないことは避けられません。ツールチェーンが更新されると、生成される MIR は劇的に変化する可能性があり、これもコールグラフに影響を与えます。rustc の動作が異なる場合、分析の一部が壊れる可能性があります。

これは予想されることです。Rust MIR は不安定であり、近い将来も不安定であり続けるでしょう。したがって、CGA は少し壊れやすいものになります。

最適化

コンパイラフラグ、特に opt-level に依存しますが、CGA が収集する MIR には到達不能なパニックが含まれる場合があります。

例えば、式 1 + 1 には暗黙的なオーバーフロー境界チェックが含まれています。定数評価を行う場合、式がパニックしないことは非常に明白です。opt-level=2(速度重視)の場合、この式は CGA が MIR を取得する前に、より早いコンパイラパスによって 2 に最適化されます。しかし、opt-level="z"(サイズ重視)の場合、このパニックパスは MIR に含まれます。代わりに、最適化は LLVM に任されます(そして LLVM はそれを実行します)。

コンパイラと cargo のオプションは、rustc とリンカーによって生成される最終バイナリに影響を与えるのと同じように、エミットされるコールグラフに関して本当に重要です。

出荷されるバイナリが異なるものを使用する場合でも、CGA には opt-level=2 を使用することを推奨します。なぜなら、opt-level=2 では一般的に到達可能なパニックが欠落しないからです(したがって、偽陽性が少なく、偽陰性もありません)。

ツールの期待値は rustc

CGA はいくつかの実世界のバイナリでテストされており、通常はすぐに機能します。しかし、時々機能しないことがあります。これは、プロジェクトがまだ build-std でコンパイルできないため(CGA が必要とする)、または build.rs が、標準の rustc ではなく rustc-driver (CGA など) でアプリケーションがビルドされることを期待しているためです(例)。

CGA がプロジェクトで機能しない場合、またはコールグラフが正しくないと思われる場合は、問題を報告してください!ツールは、実際のソフトウェアでテストすることによってのみ進化できます。

試してみる

Call Graph Analyzer (CGA) を使用すると、コールグラフを抽出し、パニックを検出できます。アプリケーションに、unwrap を使用していないにもかかわらず、秘密裏にどれだけのパニックが含まれているか疑問に思ったことがあるなら、ぜひ試してみて、何を見つけたか お知らせください!

Jyn によるこのブログ記事と、Ferrocene による作業に特別な言及をします。これらは Call Graph Analyzer に関する私たちの作業に大きく影響を与えました。