Integerüberläufe in iOS-Kapazitätsberechnungen testen

Integerüberläufe in iOS-Kapazitätsberechnungen testen

Integerüberläufe in iOS-Kapazitätsberechnungen testen

Ein Download-Modul reserviert Kapazität nach dem Schema „Anzahl der Blöcke × Blockgröße“, die Bildverarbeitung fordert einen Puffer für „Zeilenanzahl × Bytes pro Zeile“ an. Im Normalfall funktionieren solche Berechnungen unauffällig. Bei beschädigten Dateien oder unerwarteten API-Daten können sie jedoch zunächst einen Integerüberlauf auslösen und anschließend die Anwendung zum Absturz bringen. Auf einem Cloud-Mac lässt sich die reine Rechenlogik in ein eigenständiges Swift-Prüfprogramm auslagern. So entstehen zuverlässige Regressionstests für Grenzfälle im iOS-Projekt, ohne einen Simulator zu starten.

Zuerst einen Vertrag für Kapazitätsberechnungen festlegen

Dieser Artikel behandelt ausschließlich nichtnegative ganzzahlige Kapazitäten, keine Gleitkommanäherungen. Dafür gelten drei Regeln: Negative Zahlen sind ungültige Eingaben, Ergebnisse außerhalb des Wertebereichs von Int führen zu einem Überlauffehler, und der Divisor bei aufgerundeter Division muss größer als null sein. Eine Kapazität von null ist zulässig, ein Divisor von null dagegen nicht.

Eingaben dürfen nicht erst in einen vorzeichenlosen Typ umgewandelt und danach geprüft werden. Eine gewöhnliche Konvertierung eines negativen Werts in eine vorzeichenlose Ganzzahl kann sofort einen Trap auslösen; eine abschneidende Konvertierung kann daraus hingegen einen riesigen positiven Wert machen. Bei Zeichenketten muss zunächst feststehen, dass das Parsen als Int erfolgreich war. Ein fehlgeschlagener Parse-Vorgang darf nicht als null behandelt werden.

Ein darstellbares Rechenergebnis bedeutet nicht, dass sich die entsprechende Kapazität auch bereitstellen lässt. Die Prüfung des Integer-Wertebereichs und die Prüfung fachlicher Ressourcenlimits sind zwei unabhängige Schranken. Erst wenn beide Prüfungen bestanden sind, dürfen Ressourcen angefordert werden.

„Anzahl“, „Bytes pro Element“ und „Gesamtzahl der Bytes“ sollten jeweils eigene, eindeutige Namen erhalten, damit unterschiedliche Einheiten nicht vermischt werden. Fehler sollten außerdem die betroffene Berechnungsphase angeben. Sensible Roheingaben gehören jedoch nicht vollständig ins Protokoll.

Überläufe melden statt Werte umbrechen lassen

Die gewöhnlichen Operatoren + und * lösen bei einem Überlauf einen Laufzeitfehler aus. &+ und &* führen dagegen ausdrücklich einen Wraparound aus und können eine übermäßig große Kapazität in einen kleinen Wert verwandeln. Für die Kapazitätsprüfung sind deshalb Operationen nötig, die sowohl ein Ergebnis als auch ein Überlaufkennzeichen liefern: Zuerst wird das Kennzeichen geprüft, erst danach darf das Ergebnis verwendet werden.

Den folgenden Code als arithmetic.swift speichern. Er ist unabhängig vom Anwendungsprojekt und führt keine tatsächlichen großen Speicherallokationen aus.

enum CapacityError: Error, Equatable {
    case invalidInput
    case overflow
}

func checkedAdd(_ a: Int, _ b: Int) throws -> Int {
    guard a >= 0, b >= 0 else {
        throw CapacityError.invalidInput
    }
    let result = a.addingReportingOverflow(b)
    guard !result.overflow else {
        throw CapacityError.overflow
    }
    return result.partialValue
}

func checkedMultiply(_ a: Int, _ b: Int) throws -> Int {
    guard a >= 0, b >= 0 else {
        throw CapacityError.invalidInput
    }
    let result = a.multipliedReportingOverflow(by: b)
    guard !result.overflow else {
        throw CapacityError.overflow
    }
    return result.partialValue
}

func ceilingDivide(_ n: Int, by d: Int) throws -> Int {
    guard n >= 0, d > 0 else {
        throw CapacityError.invalidInput
    }
    let q = n / d
    return n % d == 0 ? q : try checkedAdd(q, 1)
}

func require(_ condition: Bool) {
    guard condition else {
        fatalError("Boundary check failed")
    }
}

func expectError(
    _ expected: CapacityError,
    _ operation: () throws -> Int
) {
    do {
        _ = try operation()
        fatalError("Expected capacity error")
    } catch let error as CapacityError {
        require(error == expected)
    } catch {
        fatalError("Unexpected error type")
    }
}

require(try checkedAdd(Int.max, 0) == Int.max)
require(try checkedMultiply(Int.max, 1) == Int.max)
require(try checkedMultiply(0, Int.max) == 0)
require(try ceilingDivide(0, by: 4) == 0)
require(try ceilingDivide(8, by: 4) == 2)
require(try ceilingDivide(9, by: 4) == 3)
require(try ceilingDivide(Int.max, by: 1) == Int.max)
require(try ceilingDivide(Int.max, by: 2) == Int.max / 2 + 1)

expectError(.overflow) {
    try checkedAdd(Int.max, 1)
}
expectError(.overflow) {
    try checkedMultiply(Int.max, 2)
}
expectError(.invalidInput) {
    try checkedMultiply(-1, 0)
}
expectError(.invalidInput) {
    try ceilingDivide(1, by: 0)
}
expectError(.invalidInput) {
    try ceilingDivide(Int.min, by: -1)
}

Die Multiplikation einer negativen Zahl mit null prüft bewusst die Reihenfolge der Validierung. Gibt die Implementierung vorzeitig null zurück, sobald ein Faktor null ist, kann eine ungültige Eingabe den Vertrag umgehen. Die Tests müssen den Fehlertyp prüfen; es genügt nicht, dass irgendein Fehler aufgetreten ist.

Beim Aufrunden nicht zuerst den Divisor addieren

Die Anzahl der Blöcke wird häufig als (n + d - 1) / d berechnet. Was mathematisch korrekt ist, muss bei Ganzzahlen fester Breite nicht sicher sein: Schon die vorangestellte Addition kann überlaufen, selbst wenn der spätere Quotient klein ist.

Das Beispiel berechnet zuerst Quotient und Rest und addiert nur dann eins, wenn ein Rest vorhanden ist. Durch die Beschränkung auf nichtnegative Dividenden und positive Divisoren wird auch der besondere Überlauffall vermieden, bei dem der kleinste vorzeichenbehaftete Integerwert durch minus eins geteilt wird.

Die Tests sollten mindestens null, eine Division ohne Rest, einen Rest von eins, den größten Integerwert und einen Divisor von null abdecken. Int.max darf nicht durch eine fest codierte Dezimalkonstante ersetzt werden. Mit der vom Typ bereitgestellten Grenze wird die Integerbreite des tatsächlichen Kompilierungsziels geprüft, nicht eine vom Entwickler vorausgesetzte Plattformbreite.

In beiden Build-Modi ausführen

Auf dem Cloud-Mac muss eine Entwicklungs-Toolchain ausgewählt sein. Zuerst prüfen, ob der Compiler verfügbar ist, und das Programm anschließend in beiden Modi kompilieren und ausführen:

set -eu
xcrun swiftc --version
mkdir -p .arithmetic-check
xcrun swiftc -Onone arithmetic.swift \
  -o .arithmetic-check/debug
.arithmetic-check/debug
xcrun swiftc -O arithmetic.swift \
  -o .arithmetic-check/optimized
.arithmetic-check/optimized

Bei Erfolg beendet sich das Programm ohne Ausgabe. Jede fehlgeschlagene Prüfung und jeder unbehandelte Fehler lässt den Schritt fehlschlagen. Die Ausgabe darf nicht so durch eine Pipe geleitet werden, dass der erfolgreiche Exit-Status eines Protokollierungswerkzeugs den Exit-Status des Programms ersetzt.

Das Beispiel verwendet kein assert, weil solche Prüfungen in optimierten Builds entfernt werden können. Die eigene Funktion require nutzt eine explizite Bedingung und fatalError; dadurch bleiben die Prüfungen in beiden hier verwendeten Modi erhalten. Zusätzliche, nicht standardmäßige Optimierungseinstellungen, die Sicherheitsprüfungen zur Laufzeit abschwächen, sollten ebenfalls nicht aktiviert werden.

Bei der Integration Ressourcenlimits zusätzlich prüfen

Dieses eigenständige Programm ist eine minimale Prüfung des Rechenvertrags. Bei der produktiven Integration sollten Anwendung und Tests dieselbe Produktionsimplementierung verwenden, statt dauerhaft zwei ähnliche Funktionssätze zu pflegen.

Die Aufrufkette in dieser Reihenfolge prüfen:

  • Schlägt das Parsen der Roheingabe fehl, sofort einen Fehler zurückgeben. Nicht mit einer Standardkapazität fortfahren.
  • Vor geprüften Multiplikationen und Additionen sicherstellen, dass die Einheiten übereinstimmen, damit nicht bereits ein Zwischenausdruck überläuft.
  • Das Ergebnis mit dem vom Produkt vorgegebenen Limit pro Vorgang vergleichen und erst danach über die Ressourcenanforderung entscheiden.
  • Beim Umwandeln in einen schmaleren Integertyp eine fehlschlagbare exakte Konvertierung verwenden und das Abschneiden von Werten ablehnen.
  • Sicherstellen, dass auf Fehlerpfaden weder Dateien erstellt noch Puffer angefordert oder nachgelagerte Aufgaben eingereicht werden.

Abschließend einen Testfall ergänzen, der rechnerisch gültig ist, aber das fachliche Ressourcenlimit überschreitet. Dabei prüfen, dass dieser Fall getrennt von Überlauffehlern behandelt wird. So lassen sich bei der Fehlersuche ungültige Eingaben, nicht im Typ darstellbare Werte und Ablehnungen durch die Ressourcenrichtlinie klar unterscheiden, statt sämtliche Grenzfälle als Speichermangel einzuordnen.

Häufig gestellte Fragen

Darf bei einem Überlauf einfach Int.max zurückgegeben werden?

Standardmäßig sollte ein eindeutiger Fehler zurückgegeben werden. Ein gesättigter Wert verschleiert den Unterschied zwischen einem gültigen großen Ergebnis und einem Fehler. Sättigung ist nur bei ausdrücklich festgelegter Semantik sinnvoll.

Brauchen die Tests einen iOS-Simulator?

Nein. Das Beispiel nutzt ausschließlich die Swift-Standardbibliothek und läuft direkt auf dem Cloud-Mac. Oberfläche, plattformspezifische Typen und tatsächliche Speicherallokationen müssen separat geprüft werden.

Build-Aufgaben an einen exklusiven Cloud-Mac übergeben

Wählen Sie einen Apple-Silicon-Server und nutzen Sie ihn exklusiv – mit nativer macOS-Oberfläche und Kommandozeile. Modell, Laufzeit und Standort richten sich nach Ihrer Bestellbestätigung.