デザインパターンの本を読んでいた。UMLクラス図のページにたどり着いて、そこで立ち止まった。

そのページの図は、Rustがすでに独自の構文で表現している内容を記述していた。structはクラスだ。Option<T>0..1の関連。Vec<T>1..N。所有権はコンポジションである。私が見ていた、箱と菱形で描かれた形式は、コンパイラがすでに持っていた情報と同じものであった——ただ表記が異なるだけで。

そこから不快な考えに至った。型システムがすでにUMLが描くものをエンコードしているなら、コードをUMLに翻訳しても新しいことは何も教えてくれないはずだ。それは冗長であるはずだ。ただし、翻訳が一致しなかった場合——一致しなかった箇所には、隙間にバグが隠れていることになる。

そこで試してみた。私のRustウェブフレームワークであるRunique(v2.1.21)を取り上げ、フレームワーク自体をモジュールごとに、構造はUMLで、データはMeriseでモデル化した。直感は私のものであり、Claudeを使って各翻訳を実際のコードと1行ずつ照合して検証した。私たちは20以上の潜在的なバグを発見した。そのうち3つはセキュリティに関連するものだった。それらはcargo testtracing、またはコンパイラのいずれでも明らかになっていなかった。

これがそのギャップの様子である。

自分のコードで見えなかったもの

RuniqueはDjangoに着想を得たフレームワークである:Axum、SeaORM、Tera、edition 2024。これは動作する。実際のレストランの予約サイトとrunique.io自体を本番環境で数ヶ月間動かしてきた——それが私のドッグフーディングである。しかしここで述べている監査は、それらのサイトではなくフレームワーク自体を対象としている。そしてそれがまさに問題だ——「動作する」という状態は、バグが見えにくい状態なのだ。コンパイラは満足している。テストは緑色だ。tracingは何も表示しない。なぜなら何もスローしていないからだ。

毎日使っているツールはすべて局所的だ。cargo checkは1つの関数を見る。ユニットテストは1つのパスを見る。tracingは1つのリクエストを見る。それらのいずれも一歩引いて全体の形状を一度に見せることはない——そして一部のバグは形状の中にしか存在しない。あるフィールドがpubであるべきなのにpub(crate)になっている。あるファイルがシーケンスの中で1ステップ早く書き込まれている。ある型が、フィーチャーフラグの向こう3ファイル離れた、合致すべき型から乖離している。

私はコードから離れることなく、コードの外からの視点が必要だった。書き換えでも、新しいツールでも、リンターでもない。同じものの異なる表現で、差異を機械的に発見できるほどに精密なものが。

3つの表記、1つの構造

本が私に与えてくれたアイデアを明示的に示す。

Rustの型システム、UMLクラス図、Meriseデータモデルは、3つの表記法で、同じ根本的事実を表している:エンティティ、それらの関係、カーディナリティ、不変条件。GoFパターンは、これら3つすべてに現れる繰り返しの形状に過ぎない。Rustは型システムでそれらをエンコードし、コンパイラが強制する。UMLとMeriseは、人間が読むための、別個のグラフィカルな形式主義でそれらをエンコードする。両者の間の翻訳はほぼ機械的である:

概念 Rust UML / Merise
エンティティ struct クラス / エンティティ
必須の1..1 値によるフィールド: T カーディナリティ 1..1
オプションの0..1 Option<T> カーディナリティ 0..1
1..N Vec<T> / HashMap<K,V> カーディナリティ 1..N
コンポジション(所有) struct A { b: B } または Box<B> 塗りつぶされた菱形
集約(共有) &B / Arc<B> / Rc<B> 中空の菱形
洗練 AST → HIR → MIR MCD → MLD → MPD

1つの注意点がある。Rustaceanは(正当に)飛びつくだろうから:所有権からコンポジションへのマッピングは完全ではない。Box<B>は依然として所有するため、それはコンポジションであり、集約ではない。集約——独立したライフタイムを持つ共有参照——は&BArc<B>Rc<B>である。この類似はレンズであって、法則ではない。しかし、それは十分に鋭利なレンズであり、コードと図が一致しない場合、その不一致は調査する価値がある。

これがこのメソッド全体を1文で表したものである:型システムがネイティブに話さない形式主義でコードをモデル化し、2つの表現が一致しない箇所を監査する。

Meriseについては、ここで注記しておく価値がある。なぜなら、英語圏の読者にとって最も馴染みのないものだからだ。これは1970年代のフランスのシステムモデリング手法で、現在もフランスのCSプログラムで教えられている。これはデータモデルを概念的(MCD)、論理的(MLD)、物理的(MPD)のレベルに分割する——そして重要なことに、論理レベルでは、すべての関連についてカーディナリティ具体的な型の両方を書き下すことを強制する。この2番目の要求が、以下のバグの1つを捉えた。

メソッド:3つのレンズ、サンプリングなし

私はスポットチェックしなかった。監査の要点は網羅性であるため、3つのレンズを使って、フレームワーク全体をモジュールごとに調べた。各レンズはその得意分野に合わせた:

  • データのためのMerise — すべてのeihwaz_*フレームワークテーブルのMCDとMLD、カーディナリティ、外部キー、物理型を含む。
  • 構造のためのUMLクラス図 — レイヤーごとに1つ:engine、forms、admin、auth、middleware、migration、config。
  • ダイナミクスのためのシーケンス図 — request/CSRF/uploadフロー、login/session、admin CRUD。これは順序を示すレンズであり、コードにもクラス図にも見えない。

すべてはMarkdown内のMermaidであり、コードの横にバージョン管理されている。バイナリツールも、別個のアプリもない——図はGitHub上でレンダリングされ、リポジトリと一緒に移動する。各図は「anomalies」セクションで終わり、重大度と各発見のfile:lineを含む単一のanomalies.mdに統合される。

ここからが面白い部分だ。

バグ1 — UML: CSRFを静かにバイパスしたpubフィールド

最も興味深いものから始めよう——最も深刻なものではなく、最も興味深いものだ。なぜなら、このバグがこの演習全体が本当に何についてなのかを最もよく示しているからだ。

リクエストの解析済みでCSRFチェック済みのボディを保持する型であるPrismeのUMLクラス図を描いていた。図は各フィールドの可視性を書き下すことを私に強制した。そしてそこにあった:

Prisme
  + data: StrMap        ← public
  + csrf_valid: bool
  + checked_data() -> Option<&StrMap>   ← fail-closed accessor

Enter fullscreen mode Exit fullscreen mode

datapubだった。checked_data()——CSRFが通過した場合にのみボディを返すアクセサ——はその隣にあった。それらの2つが図の同じボックスに並んだ瞬間、問題は明らかになった:なぜfail-closedのゲートを構築し、その隣に生のフィールドをpublicのままにしておくのか?

RuniqueのCSRF強制はHTMLフォームのミドルウェアには存在しない——それはアクセスポイントに存在する。req.form()csrf_validをチェックし、トークンが不正であれば検証済みデータを渡すことを拒否する。しかし、Prisme::datapubである場合、任意のハンドラ——または、より重要なことに、Runique上に構築されたサードパーティのコード——は生のボディを直接読み取ることができ、CSRFゲートを完全にスキップできる。しかもそれをしたことに気づかないまま。

深刻度について正確に述べたい。なぜなら、正直なバージョンは怖いバージョンよりも興味深いからだ。私の自身のコードはクリーンだった:admin、login、form()はすべてボディに触れる前にCSRFをチェックする。これはRunique自体の悪用された穴ではなかった。これは公開APIにおけるフットガンだった——フレームワーク上に構築する誰かがCSRFを偶然にバイパスできる形状だった。これは侵入ではなく、フレームワーク作者のバグだ。

修正は1つのキーワードであり、それがこの記事の要点である:

pub struct Prisme {
    /// Parsed body/query data. **Crate-private**: user code cannot read the raw
    /// body without going through the CSRF gate (anomaly C2). External access only
    /// via checked_data() (fail-closed) or req.form().
    pub(crate) data: StrMap,
    pub csrf_valid: bool,
}

impl Prisme {
    /// Fail-closed accessor: returns body data only if CSRF is valid.
    pub fn checked_data(&self) -> Option<&StrMap> {
        if self.csrf_valid { Some(&self.data) } else { None }
    }
}

Enter fullscreen mode Exit fullscreen mode

pubpub(crate)。Rustの可視性システムは、構造的に保証するようになった。リクエストボディへの唯一のドアは、CSRFを最初にチェックする2つのドアである。これは規約ではなく、リントではなく、誰かが覚えておかなければならないコードレビュールールでもない。コンパイラはバイパスを拒否する。セキュリティメカニズムとしての可視性——UML図がそれを表面化させた。なぜならクラス図は、コードがスキップさせてしまう1つのプロパティ(pub vs pub(crate))を書き下すことを強制するからだ。

バグ2 — シーケンス図: 許可される前に書き込まれたファイル

2番目のレンズは時間を示すものである。以下は、実際のパイプラインから描いたマルチパートフォームPOSTのシーケンス図である:

sequenceDiagram
    participant C as Client
    participant MW as csrf_middleware
    participant P as prisme_pipeline
    participant AG as parse_multipart
    participant H as Handler

    C->>MW: POST multipart (csrf_token as a field, no header)
    Note over MW: mutating method, no X-CSRF-Token,<br/>form content-type → LET THROUGH<br/>("Prisme will validate")
    MW->>P: next.run()
    P->>AG: parse_multipart(req)
    AG->>AG: stream file → tmp (size cap, NO extension check)
    AG->>AG: rename tmp → MEDIA_ROOT/uuid.ext (COMMIT)
    Note over AG: file committed to its final,<br/>servable location BEFORE CSRF,<br/>validation, and the handler
    AG-->>P: final paths
    P->>P: check_csrf() → csrf_valid (flag only)
    P-->>H: Request
    H->>H: if form.is_valid() { ... } else { reject }
    Note over H: file already on disk in MEDIA_ROOT,<br/>never removed on rejection

Enter fullscreen mode Exit fullscreen mode

上から下に読むと、バグは自らを語る。ファイルはMEDIA_ROOT——その最終的で、潜在的に公開提供される場所——に、CSRFがチェックされる前、フォームが検証される前、そして拡張子フィルタ(FileField::validateに後で存在する)が実行される前にリネームされる。マルチパートを受け入れ、Requestを抽出する任意のパブリックエンドポイントでは、それは以下を意味した:

  1. 認証されていないファイル書き込み。 ファイルは次に何が起こってもMEDIA_ROOTに到達する。
  2. その段階での拡張子フィルタなし — サイズキャップのみ。.html.svg、または.jsが提供されるディレクトリに到達する可能性がある。MEDIA_ROOTが静的に提供される場合、それは保存されたXSSへの道となる。
  3. 拒否時の孤児。 CSRFが失敗し、検証が失敗し、ハニーポットがトリップする——ファイルはすでに書き込まれており、何もそれをクリーンアップしない。

ユニットテストがこれを捉えなかったのは、ユニットテストがリクエストライフサイクルのような形状をしていないからだ。クラス図もそれを捉えなかった——個々のメソッドはすべて分離した状態で正しい。時間的ビュー、順序をレイアウトするビューだけが、「検証される前に書き込まれた」を一目で可視化した。

修正はコミットを最後に移動する。parse_multipartは現在、非提供のステージングディレクトリに書き込み、FileField::finalize——CSRFと検証のに実行される——だけがコミットを許可される:

// No commit here: files stay in staging. FileField::finalize (the only committer)
// moves them to their served destination after CSRF + validation. On rejection,
// staging is purged by sweep_stale_staging (best-effort, TTL). Errors are logged,
// never swallowed.
Ok(data)

Enter fullscreen mode Exit fullscreen mode

1つのコミッター、1つのゲート、正しい順序で実行——そして昇格されなかったステージングのためのTTLスイープで、すべての失敗が落とされるのではなくログに記録される。

バグ3 — Merise: ターゲットと一致しなかった外部キー

最後のものは、Meriseの「型を書き下す」規律が捉えたバグであり、なぜ別個の形式主義がその存在価値を持つのかのきれいなデモンストレーションである。

以下は、ユーザとグループの間のジャンクションテーブルの論理モデルであるMLDフラグメントである:

erDiagram
    USER ||--o{ USER_GROUPE : "belongs to"
    GROUPE ||--o{ USER_GROUPE : "groups"
    USER {
        pk id "int OR bigint under big-pk"
    }
    USER_GROUPE {
        pk user_id FK "→ users.id"
        int groupe_id FK
    }

Enter fullscreen mode Exit fullscreen mode

Meriseはuser_idの隣に2つのことを書き下すことを私に強制した:そのカーディナリティ(users.idへの必須FK)その物理型。そしてusers.idには脚注がある——通常はintegerだが、big-pkフィーチャーフラグの下ではbigintになる。そこで私はFKカラムが同じルールに従っているかどうかを確認しに行った。従っていなかった:

// user_id was hardcoded .integer() — it must follow eihwaz_users.id, which becomes
// BIGINT under the big-pk feature, or the FK is a type mismatch and fails to create.
let mut user_id_col = ColumnDef::new(Alias::new("user_id"));
user_id_col.integer();   // ← always integer, even when users.id is bigint

Enter fullscreen mode Exit fullscreen mode

sessionshistoryreset_tokensはすべてフィーチャーフラグをそれらのuser_idカラムに伝播していた。この1つのジャンクションテーブルはそうしていなかった。big-pkの下で、Postgresのような厳格なデータベースでは、外部キーは型ミスマッチであり、作成に失敗する——そのフラグを切り替えるユーザのサブセットにのみ現れる、壊れたマイグレーションだ。修正はそれを見てしまえば機械的である:

let mut user_id_col = ColumnDef::new(Alias::new("user_id"));
#[cfg(feature = "big-pk")]
user_id_col.big_integer();
#[cfg(not(feature = "big-pk"))]
user_id_col.integer();
user_id_col.not_null();

Enter fullscreen mode Exit fullscreen mode

コンパイラがこれを捉えることは決してできなかった——両方のブランチは問題なくコンパイルされる。それらは単にビルドされないデータベースを記述しているだけだ。Meriseがそれを捉えたのは、型をColumnDefビルダー呼び出しの中に埋もれた何かではなく、書き下して比較しなければならない明示的なものにしたからだ。その対応するものから3ファイル離れている。

本当の発見はバグではなかった

ここに予想していなかったひねりがある。バグは演習の目標だった。しかし、それはそれが産み出した最も価値のあるものではなかった。

最も価値のあるものは地図だった。

終わる頃には、Runique——その作者である私——が始めたときには持っていなかった、Runiqueのアーキテクチャ的理解を持っていた。漠然とした「自分のコードベースを知っている」という感覚ではなく、すべてのレイヤーとすべてのデータフローの精密で、描かれた、バージョン管理されたモデルだった。バグは、その地図を構築することの副産物だった。地図が資産である。

それを確信させた2つのことがある。1つ目は検証された偽陽性だった。私は実際のバグを記録しただけでなく、確信していたが間違っていたすべての仮説も記録した。私はmakemigrationsALTER COLUMNを扱えないと確信していた——偽りで、それは私が書いたことを忘れていた完全なスキーマ差分を使用する。2つのセッション書き込みパスが発散したsession_idを生成すると確信していた——偽りで、on_conflict句はそれを決定論的にする。自分が間違っていたことを文書化することは、監査を勝利のラップから分けるものであり、ほとんどの人が静かに削除する部分である。

2つ目は横断的なテーマだった。バグが1つのページに集まると、それらは20の別々のバグのように見えるのをやめ、4つの繰り返しのアンチパターンのように見え始めた:沈黙の中で飲み込まれたエラー。1つの事実に対する2つの真実の源。間違った順序でのセキュリティ操作。必要とされるすべての場所に伝播しなかったフィーチャーフラグ。これは「バグを修正する」から「ルールを抽出する」へのジャンプである——そしてそれは、それらすべてのインスタンスを一度に見ることができるときにしか作れない。地図がそれらを一度に見ることを可能にした。

自分のプロジェクトでこれを行う方法

試してみたい場合、メソッドは少数のルールに圧縮される:

  • 1つのモジュール、1つのレイヤーから始める。 初日に全体をモデル化しようとせず、疑い始めた部分をモデル化する。
  • レンズを質問に合わせる。 データ整合性 → Merise。構造と可視性 → UMLクラス図。順序とライフサイクル → シーケンス図。ほとんどのバグはこれら3つのビューのうちのちょうど1つに存在し、他の2つでは見えない。
  • ツールを些細なものに保つ。 Markdown内のMermaid、コードと一緒にコミットされる。それはGitHub上でレンダリングされ、ソースのようにdiffされる。つまり、図はwikiの中で腐るのではなく、正直に保たれる。
  • 偽陽性をバグと同じくらい慎重に記録する。 規律が全体の価値である。勝利だけを記録する監査はマーケティングである。

そして1つの正直な警告:これは本物の時間を要する。週末のプロジェクトには価値がない。本番コードベースで、疑い始めたもの——動作し、テストに合格し、それにもかかわらず静かな感覚を持っているもの——に価値がある。その感覚は通常正しくあり、これがそれが指し示しているものを見つける方法である。

結び

それを始めた本へのループバックはこうだ。Runiqueをモデル化したとき、私はその中にすでに存在するパターン——Builder、Template Method、Composite、Strategy——を発見した。それらは私が意図的に配置したことはなかった。それらは問題のようなものがあなたを向かわせる形状であるため、自ずと現れた。本はそれらを追加することを教えてくれなかった。それはすでにそこにあったものを見るための語彙を与えてくれ、モデル化はそれらが存在するという視覚的な証明を与えてくれた。

完全な図のセット——すべてのUMLクラス図、Meriseデータモデル、シーケンスフロー、それぞれに独自のanomaliesセクションを含む——はGitHubのdiagramme/フォルダに存在する。Runique自体はcrates.ioGitHubにあり、ドキュメントはrunique.ioにある。この監査のさらなる部分は、セキュリティ・スルー・可視性、飲み込まれたすべてのエラーのスイープ、検証された偽陽性の完全なリストとして、独自のシリーズになりつつある。

コメントへの1つの質問。なぜなら、私は本当に知りたいからだ:あなたはすでに書いたコードを監査するためにモデリング形式主義を使ったことがありますか——そしてそれはコード自体が隠していた何を明らかにしましたか?