apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

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:
ASources/RadrootsKit/RadrootsAppleImageDecode.swift | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MSources/RadrootsKit/RadrootsAppleMediaPreparation.swift | 145++++++++++++++++++++++++++++++++++---------------------------------------------
ATests/RadrootsKitTests/RadrootsAppleImageDecodeTests.swift | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/RadrootsKitTests/RadrootsAppleMediaPreparationTests.swift | 43+++++++++++++++++++++++++++++++++----------
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))