rainbow-dragon/Sources/ForgeOffload/CopyEngine.swift

133 lines
5.3 KiB
Swift

import Foundation
/// Per-file offload result.
public struct FileRecord: Sendable, Equatable {
public let relativePath: String
public let size: UInt64
public let xxh64: String
/// Destination copy verified by post-copy re-read.
public let verified: Bool
/// Already present + identical at all destinations.
public let skipped: Bool
}
/// Offload job report.
public struct OffloadReport: Sendable {
public let source: String
public let destinations: [String]
public let files: [FileRecord]
public let totalBytes: UInt64
public let duration: TimeInterval
public let startedAt: Date
}
/// Verified multi-destination copy: source hashed while copying, destinations
/// re-read + hashed after write. Never silently overwrites mismatched files.
public struct CopyEngine {
public init() {}
/// Copy source tree to all destinations.
/// - overwritePartial: re-copy destination files whose *size* differs
/// (interrupted copy detection). Size-matching mismatched hashes always error.
public func offload(
source: String,
destinations: [String],
overwritePartial: Bool = false
) throws -> OffloadReport {
let fm = FileManager.default
var isDir: ObjCBool = false
guard fm.fileExists(atPath: source, isDirectory: &isDir), isDir.boolValue else {
throw OffloadError.fileNotFound(source)
}
let start = Date()
let clock = ContinuousClock.now
let files = try listFiles(root: source).sorted()
var records = [FileRecord]()
var totalBytes: UInt64 = 0
for rel in files {
let srcPath = source + "/" + rel
let srcHash = try FileHasher.xxh64(path: srcPath)
let attrs = try fm.attributesOfItem(atPath: srcPath)
let size = attrs[.size] as? UInt64 ?? 0
totalBytes += size
var anyCopied = false
for dest in destinations {
let dstPath = dest + "/" + rel
let dstDir = (dstPath as NSString).deletingLastPathComponent
try fm.createDirectory(atPath: dstDir, withIntermediateDirectories: true)
if fm.fileExists(atPath: dstPath) {
let dstAttrs = try fm.attributesOfItem(atPath: dstPath)
let dstSize = dstAttrs[.size] as? UInt64 ?? 0
if dstSize != size {
// Partial/interrupted copy.
if overwritePartial {
try fm.removeItem(atPath: dstPath)
} else {
throw OffloadError.destinationConflict(
"\(dstPath): size \(dstSize) != source \(size) (partial? rerun with overwritePartial)")
}
} else {
let dstHash = try FileHasher.xxh64(path: dstPath)
if dstHash == srcHash {
continue // identical skip
}
throw OffloadError.destinationConflict(
"\(dstPath): exists with different content (hash \(dstHash) != \(srcHash))")
}
}
// Copy via temp + atomic rename, then verify by re-read.
let tmpPath = dstPath + ".forgetmp"
try? fm.removeItem(atPath: tmpPath)
try fm.copyItem(atPath: srcPath, toPath: tmpPath)
let copiedHash = try FileHasher.xxh64(path: tmpPath)
guard copiedHash == srcHash else {
try? fm.removeItem(atPath: tmpPath)
throw OffloadError.verifyFailed(path: dstPath, expected: srcHash, actual: copiedHash)
}
_ = try? fm.replaceItemAt(URL(fileURLWithPath: dstPath), withItemAt: URL(fileURLWithPath: tmpPath))
if !fm.fileExists(atPath: dstPath) {
// replaceItemAt fallback (some Linux Foundation builds).
try fm.moveItem(atPath: tmpPath, toPath: dstPath)
}
anyCopied = true
}
records.append(FileRecord(
relativePath: rel,
size: size,
xxh64: srcHash,
verified: true,
skipped: !anyCopied))
}
return OffloadReport(
source: source,
destinations: destinations,
files: records,
totalBytes: totalBytes,
duration: max(Double(clock.duration(to: .now).components.seconds) +
Double(clock.duration(to: .now).components.attoseconds) / 1e18, 0.000001),
startedAt: start)
}
/// Relative paths of all regular files under root.
func listFiles(root: String) throws -> [String] {
let fm = FileManager.default
guard let enumerator = fm.enumerator(atPath: root) else {
throw OffloadError.readFailed(root)
}
var out = [String]()
while let rel = enumerator.nextObject() as? String {
var isDir: ObjCBool = false
if fm.fileExists(atPath: root + "/" + rel, isDirectory: &isDir), !isDir.boolValue {
out.append(rel)
}
}
return out
}
}