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
2 changes: 1 addition & 1 deletion .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ custom_rules:
plugin_principal_class_gate:
name: "Principal Class Behind The Gate"
included: ['.*/TablePro/Core/Plugins/[^/]+\.swift$']
regex: '\bfunc\s+(?!(?:registerBundle|activateLazyBundle|validateDependencies)\b)\w+(?:(?!\bfunc\b)[\s\S])*?(\.principalClass\b)'
regex: '\bfunc\s+(?!(?:registerBundle|activateLazyBundle)\b)\w+(?:(?!\bfunc\b)[\s\S])*?(\.principalClass\b)'
capture_group: 1
message: "Bundle.principalClass loads the executable. Read it only in a function that already called assertLoadable."
severity: error
Expand Down
146 changes: 69 additions & 77 deletions TablePro/Core/Plugins/PluginInstaller.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
//
// PluginInstaller.swift
// TablePro
//

import CryptoKit
import Foundation
import os
import TableProUtilities
Expand All @@ -22,14 +16,14 @@ actor PluginInstaller {
_ registryPlugin: RegistryPlugin,
binary: RegistryBinary,
into userPluginsDir: URL,
progressHandler: @escaping @Sendable (StagedInstallState) async -> Void
progress: @escaping @Sendable (Double) async -> Void
) async throws -> URL {
try await runCoalesced(pluginId: registryPlugin.id) {
try await self.performDownloadAndCommit(
registryPlugin,
binary: binary,
into: userPluginsDir,
progressHandler: progressHandler
progress: progress
)
}
}
Expand All @@ -39,19 +33,18 @@ actor PluginInstaller {
binary: RegistryBinary,
into userPluginsDir: URL,
hasLiveConnections: Bool,
progressHandler: @escaping @Sendable (StagedInstallState) async -> Void
progress: @escaping @Sendable (Double) async -> Void
) async throws -> PluginUpdateResult {
if hasLiveConnections {
let stagedURL = try await runCoalesced(pluginId: registryPlugin.id) {
try await self.performDownloadAndStage(
registryPlugin,
binary: binary,
into: userPluginsDir,
progressHandler: progressHandler
progress: progress
)
}
stagedUpdates[registryPlugin.id] = stagedURL
await progressHandler(.staged(at: stagedURL))
return .staged(at: stagedURL)
}

Expand All @@ -60,7 +53,7 @@ actor PluginInstaller {
registryPlugin,
binary: binary,
into: userPluginsDir,
progressHandler: progressHandler
progress: progress
)
}
return .installed(pluginURL: finalURL)
Expand Down Expand Up @@ -122,23 +115,21 @@ actor PluginInstaller {
_ registryPlugin: RegistryPlugin,
binary: RegistryBinary,
into userPluginsDir: URL,
progressHandler: @escaping @Sendable (StagedInstallState) async -> Void
progress: @escaping @Sendable (Double) async -> Void
) async throws -> URL {
let extracted = try await downloadExtractVerify(
registryPlugin,
binary: binary,
into: userPluginsDir,
progressHandler: progressHandler
progress: progress
)

defer {
try? FileManager.default.removeItem(at: extracted.workingDir)
}

let destURL = userPluginsDir.appendingPathComponent(extracted.bundleURL.lastPathComponent, isDirectory: true)
let finalURL = try Self.atomicReplace(stagedBundleURL: extracted.bundleURL, destURL: destURL)
await progressHandler(.installed(pluginURL: finalURL))
return finalURL
return try Self.atomicReplace(stagedBundleURL: extracted.bundleURL, destURL: destURL)
}

// MARK: - Download + stage (live connections)
Expand All @@ -147,13 +138,13 @@ actor PluginInstaller {
_ registryPlugin: RegistryPlugin,
binary: RegistryBinary,
into userPluginsDir: URL,
progressHandler: @escaping @Sendable (StagedInstallState) async -> Void
progress: @escaping @Sendable (Double) async -> Void
) async throws -> URL {
let extracted = try await downloadExtractVerify(
registryPlugin,
binary: binary,
into: userPluginsDir,
progressHandler: progressHandler
progress: progress
)
return extracted.bundleURL
}
Expand All @@ -169,20 +160,14 @@ actor PluginInstaller {
_ registryPlugin: RegistryPlugin,
binary: RegistryBinary,
into userPluginsDir: URL,
progressHandler: @escaping @Sendable (StagedInstallState) async -> Void
progress: @escaping @Sendable (Double) async -> Void
) async throws -> ExtractedBundle {
let stagingRoot = Self.stagingRoot(for: userPluginsDir)
try FileManager.default.createDirectory(at: stagingRoot, withIntermediateDirectories: true)
let workingDir = stagingRoot.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: workingDir, withIntermediateDirectories: true)

let context = await MainActor.run {
(
kit: PluginManager.currentPluginKitVersion,
minimumKit: PluginManager.minimumCompatiblePluginKitVersion,
session: RegistryClient.shared.session
)
}
let session = await MainActor.run { RegistryClient.shared.session }

// The registry manifest is fetched over the network, so the URL it names is untrusted
// input. The code-signature check is the real gate, but nothing should be fetched over
Expand All @@ -192,30 +177,16 @@ actor PluginInstaller {
throw PluginError.downloadFailed("Invalid download URL")
}

/// Zero here is "not started", not "nought per cent done", and nothing else arrives until
/// the whole file is down: measured on a 25.7 MB download with a known `Content-Length`,
/// `download(from:delegate:)` delivered no `URLSessionDownloadDelegate` byte callbacks at
/// all, because the async form routes only `URLSessionTaskDelegate` messages to a per-task
/// delegate. Anything drawing this has to stay indeterminate until a fraction moves.
await progressHandler(.downloading(fraction: 0))

let (tempDownloadURL, response) = try await context.session.download(from: downloadURL)
let (tempDownloadURL, response) = try await Self.download(downloadURL, with: session, progress: progress)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
throw PluginError.downloadFailed("HTTP \(code)")
}

await progressHandler(.downloading(fraction: 0.5))

let payload = try Data(contentsOf: tempDownloadURL)
let digest = SHA256.hash(data: payload)
let hex = digest.hexEncoded
guard hex == binary.sha256.lowercased() else {
guard DownloadedBinary.sha256Hex(ofFileAt: tempDownloadURL.path) == binary.sha256.lowercased() else {
throw PluginError.checksumMismatch
}

await progressHandler(.downloading(fraction: 1.0))

let zipURL = workingDir.appendingPathComponent("\(registryPlugin.id).zip")
try FileManager.default.moveItem(at: tempDownloadURL, to: zipURL)

Expand All @@ -227,19 +198,37 @@ actor PluginInstaller {
}

try PluginCodeSignatureVerifier.verify(bundle: stagedBundle)

try Self.validateStagedABI(
bundleURL: bundleURL,
currentKit: context.kit,
minimumKit: context.minimumKit
)
try PluginManager.validateBundleVersions(stagedBundle)
Self.stripQuarantine(at: bundleURL)

return ExtractedBundle(workingDir: workingDir, bundleURL: bundleURL)
}

// MARK: - Helpers (nonisolated)

nonisolated static func download(
_ url: URL,
with session: URLSession,
progress: @escaping @Sendable (Double) async -> Void
) async throws -> (URL, URLResponse) {
let observer = DownloadProgressObserver()
let forwarding = Task {
for await fraction in observer.fractions {
await progress(fraction)
}
}
do {
let result = try await session.download(from: url, delegate: observer)
observer.finish(isComplete: true)
await forwarding.value
return result
} catch {
observer.finish(isComplete: false)
forwarding.cancel()
throw error
}
}

nonisolated static func stagingRoot(for userPluginsDir: URL) -> URL {
userPluginsDir.deletingLastPathComponent()
.appendingPathComponent("PluginStaging", isDirectory: true)
Expand Down Expand Up @@ -276,27 +265,6 @@ actor PluginInstaller {
DownloadedBinary.stripQuarantine(at: url)
}

nonisolated static func validateStagedABI(
bundleURL: URL,
currentKit: Int,
minimumKit: Int
) throws {
guard let bundle = Bundle(url: bundleURL),
let info = bundle.infoDictionary
else {
throw PluginError.invalidBundle("Cannot read Info.plist")
}
guard let version = info["TableProPluginKitVersion"] as? Int else {
throw PluginError.pluginOutdated(pluginVersion: 0, requiredVersion: currentKit)
}
if version > currentKit {
throw PluginError.incompatibleVersion(required: version, current: currentKit)
}
if version < minimumKit {
throw PluginError.pluginOutdated(pluginVersion: version, requiredVersion: currentKit)
}
}

nonisolated static func atomicReplace(stagedBundleURL: URL, destURL: URL) throws -> URL {
var resultURL: NSURL?
let backupName = "\(destURL.lastPathComponent).bak"
Expand All @@ -311,8 +279,6 @@ actor PluginInstaller {
options: [],
resultingItemURL: &resultURL
)
let backupURL = destDir.appendingPathComponent(backupName)
try? FileManager.default.removeItem(at: backupURL)
} else {
try FileManager.default.moveItem(at: stagedBundleURL, to: destURL)
}
Expand All @@ -321,11 +287,37 @@ actor PluginInstaller {
}
}

enum StagedInstallState: Sendable {
case downloading(fraction: Double)
case staged(at: URL)
case installed(pluginURL: URL)
case failed(any Error)
// The async download sends a task delegate no byte callbacks, but the task's own progress moves.
private final class DownloadProgressObserver: NSObject, URLSessionTaskDelegate, Sendable {
let fractions: AsyncStream<Double>
private let continuation: AsyncStream<Double>.Continuation
private let observation = OSAllocatedUnfairLock<NSKeyValueObservation?>(uncheckedState: nil)

override init() {
(fractions, continuation) = AsyncStream.makeStream(of: Double.self)
super.init()
}

func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) {
let continuation = continuation
let observation = task.progress.observe(\.fractionCompleted) { progress, _ in
// Without a Content-Length the fraction creeps on a guess; the UI stays indeterminate.
guard task.countOfBytesExpectedToReceive > 0 else { return }
continuation.yield(progress.fractionCompleted)
}
self.observation.withLockUnchecked { $0 = observation }
}

func finish(isComplete: Bool) {
observation.withLockUnchecked { observation in
observation?.invalidate()
observation = nil
}
if isComplete {
continuation.yield(1)
}
continuation.finish()
}
}

enum PluginUpdateResult: Sendable {
Expand Down
10 changes: 0 additions & 10 deletions TablePro/Core/Plugins/PluginManager+AutoUpdate.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
//
// PluginManager+AutoUpdate.swift
// TablePro
//

import Combine
import Foundation
import os
Expand Down Expand Up @@ -101,7 +96,6 @@ extension PluginManager {
do {
let outcome = try await updateFromRegistry(
registryPlugin,
existingPluginLoaded: false,
refreshManifest: false,
progress: { _ in }
)
Expand Down Expand Up @@ -271,10 +265,6 @@ extension PluginManager {
rejectedPlugins.contains { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) }
}

func outdatedReconcileReason(forTypeId typeId: String) -> String? {
rejectedPlugins.first { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) }?.reason
}

func prepareForConnecting(to type: DatabaseType, registryClient: RegistryClient = .shared) async throws {
let typeId = type.pluginTypeId
if driverPlugin(for: type) == nil, !hasFinishedInitialLoad {
Expand Down
33 changes: 4 additions & 29 deletions TablePro/Core/Plugins/PluginManager+Install.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
//
// PluginManager+Install.swift
// TablePro
//

import Foundation
import os

Expand All @@ -26,18 +21,11 @@ extension PluginManager {
let registryPlugin = await registryClient.refreshedPlugin(matching: registryPlugin)
let binary = try validateRegistryCompatibility(registryPlugin)

let userPluginsDir = self.userPluginsDir
let stateHandler: @Sendable (StagedInstallState) async -> Void = { state in
if case .downloading(let fraction) = state {
await MainActor.run { progress(fraction) }
}
}

let finalURL = try await PluginInstaller.shared.install(
registryPlugin,
binary: binary,
into: userPluginsDir,
progressHandler: stateHandler
progress: { fraction in await progress(fraction) }
)

saveRegistryMetadata(pluginId: registryPlugin.id, pluginURL: finalURL)
Expand All @@ -48,7 +36,6 @@ extension PluginManager {

func updateFromRegistry(
_ registryPlugin: RegistryPlugin,
existingPluginLoaded: Bool = true,
refreshManifest: Bool = true,
progress: @escaping @MainActor @Sendable (Double) -> Void
) async throws -> PluginUpdateOutcome {
Expand All @@ -70,20 +57,12 @@ extension PluginManager {
: registryPlugin
let binary = try validateRegistryCompatibility(registryPlugin)

let hasLive = pluginHasLiveConnections(registryPlugin)
let userPluginsDir = self.userPluginsDir
let stateHandler: @Sendable (StagedInstallState) async -> Void = { state in
if case .downloading(let fraction) = state {
await MainActor.run { progress(fraction) }
}
}

let result = try await PluginInstaller.shared.update(
registryPlugin,
binary: binary,
into: userPluginsDir,
hasLiveConnections: hasLive,
progressHandler: stateHandler
hasLiveConnections: pluginHasLiveConnections(registryPlugin),
progress: { fraction in await progress(fraction) }
)

switch result {
Expand Down Expand Up @@ -224,11 +203,7 @@ extension PluginManager {
throw PluginError.invalidBundle("Cannot create bundle from \(bundleURL.lastPathComponent)")
}
try PluginCodeSignatureVerifier.verify(bundle: bundle)
try PluginInstaller.validateStagedABI(
bundleURL: bundleURL,
currentKit: Self.currentPluginKitVersion,
minimumKit: Self.minimumCompatiblePluginKitVersion
)
try Self.validateBundleVersions(bundle)
PluginInstaller.stripQuarantine(at: bundleURL)

let bundleId = bundle.bundleIdentifier ?? bundleURL.lastPathComponent
Expand Down
Loading
Loading