A macOS CLI tool that exports original photos and videos from the macOS Photos library using PhotoKit.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit: PhotoKit export helper for iCloud Photos

Swift CLI that exports original photos/videos from the Photos library
via PhotoKit. Accepts UUIDs on stdin as JSON, downloads iCloud originals,
computes SHA-256 checksums, and writes results to a staging directory.

- LadderKit library with Hasher (SHA-256), PhotoExporter, and model types
- CLI binary that reads JSON requests from stdin
- 19 unit tests covering hashing, JSON parsing, and error handling
- SwiftLint configuration

Tijs Teulings bfc69fb4

+803
+2
.gitignore
··· 1 + .build/ 2 + .swiftpm/
+5
.swiftlint.yml
··· 1 + included: 2 + - Sources 3 + - Tests 4 + excluded: 5 + - .build
+23
Package.swift
··· 1 + // swift-tools-version: 5.9 2 + import PackageDescription 3 + 4 + let package = Package( 5 + name: "ladder", 6 + platforms: [.macOS(.v13)], 7 + targets: [ 8 + .target( 9 + name: "LadderKit", 10 + path: "Sources/LadderKit" 11 + ), 12 + .executableTarget( 13 + name: "ladder", 14 + dependencies: ["LadderKit"], 15 + path: "Sources/CLI" 16 + ), 17 + .testTarget( 18 + name: "LadderKitTests", 19 + dependencies: ["LadderKit"], 20 + path: "Tests" 21 + ), 22 + ] 23 + )
+70
Sources/CLI/Main.swift
··· 1 + import Foundation 2 + import LadderKit 3 + import Photos 4 + 5 + @main 6 + struct Ladder { 7 + static func main() async { 8 + let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite) 9 + guard status == .authorized else { 10 + fatalExit( 11 + "Photos access not authorized (status: \(status.rawValue)). " 12 + + "Grant access in System Settings > Privacy & Security > Photos." 13 + ) 14 + } 15 + 16 + let request: ExportRequest 17 + do { 18 + request = try parseInput() 19 + } catch { 20 + fatalExit("Failed to parse input: \(error.localizedDescription)") 21 + } 22 + 23 + let stagingURL: URL 24 + do { 25 + stagingURL = try PathSafety.validateStagingDir(request.stagingDir) 26 + try FileManager.default.createDirectory( 27 + at: stagingURL, 28 + withIntermediateDirectories: true 29 + ) 30 + } catch { 31 + fatalExit(error.localizedDescription) 32 + } 33 + 34 + let exporter = PhotoExporter(stagingDir: stagingURL) 35 + let response = await exporter.export(uuids: request.uuids) 36 + 37 + let encoder = JSONEncoder() 38 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 39 + 40 + do { 41 + let data = try encoder.encode(response) 42 + FileHandle.standardOutput.write(data) 43 + FileHandle.standardOutput.write(Data("\n".utf8)) 44 + } catch { 45 + fatalExit("Failed to encode response: \(error.localizedDescription)") 46 + } 47 + } 48 + 49 + private static func parseInput() throws -> ExportRequest { 50 + let data: Data 51 + 52 + if CommandLine.arguments.count > 1 { 53 + let filePath = CommandLine.arguments[1] 54 + data = try Data(contentsOf: URL(fileURLWithPath: filePath)) 55 + } else { 56 + let stdinData = FileHandle.standardInput.readDataToEndOfFile() 57 + guard stdinData.count < 10_000_000 else { 58 + throw ExportFailure.invalidStagingDir("Input exceeds 10 MB limit") 59 + } 60 + data = stdinData 61 + } 62 + 63 + return try JSONDecoder().decode(ExportRequest.self, from: data) 64 + } 65 + 66 + private static func fatalExit(_ message: String) -> Never { 67 + FileHandle.standardError.write(Data("ladder: \(message)\n".utf8)) 68 + exit(1) 69 + } 70 + }
+59
Sources/LadderKit/Hasher.swift
··· 1 + import CommonCrypto 2 + import Foundation 3 + 4 + /// Streaming SHA-256 hasher that can be fed chunks incrementally. 5 + /// 6 + /// ## Safety Invariant (`@unchecked Sendable`) 7 + /// `CC_SHA256_CTX` is a C struct and not `Sendable`. All access to `context` 8 + /// is serialized through `lock` (an `NSLock`), ensuring no concurrent mutations. 9 + /// Each public method acquires the lock before touching `context` and releases 10 + /// it on return. This makes cross-isolation use safe despite the unchecked marker. 11 + /// 12 + /// TODO: Replace with `Mutex<CC_SHA256_CTX>` (available in Swift 6 / macOS 15+) 13 + /// once the deployment target is raised, then drop `@unchecked Sendable`. 14 + public final class StreamingHasher: @unchecked Sendable { 15 + private var context = CC_SHA256_CTX() 16 + private let lock = NSLock() 17 + 18 + public init() { 19 + CC_SHA256_Init(&context) 20 + } 21 + 22 + /// Feed a chunk of data into the hash. 23 + public func update(_ data: Data) { 24 + lock.lock() 25 + defer { lock.unlock() } 26 + data.withUnsafeBytes { bytes in 27 + _ = CC_SHA256_Update(&context, bytes.baseAddress, CC_LONG(data.count)) 28 + } 29 + } 30 + 31 + /// Finalize and return the hex-encoded SHA-256 digest. 32 + public func finalize() -> String { 33 + lock.lock() 34 + defer { lock.unlock() } 35 + var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) 36 + CC_SHA256_Final(&digest, &context) 37 + return digest.map { String(format: "%02x", $0) }.joined() 38 + } 39 + } 40 + 41 + public enum FileHasher { 42 + /// Compute SHA-256 of a file on disk (streaming, memory-efficient). 43 + public static func sha256(fileAt url: URL) throws -> String { 44 + let handle = try FileHandle(forReadingFrom: url) 45 + defer { handle.closeFile() } 46 + 47 + let hasher = StreamingHasher() 48 + let bufferSize = 8 * 1024 * 1024 // 8 MB chunks 49 + 50 + while autoreleasepool(invoking: { 51 + let data = handle.readData(ofLength: bufferSize) 52 + guard !data.isEmpty else { return false } 53 + hasher.update(data) 54 + return true 55 + }) {} 56 + 57 + return hasher.finalize() 58 + } 59 + }
+48
Sources/LadderKit/Models.swift
··· 1 + import Foundation 2 + 3 + /// Input: a request to export specific assets by UUID. 4 + public struct ExportRequest: Codable { 5 + public let uuids: [String] 6 + public let stagingDir: String 7 + 8 + public init(uuids: [String], stagingDir: String) { 9 + self.uuids = uuids 10 + self.stagingDir = stagingDir 11 + } 12 + } 13 + 14 + /// Output: result for a single exported asset. 15 + public struct ExportResult: Codable { 16 + public let uuid: String 17 + public let path: String 18 + public let size: Int64 19 + public let sha256: String 20 + 21 + public init(uuid: String, path: String, size: Int64, sha256: String) { 22 + self.uuid = uuid 23 + self.path = path 24 + self.size = size 25 + self.sha256 = sha256 26 + } 27 + } 28 + 29 + /// Output: the full response written to stdout. 30 + public struct ExportResponse: Codable { 31 + public let results: [ExportResult] 32 + public let errors: [ExportError] 33 + 34 + public init(results: [ExportResult], errors: [ExportError]) { 35 + self.results = results 36 + self.errors = errors 37 + } 38 + } 39 + 40 + public struct ExportError: Codable { 41 + public let uuid: String 42 + public let message: String 43 + 44 + public init(uuid: String, message: String) { 45 + self.uuid = uuid 46 + self.message = message 47 + } 48 + }
+50
Sources/LadderKit/PathSafety.swift
··· 1 + import Foundation 2 + 3 + public enum PathSafety { 4 + /// Sanitize a string for safe use as a filename component. 5 + /// Replaces path separators and other unsafe characters with underscores. 6 + public static func sanitizeFilename(_ name: String) -> String { 7 + let unsafe = CharacterSet(charactersIn: "/\\:*?\"<>|") 8 + return name.unicodeScalars 9 + .map { unsafe.contains($0) ? "_" : String($0) } 10 + .joined() 11 + } 12 + 13 + /// Build a destination URL within stagingDir, validating the result stays inside it. 14 + public static func safeDestination( 15 + stagingDir: URL, 16 + uuid: String, 17 + originalFilename: String 18 + ) throws -> URL { 19 + let safeName = sanitizeFilename(uuid) + "_" + sanitizeFilename(originalFilename) 20 + let destURL = stagingDir.appendingPathComponent(safeName) 21 + 22 + let resolvedDest = destURL.standardizedFileURL.path 23 + let resolvedStaging = stagingDir.standardizedFileURL.path 24 + 25 + guard resolvedDest.hasPrefix(resolvedStaging) else { 26 + throw ExportFailure.unsafePath(uuid) 27 + } 28 + 29 + return destURL 30 + } 31 + 32 + /// Validate that a staging directory path is safe to use. 33 + public static func validateStagingDir(_ path: String) throws -> URL { 34 + guard !path.isEmpty else { 35 + throw ExportFailure.invalidStagingDir("Staging directory path is empty") 36 + } 37 + 38 + guard path.hasPrefix("/") else { 39 + throw ExportFailure.invalidStagingDir("Staging directory must be an absolute path: \(path)") 40 + } 41 + 42 + let forbidden = ["/System", "/Library", "/usr", "/bin", "/sbin", "/var", "/private/var"] 43 + let normalized = URL(fileURLWithPath: path).standardizedFileURL.path 44 + for prefix in forbidden where normalized == prefix || normalized.hasPrefix(prefix + "/") { 45 + throw ExportFailure.invalidStagingDir("Staging directory must not be inside \(prefix)") 46 + } 47 + 48 + return URL(fileURLWithPath: path) 49 + } 50 + }
+122
Sources/LadderKit/PhotoExporter.swift
··· 1 + import Foundation 2 + 3 + public final class PhotoExporter: Sendable { 4 + private let stagingDir: URL 5 + private let library: PhotoLibrary 6 + private let maxConcurrency: Int 7 + 8 + public init( 9 + stagingDir: URL, 10 + library: PhotoLibrary = PhotoKitLibrary(), 11 + maxConcurrency: Int = 6 12 + ) { 13 + self.stagingDir = stagingDir 14 + self.library = library 15 + self.maxConcurrency = maxConcurrency 16 + } 17 + 18 + public func export(uuids: [String]) async -> ExportResponse { 19 + let assets = library.fetchAssets(identifiers: uuids) 20 + 21 + // Report missing UUIDs 22 + let errors: [ExportError] = uuids 23 + .filter { assets[$0] == nil } 24 + .map { ExportError(uuid: $0, message: "Asset not found in Photos library") } 25 + 26 + // Export found assets with bounded concurrency 27 + let exportResults = await withTaskGroup( 28 + of: Result<ExportResult, ExportErrorPair>.self 29 + ) { group in 30 + var pending = 0 31 + var iterator = assets.makeIterator() 32 + var results: [Result<ExportResult, ExportErrorPair>] = [] 33 + 34 + // Seed the group with initial tasks up to maxConcurrency 35 + while pending < maxConcurrency, let (uuid, handle) = iterator.next() { 36 + group.addTask { await self.exportAsset(uuid: uuid, handle: handle) } 37 + pending += 1 38 + } 39 + 40 + // As each completes, start the next (checking cancellation between assets) 41 + for await result in group { 42 + results.append(result) 43 + if Task.isCancelled { break } 44 + if let (uuid, handle) = iterator.next() { 45 + group.addTask { await self.exportAsset(uuid: uuid, handle: handle) } 46 + } 47 + } 48 + 49 + return results 50 + } 51 + 52 + var allResults: [ExportResult] = [] 53 + var allErrors = errors 54 + for result in exportResults { 55 + switch result { 56 + case .success(let exportResult): 57 + allResults.append(exportResult) 58 + case .failure(let pair): 59 + allErrors.append(ExportError(uuid: pair.uuid, message: pair.message)) 60 + } 61 + } 62 + 63 + return ExportResponse(results: allResults, errors: allErrors) 64 + } 65 + 66 + private func exportAsset( 67 + uuid: String, 68 + handle: AssetHandle 69 + ) async -> Result<ExportResult, ExportErrorPair> { 70 + do { 71 + let destURL = try PathSafety.safeDestination( 72 + stagingDir: stagingDir, 73 + uuid: uuid, 74 + originalFilename: handle.originalFilename 75 + ) 76 + 77 + // Create empty file for writing 78 + FileManager.default.createFile(atPath: destURL.path, contents: nil) 79 + 80 + // Stream data: write to file + hash simultaneously 81 + let hasher = StreamingHasher() 82 + let size = try await handle.writeData( 83 + to: destURL, 84 + networkAccessAllowed: true, 85 + chunkHandler: { data in hasher.update(data) } 86 + ) 87 + let sha256 = hasher.finalize() 88 + 89 + return .success(ExportResult( 90 + uuid: uuid, 91 + path: destURL.path, 92 + size: size, 93 + sha256: sha256 94 + )) 95 + } catch { 96 + return .failure(ExportErrorPair(uuid: uuid, message: error.localizedDescription)) 97 + } 98 + } 99 + } 100 + 101 + /// Internal type for passing errors through TaskGroup. 102 + struct ExportErrorPair: Error { 103 + let uuid: String 104 + let message: String 105 + } 106 + 107 + public enum ExportFailure: LocalizedError { 108 + case noResource(String) 109 + case unsafePath(String) 110 + case invalidStagingDir(String) 111 + 112 + public var errorDescription: String? { 113 + switch self { 114 + case .noResource(let uuid): 115 + return "No exportable resource found for asset \(uuid)" 116 + case .unsafePath(let uuid): 117 + return "Destination path escapes staging directory for asset \(uuid)" 118 + case .invalidStagingDir(let reason): 119 + return "Invalid staging directory: \(reason)" 120 + } 121 + } 122 + }
+86
Sources/LadderKit/PhotoLibrary.swift
··· 1 + import Foundation 2 + @preconcurrency import Photos 3 + 4 + /// Abstraction over PhotoKit for testability. 5 + public protocol PhotoLibrary: Sendable { 6 + /// Fetch assets by their local identifiers. 7 + func fetchAssets(identifiers: [String]) -> [String: AssetHandle] 8 + } 9 + 10 + /// Abstraction over a single asset's exportable resource. 11 + public protocol AssetHandle: Sendable { 12 + var originalFilename: String { get } 13 + var resourceType: PHAssetResourceType { get } 14 + 15 + /// Write the asset's original data to a file, streaming chunks to the handler. 16 + /// Each chunk is delivered to `chunkHandler` before being written, enabling 17 + /// inline hashing. The file at `destinationURL` contains the complete data on success. 18 + func writeData( 19 + to destinationURL: URL, 20 + networkAccessAllowed: Bool, 21 + chunkHandler: @escaping @Sendable (Data) -> Void 22 + ) async throws -> Int64 23 + } 24 + 25 + /// Real PhotoKit implementation. 26 + public struct PhotoKitLibrary: PhotoLibrary { 27 + public init() {} 28 + 29 + public func fetchAssets(identifiers: [String]) -> [String: AssetHandle] { 30 + let fetchResult = PHAsset.fetchAssets( 31 + withLocalIdentifiers: identifiers, 32 + options: nil 33 + ) 34 + 35 + var result: [String: AssetHandle] = [:] 36 + fetchResult.enumerateObjects { asset, _, _ in 37 + let resources = PHAssetResource.assetResources(for: asset) 38 + guard let resource = resources.first(where: { $0.type == .photo || $0.type == .video }) 39 + ?? resources.first 40 + else { return } 41 + result[asset.localIdentifier] = PhotoKitAssetHandle(resource: resource) 42 + } 43 + return result 44 + } 45 + } 46 + 47 + struct PhotoKitAssetHandle: AssetHandle { 48 + let resource: PHAssetResource 49 + 50 + var originalFilename: String { resource.originalFilename } 51 + var resourceType: PHAssetResourceType { resource.type } 52 + 53 + func writeData( 54 + to destinationURL: URL, 55 + networkAccessAllowed: Bool, 56 + chunkHandler: @escaping @Sendable (Data) -> Void 57 + ) async throws -> Int64 { 58 + let options = PHAssetResourceRequestOptions() 59 + options.isNetworkAccessAllowed = networkAccessAllowed 60 + 61 + // Use requestData to stream chunks — enables inline hashing while writing 62 + let handle = try FileHandle(forWritingTo: destinationURL) 63 + 64 + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in 65 + PHAssetResourceManager.default().requestData( 66 + for: resource, 67 + options: options, 68 + dataReceivedHandler: { data in 69 + chunkHandler(data) 70 + handle.write(data) 71 + }, 72 + completionHandler: { error in 73 + handle.closeFile() 74 + if let error { 75 + continuation.resume(throwing: error) 76 + } else { 77 + continuation.resume() 78 + } 79 + } 80 + ) 81 + } 82 + 83 + let attrs = try FileManager.default.attributesOfItem(atPath: destinationURL.path) 84 + return (attrs[.size] as? NSNumber)?.int64Value ?? 0 85 + } 86 + }
+45
Tests/HasherTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import LadderKit 5 + 6 + @Suite("FileHasher") 7 + struct HasherTests { 8 + @Test("SHA-256 of known content") 9 + func sha256KnownContent() throws { 10 + let tempDir = FileManager.default.temporaryDirectory 11 + let fileURL = tempDir.appendingPathComponent("hasher-test-\(UUID().uuidString).txt") 12 + defer { try? FileManager.default.removeItem(at: fileURL) } 13 + 14 + // "hello\n" has a well-known SHA-256 15 + try Data("hello\n".utf8).write(to: fileURL) 16 + 17 + let hash = try FileHasher.sha256(fileAt: fileURL) 18 + 19 + // sha256("hello\n") = 5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03 20 + #expect(hash == "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03") 21 + } 22 + 23 + @Test("SHA-256 of empty file") 24 + func sha256EmptyFile() throws { 25 + let tempDir = FileManager.default.temporaryDirectory 26 + let fileURL = tempDir.appendingPathComponent("hasher-test-empty-\(UUID().uuidString)") 27 + defer { try? FileManager.default.removeItem(at: fileURL) } 28 + 29 + try Data().write(to: fileURL) 30 + 31 + let hash = try FileHasher.sha256(fileAt: fileURL) 32 + 33 + // sha256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 34 + #expect(hash == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") 35 + } 36 + 37 + @Test("SHA-256 throws for missing file") 38 + func sha256MissingFile() { 39 + let bogusURL = URL(fileURLWithPath: "/tmp/nonexistent-\(UUID().uuidString)") 40 + 41 + #expect(throws: (any Error).self) { 42 + try FileHasher.sha256(fileAt: bogusURL) 43 + } 44 + } 45 + }
+61
Tests/ModelsTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import LadderKit 5 + 6 + @Suite("Models") 7 + struct ModelsTests { 8 + @Test("ExportRequest round-trips through JSON") 9 + func exportRequestRoundTrip() throws { 10 + let request = ExportRequest( 11 + uuids: ["uuid-1", "uuid-2"], 12 + stagingDir: "/tmp/staging" 13 + ) 14 + 15 + let data = try JSONEncoder().encode(request) 16 + let decoded = try JSONDecoder().decode(ExportRequest.self, from: data) 17 + 18 + #expect(decoded.uuids == ["uuid-1", "uuid-2"]) 19 + #expect(decoded.stagingDir == "/tmp/staging") 20 + } 21 + 22 + @Test("ExportResponse round-trips through JSON") 23 + func exportResponseRoundTrip() throws { 24 + let response = ExportResponse( 25 + results: [ 26 + ExportResult( 27 + uuid: "uuid-1", 28 + path: "/tmp/staging/uuid-1_IMG_001.HEIC", 29 + size: 3_158_112, 30 + sha256: "abc123" 31 + ) 32 + ], 33 + errors: [ 34 + ExportError(uuid: "uuid-2", message: "Not found") 35 + ] 36 + ) 37 + 38 + let encoder = JSONEncoder() 39 + encoder.outputFormatting = .sortedKeys 40 + let data = try encoder.encode(response) 41 + let decoded = try JSONDecoder().decode(ExportResponse.self, from: data) 42 + 43 + #expect(decoded.results.count == 1) 44 + #expect(decoded.results[0].uuid == "uuid-1") 45 + #expect(decoded.results[0].size == 3_158_112) 46 + #expect(decoded.errors.count == 1) 47 + #expect(decoded.errors[0].message == "Not found") 48 + } 49 + 50 + @Test("ExportRequest decodes from expected JSON format") 51 + func exportRequestFromJSON() throws { 52 + let json = """ 53 + {"uuids":["abc","def"],"stagingDir":"/tmp/test"} 54 + """ 55 + let data = json.data(using: .utf8)! 56 + let request = try JSONDecoder().decode(ExportRequest.self, from: data) 57 + 58 + #expect(request.uuids == ["abc", "def"]) 59 + #expect(request.stagingDir == "/tmp/test") 60 + } 61 + }
+84
Tests/PathSafetyTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import LadderKit 5 + 6 + @Suite("PathSafety") 7 + struct PathSafetyTests { 8 + @Test("sanitizeFilename replaces path separators") 9 + func sanitizeSlashes() { 10 + let result = PathSafety.sanitizeFilename("B84E8479-475C/L0/001") 11 + #expect(!result.contains("/")) 12 + #expect(result == "B84E8479-475C_L0_001") 13 + } 14 + 15 + @Test("sanitizeFilename handles clean filenames") 16 + func sanitizeClean() { 17 + let result = PathSafety.sanitizeFilename("IMG_0001.HEIC") 18 + #expect(result == "IMG_0001.HEIC") 19 + } 20 + 21 + @Test("safeDestination produces valid path inside staging dir") 22 + func safeDestinationValid() throws { 23 + let staging = URL(fileURLWithPath: "/tmp/staging") 24 + let dest = try PathSafety.safeDestination( 25 + stagingDir: staging, 26 + uuid: "abc-123", 27 + originalFilename: "IMG_0001.HEIC" 28 + ) 29 + #expect(dest.path.hasPrefix("/tmp/staging/")) 30 + #expect(dest.lastPathComponent == "abc-123_IMG_0001.HEIC") 31 + } 32 + 33 + @Test("safeDestination sanitizes PHAsset-style identifiers with slashes") 34 + func safeDestinationWithSlashes() throws { 35 + let staging = URL(fileURLWithPath: "/tmp/staging") 36 + let dest = try PathSafety.safeDestination( 37 + stagingDir: staging, 38 + uuid: "B84E8479-475C-4727/L0/001", 39 + originalFilename: "IMG_0001.HEIC" 40 + ) 41 + #expect(dest.path.hasPrefix("/tmp/staging/")) 42 + #expect(!dest.lastPathComponent.contains("/")) 43 + } 44 + 45 + @Test("safeDestination sanitizes traversal in filename") 46 + func safeDestinationTraversal() throws { 47 + let staging = URL(fileURLWithPath: "/tmp/staging") 48 + let dest = try PathSafety.safeDestination( 49 + stagingDir: staging, 50 + uuid: "uuid-1", 51 + originalFilename: "../../../etc/passwd" 52 + ) 53 + // Slashes replaced, file stays inside staging dir 54 + #expect(dest.path.hasPrefix("/tmp/staging/")) 55 + #expect(!dest.lastPathComponent.contains("/")) 56 + } 57 + 58 + @Test("validateStagingDir rejects empty path") 59 + func validateEmpty() { 60 + #expect(throws: ExportFailure.self) { 61 + _ = try PathSafety.validateStagingDir("") 62 + } 63 + } 64 + 65 + @Test("validateStagingDir rejects relative path") 66 + func validateRelative() { 67 + #expect(throws: ExportFailure.self) { 68 + _ = try PathSafety.validateStagingDir("relative/path") 69 + } 70 + } 71 + 72 + @Test("validateStagingDir rejects system paths") 73 + func validateSystem() { 74 + #expect(throws: ExportFailure.self) { 75 + _ = try PathSafety.validateStagingDir("/System/Library") 76 + } 77 + } 78 + 79 + @Test("validateStagingDir accepts valid path") 80 + func validateValid() throws { 81 + let url = try PathSafety.validateStagingDir("/tmp/test-staging") 82 + #expect(url.path == "/tmp/test-staging") 83 + } 84 + }
+148
Tests/PhotoExporterTests.swift
··· 1 + import Foundation 2 + import Photos 3 + import Testing 4 + 5 + @testable import LadderKit 6 + 7 + /// Mock photo library for testing export logic without PhotoKit. 8 + struct MockPhotoLibrary: PhotoLibrary { 9 + let assets: [String: AssetHandle] 10 + 11 + func fetchAssets(identifiers: [String]) -> [String: AssetHandle] { 12 + var result: [String: AssetHandle] = [:] 13 + for id in identifiers { 14 + if let handle = assets[id] { 15 + result[id] = handle 16 + } 17 + } 18 + return result 19 + } 20 + } 21 + 22 + /// Mock asset handle that writes known data. 23 + struct MockAssetHandle: AssetHandle { 24 + let originalFilename: String 25 + let resourceType: PHAssetResourceType 26 + let data: Data 27 + 28 + func writeData( 29 + to destinationURL: URL, 30 + networkAccessAllowed: Bool, 31 + chunkHandler: @escaping @Sendable (Data) -> Void 32 + ) async throws -> Int64 { 33 + let handle = try FileHandle(forWritingTo: destinationURL) 34 + // Deliver in chunks to simulate streaming 35 + let chunkSize = max(data.count / 3, 1) 36 + var offset = 0 37 + while offset < data.count { 38 + let end = min(offset + chunkSize, data.count) 39 + let chunk = data[offset..<end] 40 + chunkHandler(Data(chunk)) 41 + handle.write(Data(chunk)) 42 + offset = end 43 + } 44 + handle.closeFile() 45 + return Int64(data.count) 46 + } 47 + } 48 + 49 + @Suite("PhotoExporter") 50 + struct PhotoExporterTests { 51 + @Test("exports known assets with correct hash") 52 + func exportKnownAssets() async throws { 53 + let tempDir = FileManager.default.temporaryDirectory 54 + .appendingPathComponent("ladder-test-\(UUID().uuidString)") 55 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 56 + defer { try? FileManager.default.removeItem(at: tempDir) } 57 + 58 + let testData = Data("hello world\n".utf8) 59 + 60 + let library = MockPhotoLibrary(assets: [ 61 + "uuid-1": MockAssetHandle( 62 + originalFilename: "IMG_0001.HEIC", 63 + resourceType: .photo, 64 + data: testData 65 + ) 66 + ]) 67 + 68 + let exporter = PhotoExporter(stagingDir: tempDir, library: library) 69 + let response = await exporter.export(uuids: ["uuid-1"]) 70 + 71 + #expect(response.results.count == 1) 72 + #expect(response.errors.isEmpty) 73 + 74 + let result = response.results[0] 75 + #expect(result.uuid == "uuid-1") 76 + #expect(result.size == Int64(testData.count)) 77 + #expect(result.path.hasSuffix("uuid-1_IMG_0001.HEIC")) 78 + 79 + // sha256("hello world\n") 80 + #expect(result.sha256 == "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447") 81 + } 82 + 83 + @Test("reports missing UUIDs as errors") 84 + func reportMissingUUIDs() async throws { 85 + let tempDir = FileManager.default.temporaryDirectory 86 + .appendingPathComponent("ladder-test-\(UUID().uuidString)") 87 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 88 + defer { try? FileManager.default.removeItem(at: tempDir) } 89 + 90 + let library = MockPhotoLibrary(assets: [:]) 91 + let exporter = PhotoExporter(stagingDir: tempDir, library: library) 92 + let response = await exporter.export(uuids: ["missing-uuid"]) 93 + 94 + #expect(response.results.isEmpty) 95 + #expect(response.errors.count == 1) 96 + #expect(response.errors[0].uuid == "missing-uuid") 97 + } 98 + 99 + @Test("handles mix of found and missing assets") 100 + func mixedResults() async throws { 101 + let tempDir = FileManager.default.temporaryDirectory 102 + .appendingPathComponent("ladder-test-\(UUID().uuidString)") 103 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 104 + defer { try? FileManager.default.removeItem(at: tempDir) } 105 + 106 + let library = MockPhotoLibrary(assets: [ 107 + "found-1": MockAssetHandle( 108 + originalFilename: "photo.jpg", 109 + resourceType: .photo, 110 + data: Data("test".utf8) 111 + ) 112 + ]) 113 + 114 + let exporter = PhotoExporter(stagingDir: tempDir, library: library) 115 + let response = await exporter.export(uuids: ["found-1", "missing-1"]) 116 + 117 + #expect(response.results.count == 1) 118 + #expect(response.results[0].uuid == "found-1") 119 + #expect(response.errors.count == 1) 120 + #expect(response.errors[0].uuid == "missing-1") 121 + } 122 + 123 + @Test("sanitizes PHAsset-style identifiers in file paths") 124 + func sanitizedPaths() async throws { 125 + let tempDir = FileManager.default.temporaryDirectory 126 + .appendingPathComponent("ladder-test-\(UUID().uuidString)") 127 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 128 + defer { try? FileManager.default.removeItem(at: tempDir) } 129 + 130 + let library = MockPhotoLibrary(assets: [ 131 + "ABC-123/L0/001": MockAssetHandle( 132 + originalFilename: "IMG_0001.HEIC", 133 + resourceType: .photo, 134 + data: Data("x".utf8) 135 + ) 136 + ]) 137 + 138 + let exporter = PhotoExporter(stagingDir: tempDir, library: library) 139 + let response = await exporter.export(uuids: ["ABC-123/L0/001"]) 140 + 141 + #expect(response.results.count == 1) 142 + let path = response.results[0].path 143 + // File should be flat inside staging dir, not in subdirectories 144 + let filename = URL(fileURLWithPath: path).lastPathComponent 145 + #expect(!filename.contains("/")) 146 + #expect(path.hasPrefix(tempDir.path)) 147 + } 148 + }
ladder-logo.png

This is a binary file and will not be displayed.