Inkrementelle UTF-8-Decodierung für iOS auf dem Cloud-Mac testen

Inkrementelle UTF-8-Decodierung für iOS auf dem Cloud-Mac testen

Wenn dieselbe Antwort lokal problemlos lesbar ist, in der Pipeline auf einem Cloud-Mac aber gelegentlich fehlerhafte Zeichen enthält, sollten Sie zuerst die Decodierungsgrenzen prüfen. Das Netzwerk liefert Byteblöcke, keine vollständigen Zeichen: Die drei Bytes von „中“ können in drei getrennten Blöcken ankommen. Wer jeden Block einzeln in einen String umwandelt, kann gültigen Text in Ersatzzeichen verwandeln – selbst wenn dabei kein Aufruf fehlschlägt. Das folgende eigenständige Swift-Skript hält die Anforderungen verbindlich fest, ohne sich auf die zufällige Blockaufteilung einer echten Netzwerkverbindung zu verlassen.

Zuerst den Vertrag für den Bytestrom festlegen

Als Eingabe ist ausschließlich UTF-8 zulässig; die Ausgabe darf nur vollständig decodierte Unicode-Skalarwerte enthalten. Leere Blöcke ändern den Zustand nicht, unvollständige Sequenzen bleiben bis zum nächsten Block gespeichert. Sind am Stream-Ende noch Bytes übrig, ist das ein Fehler. Eine momentan leere Textausgabe bedeutet nicht, dass die Verbindung geschlossen wurde.

Hier werden ungültige Eingaben strikt abgewiesen: Einzelne Fortsetzungsbytes, überlange Codierungen, Codierungen von Surrogaten und Sequenzen jenseits der Unicode-Obergrenze dürfen nicht akzeptiert werden. Ungültige Sequenzen durch Ersatzzeichen zu ersetzen, kann für manche Darstellungszwecke sinnvoll sein. Bei Protokollfeldern, Konfigurationen oder dauerhaft gespeicherten Datensätzen darf das jedoch nicht stillschweigend geschehen.

Netzwerkblockgrenzen, Zeichengrenzen und Grenzen von Anwendungsnachrichten sind drei verschiedene Dinge. Erfolgreiche UTF-8-Decodierung bedeutet noch nicht, dass eine vollständige Zeile oder Nachricht eingetroffen ist.

Deklariert das Protokoll eine andere Zeichencodierung, muss die Eingabe am Eingang abgewiesen oder an eine ausdrücklich dafür vorgesehene Konvertierungsschicht übergeben werden. Die Codierung darf nicht durch wiederholtes Ausprobieren erraten werden. Auch eine Byte Order Mark entfernt das Skript nicht automatisch; ob sie zulässig ist, entscheidet die darüberliegende Schicht.

Einen inkrementellen Decoder mit begrenztem Zustand implementieren

Speichern Sie den folgenden Code als utf8-contract.swift. Er hält nur das gerade unvollständige Zeichen zurück; zwischen Aufrufen bleiben höchstens drei Bytes gespeichert. Die Ausgabemenge pro Aufruf hängt weiterhin von der Größe des Eingabeblocks ab. Nach einem Fehler muss die Instanz verworfen werden und darf keine weiteren Daten erhalten.

import Foundation

enum DecodeFailure: Error {
    case invalidByte
    case truncated
}

struct StrictUTF8 {
    private var pending: [UInt8] = []
    private var expected = 0

    mutating func push(_ bytes: [UInt8]) throws -> String {
        var output: [UInt8] = []

        for byte in bytes {
            if pending.isEmpty {
                switch byte {
                case 0x00...0x7F:
                    output.append(byte)
                    continue
                case 0xC2...0xDF:
                    expected = 2
                case 0xE0...0xEF:
                    expected = 3
                case 0xF0...0xF4:
                    expected = 4
                default:
                    throw DecodeFailure.invalidByte
                }
                pending.append(byte)
                continue
            }

            var allowed: ClosedRange<UInt8> = 0x80...0xBF
            if pending.count == 1 {
                switch pending[0] {
                case 0xE0: allowed = 0xA0...0xBF
                case 0xED: allowed = 0x80...0x9F
                case 0xF0: allowed = 0x90...0xBF
                case 0xF4: allowed = 0x80...0x8F
                default: break
                }
            }

            guard allowed.contains(byte) else {
                throw DecodeFailure.invalidByte
            }
            pending.append(byte)

            if pending.count == expected {
                output.append(contentsOf: pending)
                pending.removeAll(keepingCapacity: true)
                expected = 0
            }
        }

        guard let text = String(bytes: output, encoding: .utf8) else {
            throw DecodeFailure.invalidByte
        }
        return text
    }

    func finish() throws {
        guard pending.isEmpty else {
            throw DecodeFailure.truncated
        }
    }
}

Das erste Byte legt die Länge fest, für das zweite wird der zulässige Wertebereich eingeschränkt. Bei den weiteren Bytes wird geprüft, ob sie im Bereich der Fortsetzungsbytes liegen. So werden Surrogat-Codierungen und Werte außerhalb des Unicode-Bereichs bereits vor der String-Konvertierung erkannt. Die Implementierung besitzt keine automatische Fehlerbehebung und behandelt auch ASCII-Bytes nach einem ungültigen Byte nicht als Beginn einer neuen, vertrauenswürdigen Nachricht.

Alle Blockaufteilungen testen statt Anfragen zu wiederholen

Hängen Sie die folgenden Tests an. Für dieses kurze Testmuster ist es gründlicher, zwischen benachbarten Bytes sämtliche Kombinationen aus „trennen“ und „nicht trennen“ durchzuspielen, als nur eine einzige Aufteilung in zwei Blöcke zu prüfen. Zusätzlich werden leere Blöcke eingefügt, um sicherzustellen, dass sie den zurückgehaltenen Zustand nicht löschen.

let sample = "A中🙂e\u{301}"
let bytes = Array(sample.utf8)

for mask in 0..<(1 << (bytes.count - 1)) {
    var decoder = StrictUTF8()
    var chunk: [UInt8] = []
    var result = ""

    for index in bytes.indices {
        chunk.append(bytes[index])
        let cut = index == bytes.count - 1
            || (mask & (1 << index)) != 0
        if cut {
            result += try decoder.push(chunk)
            result += try decoder.push([])
            chunk.removeAll(keepingCapacity: true)
        }
    }

    try decoder.finish()
    precondition(Array(result.utf8) == bytes)
}

Verglichen werden hier die UTF-8-Bytes. So kann ein Stringvergleich unter Berücksichtigung kanonischer Äquivalenz keine inhaltlichen Änderungen verdecken. Das kombinierende Akzentzeichen erinnert außerdem daran, dass ein vollständiger Skalarwert nicht zwangsläufig ein vollständiges, für den Benutzer sichtbares Zeichen ist. Der Decoder darf zuerst den Buchstaben und danach das kombinierende Zeichen ausgeben. Muss eine Oberfläche auf Graphemen arbeiten, gehört diese Verarbeitung in eine höhere Schicht.

Die Zahl der Aufteilungen wächst exponentiell mit der Länge des Testmusters. Der vollständige Durchlauf eignet sich deshalb nur für kurze, feste Testvektoren. Für lange Texte sollten Sie Aufteilungen mit einem festen Zufalls-Seed erzeugen und bei einem Fehler die Folge der Blocklängen speichern.

Auch Ablehnungspfade mit Assertions prüfen

Dass gültiger Text akzeptiert wird, belegt nur die Hälfte der Anforderungen. Ergänzen Sie diese Testvektoren: Sie decken ein einzelnes Fortsetzungsbyte, eine überlange Codierung, eine Surrogat-Codierung, eine Überschreitung der Unicode-Obergrenze, ein falsches Fortsetzungsbyte und eine am Ende abgeschnittene Sequenz ab. Jede Eingabe wird byteweise übergeben, damit sich die Fehlerprüfung nicht durch eine andere Blockaufteilung umgehen lässt.

let invalid: [[UInt8]] = [
    [0x80],
    [0xC0, 0xAF],
    [0xED, 0xA0, 0x80],
    [0xF4, 0x90, 0x80, 0x80],
    [0xE4, 0x41],
    [0xF0, 0x9F, 0x99]
]

for input in invalid {
    var decoder = StrictUTF8()
    var rejected = false

    do {
        for byte in input {
            _ = try decoder.push([byte])
        }
        try decoder.finish()
    } catch {
        rejected = true
    }

    precondition(rejected)
}

Ergänzen Sie außerdem Tests für die Grenzen gültiger Wertebereiche, etwa die Vier-Byte-Sequenz des höchsten zulässigen Skalarwerts. Ob ein String ein Ersatzzeichen enthält, darf nicht das einzige Prüfkriterium sein: Dieses Zeichen kann bereits rechtmäßig in der Eingabe vorkommen. Strikte Decodierung muss anhand der ursprünglichen Bytes entscheiden.

In die Pipeline integrieren und Fehlergrenzen wahren

Führen Sie den folgenden Befehl in der ausgewählten Xcode-Kommandozeilenumgebung aus:

xcrun swift utf8-contract.swift

Ein reguläres Ende bedeutet, dass die Prüfung bestanden wurde. Ein geworfener Fehler oder eine fehlgeschlagene Assertion muss nachfolgende Aufgaben blockieren. Für die eigentliche Integration gehört der Decoder in den gemeinsam genutzten Quellcode und die Testvektoren in ein Test-Target. Das eigenständige Skript eignet sich zur ersten Überprüfung des Algorithmus, ersetzt aber weder den Build noch die Ausführung des tatsächlichen iOS-Targets.

Jede Verbindung erhält eine eigene Instanz, deren Aufrufe seriell in Empfangsreihenfolge erfolgen. Beim erneuten Verbindungsaufbau muss eine neue Instanz entstehen: Ein halbes Zeichen aus der alten Verbindung darf nicht mit dem Anfang der neuen Antwort zusammengesetzt werden. Auch bei einem Abbruch ist der Zustand zu verwerfen, ohne den Pfad für einen erfolgreichen Abschluss aufzurufen.

Prüfen Sie abschließend die konsumierende Seite: Blockweise ausgegebener Text darf zunächst nur in einen temporären Puffer gelangen. Erst wenn eine Anwendungsnachricht vollständig vorliegt und erfolgreich geprüft wurde, dürfen Daten dauerhaft gespeichert oder Aktionen ausgelöst werden. Ist die erste Hälfte bereits in der Datenbank gespeichert und fällt die ungültige Codierung erst in der zweiten Hälfte auf, kann auch ein strikter Decoder die Nebenwirkungen nicht automatisch rückgängig machen. Für Fehlerprotokolle genügen Byte-Offset, Blocklänge und Fehlerkategorie; der vollständige Antwortinhalt sollte nicht standardmäßig hochgeladen werden. Damit lautet das Abnahmekriterium nicht bloß „keine fehlerhaften Zeichen“: Unterschiedliche Blockaufteilungen dürfen das Ergebnis nicht verändern, und Fehler dürfen nicht als erfolgreiche Zustellung gelten.

Häufig gestellte Fragen

Lässt sich jeder empfangene Data-Block einzeln in Text umwandeln?

Nicht sicher. Eine Blockgrenze kann innerhalb eines UTF-8-Zeichens liegen. Strikte Umwandlung kann scheitern, tolerante Umwandlung Ersatzzeichen einsetzen. Unvollständige Folgen müssen gepuffert werden.

Dürfen unvollständige Bytes am Stream-Ende entfallen?

Nein, wenn vollständiger UTF-8-Text gefordert ist. Sie zeigen eine abgeschnittene Folge an; der Abschluss muss fehlschlagen. Eine Wiederherstellung benötigt eine eigene ausdrückliche Protokollregel.

Den ganzen Mac nur für Builds Ihres Teams nutzen

Mieten Sie einen exklusiven Apple Silicon M4 Mac mini und behalten Sie Ihre eigene Toolchain und Runner-Konfiguration. Abrechnung pro Tag, Woche, Monat oder Quartal. Die aktuelle Verfügbarkeit sehen Sie in Echtzeit in der Konsole.

Build-Node mieten

Konfiguration passend zum Build wählen

Prüfen Sie zuerst Arbeitsspeicher und Speicherplatz. Wählen Sie anschließend Abrechnungszeitraum und Node aus.