クラウドMacでiOSの16進数変換契約を検証する
Windowsで業務コードを実装し、iOSプロジェクトをクラウドMacに移してビルドする際は、インターフェースの16進数フィールドもコードと併せて検証する必要があります。デバイスのメッセージ、ダイジェスト、バイナリ識別子では、この表記がよく使われます。見た目が正しい文字列でも、デコードしたバイト列が正しいとは限りません。特に、不正な文字の読み飛ばし、先頭ゼロの欠落、不完全な入力の自動補完によって別のデータに変わることを防ぐ必要があります。
この記事では、単独で実行できるSwiftスクリプトでバイト列の変換契約を定め、その検証をプロジェクトのテストに組み込む方法を説明します。ネットワークサービスにも署名用の情報やファイルにも依存しないため、アーカイブの作成前に実行できます。
入出力の契約を先に決める
デコーダーが受け付けるのはASCIIの 0–9、a–f、A–F だけで、2文字が1バイトを表します。エンコーダーの出力は小文字に統一し、各バイトを必ず2桁で表します。大文字と小文字は同じ値として扱えますが、空白、改行、0x プレフィックス、全角文字はこのプロトコルの対象外です。
低レベルの処理では、空文字列は空のデータを表します。業務上、そのフィールドを空にできない場合は、業務ルールの検証で拒否してください。汎用デコーダーにフィールド固有のルールを暗黙に持たせるべきではありません。入力の上限はデコード後のバイト数で定め、上限値そのものを許容することも明確にします。
厳密なデコードと、人が入力したデータの整形は別の責務です。空白を含むデータの貼り付けに対応する場合は、まずUIのアダプター層で明示的に変換し、その結果を厳密なデコーダーに渡します。機械間のインターフェースでは、既定で入力を自動修正しないでください。
また、16進数はバイト列の表現方法にすぎず、暗号化、完全性の保証、送信元の認証を提供するものではありません。デコードの検証に通ったからといって、そのメッセージを信頼できるとは限りません。
不正入力を拒否するデコードと固定桁数のエンコードを実装する
以下を HexContract.swift として保存します。この実装では、長さと許容バイト数を先に確認してから出力領域を確保します。文字はバイト単位で検査し、変換に失敗した要素を読み飛ばす compactMap は使いません。テストで失敗の原因を区別できるよう、専用のエラー型を定義しています。
import Foundation
enum HexError: Error, Equatable {
case invalidLimit
case oddLength
case tooLarge
case invalidCharacter
}
func nibble(_ c: UInt8) throws -> UInt8 {
switch c {
case 48...57: return c - 48
case 65...70: return c - 65 + 10
case 97...102: return c - 97 + 10
default: throw HexError.invalidCharacter
}
}
func decodeHex(_ text: String, maxBytes: Int) throws -> Data {
guard maxBytes >= 0 else { throw HexError.invalidLimit }
let count = text.utf8.count
guard count % 2 == 0 else { throw HexError.oddLength }
guard count / 2 <= maxBytes else { throw HexError.tooLarge }
var iterator = text.utf8.makeIterator()
var output = Data()
output.reserveCapacity(count / 2)
while let high = iterator.next() {
guard let low = iterator.next() else {
throw HexError.oddLength
}
let a = try nibble(high)
let b = try nibble(low)
output.append((a << 4) | b)
}
return output
}
func encodeHex(_ data: Data) -> String {
let alphabet = Array("0123456789abcdef".utf8)
var output = [UInt8]()
for byte in data {
output.append(alphabet[Int(byte >> 4)])
output.append(alphabet[Int(byte & 15)])
}
return String(decoding: output, as: UTF8.self)
}
ここで数えているのは、ユーザーに見える文字数ではなくUTF-8のバイト数です。ASCII以外の入力は、最終的に必ず拒否されます。エラーの判定順序も契約の一部です。まず上限値が負でないか、次に長さが奇数でないか、その後に容量を超えていないかを確認し、最後に許可された文字かどうかを検査します。
上限の検査では maxBytes * 2 を計算しないため、許容バイト数の乗算によるオーバーフローは発生しません。ただし、ここで制限しているのは出力領域の確保であり、リクエストの受信処理ではありません。インターフェース側では、リクエストボディの読み取り時にもサイズを制限する必要があります。無制限の文字列をいったん受信してから、デコーダーでメモリ消費を抑えようとしてはいけません。
固定テストベクトルと拒否すべき入力を追加する
同じファイルの末尾に、次のテストを追加します。固定テストベクトルでは先頭ゼロと大文字・小文字を確認します。すべての1バイト値を検証するテストでは、標準の書式化処理による結果を独立した基準として使います。これにより、エンコーダーとデコーダーが同じ誤りを持ち、往復変換だけは成功してしまう事態を防ぎます。
func reject(
_ text: String,
limit: Int = 16,
expected: HexError
) {
do {
_ = try decodeHex(text, maxBytes: limit)
preconditionFailure("Expected rejection")
} catch let error as HexError {
precondition(error == expected, "Unexpected error")
} catch {
preconditionFailure("Unexpected error type")
}
}
let fixed = try decodeHex("000aFf", maxBytes: 3)
precondition(fixed == Data([0, 10, 255]))
precondition(encodeHex(fixed) == "000aff")
let empty = try decodeHex("", maxBytes: 0)
precondition(empty.isEmpty)
for value in 0...255 {
let reference = String(format: "%02x", value)
let decoded = try decodeHex(reference, maxBytes: 1)
precondition(decoded == Data([UInt8(value)]))
precondition(encodeHex(Data([UInt8(value)])) == reference)
}
reject("f", expected: .oddLength)
reject("0g", expected: .invalidCharacter)
reject("0x00", expected: .invalidCharacter)
reject("00\n0", expected: .invalidCharacter)
reject("00", expected: .invalidCharacter)
reject("0000", limit: 1, expected: .tooLarge)
reject("", limit: -1, expected: .invalidLimit)
let sequence = Data((0...255).map { UInt8($0) })
let restored = try decodeHex(encodeHex(sequence), maxBytes: 256)
precondition(restored == sequence)
奇数桁の入力は明確に失敗させる必要があります。f を 0f や f0 と推測して補完してはいけません。また、不正な文字が末尾にあるケースも検証し、すでにデコードできた前半部分を返さないことを確認します。セキュリティに関わるフィールドでは、失敗は処理全体の失敗であるべきで、そのまま利用できる部分的な結果を返すべきではありません。
クラウドMacで実行し、プロジェクトに組み込む
まず現在の開発ツールが利用できることを確認し、スクリプトを実行します。
xcrun --find swift
xcrun swift HexContract.swift
成功した場合、スクリプトは何も出力せず、終了コードはゼロになります。アサーションの失敗や未処理のエラーが発生すると、ゼロ以外の終了コードになります。このスクリプトを、チェックが無効になる最適化モードで実行してはいけません。パイプラインにも、終了コードを無視する処理を追加しないでください。
単独のスクリプトが検証するのは、現在のmacOS向けSwift実装です。正式に組み込む際は、エンコーダーとデコーダーをアプリの共有モジュールに配置し、上記のテストベクトルをXCTestに移します。チェックを XCTAssertEqual と XCTAssertThrowsError に置き換え、既存のiOSテストターゲットで実行してください。こうすることで、ターゲットへの所属、条件付きコンパイル、実際の呼び出し経路に関する問題も検出できます。
すでに実装がある場合は、テスト専用のコピーを別途保守せず、テストから本番用のモジュールを直接インポートしてください。そうしないと、スクリプトは成功しても、アプリでは古いデコーダーを使い続けている可能性があります。
境界条件のチェックリストで回帰検証を締めくくる
マージ前に、ゼロ値のバイトが 00 にエンコードされるか、空入力がフィールドのルールに合っているか、上限ちょうどの容量が受け付けられるか、上限を1バイト超える入力が拒否されるか、不正入力に対してデータを一切返さないかを順に確認します。ランダムデータだけに頼らず、結果が確定している長いシーケンスも追加してください。
大文字と小文字が混在する入力は受け付けてもかまいませんが、元の文字列をそのまま一意キーにしてはいけません。aa と AA は同じバイトを表します。正規化して保存する場合は、まず厳密にデコードしてから、統一された形式でエンコードします。検証前の文字列の大文字・小文字変換を、バイトの検査の代わりにしないでください。
最後に残す記録は、ツールチェーンのバージョン、失敗したサンプルの名前、テストの終了結果で十分です。実際のデバイスメッセージやトークンを扱う場合は、入力全体をログに書き出さず、固定の合成テストベクトルで再現します。検証の目的は、あらゆる文字列を変換できるようにすることではありません。正当なバイト列を正確に往復変換し、許容範囲を外れた入力を明確に拒否することです。
よくある質問
空白の削除や奇数桁のゼロ埋めは必要ですか?
機械向けのインターフェースでは拒否します。手入力画面で補正を認める場合も、厳密なデコーダーとは別の変換層で処理します。
エンコード後のデコードだけで十分ですか?
不十分です。両方に同じ不具合がある可能性があります。固定ベクトル、全単一バイト、不正入力、先頭ゼロも検証します。
ビルド作業に合わせて、専用Macをレンタル。
まず最寄りのロケーションを選び、プロジェクトに必要なメモリ、ストレージ、利用期間を確認しましょう。1台の物理マシンを専有でき、仮想マシンではありません。実際の利用状況はコントロールパネルでリアルタイムに確認できます。