クラウド Mac で iOS の UTF-8 逐次デコードを検証する

クラウド Mac で iOS の UTF-8 逐次デコードを検証する

同じレスポンスでも、ローカルでは問題なく読み込めるのに、クラウド Mac 上のパイプラインでは時折文字化けする。そんなときは、まずデコードの境界を確認します。ネットワークが届けるのはバイトのチャンクであり、完全な文字ではありません。「中」を構成する 3 バイトが、3 回に分かれて届くこともあります。チャンクごとに文字列へ変換すると、毎回エラーにならなくても、正しいテキストが置換文字に変わる可能性があります。ここでは、実際のネットワークでたまたまどこが分割されるかに頼らず、単体の Swift スクリプトでデコーダーの契約を検証します。

まずバイトストリームの契約を定義する

入力は UTF-8 に限定し、出力にはデコードが完了した Unicode スカラー値だけを含めます。空のチャンクでは状態を変更せず、未完成のシーケンスは次のチャンクまで保持し、終了時に残っていればエラーにします。「今はテキストが出力されない」ことを、接続が閉じたと判断する根拠にしてはいけません。

ここでは、不正な入力を厳格に拒否する方針を採用します。単独の継続バイト、過長エンコーディング、サロゲート領域の符号化、Unicode の上限を超えるシーケンスは、いずれも受け付けません。不正な部分を置換文字にする処理は、一部の表示用途には適していますが、プロトコルのフィールド、設定、永続化するレコードに黙って適用すべきではありません。

ネットワークのチャンク境界、文字の境界、アプリケーションのメッセージ境界は、それぞれ別のものです。UTF-8 のデコードに成功しても、1 行や 1 メッセージを最後まで受信したとは限りません。

プロトコルが別のエンコーディングを指定している場合は、入口で拒否するか、明示的な変換層に渡します。複数のエンコーディングを順に試して推測してはいけません。また、この記事の実装ではバイトオーダーマークを自動的に除去しません。許可するかどうかは上位層で判断します。

保持する状態に上限を設けた逐次デコーダーを実装する

次のコードを utf8-contract.swift として保存します。保持するのは現在の未完成の文字だけで、呼び出しをまたいで残るバイト数は最大 3 バイトです。ただし、各呼び出しの出力量は入力チャンクの大きさに依存します。エラーが発生したら、そのインスタンスは破棄し、以後のデータを渡さないでください。

import Foundation

enum DecodeFailure: Error {
    case invalidByte
    case truncated
}

struct StrictUTF8 {
    private var pending: [UInt8] = []
    private var expected = 0

    mutating func push(_ bytes: [UInt8]) throws -> String {
        var output: [UInt8] = []

        for byte in bytes {
            if pending.isEmpty {
                switch byte {
                case 0x00...0x7F:
                    output.append(byte)
                    continue
                case 0xC2...0xDF:
                    expected = 2
                case 0xE0...0xEF:
                    expected = 3
                case 0xF0...0xF4:
                    expected = 4
                default:
                    throw DecodeFailure.invalidByte
                }
                pending.append(byte)
                continue
            }

            var allowed: ClosedRange<UInt8> = 0x80...0xBF
            if pending.count == 1 {
                switch pending[0] {
                case 0xE0: allowed = 0xA0...0xBF
                case 0xED: allowed = 0x80...0x9F
                case 0xF0: allowed = 0x90...0xBF
                case 0xF4: allowed = 0x80...0x8F
                default: break
                }
            }

            guard allowed.contains(byte) else {
                throw DecodeFailure.invalidByte
            }
            pending.append(byte)

            if pending.count == expected {
                output.append(contentsOf: pending)
                pending.removeAll(keepingCapacity: true)
                expected = 0
            }
        }

        guard let text = String(bytes: output, encoding: .utf8) else {
            throw DecodeFailure.invalidByte
        }
        return text
    }

    func finish() throws {
        guard pending.isEmpty else {
            throw DecodeFailure.truncated
        }
    }
}

先頭バイトで長さを決め、2 バイト目で許容範囲を絞り、それ以降は継続バイトの範囲を確認します。これにより、文字列への変換段階まで待たずに、サロゲート領域や上限を超える符号化を検出できます。この実装には自動復旧の仕組みはなく、不正なバイトの後に続く ASCII を、信頼できる新しいメッセージとして扱うこともありません。

リクエストを繰り返すのではなく分割パターンを網羅する

続けて、次のテストを追加します。この短いサンプルでは、隣接する各バイト間で「分割する・しない」の全組み合わせを列挙することで、2 分割のケースを一度だけ確認するよりも網羅的に検証できます。さらに空のチャンクを挿入し、保持中の状態が消えないことも確認します。

let sample = "A中🙂e\u{301}"
let bytes = Array(sample.utf8)

for mask in 0..<(1 << (bytes.count - 1)) {
    var decoder = StrictUTF8()
    var chunk: [UInt8] = []
    var result = ""

    for index in bytes.indices {
        chunk.append(bytes[index])
        let cut = index == bytes.count - 1
            || (mask & (1 << index)) != 0
        if cut {
            result += try decoder.push(chunk)
            result += try decoder.push([])
            chunk.removeAll(keepingCapacity: true)
        }
    }

    try decoder.finish()
    precondition(Array(result.utf8) == bytes)
}

ここでは UTF-8 のバイト列を比較します。正規等価性を考慮した文字列比較によって、内容の変化が見えなくなるのを防ぐためです。また、結合アクセントの存在からも分かるように、完全なスカラー値が必ずしも完全なユーザー可視文字になるわけではありません。デコーダーが先に英字を出力し、その後で結合文字を出力することはあり得ます。UI で書記素単位の操作が必要なら、さらに上位の層で処理します。

全組み合わせの数はサンプル長に対して指数関数的に増えるため、この方法は短い固定テストベクトルにだけ使用します。長いテキストでは、乱数シードを固定して分割パターンを生成し、失敗時のチャンク長の並びを保存します。

拒否すべき入力もアサーションで検証する

正しいテキストが通ることを確認しただけでは、検証は半分です。次のテストベクトルを追加し、単独の継続バイト、過長エンコーディング、サロゲート領域、上限超過、不正な継続バイト、末尾の途切れをそれぞれ確認します。各入力は 1 バイトずつ渡し、チャンクの分け方で不正入力の検出をすり抜けられないことを確かめます。

let invalid: [[UInt8]] = [
    [0x80],
    [0xC0, 0xAF],
    [0xED, 0xA0, 0x80],
    [0xF4, 0x90, 0x80, 0x80],
    [0xE4, 0x41],
    [0xF0, 0x9F, 0x99]
]

for input in invalid {
    var decoder = StrictUTF8()
    var rejected = false

    do {
        for byte in input {
            _ = try decoder.push([byte])
        }
        try decoder.finish()
    } catch {
        rejected = true
    }

    precondition(rejected)
}

有効範囲の境界値も追加すべきです。たとえば、最大の有効なスカラー値に対応する 4 バイトのシーケンスが該当します。文字列に置換文字が含まれるかどうかだけを判定基準にしてはいけません。入力自体に、その文字が正しく含まれている可能性があるためです。厳格なデコードでは、元のバイト列に基づいて判断する必要があります。

パイプラインに組み込み、エラーの境界を保つ

使用する Xcode コマンドラインツールの環境で、次を実行します。

xcrun swift utf8-contract.swift

正常終了すれば合格です。エラーのスローやアサーションの失敗があれば、後続タスクを必ず停止させます。本格的に組み込む際は、デコーダーを共有ソースに置き、テストベクトルをテストターゲットへ移します。単体スクリプトはアルゴリズムの事前検証には向いていますが、実際の iOS ターゲットでのコンパイルと実行の代わりにはなりません。

接続ごとに専用のインスタンスを持たせ、受信順に直列で呼び出します。再接続時は新しいインスタンスを作り、以前の接続で残った文字の一部を、新しいレスポンスの先頭につなげてはいけません。キャンセル時も状態を破棄し、「正常終了」の処理は呼び出しません。

最後に、出力を受け取る側も確認します。チャンクごとの出力は一時バッファーにだけ格納し、アプリケーションのメッセージが完成して検証にも通った時点で、初めて永続化やアクションの実行に進みます。前半をすでにデータベースへ保存した後で、後半から不正な符号化が見つかっても、厳格なデコードだけでは副作用を自動的に取り消せません。エラーログにはバイトオフセット、チャンク長、エラー種別を残せば十分であり、レスポンス全体をデフォルトでアップロードするのは避けます。これで確認できるのは、単に「文字化けしない」ことだけではありません。分割のされ方が変わっても結果が変わらず、エラーが正常な受信として扱われないことまで検証できます。

よくある質問

受信した Data を毎回そのまま文字列に変換できますか。

安全ではありません。文字の途中で分割されると、厳密な変換は失敗し、寛容な変換は置換文字を挿入する場合があります。未完成のバイト列を次の受信まで保持します。

ストリーム終端に残ったバイトは無視できますか。

完全な UTF-8 テキストを要求する場合は無視できません。文字の切り詰めとして終了処理を失敗させます。復旧を許可するなら、別の明示的な規則が必要です。

チーム専用のビルド環境をMac 1台に集約

Apple Silicon M4搭載の専有Mac miniを選び、チーム独自のツールチェーンとRunner設定をそのまま利用できます。日単位・週単位・月単位・四半期単位で契約でき、利用可能状況はコンソールでリアルタイムに確認できます。

ビルドノードをレンタル

ビルド要件に合った構成を選ぶ

まずメモリとストレージを確認し、課金期間とノードを選択してください。