offload: xxHash64 (spec vectors) + MD5 (RFC 1321 vectors), streaming + chunked file hashing — 7 tests
This commit is contained in:
parent
1ac4e6a754
commit
f52c67707f
6 changed files with 353 additions and 6 deletions
37
Sources/ForgeOffload/FileHasher.swift
Normal file
37
Sources/ForgeOffload/FileHasher.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
// ForgeOffload
|
|
||||||
108
Sources/ForgeOffload/MD5.swift
Normal file
108
Sources/ForgeOffload/MD5.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
126
Sources/ForgeOffload/XXHash64.swift
Normal file
126
Sources/ForgeOffload/XXHash64.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
Tests/ForgeOffloadTests/HashingTests.swift
Normal file
82
Tests/ForgeOffloadTests/HashingTests.swift
Normal 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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import XCTest
|
|
||||||
|
|
||||||
final class ForgeOffloadPlaceholderTests: XCTestCase {
|
|
||||||
func testPlaceholder() { XCTAssertTrue(true) }
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue