Как проверить целостность gzip-ответов и предел распаковки в iOS

Как проверить целостность gzip-ответов и предел распаковки в iOS

Когда iOS-приложение получает сжатый JSON, в перехваченном трафике ответ может занимать всего несколько десятков КБ, а данные, передаваемые парсеру, — гораздо больше. Чтобы выяснить, что именно принимает приложение, не нужно ждать сбоя рабочего API: запустите на облачном Mac тестовый сервер, доступный только с этого компьютера, и обратитесь к нему из симулятора iOS на том же Mac. Ниже три варианта ответа — корректный, с повреждённой контрольной суммой и с большим объёмом данных после распаковки — помогут проверить, какие данные возвращает URLSession и какой предел приложение устанавливает перед разбором.

Различайте размер при передаче и размер данных приложения

Gzip сжимает тело ответа при передаче. Content-Length может указывать число переданных сжатых байтов, тогда как Data, полученные кодом приложения, могут содержать уже распакованное тело. Ограничение только на первый показатель не защитит от большого расхода памяти, если после распаковки ответ состоит из повторяющихся символов или очень похожих фрагментов JSON. В этом примере предел для распакованного ответа составляет 256 KiB. Это тестовый порог, а не универсальное значение для всех iOS API; для своего API выберите предел по размерам реальных ответов.

При тестировании также нужно различать «сетевой запрос выполнен» и «тело ответа пригодно к использованию». Правильный код состояния и отсутствие тайм-аута не заменяют проверку содержимого. В частности, если сжатый поток повреждён, не стоит заранее рассчитывать на какой-то один вид системной ошибки. Критерий успешной проверки — приложение не принимает повреждённые данные за полноценный результат.

Проверяйте данные, которые использует приложение, а не только заголовки передачи или размер сжатого ответа в сетевой панели.

Создайте три локальных gzip-ответа

Сохраните скрипт ниже в /tmp/gzip_fixture.py и выполните на облачном Mac команду python3 /tmp/gzip_fixture.py. Сервер слушает только 127.0.0.1:8765 и недоступен из публичной сети. Путь /ok возвращает короткое тело, /broken — ответ с изменённой контрольной суммой в конце gzip-потока, а /large — хорошо сжимаемое тело размером 1 MiB после распаковки. Все три пути передают сжатые данные, а длина в заголовке соответствует размеру сжатого ответа.

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 указывает на этот компьютер. Для физического устройства адрес сервера и контроль доступа придётся спроектировать заново. Тестовому проекту нужно разрешить локальные 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 может возвращать очень большие ответы, в архитектуре приложения также понадобятся приём по частям, временное хранение на диске или ограничение на стороне сервера. Одной этой проверки недостаточно.

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, декодируйте его после проверки размера и сверяйте обязательные поля. Не считайте успешный разбор доказательством того, что содержимое пришло от ожидаемого API.

Зафиксируйте воспроизводимые проверки перед завершением

Перед отправкой этих тестов проверьте пять пунктов: сервер слушает только локальный адрес; тесты явно запускаются в симуляторе на том же Mac; корректный ответ сравнивается с полным ожидаемым телом; повреждённый ответ не принимается за полное содержимое; предел размера применяется к Data, которые URLSession передаёт приложению. Затем остановите временный сервер, убедитесь, что порт 8765 больше не прослушивается, и проверьте, что исключение для локального HTTP из тестового target не попало в релизную сборку.

Этот набор тестовых ответов намеренно невелик. Если впоследствии понадобится проверить пустое тело, ошибочный код состояния или другие кодировки, добавьте для каждого случая отдельный путь и отдельное утверждение, а не объединяйте несколько сбоев в одном ответе, который время от времени меняется. Фиксированные входные данные, явный предел и проверка байтов, которые действительно использует приложение, надёжнее выводов только по размеру сжатого ответа в перехваченном трафике.

Часто задаваемые вопросы

Достаточно ли проверять Content-Length?

Нет. Это длина переданного сжатого тела; ограничивать нужно размер Data, который приложение получило после распаковки.

Обязан ли URLSession выбросить ошибку при неверной контрольной сумме?

Тесту не следует зависеть от конкретной формы сбоя. Важно, чтобы приложение не приняло повреждённый ответ за полный: допустимы ошибка или провал проверки данных.

Хотите проверить на практике?

Арендуйте выделенный Mac mini и сразу запускайте сборки

Выберите одну из двух конфигураций физических Mac на M4, срок аренды и локацию. Доступность проверяйте в консоли в реальном времени.

Арендовать Mac mini