本文へスキップ

iOSビット集合の直列化を検証:ビット順、パディング、長さの境界

iOSビット集合の直列化を検証:ビット順、パディング、長さの境界

チームで「既読項目」をビット集合に圧縮し、アジア太平洋地域のメンバーが書き込んだデータを北米のメンバーが読み取る。ローカルでの往復テストは常に通っていたのに、クライアントのバージョンを変えると、1番目と8番目のフラグが入れ替わっていた。この種の問題は、ネットワークではなく、バイナリ形式の取り決めが曖昧なことに起因する場合が多い。クラウドMacで検証を定期的なタスクにすれば、画面が正常に見えるかどうかだけでなく、変更のたびに同じバイト列と照合できる。

まず通信形式を定義し、メモリ配置をそのまま使わない

この記事では、業務ロジックから独立した参照形式を実装する。先頭の4バイトはビッグエンディアンの符号なし整数でビット数を表し、その直後にビット集合のペイロードを置く。ビットのインデックスはゼロ始まりで、各バイト内では下位ビットから順に割り当てる。したがって、インデックス0は最下位ビット、インデックス8は2バイト目の最下位ビットに対応する。

ペイロードのバイト数は、「ビット数を8で割った商」に「余りがゼロでなければ1」を加えて求める。ビット数がゼロならヘッダーだけとなる。末尾バイトの未使用の上位ビットは必ずゼロとし、余分なバイトがある入力も拒否する。この形式にはバージョンフィールドがない。既存のプロトコルに正式に組み込む際は、外側のプロトコルでバージョンを明示し、古いデータ形式をそのまま置き換えてはならない。

往復テストで一致することは、エンコーダーとデコーダーの解釈が一致していることしか示さない。両方が同じようにビット順を逆にしていれば、テストは通ってしまう。手計算したバイト列のテストベクトルが別途必要になる。

構造体のメモリ、マシンワード、ネイティブ整数のバッファを直接直列化してはいけない。そうすると、アラインメント、エンディアン、実装上の都合が永続化形式に入り込んでしまう。

入力サイズに上限を設けた参照実装を書く

クラウドMacの独立したディレクトリに BitsetCheck.swift として保存し、開発用ツールチェーンに含まれるSwiftで実行する。以下では上限を100万ビットに設定している。これはサンプルの防御策であり、ファイル形式そのものの理論上の限界ではない。デコードの入口で上限と入力全体の長さを確認してから結果の配列を作成し、信頼できないヘッダーに基づく大量のメモリ確保を避ける。

import Foundation

enum FormatError: Error {
    case invalid
}

let maxBits = 1_000_000

func encode(_ bits: [Bool]) throws -> [UInt8] {
    guard bits.count <= maxBits else {
        throw FormatError.invalid
    }
    let count = UInt32(bits.count)
    var bytes = [
        UInt8(truncatingIfNeeded: count >> 24),
        UInt8(truncatingIfNeeded: count >> 16),
        UInt8(truncatingIfNeeded: count >> 8),
        UInt8(truncatingIfNeeded: count)
    ]
    let size = bits.count / 8 + (bits.count % 8 == 0 ? 0 : 1)
    bytes += Array(repeating: UInt8(0), count: size)
    for index in bits.indices where bits[index] {
        bytes[4 + index / 8] |= UInt8(1) << (index % 8)
    }
    return bytes
}

func decode(_ bytes: [UInt8]) throws -> [Bool] {
    guard bytes.count >= 4 else {
        throw FormatError.invalid
    }
    let raw = bytes.prefix(4).reduce(UInt32(0)) {
        ($0 << 8) | UInt32($1)
    }
    guard raw <= UInt32(maxBits) else {
        throw FormatError.invalid
    }
    let count = Int(raw)
    let size = count / 8 + (count % 8 == 0 ? 0 : 1)
    guard bytes.count == 4 + size else {
        throw FormatError.invalid
    }
    let remainder = count % 8
    if remainder != 0 {
        let allowed = UInt8((1 << remainder) - 1)
        guard bytes[bytes.count - 1] & ~allowed == 0 else {
            throw FormatError.invalid
        }
    }
    return (0..<count).map {
        bytes[4 + $0 / 8] & (UInt8(1) << ($0 % 8)) != 0
    }
}

func require(_ condition: Bool) {
    if !condition {
        fatalError("Bitset check failed")
    }
}

func mustReject(_ bytes: [UInt8]) {
    do {
        _ = try decode(bytes)
    } catch {
        return
    }
    fatalError("Invalid input accepted")
}

let expected: [UInt8] = [0, 0, 0, 9, 0x81, 0x01]
var sample = Array(repeating: false, count: 9)
for index in [0, 7, 8] {
    sample[index] = true
}
require(try encode(sample) == expected)
require(try decode(expected) == sample)

for count in [0, 1, 7, 8, 9, 63, 64, 65] {
    let bits = (0..<count).map { $0 % 3 == 0 }
    let bytes = try encode(bits)
    require(try decode(bytes) == bits)
}

mustReject([])
mustReject([0, 0, 0, 9, 0x81])
mustReject(expected + [0])
mustReject([0, 0, 0, 1, 0x80])
mustReject([0xFF, 0xFF, 0xFF, 0xFF])

xcrun swift BitsetCheck.swift を実行する。成功した場合は何も出力せずに終了し、アサーションの失敗や未処理のエラーがあれば、プロセスは失敗を示す終了ステータスを返す。プロジェクトへ組み込んだ後は、参照関数を実際のエンコード・デコード処理の呼び出しに置き換え、テストベクトルと不正入力の拒否テストは残す。そうしなければ、検証対象はサンプルのままだ。

固定テストベクトルでエンコードとデコードを個別に検証する

9ビットのうちインデックス0、7、8をオンにした場合、ペイロードは16進数で 81 01 でなければならない。最初のバイトで最下位ビットと最上位ビットの両方を確認し、2バイト目でバイト境界をまたぐ並びを確認する。ヘッダーは、論理的な長さがペイロードの長さではないことを明確にする。

さらに、全ビットがオフ、全ビットがオン、先頭だけオン、末尾だけオンの4種類のサンプルも追加する。8ビットすべてがオンなら FF、9ビットすべてがオンなら FF 01 であり、FF FF にしてはいけない。未使用ビットは「どうせ読み取られない」不要領域ではない。形式を正規化してこそ、バイト列の比較、ダイジェスト計算、キャッシュキーの一貫性を保てる。

固定テストベクトルはバージョン管理に含める。プロトコルを変更するときは、まず期待するバイト列をレビューし、その後で実装を更新する。テスト対象のエンコーダーで期待値ファイルを自動生成して上書きしてはいけない。それでは、テスト対象自身に正解を作らせることになる。

不正入力を独立したテスト対象にする

途中で切れた入力のテストには、ヘッダーが4バイトに満たない場合と、ヘッダーは揃っているがペイロードが1バイト足りない場合を含める。余分なバイトも拒否しなければ、上流のデータ結合ミスを黙って見逃してしまう。ゼロ以外のパディングのテストには、長さ1ビットのヘッダーと 80 を組み合わせる。これにより、ビット順の取り違えと末尾の検証漏れを同時に検出できる。

長さの上限は、アプリケーションが許容する項目数に合わせて定め、アプリケーション層でデータを受信するときにも総バイト数を制限する。このサンプルのデコーダーが受け取る時点では配列はすでに存在するため、ネットワーク層が巨大なレスポンスを先に読み込むことまでは防げない。ストリーミング受信では、累積受信バイト数に対する独自の上限も必要になる。

ヘッダーの長さに従ってメモリを確保してから実際のペイロードを検査してはいけない。また、外部から渡された任意の整数に対し、「長さに7を足して8で割る」方法を使ってはいけない。ここでは先に長さを制限し、その後で除算と剰余を使って計算している。この検査順序も、テストで保証すべき契約の一部だ。

回帰テストには比較可能な証跡を残す

スクリプトをリポジトリのテスト用ディレクトリに配置し、同じコミットにある業務実装を使って実行する。コミットID、xcrun swift --version の出力、失敗したテストベクトルの名前を記録する。ユーザーの実際の既読状態や業務ペイロード全体は記録しない。

エラーには、入力の切り詰め、不正なパディング、長さの上限超過など、種類ごとに固定の名前を付けるとよい。あらゆる失敗を1つのネットワークエラーにまとめないこと。上のコードでエラーを統一しているのは参照実装を短くするためであり、本番の実装では原因を診断できる分類を保つ必要がある。

最後に、意図的に不具合を入れてテストの検出能力を確かめる。バイト内のビット順を逆にして固定テストベクトルが失敗することを確認し、末尾の検査を外して不正なパディングをテストが検出することを確認する。これらの不具合で実際にタスクが失敗して初めて、クラウドでの回帰テストは、単に往復テストの成功を繰り返すのではなく、形式の境界条件を守っていると言える。

よくある質問

エンコードとデコードの往復テストだけでは不十分ですか?

両方が同じ誤ったビット順を使うと、往復テストは成功します。手計算した固定バイト列で、実際の形式を独立して確認する必要があります。

最後のバイトで使わないビットはどう扱いますか?

この形式では未使用の上位ビットをゼロにします。デコーダーはゼロ以外のパディングを拒否し、同じ集合の表現が一意になるようにします。

プロジェクトのペースに合わせてMac miniをレンタル

専有物理ノードを選び、日・週・月・四半期単位で利用できます。繁忙期には追加で契約でき、期間終了後は自動更新されません。実際の利用可能状況はコンソールでご確認ください。

次のビルドに使えるマシンを用意

まずスペックと利用期間を確認し、プロジェクトに合った専有物理ノードをお選びください。