Vérifier l’encodage multipart iOS sur un Mac cloud

Vérifier l’encodage multipart iOS sur un Mac cloud

La même image s’envoie sans problème depuis un outil de débogage, mais le client iOS reçoit une erreur « champ fichier manquant » ? Ne commencez pas par modifier les délais d’expiration. Transférez le corps de la requête d’envoi sur un Mac cloud et inspectez ses octets bruts avec des données d’entrée fixes. Vous pourrez généralement déterminer plus vite si le problème vient d’un nom de champ incorrect, de fins de ligne mal formées ou d’une conversion en chaîne qui a altéré le contenu du fichier. Cette machine de développement n’a besoin que d’une chaîne d’outils Swift fonctionnelle ; elle n’a pas à se connecter au service d’envoi de production.

Définir d’abord le contrat du corps de requête, octet par octet

Le format multipart/form-data ne consiste pas à insérer un fichier dans un bloc de texte. Chaque partie comprend une ligne de délimitation, des en-têtes de champ, une ligne vide et son contenu. Les fins de ligne du protocole sont des CRLF, soit les deux octets 0D 0A. Il faut également ajouter un CRLF après le contenu du fichier, puis deux tirets supplémentaires à la fin du délimiteur de fermeture.

Confirmez d’abord trois points auprès de l’équipe responsable de l’API : le nom du champ texte, le nom du champ fichier et l’autorisation éventuelle de répéter un même nom de champ. Dans l’exemple ci-dessous, le champ texte s’appelle note et le champ fichier file. Les parties sont encodées dans l’ordre du tableau, sans passer par un dictionnaire qui ferait disparaître les clés répétées.

La validation porte sur le Data effectivement envoyé, pas sur une chaîne qui semble correcte dans les journaux. Une capture d’écran ne prouve pas que le contenu binaire est resté intact.

Utilisez une valeur boundary fixe pour les tests afin de faciliter les comparaisons. Pour les requêtes de production, générez une valeur aléatoire et vérifiez qu’elle n’entre pas en collision avec le contenu. Le paramètre boundary de l’en-tête de requête ne comprend pas les deux tirets initiaux ajoutés aux lignes de délimitation.

Écrire un encodeur au périmètre bien défini

Enregistrez le code suivant dans multipart-check.swift. Il n’accepte que des noms de champs et de fichiers composés de caractères ASCII sûrs, et utilise systématiquement application/octet-stream pour les parties fichier. Cette implémentation de validation est volontairement limitée : elle ne prétend pas couvrir toutes les façons dont les serveurs traitent les noms de fichiers internationalisés.

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

Le code ajoute directement le fichier sous forme de Data, sans décodage UTF-8. Les valeurs d’en-tête rejettent les caractères de contrôle, les guillemets doubles et les barres obliques inverses, afin qu’un nom de fichier fourni par l’utilisateur ne puisse ni sortir des guillemets ni injecter un nouvel en-tête de champ. La détection des collisions est ici plus stricte que ne l’exige le protocole : la simple présence de boundary dans le contenu entraîne un rejet. En production, vous pouvez générer une nouvelle valeur et réessayer.

Exécuter sur un Mac cloud et compléter les échantillons

Commencez par exécuter xcrun swift multipart-check.swift. Le résultat attendu est une absence de sortie et un code de sortie égal à zéro. Une assertion qui échoue ou une erreur d’encodage produit un résultat non nul. N’exécutez pas ce script de validation avec un mode d’optimisation qui désactive les assertions.

L’échantillon binaire contient volontairement un octet nul, des octets UTF-8 invalides et un CRLF. Les assertions ne se contentent pas de rechercher le contenu du fichier : elles vérifient aussi que la ligne de délimitation finale le suit immédiatement, pour éviter de valider des octets présents au mauvais endroit.

Ajouter quatre groupes de cas d’échec et de cas limites

Ajoutez un fichier vide, un texte vide, deux champs texte portant le même nom et une partie dont le nom contient un guillemet double. Même si le contenu est vide, la ligne vide après les en-têtes et le CRLF après le contenu doivent rester présents. Pour les champs répétés, le nombre d’occurrences et leur ordre doivent être conservés. Ajoutez également un nom de fichier en chinois, avec un rejet explicite comme résultat attendu, plutôt qu’un renommage silencieux.

Si l’application doit accepter des noms de fichiers en chinois, déterminez d’abord la convention d’encodage prise en charge par le serveur, puis étendez l’implémentation. Ne considérez pas l’encodage en pourcentage du nom de fichier comme une correction universelle, et n’autorisez pas tous les caractères Unicode en supprimant les contrôles contre l’injection d’en-têtes.

Conserver les mêmes données lors de l’intégration à URLSession

Lors de l’intégration en production, transmettez ensemble boundary et le Data encodé à la couche de construction de la requête. Pour Content-Type, utilisez multipart/form-data; boundary= suivi de la valeur effective, puis fournissez ce même Data à la tâche d’envoi. Ne laissez pas les deux couches générer chacune leur propre délimiteur.

Les petits échantillons peuvent être encodés en mémoire. Pour les fichiers volumineux, écrivez le corps de la requête par morceaux dans un fichier temporaire, puis utilisez l’envoi depuis un fichier. Vous éviterez ainsi de conserver simultanément en mémoire le fichier d’origine et le corps complet de la requête. Après le passage à un encodage sur fichier, répétez les mêmes vérifications d’octets sur le fichier final.

Si l’application définit Content-Length, sa valeur doit correspondre au nombre d’octets du corps final de la requête, et non au nombre de caractères ou à la taille du fichier d’origine. La décision de définir cet en-tête manuellement doit rester centralisée dans la couche réseau. L’encodeur ne doit pas produire de son côté une autre valeur de longueur susceptible d’être inexacte.

Séparer la validation du protocole de celle de l’API

La réussite des vérifications locales des octets prouve uniquement le respect de ce contrat d’encodage. Faites ensuite analyser la requête par une API de test : vérifiez les valeurs des champs, la longueur du fichier et son empreinte, puis contrôlez le comportement réel du serveur face aux champs répétés, aux fichiers vides et aux noms de fichiers.

En cas d’échec, conservez seulement boundary, la longueur en octets et les échantillons synthétiques ; n’archivez pas les contenus réellement envoyés par les utilisateurs. Suivez toujours le même ordre de diagnostic : les délimiteurs d’abord, les en-têtes de champ ensuite, puis la comparaison des octets du fichier. Si le fichier dépasse la limite autorisée ou si l’API refuse son type, revenez aux règles convenues avec le serveur. Ne cherchez pas à masquer une erreur métier en modifiant les fins de ligne.

Enfin, intégrez le script aux vérifications avant commit et faites consigner la version de la chaîne d’outils ainsi que le code de sortie dans le rapport de test. Lors d’un changement d’implémentation du module d’envoi, vous validerez ainsi le même contrat au niveau des octets, plutôt qu’une requête réseau qui a réussi une fois par hasard.

Questions fréquentes

Faut-il conserver un délimiteur fixe en production ?

Non. Réservez-le aux tests reproductibles. En production, générez un délimiteur aléatoire, vérifiez son absence dans chaque partie et utilisez la même valeur dans l’en-tête et le corps.

Peut-on vérifier le corps en le convertissant en texte ?

Pas pour valider les fichiers binaires : leurs octets peuvent être invalides en UTF-8. Comparez directement les valeurs Data et utilisez le texte uniquement pour inspecter les en-têtes et les délimiteurs.

Un Mac dédié pour vos projets

Des nœuds Mac mini physiques, pas des machines virtuelles. Louez à la journée, à la semaine, au mois ou au trimestre. Choisissez votre usage et votre configuration, puis vérifiez la disponibilité en temps réel dans la console.

Choisir un modèle et une durée Voir tous les tarifs