Перейти к содержимому

Проверка битовых наборов iOS: порядок битов, заполнение и длина

Проверка битовых наборов iOS: порядок битов, заполнение и длина

Команда упаковывает отметки о прочтении в битовый набор: участники из Азиатско-Тихоокеанского региона записывают данные, а коллеги из Северной Америки затем их читают. Локальная проверка с обратным чтением всегда проходит, но в другой версии клиента первый и восьмой флаги оказываются переставлены. Обычно причина не в сети, а в недостаточно чётком описании бинарного формата. Облачный Mac позволяет сделать такую проверку регулярной задачей: после каждого изменения сравнивать результат с одними и теми же образцами байтов, а не просто убеждаться, что интерфейс выглядит нормально.

Сначала задайте формат передачи, а не копируйте представление в памяти

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

Число байтов полезной нагрузки вычисляется как «целая часть от деления числа битов на восемь плюс единица, если остаток ненулевой». При нулевом числе битов остаётся только заголовок. Неиспользуемые старшие биты последнего байта должны быть нулевыми; лишние байты также недопустимы. В этом формате нет поля версии. При включении в существующий протокол версию нужно явно задать на внешнем уровне, а не просто заменить старый формат данных новым.

Успешное обратное чтение говорит лишь о том, что обе стороны одинаково понимают формат. Если и кодировщик, и декодировщик перепутали порядок битов одним и тем же способом, тест всё равно пройдёт. Поэтому нужны отдельные тестовые векторы с байтами, рассчитанными вручную.

Не сериализуйте напрямую память структур, машинные слова или буферы целых чисел в нативном представлении. Иначе в формат хранения попадут выравнивание, порядок байтов и детали реализации.

Напишите эталонную реализацию с ограничением размера входа

Сохраните код в отдельном каталоге на облачном Mac под именем BitsetCheck.swift и запустите его с помощью Swift из набора инструментов разработки. В примере установлен предел в один миллион битов. Это защитная мера для примера, а не теоретический предел формата файла. На входе декодировщик сначала проверяет ограничение и полную длину данных и только затем создаёт массив результата. Так недоверенный заголовок не сможет вызвать выделение большого объёма памяти.

import Foundation

enum FormatError: Error {
    case invalid
}

let maxBits = 1_000_000

func encode(_ bits: [Bool]) throws -> [UInt8] {
    guard bits.count <= maxBits else {
        throw FormatError.invalid
    }
    let count = UInt32(bits.count)
    var bytes = [
        UInt8(truncatingIfNeeded: count >> 24),
        UInt8(truncatingIfNeeded: count >> 16),
        UInt8(truncatingIfNeeded: count >> 8),
        UInt8(truncatingIfNeeded: count)
    ]
    let size = bits.count / 8 + (bits.count % 8 == 0 ? 0 : 1)
    bytes += Array(repeating: UInt8(0), count: size)
    for index in bits.indices where bits[index] {
        bytes[4 + index / 8] |= UInt8(1) << (index % 8)
    }
    return bytes
}

func decode(_ bytes: [UInt8]) throws -> [Bool] {
    guard bytes.count >= 4 else {
        throw FormatError.invalid
    }
    let raw = bytes.prefix(4).reduce(UInt32(0)) {
        ($0 << 8) | UInt32($1)
    }
    guard raw <= UInt32(maxBits) else {
        throw FormatError.invalid
    }
    let count = Int(raw)
    let size = count / 8 + (count % 8 == 0 ? 0 : 1)
    guard bytes.count == 4 + size else {
        throw FormatError.invalid
    }
    let remainder = count % 8
    if remainder != 0 {
        let allowed = UInt8((1 << remainder) - 1)
        guard bytes[bytes.count - 1] & ~allowed == 0 else {
            throw FormatError.invalid
        }
    }
    return (0..<count).map {
        bytes[4 + $0 / 8] & (UInt8(1) << ($0 % 8)) != 0
    }
}

func require(_ condition: Bool) {
    if !condition {
        fatalError("Bitset check failed")
    }
}

func mustReject(_ bytes: [UInt8]) {
    do {
        _ = try decode(bytes)
    } catch {
        return
    }
    fatalError("Invalid input accepted")
}

let expected: [UInt8] = [0, 0, 0, 9, 0x81, 0x01]
var sample = Array(repeating: false, count: 9)
for index in [0, 7, 8] {
    sample[index] = true
}
require(try encode(sample) == expected)
require(try decode(expected) == sample)

for count in [0, 1, 7, 8, 9, 63, 64, 65] {
    let bits = (0..<count).map { $0 % 3 == 0 }
    let bytes = try encode(bits)
    require(try decode(bytes) == bits)
}

mustReject([])
mustReject([0, 0, 0, 9, 0x81])
mustReject(expected + [0])
mustReject([0, 0, 0, 1, 0x80])
mustReject([0xFF, 0xFF, 0xFF, 0xFF])

Выполните xcrun swift BitsetCheck.swift. При успехе скрипт завершится без вывода; нарушение проверяемого условия или необработанная ошибка приведут к завершению процесса с кодом ошибки. После переноса в проект замените эталонные функции вызовами реального кодировщика и декодировщика, сохранив векторы и тесты отклонения некорректных данных. Иначе проверяться по-прежнему будет только пример.

Проверяйте кодирование и декодирование отдельно с помощью фиксированных векторов

Если в наборе из девяти битов установлены биты с индексами ноль, семь и восемь, полезная нагрузка должна иметь шестнадцатеричный вид 81 01. Первый байт проверяет одновременно младший и старший биты, второй — продолжение набора за границей байта. Заголовок явно показывает, что логическая длина — не то же самое, что длина полезной нагрузки.

Стоит добавить ещё четыре вида образцов: все биты сброшены, все установлены, установлен только первый и установлен только последний. Восемь установленных битов должны давать FF, а девять — FF 01, но не FF FF. Неиспользуемые биты — не место для мусора под предлогом «их всё равно никто не прочитает». Только нормализованное представление обеспечивает согласованность побайтового сравнения, вычисления хешей и ключей кеша.

Фиксированные векторы нужно хранить в системе контроля версий. При изменении протокола сначала проверяйте ожидаемые байты и лишь затем обновляйте реализацию. Не генерируйте и не перезаписывайте файлы ожидаемых результатов автоматически с помощью проверяемого кодировщика: это равносильно тому, чтобы дать объекту проверки самому подготовить правильные ответы.

Выделите некорректный вход в отдельное направление тестирования

Тесты усечённых данных должны охватывать два случая: заголовок короче четырёх байтов и полный заголовок с полезной нагрузкой, в которой не хватает одного байта. Лишние байты тоже нужно отклонять, иначе ошибки сборки пакетов на предыдущем этапе останутся незамеченными. Для проверки ненулевого заполнения используйте длину в один бит и байт 80. Такой образец позволяет выявить и путаницу в порядке битов, и отсутствие проверки завершающего заполнения.

Предел длины следует выбирать исходя из допустимого для приложения числа элементов. Кроме того, при получении данных на уровне приложения нужно ограничивать общее число байтов. Массив, переданный декодировщику из примера, уже существует: декодировщик не может помешать сетевому слою заранее загрузить слишком большой ответ. При потоковом приёме также требуется собственный лимит суммарного числа полученных байтов.

Не выделяйте память по длине из заголовка до проверки фактической полезной нагрузки. Не используйте и формулу «прибавить к длине семь и разделить на восемь» для произвольных целых чисел, поступающих извне. Здесь сначала ограничивается длина, а затем выполняется расчёт через деление и остаток. Порядок проверок тоже входит в контракт, который должны контролировать тесты.

При включении в регрессионные проверки сохраняйте сопоставимые свидетельства

Поместите скрипт в тестовый каталог репозитория и запускайте его с прикладной реализацией из того же коммита. Записывайте идентификатор коммита, вывод xcrun swift --version и имя вектора, на котором произошёл сбой. Не записывайте реальные отметки пользователей о прочтении или прикладную полезную нагрузку целиком.

Рекомендуется дать каждому виду ошибки стабильное имя, например «усечённые данные», «некорректное заполнение» и «превышение длины», вместо того чтобы сводить все сбои к одной сетевой ошибке. Единая ошибка в коде выше нужна лишь для краткости эталонного примера. В рабочей реализации следует сохранить категории, позволяющие диагностировать причину.

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

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

Почему недостаточно закодировать набор и прочитать его обратно?

Кодировщик и декодировщик могут одинаково ошибаться в порядке битов. Эталонные байты, рассчитанные вручную, проверяют сам формат независимо от этой общей ошибки.

Как обрабатывать неиспользуемые биты последнего байта?

В этом формате неиспользуемые старшие биты должны быть нулевыми. Декодировщик отклоняет ненулевое заполнение, обеспечивая единственное каноническое представление набора.

Арендуйте Mac mini в удобном для проекта ритме

Выберите выделенный физический узел с оплатой за день, неделю, месяц или квартал. В пиковый сезон можно продлить аренду; по окончании срока без продления она прекращается. Фактический статус доступности смотрите в консоли.

Обеспечьте ресурсами следующую сборку

Сначала сравните характеристики и сроки, а затем выберите подходящий проекту выделенный физический узел.