RadrootsAppleImageDecodeTests.swift (10840B)
1 import CoreGraphics 2 import Darwin 3 import Foundation 4 import ImageIO 5 @testable import RadrootsKit 6 7 @Test func imageDecodeAcceptsSupportedCameraRastersAsSanitizedPNG() async throws { 8 let fixture = try ImageDecodeFixture() 9 defer { fixture.remove() } 10 let preparer = RadrootsAppleMediaPreparer(roots: fixture.roots) 11 for type in [UTType.jpeg, UTType.png, UTType.heic] { 12 try fixture.writeImage(type: type, dimension: 64) 13 let result = try await preparer.prepareImage(.init(source: fixture.source)) 14 #expect(result.width == 64 && result.height == 64) 15 #expect(result.file.mediaType == "image/png") 16 let bytes = try Data(contentsOf: fixture.roots.stagedBlobURL(for: result.file)) 17 #expect(RadrootsAppleFileDigest.sha256(bytes) == result.sha256) 18 let source = try #require(CGImageSourceCreateWithData(bytes as CFData, nil)) 19 #expect(CGImageSourceGetType(source) as String? == UTType.png.identifier) 20 } 21 } 22 23 import Testing 24 import UniformTypeIdentifiers 25 26 @Test func imageDecodeRejectsDimensionPixelAndWorkingMemoryBombsBeforeAllocation() throws { 27 try RadrootsAppleImageDecode.validateDimensions(width: 8000, height: 5000, inputBytes: 40 * 1024 * 1024, 28 maximumPixelCount: 40_000_000, maximumDimension: 4096) 29 #expect(throws: RadrootsAppleMediaPreparationError.invalidRequest) { 30 try RadrootsAppleImageDecode.validateDimensions(width: 8000, height: 5000, inputBytes: 1, 31 maximumPixelCount: 39_999_999, maximumDimension: 4096) 32 } 33 for dimension in [0, -1, 32769, Int.max] { 34 #expect(throws: RadrootsAppleMediaPreparationError.invalidRequest) { 35 try RadrootsAppleImageDecode.validateDimensions(width: dimension, height: 1, inputBytes: 1, 36 maximumPixelCount: 40_000_000, maximumDimension: 4096) 37 } 38 } 39 // 32,768,000 pixels and three same-sized derivative rasters consume 40 // 524,288,000 bytes. This input reaches the 512 MiB admission boundary. 41 try RadrootsAppleImageDecode.validateDimensions(width: 8192, height: 4000, inputBytes: 12_582_912, 42 maximumPixelCount: 40_000_000, maximumDimension: 8192) 43 #expect(throws: RadrootsAppleMediaPreparationError.invalidRequest) { 44 try RadrootsAppleImageDecode.validateDimensions(width: 8192, height: 4000, inputBytes: 12_582_913, 45 maximumPixelCount: 40_000_000, maximumDimension: 8192) 46 } 47 } 48 49 @Test func imageDecodeEnforcesActualInputOutputAndPixelBoundaries() async throws { 50 let fixture = try ImageDecodeFixture() 51 defer { fixture.remove() } 52 try fixture.writeImage() 53 let size = try Data(contentsOf: fixture.sourceURL).count 54 let preparer = RadrootsAppleMediaPreparer(roots: fixture.roots) 55 let first = try await preparer.prepareImage(.init( 56 source: fixture.source, 57 maximumInputBytes: size, 58 maximumPixelCount: 16 59 )) 60 let exact = try await preparer.prepareImage(.init(source: fixture.source, maximumOutputBytes: first.file.sizeBytes)) 61 #expect(exact == first) 62 for request in try [ 63 RadrootsAppleImagePreparationRequest(source: fixture.source, maximumInputBytes: size - 1), 64 RadrootsAppleImagePreparationRequest(source: fixture.source, maximumOutputBytes: first.file.sizeBytes - 1), 65 RadrootsAppleImagePreparationRequest(source: fixture.source, maximumPixelCount: 15) 66 ] { 67 await #expect(throws: RadrootsAppleMediaPreparationError.invalidRequest) { 68 _ = try await preparer.prepareImage(request) 69 } 70 } 71 let tiny = try await preparer.prepareImage(.init(source: fixture.source, maximumDimension: 1)) 72 #expect(tiny.width == 1 && tiny.height == 1) 73 let bytes = try Data(contentsOf: fixture.roots.stagedBlobURL(for: tiny.file)) 74 #expect(RadrootsAppleFileDigest.sha256(bytes) == tiny.sha256) 75 let decoded = try #require(CGImageSourceCreateWithData(bytes as CFData, nil)) 76 let image = try #require(CGImageSourceCreateImageAtIndex(decoded, 0, nil)) 77 #expect(image.width == 1 && image.height == 1 && image.bitsPerComponent == 8) 78 #expect(try fixture.temporaryFiles().isEmpty) 79 } 80 81 @Test func imageDecodeRejectsMalformedUnsupportedHighDepthAndMultipleFrames() async throws { 82 let fixture = try ImageDecodeFixture() 83 defer { fixture.remove() } 84 let preparer = RadrootsAppleMediaPreparer(roots: fixture.roots) 85 let request = try RadrootsAppleImagePreparationRequest(source: fixture.source) 86 for bytes in [Data(), Data("not a raster".utf8), ImageDecodeFixture.hugePNGHeader()] { 87 try bytes.write(to: fixture.sourceURL) 88 await #expect(throws: RadrootsAppleMediaPreparationError.invalidRequest) { 89 _ = try await preparer.prepareImage(request) 90 } 91 } 92 for (type, depth, frames) in [(UTType.gif, 8, 1), (UTType.png, 16, 1), (UTType.png, 8, 2)] { 93 try fixture.writeImage(type: type, depth: depth, frames: frames) 94 let source = try #require(CGImageSourceCreateWithURL(fixture.sourceURL as CFURL, nil)) 95 if depth == 16 { 96 let properties = try #require(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]) 97 #expect((properties[kCGImagePropertyDepth] as? NSNumber)?.intValue == 16) 98 } 99 #expect(CGImageSourceGetCount(source) == frames) 100 await #expect(throws: RadrootsAppleMediaPreparationError.invalidRequest) { 101 _ = try await preparer.prepareImage(request) 102 } 103 } 104 #expect(try fixture.temporaryFiles().isEmpty) 105 } 106 107 @Test func imageDecodeCancellationAndConcurrentRequestsLeaveOneSanitizedIdentity() async throws { 108 let fixture = try ImageDecodeFixture() 109 defer { fixture.remove() } 110 try fixture.writeImage() 111 let preparer = RadrootsAppleMediaPreparer(roots: fixture.roots) 112 let request = try RadrootsAppleImagePreparationRequest(source: fixture.source) 113 let cancelled = Task { 114 withUnsafeCurrentTask { $0?.cancel() } 115 return try await preparer.prepareImage(request) 116 } 117 await #expect(throws: CancellationError.self) { try await cancelled.value } 118 #expect(!FileManager.default.fileExists(atPath: fixture.roots.stagedBlobsRoot.path)) 119 let results = try await withThrowingTaskGroup(of: RadrootsApplePreparedImage.self) { group in 120 for _ in 0 ..< 8 { 121 group.addTask { try await preparer.prepareImage(request) } 122 } 123 var values: [RadrootsApplePreparedImage] = [] 124 for try await value in group { 125 values.append(value) 126 } 127 return values 128 } 129 #expect(results.count == 8) 130 #expect(Set(results).count == 1) 131 #expect(try FileManager.default.contentsOfDirectory(atPath: fixture.roots.stagedBlobsRoot.path).count == 1) 132 #expect(try fixture.temporaryFiles().isEmpty) 133 } 134 135 private struct ImageDecodeFixture { 136 let base: URL 137 let roots: RadrootsAppleFileRoots 138 let sourceURL: URL 139 let source: RadrootsBackgroundTransferLocalFile 140 141 init() throws { 142 let unresolved = FileManager.default.temporaryDirectory 143 .appendingPathComponent("radroots-image-decode-\(UUID().uuidString)", isDirectory: true) 144 try FileManager.default.createDirectory(at: unresolved, withIntermediateDirectories: true) 145 let pointer = try #require(unresolved.path.withCString { Darwin.realpath($0, nil) }) 146 defer { Darwin.free(pointer) } 147 base = URL(fileURLWithPath: String(cString: pointer), isDirectory: true) 148 roots = try RadrootsAppleFileRoots( 149 appIdentifier: "org.radroots.tests", 150 dataRoot: base.appendingPathComponent("data"), 151 cacheRoot: base.appendingPathComponent("cache"), 152 temporaryRoot: base.appendingPathComponent("tmp") 153 ) 154 let reference = RadrootsFileReference(scope: .cache, relativePath: "source.image") 155 source = .file(reference) 156 sourceURL = try roots.resolvedURL(for: reference) 157 try FileManager.default.createDirectory( 158 at: sourceURL.deletingLastPathComponent(), 159 withIntermediateDirectories: true 160 ) 161 } 162 163 func remove() { 164 try? FileManager.default.removeItem(at: base) 165 } 166 167 func temporaryFiles() throws -> [String] { 168 let path = roots.temporaryRoot.appendingPathComponent("media_preparation").path 169 return FileManager.default.fileExists(atPath: path) ? try FileManager.default 170 .contentsOfDirectory(atPath: path) : [] 171 } 172 173 func writeImage(type: UTType = .png, depth: Int = 8, frames: Int = 1, dimension: Int = 4) throws { 174 let pixels = Data(repeating: 127, count: dimension * dimension * 4 * (depth / 8)) 175 let provider = try #require(CGDataProvider(data: pixels as CFData)) 176 let image = try #require(CGImage( 177 width: dimension, 178 height: dimension, 179 bitsPerComponent: depth, 180 bitsPerPixel: depth * 4, 181 bytesPerRow: dimension * 4 * (depth / 8), 182 space: CGColorSpaceCreateDeviceRGB(), 183 bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue), 184 provider: provider, 185 decode: nil, 186 shouldInterpolate: false, 187 intent: .defaultIntent 188 )) 189 let destination = try #require(CGImageDestinationCreateWithURL( 190 sourceURL as CFURL, 191 type.identifier as CFString, 192 frames, 193 nil 194 )) 195 for _ in 0 ..< frames { 196 CGImageDestinationAddImage(destination, image, nil) 197 } 198 try #require(CGImageDestinationFinalize(destination)) 199 } 200 201 static func hugePNGHeader() -> Data { 202 // A valid CRC over a claimed 2^31-1 square raster, without allocating 203 // pixel storage. Decoders must reject it before raster allocation. 204 var payload: [UInt8] = [73, 72, 68, 82, 127, 255, 255, 255, 127, 255, 255, 255, 8, 6, 0, 0, 0] 205 var crc: UInt32 = 0xFFFF_FFFF 206 for byte in payload { 207 crc ^= UInt32(byte) 208 for _ in 0 ..< 8 { 209 crc = (crc >> 1) ^ (crc & 1 == 0 ? 0 : 0xEDB8_8320) 210 } 211 } 212 crc ^= 0xFFFF_FFFF 213 payload += [ 214 UInt8(truncatingIfNeeded: crc >> 24), 215 UInt8(truncatingIfNeeded: crc >> 16), 216 UInt8(truncatingIfNeeded: crc >> 8), 217 UInt8(truncatingIfNeeded: crc) 218 ] 219 return Data([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13] + payload + [ 220 0, 221 0, 222 0, 223 0, 224 73, 225 69, 226 78, 227 68, 228 174, 229 66, 230 96, 231 130 232 ]) 233 } 234 }