Проверяем границы декодирования бинарных пакетов iOS на облачном Mac
Удалённая сборка прошла успешно, но бинарный пакет, полученный устройством, привёл к аварийному завершению приложения. Не спешите винить сеть: значение поля длины может превышать фактический размер содержимого, адрес чтения может быть невыровненным, а целое число в формате big-endian — интерпретироваться в порядке байтов текущей платформы. На облачном Mac в VpsKeep можно сначала закрепить такие входные данные в проверках на Swift из командной строки, а затем перенести те же образцы в модульные тесты iOS. Критерий приёмки — отклонять некорректные пакеты, а не возвращать данные для любого входа.
Сначала определите формат кадра и ограничения
В статье используется минимальный протокол: первый байт задаёт версию, следующие четыре байта — беззнаковую длину полезной нагрузки в формате big-endian, а оставшаяся часть содержит саму нагрузку в виде сырых байтов. Допускается только версия 1. Длина может быть нулевой, верхний предел — 1 MiB. За один вызов декодер должен обработать весь вход; лишние байты в конце тоже считаются ошибкой.
Контракт разбора одного кадра — не то же самое, что контракт потоковой передачи. Одно чтение из сети может вернуть половину кадра или сразу два кадра. Накопление данных и выделение отдельных кадров — задача принимающего слоя; парсер получает только один кадр с уже определёнными границами. Не считайте ошибку из-за неполного кадра прямым признаком неисправного соединения.
Поле длины — недоверенные входные данные. Прежде чем выделять срез или память, проверьте верхний предел и количество оставшихся байтов. Не создавайте буфер непосредственно по длине, заявленной другой стороной.
Сначала выполните xcrun swift --version и сохраните вывод с версией инструментария. Пример зависит только от Foundation: симулятор и настройка подписи не нужны.
Используйте явный курсор вместо предположений о указателях
Сохраните следующий код в файл packet-check.swift. Внутри считывателя используется массив байтов, а курсор сдвигается только после успешного чтения. Проверка строится по принципу «запрошено не больше, чем осталось»: так не приходится сначала складывать два недоверенных значения, а затем сравнивать результат.
import Foundation
enum PacketError: Error {
case truncated, oversized, version, trailing
}
struct Reader {
let bytes: [UInt8]
private(set) var offset = 0
mutating func take(_ count: Int) throws -> [UInt8] {
guard count >= 0, count <= bytes.count - offset else {
throw PacketError.truncated
}
let start = offset
offset += count
return Array(bytes[start..<offset])
}
mutating func u32BE() throws -> UInt32 {
let b = try take(4)
return b.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
}
}
func decode(_ data: Data) throws -> [UInt8] {
let limit = 1_048_576
guard data.count <= limit + 5 else {
throw PacketError.oversized
}
var reader = Reader(bytes: Array(data))
guard try reader.take(1)[0] == 1 else {
throw PacketError.version
}
let declared = try reader.u32BE()
guard let length = Int(exactly: declared), length <= limit else {
throw PacketError.oversized
}
let payload = try reader.take(length)
guard reader.offset == reader.bytes.count else {
throw PacketError.trailing
}
return payload
}
Побайтовая сборка целого числа явно задаёт порядок big-endian и не зависит от выравнивания адреса. Не заменяйте проверки длины принудительным извлечением optional-значений и тем более не подавляйте ошибки, возвращая пустую нагрузку: иначе корректный пустой кадр станет неотличим от повреждённого пакета.
Преобразование в массив создаёт копию. Здесь размер всего кадра ограничивается заранее, поэтому затраты на копирование тоже ограничены. Для протоколов передачи больших файлов следует использовать чтение ограниченными блоками, а не просто повышать предел и продолжать копировать кадр целиком. Принимающий слой также должен ограничивать размер накопительного буфера, иначе память может закончиться ещё до передачи данных парсеру.
Проверяйте корректный пакет с усечением на каждом байте
Добавьте следующий код в тот же файл. Фиксированная нагрузка содержит нулевой байт и байты с установленным старшим битом, чтобы тесты не ограничивались текстовыми данными. Переберите все неполные префиксы корректного кадра: даже при нехватке одного байта декодер обязан выбросить ошибку, а не аварийно завершиться или вернуть частичный результат.
func expectRejected(_ data: Data) {
do {
_ = try decode(data)
} catch {
return
}
fatalError("Invalid frame accepted")
}
let frame = Data([1, 0, 0, 0, 3, 0, 128, 255])
let payload = try decode(frame)
precondition(payload == [0, 128, 255])
for end in 0..<frame.count {
expectRejected(Data(frame.prefix(end)))
}
let empty = try decode(Data([1, 0, 0, 0, 0]))
precondition(empty.isEmpty)
expectRejected(Data([2, 0, 0, 0, 0]))
expectRejected(Data([1, 255, 255, 255, 255]))
expectRejected(Data([1, 0, 0, 0, 0, 7]))
Запустите xcrun swift packet-check.swift. Нормальный результат — отсутствие вывода и нулевой код завершения. Если некорректные данные неожиданно будут приняты, программа остановится с ошибкой. В конвейере достаточно проверять статус завершения, не разбирая локализованные журналы.
Здесь не используются параметры оптимизированной сборки, отключающие проверки. После переноса в тестовый таргет замените проверки успешного результата на XCTAssertEqual, а проверки отклонения — на XCTAssertThrowsError. Дополнительно проверяйте конкретный тип ошибки.
Добавьте контрпримеры для длины и порядка байтов
Одних тестов на усечение недостаточно, чтобы подтвердить правильность порядка байтов. Добавьте фиксированный кадр с заявленной длиной 256 и фактической нагрузкой длиной 256: поле длины должно содержать 00 00 01 00. Корректный парсер обязан вернуть всю нагрузку; реализация, ошибочно использующая little-endian, прочитает другое значение длины.
Также проверьте сам верхний предел и значение на единицу больше него. В первом случае кадр с полной нагрузкой должен успешно разбираться; во втором даже один заголовок должен быть немедленно отклонён. Добавьте и вход, в котором заявленная длина меньше фактического содержимого, чтобы убедиться: строгий режим одного кадра не игнорирует байты в конце.
Не генерируйте все заголовки в тестах с помощью рабочего кодировщика. Кодировщик и декодер могут содержать одну и ту же ошибку, и тесты полного цикла кодирования и декодирования всё равно пройдут. Сохраните хотя бы несколько наборов байтов, проверенных вручную, и укажите назначение каждого поля.
Включите поведение при ошибках в критерии приёмки
Перед слиянием изменений проверьте четыре пункта: все полные корректные кадры восстанавливают исходные байты; все усечённые префиксы явно отклоняются; неизвестные версии и длины сверх лимита отвергаются; данные в конце не позволяют обойти границу одного кадра. В результаты тестов включайте идентификатор коммита, версию инструментария и имена образцов, но не реальные нагрузки пользователей.
При обнаружении сбоя сначала сохраните исходные байты, затем сократите пример до минимального контрпримера. Не начинайте с изменения лимита ради прохождения теста. Если в протокол добавляются сжатие или вложенные поля, отдельно ограничьте размер после распаковки и глубину вложенности: ограничение внешней длины не заменяет эти правила.
Эти критерии приёмки охватывают только структуру кадра и не проверяют допустимость нагрузки с точки зрения бизнес-логики. Разделяйте разбор структуры, декодирование текста и бизнес-валидацию: тогда при добавлении полей будет понятно, какой слой изменил поведение при отклонении данных.
Часто задаваемые вопросы
Почему нельзя просто прочитать UInt32 из указателя Data?
Произвольное смещение может нарушать требования к выравниванию, а порядок байтов всё равно нужно учитывать отдельно. Побайтовая сборка явно задаёт формат протокола.
Нужно ли отклонять пустую полезную нагрузку?
Это определяется протоколом. В примере нулевая длина допустима; если данные обязательны, добавьте явную проверку и отдельный тест.
Заменяют ли тесты усечения фаззинг?
Нет. Они проверяют только префиксы выбранного корректного кадра. Комбинации полей и состояние потока требуют дополнительных тестов с ограничением ресурсов.
Запускайте задачи на выделенном облачном Mac
Арендуйте целый физический Mac mini, а не виртуальную машину. Выберите срок аренды: день, неделя, месяц или квартал. Сначала проверьте модель и расположение узла, затем подтвердите заказ. Актуальная доступность отображается в консоли.