跳至主要內容

雲端 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

選擇獨享實體節點,提供日租、週租、月租和季租。旺季可加租;租期結束後若未續租,服務即會停止。實際可用狀態請以控制台即時回報為準。

讓下一次建置有台機器可用

先確認規格與租期,再為專案選擇合適的獨享實體節點。