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
12 changes: 7 additions & 5 deletions Modules/Capabilities/ImageOpening/Sources/ImageOpener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ public struct ImageOpener {
public init() {}

public func openImage(at url: URL) throws -> UIImage {
guard url.startAccessingSecurityScopedResource() else {
throw ImageOpeningError.securityScopeDenied
}
defer { url.stopAccessingSecurityScopedResource() }
return try openImage(resource: url)
}

func openImage(resource: SecureResource) throws -> UIImage {
_ = resource.startAccessingSecurityScopedResource()
defer { resource.stopAccessingSecurityScopedResource() }

let imageData = try Data(contentsOf: url)
let imageData = try resource.data
guard let image = UIImage(data: imageData) else {
throw ImageOpeningError.noImageFound
}
Expand Down
18 changes: 18 additions & 0 deletions Modules/Capabilities/ImageOpening/Sources/SecureResource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Created by Geoff Pado on 9/19/25.
// Copyright © 2025 Cocoatype, LLC. All rights reserved.

import Foundation

protocol SecureResource {
func startAccessingSecurityScopedResource() -> Bool
func stopAccessingSecurityScopedResource()
var data: Data { get throws }
}

extension URL: SecureResource {
var data: Data {
get throws {
try Data(contentsOf: self)
}
}
}
66 changes: 58 additions & 8 deletions Modules/Capabilities/ImageOpening/Tests/ImageOpenerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,65 @@ import UIKit
@testable import ImageOpening

struct ImageOpenerTests {
static var imageData: Data {
get throws {
let image = try #require(UIImage(systemName: "bolt"))
return try #require(image.pngData())
}
}

@Test
func `openImage succeeds if not secure`() throws {
let resource = try SpySecureResource(
dataResult: .success(Self.imageData),
isSecure: false
)
_ = try ImageOpener().openImage(resource: resource)
#expect(resource.startAccessingCalled)
#expect(resource.stopAccessingCalled)
}

@Test
func `openImage fails if data errors and not secure`() throws {
let resource = SpySecureResource(
dataResult: .failure(TestError.sample),
isSecure: false
)

#expect(throws: TestError.sample) {
_ = try ImageOpener().openImage(resource: resource)
}
#expect(resource.startAccessingCalled)
#expect(resource.stopAccessingCalled)
}

@Test
func openImage() throws {
let boltImage = try #require(UIImage(systemName: "bolt"))
let boltData = try #require(boltImage.pngData())
let boltEncoded = boltData.base64EncodedString()
let boltURL = try #require(URL(string: "data:image/png;base64,\(boltEncoded)"))

#expect(throws: ImageOpeningError.securityScopeDenied) {
try ImageOpener().openImage(at: boltURL)
func `openImage succeeds if secure`() throws {
let resource = try SpySecureResource(
dataResult: .success(Self.imageData),
isSecure: true
)

_ = try ImageOpener().openImage(resource: resource)
#expect(resource.startAccessingCalled)
#expect(resource.stopAccessingCalled)
}

@Test
func `openImage fails if data errors and secure`() throws {
let resource = SpySecureResource(
dataResult: .failure(TestError.sample),
isSecure: true
)

#expect(throws: TestError.sample) {
_ = try ImageOpener().openImage(resource: resource)
}
#expect(resource.startAccessingCalled)
#expect(resource.stopAccessingCalled)
}

enum TestError: Error {
case sample
}
}
46 changes: 46 additions & 0 deletions Modules/Capabilities/ImageOpening/Tests/SpySecureResource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Created by Geoff Pado on 9/19/25.
// Copyright © 2025 Cocoatype, LLC. All rights reserved.

import Foundation

@testable import ImageOpening

class SpySecureResource: SecureResource {
private let dataResult: Result<Data, Swift.Error>
private let isSecure: Bool

init(
dataResult: Result<Data, Swift.Error>,
isSecure: Bool,
) {
self.dataResult = dataResult
self.isSecure = isSecure
}

private(set) var startAccessingCalled = false
func startAccessingSecurityScopedResource() -> Bool {
startAccessingCalled = true
return isSecure
}

private(set) var stopAccessingCalled = false
func stopAccessingSecurityScopedResource() {
stopAccessingCalled = true
}

var data: Data {
get throws {
guard isSecure else { return try dataResult.get() }

guard startAccessingCalled else { throw Error.startNotCalled }
guard stopAccessingCalled == false else { throw Error.stopAlreadyCalled }

return try dataResult.get()
}
}

enum Error: Swift.Error {
case startNotCalled
case stopAlreadyCalled
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Created by Geoff Pado on 9/19/25.
// Copyright © 2025 Cocoatype, LLC. All rights reserved.

import Testing

struct SpySecureResourceTests {
@Test func `data fails if start not called`() throws {
let resource = SpySecureResource(
dataResult: .failure(TestError.sample),
isSecure: true
)

#expect(throws: SpySecureResource.Error.startNotCalled) {
_ = try resource.data
}
}

@Test func `data fails if stop called early`() throws {
let resource = SpySecureResource(
dataResult: .failure(TestError.sample),
isSecure: true
)

_ = resource.startAccessingSecurityScopedResource()
resource.stopAccessingSecurityScopedResource()

#expect(throws: SpySecureResource.Error.stopAlreadyCalled) {
_ = try resource.data
}
}

@Test func `startAccessing returns true if secure`() throws {
let resource = SpySecureResource(
dataResult: .failure(TestError.sample),
isSecure: true
)

#expect(resource.startAccessingSecurityScopedResource() == true)
}

@Test func `startAccessing returns false if not secure`() throws {
let resource = SpySecureResource(
dataResult: .failure(TestError.sample),
isSecure: false
)

#expect(resource.startAccessingSecurityScopedResource() == false)
}

enum TestError: Error {
case sample
}
}