iOS UUID の契約テスト:厳密な解析とバイト順の検証
同じ注文レコードの UUID なのに、API は大文字で返し、ローカルキャッシュは小文字で保存している。それにもかかわらず、重複排除では文字列をそのまま比較している。別の処理経路では UUID をバイナリで保存したところ、読み戻すと先頭の数グループのバイト順が変わってしまう。こうした問題は、シミュレータを起動しなくても、クラウド Mac のビルド前チェックで検出できます。まず固定の Swift テストベクトルで、入力、同一性の比較、シリアライズの契約を固め、iOS プロジェクトでも同じルールを使うようにします。
書式の検証と業務上のポリシーを分ける
UUID は 128 ビットの識別子です。一般的なテキスト表現は、16 進数の文字を 8、4、4、4、12 文字の五つのグループに分けたものです。ハイフンは必ず四つ入りますが、この見た目だけで、ランダムに生成されたバージョン 4 の UUID だとは判断できません。
最初に、大文字を受け付けるか、前後の空白を許容するか、バージョンを制限するか、という三つのルールを明確にします。本記事では大文字と小文字の両方を受け付け、空白、波括弧、ハイフンのない形式、非 ASCII 文字は拒否します。バージョン 4 と対応するバリアントを検査するのは、ランダムな識別子を明示的に要求するフィールドだけです。
「解析できるか」はライブラリの機能であり、「システムに受け入れてよいか」はプロトコルで決めることです。パーサーの寛容な挙動を、そのまま API の契約にしてはいけません。
すべてゼロの UUID についても、扱いを別途決める必要があります。一般的なテキスト形式としては有効でも、自動的に「未設定」の代わりに使ってよいわけではありません。任意項目には、値がないことを明示できる仕組みを優先します。
クラウド Mac で固定テストベクトルを実行する
使用する開発ツールチェーンを選択済みのクラウド Mac を用意し、次の内容を uuid-contract.swift として保存します。このスクリプトが使うのは Foundation だけで、プロジェクト、ネットワーク、乱数には依存しません。ここでの precondition は、テスト失敗時にスクリプトを停止するためのものであり、アプリで外部入力を処理するためのものではありません。
import Foundation
func parseUUID(_ text: String, requireV4: Bool = false) -> UUID? {
let bytes = Array(text.utf8)
guard bytes.count == 36 else { return nil }
let separators: Set<Int> = [8, 13, 18, 23]
for (index, byte) in bytes.enumerated() {
if separators.contains(index) {
guard byte == 45 else { return nil }
} else {
let isHex = (48...57).contains(byte)
|| (65...70).contains(byte)
|| (97...102).contains(byte)
guard isHex else { return nil }
}
}
guard let value = UUID(uuidString: text) else { return nil }
if requireV4 {
let raw = value.uuid
guard raw.6 >> 4 == 4, raw.8 & 0xC0 == 0x80 else {
return nil
}
}
return value
}
let valid = "00112233-4455-4677-8899-aabbccddeeff"
let parsed = parseUUID(valid, requireV4: true)!
precondition(parsed.uuidString.lowercased() == valid)
precondition(parseUUID(valid.uppercased()) == parsed)
let invalid = [
" " + valid,
valid + "\n",
"{" + valid + "}",
valid.replacingOccurrences(of: "-", with: ""),
valid.replacingOccurrences(of: "a", with: "a")
]
for input in invalid {
precondition(parseUUID(input) == nil)
}
let otherVersion = "00112233-4455-5677-8899-aabbccddeeff"
precondition(parseUUID(otherVersion) != nil)
precondition(parseUUID(otherVersion, requireV4: true) == nil)
let wrongVariant = "00112233-4455-4677-0899-aabbccddeeff"
precondition(parseUUID(wrongVariant) != nil)
precondition(parseUUID(wrongVariant, requireV4: true) == nil)
let raw = parsed.uuid
let wire: [UInt8] = [
raw.0, raw.1, raw.2, raw.3,
raw.4, raw.5, raw.6, raw.7,
raw.8, raw.9, raw.10, raw.11,
raw.12, raw.13, raw.14, raw.15
]
precondition(wire == [
0x00, 0x11, 0x22, 0x33,
0x44, 0x55, 0x46, 0x77,
0x88, 0x99, 0xaa, 0xbb,
0xcc, 0xdd, 0xee, 0xff
])
xcrun swift uuid-contract.swift を実行します。成功時は何も出力せず、失敗時はゼロ以外の終了ステータスを返します。パイプラインでは、この終了ステータスを必ず引き継いでください。あわせて xcrun swift --version の出力を記録しておくと、ツールチェーンの更新後に再実行して比較できます。
同一性を比較する経路を一本化する
アプリ内部では、できるだけ API から受け取った生の文字列ではなく、UUID 値を比較します。テキスト形式のデータベースキーとして保存する必要がある場合は、厳密な解析に成功してから uuidString.lowercased() を呼び出し、小文字とハイフンによる一意の表現にそろえます。
解析前にハイフンを削除したり、前後の空白を取り除いたりしてはいけません。このような「修復」は上流のプロトコル違反を隠すだけでなく、本来はどちらも拒否すべき二つの入力を、同じキーに変換してしまう可能性があります。表示層でユーザーの貼り付け入力を受け付ける場合は、明示的な整形ステップを用意してもかまいません。ただし、その処理を API 層でも暗黙に使ってはいけません。
既存データを移行する際は、まず大文字と小文字を正規化した結果、衝突が何件発生するかを確認します。たとえば、UUID の英字の大小だけが異なるレコードが二つあっても、一方をそのまま上書きしてはいけません。業務データとの関連を先に照合し、そのうえで統合するか異常レコードとして隔離し、移行結果を残します。
バイナリの受け渡しではバイト順全体を確認する
上のテストベクトルでは、UUID のテキスト表現に含まれる 16 進数の並びに沿って、16 バイトを列挙しています。この順序は通信プロトコルの一部として定めるべきものであり、実行中のマシンが整数をどのように格納するかに左右されてはいけません。
先頭の 8 バイトをネイティブの整数として読み取り、そのまま書き出すことは避けてください。また、あるインターフェースが識別子を GUID と呼んでいるというだけで、このプロトコルと同じメモリレイアウトだと判断してはいけません。異なる言語間で受け渡す際は、同じ固定テキストから、双方が完全に一致する 16 進バイト列を出力することを確認します。
読み戻しにも独立したサンプルを使う
「自分でエンコードし、自分でデコードする」だけでは不十分です。両方が同じ間違いをしていれば、往復テストは成功してしまいます。受信側では固定の 16 バイトのサンプルを直接使い、復元したテキストが元のテストベクトルと等しいことを検証します。さらに、15 バイトと 17 バイトの入力が必ず拒否され、ゼロ埋めや切り詰めで処理されないこともテストします。
実際の解析経路にチェックを組み込む
このスクリプトが検証するのは、ルールの実装例です。実際に導入する際は、解析関数を共有モジュールに移し、API のデコード、キャッシュの読み書き、インポートツールのすべてから呼び出すようにします。対応するテストベクトルはテストターゲットに配置し、チェックスクリプトは通るのに、アプリでは別の緩い解析経路が使われている、という状態を防ぎます。
テストは四つのグループをそろえます。有効な大文字表記と小文字表記は同じ識別子として扱うこと、不正な文字と空白は拒否すること、バージョン制約は指定したフィールドだけに適用すること、バイナリサンプルは 1 バイトずつ一致することです。どのグループでも戻り値を検証し、「クラッシュしなかった」だけで済ませてはいけません。
失敗ログにはフィールド名、失敗の分類、サンプル番号だけを記録し、実際の業務識別子は出力しません。ツールチェーンの更新、サーバー側のシリアライズライブラリの置き換え、過去データのインポートを行う前には、この契約テストを再実行します。キャッシュと API の間で原因を推測し続けるよりも、テキスト、ポリシー、バイト順のどの層で失敗したかを切り分けるほうが、直接的に原因へたどり着けます。
よくある質問
すべての UUID をバージョン 4 に制限すべきですか?
いいえ。インターフェースがランダムなバージョン 4 識別子を要求する場合だけ制限します。文字形式の検証と許可するバージョンの方針は分離してください。
大文字小文字が違う UUID は別の識別子ですか?
同じ値です。検証後に小文字とハイフンを使う表記へ統一し、それをキーにします。元の表記は診断用にのみ保持します。
次のビルドは、専有のMacで。
仮想マシンではないクラウドMacを選び、既存のXcodeやCIのワークフローをそのまま利用できます。開発プロジェクトの負荷に合わせてマシンを選択してください。実際の利用可否はコンソールに表示される最新情報をご確認ください。