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