Контракты UUID в iOS: строгий разбор и порядок байтов

Контракты UUID в iOS: строгий разбор и порядок байтов

Для одной и той же записи заказа API возвращает UUID в верхнем регистре, а локальный кеш хранит его в нижнем — при этом логика дедупликации просто сравнивает строки. В другом тракте обработки UUID записывается в двоичном виде, а после чтения порядок байтов в первых группах оказывается другим. Такие ошибки можно выявить на облачном Mac ещё до сборки, без запуска симулятора. Сначала зафиксируйте контракт входных данных, сравнения идентификаторов и сериализации с помощью неизменных тестовых векторов на Swift, а затем используйте те же правила в iOS-проекте.

Отделите формат от бизнес-правил

UUID — это 128-битный идентификатор. Его обычное текстовое представление состоит из пяти групп шестнадцатеричных символов длиной 8, 4, 4, 4 и 12. В нём всегда четыре дефиса, но сам по себе такой вид не означает, что перед вами случайно сгенерированный UUID версии 4.

Сначала явно определите три правила: допускается ли верхний регистр, разрешены ли пробельные символы в начале и конце строки и есть ли ограничение по версии. В этой статье принимаются оба регистра, но отклоняются пробельные символы, фигурные скобки, запись без дефисов и символы вне ASCII. Версия 4 и соответствующий вариант проверяются только у полей, для которых явно требуется случайный идентификатор.

«Можно разобрать» — это возможность библиотеки, а «можно принять в систему» — решение протокола. Не превращайте снисходительное поведение парсера в неявный контракт API.

Отдельно решите, как обрабатывать UUID, состоящий из нулей. Он может соответствовать общему текстовому формату, но не должен автоматически подменять значение «не задано». Для необязательных полей предпочтительнее явная семантика отсутствующего значения.

Запустите фиксированные тестовые векторы на облачном Mac

Подготовьте облачный Mac с уже выбранным набором инструментов разработки и сохраните следующий код в файл uuid-contract.swift. Скрипт использует только Foundation и не зависит от проекта, сети или случайных чисел. Здесь precondition останавливает тестовый скрипт при провале проверки; использовать его для обработки внешних данных в приложении не следует.

import Foundation

func parseUUID(_ text: String, requireV4: Bool = false) -> UUID? {
    let bytes = Array(text.utf8)
    guard bytes.count == 36 else { return nil }

    let separators: Set<Int> = [8, 13, 18, 23]
    for (index, byte) in bytes.enumerated() {
        if separators.contains(index) {
            guard byte == 45 else { return nil }
        } else {
            let isHex = (48...57).contains(byte)
                || (65...70).contains(byte)
                || (97...102).contains(byte)
            guard isHex else { return nil }
        }
    }

    guard let value = UUID(uuidString: text) else { return nil }
    if requireV4 {
        let raw = value.uuid
        guard raw.6 >> 4 == 4, raw.8 & 0xC0 == 0x80 else {
            return nil
        }
    }
    return value
}

let valid = "00112233-4455-4677-8899-aabbccddeeff"
let parsed = parseUUID(valid, requireV4: true)!
precondition(parsed.uuidString.lowercased() == valid)
precondition(parseUUID(valid.uppercased()) == parsed)

let invalid = [
    " " + valid,
    valid + "\n",
    "{" + valid + "}",
    valid.replacingOccurrences(of: "-", with: ""),
    valid.replacingOccurrences(of: "a", with: "a")
]
for input in invalid {
    precondition(parseUUID(input) == nil)
}

let otherVersion = "00112233-4455-5677-8899-aabbccddeeff"
precondition(parseUUID(otherVersion) != nil)
precondition(parseUUID(otherVersion, requireV4: true) == nil)

let wrongVariant = "00112233-4455-4677-0899-aabbccddeeff"
precondition(parseUUID(wrongVariant) != nil)
precondition(parseUUID(wrongVariant, requireV4: true) == nil)

let raw = parsed.uuid
let wire: [UInt8] = [
    raw.0, raw.1, raw.2, raw.3,
    raw.4, raw.5, raw.6, raw.7,
    raw.8, raw.9, raw.10, raw.11,
    raw.12, raw.13, raw.14, raw.15
]
precondition(wire == [
    0x00, 0x11, 0x22, 0x33,
    0x44, 0x55, 0x46, 0x77,
    0x88, 0x99, 0xaa, 0xbb,
    0xcc, 0xdd, 0xee, 0xff
])

Выполните xcrun swift uuid-contract.swift: при успехе скрипт ничего не выводит, а при провале возвращает ненулевой код завершения. Конвейер должен сохранять этот код. Также записывайте вывод xcrun swift --version, чтобы после обновления инструментов можно было повторить проверку и сравнить результаты.

Оставьте единую точку нормализации для сравнения идентификаторов

Внутри приложения по возможности сравнивайте значения UUID, а не исходные строки из API. Если идентификатор нужно записать как текстовый ключ в базу данных, после успешного строгого разбора вызовите uuidString.lowercased(). Так вы получите единственное каноническое представление: строчные буквы и дефисы.

Не удаляйте дефисы и не обрезайте пробельные символы перед разбором. Такое «исправление» скрывает нарушения протокола на стороне источника и может свести два входных значения, которые следовало отклонить, к одному ключу. Если пользователь вставляет данные в интерфейсе, можно явно предложить этап очистки, но слой API не должен незаметно применять ту же обработку.

Перед миграцией старых данных сначала подсчитайте коллизии после нормализации регистра. Например, если две записи различаются только регистром букв в UUID, нельзя просто перезаписать одну из них. Сначала проверьте связи с бизнес-данными, затем объедините записи или изолируйте аномалии и сохраните результаты миграции.

Проверяйте полный порядок байтов при двоичном обмене

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

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

Для обратного чтения тоже нужен независимый образец

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

Подключите проверки к реальному пути разбора данных

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

Дополните тесты четырьмя группами случаев: допустимые строки в разных регистрах должны обозначать один идентификатор; недопустимые символы и пробельные символы должны отклоняться; ограничения версии должны применяться только к указанным полям; двоичные образцы должны совпадать побайтно. В каждой группе проверяйте возвращаемый результат — одного утверждения «не упало» недостаточно.

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

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

Нужно ли требовать версию 4 для всех UUID?

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

Разный регистр букв означает разные UUID?

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

Выделите Mac для следующей сборки.

Выберите облачный Mac на физической машине, не меняя привычные процессы Xcode и CI. Подберите конфигурацию под нагрузку проекта; актуальный статус доступности отображается в консоли.

Арендовать облачный Mac

Поручите следующую сборку выделенной физической машине.

Сохраните привычные команды и процессы CI, выбрав облачный Mac под нагрузку проекта.