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
100 changes: 100 additions & 0 deletions BitMatchTests/XMLMetadataDetectionBoundsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import Foundation
import Testing
@testable import BitMatch

struct XMLMetadataDetectionBoundsTests {
@Test func smallCameraMetadataStillIdentifiesCamera() throws {
let dir = try makeDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
try "<metadata><make>Sony</make><model>FX3</model></metadata>"
.write(to: dir.appendingPathComponent("metadata.xml"), atomically: true, encoding: .utf8)

#expect(XMLMetadataDetectionService.shared.detectCameraFromXML(at: dir) == "Sony FX3")
}

@Test func oversizedXMLIsSkippedRatherThanParsedAsAPrefix() throws {
let dir = try makeDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
let oversized = dir.appendingPathComponent("metadata.xml")
try Data("<camera>Wrong Camera</camera>".utf8).write(to: oversized)
let handle = try FileHandle(forWritingTo: oversized)
try handle.truncate(atOffset: UInt64(XMLMetadataDetectionService.maximumXMLBytes + 1))
try handle.close()

#expect(XMLMetadataDetectionService.shared.detectCameraFromXML(at: dir) == nil)

try "<camera>Right Camera</camera>"
.write(to: dir.appendingPathComponent("other.xml"), atomically: true, encoding: .utf8)
#expect(XMLMetadataDetectionService.shared.detectCameraFromXML(at: dir) == "Right Camera")
}

@Test func exactlyFourMiBXMLStillParses() throws {
let dir = try makeDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
let prefix = "<camera>Boundary Camera</camera>"
let padding = String(repeating: " ", count: XMLMetadataDetectionService.maximumXMLBytes - prefix.utf8.count)
try (prefix + padding).write(to: dir.appendingPathComponent("boundary.xml"), atomically: true, encoding: .utf8)

#expect(XMLMetadataDetectionService.shared.detectCameraFromXML(at: dir) == "Boundary Camera")
}

@Test func linkedXMLIsNotFollowed() throws {
let dir = try makeDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
let outside = try makeDirectory()
defer { try? FileManager.default.removeItem(at: outside) }
let target = outside.appendingPathComponent("target.xml")
try "<camera>Outside Camera</camera>".write(to: target, atomically: true, encoding: .utf8)
try FileManager.default.createSymbolicLink(
at: dir.appendingPathComponent("linked.xml"),
withDestinationURL: target
)

#expect(XMLMetadataDetectionService.shared.detectCameraFromXML(at: dir) == nil)
}

@Test func cancellationAfterReadNeverPublishesCameraName() async throws {
let dir = try makeDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
try "<camera>Too Late</camera>"
.write(to: dir.appendingPathComponent("camera.xml"), atomically: true, encoding: .utf8)

let gate = XMLReadGate()
defer { gate.release() }
let detection = Task.detached {
XMLMetadataDetectionService.shared.detectCameraFromXML(at: dir, onFileRead: {
gate.pauseAfterRead()
})
}
let reachedRead = await Task.detached { gate.waitForRead() }.value
try #require(reachedRead, "camera detection never reached its file read")
detection.cancel()
gate.release()

#expect(await detection.value == nil)
}

private func makeDirectory() throws -> URL {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
}

private final class XMLReadGate: @unchecked Sendable {
private let read = DispatchSemaphore(value: 0)
private let proceed = DispatchSemaphore(value: 0)

func pauseAfterRead() {
read.signal()
proceed.wait()
}

func waitForRead() -> Bool {
read.wait(timeout: .now() + 5) == .success
}

func release() {
proceed.signal()
}
}
145 changes: 85 additions & 60 deletions Shared/Core/Services/Camera/XMLMetadataDetectionService.swift
Original file line number Diff line number Diff line change
@@ -1,77 +1,88 @@
// Core/Services/Camera/XMLMetadataDetectionService.swift
import Darwin
import Foundation

/// Service for detecting cameras via generic XML metadata files
/// Service for detecting cameras via generic XML metadata files.
final class XMLMetadataDetectionService: Sendable {
static let shared = XMLMetadataDetectionService()
private init() {}
static let maximumXMLBytes = 4 * 1024 * 1024
private static let readChunkBytes = 64 * 1024

// MARK: - Public Interface
private init() {}

/// The `onEnumerationStarted` hook is invocation-specific and defaults
/// to nil: production callers omit it, so no shared mutable state is
/// involved and parallel detections cannot cross-signal.
func detectCameraFromXML(at url: URL, onEnumerationStarted: (() -> Void)? = nil) -> String? {
/// Hooks are invocation-specific and nil in production. Tests use them to
/// order cancellation without shared mutable state or timing guesses.
/// Keep `onEnumerationStarted` last so existing trailing-closure callers
/// still observe enumeration rather than the later file read.
func detectCameraFromXML(
at url: URL,
onFileRead: (() -> Void)? = nil,
onEnumerationStarted: (() -> Void)? = nil
) -> String? {
let fm = FileManager.default

// Common XML metadata files
let xmlFiles = [
"metadata.xml",
"info.xml",
"camera.xml",
"settings.xml"
]

// Search for XML files in directory structure

onEnumerationStarted?()
if let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) {
for case let fileURL as URL in enumerator {
guard !Task.isCancelled else { return nil }
if fileURL.pathExtension.lowercased() == "xml" {
if let cameraInfo = parseCameraFromXML(fileURL) {
return cameraInfo
}
}

// Also check specific metadata files
for xmlFile in xmlFiles {
if fileURL.lastPathComponent.lowercased() == xmlFile {
if let cameraInfo = parseCameraFromXML(fileURL) {
return cameraInfo
}
}
guard fileURL.pathExtension.lowercased() == "xml" else { continue }
// metadata.xml, info.xml and the other conventional names are
// already covered by the extension check; parse each once.
if let cameraInfo = parseCameraFromXML(fileURL, onFileRead: onFileRead) {
return cameraInfo
}
}
}

return nil
}

// MARK: - XML Parsing

private func parseCameraFromXML(_ xmlFile: URL) -> String? {
do {
let xmlContent = try String(contentsOf: xmlFile, encoding: .utf8)

// Look for common camera metadata tags

private func parseCameraFromXML(_ xmlFile: URL, onFileRead: (() -> Void)?) -> String? {
guard !Task.isCancelled else { return nil }

// Camera naming is advisory. Refuse links and non-files, and never
// load an unbounded XML file just to look for a camera label.
let fd = xmlFile.path.withCString {
Darwin.open($0, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC)
}
guard fd >= 0 else { return nil }
defer { _ = Darwin.close(fd) }

var info = stat()
guard fstat(fd, &info) == 0,
(info.st_mode & S_IFMT) == S_IFREG,
info.st_size >= 0,
info.st_size <= off_t(Self.maximumXMLBytes) else {
return nil
}

// A pool per candidate also drains Foundation/regex temporaries when
// a card contains many XML files but no camera metadata.
return autoreleasepool {
guard let xmlData = readBoundedXML(fd: fd, expectedSize: Int(info.st_size)) else { return nil }
onFileRead?()
guard !Task.isCancelled,
let xmlContent = String(data: xmlData, encoding: .utf8) else { return nil }

let cameraPatterns = [
// Generic patterns
"<camera[^>]*>([^<]+)</camera>",
"<make[^>]*>([^<]+)</make>",
"<model[^>]*>([^<]+)</model>",
"<manufacturer[^>]*>([^<]+)</manufacturer>",

// Attribute patterns
"camera=\"([^\"]+)\"",
"make=\"([^\"]+)\"",
"model=\"([^\"]+)\""
]

var make: String?
var model: String?

for pattern in cameraPatterns {
guard !Task.isCancelled else { return nil }
if let range = xmlContent.range(of: pattern, options: .regularExpression) {
// A cancellation during the regex must not publish a
// camera name after the caller has moved to another card.
guard !Task.isCancelled else { return nil }
let match = String(xmlContent[range])
if let valueRange = match.range(of: ">([^<]+)<", options: .regularExpression) ??
match.range(of: "=\"([^\"]+)\"", options: .regularExpression) {
Expand All @@ -81,32 +92,46 @@ final class XMLMetadataDetectionService: Sendable {
.replacingOccurrences(of: "=\"", with: "")
.replacingOccurrences(of: "\"", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)

if pattern.contains("make") || pattern.contains("manufacturer") {
make = value
} else if pattern.contains("model") {
model = value
} else if pattern.contains("camera") {
return value // Direct camera name
return value
}
}
}
}

// Combine make and model if available
if let make = make, let model = model {
return "\(make) \(model)"
} else if let make = make {
return make
} else if let model = model {
return model

guard !Task.isCancelled else { return nil }
if let make, let model { return "\(make) \(model)" }
return make ?? model
}
}

/// Reads through the first byte beyond the cap, so a file that grows
/// after fstat is still skipped rather than parsed as a valid prefix.
private func readBoundedXML(fd: Int32, expectedSize: Int) -> Data? {
var data = Data()
data.reserveCapacity(expectedSize)
var buffer = [UInt8](repeating: 0, count: Self.readChunkBytes)

while data.count <= Self.maximumXMLBytes {
guard !Task.isCancelled else { return nil }
let remaining = Self.maximumXMLBytes + 1 - data.count
let count = min(buffer.count, remaining)
let bytesRead = buffer.withUnsafeMutableBytes { bytes in
Darwin.read(fd, bytes.baseAddress, count)
}
if bytesRead < 0 {
if errno == EINTR { continue }
return nil
}

} catch {
// Failed to read XML file
return nil
if bytesRead == 0 { return data }
data.append(contentsOf: buffer[..<bytesRead])
}

return nil
}
}
}
Loading