雲端 Mac 上檢核 iOS multipart 請求編碼與二進位邊界

雲端 Mac 上檢核 iOS multipart 請求編碼與二進位邊界

雲端 Mac 上檢核 iOS multipart 請求編碼與二進位邊界

同一張圖片在除錯工具中上傳成功,換成 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 本身就會拒絕編碼;正式環境可以重新產生 boundary 後再試一次。

在雲端 Mac 執行並補齊測試樣本

先執行 xcrun swift multipart-check.swift。正常結果是不產生輸出,且結束碼為零;斷言失敗或編碼發生例外,都會得到非零的結束碼。不要以會停用斷言的最佳化模式執行這份驗收指令碼。

目前的二進位樣本刻意包含零位元組、無效的 UTF-8 位元組與 CRLF。斷言不只是搜尋檔案內容,還會檢查檔案後方是否緊接結束分隔行,避免「位元組存在,但位置錯誤」的情況也通過驗收。

增加四組失敗與邊界輸入

接著加入空檔案、空文字、兩個同名文字欄位,以及名稱含有雙引號的部分。即使內容為空,仍應保留標頭後的空行與內容後的 CRLF;重複欄位則必須保留出現次數與順序。另外加入中文檔名樣本,明確將預期結果設為拒絕,而不是默默重新命名。

如果業務需求必須接受中文檔名,先確認伺服器支援的編碼約定,再擴充實作。不要自行將檔名做百分比編碼就當成通用修正,也不要直接允許所有 Unicode 字元,卻略過標頭注入檢查。

串接 URLSession 時沿用同一份資料

正式串接時,將 boundary 與編碼後的 Data 一起交給請求建構層。Content-Type 使用 multipart/form-data; boundary= 加上實際值,再將同一份 Data 交給上傳任務。不要讓兩層程式碼各自產生邊界。

小型樣本可以在記憶體中編碼;大型檔案則應分段寫入暫存的請求本文檔案,再採用從檔案上傳的方式,避免同時持有原始檔案與完整請求本文兩份資料。改為寫入檔案後,必須對最終檔案重新執行相同的位元組驗收。

如果業務邏輯會設定 Content-Length,必須使用最終請求本文的位元組數,不能使用字元數或原始檔案大小。是否需要手動設定,應由網路層統一決定;編碼器不要另外產生一個可能不準確的長度值。

將協定驗收與 API 驗收分開

本機位元組檢查通過,只能證明符合這份編碼契約。接下來要讓測試 API 解析請求,核對欄位值、檔案長度與檔案雜湊摘要,再檢查伺服器對重複欄位、空檔案及檔名的實際處理方式。

發現失敗時,保留 boundary、位元組長度與合成樣本即可,不要封存使用者實際上傳的內容。診斷順序固定為:先查分隔符號,再查欄位標頭,最後比對檔案位元組。若遇到檔案超出大小限制,或 API 拒絕檔案類型,應回頭確認伺服器端約定,不要靠改換行來掩蓋業務錯誤。

最後將指令碼納入提交檢查,並讓測試報告記錄工具鏈版本與結束碼。如此一來,上傳模組更換實作時,驗收的仍是同一份位元組契約,而不是某一次偶然成功的網路請求。

常見問題

正式請求也應使用固定 boundary 嗎?

不應該。固定 boundary 僅供可重複測試;正式環境應產生隨機邊界,確認它未出現在各部分內容中,再以同一個值設定標頭及編碼請求本文。

可以把整個請求本文轉成字串來檢查嗎?

不能用來驗收二進位資料。檔案內容可能不是有效的 UTF-8,應直接比較 Data 位元組;文字檢視僅適合輔助檢查欄位標頭與分隔符號。

為你的專案保留一台專屬 Mac

Mac mini 實體節點,非虛擬機器。可按日、週、月或季租用;先選擇用途與規格,再確認控制台即時回傳的可用狀態。

選擇機型與租期 查看完整價格