かとじゅんの技術日誌

技術の話をするところ

Rustで真に安全なプログラムを書く方法

この記事はRust Advent Calendar 2021の12/8日の記事です。

Rust前提の記事として書きましたが、他の言語にも適用できる考え方なので、ほかの言語勢の方々もよければお付き合い下さい。

今回のテーマは「Rustで真に安全なプログラムを書く方法」についてです。

「真に安全なプログラム」の定義は以下とします。

  • 挙動が安定し、結果が予測可能となる
  • 正しさの基準に基づき、プログラムの間違いを検知することができる

「真に」とはドメイン知識に基づく正しさという意味です。詳しくは後述します。

それと「そもそもRustで実装されるプログラムは安全じゃないのか」という想定質問については「メモリの操作は安全。だが、それだけでは真に安全なプログラムにはならない」が答えになります。これについて興味がある方、ぜひ最後までお付き合いください。

「真に安全なプログラム」を実現するレシピとしては「関数型プログラミング」「ドメイン・プリミティブ」「契約による設計」あたりを使ってみようと思います。

関数型はプログラミングスタイル

先日、関数型は不変が基本のプログラミングスタイルだという記事を書きました *1

zenn.dev

プログラムが数学的特性を帯びることで、以下のような設計のよい効果が得られます。最初の項目は「真に安全なプログラム」の条件に含めています*2

  1. 挙動が安定し、結果が予測可能となる
  2. プログラムを改修した際、不具合を起こしにくい
  3. 副作用が分離されているため、テストがしやすい
  4. 宣言的で意図が理解しやすい

この記事ではRustのコード例も記載していますが、Rustでも関数型プログラミングは可能なので同様の効果が見込めます。

ドメイン・プリミティブ(ドメイン固有型)

ドメイン固有型は以下の記事に詳しくまとめています。

qiita.com

関心の対象にあわせた独自の型は、一般的に「ドメイン固有型」や「ドメイン特化型」と呼ばれることがあります。

「それがそんなに有用なの?」という疑問には、この探査機の教訓が答えてくれます。

1999年9月23日、火星探査機「マーズ・クライメイト・オーピター(MCO)」は火星を周回する軌道への突入に失敗し、燃え尽きました。3億2,730万ドルが失われた原因はソフトウェアのエラーでした。そのエラーは、具体的には「単位の混在」でした。同じ数値の単位を、地上のソフトウェアではポンドとしていたのに対し、宇宙船ではニュートンとしていたのです。その結果地上では、宇宙船のスラスタ推力を実際の約4.45分の1とみなしてしまうことになりました。

単位の扱いを間違えないように、プリミティブ型ではなくドメイン固有型を使えば、探査機は燃え尽きることはなかったのではという話です。ドメイン固有型は、間違いを防止するだけでなく、可読性やテスタビリティが向上するという効能もあります。

なぜセキュア・バイ・デザインなのか

最近発売された『セキュア・バイ・デザイン』という書籍でも、ドメイン固有型が「ドメイン・プリミティブ」として取り上げられています。セキュリティを担保する強力なツールとして紹介されています(この記事でもドメイン固有型ではなく「ドメイン・プリミティブ」として表記を統一します)。和訳本が出てから、読書会が各所で立ち上がっているようで盛況ですね。

ということで、少し本題から逸れますが、『セキュア・バイ・デザイン』の話にお付き合いください。

僕も既にこの本を読む前からドメイン・プリミティブを採用して、脆弱性診断でも脆弱性0件を経験したことがあります。自慢?自慢ですね…。

さて、なぜ『セキュア・バイ・デザイン』がそこまで注目されるか。「ドメイン駆動設計」がセキュリティ対策にも大きなインパクトを与えるからです。

というのは、XSSやインジェクションなどの既知の攻撃を対策するだけがセキュリティ対策ではないからなんです。たとえば、オンライン書店で、既知の攻撃対策は完ぺきでしたが、-1冊の誤注文を受理し請求処理に失敗し返金処理まで至ったという実際の事故例が書籍中で紹介されています。このような問題は既知の攻撃対策では防げません。問題が対象ドメインのルールに依存するので、ドメインモデルに手を入れないと対策できません。

『セキュア・バイ・デザイン』では、ドメイン知識による正しさから逸れた際に、プログラムを安全に停止させるという考え方があります *3。これは後で説明しますが、「契約による設計」に基づいています。これを実現するには、ドメイン知識を設計に反映する必要があります。そう「ドメイン駆動設計」です。セキュリティの問題を解決したければ(セキュリティだけに注目するのではなく)ドメインにフォーカスしろというわけです*4。つまり「真に安全なプログラム」の「真に」とはドメイン知識に基づく正しさという意味になります。

というわけで、セキュリティ対策を口実にドメイン駆動設計をやれますね!(えっ手段の目的化…

こういう話を聞くと「Rustを使ったら安全になるんじゃないの?」という疑問を持つかもしれませんが、メモリに関する操作は安全になりますが、そもそもビジネスルール上安全かは別問題です。明らかにレイヤーが違います。とはいえ、どちらの考え方も有用です。安全性は多層的に考えるべきなので、Rustのメモリ安全性に加えて『セキュア・バイ・デザイン』を適用すれば、真に安全なプログラムへ近づくのではないかと思います。

攻撃を受けないスタンドアローンなシステム、それこそ探査機のシステムであっても*5、自らのシステムが攻撃者になる可能性もゼロではありません。そんな自己矛盾が発生したときどう対処するのかも、『セキュア・バイ・デザイン』からヒントを得られるでしょう。

論よりコード

前置きが長くなりましたが、「関数型プログラミング」と「契約による設計」の観点を取り入れて、設計を考えてみたいと思います。何か題材が必要だと思うので『セキュア・バイ・デザイン』の例をRustで書いてみようと思います。

題材は子猫名リストオブジェクト

『セキュア・バイ・デザイン』の第4章に登場する、子猫の名前を集合として管理するオブジェクトの例です。本書内のコードはJavaですが、ざっとRustで書き直すと以下のような形になります。実はこれもドメイン・プリミティブです。Vec<String>型をそのまま利用せずに、有用なメソッドを介して利用する想定です。ただこのままだと問題が多そうです…。

pub struct CatNameList {
  cat_names: Vec<String>,
}

impl CatNameList {
  /// コンストラクタ相当
  pub fn new() -> Self {
    Self { cat_names: Vec::new() }
  }

  /// - 事前条件
  ///   - 子猫の名前には「s」が含まれていること
  ///   - まだ登録されていない名前であること
  pub fn queue_cat_name(&mut self, name: &str) {
    self.cat_names.push(name.to_string());
  }
  
  /// - 事前条件
  ///   - 候補となる子猫の名前が登録されていること
  pub fn next_cat_name(&self) -> Option<&String> {
    self.cat_names.get(0)
  }
  
  /// - 事前条件
  ///   - 候補となる子猫の名前が登録されていること
  pub fn dequeue_cat_name(&mut self) {
    self.cat_names.remove(0);
  }

  pub fn size(&self) -> usize {
    self.cat_names.len()
  }
}

現状の何が問題なのか?

守るべき事前条件はコメントに記載しましたが、お気づきのとおり、この実装は契約を守っていません…。

『オブジェクト指向入門 第2版 原則・コンセプト』の「契約による設計」では、欠陥(バグを含む)と判断する正しさとして、以下のような考え方が示されています。これもすごい鈍器本ですが、興味があればぜひ手にとってみてほしい…。

  • 正しさとは相対的な概念である
  • ソフトウェア要素そのものが正しいかどうかではなく、対応する仕様と一致するかを論ずるべき(ソフトウェア要素と仕様のペアで考える)
  • 正しさを評価するには、 顧客(クライアント)と提供者(サービス)間で取り決める「契約」を定義し、その「契約」に沿っているか判断する
  • 「契約」に沿っているかは「表明」を使った仕様の記述方法を利用するのが一般的

コード例で示した、守るべき事前条件は「契約」の一種です。

ところで、このコードをみてどんな感想を持ちましたか?「えっ気にしたことがなかった」「バリデーションしてるから大丈夫」「テストしてるから大丈夫」とかでしょうか。本当にそう思いますか?

例えば、プログラマーが正しくないロジックを書いた場合、プログラムは契約を違反したまま実行します。契約は守るべきルールですが、それが守られないまま実行します。つまり期待した挙動にはなりません。意図した振る舞いから逸れることになるので「欠陥」と言えます。バグもこの概念に含まれます。欠陥の詳細については以下の記事を見てもらうとよいと思います。

zenn.dev

エラーはリカバリ可能ですが、欠陥は「通常ありえない状況」で、そのまま続行してもよいことはありませんし、リカバリするものではありません。『達人プログラマー ―熟達に向けたあなたの旅― 第2版』でもトラッシュよりクラッシュすべきと言われています。RustやGoではパニックさせることが多いでしょう。

「できるだけ早期に問題を検出すれば、早めにクラッシュ(停止)に持っていけるというメリットが出てきます。そして多くの場合、プログラムのクラッシュは最も正しい行いとなるのです。クラッシュさせずに放っておくのは、壊れたデータが重要なデータベースに格納されるのを指をくわえて見ていたり、衣類を 20回ほど連続して洗濯機にかけたりするのと同じことです。」

あり得ない状況でも無理やり実行するコードを書いたことがありますが、障害の解析に凄く戸惑ってしまって無駄な時間を溶かした経験があります…。

さらに、このような状況を放置するとよくないです。クライアント(呼出し元)がこのような不完全なモジュールを使うと、契約ではなく現状のサービス(呼出し先)の実装に依存します。そして、間違いに気づいて実装を修正しても、手遅れです。クライアント側が間違いに依存しているので壊れてしまう可能性があります。

こういう話をすると、PHPのmt_rand()関数がすぐに修正できなかったことを思い出しますね。

qiita.com

本来であればクライアントも修正すべきですが、修正規模が大きすぎると難しいかもしれません。仕様に照らして明らかに間違っているのにそれを正せない。いつのまにかそれが正しいと言わざるを得ない状況になり、何が正しいか曖昧な状況になりかねません…。

このような事態を避けるには、少なくとも開発者がドメイン知識に関心を持たないと難しいと思います…。

表明(値による表明)を導入する

ということで、事前条件を表明する実装に変更してみましょう。以下のように実装してみました*6。事前条件から逸れたときは正しくない状況なのでpanic!するようにしました。

use once_cell::sync::Lazy;
use regex::Regex;

#[derive(Debug)]
pub struct CatNameList {
  cat_names: Vec<String>,
}

// 正規表現型(Regex)のインスタンス
static REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r".*s.*").unwrap());

impl CatNameList {
  /// コンストラクタ相当
  pub fn new() -> Self {
    Self { cat_names: Vec::new() }
  }

  /// - 事前条件
  ///   - 子猫の名前には「s」が含まれていること
  ///   - まだ登録されていない名前であること
  pub fn queue_cat_name(&mut self, name: &str) {
    if !REGEX.is_match(name) {
      panic!("Must contain s");
    }
    if self.cat_names.contains(&name.to_string()) {
      panic!("Already queued");
    }
    self.cat_names.push(name.to_string());
  }

  /// - 事前条件
  ///   - 候補となる子猫の名前が登録されていること
  pub fn next_cat_name(&self) -> Option<&String> {
    self.cat_names.get(0)
  }

  /// - 事前条件
  ///   - 候補となる子猫の名前が登録されていること
  pub fn dequeue_cat_name(&mut self) {
    if self.cat_names.is_empty() {
      panic!("Must non empty");
    }
    self.cat_names.remove(0);
  }

  pub fn size(&self) -> usize {
    self.cat_names.len()
  }
}

#[test]
fn example() {
  let mut cat_names = CatNameList::new();
  cat_names.queue_cat_name("cats");
  let cat_name = cat_names.next_cat_name().unwrap();
  println!("{:?}", cat_name);
  cat_names.dequeue_cat_name();
  println!("{:?}", cat_names);
}

事前条件を表明するのは、queue_cat_namedequeue_cat_name だけになりました。事前条件から逸れる=欠陥(バグを含む)です。その場合リカバリしても意味がありませんので、panic!でプログラムは早期に中断します。ここでは変数の値が正しいか判断するので「値による表明」と呼ぶことにします。

この二つのメソッドに、意図した値による表明が組み込まれました。next_cat_nameは要素サイズが0個であればNoneを返し、要素が1個以上であればSomeを返すので表明を不要としました。使用例はexampleを参照してください。

本題に関係ないところではありますが、内部データはVecを使っていますが、キューのような振る舞いであればVecDequeを使うほうがよいかもしれません。

可変と不変の使い分け

queue_cat_name, dequeue_cat_nameの二つのメソッドを可変ではなく不変にしたほうがいいのではという指摘もあると思います。

Rust以外の言語では、可変と不変を別々の型で設計することが多いでしょう。例えばJavaのStringStringBuilderです。Rustでは、型(struct)が不変や可変を決めるのではなく、メソッド(関数)が決めます*7。可変か不変で型(struct)を別々に定義する必要はありません。問題はメソッドを、可変か不変のどちらにするか、という論点になります。

インスタンスを定義するとき、letは不変で、let mutは可変になります。Rustでは、不変インスタンス時に可変操作をしようとすると以下のようにコンパイルエラーになります。queue_cat_nameメソッド、dequeue_cat_nameメソッドは&mut selfが必要なので、このコードではコンパイルできないわけです。不変インスタンス時に誤って可変操作できません。安全な可変操作ができます。

#[test]
fn example() {
  let cat_names = CatNameList::new();
  // 不変インスタンスの場合はコンパイルエラー
  cat_names.queue_cat_name("cats");
  let cat_name = cat_names.next_cat_name().unwrap();
  println!("{:?}", cat_name);
  // 不変インスタンスの場合はコンパイルエラー
  cat_names.dequeue_cat_name();
  println!("{:?}", cat_names);
}

ScalaやKotlinの可変性には豊富なバリエーションがあります。

Immutable Mutable
val 😁 😕
var 😀 😱

Rustの場合はシンプルです。Rustの可変性は他の言語と違って安全に扱えるので、😱ではなく😀にしました。

Immutable(&self) Mutable(&mut self)
let 😄
let mut - 😀

※に近いことは、T型が&selfであってもRc<RefCell<T>>, Arc<Mutex<T>>などの内部可変性を使うと可能です。

さらに、ここでは詳しく述べませんが、可変参照には以下のような厳しい制約があります。

  • 一つのスコープで一つのインスタンスに対して、可変参照は一つしか取れません
  • 不変参照を借用中は可変参照を借用できません

詳しくは公式ドキュメントを参照してください。

メソッドを不変に変更する

可変でも安全に扱えますが、挙動を予測しやすくするにはやはり不変にした方がよいです。不変の例を以下に紹介します。

&mut self&selfに変更し、戻り値がSelfに変わります。self.cat_namesの複製に対して可変操作した結果を保持する新しいインスタンスを返します。当然、exampleを変わります。シャドーイングをうまく使ってすっきり書くことができます。

// ...

  pub fn queue_cat_name(&self, name: &str) -> Self {
    if !REGEX.is_match(name) {
      panic!("Must contain s");
    }
    if self.cat_names.contains(&name.to_string()) {
      panic!("Already queued");
    }
    // 複製を作り変更を加え新しいインスタンスを返す
    let mut cloned = self.cat_names.clone();
    cloned.push(name.to_string());
    Self { cat_names: cloned }
  }
  
  pub fn dequeue_cat_name(&self) -> Self {
    if self.cat_names.is_empty() {
      panic!("Must non empty");
    }
    // 複製を作り変更を加え新しいインスタンスを返す
    let mut cloned = self.cat_names.clone();
    cloned.remove(0);
    Self { cat_names: cloned }
  }
}

#[test]
fn example() {
  let cat_names = CatNameList::new();
  let cat_names = cat_names.queue_cat_name("cats");
  let cat_name = cat_names.next_cat_name().unwrap();
  println!("{:?}", cat_name);
  let cat_names = cat_names.dequeue_cat_name();
  println!("{:?}", cat_names);
}

この例では、状態を変えるような操作は常に新しいインスタンスを作ります。すべてのメソッドは引数に対して参照透明性があり、その挙動は予測可能になります。

つまるところ、self.cat_namesの複製をどう考えるかによって設計が変わります。共有される型なら原則 不変したほうがよいでしょう。性能上のペナルティが大きいなら&mut selfで局所的な可変を検討するのがよいでしょう。

何を検証すればいいのか

『セキュア・バイ・デザイン』では以下の項目と検証順序が提唱されています。詳しくは書籍を読んでみてください。

  1. オリジン(発生源)
    • 正当な送信元から送信されたデータか?
  2. サイズ
    • データのサイズは適切か
  3. 字句的内容 (lexical content)
    • データを構成する文字は正当な文字だけを使っており、正しくエンコードされているか?
  4. 構文 (syntax)
    • データは正しいフォーマットに沿っているか?
  5. 意味 (semantics)
    • そのデータは意味的に正しいものか?

今回の場合は、サイズや字句的内容や構文に関する表明を扱っていることがわかります。

型による表明

CatNameList型をさらに改善してみます。

子猫の名前をドメイン・プリミティブ化する

nameを文字列型&strではなく、ドメイン・プリミティブに切り出してみます。

pub struct CatNameList {
  cat_names: Vec<CatName>,
}

impl CatNameList {
// ...
  pub fn queue_cat_name(&mut self, name: CatName) {
    if self.cat_names.contains(&name.to_string()) {
      panic!("Already queued");
    }
    self.cat_names.push(name);
  }
// ...
}

さらに、正規表現を使った表明をCatName側に移動させます。

#[derive(Debug)]
pub struct CatName(String);

static REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r".*s.*").unwrap());

impl CatName {
  pub fn new(name: &str) -> Self {
    if !REGEX.is_match(name) {
      panic!("Must contain s");
    } 
    Self(name)
  }
}

queue_cat_nameメソッドはCatName型を使います。CatNamenewメソッドには表明があるのでインスタンス化できれば常に正しいインスタンスであると言えるので、queue_cat_nameメソッドにあったnameに関する表明は不要になります。ドメイン・プリミティブを引数に取ることは値による表明ではなく「型による表明」と呼ぶことにします*8

常に要素数1個以上となるCatNameList型

子猫の名前のリストが空かどうか、ある値の状態を表明するのは安全なのですが、そもそもそういった「値の表明」を利用せずに安全に操作できないものでしょうか。

CatNameListを、そもそも要素0個でインスタンス化できないように設計を変更します。これも「型による表明」の一種です。どういうことかみていきましょう。

#[derive(Debug)]
pub struct CatNameList {
  head: CatName,
  tail: Option<Rc<CatNameList>>
  size: usize,
}

impl CatNameList {

  pub fn new(head: CatName) -> Self {
    Self {
      head,
      tail: None,
      size: 1,
    }
  }

  fn contains(&self, name: &CatName) -> bool {
    if self.head == *name {
      true
    } else {
      match &self.tail {
        None => false,
        Some(next) => (&*next).contains(name)
      }
    }
  }

  fn combine(&self, other: Self) -> Self {
    let t = match self.tail.as_ref() {
      None => Rc::new(other),
      Some(t) => Rc::new((&*t).combine(other)),
    };
    Self {
      head: self.head.clone(),
      tail: Some(t),
      size: 1 + (&*t).size
    }
  }

  pub fn queue_cat_name(&self, name: CatName) -> Self {
    if self.contains(&name) {
      panic!("Already queued");
    }
    self.combine(CatNameList::new(name))
  }

  pub fn next_cat_name(&self) -> &CatName {
    &self.head
  }

  pub fn dequeue_cat_name(&self) -> Option<&Rc<CatNameList>> {
    self.tail.as_ref()
  }

  pub fn size(&self) -> usize {
    self.size
  }  
}

headに必ず1要素を取るようにし、残りはtailに格納します。tailtail: Option<Rc<CatNameList>>と少し複雑ですね。Rcはリファレンスカウントを扱う型です。Rcを使うとクローンしても参照のカウント値が加算されるだけでインスタンスのコピーは起きません。今回のケースでは、自己参照型を扱うために使っています。sizeは要素数を表現します。

細かい変更点は以下です。

  • containsメソッドは、headを比較したのち、tailがあれば委譲するだけです。
  • queue_cat_nameメソッドは、containsメソッドで含まれていないことを確認したら、combineで要素をリストにして追加します。combineでは現在のtailに引数で与えた新しいリストをcombineしたインスタンスを返します。sizeも同時に計算します。head, tailの構造では末尾に追加する効率が悪いですね…。課題が残りますが、今回の説明は本題がそこじゃないので…。
  • next_cat_nameメソッドは、もはやself.headの参照を返すだけになりました。常に最初の要素があるのでOptionでラップしなくて済みました
  • dequeue_cat_nameメソッドも、self.tailの参照を返すだけです。要素数が1個以上か表明する必要もありませんし、後続がある場合でも後続のCatNameListのインスタンスを返すだけなので大きな削除コストはかからないでしょう
  • sizeメソッドはsizeを返すだけです。この型は不変なので、要素数を一度計算すれば再計算は不要です。

この変更によって、CatNameListが要素数0個で誤って利用されることがなくなりました。間違った操作がそもそもできない設計になりました。これも「型による表明」の効果です。 もちろん、「型による表明」は「値による表明」の下支えがないと実現が難しいのですが、重要な部分ではできる限り「型の表明」を適用することを考えたほうがよいでしょう。

まとめ

ところで、ヒューマンエラーの考え方には、フェイルセーフとフールプルーフという考え方があります。フェイルセーフは、システムが故障あるいはエラーを発生させても安全が維持できることです。フールプルーフは人間が誤った行為をしようとしてもそもそも出来ないようにすることです。

「値による表明」は、フェイルセーフの考え方に分類されます。意図した振る舞いから逸れたとき、そのまま続行するとシステムがトラッシュします。それを防ぐために、早期にシステムを中断するからです。一方で「型による表明」は、そもそも危険な操作自体ができない(コンパイルエラーでプログラムとして実行できない)のでフールプルーフに分類されるでしょう。

「値による表明」は実行すれば正しいことが分かる。「型による表明」はコンパイルすれば正しいことが分かる。後者が増えれば、テストを実行するまでもなく、間違いに気づく機会が増えるでしょう、きっと。この考え方が、Rustと合わされば鬼に金棒ですね。

追記:12/8

わかりにくかったかもしれませんが、ErrorとDefect(欠陥)で対応方法が異なります。

panic!するかResultを返すか公式のドキュメントも参照してみてください。Result型を返すからRustらしいは思考停止だと思います。

*1:オブジェクト指向もプログラミングスタイルです

*2:2も入るかなとは思いますが、改修の方法や手順に依存するかもしれないので今回は条件に含めず

*3:これは代表的なパターン

*4:『セキュア・バイ・デザイン』が既知のセキュリティ対策を軽視しているわけではありません

*5:探査機は地球と交信するので非スタンドアローンのでは…

*6:外部クレートとして遅延初期化のためにonce_cell, 正規表現のためにregexを利用しています

*7:関数に記述されるselfの借用記述で決定します

*8:静的型付き言語前提の解説になっていますが、動的型付き言語でも値の型を判定できるのであれば似たようなことはできるかもしれない…