跳到正文

云端 Mac 上的 iOS 位集合序列化验收:位序与长度边界

云端 Mac 上的 iOS 位集合序列化验收:位序与长度边界

团队把“已读条目”压成位集合,亚太成员写入,北美成员接着读取:本机回读一直通过,换一版客户端却发现第一个与第八个标记颠倒。问题通常不在网络,而在二进制约定没有写清。云端 Mac 可以把这类检查变成固定任务,让每次修改都与相同字节样本比较,而不是只看界面是否正常。

先固定线格式,不照搬内存布局

本文实现一个独立于业务的参考格式:前四个字节是大端无符号位数,后面紧跟位集合载荷。位索引从零开始,每个字节内低位优先,因此索引零对应最低位,索引八对应第二个字节的最低位。

载荷字节数按“位数除以八,再加上余数是否非零”计算。位数为零时只有头部;尾字节未使用的高位必须为零;额外字节也拒绝。这个格式没有版本字段,正式接入已有协议时,应在外层明确版本,不能直接替换旧数据。

回读一致只说明两端彼此认同。编码器和解码器若同时把位序写反,测试仍会通过,必须另有人工计算的字节向量。

不要直接序列化结构体内存、机器字长或原生整数缓冲区。那会把对齐、端序与实现细节带进持久化格式。

写一个有输入上限的参考实现

在云端 Mac 的独立目录保存为 BitsetCheck.swift,用开发工具链中的 Swift 执行。下面把上限设为一百万位,这是示例防护策略,不是文件格式的理论极限。入口先检查上限和完整长度,再创建结果数组,避免依据不可信头部进行大额分配。

import Foundation

enum FormatError: Error {
    case invalid
}

let maxBits = 1_000_000

func encode(_ bits: [Bool]) throws -> [UInt8] {
    guard bits.count <= maxBits else {
        throw FormatError.invalid
    }
    let count = UInt32(bits.count)
    var bytes = [
        UInt8(truncatingIfNeeded: count >> 24),
        UInt8(truncatingIfNeeded: count >> 16),
        UInt8(truncatingIfNeeded: count >> 8),
        UInt8(truncatingIfNeeded: count)
    ]
    let size = bits.count / 8 + (bits.count % 8 == 0 ? 0 : 1)
    bytes += Array(repeating: UInt8(0), count: size)
    for index in bits.indices where bits[index] {
        bytes[4 + index / 8] |= UInt8(1) << (index % 8)
    }
    return bytes
}

func decode(_ bytes: [UInt8]) throws -> [Bool] {
    guard bytes.count >= 4 else {
        throw FormatError.invalid
    }
    let raw = bytes.prefix(4).reduce(UInt32(0)) {
        ($0 << 8) | UInt32($1)
    }
    guard raw <= UInt32(maxBits) else {
        throw FormatError.invalid
    }
    let count = Int(raw)
    let size = count / 8 + (count % 8 == 0 ? 0 : 1)
    guard bytes.count == 4 + size else {
        throw FormatError.invalid
    }
    let remainder = count % 8
    if remainder != 0 {
        let allowed = UInt8((1 << remainder) - 1)
        guard bytes[bytes.count - 1] & ~allowed == 0 else {
            throw FormatError.invalid
        }
    }
    return (0..<count).map {
        bytes[4 + $0 / 8] & (UInt8(1) << ($0 % 8)) != 0
    }
}

func require(_ condition: Bool) {
    if !condition {
        fatalError("Bitset check failed")
    }
}

func mustReject(_ bytes: [UInt8]) {
    do {
        _ = try decode(bytes)
    } catch {
        return
    }
    fatalError("Invalid input accepted")
}

let expected: [UInt8] = [0, 0, 0, 9, 0x81, 0x01]
var sample = Array(repeating: false, count: 9)
for index in [0, 7, 8] {
    sample[index] = true
}
require(try encode(sample) == expected)
require(try decode(expected) == sample)

for count in [0, 1, 7, 8, 9, 63, 64, 65] {
    let bits = (0..<count).map { $0 % 3 == 0 }
    let bytes = try encode(bits)
    require(try decode(bytes) == bits)
}

mustReject([])
mustReject([0, 0, 0, 9, 0x81])
mustReject(expected + [0])
mustReject([0, 0, 0, 1, 0x80])
mustReject([0xFF, 0xFF, 0xFF, 0xFF])

执行 xcrun swift BitsetCheck.swift。成功时静默退出;断言失败或未处理错误会让进程返回失败。迁入工程后,把参考函数替换为实际编解码入口,保留向量和拒绝测试,否则验收的仍只是示例。

用固定向量拆开编码与解码

九个位中打开零、七、八,载荷必须为十六进制 81 01。第一个字节同时覆盖最低位与最高位,第二个字节检查跨字节续接,头部则明确逻辑长度不是载荷长度。

还应补上全关、全开、仅首位与仅末位四类样本。八个位全开应为 FF,九个位全开应为 FF 01,不能写成 FF FF。未使用位不是“反正读取不到”的垃圾空间,规范化才能保证字节比较、摘要计算与缓存键一致。

固定向量应进入版本库。修改协议时先评审预期字节,再更新实现;不要由待测编码器自动生成并覆盖预期文件,那相当于让被测对象给自己出答案。

把非法输入当成单独的测试面

截断样本要覆盖头部不足四字节,以及头部完整但载荷少一字节。额外字节也要拒绝,否则上游拼包错误会被悄悄吞掉。非零填充用一位长度配 80,能同时发现位序混淆与尾部校验遗漏。

长度上限应根据业务允许的条目数量制定,并在应用层接收数据时限制总字节数。示例解码器接收的数组已经存在,无法阻止网络层预先读入超大响应;流式接收还需要自己的累计字节预算。

不要先用头部长度分配,再检查实际载荷,也不要用“长度加七再除八”处理任意外部整数。这里先限长,再用除法与余数计算,检查顺序也是测试契约的一部分。

接入回归时记录可比较的证据

将脚本放在仓库测试目录,由同一提交中的业务实现执行。记录提交标识、xcrun swift --version 输出与失败向量名称;不要记录用户的真实已读状态或整段业务载荷。

建议让每种错误都有稳定名称,例如截断、填充非法与长度超限,而不是把所有失败都压成一个网络错误。上面的统一错误仅用于缩短参考代码,生产实现应保留可诊断的分类。

最后做一次负向演练:故意交换字节内位序,确认固定向量失败;取消尾部检查,确认非法填充被测试抓住。只有这些故障确实能阻断任务,云端回归才真正守住了格式边界,而不只是重复一次成功回读。

常见问题

为什么编码后再解码的回读测试还不够?

编码器和解码器可能使用同一种错误位序,从而互相抵消。必须另加人工计算的固定字节向量,直接比较编码结果,并独立验证解码含义。

位集合最后一个字节的未使用位应该怎样处理?

本文格式要求未使用的高位全部为零,编码器只写有效位,解码器遇到非零填充直接拒绝,使同一逻辑集合只有一种规范编码。

按你的项目节奏租用 Mac mini

选择独享物理节点,按天、周、月、季计费。旺季可以加订,到期不续即停;实际可用状态以控制台实时返回为准。

让下一次构建有台机器可用

先对照规格和周期,再为项目选择合适的独享物理节点。