Проверяем границы декодирования бинарных пакетов iOS на облачном Mac

Проверяем границы декодирования бинарных пакетов iOS на облачном Mac

Проверяем границы декодирования бинарных пакетов 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, а не виртуальную машину. Выберите срок аренды: день, неделя, месяц или квартал. Сначала проверьте модель и расположение узла, затем подтвердите заказ. Актуальная доступность отображается в консоли.