クラウド Mac で検証する iOS 整数オーバーフローと容量計算

クラウド Mac で検証する iOS 整数オーバーフローと容量計算

クラウド Mac で検証する iOS 整数オーバーフローと容量計算

ダウンロード処理では「チャンク数 × チャンクサイズ」で容量を確保し、画像処理では「行数 × 1 行あたりのバイト数」でバッファを確保します。通常は問題のない計算でも、破損したファイルや異常な API データが入力されると、整数オーバーフローを起こし、アプリのクラッシュにつながることがあります。クラウド Mac では、純粋な算術ロジックを Swift の検証用実行ファイルに切り出すことで、シミュレータを起動せずに、iOS プロジェクト向けの安定した境界値回帰テストを構築できます。

まず容量計算の契約を定義する

この記事で扱うのは非負整数の容量だけで、浮動小数点数による近似値は対象にしません。ルールは 3 つです。負数は不正な入力とし、Int の範囲を超えた場合はオーバーフローエラーを返し、切り上げ除算の除数は正数に限定します。容量がゼロであることは許容しますが、除数がゼロであることは許容しません。

検証する前に符号なし整数型へ変換してはいけません。負数を通常の方法で符号なし整数に変換すると、その場でトラップして終了する可能性があります。一方、切り詰め変換では、巨大な正数に変わることがあります。文字列から入力を受け取る場合も、まず Int としての解析が成功したことを確認し、解析の失敗をゼロで置き換えないようにします。

計算結果を整数型で表現できることと、その容量を確保できることは別です。整数の範囲チェックと業務上のリソース上限チェックは独立した検証であり、両方を通過して初めてリソースを確保できます。

「個数」「1 個あたりのバイト数」「合計バイト数」は、それぞれ区別できる名前にして、異なる単位の混在を防ぎます。エラーには、どの演算段階で発生したかも含めるべきですが、機密性のある生の入力を丸ごとログに書き込んではいけません。

ラップアラウンドではなく、オーバーフローを報告する演算を使う

通常の +、* は、オーバーフローすると実行時エラーになります。一方、&+、&* は明示的にラップアラウンドを行うため、極端に大きな容量が小さな値に化けることがあります。容量の検証には、計算結果とオーバーフローの有無を同時に取得できる演算が必要です。先にフラグを確認し、その後で結果を使用します。

次のコードを arithmetic.swift として保存してください。アプリのプロジェクトには依存せず、実際に大容量のメモリを確保することもありません。

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

ここでは、検証の順序を確認するために、あえて負数とゼロの乗算をテストしています。「乗数がゼロならゼロを返す」という早期リターンを先に実行すると、不正な入力が契約の検証をすり抜けてしまいます。テストではエラーの種類まで確認し、単に「何らかのエラーが発生した」ことだけで済ませないようにします。

切り上げ除算で先に除数を加えない

チャンク数の計算には、よく (n + d - 1) / d が使われます。数学的に正しくても、固定幅整数で安全とは限りません。最終的な商が小さくても、その前の加算で範囲を超える可能性があります。

この例では、先に商と余りを求め、余りがある場合だけ 1 を加えます。入力を非負数、除数を正数に限定しているため、符号付き整数の最小値を負の 1 で割るという、除算特有のオーバーフローも発生しません。

テストには少なくとも、ゼロ、割り切れる場合、余りが 1 の場合、整数の最大値、除数がゼロの場合を含めます。Int.max を特定の 10 進定数で置き換えてはいけません。型が提供する境界値を直接使うことで、開発者が記憶しているプラットフォームの前提ではなく、実際のコンパイルターゲットの整数幅を検証できます。

2 種類のコンパイルモードで実行する

クラウド Mac では、使用する開発ツールチェーンが選択されている必要があります。まずコンパイラを利用できることを確認し、それぞれのモードでコンパイルして実行します。

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

成功すると、プログラムは何も出力せずに終了します。検証の失敗や未処理のエラーがあれば、そのステップは失敗します。パイプを使って、プログラムの終了コードがログツールの成功ステータスに置き換わらないようにしてください。

この例で assert を使っていないのは、最適化ビルドでは削除される可能性があるためです。独自の require は明示的な条件判定と fatalError を使っており、ここで使用するどちらのモードでもチェックが残ります。実行時の安全性チェックを弱めるような、非標準の最適化設定も追加しないでください。

アプリへの組み込み時にはリソース上限も確認する

この独立したプログラムは、算術の契約を検証するための最小構成です。実際に組み込む際は、似た関数を 2 組ずっと保守するのではなく、アプリとテストが同じ本番実装を参照するようにします。

呼び出しの流れを、次の順序で確認してください。

  • 元の入力の解析に失敗したら、直ちにエラーを返す。デフォルトの容量で処理を続けない。
  • 各値の単位をそろえてから、オーバーフローチェック付きの乗算・加算を行う。途中の式で先にオーバーフローしないようにする。
  • 結果を製品仕様で定めた 1 回あたりの上限と比較し、確保するかどうかを判断する。
  • よりビット幅の狭い整数型へ変換する場合は、失敗を検出できる厳密な変換を使い、切り詰めを許容しない。
  • 失敗時の経路で、ファイルの作成、バッファの確保、後続タスクの投入が行われていないことを確認する。

最後に、「算術としては正しいが、業務上のリソース上限を超える」ケースを追加し、オーバーフローエラーとは別に扱われることを確認します。これにより、失敗の原因を調べる際に、不正な入力、型の表現範囲超過、リソースポリシーによる拒否を明確に区別でき、あらゆる境界値の問題をメモリ不足として片付けずに済みます。

よくある質問

オーバーフローしたら Int.max を返してもよいですか?

原則として明示的なエラーを返します。Int.max への置き換えは正常な大容量と失敗を区別できなくします。飽和演算を仕様として定めた場合に限り採用してください。

iOS シミュレータの起動は必要ですか?

サンプルは Swift 標準ライブラリだけで動作するため不要です。ただし、画面表示、プラットフォーム固有の型、実際のメモリ確保は別途プラットフォーム上で検証します。

ビルドは専有クラウド Mac におまかせ

Apple Silicon 搭載の物理ノードを選び、マシンを丸ごと専有できます。macOS のグラフィカル環境もコマンドラインもそのまま利用可能です。モデル、利用期間、ノードは注文内容をご確認ください。