Проверка кодирования multipart-запросов iOS на облачном Mac

Проверка кодирования multipart-запросов iOS на облачном Mac

Проверка кодирования multipart-запросов iOS на облачном Mac

Если одна и та же картинка успешно загружается из инструмента отладки, а iOS-клиент получает ошибку «отсутствует поле файла», не спешите менять тайм-ауты. Перенесите тело запроса на облачный Mac и проверьте исходные байты на фиксированных входных данных. Так обычно быстрее удаётся выяснить причину: неверное имя поля, неправильные переводы строк или повреждение содержимого файла при преобразовании в строку. На машине для разработки достаточно работоспособного набора инструментов Swift; подключение к рабочему сервису загрузки не требуется.

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

multipart/form-data — это не текст, в который просто вставили файл. Каждая часть состоит из строки-разделителя, заголовков поля, пустой строки и содержимого. Для перевода строки протокол использует CRLF — два байта 0D 0A. После содержимого файла тоже нужен CRLF, а завершающий разделитель должен заканчиваться двумя дополнительными дефисами.

Сначала уточните у разработчиков API три вещи: имя текстового поля, имя поля файла и допустимость повторяющихся полей с одинаковым именем. В примере ниже текстовое поле называется note, а поле файла — file. Части кодируются в порядке элементов массива: словарь не используется, чтобы не потерять повторяющиеся ключи.

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

В тестах используйте фиксированный boundary, чтобы упростить сравнение. Для рабочих запросов генерируйте случайное значение и проверяйте, что оно не встречается в содержимом. Параметр boundary в заголовке запроса не включает два начальных дефиса, которые добавляются в строках-разделителях.

Напишите кодировщик с чётко ограниченной областью применения

Сохраните следующий код в файл multipart-check.swift. Он поддерживает только безопасные ASCII-имена полей и файлов, а для файловых частей всегда использует application/octet-stream. Это намеренно ограниченная реализация для проверки контракта: она не претендует на поддержку всех серверных вариантов обработки интернационализированных имён файлов.

import Foundation

struct Part {
    let name: String
    let filename: String?
    let bytes: Data
}

enum EncodingError: Error {
    case invalidHeader
    case invalidBoundary
    case boundaryCollision
}

func safeHeader(_ value: String) -> Bool {
    !value.isEmpty && value.utf8.allSatisfy {
        $0 >= 32 && $0 <= 126 && $0 != 34 && $0 != 92
    }
}

func encode(_ parts: [Part], boundary: String) throws -> Data {
    guard (1...70).contains(boundary.utf8.count),
          boundary.utf8.allSatisfy({
              (48...57).contains($0) ||
              (65...90).contains($0) ||
              (97...122).contains($0) || $0 == 45
          }) else {
        throw EncodingError.invalidBoundary
    }

    var body = Data()
    func append(_ text: String) {
        body.append(Data(text.utf8))
    }

    for part in parts {
        guard safeHeader(part.name),
              part.filename.map({ safeHeader($0) }) ?? true else {
            throw EncodingError.invalidHeader
        }
        guard part.bytes.range(of: Data(boundary.utf8)) == nil else {
            throw EncodingError.boundaryCollision
        }

        append("--\(boundary)\r\n")
        var header = "Content-Disposition: form-data; name=\"\(part.name)\""
        if let filename = part.filename {
            header += "; filename=\"\(filename)\""
        }
        append(header + "\r\n")
        append(part.filename == nil
            ? "Content-Type: text/plain; charset=utf-8\r\n"
            : "Content-Type: application/octet-stream\r\n")
        append("\r\n")
        body.append(part.bytes)
        append("\r\n")
    }
    append("--\(boundary)--\r\n")
    return body
}

func expectFailure(_ action: () throws -> Data) {
    do {
        _ = try action()
    } catch {
        return
    }
    fatalError("Expected encoding failure")
}

let boundary = "fixture-boundary"
let binary = Data([0x00, 0xFF, 0x0D, 0x0A, 0x80])
let parts = [
    Part(name: "note", filename: nil, bytes: Data("验收".utf8)),
    Part(name: "file", filename: "sample.bin", bytes: binary)
]
let body = try encode(parts, boundary: boundary)
precondition(body.starts(with: Data("--\(boundary)\r\n".utf8)))
precondition(body.suffix(Data("--\(boundary)--\r\n".utf8).count)
    == Data("--\(boundary)--\r\n".utf8))
let filePrefix = Data(
    ("Content-Disposition: form-data; name=\"file\"; filename=\"sample.bin\"\r\n"
    + "Content-Type: application/octet-stream\r\n\r\n").utf8
)
guard let headerRange = body.range(of: filePrefix) else {
    fatalError("Missing file header")
}
let start = headerRange.upperBound
precondition(body.subdata(in: start..<(start + binary.count)) == binary)
precondition(body.subdata(in: (start + binary.count)..<body.count)
    == Data("\r\n--\(boundary)--\r\n".utf8))

expectFailure {
    try encode([
        Part(name: "file", filename: "bad\r\nInjected: yes", bytes: binary)
    ], boundary: boundary)
}
expectFailure {
    try encode([
        Part(name: "note", filename: nil, bytes: Data(boundary.utf8))
    ], boundary: boundary)
}

Код добавляет файл напрямую как Data, не декодируя его в UTF-8. В значениях заголовков запрещены управляющие символы, двойные кавычки и обратная косая черта: это не позволяет имени файла, полученному от пользователя, выйти за пределы кавычек или внедрить новый заголовок поля. Проверка конфликтов здесь строже, чем требует протокол: кодировщик отклоняет содержимое, если в нём встречается само значение boundary. В рабочей среде можно сгенерировать другое значение и повторить попытку.

Запустите проверку на облачном Mac и дополните набор образцов

Сначала выполните xcrun swift multipart-check.swift. При успешной проверке вывода нет, а код завершения равен нулю. Нарушение проверяемых условий или ошибка кодирования приводят к ненулевому коду завершения. Не запускайте этот проверочный скрипт в режиме оптимизации, отключающем проверки утверждений.

Текущий бинарный образец намеренно содержит нулевой байт, недопустимые для UTF-8 байты и CRLF. Проверки не просто ищут содержимое файла: они также удостоверяются, что сразу после него находится завершающая строка-разделитель. Это не позволяет пройти проверку телу, в котором нужные байты есть, но расположены не там.

Добавьте четыре группы ошибочных и граничных входных данных

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

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

При интеграции с URLSession используйте те же данные

При подключении к приложению передавайте boundary и закодированный объект Data вместе в слой построения запроса. Значение Content-Type должно состоять из multipart/form-data; boundary= и фактического значения boundary. Затем передайте тот же объект Data задаче загрузки. Не допускайте, чтобы два слоя кода генерировали границы независимо друг от друга.

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

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

Разделяйте проверку протокола и проверку API

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

При сбое достаточно сохранить boundary, длину в байтах и синтетический образец; реальные пользовательские файлы архивировать не нужно. Придерживайтесь одного порядка диагностики: сначала разделители, затем заголовки полей, в конце — сравнение байтов файла. Если файл превышает допустимый размер или API отклоняет его тип, вернитесь к требованиям сервера. Изменение переводов строк не должно маскировать ошибки, связанные с этими требованиями.

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

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

Нужна ли фиксированная граница в рабочих запросах?

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

Можно ли проверять тело запроса как строку?

Для бинарных данных этого недостаточно: байты файла могут быть недопустимыми в UTF-8. Сравнивайте Data напрямую, а текстовое представление используйте только для просмотра заголовков и разделителей.

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

Физические узлы Mac mini, не виртуальные машины. Арендуйте на день, неделю, месяц или квартал: выберите задачу и конфигурацию, затем проверьте актуальную доступность в консоли.

Выбрать модель и срок аренды Посмотреть все цены