wakuwaku
バリデーション (Proof)

バリデーション (Proof)

生の値ではなく検証済みの証明を受け渡すことで、「検証し忘れ」をコンパイルエラーにします。

wakuwaku::proof (wakuwaku-proof クレート) は、証明を持ち運ぶバリデーションの仕組みです。ルールを 述語 (predicate) として記述し、prove で一度だけ実行すると、Proven<T, P> が返ります。これは値そのものと、ルールが成り立ったという証拠 (evidence) の組です。検証済みの値を必要とするコードはシグネチャで Proven<T, P> を要求するので、未検証の値はそこに届きません。

防げるバグ

バリデーションのバグの多くは、ルールが間違っていることではなく、ルールが実行されなかったことから生まれます。

// 呼び出し側は必ず先に `name` を検証しなければならない
fn register(name: String) { /* ... */ }

検証済みの String と未検証の String は同じ型なので、新しいエンドポイントやバックグラウンドジョブ、リファクタリング後のコードが生の入力で register を呼んでも何も止めてくれません。Spring でいえば、コントローラーの引数に @Valid を付け忘れる典型的なケースです。制約は宣言されていても、それがチェックされることを強制するものがありません。

wakuwaku::proof では、この要件を型に移します。

fn register(name: Proven<String, ValidUsername>) { /* ... */ }

Proven<String, ValidUsername> を手に入れる方法は、ValidUsername のチェックを実行する (または別の証明から導出する) ことだけです。検証のし忘れは型エラーになります。

prove は subject を Proven<T, P> かエラーに変えるsubject: TP: Predicate<T>ctx: &P::Ctxorder: CheckOrderprove::<T, P>P::check(&s, ctx, order)非同期、I/O を伴ってよいOk(Proven<T, P>)subject() → &Tevidence() → &PErr(Deny(DeniedReason))判定済み:P は成り立たないErr(IoError(E))P を判定できなかったエラー時は subject を破棄します。下流のコードはProven<T, P>だけを受け取るT や P 単体は不可再検査せずに命題を変換Proven<T, And<A, B>>derive()CommuteProven<T, And<B, A>>Proven<T, And<A, B>>into_a()Proven<T, A>Proven<T, A>from_a()Proven<T, Or<A, B>>再検査も I/O もなく、失敗しません。証拠の中身AndEvidence<A>Evidence<B>Or::LeftEvidence<A>情報なしOr::Right情報なしEvidence<B>Or::BothEvidence<A>Evidence<B>
述語の型そのものが証拠です。Proven<T, P> は prove か既存の証明からしか作れないため、それを受け取る関数に未検査の値は渡りません。

セットアップ

proof フィーチャーはデフォルトで有効なので、wakuwaku だけで使えます。述語は普通の非同期コードです。tokio を直接依存に加える必要があるのは #[tokio::main] や #[tokio::test] を使うときだけで、wakuwaku のサービス内では gRPC ハンドラーがすでにランタイム上で動いています。

Cargo.toml
[dependencies]
wakuwaku = "1.0.0-rc3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

構成要素

項目役割
ValidateContext判定中にチェックが参照するもの (設定、接続ハンドル、キャッシュなど) と、「判定できなかった」ときのエラー型。
Predicate<T>実行時に判定される T についての命題。述語の型そのものが証拠になります。
prove対象 (subject) に述語を適用し、成功すれば両者を Proven に包みます。
PureVerifier<T, P, B = T>T の借用形 &B を受け取る関数ポインタとして表した純粋なチェック。static や const として宣言できます。Proven を得るもう一つの方法です。
Proven<T, P>対象と、P が成り立った証拠。prove、PureVerifier、または別の証明からしか得られません。T と P が Debug なら Debug です。
ValidateError<E>ルールが偽なら Deny(DeniedReason)、判定できなければ IoError(E)。E が Debug + Display なら std::error::Error です。
DeniedReason拒否の理由を表す &'static str。同一性で比較されます。Display はそのテキストです。

コンテキスト

すべての述語は自分のコンテキスト型を指定します。コンテキストは ValidateContext を実装し、判定に失敗したときのエラー型を決めます。純粋なチェックはそのような失敗をしないので Infallible を使います。

src/validation.rs
use std::convert::Infallible;
use wakuwaku::proof::{
    And, CheckOrder, DeniedReason, Predicate, Proven, ValidateContext, ValidateError, prove,
};

/// チェックが参照するもの。ここでは設定だけ
#[derive(Clone)]
pub struct Limits {
    pub max_len: usize,
}

impl ValidateContext for Limits {
    // 純粋なチェックは判定に失敗しない
    type IoError = Infallible;
}

述語

述語とは Predicate<T> を実装した型です。check は self を取りません。判定に必要なものはすべて対象とコンテキストから得ます。ルールが成り立つと、check は述語の型そのものの値を返します。この値が 証拠 です。報告することがないルールはユニット構造体に、判定中に何かを知るルールはそれをフィールドに保持します。

src/validation.rs
pub static EMPTY: DeniedReason = DeniedReason("username is empty");
pub static TOO_LONG: DeniedReason = DeniedReason("username is too long");

/// 「名前が空でない」。報告することがないのでユニット構造体
pub struct NonEmpty;

impl Predicate<String> for NonEmpty {
    type Ctx = Limits;

    async fn check(
        name: &String,
        _: &Limits,
        _: CheckOrder,
    ) -> Result<Self, ValidateError<Infallible>> {
        if name.is_empty() {
            Err(ValidateError::Deny(EMPTY))
        } else {
            Ok(NonEmpty)
        }
    }
}

/// 「名前が `max_len` バイトに収まる」。残りの余裕を記録する
pub struct ShortEnough {
    pub spare: usize,
}

impl Predicate<String> for ShortEnough {
    type Ctx = Limits;

    async fn check(
        name: &String,
        limits: &Limits,
        _: CheckOrder,
    ) -> Result<Self, ValidateError<Infallible>> {
        match limits.max_len.checked_sub(name.len()) {
            Some(spare) => Ok(ShortEnough { spare }),
            None => Err(ValidateError::Deny(TOO_LONG)),
        }
    }
}

check が返す Future は Send でなければならないので、どの tonic ハンドラーの中でもチェックを実行できます。CheckOrder 引数は他の述語を組み合わせる述語にとってだけ意味があり、末端の述語は無視して構いません (述語の合成 を参照)。

証明する

prove は対象を値で受け取り、チェックを実行して、証明かエラーを返します。And<NonEmpty, ShortEnough> は両方が成り立つときに成り立つ述語で、詳しくは 述語の合成 で扱います。

src/validation.rs
/// 両方が成り立つ必要がある
pub type ValidUsername = And<NonEmpty, ShortEnough>;

/// `prove` を通った名前でしか呼べない
pub fn register(name: Proven<String, ValidUsername>) -> String {
    let And(_, short_enough) = name.evidence();
    format!("{} ({} bytes to spare)", name.subject(), short_enough.spare)
}

pub async fn handle(input: String) -> Result<String, &'static str> {
    let limits = Limits { max_len: 16 };

    match prove::<_, ValidUsername>(input, &limits, CheckOrder::Sequential).await {
        Ok(name) => Ok(register(name)),
        Err(ValidateError::Deny(reason)) => Err(reason.0),
        // `Infallible` には値が存在しないので、このアームは決して実行されない
        Err(ValidateError::IoError(never)) => match never {},
    }
}

handle("haruki".into()) は Ok("haruki (10 bytes to spare)") を返し、空文字列なら Err("username is empty") を返します。チェックが失敗すると、対象はエラーとともに破棄されます。

純粋なチェック: PureVerifier

スラッグの形式、値の範囲、チェックサムのように、対象以外を何も読まないルールがあります。こうしたルールはコンテキストを必要とせず、判定に失敗することもなく、合成するものもありません。その場合は PureVerifier<T, P, B> を使えます。これはチェックそのものを関数ポインタ fn(&B) -> Result<P, ValidateError<Infallible>> として表したものです。B は対象の型 T の借用形 (T: Borrow<B>) で、省略すると T になります。PureVerifier::new は const fn なので、検証器は証拠の型の隣に static (または const) として一度だけ宣言でき、証明は普通の同期呼び出しになります。

src/validation/slug.rs
use std::convert::Infallible;
use wakuwaku::proof::{DeniedReason, Proven, PureVerifier, ValidateError};

pub static NOT_A_SLUG: DeniedReason = DeniedReason("not a lowercase slug");

/// 「文字列が URL スラッグである」。非公開フィールドにより、このモジュールだけが作れる
pub struct Slug(());

fn is_slug(s: &str) -> Result<Slug, ValidateError<Infallible>> {
    let valid = !s.is_empty()
        && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
    if valid { Ok(Slug(())) } else { Err(ValidateError::Deny(NOT_A_SLUG)) }
}

/// `String` を所有し、`&str` として検査する
pub static SLUG: PureVerifier<String, Slug, str> = PureVerifier::new(is_slug);

/// `SLUG` を通った文字列でしか呼べない
pub fn publish(slug: Proven<String, Slug>) -> String {
    format!("/posts/{}", slug.subject())
}

pub fn handle(input: String) -> Result<String, &'static str> {
    match SLUG.prove(input) {
        Ok(slug) => Ok(publish(slug)),
        Err(ValidateError::Deny(reason)) => Err(reason.0),
        // I/O エラーの型は `Infallible`。純粋なチェックは拒否しかできない
        Err(ValidateError::IoError(never)) => match never {},
    }
}

結果は普通の Proven<String, Slug> です。これを要求するコードには、どちらの方法で得られたかは区別できず、区別する必要もありません。

借用を通した検査。 3 つ目のパラメーターにより、関数は対象を借用形で受け取れます。SLUG は String を所有し、&str として検査します。Vec<u8> の検証器なら &[u8] を検査できます。同じ is_slug は PureVerifier<Box<str>, Slug, str> にも使えます。B を省略すると T になり、関数は &T を受け取ります。証明の健全性は T の Borrow<B> に依存します。関数が P を判定するのは借用されたビューについてであり、そのビューが対象を表すと言えるのは、借用形が所有された値と同じように振る舞うという Borrow の契約があるからです。標準ライブラリの実装はこの契約を守っています。別のビューを返すような手書きの Borrow では、P はそのビューについての証明になってしまいます。

誰が検証器を書けるか。 関数は Slug を返さなければならないので、Slug を構築できるコードだけがその検証器を書けます。Slug に非公開フィールドがあるのはそのためです。述語の意味はただ一つの impl Predicate<T> で決まりますが、同じ証拠の型を対象とする検証器はいくつでも書けます。pub struct NonEmpty; のようなユニット構造体だと、誰でも PureVerifier::new(|_| Ok(NonEmpty)) と書いて何でも証明できてしまいます。非公開フィールドがあれば、Slug の証明はすべてそれを定義するモジュール、つまりルールの意味が置かれている場所で書かれることになります。

使い分け。

Predicate + provePureVerifier
読むもの対象と ValidateContext (設定、データベースハンドルなど)対象だけ
結果成立、Deny、IoError成立、Deny
呼び出しasync。CheckOrder を受け取る同期。コンストラクタや TryFrom の実装の中でも使える
And / Or や 導出の法則 との合成できるできない (P は Predicate を実装しなくてよい)

ルールが値そのものの局所的な性質で、コンテキスト型も .await もなしに値を作る場所で検査したいときは PureVerifier を選んでください。ルールに設定や I/O が必要なとき、またはより大きなルールの一部になるときは述語を使います。検証器で得た証明も、述語を必要としない Proven::from_both、from_a、from_b を通して他の証明と組み合わせられます。

3 つの結果

チェックの結果は必ず次の 3 つのうちどれか 1 つです。

結果意味典型的な扱い
Ok(evidence)命題が成り立つ。prove は Proven<T, P> を返す。証明を持って処理を続ける。
Err(ValidateError::Deny(reason))命題は判定され、偽だった。入力を拒否する (INVALID_ARGUMENT、ALREADY_EXISTS など)。
Err(ValidateError::IoError(e))命題を判定できなかった。例: データベースに到達できない。不正な入力ではなく、サーバー側の障害として扱う (UNAVAILABLE)。

I/O エラーは拒否ではありません

IoError は対象が正しいかどうかについて何も語りません。これをクライアントに「不正な入力」として返すと、障害の責任をユーザーに押し付けることになります。2 つのアームは分けて扱ってください。tonic::Status への変換例は サービスでの証明の利用 にあります。

標準のエラーとして

ValidateError<E> は、E が Debug + Display であれば (Infallible も含みます) std::error::Error と Display を実装します。Deny は理由のテキストとして、IoError は could not decide the proposition: に続けて I/O エラーとして表示されます。最初の失敗で止まるバッチジョブのように、呼び出し側が 2 つを区別する必要がない場合は、? で Box<dyn Error> や anyhow::Error に伝播できます。

src/validation.rs
use std::error::Error;

/// 不正な名前が 1 つでもあればインポートを中断する
pub async fn import(names: Vec<String>) -> Result<Vec<String>, Box<dyn Error>> {
    let limits = Limits { max_len: 16 };
    let mut registered = Vec::new();
    for name in names {
        let name = prove::<_, ValidUsername>(name, &limits, CheckOrder::Sequential).await?;
        registered.push(register(name));
    }
    Ok(registered)
}

import(vec![String::new()]) は、username is empty と表示されるエラーで失敗します。

拒否の理由

DeniedReason は &'static str を包んだ型で、等価性は文字列の内容ではなく ポインタの同一性 で判定されます。同じテキストでもアドレスが異なれば等しくならず、文字列リテラルや const への参照が同じアドレスを共有する保証もありません。理由はそれぞれ一度だけ static として宣言し、その static と比較してください。

pub static TAKEN: DeniedReason = DeniedReason("username is already taken");

// 後でエラーを変換するとき
if reason == TAKEN { /* ... */ }

DeniedReason は Display を実装しており、そのテキストを書き出すので、format!("rejected: {reason}") のように使えます。reason.0 は &'static str そのもので、&'static str を受け取る API に渡すときに使います。

証拠

述語の型がそのまま証拠になります。Proven::evidence() はそれを 参照で 返します。

  • NonEmpty は何も持ちません。証明を持っていること自体が情報です。
  • ShortEnough { spare } は残りの余裕を記録しているので、register は長さを測り直さずに short_enough.spare を読めます。
  • I/O を行う述語は検索したデータベースの行を持てるので (I/O を行う述語 を参照)、後続のコードで同じクエリを繰り返す必要がありません。

Proven、And、Or の中では、各証拠は Evidence<P> に包まれています。これは &P に Deref でき、値として取り出すことはできず、P が Clone でも Clone ではありません。証拠を複製するのはクレートが提供するブール代数の法則のうち、結果に同じ証明を 2 回含むものだけで、それらは P: Clone を要求します (証明の導出 を参照)。

対象も Proven::subject() を通して &T としてしか公開されないため、チェック後に変更することはできません (内部可変性を除く)。対象を値で返すメソッドはないので、所有権が必要な場合はクローンしてください。

Proven<T, P> は T と P が Debug なら Debug を実装します (Evidence、And、Or も同様です)。そのため、対象と述語の型が Debug を実装していれば、証明を持つ構造体にも #[derive(Debug)] を付けられます。

証明はスナップショットです

対象も証拠も、チェックした瞬間の状態を表しています。「このユーザー名は空いている」のように外部の状態に依存する述語では、その状態がすでに変わっている可能性があります。最終的な防御としてデータベース自身の制約 (一意インデックスなど) を残してください。

述語そのものを受け取らない

証拠の型を直接要求したくなるかもしれません。

// これは何も証明しない
fn register(name: String, _proof: NonEmpty) { /* ... */ }

NonEmpty は誰でも書けますし、Predicate::check を直接呼べばどの対象とも結び付いていない証拠が得られます。シグネチャでは常に Proven<T, P> を受け取り、P 単体は受け取らないでください。

Proven を要求するのが安全なのは、フィールドが private だからです。クレートの外で Proven を得る方法は prove、PureVerifier、または既存の証明から Proven::from_a、from_b、from_both、into_a、into_b、derive を通すことだけです。近道はどちらもコンパイルできません。

let input = String::from("haruki");
register(input); // error[E0308]: mismatched types
                 // expected `Proven<String, And<NonEmpty, ShortEnough>>`, found `String`

let fake = Proven { subject: String::new(), evidence: todo!() };
// error[E0451]: fields `subject` and `evidence` of struct `Proven` are private

次のステップ

目次