クラウド Mac で検証する iOS UUID:厳密な解析と正規化

クラウド Mac で検証する iOS UUID:厳密な解析と正規化

API が同じレコードの UUID を大文字から小文字に変えただけなのに、クライアントが別のレコードを挿入してしまう。この問題の原因は、たいてい乱数生成器ではありません。文字列をそのまま主キーとして扱い、比較、キャッシュ、永続化でそれぞれ異なるルールを使っていることにあります。クラウド Mac では、まずこの境界の動作を検証する独立した Swift の受け入れテスト用スクリプトを作り、その後 iOS プロジェクトのテストに組み込めます。シミュレータを起動する必要はありません。

まず入力の契約を明確にする

この記事で受け付けるのは、標準的な 36 バイトのテキスト形式です。16 進文字を長さ 8、4、4、4、12 の五つのグループに分け、ASCII のハイフンで区切ります。入力では大文字と小文字の混在を認め、出力は小文字に統一します。前後の空白、波括弧付きの形式、ハイフンのない形式は受け付けません。

これは、ほかの表現がすべて無効だという意味ではありません。API の入力形式を一つに定めるための方針です。既存の API が別の形式を許容している場合は、明示的な互換レイヤーで対応し、コアのパーサーが暗黙に補正する設計にはしないでください。

また、構文として正しいことと、業務上許可されることは別です。全ゼロの UUID は構造上は有効ですが、注文やドキュメントの識別子としては禁止できます。UUID のバージョンを制限するかどうかも API の契約で決めるべきです。3 番目のグループの先頭文字が特定の数字ではないというだけで、直ちにエラーにしてはいけません。

UUID は識別子であり、アクセス資格情報ではありません。形式が正しくても、サーバー側では現在のユーザーに対象レコードを読み取る権限があるかを確認する必要があります。

実行可能な厳密パーサーを書く

ノード上で xcrun --find swift を実行し、現在のツールチェーンで Swift が使えることを確認します。以下の内容を uuid-contract.swift として保存してください。最初にバイト構造を検証してから Foundation で解析することで、不正規な入力をどこまで許容するかという OS バージョンごとの挙動に依存しないようにします。

import Foundation

enum IdentifierError: Error {
    case invalidSyntax
    case zeroNotAllowed
}

func parseIdentifier(
    _ text: String,
    allowZero: Bool = false
) throws -> UUID {
    let bytes = Array(text.utf8)
    let separators: Set<Int> = [8, 13, 18, 23]

    guard bytes.count == 36 else {
        throw IdentifierError.invalidSyntax
    }

    for (index, byte) in bytes.enumerated() {
        if separators.contains(index) {
            guard byte == 45 else {
                throw IdentifierError.invalidSyntax
            }
        } else {
            let isHex = (48...57).contains(byte)
                || (65...70).contains(byte)
                || (97...102).contains(byte)
            guard isHex else {
                throw IdentifierError.invalidSyntax
            }
        }
    }

    guard let value = UUID(uuidString: text) else {
        throw IdentifierError.invalidSyntax
    }

    let zero = UUID(
        uuid: (0, 0, 0, 0, 0, 0, 0, 0,
               0, 0, 0, 0, 0, 0, 0, 0)
    )
    guard allowZero || value != zero else {
        throw IdentifierError.zeroNotAllowed
    }
    return value
}

func canonical(_ value: UUID) -> String {
    value.uuidString.lowercased()
}

見た目の文字数ではなく UTF-8 のバイトを使うのは、全角文字やハイフンに似た Unicode 記号を明確に排除するためです。ここでの lowercased() は、検証済みの UUID の出力だけを処理します。任意のユーザー入力を先に変換してから受け入れるものではありません。

固定のテストデータで境界を確認する

同じファイルの末尾に、続けてテストを追加します。成功ケースでは値の一致、正規化、冪等性を検証し、失敗ケースでは空白、文字の置き換え、長さの変化を確認します。固定の不正入力をランダムな UUID で置き換えないでください。失敗時の状況を再現しにくくなります。

let upper = "550E8400-E29B-41D4-A716-446655440000"
let lower = upper.lowercased()
let value = try parseIdentifier(upper)

precondition(value == (try parseIdentifier(lower)))
precondition(canonical(value) == lower)
let reparsed = try parseIdentifier(canonical(value))
precondition(canonical(reparsed) == lower)

let invalid = [
    "",
    " " + lower,
    lower + "\n",
    "{" + lower + "}",
    lower.replacingOccurrences(of: "-", with: ""),
    lower.replacingOccurrences(of: "-", with: "-"),
    lower.replacingOccurrences(of: "5", with: "g"),
    lower + "0"
]

for text in invalid {
    do {
        _ = try parseIdentifier(text)
        fatalError("Invalid UUID accepted")
    } catch IdentifierError.invalidSyntax {
    }
}

let zero = "00000000-0000-0000-0000-000000000000"
do {
    _ = try parseIdentifier(zero)
    fatalError("Zero UUID accepted")
} catch IdentifierError.zeroNotAllowed {
}
_ = try parseIdentifier(zero, allowZero: true)

xcrun swift uuid-contract.swift を実行します。成功時は何も出力されず、失敗時はプロセスが非ゼロの終了コードで終了します。コード中の空の catch ブロックは、想定したエラーが発生したことを示しています。それ以外のエラーは握りつぶされず、スクリプトを失敗させます。XCTest に移す際も、具体的なエラーを検証するアサーションを使い、この区別を維持してください。

データの境界で正規化する

解析は、ネットワークレスポンス、ディープリンクのパラメーター、インポートファイルのデータがドメインモデルに入る位置で行います。ドメイン層では UUID を保持し、リクエストの送信やテキストフィールドへの書き込みが必要になった時点で canonical を呼び出します。UI 層やキャッシュ層が、それぞれ独自に文字列置換を行う設計は避けてください。

ストレージの移行には、特に注意が必要です。まず読み取り専用のコピーで既存のキーを正規形に対応付け、「一つの正規形に複数の既存キーが対応する」ケースを集計します。衝突が見つかったら、業務上のレコード統合ルールに従って処理し、その後で外部キーと一意性制約を更新します。いきなり一括で小文字に変換すると、過去の二つのレコードを一つにまとめてしまうおそれがあります。

UUID の文字列順を時系列順として扱わない

ランダム UUID の辞書順は、作成順を表しません。一覧に安定した順序が必要な場合は、明示的な作成日時フィールドを使い、日時が同じ場合の第 2 ソートキーとして正規化済みの識別子を追加します。時刻の意味を持つ UUID 方式を採用する場合も、バージョンと並べ替えルールは別途定める必要があります。

クラウド Mac の受け入れ検証フローに組み込む

ArmVNC ノードでスクリプトを実行する前に、コミット識別子、sw_vers、xcrun swift --version を記録します。これらはツールチェーンによる違いを説明するための情報であり、業務データは含みません。失敗ケースには固定のテスト値を使い、ユーザーのレコードをそのまま保存しないでください。

継続的インテグレーションに組み込んだら、少なくとも次の四つを確認します。

  • 標準形式のテキストを解析でき、大文字と小文字が異なっても同じ値になる。
  • 空白、不正な区切り文字、16 進文字以外の文字を含む入力は必ず失敗する。
  • 全ゼロ値は明示的な方針に従って処理され、通常の構文エラーとして扱われない。
  • 既存キーの移行では先に衝突を確認し、移行前後で関連レコード数が変わらない。

最後に、統合テストを一つ追加します。同じエンティティを大文字と小文字の UUID でそれぞれ入力しても、ストレージ内のレコードは一つのままで、更新が元のレコードに反映されることを確認します。パーサーが保証するのは入力境界の一貫性です。キャッシュ、データベース、業務上の更新処理が本当にこの契約を守っていることは、統合テストで初めて確かめられます。

よくある質問

UUID の大文字と小文字は別の識別子ですか?

いいえ。UUID 値へ変換して比較し、文字列として保存する場合は小文字の標準形式に統一します。既存の文字列キーは、正規化による衝突を移行前に確認してください。

全ゼロの UUID は必ず拒否すべきですか?

構造上は有効です。エンティティの識別子として禁止するかどうかは業務ルールなので、明示的な設定にし、許可と拒否の両方をテストします。

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

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