offload: xxHash64 (spec vectors) + MD5 (RFC 1321 vectors), streaming + chunked file hashing — 7 tests

This commit is contained in:
Forge Dev 2026-07-10 20:11:17 +00:00
parent 1ac4e6a754
commit f52c67707f
6 changed files with 353 additions and 6 deletions

View file

@ -0,0 +1,37 @@
import Foundation
public enum OffloadError: Error, Equatable {
case fileNotFound(String)
case readFailed(String)
case writeFailed(String)
case verifyFailed(path: String, expected: String, actual: String)
case destinationConflict(String)
}
/// Chunked file hashing constant memory over multi-GB camera files.
public enum FileHasher {
static let chunkSize = 4 * 1024 * 1024
public static func xxh64(path: String) throws -> String {
var hasher = XXHash64()
try stream(path: path) { hasher.update($0) }
return XXHash64.hexString(hasher.finalize())
}
public static func md5(path: String) throws -> String {
var hasher = MD5()
try stream(path: path) { hasher.update($0) }
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
private static func stream(path: String, _ body: (Data) -> Void) throws {
guard let handle = FileHandle(forReadingAtPath: path) else {
throw OffloadError.fileNotFound(path)
}
defer { try? handle.close() }
while true {
guard let chunk = try handle.read(upToCount: chunkSize), !chunk.isEmpty else { break }
body(chunk)
}
}
}

View file

@ -1 +0,0 @@
// ForgeOffload

View file

@ -0,0 +1,108 @@
import Foundation
/// MD5 (RFC 1321) pure Swift. Legacy-post-workflow compatibility only;
/// integrity verification uses xxHash64.
public struct MD5 {
private var a0: UInt32 = 0x67452301
private var b0: UInt32 = 0xefcdab89
private var c0: UInt32 = 0x98badcfe
private var d0: UInt32 = 0x10325476
private var buffer = [UInt8]()
private var totalLength: UInt64 = 0
private static let s: [UInt32] = [
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
]
private static let k: [UInt32] = (0..<64).map {
UInt32(truncatingIfNeeded: Int64(abs(sin(Double($0 + 1))) * 4294967296.0))
}
public init() {}
public mutating func update(_ data: Data) {
totalLength += UInt64(data.count)
buffer.append(contentsOf: data)
var offset = 0
while buffer.count - offset >= 64 {
processBlock(Array(buffer[offset..<offset + 64]))
offset += 64
}
if offset > 0 {
buffer.removeFirst(offset)
}
}
private mutating func processBlock(_ block: [UInt8]) {
var m = [UInt32](repeating: 0, count: 16)
for i in 0..<16 {
m[i] = UInt32(block[i * 4])
| (UInt32(block[i * 4 + 1]) << 8)
| (UInt32(block[i * 4 + 2]) << 16)
| (UInt32(block[i * 4 + 3]) << 24)
}
var a = a0, b = b0, c = c0, d = d0
for i in 0..<64 {
var f: UInt32
var g: Int
switch i {
case 0..<16:
f = (b & c) | (~b & d)
g = i
case 16..<32:
f = (d & b) | (~d & c)
g = (5 * i + 1) % 16
case 32..<48:
f = b ^ c ^ d
g = (3 * i + 5) % 16
default:
f = c ^ (b | ~d)
g = (7 * i) % 16
}
f = f &+ a &+ Self.k[i] &+ m[g]
a = d
d = c
c = b
b = b &+ ((f << Self.s[i]) | (f >> (32 - Self.s[i])))
}
a0 = a0 &+ a
b0 = b0 &+ b
c0 = c0 &+ c
d0 = d0 &+ d
}
public mutating func finalize() -> Data {
let bitLength = totalLength * 8
// Padding: 0x80 then zeros to 56 mod 64, then length LE64.
var padding: [UInt8] = [0x80]
let remainder = (buffer.count + 1) % 64
let zeros = remainder <= 56 ? 56 - remainder : 120 - remainder
padding.append(contentsOf: [UInt8](repeating: 0, count: zeros))
var lenLE = bitLength.littleEndian
withUnsafeBytes(of: &lenLE) { padding.append(contentsOf: $0) }
buffer.append(contentsOf: padding)
var offset = 0
while buffer.count - offset >= 64 {
processBlock(Array(buffer[offset..<offset + 64]))
offset += 64
}
buffer.removeAll()
var out = Data()
for v in [a0, b0, c0, d0] {
var le = v.littleEndian
withUnsafeBytes(of: &le) { out.append(contentsOf: $0) }
}
return out
}
public static func hexString(_ data: Data) -> String {
var md5 = MD5()
md5.update(data)
return md5.finalize().map { String(format: "%02x", $0) }.joined()
}
}

View file

@ -0,0 +1,126 @@
import Foundation
/// xxHash64 (seed 0) pure Swift, streaming. Spec: github.com/Cyan4973/xxHash.
public struct XXHash64 {
private static let p1: UInt64 = 0x9E3779B185EBCA87
private static let p2: UInt64 = 0xC2B2AE3D27D4EB4F
private static let p3: UInt64 = 0x165667B19E3779F9
private static let p4: UInt64 = 0x85EBCA77C2B2AE63
private static let p5: UInt64 = 0x27D4EB2F165667C5
private var v1: UInt64
private var v2: UInt64
private var v3: UInt64
private var v4: UInt64
private var buffer = [UInt8]()
private var totalLength: UInt64 = 0
private let seed: UInt64
public init(seed: UInt64 = 0) {
self.seed = seed
v1 = seed &+ Self.p1 &+ Self.p2
v2 = seed &+ Self.p2
v3 = seed
v4 = seed &- Self.p1
}
@inline(__always)
private static func rotl(_ x: UInt64, _ r: UInt64) -> UInt64 {
(x << r) | (x >> (64 - r))
}
@inline(__always)
private static func round(_ acc: UInt64, _ input: UInt64) -> UInt64 {
rotl(acc &+ input &* p2, 31) &* p1
}
@inline(__always)
private static func mergeRound(_ acc: UInt64, _ val: UInt64) -> UInt64 {
(acc ^ round(0, val)) &* p1 &+ p4
}
@inline(__always)
private static func readLE64(_ bytes: [UInt8], _ offset: Int) -> UInt64 {
var v: UInt64 = 0
for i in 0..<8 {
v |= UInt64(bytes[offset + i]) << (8 * UInt64(i))
}
return v
}
@inline(__always)
private static func readLE32(_ bytes: [UInt8], _ offset: Int) -> UInt64 {
var v: UInt64 = 0
for i in 0..<4 {
v |= UInt64(bytes[offset + i]) << (8 * UInt64(i))
}
return v
}
public mutating func update(_ data: Data) {
totalLength += UInt64(data.count)
buffer.append(contentsOf: data)
// Consume full 32-byte stripes, keep remainder buffered.
var offset = 0
while buffer.count - offset >= 32 {
v1 = Self.round(v1, Self.readLE64(buffer, offset))
v2 = Self.round(v2, Self.readLE64(buffer, offset + 8))
v3 = Self.round(v3, Self.readLE64(buffer, offset + 16))
v4 = Self.round(v4, Self.readLE64(buffer, offset + 24))
offset += 32
}
if offset > 0 {
buffer.removeFirst(offset)
}
}
public func finalize() -> UInt64 {
var h: UInt64
if totalLength >= 32 {
h = Self.rotl(v1, 1) &+ Self.rotl(v2, 7) &+ Self.rotl(v3, 12) &+ Self.rotl(v4, 18)
h = Self.mergeRound(h, v1)
h = Self.mergeRound(h, v2)
h = Self.mergeRound(h, v3)
h = Self.mergeRound(h, v4)
} else {
h = seed &+ Self.p5
}
h &+= totalLength
var offset = 0
let tail = buffer
while tail.count - offset >= 8 {
h ^= Self.round(0, Self.readLE64(tail, offset))
h = Self.rotl(h, 27) &* Self.p1 &+ Self.p4
offset += 8
}
if tail.count - offset >= 4 {
h ^= Self.readLE32(tail, offset) &* Self.p1
h = Self.rotl(h, 23) &* Self.p2 &+ Self.p3
offset += 4
}
while offset < tail.count {
h ^= UInt64(tail[offset]) &* Self.p5
h = Self.rotl(h, 11) &* Self.p1
offset += 1
}
h ^= h >> 33
h &*= Self.p2
h ^= h >> 29
h &*= Self.p3
h ^= h >> 32
return h
}
/// One-shot.
public static func hash(_ data: Data, seed: UInt64 = 0) -> UInt64 {
var h = XXHash64(seed: seed)
h.update(data)
return h.finalize()
}
public static func hexString(_ value: UInt64) -> String {
String(format: "%016lx", value)
}
}

View file

@ -0,0 +1,82 @@
import XCTest
import Foundation
@testable import ForgeOffload
final class HashingTests: XCTestCase {
// xxHash64 official spec vectors (seed 0).
func testXXHash64SpecVectors() {
XCTAssertEqual(XXHash64.hash(Data()), 0xEF46_DB37_51D8_E999)
XCTAssertEqual(XXHash64.hash(Data([0x9E])), 0x4FCE_394C_C88952D8)
// "Nobody inspects the spammish repetition" widely published vector.
let spam = Data("Nobody inspects the spammish repetition".utf8)
XCTAssertEqual(XXHash64.hash(spam), 0xFBCE_A83C_8A378BF1)
}
// Known ASCII vector.
func testXXHash64Ascii() {
XCTAssertEqual(XXHash64.hash(Data("xxhash".utf8)), 0x32DD_38952C_4BC720)
}
// Streaming hash == whole-buffer hash for multi-MB input crossing block sizes.
func testStreamingMatchesWhole() {
var data = Data()
var seed: UInt64 = 0x12345678
for _ in 0..<(3 * 1024 * 1024 / 8) {
seed = seed &* 6364136223846793005 &+ 1442695040888963407
withUnsafeBytes(of: seed) { data.append(contentsOf: $0) }
}
let whole = XXHash64.hash(data)
var streamer = XXHash64()
// Uneven chunk sizes to exercise buffering.
var offset = 0
let chunks = [1, 31, 32, 33, 4096, 1_000_000]
var i = 0
while offset < data.count {
let size = min(chunks[i % chunks.count], data.count - offset)
streamer.update(data.subdata(in: offset..<offset + size))
offset += size
i += 1
}
XCTAssertEqual(streamer.finalize(), whole)
}
// Hex formatting: 16 lowercase hex chars, zero-padded.
func testHexFormat() {
let h = XXHash64.hexString(XXHash64.hash(Data("xxhash".utf8)))
XCTAssertEqual(h, "32dd38952c4bc720")
// Zero-padding for small values.
XCTAssertEqual(XXHash64.hexString(0xAB), "00000000000000ab")
}
// MD5 known vectors (RFC 1321).
func testMD5Vectors() {
XCTAssertEqual(MD5.hexString(Data()), "d41d8cd98f00b204e9800998ecf8427e")
XCTAssertEqual(MD5.hexString(Data("abc".utf8)), "900150983cd24fb0d6963f7d28e17f72")
XCTAssertEqual(
MD5.hexString(Data("message digest".utf8)),
"f96b697d7cb7938d525a2f31aaf161d0")
}
// File hashing: chunked reader over a temp file matches in-memory hash.
func testFileHashing() throws {
let path = NSTemporaryDirectory() + "forge-hash-\(UUID().uuidString).bin"
defer { try? FileManager.default.removeItem(atPath: path) }
var data = Data()
for i in 0..<100_000 {
data.append(UInt8(truncatingIfNeeded: i))
}
try data.write(to: URL(fileURLWithPath: path))
let fileHash = try FileHasher.xxh64(path: path)
XCTAssertEqual(fileHash, XXHash64.hexString(XXHash64.hash(data)))
let fileMD5 = try FileHasher.md5(path: path)
XCTAssertEqual(fileMD5, MD5.hexString(data))
}
func testMissingFileThrows() {
XCTAssertThrowsError(try FileHasher.xxh64(path: "/nonexistent/nope.bin"))
}
}

View file

@ -1,5 +0,0 @@
import XCTest
final class ForgeOffloadPlaceholderTests: XCTestCase {
func testPlaceholder() { XCTAssertTrue(true) }
}