远程构建通过了,设备收到的二进制报文却让应用崩溃。先不要归因于网络:长度字段可能比实际内容大,读取位置可能未对齐,也可能把大端整数按本机顺序解释了。在 VpsKeep 云端 Mac 上,可以先用命令行 Swift 固定这些输入,再把相同样本接入 iOS 单元测试。验收目标是拒绝非法报文,而不是让每个输入都返回数据。
先写清楚帧格式与限制
本文使用一个最小协议:首字节为版本,随后四字节为大端无符号载荷长度,最后是原始载荷。版本只接受 1,长度允许为零,上限为 1 MiB。单次解码必须消费全部输入,多余尾部字节也算错误。
这里的单帧契约不等于流式传输契约。一次网络读取可能只返回半帧,也可能包含两帧。接收层负责累积和拆帧;解析器只接收已经划定边界的一帧。不要把半帧错误直接当成连接损坏。
长度字段是不可信输入。先检查上限和剩余字节,再切片或分配内存;不要按对方声明的长度直接创建缓冲区。
先执行 xcrun swift --version,保留工具链输出。样例只依赖 Foundation,不需要模拟器或签名配置。
用显式游标代替指针假设
将下面代码保存为 packet-check.swift。读取器内部使用字节数组,游标只能由成功读取推进。检查采用“请求量不超过剩余量”,避免把两个不可信数相加后再比较。
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
}
逐字节组合整数,既表达了大端顺序,也不依赖地址对齐。不要用强制解包替代长度检查,更不要把错误吞掉后返回空载荷,否则合法空帧与损坏报文将无法区分。
转换为数组会产生复制。本文先限制整帧大小,使复制成本有界;大文件协议应改用受限分块读取,而不是扩大上限后继续整帧复制。接收层也必须限制累计缓冲区,否则内存可能在进入解析器之前就耗尽。
逐字节截断合法报文
把以下代码追加到同一文件。固定载荷包含零字节和高位字节,避免测试只覆盖文本。遍历合法帧的所有不完整前缀:少一个字节也必须抛出错误,不能崩溃或返回部分结果。
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]))
运行 xcrun swift packet-check.swift。正常结果是无输出且退出码为零;意外接受非法输入会终止程序。流水线直接检查退出状态,不必解析本地化日志。
这里不使用优化构建禁用检查的选项。迁入测试目标后,将成功断言改为 XCTAssertEqual,拒绝断言改为 XCTAssertThrowsError,并进一步核对具体错误类型。
补齐长度与字节序反例
截断测试不能单独证明字节序正确。加入声明长度为 256、实际载荷也为 256 的固定帧,长度头应为 00 00 01 00。正确解析器必须返回完整载荷;误用小端的实现会把长度解释为另一数值。
同时测试上限本身与上限加一:前者有完整载荷时应成功,后者只有头部也应立即拒绝。再加入“声明长度小于实际内容”的输入,验证严格单帧模式不会默默忽略尾部。
避免在测试里调用生产编码器生成所有头部。编码器和解码器可能共享同一个错误,往返测试仍会通过。至少保留几组人工核对的字节样本,并注明每个字段的意义。
把错误行为纳入验收契约
合并前检查四项:所有完整合法帧还原相同字节;所有截断前缀明确失败;未知版本和超限长度被拒绝;尾随数据不会绕过单帧边界。测试记录带上提交标识、工具链版本和样本名称,不记录真实用户载荷。
发现失败时先保留原始字节,再缩减为最短反例,不要先修改上限让测试通过。若业务新增压缩或嵌套字段,还要分别约束解压后的大小与嵌套深度,外层长度限制不能替代这些规则。
这套验收只处理帧结构,不判断载荷的业务合法性。把结构解析、文本解码和业务校验分开,后续增加字段时就能知道是哪一层改变了拒绝行为。
常见问题
为什么不直接把 Data 指针读取成 UInt32?
任意字节位置不一定满足 UInt32 的对齐要求,直接加载还需要另行处理字节序。按字节移位组合数值,可以显式表达协议顺序并避开对齐假设。
空载荷应该视为错误吗?
由协议契约决定。本文允许长度为零的载荷;如果业务要求非空,应在协议或业务校验层明确拒绝,并补充对应固定样本。
逐字节截断测试可以代替模糊测试吗?
不可以。它只覆盖固定合法帧的所有前缀;随机字段组合、嵌套长度和多帧状态仍需要额外样本或有资源上限的模糊测试。