云端 Mac 上的 iOS 十六进制编解码契约验收

云端 Mac 上的 iOS 十六进制编解码契约验收

在 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 表示相同字节。需要规范化存储时,先严格解码,再统一编码;不要在验证前依赖字符串大小写转换替代字节检查。

最后保留工具链版本、失败样本名称和测试退出结果即可。涉及真实设备报文或令牌时,不把完整输入写进日志,用固定合成向量复现。验收的目标不是让所有字符串都能转换,而是让合法字节准确往返,让越界输入明确停止。

常见问题

十六进制输入是否应该自动删除空格和补齐奇数长度?

机器接口不应默认修复。应拒绝空格和奇数长度;如果人工录入界面允许格式整理,应在独立适配层完成,再调用严格解码器。

只检查编码后再解码能否证明实现正确?

不能。编码器和解码器可能共享同一个错误。还要加入固定字节向量、全部单字节覆盖、非法字符拒绝和前导零检查。

按出包任务,租一台独享 Mac。

先选就近节点,再核对工程所需的内存、存储与计费周期。整台物理机独享,非虚拟机;实际可用状态以控制台实时返回为准。

把准备做完,再开始出包。

核对配置与计费周期后再下单。连接、环境迁移与首次构建的操作步骤,可在支持页查看。