iOS の gzip 応答で整合性と展開後のサイズを検証する

iOS の gzip 応答で整合性と展開後のサイズを検証する

iOS アプリが圧縮された JSON を受け取るとき、パケットキャプチャ上では数十 KB でも、パーサーに渡されるデータははるかに大きくなることがあります。アプリが実際に何を受け入れるかを調べるために、本番 API で障害が起きるのを待つ必要はありません。クラウド Mac 上でローカルホストだけを待ち受けるテストサーバーを起動し、同じ Mac 上の iOS シミュレータからアクセスします。ここでは正常な応答、チェックサムを破損させた応答、展開後のサイズが大きい応答の 3 種類を使い、URLSession が返すデータと、アプリが解析前に設ける制限を確認します。

転送時のサイズとアプリが受け取るデータを区別する

gzip が圧縮するのは転送されるレスポンスボディです。Content-Length は圧縮後に送信するバイト数を示す場合があります。一方、アプリの処理が受け取る Data は、すでに展開された本文かもしれません。転送時のサイズだけに上限を設けても、同じ文字やよく似た JSON 断片が展開後に大量のメモリを占めることは防げません。この記事では、サンプル API の展開後サイズの上限を 256 KiB とします。これはテスト用のしきい値であり、すべての iOS API に共通する値ではありません。実際のレスポンスサイズに合わせて設定してください。

テストでは、「ネットワーク呼び出しが成功したこと」と「本文を利用できること」も区別します。ステータスコードが正しく、リクエストがタイムアウトしなかったとしても、内容の検証は省けません。特に圧縮ストリームが破損している場合、システムが必ず特定のエラーを返すとは想定しないでください。確認すべきなのは、アプリが最終的に破損したデータを完全な業務データとして扱わないことです。

テストのアサーションは、転送ヘッダーやネットワークパネルに表示される圧縮後のサイズだけでなく、アプリが実際に使用するデータに対して行います。

ローカルで 3 種類の gzip 応答を用意する

次のスクリプトを /tmp/gzip_fixture.py に保存し、クラウド Mac で python3 /tmp/gzip_fixture.py を実行します。サーバーは 127.0.0.1:8765 のみで待ち受け、インターネットには公開しません。/ok は短い本文を返し、/broken は gzip の末尾にあるチェックサムを書き換え、/large は圧縮率が高く、展開後に 1 MiB となる本文を送信します。3 つのパスはいずれも実際に圧縮したデータを送り、レスポンスヘッダーには圧縮後の長さを設定します。

import gzip
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

SMALL = b'{"status":"ready"}'
LARGE = b"A" * 1_048_576

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path not in ("/ok", "/broken", "/large"):
            self.send_error(404)
            return

        payload = LARGE if self.path == "/large" else SMALL
        wire = bytearray(gzip.compress(payload))
        if self.path == "/broken":
            wire[-8] ^= 0xFF

        self.send_response(200)
        self.send_header("Content-Type", "application/octet-stream")
        self.send_header("Content-Encoding", "gzip")
        self.send_header("Content-Length", str(len(wire)))
        self.end_headers()
        self.wfile.write(wire)

ThreadingHTTPServer(("127.0.0.1", 8765), Handler).serve_forever()

ここでは TCP 接続を無作為に途中で切るのではなく、意図的にチェックサムを変更しています。これにより、「転送は完了したが内容が破損している場合」と「接続が中断された場合」を分けて観察できます。このスクリプトは固定のテスト入力を提供するもので、実際の API の圧縮率やスループットを再現するものではありません。テスト前に lsof -nP -iTCP:8765 -sTCP:LISTEN を実行し、待ち受けているのが今回起動したプロセスであることを確認してください。ポートが使用中なら古いサーバーを先に停止し、別のプログラムにテストが誤って接続しないようにします。

シミュレータで実際の本文を確認する

次の XCTest の例を、プロジェクトに既存のテスト target に追加します。この例を使えるのは、サーバーと同じ Mac 上で動くシミュレータに限られます。ここでの 127.0.0.1 はその Mac を指します。実機を使う場合は、サーバーのアドレスとアクセス制御を設計し直す必要があります。テストプロジェクトでは、この開発環境からのローカル HTTP リクエストを許可してください。テストを動かすために、正式ビルドのリモートリクエストに対するセキュリティ設定を緩めてはいけません。

import Foundation
import XCTest

final class GzipResponseTests: XCTestCase {
    private let base = URL(string: "http://127.0.0.1:8765")!

    private func fetch(_ path: String) async throws -> Data {
        let url = base.appendingPathComponent(path)
        let (data, response) = try await URLSession.shared.data(from: url)
        XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
        return data
    }

    func testValidBody() async throws {
        let data = try await fetch("ok")
        XCTAssertEqual(data, Data(#"{"status":"ready"}"#.utf8))
    }

    func testLargeDecodedBodyExceedsAppLimit() async throws {
        let data = try await fetch("large")
        XCTAssertGreaterThan(data.count, 256 * 1024)
    }

    func testDamagedBodyIsNotAcceptedAsComplete() async {
        do {
            let data = try await fetch("broken")
            XCTAssertNotEqual(
                data,
                Data(#"{"status":"ready"}"#.utf8)
            )
        } catch {
            return
        }
    }
}

最初に正常な応答のテストを実行し、次に大きい本文と破損した本文をテストします。正常な応答で期待するテキストではなく gzip の生バイト列を受け取った場合は、レスポンスヘッダー、テスト環境、リクエストの実装を先に確認してください。アサーションを「データがあれば合格」に変えてはいけません。破損した本文のテストでは、呼び出しがエラーになるか、返されたデータが期待する完全な本文と一致しないかの、どちらかを受け入れます。システムがチェックサムエラーを必ず特定の段階で拒否することを保証するものではなく、業務ロジックでの JSON フィールドの検証に代わるものでもありません。

解析前にサイズを制限する

アプリのコードでは、デコーダーに渡す前に Data.count を確認します。次の例は、レスポンス全体を受信した後に適用するサイズ制限です。大きすぎる本文の解析は防げますが、ダウンロードや展開の過程ですでに発生したメモリ使用は防げません。API が極端に大きいデータを返す可能性があるなら、分割受信、ディスクへの一時保存、サーバー側の制限もアプリの設計に取り入れる必要があります。この 1 行の判定だけに頼らないでください。

enum ResponseError: Error {
    case tooLarge
}

func checkedBody(_ data: Data) throws -> Data {
    guard data.count <= 256 * 1024 else {
        throw ResponseError.tooLarge
    }
    return data
}

大きい本文のテストをさらに変更し、checkedBody(data) が tooLarge をスローすることをアサートします。正常な本文は通過するはずです。これにより、テストの目的は「差を観察すること」から「アプリが実際に制限を適用すること」へ進みます。API が JSON を返す場合は、サイズチェックを通過してから JSON をデコードし、必須フィールドを確認してください。「解析できる」ことを「想定した API から届いた内容である」ことと取り違えないようにします。

終了前に再現可能な確認項目を残す

このテスト一式をコミットする前に、5 点を確認します。サーバーがローカルホストだけで待ち受けていること、テストが同じ Mac 上のシミュレータを明示的に使っていること、正常な応答では本文全体を照合していること、破損した応答が完全な内容として受け入れられないこと、そしてサイズ制限が URLSession からアプリに渡される Data に適用されることです。最後に一時サーバーを停止し、ポート 8765 が待ち受け状態でなくなったことを確認します。テスト target のローカル HTTP 用の例外が正式ビルドに入り込んでいないかも確認してください。

このテスト用サーバーは意図的に小さく保っています。今後、空の本文、エラーステータスコード、異なるエンコーディングを扱う場合は、それぞれに独立したパスとアサーションを追加してください。複数の障害を、挙動がときどき変わる 1 つの応答に詰め込まないようにします。固定された入力を使い、境界を明確にし、アプリが実際に使用するバイト列を確認するほうが、パケットキャプチャ上の圧縮後の長さだけで安全性を判断するより確実です。

よくある質問

Content-Length だけでサイズを制限できますか?

できません。示されるのは転送時の圧縮データの長さです。アプリでは展開後に返された Data のサイズを確認します。

チェックサムが壊れたら URLSession は必ず例外を出しますか?

失敗の形を一つに決めつけないでください。エラーでも本文照合の失敗でも、破損した応答を完全な本文として受け入れないことを確認します。

実機で試してみませんか?

専有 Mac mini をレンタルして、ビルドを実行

M4搭載の物理マシン2種類から選び、用途に合わせて利用期間とロケーションを指定できます。利用状況は管理画面に表示される最新情報をご確認ください。

Mac mini をレンタル