云端 Mac 上验收 iOS 二进制协议:字节序与截断边界

云端 Mac 上验收 iOS 二进制协议:字节序与截断边界

远程构建通过了,设备收到的二进制报文却让应用崩溃。先不要归因于网络:长度字段可能比实际内容大,读取位置可能未对齐,也可能把大端整数按本机顺序解释了。在 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 的对齐要求,直接加载还需要另行处理字节序。按字节移位组合数值,可以显式表达协议顺序并避开对齐假设。

空载荷应该视为错误吗?

由协议契约决定。本文允许长度为零的载荷;如果业务要求非空,应在协议或业务校验层明确拒绝,并补充对应固定样本。

逐字节截断测试可以代替模糊测试吗?

不可以。它只覆盖固定合法帧的所有前缀;随机字段组合、嵌套长度和多帧状态仍需要额外样本或有资源上限的模糊测试。

把步骤放到独享云 Mac 上运行

租用整台物理 Mac mini,非虚拟机。按天、周、月、季选择周期,先核对机型和节点,再确认订单;实际可用状态以控制台实时返回为准。