commit c22bcf2219e71a4c1948cb00de855a198f168e8d
parent c254f3740126527e95340c41f6d2650770893e9c
Author: triesap <tyson@radroots.org>
Date: Tue, 15 Sep 2026 00:07:04 +0000
media: bound and sanitize native image preparation
- Admit supported raster formats within dimension and working-memory limits
- Render oriented standard-color pixels without source-sensitive metadata
- Preserve actor-owned decode serialization and cancellation boundaries
- Verify adversarial fixtures, exact API, macOS and hosted iOS tests
Diffstat:
4 files changed, 414 insertions(+), 92 deletions(-)
diff --git a/Sources/RadrootsKit/RadrootsAppleImageDecode.swift b/Sources/RadrootsKit/RadrootsAppleImageDecode.swift
@@ -0,0 +1,84 @@
+import CoreGraphics
+import Foundation
+import ImageIO
+import UniformTypeIdentifiers
+
+/// Admission bounds for one actor-owned decode. The working-byte estimate
+/// includes compressed input, an eight-bit source raster, and three derivative
+/// rasters (thumbnail, color conversion and encoder input). ImageIO owns its
+/// internal codec workspace; this is not a process RSS or allocator guarantee.
+enum RadrootsAppleImageDecode {
+ static let maximumSourceDimension = 32768
+ static let maximumRasterBytes = 160_000_000
+ static let maximumWorkingBytes = 512 * 1024 * 1024
+
+ static func validateDimensions(
+ width: Int, height: Int, inputBytes: Int, maximumPixelCount: Int, maximumDimension: Int
+ ) throws {
+ guard (1 ... maximumSourceDimension).contains(width),
+ (1 ... maximumSourceDimension).contains(height),
+ (0 ... (40 * 1024 * 1024)).contains(inputBytes),
+ (1 ... 8192).contains(maximumDimension)
+ else { throw RadrootsAppleMediaPreparationError.invalidRequest }
+ // Dimension admission precedes all multiplication; these products fit
+ // Int on every supported 64-bit Apple target.
+ let pixels = width * height
+ let sourceBytes = pixels * 4
+ let derivativePixels = min(pixels, maximumDimension * maximumDimension)
+ guard pixels <= maximumPixelCount, sourceBytes <= maximumRasterBytes,
+ inputBytes + sourceBytes + derivativePixels * 4 * 3 <= maximumWorkingBytes
+ else { throw RadrootsAppleMediaPreparationError.invalidRequest }
+ }
+
+ static func normalizedImage(_ data: Data, request: RadrootsAppleImagePreparationRequest) throws -> CGImage {
+ try Task.checkCancellation()
+ guard !data.isEmpty, data.count <= request.maximumInputBytes,
+ let source = CGImageSourceCreateWithData(
+ data as CFData,
+ [kCGImageSourceShouldCache: false] as CFDictionary
+ ),
+ let type = CGImageSourceGetType(source) as String?,
+ [UTType.jpeg.identifier, UTType.png.identifier, UTType.heic.identifier, UTType.heif.identifier]
+ .contains(type),
+ CGImageSourceGetCount(source) == 1,
+ let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
+ let widthNumber = properties[kCGImagePropertyPixelWidth] as? NSNumber,
+ let heightNumber = properties[kCGImagePropertyPixelHeight] as? NSNumber,
+ let width = Int(exactly: widthNumber.doubleValue),
+ let height = Int(exactly: heightNumber.doubleValue),
+ let depth = (properties[kCGImagePropertyDepth] as? NSNumber)?.intValue,
+ (1 ... 8).contains(depth)
+ else { throw RadrootsAppleMediaPreparationError.invalidRequest }
+ try validateDimensions(width: width, height: height, inputBytes: data.count,
+ maximumPixelCount: request.maximumPixelCount, maximumDimension: request.maximumDimension)
+ try Task.checkCancellation()
+ let options: [CFString: Any] = [
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
+ kCGImageSourceCreateThumbnailWithTransform: true,
+ kCGImageSourceShouldCacheImmediately: true,
+ kCGImageSourceShouldAllowFloat: false,
+ kCGImageSourceThumbnailMaxPixelSize: request.maximumDimension
+ ]
+ guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary),
+ image.width > 0, image.height > 0,
+ image.width <= request.maximumDimension, image.height <= request.maximumDimension,
+ image.width * image.height <= request.maximumPixelCount,
+ (1 ... 8).contains(image.bitsPerComponent), image.bitsPerPixel <= 32,
+ image.bytesPerRow > 0, image.bytesPerRow <= maximumRasterBytes / image.height
+ else { throw RadrootsAppleMediaPreparationError.invalidRequest }
+ try Task.checkCancellation()
+ // Render pixels into a standard space so source ICC/device profiles and
+ // image properties cannot ride along with the sanitized derivative.
+ guard let space = CGColorSpace(name: CGColorSpace.sRGB),
+ let context = CGContext(data: nil, width: image.width, height: image.height,
+ bitsPerComponent: 8, bytesPerRow: image.width * 4, space: space,
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
+ else { throw RadrootsAppleMediaPreparationError.preparationFailure }
+ context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
+ try Task.checkCancellation()
+ guard let normalized = context.makeImage() else {
+ throw RadrootsAppleMediaPreparationError.preparationFailure
+ }
+ return normalized
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsAppleMediaPreparation.swift b/Sources/RadrootsKit/RadrootsAppleMediaPreparation.swift
@@ -30,7 +30,8 @@ public struct RadrootsAppleImagePreparationRequest: Sendable, Equatable, Hashabl
source: RadrootsBackgroundTransferLocalFile, maximumInputBytes: Int = 40 * 1024 * 1024,
maximumOutputBytes: Int = 10 * 1024 * 1024, maximumPixelCount: Int = 40_000_000, maximumDimension: Int = 4096
) throws {
- guard (1 ... (40 * 1024 * 1024)).contains(maximumInputBytes), (1 ... (10 * 1024 * 1024)).contains(maximumOutputBytes),
+ guard (1 ... (40 * 1024 * 1024)).contains(maximumInputBytes),
+ (1 ... (10 * 1024 * 1024)).contains(maximumOutputBytes),
(1 ... 40_000_000).contains(maximumPixelCount), (1 ... 8192).contains(maximumDimension)
else { throw RadrootsAppleMediaPreparationError.invalidRequest }
do { try RadrootsBackgroundTransferValidation.validateLocalFile(source) } catch {
@@ -44,19 +45,17 @@ public struct RadrootsAppleImagePreparationRequest: Sendable, Equatable, Hashabl
}
}
-public struct RadrootsApplePreparedImage: Sendable, Equatable, Hashable,
- CustomDebugStringConvertible
-{
+public struct RadrootsApplePreparedImage: Sendable, Equatable, Hashable, CustomDebugStringConvertible {
public let file: RadrootsStagedBlobReference
public let sha256: String
public let width: UInt32
public let height: UInt32
- public init(file: RadrootsStagedBlobReference, sha256: String, width: UInt32, height: UInt32)
- throws
- {
+ public init(
+ file: RadrootsStagedBlobReference, sha256: String, width: UInt32, height: UInt32
+ ) throws {
guard sha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil, width > 0,
- height > 0, file.sizeBytes > 0,
+ height > 0, file.sizeBytes > 0,
file.mediaType == "image/png"
else { throw RadrootsAppleMediaPreparationError.invalidRequest }
self.file = file
@@ -66,7 +65,8 @@ public struct RadrootsApplePreparedImage: Sendable, Equatable, Hashable,
}
public var debugDescription: String {
- "RadrootsApplePreparedImage(sha256: \(sha256), sizeBytes: \(file.sizeBytes), width: \(width), height: \(height))"
+ "RadrootsApplePreparedImage(sha256: \(sha256), sizeBytes: \(file.sizeBytes), "
+ + "width: \(width), height: \(height))"
}
}
@@ -86,51 +86,31 @@ public actor RadrootsAppleMediaPreparer {
self.protectedData = protectedData
}
- public func prepareImage(_ request: RadrootsAppleImagePreparationRequest) async throws
- -> RadrootsApplePreparedImage
- {
- do { return try await prepareValidatedImage(request) } catch is CancellationError {
+ /// Prepares a single-frame JPEG, PNG or HEIF/HEIC raster with at most
+ /// eight-bit source components. Source axes are limited to 32,768 pixels;
+ /// the existing request limits and a 512 MiB raster working-byte estimate
+ /// apply before decoding. One request decodes at a time per preparer.
+ /// The returned PNG contains oriented standard-sRGB pixels, without source
+ /// location, device, comment or camera-profile metadata.
+ public func prepareImage(
+ _ request: RadrootsAppleImagePreparationRequest
+ ) async throws -> RadrootsApplePreparedImage {
+ // One actor-owned, non-suspending decode at a time. Drain native temporary
+ // objects before another queued request can allocate its raster buffers.
+ do { return try autoreleasepool { try prepareValidatedImage(request) } } catch is CancellationError {
throw CancellationError()
- } catch let error
- as RadrootsAppleMediaPreparationError
- { throw error } catch { throw RadrootsAppleMediaPreparationError.preparationFailure }
+ } catch let error as RadrootsAppleMediaPreparationError {
+ throw error
+ } catch { throw RadrootsAppleMediaPreparationError.preparationFailure }
}
- private func prepareValidatedImage(_ request: RadrootsAppleImagePreparationRequest) async throws
- -> RadrootsApplePreparedImage
- {
+ private func prepareValidatedImage(
+ _ request: RadrootsAppleImagePreparationRequest
+ ) throws -> RadrootsApplePreparedImage {
try Task.checkCancellation()
try requireProtectedData()
- let sourceData: Data
- do {
- sourceData = try resolver.read(request.source, maximumBytes: request.maximumInputBytes)
- } catch {
- throw RadrootsAppleMediaPreparationError.invalidRequest
- }
- guard !sourceData.isEmpty else {
- throw RadrootsAppleMediaPreparationError.invalidRequest
- }
- guard
- let source = CGImageSourceCreateWithData(
- sourceData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
- CGImageSourceGetCount(source) == 1
- else { throw RadrootsAppleMediaPreparationError.invalidRequest }
- let dimensions = try Self.sourceDimensions(source)
- guard dimensions.pixelCount <= request.maximumPixelCount else {
- throw RadrootsAppleMediaPreparationError.invalidRequest
- }
- let thumbnailOptions: [CFString: Any] = [
- kCGImageSourceCreateThumbnailFromImageAlways: true,
- kCGImageSourceCreateThumbnailWithTransform: true,
- kCGImageSourceShouldCacheImmediately: true,
- kCGImageSourceThumbnailMaxPixelSize: request.maximumDimension,
- ]
- guard
- let normalizedImage = CGImageSourceCreateThumbnailAtIndex(
- source, 0, thumbnailOptions as CFDictionary)
- else {
- throw RadrootsAppleMediaPreparationError.preparationFailure
- }
+ let sourceData = try readSource(request)
+ let normalizedImage = try RadrootsAppleImageDecode.normalizedImage(sourceData, request: request)
try Task.checkCancellation()
let temporaryURL = roots.temporaryRoot.appendingPathComponent(
@@ -143,24 +123,7 @@ public actor RadrootsAppleMediaPreparer {
try? fileManager.removeItem(at: temporaryURL)
}
}
- try fileManager.createDirectory(
- at: temporaryURL.deletingLastPathComponent(), withIntermediateDirectories: true)
- #if os(iOS)
- try fileManager.setAttributes(
- [.protectionKey: FileProtectionType.complete],
- ofItemAtPath: temporaryURL.deletingLastPathComponent().path
- )
- #endif
- guard
- let destination = CGImageDestinationCreateWithURL(
- temporaryURL as CFURL, UTType.png.identifier as CFString, 1, nil)
- else {
- throw RadrootsAppleMediaPreparationError.preparationFailure
- }
- CGImageDestinationAddImage(destination, normalizedImage, [:] as CFDictionary)
- guard CGImageDestinationFinalize(destination) else {
- throw RadrootsAppleMediaPreparationError.preparationFailure
- }
+ try encodePNG(normalizedImage, at: temporaryURL)
try Task.checkCancellation()
let outputSize = try Self.fileSize(at: temporaryURL)
guard outputSize > 0, outputSize <= request.maximumOutputBytes else {
@@ -170,6 +133,9 @@ public actor RadrootsAppleMediaPreparer {
let staged = try RadrootsStagedBlobReference(
blobID: digest, sizeBytes: outputSize, mediaType: "image/png", filenameHint: "\(digest).png"
)
+ try Task.checkCancellation()
+ try requireProtectedData()
+ try Task.checkCancellation()
let stagedURL = try roots.stagedBlobURL(for: staged)
try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true)
if fileManager.fileExists(atPath: stagedURL.path) {
@@ -212,36 +178,51 @@ public actor RadrootsAppleMediaPreparer {
headers: [
"Authorization": authorization, "Content-Type": "image/png",
"X-SHA-256": preparedImage.sha256,
- "Accept": "application/json", "Accept-Encoding": "identity",
+ "Accept": "application/json", "Accept-Encoding": "identity"
],
metadata: ["purpose": "blossom_upload", "sha256": preparedImage.sha256],
networkPolicy: networkPolicy,
responsePolicy: .boundedJSON(), expectedSourceSHA256: preparedImage.sha256
)
- } catch let error as RadrootsAppleMediaPreparationError { throw error } catch let error
- as RadrootsBackgroundTransferError
- {
+ } catch let error as RadrootsAppleMediaPreparationError { throw error
+ } catch let error as RadrootsBackgroundTransferError {
throw error
} catch { throw RadrootsAppleMediaPreparationError.preparationFailure }
}
+ private func readSource(_ request: RadrootsAppleImagePreparationRequest) throws -> Data {
+ do {
+ return try resolver.read(request.source, maximumBytes: request.maximumInputBytes)
+ } catch {
+ throw RadrootsAppleMediaPreparationError.invalidRequest
+ }
+ }
+
+ private func encodePNG(_ image: CGImage, at url: URL) throws {
+ try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ #if os(iOS)
+ try fileManager.setAttributes(
+ [.protectionKey: FileProtectionType.complete],
+ ofItemAtPath: url.deletingLastPathComponent().path
+ )
+ #endif
+ guard let destination = CGImageDestinationCreateWithURL(
+ url as CFURL, UTType.png.identifier as CFString, 1, nil
+ ) else {
+ throw RadrootsAppleMediaPreparationError.preparationFailure
+ }
+ CGImageDestinationAddImage(destination, image, [:] as CFDictionary)
+ guard CGImageDestinationFinalize(destination) else {
+ throw RadrootsAppleMediaPreparationError.preparationFailure
+ }
+ }
+
private func requireProtectedData() throws {
guard protectedData.currentState() == .available else {
throw RadrootsAppleMediaPreparationError.unavailable
}
}
- private static func sourceDimensions(_ source: CGImageSource) throws -> (
- width: Int, height: Int, pixelCount: Int
- ) {
- guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
- let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue,
- let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue, width > 0,
- height > 0, width <= Int.max / height
- else { throw RadrootsAppleMediaPreparationError.invalidRequest }
- return (width, height, width * height)
- }
-
private static func fileSize(at url: URL) throws -> Int {
guard let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize else {
throw RadrootsAppleMediaPreparationError.preparationFailure
diff --git a/Tests/RadrootsKitTests/RadrootsAppleImageDecodeTests.swift b/Tests/RadrootsKitTests/RadrootsAppleImageDecodeTests.swift
@@ -0,0 +1,234 @@
+import CoreGraphics
+import Darwin
+import Foundation
+import ImageIO
+@testable import RadrootsKit
+
+@Test func imageDecodeAcceptsSupportedCameraRastersAsSanitizedPNG() async throws {
+ let fixture = try ImageDecodeFixture()
+ defer { fixture.remove() }
+ let preparer = RadrootsAppleMediaPreparer(roots: fixture.roots)
+ for type in [UTType.jpeg, UTType.png, UTType.heic] {
+ try fixture.writeImage(type: type, dimension: 64)
+ let result = try await preparer.prepareImage(.init(source: fixture.source))
+ #expect(result.width == 64 && result.height == 64)
+ #expect(result.file.mediaType == "image/png")
+ let bytes = try Data(contentsOf: fixture.roots.stagedBlobURL(for: result.file))
+ #expect(RadrootsAppleFileDigest.sha256(bytes) == result.sha256)
+ let source = try #require(CGImageSourceCreateWithData(bytes as CFData, nil))
+ #expect(CGImageSourceGetType(source) as String? == UTType.png.identifier)
+ }
+}
+
+import Testing
+import UniformTypeIdentifiers
+
+@Test func imageDecodeRejectsDimensionPixelAndWorkingMemoryBombsBeforeAllocation() throws {
+ try RadrootsAppleImageDecode.validateDimensions(width: 8000, height: 5000, inputBytes: 40 * 1024 * 1024,
+ maximumPixelCount: 40_000_000, maximumDimension: 4096)
+ #expect(throws: RadrootsAppleMediaPreparationError.invalidRequest) {
+ try RadrootsAppleImageDecode.validateDimensions(width: 8000, height: 5000, inputBytes: 1,
+ maximumPixelCount: 39_999_999, maximumDimension: 4096)
+ }
+ for dimension in [0, -1, 32769, Int.max] {
+ #expect(throws: RadrootsAppleMediaPreparationError.invalidRequest) {
+ try RadrootsAppleImageDecode.validateDimensions(width: dimension, height: 1, inputBytes: 1,
+ maximumPixelCount: 40_000_000, maximumDimension: 4096)
+ }
+ }
+ // 32,768,000 pixels and three same-sized derivative rasters consume
+ // 524,288,000 bytes. This input reaches the 512 MiB admission boundary.
+ try RadrootsAppleImageDecode.validateDimensions(width: 8192, height: 4000, inputBytes: 12_582_912,
+ maximumPixelCount: 40_000_000, maximumDimension: 8192)
+ #expect(throws: RadrootsAppleMediaPreparationError.invalidRequest) {
+ try RadrootsAppleImageDecode.validateDimensions(width: 8192, height: 4000, inputBytes: 12_582_913,
+ maximumPixelCount: 40_000_000, maximumDimension: 8192)
+ }
+}
+
+@Test func imageDecodeEnforcesActualInputOutputAndPixelBoundaries() async throws {
+ let fixture = try ImageDecodeFixture()
+ defer { fixture.remove() }
+ try fixture.writeImage()
+ let size = try Data(contentsOf: fixture.sourceURL).count
+ let preparer = RadrootsAppleMediaPreparer(roots: fixture.roots)
+ let first = try await preparer.prepareImage(.init(
+ source: fixture.source,
+ maximumInputBytes: size,
+ maximumPixelCount: 16
+ ))
+ let exact = try await preparer.prepareImage(.init(source: fixture.source, maximumOutputBytes: first.file.sizeBytes))
+ #expect(exact == first)
+ for request in try [
+ RadrootsAppleImagePreparationRequest(source: fixture.source, maximumInputBytes: size - 1),
+ RadrootsAppleImagePreparationRequest(source: fixture.source, maximumOutputBytes: first.file.sizeBytes - 1),
+ RadrootsAppleImagePreparationRequest(source: fixture.source, maximumPixelCount: 15)
+ ] {
+ await #expect(throws: RadrootsAppleMediaPreparationError.invalidRequest) {
+ _ = try await preparer.prepareImage(request)
+ }
+ }
+ let tiny = try await preparer.prepareImage(.init(source: fixture.source, maximumDimension: 1))
+ #expect(tiny.width == 1 && tiny.height == 1)
+ let bytes = try Data(contentsOf: fixture.roots.stagedBlobURL(for: tiny.file))
+ #expect(RadrootsAppleFileDigest.sha256(bytes) == tiny.sha256)
+ let decoded = try #require(CGImageSourceCreateWithData(bytes as CFData, nil))
+ let image = try #require(CGImageSourceCreateImageAtIndex(decoded, 0, nil))
+ #expect(image.width == 1 && image.height == 1 && image.bitsPerComponent == 8)
+ #expect(try fixture.temporaryFiles().isEmpty)
+}
+
+@Test func imageDecodeRejectsMalformedUnsupportedHighDepthAndMultipleFrames() async throws {
+ let fixture = try ImageDecodeFixture()
+ defer { fixture.remove() }
+ let preparer = RadrootsAppleMediaPreparer(roots: fixture.roots)
+ let request = try RadrootsAppleImagePreparationRequest(source: fixture.source)
+ for bytes in [Data(), Data("not a raster".utf8), ImageDecodeFixture.hugePNGHeader()] {
+ try bytes.write(to: fixture.sourceURL)
+ await #expect(throws: RadrootsAppleMediaPreparationError.invalidRequest) {
+ _ = try await preparer.prepareImage(request)
+ }
+ }
+ for (type, depth, frames) in [(UTType.gif, 8, 1), (UTType.png, 16, 1), (UTType.png, 8, 2)] {
+ try fixture.writeImage(type: type, depth: depth, frames: frames)
+ let source = try #require(CGImageSourceCreateWithURL(fixture.sourceURL as CFURL, nil))
+ if depth == 16 {
+ let properties = try #require(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any])
+ #expect((properties[kCGImagePropertyDepth] as? NSNumber)?.intValue == 16)
+ }
+ #expect(CGImageSourceGetCount(source) == frames)
+ await #expect(throws: RadrootsAppleMediaPreparationError.invalidRequest) {
+ _ = try await preparer.prepareImage(request)
+ }
+ }
+ #expect(try fixture.temporaryFiles().isEmpty)
+}
+
+@Test func imageDecodeCancellationAndConcurrentRequestsLeaveOneSanitizedIdentity() async throws {
+ let fixture = try ImageDecodeFixture()
+ defer { fixture.remove() }
+ try fixture.writeImage()
+ let preparer = RadrootsAppleMediaPreparer(roots: fixture.roots)
+ let request = try RadrootsAppleImagePreparationRequest(source: fixture.source)
+ let cancelled = Task {
+ withUnsafeCurrentTask { $0?.cancel() }
+ return try await preparer.prepareImage(request)
+ }
+ await #expect(throws: CancellationError.self) { try await cancelled.value }
+ #expect(!FileManager.default.fileExists(atPath: fixture.roots.stagedBlobsRoot.path))
+ let results = try await withThrowingTaskGroup(of: RadrootsApplePreparedImage.self) { group in
+ for _ in 0 ..< 8 {
+ group.addTask { try await preparer.prepareImage(request) }
+ }
+ var values: [RadrootsApplePreparedImage] = []
+ for try await value in group {
+ values.append(value)
+ }
+ return values
+ }
+ #expect(results.count == 8)
+ #expect(Set(results).count == 1)
+ #expect(try FileManager.default.contentsOfDirectory(atPath: fixture.roots.stagedBlobsRoot.path).count == 1)
+ #expect(try fixture.temporaryFiles().isEmpty)
+}
+
+private struct ImageDecodeFixture {
+ let base: URL
+ let roots: RadrootsAppleFileRoots
+ let sourceURL: URL
+ let source: RadrootsBackgroundTransferLocalFile
+
+ init() throws {
+ let unresolved = FileManager.default.temporaryDirectory
+ .appendingPathComponent("radroots-image-decode-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: unresolved, withIntermediateDirectories: true)
+ let pointer = try #require(unresolved.path.withCString { Darwin.realpath($0, nil) })
+ defer { Darwin.free(pointer) }
+ base = URL(fileURLWithPath: String(cString: pointer), isDirectory: true)
+ roots = try RadrootsAppleFileRoots(
+ appIdentifier: "org.radroots.tests",
+ dataRoot: base.appendingPathComponent("data"),
+ cacheRoot: base.appendingPathComponent("cache"),
+ temporaryRoot: base.appendingPathComponent("tmp")
+ )
+ let reference = RadrootsFileReference(scope: .cache, relativePath: "source.image")
+ source = .file(reference)
+ sourceURL = try roots.resolvedURL(for: reference)
+ try FileManager.default.createDirectory(
+ at: sourceURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ }
+
+ func remove() {
+ try? FileManager.default.removeItem(at: base)
+ }
+
+ func temporaryFiles() throws -> [String] {
+ let path = roots.temporaryRoot.appendingPathComponent("media_preparation").path
+ return FileManager.default.fileExists(atPath: path) ? try FileManager.default
+ .contentsOfDirectory(atPath: path) : []
+ }
+
+ func writeImage(type: UTType = .png, depth: Int = 8, frames: Int = 1, dimension: Int = 4) throws {
+ let pixels = Data(repeating: 127, count: dimension * dimension * 4 * (depth / 8))
+ let provider = try #require(CGDataProvider(data: pixels as CFData))
+ let image = try #require(CGImage(
+ width: dimension,
+ height: dimension,
+ bitsPerComponent: depth,
+ bitsPerPixel: depth * 4,
+ bytesPerRow: dimension * 4 * (depth / 8),
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue),
+ provider: provider,
+ decode: nil,
+ shouldInterpolate: false,
+ intent: .defaultIntent
+ ))
+ let destination = try #require(CGImageDestinationCreateWithURL(
+ sourceURL as CFURL,
+ type.identifier as CFString,
+ frames,
+ nil
+ ))
+ for _ in 0 ..< frames {
+ CGImageDestinationAddImage(destination, image, nil)
+ }
+ try #require(CGImageDestinationFinalize(destination))
+ }
+
+ static func hugePNGHeader() -> Data {
+ // A valid CRC over a claimed 2^31-1 square raster, without allocating
+ // pixel storage. Decoders must reject it before raster allocation.
+ var payload: [UInt8] = [73, 72, 68, 82, 127, 255, 255, 255, 127, 255, 255, 255, 8, 6, 0, 0, 0]
+ var crc: UInt32 = 0xFFFF_FFFF
+ for byte in payload {
+ crc ^= UInt32(byte)
+ for _ in 0 ..< 8 {
+ crc = (crc >> 1) ^ (crc & 1 == 0 ? 0 : 0xEDB8_8320)
+ }
+ }
+ crc ^= 0xFFFF_FFFF
+ payload += [
+ UInt8(truncatingIfNeeded: crc >> 24),
+ UInt8(truncatingIfNeeded: crc >> 16),
+ UInt8(truncatingIfNeeded: crc >> 8),
+ UInt8(truncatingIfNeeded: crc)
+ ]
+ return Data([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13] + payload + [
+ 0,
+ 0,
+ 0,
+ 0,
+ 73,
+ 69,
+ 78,
+ 68,
+ 174,
+ 66,
+ 96,
+ 130
+ ])
+ }
+}
diff --git a/Tests/RadrootsKitTests/RadrootsAppleMediaPreparationTests.swift b/Tests/RadrootsKitTests/RadrootsAppleMediaPreparationTests.swift
@@ -2,11 +2,10 @@ import CoreGraphics
import Darwin
import Foundation
import ImageIO
+@testable import RadrootsKit
import Testing
import UniformTypeIdentifiers
-@testable import RadrootsKit
-
@Test func appleMediaPreparationNormalizesAndCommitsStableFinalBytes() async throws {
let roots = try mediaPreparationRoots()
let sourceReference = RadrootsFileReference(scope: .cache, relativePath: "capture/source.jpg")
@@ -28,8 +27,20 @@ import UniformTypeIdentifiers
let outputURL = try roots.stagedBlobURL(for: first.file)
let outputSource = try #require(CGImageSourceCreateWithURL(outputURL as CFURL, nil))
let outputProperties = try #require(
- CGImageSourceCopyPropertiesAtIndex(outputSource, 0, nil) as? [CFString: Any])
+ CGImageSourceCopyPropertiesAtIndex(outputSource, 0, nil) as? [CFString: Any]
+ )
#expect(outputProperties[kCGImagePropertyGPSDictionary] == nil)
+ let exif = outputProperties[kCGImagePropertyExifDictionary] as? [CFString: Any] ?? [:]
+ #expect(Set(exif.keys).isSubset(of: [
+ kCGImagePropertyExifColorSpace,
+ kCGImagePropertyExifPixelXDimension,
+ kCGImagePropertyExifPixelYDimension
+ ]))
+ #expect(outputProperties[kCGImagePropertyTIFFDictionary] == nil)
+ #expect(outputProperties[kCGImagePropertyIPTCDictionary] == nil)
+ let output = try Data(contentsOf: outputURL)
+ #expect(RadrootsAppleFileDigest.sha256(output) == first.sha256)
+ #expect(output.range(of: Data("SECRET_DEVICE".utf8)) == nil)
#expect((outputProperties[kCGImagePropertyOrientation] as? NSNumber)?.intValue ?? 1 == 1)
}
@@ -39,7 +50,8 @@ import UniformTypeIdentifiers
try writeOrientedImageWithMetadata(to: roots.resolvedURL(for: sourceReference))
let preparer = RadrootsAppleMediaPreparer(roots: roots)
let prepared = try await preparer.prepareImage(
- RadrootsAppleImagePreparationRequest(source: .file(sourceReference)))
+ RadrootsAppleImagePreparationRequest(source: .file(sourceReference))
+ )
let request = try await preparer.blossomUploadRequest(
preparedImage: prepared,
@@ -80,14 +92,17 @@ import UniformTypeIdentifiers
let bounded = RadrootsAppleMediaPreparer(roots: roots)
await #expect(throws: RadrootsAppleMediaPreparationError.invalidRequest) {
_ = try await bounded.prepareImage(
- RadrootsAppleImagePreparationRequest(source: .file(sourceReference), maximumInputBytes: 1))
+ RadrootsAppleImagePreparationRequest(source: .file(sourceReference), maximumInputBytes: 1)
+ )
}
let locked = RadrootsAppleMediaPreparer(
- roots: roots, protectedData: RadrootsProtectedDataProvider { .locked })
+ roots: roots, protectedData: RadrootsProtectedDataProvider { .locked }
+ )
await #expect(throws: RadrootsAppleMediaPreparationError.unavailable) {
_ = try await locked.prepareImage(
- RadrootsAppleImagePreparationRequest(source: .file(sourceReference)))
+ RadrootsAppleImagePreparationRequest(source: .file(sourceReference))
+ )
}
}
@@ -103,13 +118,21 @@ private func writeOrientedImageWithMetadata(to url: URL) throws {
context.fill(CGRect(x: 0, y: 0, width: 2, height: 3))
let image = try #require(context.makeImage())
try FileManager.default.createDirectory(
- at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ at: url.deletingLastPathComponent(), withIntermediateDirectories: true
+ )
let destination = try #require(
- CGImageDestinationCreateWithURL(url as CFURL, UTType.jpeg.identifier as CFString, 1, nil))
+ CGImageDestinationCreateWithURL(url as CFURL, UTType.jpeg.identifier as CFString, 1, nil)
+ )
let properties: [CFString: Any] = [
kCGImagePropertyOrientation: 6,
kCGImagePropertyGPSDictionary: [kCGImagePropertyGPSLatitude: 45.0],
- kCGImageDestinationLossyCompressionQuality: 0.9,
+ kCGImagePropertyTIFFDictionary: [
+ kCGImagePropertyTIFFMake: "SECRET_DEVICE",
+ kCGImagePropertyTIFFModel: "SECRET_DEVICE"
+ ],
+ kCGImagePropertyExifDictionary: [kCGImagePropertyExifUserComment: "SECRET_DEVICE"],
+ kCGImagePropertyIPTCDictionary: [kCGImagePropertyIPTCCaptionAbstract: "SECRET_DEVICE"],
+ kCGImageDestinationLossyCompressionQuality: 0.9
]
CGImageDestinationAddImage(destination, image, properties as CFDictionary)
try #require(CGImageDestinationFinalize(destination))