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

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 }