雲端 Mac 上的 iOS CRC-32 串流驗證與標準向量測試
同一份檔案,一次處理全部內容時檢查碼驗證通過,改成分塊讀取卻失敗,通常不是雲端 Mac 的網路出了問題,而是每讀取一塊就重設了 CRC 狀態。跨區團隊將檔案處理邏輯納入持續測試時,需要固定的不只是輸入檔案,還有演算法變體、分塊方式與結果表示格式。以下用 Swift 寫一份可直接在雲端 Mac 上執行的驗收範例,再將相同檢查整合到 iOS 專案。
先確立 CRC-32 的計算規約
本文使用 CRC-32/ISO-HDLC:正常表示法的多項式為 0x04C11DB7,反射實作使用 0xEDB88320,初始值與最終 XOR 值皆為 0xFFFFFFFF,輸入與輸出均採反射慣例。不要只在介面中標示「CRC32」,否則兩端可能選用不同的變體。
標準測試字串 123456789 的結果應為 CBF43926,空輸入的結果為 00000000。這裡的字串以 UTF-8 轉成位元組,不包含換行字元。在終端機準備樣本時,請留意部分輸出指令會自動附加換行字元。
CRC-32 用來偵測意外損毀,不提供防竄改保證。若需確認檔案來源,應驗證可信的數位簽章,不能將附在檔案旁的 CRC 當成安全憑證。
傳輸規約也必須明確定義結果是八位十六進位文字,還是四位元組整數;若為後者,還要另外約定位元組順序。演算法的「反射」不代表網路傳輸採用小端序。
實作可保留內部狀態的 CRC-32
將以下程式碼儲存為 crc-check.swift。範例逐位元處理,方便檢視參數;它適合作為正確性基準,不代表高吞吐量實作。正式環境若改成查表版本,也應沿用後面的測試。
import Foundation
struct CRC32 {
private var register: UInt32 = 0xFFFFFFFF
mutating func update<S: Sequence>(_ bytes: S)
where S.Element == UInt8 {
for byte in bytes {
register ^= UInt32(byte)
for _ in 0..<8 {
let lowBit = register & 1
register >>= 1
if lowBit != 0 {
register ^= 0xEDB88320
}
}
}
}
var value: UInt32 {
register ^ 0xFFFFFFFF
}
}
func checksum(_ bytes: [UInt8]) -> UInt32 {
var crc = CRC32()
crc.update(bytes)
return crc.value
}
func require(_ condition: Bool, _ name: String) {
if !condition {
fatalError(name)
}
}
let vector = Array("123456789".utf8)
require(checksum(vector) == 0xCBF43926, "reference-vector")
require(checksum([]) == 0, "empty-input")
for split in 0...vector.count {
var crc = CRC32()
crc.update(vector[..<split])
let interim = crc.value
require(interim == checksum(Array(vector[..<split])),
"intermediate-value")
crc.update([])
crc.update(vector[split...])
require(crc.value == 0xCBF43926, "split-invariance")
}
value 只是內部暫存器的唯讀投影,讀取中間結果不會改變後續計算。不要在這個屬性中修改暫存器,也不要將經過最終 XOR 的結果當成下一塊的初始狀態。每個檔案都建立新實例,比提供容易誤用的重設介面更直觀。
從標準向量擴充到分塊測試
九位元組的向量只能驗證一小部分行為。接著加入涵蓋所有位元組值的確定性輸入,讓最高位元為 1 的位元組、零位元組,以及不足一整塊的尾端資料都納入測試。
let payload = (0..<4097).map {
UInt8(truncatingIfNeeded: $0)
}
let expected = checksum(payload)
for size in [1, 7, 64, 1024, 4096, 8192] {
var crc = CRC32()
var offset = 0
while offset < payload.count {
let end = min(offset + size, payload.count)
crc.update(payload[offset..<end])
offset = end
}
require(crc.value == expected, "chunk-size-\(size)")
}
var first = CRC32()
first.update(vector)
var second = CRC32()
second.update(payload)
require(first.value == 0xCBF43926, "independent-state")
require(second.value == expected, "second-stream")
這裡驗證的是「同一份實作在不同切塊方式下,結果保持一致」,無法單獨證明演算法正確。因此,必須同時保留標準向量;正式串接通訊協定時,再加入由獨立實作產生、且經過核對的二進位樣本。
不要斷言任意兩份不同輸入的 CRC 必然不同。檢查碼的值域有限,碰撞確實存在,不能因為隨機檔案沒有發生碰撞,就認定實作可靠。
透過檔案讀取驗證錯誤路徑
接著將讀取函式附加到同一份檔案。透過 FileHandle 依序讀取區塊,每次只將目前這塊資料交給同一個實例,避免將整份檔案載入記憶體。
func checksumFile(_ url: URL) throws -> UInt32 {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var crc = CRC32()
while true {
let data = try handle.read(upToCount: 64 * 1024) ?? Data()
if data.isEmpty {
break
}
crc.update(data)
}
return crc.value
}
if CommandLine.arguments.count == 2 {
let url = URL(fileURLWithPath: CommandLine.arguments[1])
let result = try checksumFile(url)
Swift.print(String(format: "%08X", result))
}
執行 xcrun swift crc-check.swift 會執行前面的斷言;若再附上一個可讀取的檔案路徑,還會輸出該檔案的檢查碼。讀取錯誤會向上傳遞,而不是被誤判為空檔案。開啟失敗、讀取失敗與結果不符,應在應用邏輯層分別記錄。
讀取的檔案應是已關閉寫入、不再變動的副本。若下載過程持續修改同一路徑,又另外開啟檔案計算檢查碼,就可能讀到不同版本混合的內容;計算完成也不能證明之後沒有發生寫入。驗收時,應先封存輸入,再比對長度與 CRC。
整合到專案時檢查狀態歸屬
將實作放入單元測試可存取的模組,保留四類檢查:標準向量、分塊不變性、實例隔離、檔案錯誤傳遞。檔案測試應使用獨立的暫存目錄,分別建立空檔案與二進位檔案,並在結束後清理。
iOS 介面只負責顯示進度與結果,檔案讀取不要占用主執行緒。每個傳輸任務都應獨占一個 CRC 實例;同一任務的區塊必須依檔案順序處理,不能讓並行回呼直接修改暫存器。
若任務中途取消,就捨棄該實例。斷點續傳時,只有最終檢查碼並不足以直接接續計算:應重新讀取已儲存的前段資料,或設計有版本約束的內部狀態檢查點。後者還必須核對偏移量與檔案身分,不能只儲存一個整數。
最後,將工具鏈版本、樣本長度、切塊方式與失敗的斷言名稱保留在測試產物中。如此一來,下次更換節點或最佳化演算法時,就能區分是參數改變、區塊遺漏,還是不同任務混用了狀態,而不必反覆重傳檔案碰運氣。
常見問題
CRC-32 能證明下載檔案沒有遭到惡意替換嗎?
不能。CRC-32 用於偵測意外損壞,攻擊者可以同時修改檔案與檢查碼。需要防止竄改時,應驗證可信來源提供的數位簽章,或採用符合協定的金鑰式驗證機制。
把各分塊的 CRC 做互斥或,會得到整份檔案的 CRC 嗎?
不會。依序讀取時應持續更新同一個內部暫存器,全部位元組處理完再取得最終值。獨立分塊結果的合併需要考慮分塊長度的專用演算法,不能直接做互斥或。