クラウド Mac で検証する iOS バイナリ通信の境界条件
リモートビルドは成功したのに、デバイスが受信したバイナリパケットでアプリがクラッシュする。そんなとき、まずネットワークのせいにするのは早計です。長さフィールドの値が実際の内容より大きい、読み取り位置のアライメントが合っていない、ビッグエンディアンの整数をホストのバイト順で解釈している、といった原因が考えられます。VpsKeep のクラウド Mac なら、まずコマンドラインの Swift でこれらの入力を固定して検証し、同じサンプルを iOS のユニットテストに組み込めます。合格条件は、不正なパケットを拒否することです。どんな入力に対してもデータを返すことではありません。
まずフレーム形式と制約を明確にする
この記事では、最小限のプロトコルを使います。先頭の 1 バイトがバージョン、続く 4 バイトがビッグエンディアンの符号なし整数で表したペイロード長、その後が生のペイロードです。受け付けるバージョンは 1 のみ。長さはゼロを許容し、上限は 1 MiB とします。1 回のデコードで入力全体を消費する必要があり、末尾に余分なバイトがある場合もエラーとします。
この単一フレームの契約は、ストリーム転送の契約とは別です。ネットワークからの 1 回の読み取りで、フレームの半分だけが届くことも、2 フレーム分が届くこともあります。データの蓄積とフレームの切り出しは受信層の責務です。パーサーには、境界が確定した 1 フレームだけを渡します。不完全なフレームによるエラーを、そのまま接続の異常とみなしてはいけません。
長さフィールドは信頼できない入力です。スライスの切り出しやメモリ確保の前に、上限と残りのバイト数を確認してください。相手が宣言した長さをそのまま使ってバッファーを作成してはいけません。
まず xcrun swift --version を実行し、ツールチェーンの出力を保存します。サンプルが依存するのは Foundation だけで、シミュレーターや署名の設定は不要です。
ポインターへの暗黙の前提を明示的なカーソルに置き換える
次のコードを packet-check.swift として保存します。リーダーは内部でバイト配列を使い、読み取りが成功した場合だけカーソルを進めます。チェックでは「要求量が残量以下である」ことを確認し、信頼できない 2 つの値を加算してから比較する方法を避けています。
import Foundation
enum PacketError: Error {
case truncated, oversized, version, trailing
}
struct Reader {
let bytes: [UInt8]
private(set) var offset = 0
mutating func take(_ count: Int) throws -> [UInt8] {
guard count >= 0, count <= bytes.count - offset else {
throw PacketError.truncated
}
let start = offset
offset += count
return Array(bytes[start..<offset])
}
mutating func u32BE() throws -> UInt32 {
let b = try take(4)
return b.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
}
}
func decode(_ data: Data) throws -> [UInt8] {
let limit = 1_048_576
guard data.count <= limit + 5 else {
throw PacketError.oversized
}
var reader = Reader(bytes: Array(data))
guard try reader.take(1)[0] == 1 else {
throw PacketError.version
}
let declared = try reader.u32BE()
guard let length = Int(exactly: declared), length <= limit else {
throw PacketError.oversized
}
let payload = try reader.take(length)
guard reader.offset == reader.bytes.count else {
throw PacketError.trailing
}
return payload
}
整数を 1 バイトずつ組み立てることで、ビッグエンディアンの順序を明示でき、アドレスのアライメントにも依存しません。長さのチェックを強制アンラップで代用してはいけません。また、エラーを握りつぶして空のペイロードを返すのも避けてください。正しい空フレームと壊れたパケットを区別できなくなります。
配列への変換ではコピーが発生します。ここでは先にフレーム全体のサイズを制限することで、コピーのコストにも上限を設けています。大きなファイルを扱うプロトコルでは、上限を引き上げてフレーム全体のコピーを続けるのではなく、サイズを制限したチャンク単位の読み取りに切り替えるべきです。受信層でも蓄積バッファーのサイズを制限しなければ、パーサーに渡す前にメモリを使い切る可能性があります。
正常なパケットを各バイト位置で切り詰める
次のコードを同じファイルに追記します。固定のペイロードにはゼロバイトと最上位ビットが立ったバイトを含め、テスト対象がテキストだけにならないようにしています。正常なフレームの不完全なプレフィックスをすべて検証します。たった 1 バイト足りない場合でも、クラッシュしたり部分的な結果を返したりせず、必ずエラーをスローしなければなりません。
func expectRejected(_ data: Data) {
do {
_ = try decode(data)
} catch {
return
}
fatalError("Invalid frame accepted")
}
let frame = Data([1, 0, 0, 0, 3, 0, 128, 255])
let payload = try decode(frame)
precondition(payload == [0, 128, 255])
for end in 0..<frame.count {
expectRejected(Data(frame.prefix(end)))
}
let empty = try decode(Data([1, 0, 0, 0, 0]))
precondition(empty.isEmpty)
expectRejected(Data([2, 0, 0, 0, 0]))
expectRejected(Data([1, 255, 255, 255, 255]))
expectRejected(Data([1, 0, 0, 0, 0, 7]))
xcrun swift packet-check.swift を実行します。正常なら何も出力されず、終了コードはゼロになります。不正な入力を誤って受け付けた場合は、プログラムが異常終了します。パイプラインでは終了ステータスを直接確認すればよく、ローカライズされたログを解析する必要はありません。
ここでは、最適化ビルドでチェックを無効にするオプションは使いません。テストターゲットへ移したら、成功時のアサーションを XCTAssertEqual に、拒否を確認するアサーションを XCTAssertThrowsError に置き換え、具体的なエラーの種類も確認します。
長さとバイト順の反例を追加する
切り詰めのテストだけでは、バイト順が正しいことを証明できません。宣言した長さが 256、実際のペイロード長も 256 の固定フレームを追加します。長さフィールドのバイト列は 00 00 01 00 です。正しいパーサーはペイロード全体を返す必要があります。誤ってリトルエンディアンを使った実装は、長さを別の値として解釈します。
上限ちょうどの値と、上限に 1 を加えた値もテストします。前者は完全なペイロードがあれば成功し、後者はヘッダーだけでも直ちに拒否されるべきです。さらに、宣言した長さが実際の内容より小さい入力を追加し、厳密な単一フレームモードが末尾のデータを黙って無視しないことを確認します。
テスト内のすべてのヘッダーを、本番用エンコーダーで生成するのは避けてください。エンコーダーとデコーダーが同じ不具合を共有していると、ラウンドトリップテストは通ってしまいます。少なくとも数組は手作業で確認したバイトサンプルを残し、各フィールドの意味を記載します。
エラー時の動作を受け入れ基準に含める
マージ前に、次の 4 点を確認します。完全で正常なフレームはすべて元のバイト列を復元すること、切り詰めたプレフィックスはすべて明確に失敗すること、未知のバージョンと上限を超える長さを拒否すること、そして末尾のデータで単一フレームの境界をすり抜けられないことです。テスト記録にはコミット識別子、ツールチェーンのバージョン、サンプル名を含め、実際のユーザーのペイロードは記録しません。
失敗が見つかったら、まず元のバイト列を保存し、その後で最短の反例まで縮小します。テストを通すために、先に上限を変更してはいけません。要件に圧縮やネストしたフィールドが追加される場合は、展開後のサイズとネストの深さにも、それぞれ制限を設ける必要があります。外側の長さ制限は、これらのルールの代わりにはなりません。
この受け入れ基準で扱うのはフレーム構造だけであり、ペイロードが業務上妥当かどうかは判定しません。構造の解析、テキストのデコード、業務上の検証を分離しておけば、後でフィールドを追加したときに、どの層で拒否動作が変わったのかを特定できます。
よくある質問
Data のポインターから UInt32 を直接読まないのはなぜですか?
任意の位置が UInt32 のアラインメント条件を満たすとは限らず、バイト順も別途変換が必要です。各バイトをシフトして組み立てれば、プロトコルの順序を明示できます。
空のペイロードはエラーにすべきですか?
プロトコルの仕様で決めます。本文では長さゼロを許可します。空を認めない場合は明示的な検証と固定テストを追加してください。
全切断位置のテストでファジングを置き換えられますか?
置き換えられません。対象は特定の正常フレームの接頭部分だけです。フィールドの組み合わせや複数フレームの状態には追加検証が必要です。
専用クラウドMacで作業を実行
Mac miniを1台まるごとレンタルできます。仮想マシンではありません。日・週・月・四半期から利用期間を選び、機種とノードを確認してから注文してください。実際の空き状況はコントロールパネルにリアルタイムで表示されます。