Test a strict iOS hex codec on a cloud Mac
After finishing the business logic on Windows and moving the iOS project to a cloud Mac for a build, the hexadecimal fields in your interfaces need to be validated alongside the code. Device messages, digests, and binary identifiers often use this representation: a string that looks valid does not necessarily decode to the correct bytes. In particular, guard against silently ignoring invalid characters, dropping leading zeros, or automatically completing incomplete input into different data.
This article establishes a byte-level contract with a standalone Swift script, then explains how to integrate it into project tests. It requires neither network services nor signing credentials, making it suitable for running before an archive build.
Define the input and output contract first
The decoder accepts only ASCII 0–9, a–f, and A–F, with each pair of characters representing one byte. The encoder always emits lowercase, using exactly two characters per byte. Uppercase and lowercase are equivalent, but spaces, newlines, the 0x prefix, and full-width characters are outside this protocol.
At the codec level, an empty string represents empty data. If a business field must not be empty, reject it through business validation rather than silently making the general-purpose decoder enforce field-specific rules. Define the input limit in decoded bytes and make it explicitly inclusive.
Strict decoding and cleanup of human-entered input are separate responsibilities. If users need to paste data containing spaces, perform an explicit conversion in the UI adapter layer before passing it to the strict decoder. Machine interfaces should not correct input by default.
Hexadecimal is also just a representation of bytes; it provides no encryption, integrity protection, or source authentication. Passing decoding checks does not establish that a message is trustworthy.
Implement reject-on-error decoding and fixed-width encoding
Save the following as HexContract.swift. The implementation checks length and the byte budget before allocating output, then validates characters byte by byte. It avoids compactMap, which can silently discard failed conversions. Separate error cases let the tests distinguish why an input was rejected.
import Foundation
enum HexError: Error, Equatable {
case invalidLimit
case oddLength
case tooLarge
case invalidCharacter
}
func nibble(_ c: UInt8) throws -> UInt8 {
switch c {
case 48...57: return c - 48
case 65...70: return c - 65 + 10
case 97...102: return c - 97 + 10
default: throw HexError.invalidCharacter
}
}
func decodeHex(_ text: String, maxBytes: Int) throws -> Data {
guard maxBytes >= 0 else { throw HexError.invalidLimit }
let count = text.utf8.count
guard count % 2 == 0 else { throw HexError.oddLength }
guard count / 2 <= maxBytes else { throw HexError.tooLarge }
var iterator = text.utf8.makeIterator()
var output = Data()
output.reserveCapacity(count / 2)
while let high = iterator.next() {
guard let low = iterator.next() else {
throw HexError.oddLength
}
let a = try nibble(high)
let b = try nibble(low)
output.append((a << 4) | b)
}
return output
}
func encodeHex(_ data: Data) -> String {
let alphabet = Array("0123456789abcdef".utf8)
var output = [UInt8]()
for byte in data {
output.append(alphabet[Int(byte >> 4)])
output.append(alphabet[Int(byte & 15)])
}
return String(decoding: output, as: UTF8.self)
}
The length here is measured in UTF-8 bytes, not user-perceived characters; non-ASCII input must ultimately fail. Error precedence is part of the contract as well: a negative budget comes first, followed by odd length, then capacity, and finally character-set validation.
The limit check does not calculate maxBytes * 2, so multiplying the budget cannot cause an overflow. This limit controls output allocation, not request ingestion. The interface still needs to enforce a size limit while reading the request body; accepting an unbounded string first and expecting the decoder to save memory is not sufficient.
Add fixed vectors and rejection cases
Append the following tests to the same file. The fixed vectors check leading zeros and letter case. Tests covering every single-byte value use standard formatting as an independent reference, preventing matching bugs in the encoder and decoder from producing a misleadingly successful round trip.
func reject(
_ text: String,
limit: Int = 16,
expected: HexError
) {
do {
_ = try decodeHex(text, maxBytes: limit)
preconditionFailure("Expected rejection")
} catch let error as HexError {
precondition(error == expected, "Unexpected error")
} catch {
preconditionFailure("Unexpected error type")
}
}
let fixed = try decodeHex("000aFf", maxBytes: 3)
precondition(fixed == Data([0, 10, 255]))
precondition(encodeHex(fixed) == "000aff")
let empty = try decodeHex("", maxBytes: 0)
precondition(empty.isEmpty)
for value in 0...255 {
let reference = String(format: "%02x", value)
let decoded = try decodeHex(reference, maxBytes: 1)
precondition(decoded == Data([UInt8(value)]))
precondition(encodeHex(Data([UInt8(value)])) == reference)
}
reject("f", expected: .oddLength)
reject("0g", expected: .invalidCharacter)
reject("0x00", expected: .invalidCharacter)
reject("00\n0", expected: .invalidCharacter)
reject("00", expected: .invalidCharacter)
reject("0000", limit: 1, expected: .tooLarge)
reject("", limit: -1, expected: .invalidLimit)
let sequence = Data((0...255).map { UInt8($0) })
let restored = try decodeHex(encodeHex(sequence), maxBytes: 256)
precondition(restored == sequence)
Odd-length inputs must fail explicitly: the decoder must not guess that f means 0f or f0. Also cover an invalid character at the end of the input to confirm that the implementation does not return the prefix it has already decoded. For security-sensitive fields, failure must reject the entire input rather than return a usable partial result.
Run on a cloud Mac and integrate into the project
First confirm that the current developer tools are available, then run the script:
xcrun --find swift
xcrun swift HexContract.swift
On success, the script produces no output and exits with code zero. A failed assertion or an unhandled error produces a nonzero exit. Do not run this script with an optimization mode that disables checks, and do not add pipeline handling that ignores the exit code.
The standalone script validates the current macOS Swift implementation. For project integration, put the codec in a shared application module, move these vectors into XCTest, replace the checks with XCTAssertEqual and XCTAssertThrowsError, and run them in the existing iOS test target. This is necessary to catch issues with target membership, conditional compilation, and actual call paths as well.
If an implementation already exists, do not maintain a separate copy just for testing; have the tests import the production module directly. Otherwise, the script may pass while the app still uses an old decoder.
Finish regression testing with a boundary checklist
Before merging, check each boundary: a zero byte encodes as 00, empty input follows the field rules, input at the maximum capacity succeeds, input one byte over the limit is rejected, and invalid input returns no data at all. Add deterministic long sequences rather than relying only on random data and luck.
Mixed case is acceptable, but do not use the original string directly as a unique key: aa and AA represent the same bytes. When canonical storage is needed, decode strictly first, then re-encode consistently. Do not rely on changing a string's case before validation as a substitute for checking its bytes.
Finally, retain the toolchain version, the names of failing test cases, and the test exit status. When working with real device messages or tokens, do not log the full input; reproduce issues with fixed synthetic vectors. The goal is not to make every string convertible, but to ensure that valid bytes round-trip exactly and out-of-bounds input is explicitly rejected.
Frequently asked questions
Should a hex decoder strip spaces or pad odd-length input?
Not at a machine interface. Reject both by default. If a manual-entry screen permits cleanup, implement it in a separate adapter before strict decoding.
Are encode-decode round trips enough?
No. Both functions can share a defect. Add fixed byte vectors, exhaustive single-byte coverage, invalid-input rejection, and leading-zero checks.
Rent a dedicated Mac for your build.
Choose a nearby location, then check the memory, storage, and billing period your project needs. Each physical machine is dedicated to you, not a virtual machine. Check the console for real-time availability.