iOS-Multipart-Kodierung auf einem Cloud-Mac prüfen

iOS-Multipart-Kodierung auf einem Cloud-Mac prüfen

iOS-Multipart-Kodierung auf einem Cloud-Mac prüfen

Dasselbe Bild lässt sich mit einem Debugging-Tool problemlos hochladen, doch der iOS-Client meldet „Dateifeld fehlt“? Dann sollten Sie nicht zuerst die Zeitlimits ändern. Prüfen Sie stattdessen den Upload-Request-Body auf einem Cloud-Mac anhand festgelegter Eingaben auf Byte-Ebene. So lässt sich meist schneller unterscheiden, ob ein Feldname nicht stimmt, die Zeilenumbrüche falsch sind oder eine Umwandlung in einen String den Dateiinhalt beschädigt hat. Auf dem Entwicklungsrechner benötigen Sie lediglich eine funktionierende Swift-Toolchain; eine Verbindung zum produktiven Upload-Dienst ist nicht erforderlich.

Zuerst den Byte-Vertrag für den Request-Body festlegen

multipart/form-data bedeutet nicht, eine Datei in einen Text einzufügen. Jeder Teil besteht aus einer Boundary-Zeile, den Feld-Headern, einer Leerzeile und dem Inhalt. Das Protokoll verwendet CRLF als Zeilenumbruch, also die beiden Bytes 0D 0A. Auch nach dem Dateiinhalt muss ein CRLF folgen; die abschließende Boundary erhält zusätzlich zwei Bindestriche am Ende.

Klären Sie zunächst mit den Verantwortlichen für die API drei Punkte: den Namen des Textfelds, den Namen des Dateifelds und ob mehrere Felder denselben Namen haben dürfen. Im folgenden Beispiel heißt das Textfeld note und das Dateifeld file. Die Kodierung folgt der Reihenfolge im Array; ein Dictionary kommt nicht zum Einsatz, damit mehrfach vorkommende Schlüssel erhalten bleiben.

Geprüft wird das tatsächlich gesendete Data-Objekt, nicht ein String, der im Log korrekt aussieht. Ein Screenshot belegt nicht, dass die Binärdaten unverändert geblieben sind.

Verwenden Sie im Test eine feste Boundary, damit sich die Ergebnisse vergleichen lassen. Für produktive Requests erzeugen Sie einen zufälligen Wert und prüfen, dass er nicht mit dem Inhalt kollidiert. Der Boundary-Parameter im Request-Header enthält nicht die beiden vorangestellten Bindestriche, die in den Trennzeilen zusätzlich verwendet werden.

Einen Encoder mit klar begrenztem Funktionsumfang schreiben

Speichern Sie den folgenden Code als multipart-check.swift. Er unterstützt ausschließlich sichere ASCII-Feldnamen und -Dateinamen. Für Dateiteile verwendet er immer application/octet-stream. Diese Implementierung ist bewusst auf einen engen Prüfumfang beschränkt und erhebt keinen Anspruch darauf, alle serverseitigen Verfahren für internationalisierte Dateinamen abzudecken.

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

Der Code hängt die Datei direkt als Data an, ohne sie als UTF-8 zu dekodieren. In den Header-Werten werden Steuerzeichen, doppelte Anführungszeichen und Backslashes abgewiesen. So kann ein vom Benutzer angegebener Dateiname weder aus dem in Anführungszeichen eingeschlossenen Wert ausbrechen noch zusätzliche Feld-Header einschleusen. Die Kollisionsprüfung ist hier strenger als vom Protokoll verlangt: Schon das Vorkommen des Boundary-Werts im Inhalt führt zur Ablehnung. Im produktiven Betrieb lässt sich eine neue Boundary erzeugen und die Kodierung erneut versuchen.

Auf dem Cloud-Mac ausführen und die Testfälle ergänzen

Führen Sie zunächst xcrun swift multipart-check.swift aus. Bei erfolgreicher Prüfung bleibt die Ausgabe leer und der Exit-Code ist null. Eine fehlgeschlagene Assertion oder ein Kodierungsfehler führt zu einem Exit-Code ungleich null. Führen Sie dieses Prüfskript nicht in einem Optimierungsmodus aus, der Assertions deaktiviert.

Die binären Testdaten enthalten absichtlich ein Nullbyte, ungültiges UTF-8 und CRLF. Die Assertions suchen nicht nur nach dem Dateiinhalt, sondern prüfen auch, dass unmittelbar danach die abschließende Trennzeile folgt. So besteht ein Request-Body den Test nicht schon deshalb, weil die Bytes zwar vorhanden sind, aber an der falschen Stelle stehen.

Vier Gruppen von Fehler- und Grenzfällen ergänzen

Ergänzen Sie Tests für eine leere Datei, einen leeren Text, zwei gleichnamige Textfelder und einen Teil, dessen Name ein doppeltes Anführungszeichen enthält. Auch bei leerem Inhalt müssen die Leerzeile nach den Headern und das CRLF nach dem Inhalt erhalten bleiben. Bei mehrfach vorkommenden Feldern müssen Anzahl und Reihenfolge unverändert bleiben. Fügen Sie außerdem einen Test mit einem chinesischen Dateinamen hinzu: Das ausdrücklich erwartete Ergebnis ist eine Ablehnung, keine stillschweigende Umbenennung.

Muss die Anwendung chinesische Dateinamen akzeptieren, klären Sie zuerst, welche Kodierung der Server unterstützt, und erweitern Sie erst danach die Implementierung. Behandeln Sie eine Prozentkodierung des Dateinamens nicht eigenmächtig als universelle Lösung. Lassen Sie auch nicht einfach sämtliche Unicode-Zeichen zu, ohne weiterhin auf Header-Injektionen zu prüfen.

Bei der URLSession-Anbindung dieselben Daten verwenden

Übergeben Sie bei der produktiven Anbindung die Boundary und das kodierte Data-Objekt gemeinsam an die Schicht, die den Request erstellt. Setzen Sie Content-Type aus multipart/form-data; boundary= und dem tatsächlichen Wert zusammen und übergeben Sie dasselbe Data-Objekt an den Upload-Task. Die beiden Schichten dürfen nicht unabhängig voneinander Boundaries erzeugen.

Kleine Testdaten lassen sich im Arbeitsspeicher kodieren. Große Dateien sollten dagegen abschnittsweise in eine temporäre Request-Body-Datei geschrieben und anschließend als Datei hochgeladen werden. So müssen nicht gleichzeitig die Originaldatei und der vollständige Request-Body im Speicher liegen. Nach der Umstellung auf eine dateibasierte Kodierung müssen dieselben Byte-Prüfungen erneut auf die fertige Datei angewendet werden.

Falls die Anwendung Content-Length setzt, muss der Wert der Byte-Anzahl des fertigen Request-Bodys entsprechen, nicht der Zeichenanzahl oder der Größe der Originaldatei. Ob der Header manuell gesetzt werden muss, sollte einheitlich in der Netzwerkschicht entschieden werden. Der Encoder sollte keinen separaten, möglicherweise unzutreffenden Längenwert erzeugen.

Protokollprüfung und API-Prüfung getrennt durchführen

Eine erfolgreiche lokale Byte-Prüfung belegt lediglich, dass der Request-Body diesen Kodierungsvertrag erfüllt. Lassen Sie anschließend eine Test-API den Request parsen und gleichen Sie Feldwerte, Dateilänge und Hashwert der Datei ab. Prüfen Sie danach das tatsächliche Verhalten des Servers bei mehrfach vorkommenden Feldern, leeren Dateien und Dateinamen.

Bewahren Sie bei einem Fehler nur die Boundary, die Byte-Länge und synthetische Testdaten auf; archivieren Sie keine echten Upload-Inhalte von Benutzern. Halten Sie bei der Diagnose eine feste Reihenfolge ein: zuerst die Trennzeilen prüfen, dann die Feld-Header und zuletzt die Dateibytes vergleichen. Überschreitet eine Datei die zulässige Größe oder lehnt die API ihren Typ ab, prüfen Sie die serverseitigen Vorgaben. Versuchen Sie nicht, fachliche Fehler durch geänderte Zeilenumbrüche zu kaschieren.

Nehmen Sie das Skript schließlich in die Prüfungen bei Code-Commits auf und lassen Sie im Testbericht die Toolchain-Version sowie den Exit-Code festhalten. Wenn die Implementierung des Upload-Moduls später ausgetauscht wird, prüfen Sie damit weiterhin denselben Byte-Vertrag statt eines einzelnen, zufällig erfolgreichen Netzwerk-Requests.

Häufig gestellte Fragen

Sollte die Boundary in Produktion fest vorgegeben sein?

Nein. Feste Grenzen dienen reproduzierbaren Tests. Erzeugen Sie in Produktion eine zufällige Grenze, prüfen Sie alle Teile auf Kollisionen und verwenden Sie denselben Wert in Header und Body.

Kann ich den gesamten Body als Zeichenkette prüfen?

Nicht zur Prüfung von Binärdaten: Dateibytes müssen kein gültiges UTF-8 sein. Vergleichen Sie Data direkt und nutzen Sie die Textansicht nur zur Kontrolle von Headern und Trennzeichen.

Ein eigener Mac für Ihre Projekte

Mac-mini-Hardware, keine virtuelle Maschine. Mieten Sie tage-, wochen-, monats- oder quartalsweise. Wählen Sie zuerst Einsatzzweck und Ausstattung und prüfen Sie anschließend die aktuelle Verfügbarkeit direkt in der Konsole.

Modell und Mietdauer wählen Alle Preise ansehen