在 Windows 上完成业务代码后,把 iOS 工程搬到云端 Mac 出包,接口里的十六进制字段也需要随代码一起验收。设备报文、摘要和二进制标识符经常使用这种表示:字符串看起来正常,不代表解码得到的字节正确。尤其要防止忽略非法字符、丢失前导零,以及把不完整输入自动补成另一条数据。
本文用独立 Swift 脚本建立字节契约,再说明如何接入工程测试。不依赖网络服务,也不需要签名材料,适合在归档前运行。
先确定输入与输出契约
解码器只接受 ASCII 的 0–9、a–f、A–F,每两个字符表示一个字节;编码器统一输出小写,每个字节固定两位。大小写可以等价,空格、换行、0x 前缀和全角字符则不属于该协议。
空字符串在底层表示空数据。如果业务字段不允许为空,应由业务校验拒绝,不要让通用解码器暗中承担字段规则。输入上限按解码后的字节数计算,并明确包含边界。
严格解码与人工输入整理是两层职责。需要支持粘贴带空格的数据时,先在界面适配层明确转换,再交给严格解码器;机器接口不要默认纠错。
同样,十六进制只是字节表示,不提供加密、完整性或来源认证。通过解码验收,不能推导出报文可信。
实现拒绝式解码与定宽编码
把以下内容保存为 HexContract.swift。实现先检查长度和预算,再分配输出;逐字节判断字符,避免使用会跳过失败项的 compactMap。本例设置独立错误类型,方便测试区分失败原因。
import Foundation
enum HexError: Error, Equatable {
case invalidLimit
case oddLength
case tooLarge
case invalidCharacter
}
func nibble(_ c: UInt8) throws -> UInt8 {
switch c {
case 48...57: return c - 48
case 65...70: return c - 65 + 10
case 97...102: return c - 97 + 10
default: throw HexError.invalidCharacter
}
}
func decodeHex(_ text: String, maxBytes: Int) throws -> Data {
guard maxBytes >= 0 else { throw HexError.invalidLimit }
let count = text.utf8.count
guard count % 2 == 0 else { throw HexError.oddLength }
guard count / 2 <= maxBytes else { throw HexError.tooLarge }
var iterator = text.utf8.makeIterator()
var output = Data()
output.reserveCapacity(count / 2)
while let high = iterator.next() {
guard let low = iterator.next() else {
throw HexError.oddLength
}
let a = try nibble(high)
let b = try nibble(low)
output.append((a << 4) | b)
}
return output
}
func encodeHex(_ data: Data) -> String {
let alphabet = Array("0123456789abcdef".utf8)
var output = [UInt8]()
for byte in data {
output.append(alphabet[Int(byte >> 4)])
output.append(alphabet[Int(byte & 15)])
}
return String(decoding: output, as: UTF8.self)
}
这里使用 UTF-8 字节数,而不是用户可见字符数量;非 ASCII 输入最终必须失败。错误判定顺序也是契约的一部分:负预算优先,其次是奇数长度,然后是容量,最后才检查字母表。
上限检查没有计算 maxBytes * 2,因此不会因预算乘法溢出。它控制的是输出分配,不是请求接收过程;接口仍需在读取请求体时限制大小,不能先接收无限字符串再指望解码器节省内存。
加入固定向量与拒绝样本
将以下测试接在同一文件后。固定向量检查前导零和大小写;全部单字节测试使用标准格式化结果作为独立参照,避免编码器与解码器共同犯错却仍能往返成功。
func reject(
_ text: String,
limit: Int = 16,
expected: HexError
) {
do {
_ = try decodeHex(text, maxBytes: limit)
preconditionFailure("Expected rejection")
} catch let error as HexError {
precondition(error == expected, "Unexpected error")
} catch {
preconditionFailure("Unexpected error type")
}
}
let fixed = try decodeHex("000aFf", maxBytes: 3)
precondition(fixed == Data([0, 10, 255]))
precondition(encodeHex(fixed) == "000aff")
let empty = try decodeHex("", maxBytes: 0)
precondition(empty.isEmpty)
for value in 0...255 {
let reference = String(format: "%02x", value)
let decoded = try decodeHex(reference, maxBytes: 1)
precondition(decoded == Data([UInt8(value)]))
precondition(encodeHex(Data([UInt8(value)])) == reference)
}
reject("f", expected: .oddLength)
reject("0g", expected: .invalidCharacter)
reject("0x00", expected: .invalidCharacter)
reject("00\n0", expected: .invalidCharacter)
reject("00", expected: .invalidCharacter)
reject("0000", limit: 1, expected: .tooLarge)
reject("", limit: -1, expected: .invalidLimit)
let sequence = Data((0...255).map { UInt8($0) })
let restored = try decodeHex(encodeHex(sequence), maxBytes: 256)
precondition(restored == sequence)
奇数长度样本应明确失败,不能把 f 猜成 0f 或 f0。还要覆盖“非法字符出现在末尾”的情况,确认实现不会返回已经解出的前半段。对于安全相关字段,失败应是整体失败,而不是可继续使用的部分结果。
在云端 Mac 执行并接入工程
先确认当前开发工具可用,再执行脚本:
xcrun --find swift
xcrun swift HexContract.swift
成功时脚本不输出内容,退出码为零;断言失败或未处理错误会产生非零退出。不要使用关闭检查的优化模式运行这份脚本,也不要在流水线里追加忽略退出码的处理。
独立脚本验证的是当前 macOS Swift 实现。正式接入时,把编解码器放进应用共享模块,将上述向量搬入 XCTest,用 XCTAssertEqual 与 XCTAssertThrowsError 替换检查,并在已有 iOS 测试目标执行。这样才能同时发现目标成员资格、条件编译和实际调用路径的问题。
若已有实现,不要再维护一份测试专用副本;让测试直接导入生产模块。否则脚本通过了,应用仍可能使用旧解码器。
用边界清单收束回归
合并前逐项检查:零字节是否编码成 00,空输入是否符合字段规则,最大容量是否可通过,超过一个字节是否拒绝,非法输入是否完全不返回数据。再补充确定性的长序列,而不是只使用随机数据碰运气。
对混合大小写可以接受,但不要把原始字符串直接当唯一键:aa 与 AA 表示相同字节。需要规范化存储时,先严格解码,再统一编码;不要在验证前依赖字符串大小写转换替代字节检查。
最后保留工具链版本、失败样本名称和测试退出结果即可。涉及真实设备报文或令牌时,不把完整输入写进日志,用固定合成向量复现。验收的目标不是让所有字符串都能转换,而是让合法字节准确往返,让越界输入明确停止。
常见问题
十六进制输入是否应该自动删除空格和补齐奇数长度?
机器接口不应默认修复。应拒绝空格和奇数长度;如果人工录入界面允许格式整理,应在独立适配层完成,再调用严格解码器。
只检查编码后再解码能否证明实现正确?
不能。编码器和解码器可能共享同一个错误。还要加入固定字节向量、全部单字节覆盖、非法字符拒绝和前导零检查。