Test iOS multipart encoding on a cloud Mac

Test iOS multipart encoding on a cloud Mac

Test iOS multipart encoding on a cloud Mac

If the same image uploads successfully from a debugging tool but the iOS client gets a “missing file field” error, do not start by changing the timeout. Take the upload request body to a cloud Mac and inspect its raw bytes using fixed inputs. This usually makes it easier to distinguish a field-name mismatch from incorrect line endings or file content damaged by string conversion. The development machine only needs a working Swift toolchain; it does not need access to the production upload service.

Define the request body's byte contract first

multipart/form-data is not a matter of inserting a file into a string. Each part consists of a boundary line, field headers, a blank line, and content. The protocol uses CRLF line endings: the two bytes 0D 0A. File content must also be followed by CRLF, and the closing boundary must have two trailing hyphens.

First, confirm three things with the API maintainer: the text field name, the file field name, and whether repeated fields with the same name are allowed. In the example below, the text field is note and the file field is file. Parts are encoded in array order rather than stored in a dictionary that would discard duplicate keys.

Validate the Data that will actually be sent, not a string that looks correct in a log. A screenshot cannot prove that binary content is unchanged.

Use a fixed boundary for tests to make comparisons easier. For production requests, generate a random value and check that it does not collide with the content. The boundary parameter in the request header does not include the two leading hyphens added to delimiter lines.

Write an encoder with a clearly defined scope

Save the following code as multipart-check.swift. It supports only safe ASCII field names and filenames, and file parts always use application/octet-stream. This is a deliberately limited implementation for validation, not a promise to support every server's handling of internationalized filenames.

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)
}

The code appends the file directly as Data without decoding it as UTF-8. Header values reject control characters, double quotes, and backslashes so that a user-supplied filename cannot escape its quotes or inject a new field header. The collision check here is more conservative than the protocol requires: it rejects content containing the boundary value anywhere. In production, you can generate a new boundary and retry.

Run it on a cloud Mac and expand the fixtures

Start by running xcrun swift multipart-check.swift. A successful run produces no output and exits with code zero. An assertion failure or encoding error produces a nonzero result. Do not run this validation script with an optimization mode that disables assertions.

The binary fixture deliberately includes a zero byte, invalid UTF-8, and CRLF. The assertions do more than search for the file content: they also check that the closing delimiter follows immediately after the file's CRLF. This prevents a body from passing merely because the bytes exist somewhere, even if they are in the wrong position.

Add four groups of failure and edge-case inputs

Add cases for an empty file, empty text, two text fields with the same name, and a part whose name contains a double quote. Empty content must still retain the blank line after the headers and the CRLF after the content. Repeated fields must preserve both their count and their order. Also add a Chinese filename fixture with an explicit expectation that it will be rejected, not silently renamed.

If the application must accept Chinese filenames, first establish which encoding convention the server supports, then extend the implementation. Do not assume that percent-encoding the filename is a universal fix, and do not simply allow all Unicode characters while skipping header-injection checks.

Keep the same data when integrating with URLSession

For production integration, pass the boundary and the encoded Data together to the request-building layer. Set Content-Type to multipart/form-data; boundary= followed by the actual value, then pass that same Data to the upload task. Do not let two layers generate their own boundaries independently.

Small fixtures can be encoded in memory. For large files, write the request body incrementally to a temporary file and use a file-based upload instead, so you do not hold both the original file and the complete request body in memory at once. After switching to file-based encoding, rerun the same byte-level validation against the final file.

If the application sets Content-Length, use the byte count of the final request body, not the character count or the original file size. The networking layer should make a single, consistent decision about whether to set this header manually. The encoder should not produce a separate length value that could be inaccurate.

Separate protocol validation from API validation

Passing the local byte checks proves only that the body satisfies this encoding contract. Next, have a test endpoint parse the request and verify the field values, file length, and file digest. Then check the server's actual behavior for repeated fields, empty files, and filenames.

When a test fails, retain only the boundary, byte length, and synthetic fixture; do not archive real user uploads. Follow a fixed diagnostic order: check delimiters first, then field headers, and finally compare the file bytes. If the file exceeds a size limit or the API rejects its type, revisit the server's requirements rather than changing line endings to mask an application-level error.

Finally, include the script in commit checks and record the toolchain version and exit code in the test report. When the upload module is reimplemented, you will then be validating the same byte contract—not a network request that happened to succeed once.

Frequently asked questions

Should production requests use a fixed boundary?

No. Keep a fixed boundary for deterministic tests. Generate a random boundary in production, check it against every part, and use the same value in the header and body.

Can I validate the body by converting it to a string?

Not for binary validation. File bytes may be invalid UTF-8, so compare Data directly. A text view is only useful for inspecting headers and delimiters.

A dedicated Mac for your projects

Mac mini physical nodes, not virtual machines. Rent by the day, week, month, or quarter. Choose your use case and specs, then check real-time availability in the console.

Choose a model and rental term View full pricing