diff --git a/Sources/ForgeOffload/FileHasher.swift b/Sources/ForgeOffload/FileHasher.swift new file mode 100644 index 0000000..f87edeb --- /dev/null +++ b/Sources/ForgeOffload/FileHasher.swift @@ -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) + } + } +} diff --git a/Sources/ForgeOffload/ForgeOffload.swift b/Sources/ForgeOffload/ForgeOffload.swift deleted file mode 100644 index 35edea5..0000000 --- a/Sources/ForgeOffload/ForgeOffload.swift +++ /dev/null @@ -1 +0,0 @@ -// ForgeOffload diff --git a/Sources/ForgeOffload/MD5.swift b/Sources/ForgeOffload/MD5.swift new file mode 100644 index 0000000..aa9ce4c --- /dev/null +++ b/Sources/ForgeOffload/MD5.swift @@ -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.. 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.. String { + var md5 = MD5() + md5.update(data) + return md5.finalize().map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Sources/ForgeOffload/XXHash64.swift b/Sources/ForgeOffload/XXHash64.swift new file mode 100644 index 0000000..e19844d --- /dev/null +++ b/Sources/ForgeOffload/XXHash64.swift @@ -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) + } +} diff --git a/Tests/ForgeOffloadTests/HashingTests.swift b/Tests/ForgeOffloadTests/HashingTests.swift new file mode 100644 index 0000000..3404715 --- /dev/null +++ b/Tests/ForgeOffloadTests/HashingTests.swift @@ -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..