Test iOS UUID contracts: strict parsing and byte order
An API returns an uppercase UUID for an order, while the local cache stores it in lowercase—and the deduplication logic compares the strings directly. Elsewhere, a UUID is stored in binary form, but the byte order in its first few groups changes when it is read back. You can catch these problems in pre-build checks on a cloud Mac without launching a simulator. First, use fixed Swift test vectors to pin down the contracts for input, identity comparison and serialization. Then have the iOS project use the same rules.
Separate format validation from business policy
A UUID is a 128-bit identifier. Its common text representation consists of five groups of hexadecimal characters, with lengths of 8, 4, 4, 4 and 12. There are always four hyphens, but that shape alone does not mean the UUID is a randomly generated version 4 identifier.
Define three rules up front: whether to accept uppercase letters, whether to allow leading or trailing whitespace, and whether to restrict the version. This article accepts either letter case and rejects whitespace, braces, hyphen-free forms and non-ASCII characters. It checks for version 4 and the corresponding variant only on fields that explicitly require random identifiers.
“Can be parsed” describes a library capability; “may enter the system” is a protocol decision. Do not treat a permissive parser's behavior as your API contract.
The all-zero UUID also needs an explicit policy. It may satisfy the general text format, but that does not automatically make it a substitute for “not set.” For optional fields, prefer explicit null semantics.
Run fixed test vectors on a cloud Mac
Prepare a cloud Mac with your chosen development toolchain selected, then save the following as uuid-contract.swift. The script uses only Foundation and requires no project, network access or random numbers. Here, precondition stops the test script on failure; it is not intended for handling external input in an application.
import Foundation
func parseUUID(_ text: String, requireV4: Bool = false) -> UUID? {
let bytes = Array(text.utf8)
guard bytes.count == 36 else { return nil }
let separators: Set<Int> = [8, 13, 18, 23]
for (index, byte) in bytes.enumerated() {
if separators.contains(index) {
guard byte == 45 else { return nil }
} else {
let isHex = (48...57).contains(byte)
|| (65...70).contains(byte)
|| (97...102).contains(byte)
guard isHex else { return nil }
}
}
guard let value = UUID(uuidString: text) else { return nil }
if requireV4 {
let raw = value.uuid
guard raw.6 >> 4 == 4, raw.8 & 0xC0 == 0x80 else {
return nil
}
}
return value
}
let valid = "00112233-4455-4677-8899-aabbccddeeff"
let parsed = parseUUID(valid, requireV4: true)!
precondition(parsed.uuidString.lowercased() == valid)
precondition(parseUUID(valid.uppercased()) == parsed)
let invalid = [
" " + valid,
valid + "\n",
"{" + valid + "}",
valid.replacingOccurrences(of: "-", with: ""),
valid.replacingOccurrences(of: "a", with: "a")
]
for input in invalid {
precondition(parseUUID(input) == nil)
}
let otherVersion = "00112233-4455-5677-8899-aabbccddeeff"
precondition(parseUUID(otherVersion) != nil)
precondition(parseUUID(otherVersion, requireV4: true) == nil)
let wrongVariant = "00112233-4455-4677-0899-aabbccddeeff"
precondition(parseUUID(wrongVariant) != nil)
precondition(parseUUID(wrongVariant, requireV4: true) == nil)
let raw = parsed.uuid
let wire: [UInt8] = [
raw.0, raw.1, raw.2, raw.3,
raw.4, raw.5, raw.6, raw.7,
raw.8, raw.9, raw.10, raw.11,
raw.12, raw.13, raw.14, raw.15
]
precondition(wire == [
0x00, 0x11, 0x22, 0x33,
0x44, 0x55, 0x46, 0x77,
0x88, 0x99, 0xaa, 0xbb,
0xcc, 0xdd, 0xee, 0xff
])
Run xcrun swift uuid-contract.swift. It produces no output on success and exits with a nonzero status on failure. The pipeline must preserve that exit status. Also record xcrun swift --version so you can rerun the checks and compare results after a toolchain update.
Use a single path for identity comparison
Within the application, compare UUID values wherever possible rather than raw strings from the API. When you need a text database key, call uuidString.lowercased() after strict parsing succeeds to obtain a single canonical, lowercase, hyphenated representation.
Do not remove hyphens or trim whitespace before attempting to parse. These “repairs” hide upstream protocol errors and can map two inputs that should both have been rejected to the same key. If the presentation layer accepts pasted input, it can offer an explicit cleanup step, but the API layer must not silently adopt that behavior.
Before migrating legacy data, count collisions after case normalization. If two records differ only in the letter case of their UUIDs, for example, do not simply overwrite one of them. Check their business relationships first, then merge or quarantine the anomalous records and retain the migration results.
Verify the full byte order at binary boundaries
The vector above lists 16 bytes in the order of the hexadecimal characters in the UUID's text representation. This order must be part of the wire protocol, not a consequence of how the current machine stores integers.
Do not read the first eight bytes as a native integer and then write it out directly. Nor should you assume that an interface uses the same memory layout as this protocol just because it calls the identifier a GUID. When exchanging data across languages, require both sides to produce exactly the same hexadecimal byte sequence from the same fixed text.
Read-back tests need an independent sample too
“Encode it ourselves, then decode it ourselves” is not enough: a round trip can still succeed if both sides make the same mistake. The receiver should use a fixed 16-byte sample directly and assert that the recovered text equals the original vector. Also test that 15-byte and 17-byte inputs are rejected, rather than padded with zeros or truncated.
Connect the checks to the actual parsing path
The script verifies an example implementation of the rules. In production, move the parsing function into a shared module used by API decoding, cache reads and writes, and import tools. Put the corresponding vectors in the test target so that the check script cannot pass while the application still uses a different, more permissive path.
Cover four groups of cases: valid uppercase and lowercase forms must resolve to the same identity; invalid characters and whitespace must be rejected; version restrictions must apply only to designated fields; and binary samples must match byte for byte. Check the returned results in every group—do not merely assert that nothing crashed.
Failure logs should record only the field name, failure category and sample number, never real business identifiers. Rerun this contract test suite before updating the toolchain, replacing the server-side serialization library or importing historical data. Pinpointing a failure in the text, policy or byte-order layer is more direct than repeatedly guessing between the cache and the API.
Frequently asked questions
Should every UUID field require version 4?
No. Enforce version 4 only when the interface explicitly requires random version 4 identifiers. Keep general text validation separate from the allowed-version policy.
Do differently capitalized UUIDs identify different records?
No. Parse valid input and use one lowercase, hyphenated representation as the key. Keep the original spelling only for diagnostics, not identity comparisons.
Give your next build a dedicated Mac.
Choose a cloud Mac that isn’t a virtual machine, and keep your existing Xcode and CI workflows. Select a machine based on your workload; check the console for real-time availability.