团队把“已读条目”压成位集合,亚太成员写入,北美成员接着读取:本机回读一直通过,换一版客户端却发现第一个与第八个标记颠倒。问题通常不在网络,而在二进制约定没有写清。云端 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 输出与失败向量名称;不要记录用户的真实已读状态或整段业务载荷。
建议让每种错误都有稳定名称,例如截断、填充非法与长度超限,而不是把所有失败都压成一个网络错误。上面的统一错误仅用于缩短参考代码,生产实现应保留可诊断的分类。
最后做一次负向演练:故意交换字节内位序,确认固定向量失败;取消尾部检查,确认非法填充被测试抓住。只有这些故障确实能阻断任务,云端回归才真正守住了格式边界,而不只是重复一次成功回读。
常见问题
为什么编码后再解码的回读测试还不够?
编码器和解码器可能使用同一种错误位序,从而互相抵消。必须另加人工计算的固定字节向量,直接比较编码结果,并独立验证解码含义。
位集合最后一个字节的未使用位应该怎样处理?
本文格式要求未使用的高位全部为零,编码器只写有效位,解码器遇到非零填充直接拒绝,使同一逻辑集合只有一种规范编码。