Test iOS binary decoder boundaries on a cloud Mac
The remote build passed, but a binary packet received by the device crashes the app. Don’t blame the network just yet: the length field may exceed the actual content, the read position may be misaligned, or a big-endian integer may have been interpreted using native byte order. On a VpsKeep cloud Mac, you can first establish fixed test inputs with command-line Swift, then use the same fixtures in iOS unit tests. The acceptance goal is to reject invalid packets—not to return data for every input.
Define the frame format and limits first
This article uses a minimal protocol: the first byte is the version, followed by a four-byte, big-endian unsigned payload length, then the raw payload. Only version 1 is accepted. The length may be zero, with a maximum of 1 MiB. Each decode operation must consume the entire input; extra trailing bytes are also an error.
This single-frame contract is not a streaming transport contract. One network read may return half a frame or contain two frames. The receive layer is responsible for accumulating data and splitting it into frames; the parser accepts only one frame whose boundaries have already been established. Do not treat an incomplete-frame error as proof that the connection is broken.
The length field is untrusted input. Check the limit and the remaining byte count before slicing data or allocating memory; never create a buffer directly from the length declared by the sender.
Start by running xcrun swift --version and save the toolchain output. The example depends only on Foundation and requires neither a simulator nor code-signing configuration.
Use an explicit cursor instead of pointer assumptions
Save the following code as packet-check.swift. The reader uses a byte array internally, and its cursor advances only after a successful read. The bounds check asks whether the requested count fits within the remaining count, avoiding the addition of two untrusted values before comparing them.
import Foundation
enum PacketError: Error {
case truncated, oversized, version, trailing
}
struct Reader {
let bytes: [UInt8]
private(set) var offset = 0
mutating func take(_ count: Int) throws -> [UInt8] {
guard count >= 0, count <= bytes.count - offset else {
throw PacketError.truncated
}
let start = offset
offset += count
return Array(bytes[start..<offset])
}
mutating func u32BE() throws -> UInt32 {
let b = try take(4)
return b.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
}
}
func decode(_ data: Data) throws -> [UInt8] {
let limit = 1_048_576
guard data.count <= limit + 5 else {
throw PacketError.oversized
}
var reader = Reader(bytes: Array(data))
guard try reader.take(1)[0] == 1 else {
throw PacketError.version
}
let declared = try reader.u32BE()
guard let length = Int(exactly: declared), length <= limit else {
throw PacketError.oversized
}
let payload = try reader.take(length)
guard reader.offset == reader.bytes.count else {
throw PacketError.trailing
}
return payload
}
Assembling the integer byte by byte makes the big-endian order explicit and avoids any dependence on address alignment. Do not replace length checks with force unwrapping, and never swallow an error and return an empty payload. That would make a valid empty frame indistinguishable from a damaged packet.
Converting to an array creates a copy. Here, the total frame size is capped first, so the copying cost is bounded. Protocols for large files should use bounded chunked reads rather than simply raising the limit and continuing to copy entire frames. The receive layer must also cap its accumulation buffer; otherwise, memory may be exhausted before the data reaches the parser.
Truncate a valid packet at every byte boundary
Append the following code to the same file. The fixed payload contains a zero byte and bytes with the high bit set, so the tests cover more than text. Iterate through every incomplete prefix of the valid frame: even a frame missing just one byte must throw an error, not crash or return a partial result.
func expectRejected(_ data: Data) {
do {
_ = try decode(data)
} catch {
return
}
fatalError("Invalid frame accepted")
}
let frame = Data([1, 0, 0, 0, 3, 0, 128, 255])
let payload = try decode(frame)
precondition(payload == [0, 128, 255])
for end in 0..<frame.count {
expectRejected(Data(frame.prefix(end)))
}
let empty = try decode(Data([1, 0, 0, 0, 0]))
precondition(empty.isEmpty)
expectRejected(Data([2, 0, 0, 0, 0]))
expectRejected(Data([1, 255, 255, 255, 255]))
expectRejected(Data([1, 0, 0, 0, 0, 7]))
Run xcrun swift packet-check.swift. A successful run produces no output and exits with status zero; unexpectedly accepting invalid input terminates the program. The pipeline can check the exit status directly without parsing localized logs.
Do not use build options that disable these checks in optimized builds. After moving the fixtures into a test target, replace the success assertions with XCTAssertEqual and the rejection assertions with XCTAssertThrowsError, then verify the specific error types as well.
Add length and byte-order counterexamples
Truncation tests alone cannot prove that byte order is correct. Add a fixed frame with a declared length of 256 and an actual payload of 256; its length header must be 00 00 01 00. A correct parser must return the complete payload. An implementation that mistakenly uses little-endian order will interpret the length as a different value.
Test both the limit itself and the limit plus one. The former should succeed when the complete payload is present; the latter should be rejected immediately, even when only the header is supplied. Also add an input whose declared length is smaller than its actual content, verifying that strict single-frame mode does not silently ignore trailing data.
Avoid using the production encoder to generate every header in the tests. The encoder and decoder may share the same bug, allowing round-trip tests to pass anyway. Keep at least a few manually verified byte fixtures and document what each field means.
Make error behavior part of the acceptance contract
Before merging, check four things: every complete, valid frame reproduces the same bytes; every truncated prefix fails explicitly; unknown versions and lengths above the limit are rejected; and trailing data cannot bypass the single-frame boundary. Test records should include the commit identifier, toolchain version, and fixture names, but not real user payloads.
When a test fails, preserve the original bytes first, then reduce the input to the shortest counterexample. Do not change the limit just to make the test pass. If business requirements introduce compression or nested fields, separately constrain the decompressed size and nesting depth. An outer length limit cannot replace those rules.
These acceptance checks cover only frame structure, not the payload’s validity under business rules. Keep structural parsing, text decoding, and business validation separate so that, when fields are added later, you can identify which layer changed the rejection behavior.
Frequently asked questions
Why not load UInt32 directly from a Data pointer?
An arbitrary byte offset may not meet UInt32 alignment requirements, and byte order still needs explicit handling. Assembling individual bytes avoids those assumptions.
Should an empty payload be rejected?
That is a protocol decision. This example accepts a zero-length payload. If your application requires content, add an explicit validation rule and a dedicated fixture.
Do truncation tests replace fuzz testing?
No. They cover only the prefixes of a particular valid frame. Field combinations, nested lengths and multi-frame state need additional tests with resource limits.
Run your workflow on a dedicated cloud Mac
Rent an entire physical Mac mini—not a virtual machine. Choose a daily, weekly, monthly, or quarterly plan. Check the model and location before confirming your order; availability is confirmed in real time by the console.