雲端 Mac 上驗收 iOS 二進位協定:位元組序與截斷邊界

雲端 Mac 上驗收 iOS 二進位協定:位元組序與截斷邊界

雲端 Mac 上驗收 iOS 二進位協定:位元組序與截斷邊界

遠端建置通過了,裝置收到二進位封包後,應用程式卻崩潰了。先別把問題歸咎於網路:長度欄位可能大於實際內容,讀取位置可能未對齊,也可能是把大端序整數當成本機位元組序解讀。在 VpsKeep 雲端 Mac 上,可以先透過命令列 Swift 固定這些測試輸入,再將相同樣本納入 iOS 單元測試。驗收目標是拒絕非法封包,而不是讓每個輸入都能傳回資料。

先定義訊框格式與限制

本文採用最小協定:第一個位元組表示版本,接著四個位元組是大端序的無號負載長度,最後則是原始負載。版本只接受 1,長度可以為零,上限為 1 MiB。每次解碼都必須消耗全部輸入;尾端多出的位元組也視為錯誤。

這裡的單一訊框契約不等於串流傳輸契約。一次網路讀取可能只取得半個訊框,也可能同時包含兩個訊框。接收層負責累積資料與切分訊框;解析器只接收邊界已確定的單一訊框。不要直接把不完整訊框的錯誤當成連線損壞。

長度欄位是不可信的輸入。先檢查上限與剩餘位元組數,再切片或配置記憶體;不要直接依照對方宣告的長度建立緩衝區。

先執行 xcrun swift --version,並保留工具鏈輸出。範例只依賴 Foundation,不需要模擬器或簽署設定。

用明確的游標取代對指標的假設

將以下程式碼儲存為 packet-check.swift。讀取器內部使用位元組陣列,只有讀取成功時才能推進游標。檢查條件採用「請求量不超過剩餘量」,避免先將兩個不可信的數值相加,再進行比較。

import Foundation

enum PacketError: Error {
    case truncated, oversized, version, trailing
}

struct Reader {
    let bytes: [UInt8]
    private(set) var offset = 0

    mutating func take(_ count: Int) throws -> [UInt8] {
        guard count >= 0, count <= bytes.count - offset else {
            throw PacketError.truncated
        }
        let start = offset
        offset += count
        return Array(bytes[start..<offset])
    }

    mutating func u32BE() throws -> UInt32 {
        let b = try take(4)
        return b.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
    }
}

func decode(_ data: Data) throws -> [UInt8] {
    let limit = 1_048_576
    guard data.count <= limit + 5 else {
        throw PacketError.oversized
    }
    var reader = Reader(bytes: Array(data))
    guard try reader.take(1)[0] == 1 else {
        throw PacketError.version
    }
    let declared = try reader.u32BE()
    guard let length = Int(exactly: declared), length <= limit else {
        throw PacketError.oversized
    }
    let payload = try reader.take(length)
    guard reader.offset == reader.bytes.count else {
        throw PacketError.trailing
    }
    return payload
}

逐位元組組合整數,既能明確表達大端序,也不依賴位址對齊。不要用強制解包取代長度檢查,更不要吞掉錯誤後傳回空負載,否則就無法區分合法的空訊框與損壞的封包。

轉換成陣列會產生複製。本文先限制整個訊框的大小,讓複製成本有明確上限;大型檔案協定應改用有界的分塊讀取,而不是提高上限後繼續複製整個訊框。接收層也必須限制累積緩衝區的大小,否則資料可能尚未進入解析器,就已耗盡記憶體。

逐位元組截斷合法封包

將以下程式碼附加到同一個檔案。固定負載包含零位元組與最高位元為 1 的位元組,避免測試只涵蓋文字。遍歷合法訊框的所有不完整前綴:即使只少一個位元組,也必須拋出錯誤,不能崩潰或傳回部分結果。

func expectRejected(_ data: Data) {
    do {
        _ = try decode(data)
    } catch {
        return
    }
    fatalError("Invalid frame accepted")
}

let frame = Data([1, 0, 0, 0, 3, 0, 128, 255])
let payload = try decode(frame)
precondition(payload == [0, 128, 255])

for end in 0..<frame.count {
    expectRejected(Data(frame.prefix(end)))
}

let empty = try decode(Data([1, 0, 0, 0, 0]))
precondition(empty.isEmpty)

expectRejected(Data([2, 0, 0, 0, 0]))
expectRejected(Data([1, 255, 255, 255, 255]))
expectRejected(Data([1, 0, 0, 0, 0, 7]))

執行 xcrun swift packet-check.swift。正常情況下不會有輸出,且結束碼為零;若意外接受非法輸入,程式就會終止。CI 流水線直接檢查結束狀態即可,不必解析本地化的日誌。

這裡不使用會在最佳化建置中停用檢查的選項。移入測試目標後,將成功斷言改為 XCTAssertEqual,拒絕斷言改為 XCTAssertThrowsError,並進一步確認具體的錯誤型別。

補齊長度與位元組序的反例

單靠截斷測試無法證明位元組序正確。加入宣告長度為 256、實際負載也為 256 的固定訊框,長度標頭應為 00 00 01 00。正確的解析器必須傳回完整負載;誤用小端序的實作則會將長度解讀為另一個數值。

同時測試上限值與上限加一:前者在負載完整時應成功,後者即使只有標頭也應立即遭到拒絕。再加入「宣告長度小於實際內容」的輸入,確認嚴格的單一訊框模式不會默默忽略尾端資料。

避免在測試中呼叫正式環境的編碼器來產生所有標頭。編碼器與解碼器可能存在相同的錯誤,導致往返測試仍然通過。至少保留幾組經過人工核對的位元組樣本,並註明每個欄位的意義。

將錯誤行為納入驗收契約

合併前檢查四項:所有完整且合法的訊框都能還原出相同位元組;所有截斷前綴都會明確失敗;未知版本與超限長度會遭到拒絕;尾隨資料無法繞過單一訊框邊界。測試紀錄應包含提交識別碼、工具鏈版本與樣本名稱,但不要記錄真實使用者的負載。

發現失敗時,先保留原始位元組,再縮減成最短反例,不要先修改上限來讓測試通過。如果業務需求新增壓縮或巢狀欄位,還必須分別限制解壓縮後的大小與巢狀深度;外層的長度限制不能取代這些規則。

這套驗收只處理訊框結構,不判斷負載是否符合業務規則。將結構解析、文字解碼與業務驗證分開,之後新增欄位時,才能知道是哪一層改變了拒絕行為。

常見問題

為什麼不直接從 Data 指標載入 UInt32?

任意位元組位置不一定符合 UInt32 的對齊需求,而且仍須轉換位元組序。逐位元組移位組合可明確表達協定順序,避免依賴對齊假設。

空負載是否應視為錯誤?

依協定契約決定。本文允許長度為零;若業務要求內容非空,應加入明確驗證及對應的固定測試樣本。

逐位元組截斷測試能取代模糊測試嗎?

不能。它只涵蓋特定合法封包的所有前綴。隨機欄位組合、巢狀長度與多封包狀態仍需額外測試,並設定資源上限。

在專屬雲端 Mac 上執行工作流程

租用整台實體 Mac mini,非虛擬機器。可選擇日租、週租、月租或季租;下單前請先確認機型與節點,實際供應狀況以控制台即時顯示為準。