Проверка разбора и нормализации UUID в iOS на облачном Mac

Проверка разбора и нормализации UUID в iOS на облачном Mac

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

Сначала зафиксируйте контракт входных данных

В этой статье принимается стандартное текстовое представление длиной 36 байт: шестнадцатеричные символы разбиты на пять групп длиной 8, 4, 4, 4 и 12 символов, разделённых дефисами ASCII. На входе допускается любой регистр, на выходе используется только нижний. Пробельные символы по краям, фигурные скобки и запись без дефисов не допускаются.

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

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

UUID — это идентификатор, а не средство авторизации. Даже при корректном формате сервер обязан проверить, имеет ли текущий пользователь право читать соответствующую запись.

Напишите исполняемый строгий парсер

Выполните на узле xcrun --find swift, чтобы убедиться, что текущий набор инструментов предоставляет Swift. Сохраните приведённый ниже код в файл uuid-contract.swift. Сначала он проверяет структуру байтов, а затем передаёт строку в Foundation. Это позволяет не зависеть от того, насколько терпимо конкретная версия системы относится к отклонениям от формата.

import Foundation

enum IdentifierError: Error {
    case invalidSyntax
    case zeroNotAllowed
}

func parseIdentifier(
    _ text: String,
    allowZero: Bool = false
) throws -> UUID {
    let bytes = Array(text.utf8)
    let separators: Set<Int> = [8, 13, 18, 23]

    guard bytes.count == 36 else {
        throw IdentifierError.invalidSyntax
    }

    for (index, byte) in bytes.enumerated() {
        if separators.contains(index) {
            guard byte == 45 else {
                throw IdentifierError.invalidSyntax
            }
        } else {
            let isHex = (48...57).contains(byte)
                || (65...70).contains(byte)
                || (97...102).contains(byte)
            guard isHex else {
                throw IdentifierError.invalidSyntax
            }
        }
    }

    guard let value = UUID(uuidString: text) else {
        throw IdentifierError.invalidSyntax
    }

    let zero = UUID(
        uuid: (0, 0, 0, 0, 0, 0, 0, 0,
               0, 0, 0, 0, 0, 0, 0, 0)
    )
    guard allowZero || value != zero else {
        throw IdentifierError.zeroNotAllowed
    }
    return value
}

func canonical(_ value: UUID) -> String {
    value.uuidString.lowercased()
}

Проверка байтов UTF-8, а не количества видимых символов, явно исключает полноширинные символы и знаки Unicode, похожие на дефис. Вызов lowercased() здесь применяется только к строковому представлению уже проверенного UUID: произвольный пользовательский ввод не приводится к нижнему регистру ради последующего допуска.

Проверьте граничные случаи на фиксированных примерах

Добавьте тесты в конец того же файла. Корректные примеры проверяют равенство значений, нормализацию и идемпотентность; некорректные охватывают пробельные символы, подмену символов и изменение длины. Не заменяйте фиксированные контрпримеры случайными UUID — иначе воспроизвести сбой будет сложнее.

let upper = "550E8400-E29B-41D4-A716-446655440000"
let lower = upper.lowercased()
let value = try parseIdentifier(upper)

precondition(value == (try parseIdentifier(lower)))
precondition(canonical(value) == lower)
let reparsed = try parseIdentifier(canonical(value))
precondition(canonical(reparsed) == lower)

let invalid = [
    "",
    " " + lower,
    lower + "\n",
    "{" + lower + "}",
    lower.replacingOccurrences(of: "-", with: ""),
    lower.replacingOccurrences(of: "-", with: "-"),
    lower.replacingOccurrences(of: "5", with: "g"),
    lower + "0"
]

for text in invalid {
    do {
        _ = try parseIdentifier(text)
        fatalError("Invalid UUID accepted")
    } catch IdentifierError.invalidSyntax {
    }
}

let zero = "00000000-0000-0000-0000-000000000000"
do {
    _ = try parseIdentifier(zero)
    fatalError("Zero UUID accepted")
} catch IdentifierError.zeroNotAllowed {
}
_ = try parseIdentifier(zero, allowZero: true)

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

Выполняйте нормализацию на границе данных

Разбирать идентификатор следует в тот момент, когда ответ API, параметр глубокой ссылки или содержимое импортируемого файла попадает в доменную модель. Доменный слой должен хранить UUID и вызывать canonical только при отправке запроса или записи в текстовое поле. Не позволяйте слоям интерфейса и кеширования независимо выполнять строковые замены.

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

Не считайте сортировку UUID как текста хронологической

Лексикографический порядок случайных UUID не отражает порядок создания записей. Если списку нужен стабильный порядок, используйте отдельное поле времени создания, а при совпадении времени — нормализованный идентификатор как вторичный ключ сортировки. Даже если выбран вариант UUID с временной семантикой, версию и правила сортировки необходимо оговорить отдельно.

Включите проверки в приёмочный процесс на облачном Mac

Перед запуском скрипта на узле ArmVNC зафиксируйте идентификатор коммита, вывод sw_vers и xcrun swift --version. Эти сведения помогают объяснить различия между наборами инструментов и не содержат бизнес-данных. Для примеров, которые должны завершаться ошибкой, используйте фиксированные тестовые значения, а не архивируйте реальные пользовательские записи.

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

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

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

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

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

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

Нужно ли всегда отклонять UUID из одних нулей?

Нет. Его структура корректна, но бизнес-контракт может запрещать такое значение в качестве идентификатора сущности. Задайте правило явно и проверьте оба варианта.

Передайте задачи сборки на выделенный облачный Mac

Выберите выделенный узел Apple Silicon и получите в своё распоряжение всю машину — с нативным графическим интерфейсом macOS и командной строкой. Модель, срок аренды и узел указаны в подтверждении заказа.