import CryptoKit import Foundation @MainActor protocol PluginPackageResolving { func resolvePackage(for entry: PluginCatalogEntry) async throws -> URL } @MainActor final class PluginPackageResolver: PluginPackageResolving { private let temporaryDirectory: URL private let fileManager: FileManager private let session: URLSession private let maximumPackageSize: Int64 init( temporaryDirectory: URL, fileManager: FileManager = .default, session: URLSession = .shared, maximumPackageSize: Int64 = 200 * 1024 * 1024 ) { self.temporaryDirectory = temporaryDirectory self.fileManager = fileManager self.session = session self.maximumPackageSize = maximumPackageSize } func resolvePackage(for entry: PluginCatalogEntry) async throws -> URL { try fileManager.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) let workingDirectory = temporaryDirectory .appendingPathComponent("\(entry.id)-\(UUID().uuidString)", isDirectory: true) try fileManager.createDirectory(at: workingDirectory, withIntermediateDirectories: true) do { let downloadedURL = try await materializePackage(entry.package, in: workingDirectory) try validatePackageFile(downloadedURL, expected: entry.package) let packageURL = try await expandedPackageURL(from: downloadedURL, in: workingDirectory) try validateResolvedPackage(packageURL, matches: entry) return packageURL } catch { try? fileManager.removeItem(at: workingDirectory) throw error } } private func materializePackage( _ package: PluginCatalogPackage, in workingDirectory: URL ) async throws -> URL { if package.url.isFileURL { return try copyLocalPackage(package.url, to: workingDirectory) } guard package.url.scheme?.lowercased() == "https" else { throw PluginPackageResolverError.unsupportedPackageURL(package.url) } return try await downloadPackage(package.url, to: workingDirectory) } private func copyLocalPackage(_ sourceURL: URL, to workingDirectory: URL) throws -> URL { guard fileManager.fileExists(atPath: sourceURL.path) else { throw PluginPackageResolverError.missingLocalPackage(sourceURL) } let destinationURL = workingDirectory.appendingPathComponent(sourceURL.lastPathComponent) try fileManager.copyItem(at: sourceURL, to: destinationURL) return destinationURL } private func downloadPackage(_ url: URL, to workingDirectory: URL) async throws -> URL { var request = URLRequest(url: url) request.timeoutInterval = 60 request.cachePolicy = .reloadIgnoringLocalCacheData let (temporaryURL, response) = try await session.download(for: request) if let httpResponse = response as? HTTPURLResponse, !(200..<300).contains(httpResponse.statusCode) { throw PluginPackageResolverError.httpStatus(httpResponse.statusCode) } let fileName = response.suggestedFilename ?? url.lastPathComponent let destinationURL = workingDirectory.appendingPathComponent(fileName.isEmpty ? "Package" : fileName) try fileManager.moveItem(at: temporaryURL, to: destinationURL) return destinationURL } private func validatePackageFile(_ url: URL, expected package: PluginCatalogPackage) throws { let metrics = try packageMetrics(for: url) guard metrics.size == package.size else { throw PluginPackageResolverError.packageSizeMismatch(expected: package.size, actual: metrics.size) } guard metrics.size <= maximumPackageSize else { throw PluginPackageResolverError.packageTooLarge(metrics.size) } guard metrics.sha256.caseInsensitiveCompare(package.sha256) == .orderedSame else { throw PluginPackageResolverError.checksumMismatch(expected: package.sha256, actual: metrics.sha256) } } private func expandedPackageURL(from url: URL, in workingDirectory: URL) async throws -> URL { if url.pathExtension == "mactoolsplugin" { return url } if url.pathExtension == "zip" { return try await extractZipPackage(url, in: workingDirectory) } throw PluginPackageResolverError.unsupportedPackageFormat(url) } private func extractZipPackage(_ url: URL, in workingDirectory: URL) async throws -> URL { let extractionDirectory = workingDirectory.appendingPathComponent("Expanded", isDirectory: true) try fileManager.createDirectory(at: extractionDirectory, withIntermediateDirectories: true) try await PluginZipExtractor.extract(zipURL: url, destinationURL: extractionDirectory) let packageURLs = try fileManager.contentsOfDirectory( at: extractionDirectory, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles] ) .filter { $0.pathExtension == "mactoolsplugin" } guard packageURLs.count == 1, let packageURL = packageURLs.first else { throw PluginPackageResolverError.invalidExpandedPackage } let resolvedPackagePath = packageURL.resolvingSymlinksInPath().path let resolvedExtractionPath = extractionDirectory.resolvingSymlinksInPath().path guard resolvedPackagePath.hasPrefix(resolvedExtractionPath + "/") else { throw PluginPackageResolverError.archiveEscapedDestination } return packageURL } private func validateResolvedPackage(_ packageURL: URL, matches entry: PluginCatalogEntry) throws { let manifest = try PluginPackageManifestLoader.load(from: packageURL) guard manifest.id == entry.id else { throw PluginPackageResolverError.manifestMismatch(field: "id") } guard manifest.version == entry.version else { throw PluginPackageResolverError.manifestMismatch(field: "version") } guard manifest.pluginKitVersion == entry.pluginKitVersion else { throw PluginPackageResolverError.manifestMismatch(field: "pluginKitVersion") } guard manifest.minHostVersion == entry.minimumHostVersion else { throw PluginPackageResolverError.manifestMismatch(field: "minimumHostVersion") } guard manifest.releaseChannel == entry.releaseChannel else { throw PluginPackageResolverError.manifestMismatch(field: "releaseChannel") } } static func packageMetrics(for url: URL, fileManager: FileManager = .default) throws -> PluginPackageMetrics { let values = try url.resourceValues(forKeys: [.isDirectoryKey]) if values.isDirectory == true { return try directoryMetrics(for: url, fileManager: fileManager) } let data = try Data(contentsOf: url) let digest = SHA256.hash(data: data) return PluginPackageMetrics( size: Int64(data.count), sha256: digest.map { String(format: "%02x", $0) }.joined() ) } private func packageMetrics(for url: URL) throws -> PluginPackageMetrics { try Self.packageMetrics(for: url, fileManager: fileManager) } private static func directoryMetrics( for url: URL, fileManager: FileManager ) throws -> PluginPackageMetrics { guard let enumerator = fileManager.enumerator( at: url, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], options: [.skipsHiddenFiles] ) else { throw PluginPackageResolverError.unsupportedPackageFormat(url) } var hasher = SHA256() var size: Int64 = 0 let basePath = url.standardizedFileURL.path let files = try enumerator.compactMap { item -> (url: URL, relativePath: String, fileSize: Int?)? in guard let fileURL = item as? URL else { return nil } let values = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) guard values.isRegularFile == true else { return nil } let standardizedPath = fileURL.standardizedFileURL.path let relativePath = String( standardizedPath .dropFirst(basePath.count) .drop { $0 == "/" } ) return (fileURL, relativePath, values.fileSize) } .sorted { lhs, rhs in lhs.relativePath.utf8.lexicographicallyPrecedes(rhs.relativePath.utf8) } for file in files { let fileData = try Data(contentsOf: file.url) let relativePath = file.relativePath let pathData = Data(relativePath.utf8) hasher.update(data: pathData) hasher.update(data: Data([0])) hasher.update(data: fileData) hasher.update(data: Data([0])) size += Int64(file.fileSize ?? fileData.count) } let digest = hasher.finalize() return PluginPackageMetrics( size: size, sha256: digest.map { String(format: "%02x", $0) }.joined() ) } } struct PluginPackageMetrics: Equatable { let size: Int64 let sha256: String } enum PluginPackageResolverError: LocalizedError, Equatable { case unsupportedPackageURL(URL) case missingLocalPackage(URL) case httpStatus(Int) case packageSizeMismatch(expected: Int64, actual: Int64) case packageTooLarge(Int64) case checksumMismatch(expected: String, actual: String) case unsupportedPackageFormat(URL) case invalidExpandedPackage case archiveEscapedDestination case manifestMismatch(field: String) case unzipFailed(String) var errorDescription: String? { switch self { case let .unsupportedPackageURL(url): return AppL10n.pluginsFormat("plugin.error.package.unsupportedURLFormat", defaultValue: "插件包地址不支持:%@", url.absoluteString) case let .missingLocalPackage(url): return AppL10n.pluginsFormat("plugin.error.package.missingLocalFormat", defaultValue: "本地插件包不存在:%@", url.path) case let .httpStatus(statusCode): return AppL10n.pluginsFormat("plugin.error.package.httpStatusFormat", defaultValue: "插件包下载失败:HTTP %d", statusCode) case let .packageSizeMismatch(expected, actual): return AppL10n.pluginsFormat( "plugin.error.package.sizeMismatchFormat", defaultValue: "插件包大小不匹配,期望 %lld,实际 %lld。", expected, actual ) case let .packageTooLarge(size): return AppL10n.pluginsFormat("plugin.error.package.tooLargeFormat", defaultValue: "插件包过大:%lld", size) case let .checksumMismatch(expected, actual): return AppL10n.pluginsFormat( "plugin.error.package.checksumMismatchFormat", defaultValue: "插件包校验失败,期望 %@,实际 %@。", expected, actual ) case let .unsupportedPackageFormat(url): return AppL10n.pluginsFormat("plugin.error.package.unsupportedFormatFormat", defaultValue: "插件包格式不支持:%@", url.lastPathComponent) case .invalidExpandedPackage: return AppL10n.plugins("plugin.error.package.invalidExpanded", defaultValue: "插件压缩包内容无效。") case .archiveEscapedDestination: return AppL10n.plugins("plugin.error.package.unsafeArchivePath", defaultValue: "插件压缩包包含不安全路径。") case let .manifestMismatch(field): return AppL10n.pluginsFormat("plugin.error.package.manifestMismatchFormat", defaultValue: "插件包 manifest 与插件列表不一致:%@", field) case let .unzipFailed(reason): return AppL10n.pluginsFormat("plugin.error.package.unzipFailedFormat", defaultValue: "插件压缩包解压失败:%@", reason) } } } private enum PluginZipExtractor { static func extract(zipURL: URL, destinationURL: URL) async throws { try await Task.detached(priority: .userInitiated) { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") process.arguments = [ "-x", "-k", "--sequesterRsrc", "--rsrc", zipURL.path, destinationURL.path ] let pipe = Pipe() process.standardError = pipe try process.run() process.waitUntilExit() guard process.terminationStatus == 0 else { let data = pipe.fileHandleForReading.readDataToEndOfFile() let message = String(data: data, encoding: .utf8) ?? "ditto \(process.terminationStatus)" throw PluginPackageResolverError.unzipFailed(message) } }.value } }