Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
355 changes: 300 additions & 55 deletions Packages/BitMatchEngine/Sources/BitMatchEngine/ASCMHLGenerator.swift

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -26,61 +26,70 @@ public enum AppleDoubleSelection {
let fd = open(entry.url.path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC)
guard fd >= 0 else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) }
defer { close(fd) }
var before = stat()
guard fstat(fd, &before) == 0, (before.st_mode & S_IFMT) == S_IFREG,
Int64(before.st_size) == entry.size else {
throw FileOperationError.unsafeOperation("Source metadata changed while reviewing exclusions. Review the source again.")
}
func read(_ count: Int, at offset: Int64) throws -> [UInt8] {
var buffer = [UInt8](repeating: 0, count: count)
var done = 0
while done < count {
try Task.checkCancellation()
let received = buffer.withUnsafeMutableBytes { bytes in
pread(fd, bytes.baseAddress!.advanced(by: done), count - done, offset + Int64(done))
}
if received < 0 && errno == EINTR { continue }
guard received > 0 else {
throw FileOperationError.unsafeOperation("Unable to read source metadata while reviewing exclusions.")
}
done += received
return try isMetadataOnlyCompanion(fd: fd, size: entry.size)
}.sorted { $0.relativePath < $1.relativePath }
}

/// The same bounded parser is used for source selection and the exact
/// filesystem-created companions beside a directly published FAT history.
/// The caller owns the descriptor, which can be opened relative to a
/// pinned directory without following the companion's pathname.
static func isMetadataOnlyCompanion(fd: Int32, size: Int64) throws -> Bool {
guard size >= 26 else { return false }
var before = stat()
guard fstat(fd, &before) == 0, (before.st_mode & S_IFMT) == S_IFREG,
Int64(before.st_size) == size else {
throw FileOperationError.unsafeOperation("Source metadata changed while reviewing exclusions. Review the source again.")
}
func read(_ count: Int, at offset: Int64) throws -> [UInt8] {
var buffer = [UInt8](repeating: 0, count: count)
var done = 0
while done < count {
try Task.checkCancellation()
let received = buffer.withUnsafeMutableBytes { bytes in
pread(fd, bytes.baseAddress!.advanced(by: done), count - done, offset + Int64(done))
}
return buffer
}
let header = try read(26, at: 0)
func uint32(_ bytes: [UInt8], _ offset: Int) -> UInt32 {
bytes[offset..<offset + 4].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
}
guard uint32(header, 0) == 0x00051607, uint32(header, 4) == 0x00020000 else { return false }
let count = Int(header[24]) * 256 + Int(header[25])
guard count > 0, count <= 1024, 26 + count * 12 <= entry.size else { return false }
let records = try read(count * 12, at: 26)
var ids = Set<UInt32>()
var ranges: [(UInt64, UInt64)] = []
for index in 0..<count {
let start = index * 12
let id = uint32(records, start)
let offset = UInt64(uint32(records, start + 4))
let length = UInt64(uint32(records, start + 8))
// AppleDouble metadata entries only; data-fork entry 1 is not
// safe to omit. Unknown extensions stay in the transfer.
guard let minimumLength = Self.minimumEntryLengths[id], length >= minimumLength,
ids.insert(id).inserted,
offset >= UInt64(26 + count * 12), offset + length <= UInt64(entry.size) else { return false }
ranges.append((offset, offset + length))
}
let ordered = ranges.filter { $0.1 > $0.0 }.sorted { $0.0 < $1.0 }
for index in ordered.indices.dropFirst() {
guard ordered[index - 1].1 <= ordered[index].0 else { return false }
}
var after = stat()
guard fstat(fd, &after) == 0, before.st_size == after.st_size,
before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec,
before.st_ctimespec.tv_sec == after.st_ctimespec.tv_sec, before.st_ctimespec.tv_nsec == after.st_ctimespec.tv_nsec else {
throw FileOperationError.unsafeOperation("Source metadata changed while reviewing exclusions. Review the source again.")
if received < 0 && errno == EINTR { continue }
guard received > 0 else {
throw FileOperationError.unsafeOperation("Unable to read source metadata while reviewing exclusions.")
}
done += received
}
return true
}.sorted { $0.relativePath < $1.relativePath }
return buffer
}
let header = try read(26, at: 0)
func uint32(_ bytes: [UInt8], _ offset: Int) -> UInt32 {
bytes[offset..<offset + 4].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
}
guard uint32(header, 0) == 0x00051607, uint32(header, 4) == 0x00020000 else { return false }
let count = Int(header[24]) * 256 + Int(header[25])
guard count > 0, count <= 1024, 26 + count * 12 <= size else { return false }
let records = try read(count * 12, at: 26)
var ids = Set<UInt32>()
var ranges: [(UInt64, UInt64)] = []
for index in 0..<count {
let start = index * 12
let id = uint32(records, start)
let offset = UInt64(uint32(records, start + 4))
let length = UInt64(uint32(records, start + 8))
// AppleDouble metadata entries only; data-fork entry 1 is not
// safe to omit. Unknown extensions stay in the transfer.
guard let minimumLength = Self.minimumEntryLengths[id], length >= minimumLength,
ids.insert(id).inserted,
offset >= UInt64(26 + count * 12), offset + length <= UInt64(size) else { return false }
ranges.append((offset, offset + length))
}
let ordered = ranges.filter { $0.1 > $0.0 }.sorted { $0.0 < $1.0 }
for index in ordered.indices.dropFirst() {
guard ordered[index - 1].1 <= ordered[index].0 else { return false }
}
var after = stat()
guard fstat(fd, &after) == 0, before.st_size == after.st_size,
before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec,
before.st_ctimespec.tv_sec == after.st_ctimespec.tv_sec, before.st_ctimespec.tv_nsec == after.st_ctimespec.tv_nsec else {
throw FileOperationError.unsafeOperation("Source metadata changed while reviewing exclusions. Review the source again.")
}
return true
}

public static func review(source: URL) throws -> [String] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,13 @@ public struct SavedChecksumCheck: Sendable {
public let root: URL
public let expectedFiles: [String: ExpectedFile]
public let records: [Record]
public let publicationStatuses: [URL]

public init(root: URL, expectedFiles: [String: ExpectedFile], records: [Record]) {
public init(root: URL, expectedFiles: [String: ExpectedFile], records: [Record], publicationStatuses: [URL] = []) {
self.root = root
self.expectedFiles = expectedFiles
self.records = records
self.publicationStatuses = publicationStatuses
}

public var newestRecordDate: Date { records.map(\.date).max() ?? .distantPast }
Expand Down Expand Up @@ -121,14 +123,15 @@ public struct SavedChecksumCheck: Sendable {

let files = try await fileAccess.getFileList(from: root)
try Task.checkCancellation()
let statuses = try Self.validPublicationStatuses(in: files, under: canonicalRoot)
let reports = files.filter { Self.isBitMatchReport($0, under: canonicalRoot) }
var reportEntries: [ExpectedFile] = []
for reportURL in reports {
try Task.checkCancellation()
reportEntries.append(contentsOf: try Self.readReport(reportURL, root: canonicalRoot))
}
if !reportEntries.isEmpty {
return Self.makeDiscovery(root: canonicalRoot, entries: reportEntries)
return Self.makeDiscovery(root: canonicalRoot, entries: reportEntries, publicationStatuses: statuses)
}

let histories = files.filter { Self.isMHLHistory($0, under: canonicalRoot) }
Expand All @@ -141,7 +144,7 @@ public struct SavedChecksumCheck: Sendable {
mhlEntries.append(contentsOf: try Self.readMHL(historyURL, root: canonicalRoot))
}
guard !mhlEntries.isEmpty else { throw CheckError.notFound }
return Self.makeDiscovery(root: canonicalRoot, entries: mhlEntries)
return Self.makeDiscovery(root: canonicalRoot, entries: mhlEntries, publicationStatuses: statuses)
}

/// Re-reads every recorded file, reports ordered progress, and identifies
Expand All @@ -155,6 +158,11 @@ public struct SavedChecksumCheck: Sendable {
defer { if scoped { fileAccess.stopAccessing(url: root) } }

let files = try await fileAccess.getFileList(from: root)
try Task.checkCancellation()
let statuses = try Self.validPublicationStatuses(in: files, under: root)
guard statuses == discovery.publicationStatuses else {
throw CheckError.unreadableEvidence
}
let resolver = RelativePathResolver(base: root)
var actual: [String: URL] = [:]
actual.reserveCapacity(files.count)
Expand Down Expand Up @@ -195,6 +203,11 @@ public struct SavedChecksumCheck: Sendable {
}

try Task.checkCancellation()
let finalFiles = try await fileAccess.getFileList(from: root)
try Task.checkCancellation()
guard try Self.validPublicationStatuses(in: finalFiles, under: root) == discovery.publicationStatuses else {
throw CheckError.unreadableEvidence
}
let newPaths = Set(actual.keys).subtracting(expected.keys).sorted()
let used = Set(expected.values.map(\.record.url))
return Result(
Expand Down Expand Up @@ -379,6 +392,27 @@ public struct SavedChecksumCheck: Sendable {
return relative.split(separator: "/").contains { $0.caseInsensitiveCompare("ascmhl") == .orderedSame }
}

private static func isPublicationStatus(_ url: URL, under root: URL) -> Bool {
guard url.lastPathComponent.caseInsensitiveCompare(ASCMHLGenerator.publicationStatusName) == .orderedSame,
let relative = try? RelativePathResolver(base: root).resolve(url) else { return false }
let parts = relative.split(separator: "/")
return parts.count >= 2 && parts[parts.count - 2].caseInsensitiveCompare("ascmhl") == .orderedSame
}

private static func validPublicationStatuses(in files: [URL], under root: URL) throws -> [URL] {
let statuses = files.filter { isPublicationStatus($0, under: root) }.sorted { $0.path < $1.path }
let statusDirectories = Set(statuses.map { $0.deletingLastPathComponent().path })
let fatDirectories = Set(files.filter { url in
url.lastPathComponent.lowercased().hasPrefix(ASCMHLGenerator.fatManifestPrefix.lowercased())
&& isMHLHistory(url, under: root)
}.map { $0.deletingLastPathComponent().path })
guard fatDirectories.isSubset(of: statusDirectories) else { throw CheckError.unreadableEvidence }
for status in statuses {
guard try ASCMHLGenerator.publicationIsComplete(at: status) else { throw CheckError.unreadableEvidence }
}
return statuses
}

private static func isMHLMetadataCompanion(_ url: URL) throws -> Bool {
guard url.lastPathComponent.hasPrefix("._") else { return false }
guard let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize else {
Expand Down Expand Up @@ -517,7 +551,7 @@ public struct SavedChecksumCheck: Sendable {
}
}

private static func makeDiscovery(root: URL, entries: [ExpectedFile]) -> Discovery {
private static func makeDiscovery(root: URL, entries: [ExpectedFile], publicationStatuses: [URL]) -> Discovery {
let sorted = entries.sorted {
if $0.record.date != $1.record.date { return $0.record.date < $1.record.date }
return $0.record.url.path < $1.record.url.path
Expand All @@ -532,7 +566,7 @@ public struct SavedChecksumCheck: Sendable {
if $0.date != $1.date { return $0.date > $1.date }
return $0.url.path < $1.url.path
}
return Discovery(root: root, expectedFiles: expected, records: records)
return Discovery(root: root, expectedFiles: expected, records: records, publicationStatuses: publicationStatuses)
}

private static func progress(processed: Int, total: Int, path: String?) -> OperationProgress {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public enum SharedLogger: Sendable {
extension SharedLogger {
public enum TransferEvent: String, Codable, Sendable {
case admitted, copyDrained, verificationDrained, pipelineDrained, authoritativeAccepted, authoritativeRefused
case mhlStarted, mhlFinished, mhlExclusiveRename, mhlClaimRename
case mhlStarted, mhlFinished, mhlExclusiveRename, mhlClaimRename, mhlClaimDirectWrite
case reportStarted, reportFinished, explicitCancel, parentCancelled
case phase, cancelOrigin, error, pipelineResult, config, destination, progress, appStarted, appEnded
case terminalError, journalCancelled, journalInterrupted, journalFinished, phaseChanged
Expand Down
Loading
Loading