diff --git a/AppUpdater.swift b/AppUpdater.swift index 286fd04..3399834 100644 --- a/AppUpdater.swift +++ b/AppUpdater.swift @@ -9,11 +9,12 @@ public final class AppUpdater { private var active: Task? private let owner: String private let repo: String + private let configuration: Configuration private let session: URLSession private let hasExecutable: @Sendable () -> Bool private let currentVersion: @Sendable () throws -> Version private let fetchReleases: @Sendable () async throws -> [Release] - private let stageAsset: @MainActor @Sendable (Release.Asset) async throws -> Update + private let prepareAsset: @MainActor @Sendable (Release.Asset) async throws -> PreparedUpdate public var allowPrereleases = false @@ -49,6 +50,7 @@ public final class AppUpdater { let session = URLSession(configuration: sessionConfiguration) self.owner = owner self.repo = repo + self.configuration = configuration self.session = session hasExecutable = { Bundle.main.executableURL != nil } currentVersion = { try Bundle.main.appVersion } @@ -60,8 +62,8 @@ public final class AppUpdater { configuration: configuration ) } - stageAsset = { asset in - try await Self.stageUpdate( + prepareAsset = { asset in + try await Self.prepareUpdate( with: asset, replacing: .main, session: session, @@ -73,18 +75,20 @@ public final class AppUpdater { init( owner: String, repo: String, + configuration: Configuration = .init(), hasExecutable: @escaping @Sendable () -> Bool = { true }, currentVersion: @escaping @Sendable () throws -> Version, fetchReleases: @escaping @Sendable () async throws -> [Release], - stageAsset: @escaping @MainActor @Sendable (Release.Asset) async throws -> Update + prepareAsset: @escaping @MainActor @Sendable (Release.Asset) async throws -> PreparedUpdate ) { self.owner = owner self.repo = repo + self.configuration = configuration self.session = .shared self.hasExecutable = hasExecutable self.currentVersion = currentVersion self.fetchReleases = fetchReleases - self.stageAsset = stageAsset + self.prepareAsset = prepareAsset } public func check() async throws -> Update? { @@ -93,11 +97,12 @@ public final class AppUpdater { } let repo = repo + let configuration = configuration let allowPrereleases = allowPrereleases let hasExecutable = hasExecutable let currentVersion = currentVersion let fetchReleases = fetchReleases - let stageAsset = stageAsset + let prepareAsset = prepareAsset let task = Task { guard hasExecutable() else { @@ -106,15 +111,20 @@ public final class AppUpdater { let appVersion = try currentVersion() let releases = try await fetchReleases() - guard let asset = try releases.findViableUpdate( + guard let update = try releases.findViableUpdate( appVersion: appVersion, repo: repo, prerelease: allowPrereleases ) else { return nil } + try Self.validateAssetMetadata(update.asset, configuration: configuration) - return try await stageAsset(asset) + return Update( + version: update.version.description, + assetName: update.asset.name, + prepare: { try await prepareAsset(update.asset) } + ) } active = task @@ -144,25 +154,13 @@ public final class AppUpdater { return try decoder.decode([Release].self, from: data) } - private static func stageUpdate( + private static func prepareUpdate( with asset: Release.Asset, replacing installedAppBundle: Bundle, session: URLSession, configuration: Configuration - ) async throws -> Update { - guard asset.browserDownloadURL.scheme == "https" else { - throw AppUpdaterError.insecureDownloadURL - } - - guard let contentType = asset.contentType, contentType == .dmg else { - throw AppUpdaterError.unsupportedAsset(asset.name) - } - guard asset.size > 0 else { - throw AppUpdaterError.invalidGitHubResponse - } - guard asset.size <= configuration.maximumDownloadBytes else { - throw AppUpdaterError.resourceLimitExceeded("download size") - } + ) async throws -> PreparedUpdate { + try validateAssetMetadata(asset, configuration: configuration) let tmpdir = try Self.stagingDirectory() do { @@ -200,17 +198,11 @@ public final class AppUpdater { } let lease = StagingLease(root: tmpdir, mount: mount) - return Update( + return try await Installation.prepare( assetName: asset.name, - prepare: { - try await Installation.prepare( - assetName: asset.name, - lease: lease, - installedBundle: installedAppBundle, - limits: limits - ) - }, - discard: { await lease.discard() } + lease: lease, + installedBundle: installedAppBundle, + limits: limits ) } catch { try? FileManager.default.removeItem(at: tmpdir) @@ -218,6 +210,25 @@ public final class AppUpdater { } } + private static func validateAssetMetadata( + _ asset: Release.Asset, + configuration: Configuration + ) throws { + guard asset.browserDownloadURL.scheme == "https" else { + throw AppUpdaterError.insecureDownloadURL + } + + guard let contentType = asset.contentType, contentType == .dmg else { + throw AppUpdaterError.unsupportedAsset(asset.name) + } + guard asset.size > 0 else { + throw AppUpdaterError.invalidGitHubResponse + } + guard asset.size <= configuration.maximumDownloadBytes else { + throw AppUpdaterError.resourceLimitExceeded("download size") + } + } + static func stagingDirectory() throws -> URL { let baseURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let url = baseURL.appendingPathComponent( @@ -236,36 +247,31 @@ public final class AppUpdater { @MainActor public final class Update { typealias PrepareOperation = @MainActor () async throws -> PreparedUpdate - typealias DiscardOperation = @MainActor () async -> Void + public let version: String public let assetName: String private var prepareOperation: PrepareOperation? - private var discardOperation: DiscardOperation? public func prepareInstallation() async throws -> PreparedUpdate { guard let operation = prepareOperation else { throw AppUpdaterError.invalidUpdateState } prepareOperation = nil - discardOperation = nil return try await operation() } public func discard() async { - let operation = discardOperation prepareOperation = nil - discardOperation = nil - await operation?() } init( + version: String, assetName: String, - prepare: @escaping PrepareOperation, - discard: @escaping DiscardOperation + prepare: @escaping PrepareOperation ) { + self.version = version self.assetName = assetName prepareOperation = prepare - discardOperation = discard } } @@ -587,19 +593,24 @@ enum ContentType: Decodable, Equatable { } } +struct AvailableUpdate { + let version: Version + let asset: Release.Asset +} + extension Array where Element == Release { func findViableUpdate( appVersion: Version, repo: String, prerelease: Bool - ) throws -> Release.Asset? { + ) throws -> AvailableUpdate? { let suitableReleases = prerelease ? self : filter { !$0.prerelease } for release in suitableReleases.sorted().reversed() { guard appVersion < release.tagName else { return nil } if let asset = release.viableAsset(forRepo: repo) { - return asset + return AvailableUpdate(version: release.tagName, asset: asset) } } return nil diff --git a/README.md b/README.md index f41fd6f..0e9d150 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,14 @@ GitHub Releases, validates a DMG, replaces the running app, and relaunches it. [coveralls-badge]: https://coveralls.io/repos/github/mxcl/AppUpdater/badge.svg [coveralls]: https://coveralls.io/github/mxcl/AppUpdater -AppUpdater supports macOS 12 and later. Version 3 has a source-breaking API. +AppUpdater supports macOS 12 and later. Version 4 separates update discovery +from downloading and preparing an installation. ## Package ```swift package.dependencies.append( - .package(url: "https://github.com/mxcl/AppUpdater.git", from: "3.0.0") + .package(url: "https://github.com/mxcl/AppUpdater.git", from: "4.0.0") ) ``` @@ -51,9 +52,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Task { @MainActor in do { guard let update = try await updater.check() else { return } + print("Version \(update.version) is available") - // The app can keep operating while this runs. Finder may ask - // for authorization when the app lives in a protected folder. + // This downloads and validates the update. The app can keep + // operating while it runs. Finder may ask for authorization + // when the app lives in a protected folder. let prepared = try await update.prepareInstallation() // Save documents, stop background work, close helper processes, @@ -70,14 +73,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } ``` -`check()` downloads the DMG, mounts it read-only and non-browsable, enforces the -configured resource limits, and validates the app. It returns a one-shot -`Update` without exposing staging paths. +`check()` fetches bounded GitHub release metadata and returns a lightweight, +one-shot `Update` with `version` and `assetName`. It does not download the DMG. -`prepareInstallation()` copies the DMG beside the installed app, mounts that -copy read-only, and repeats the resource and signature checks. The returned -`PreparedUpdate` is also one-shot. Call `discard()` on either object if you -decide not to continue. +`prepareInstallation()` downloads the DMG, mounts it read-only and +non-browsable, enforces the configured resource limits, and validates the app. +It then copies the DMG beside the installed app, mounts that copy read-only, +and repeats the resource and signature checks. The returned `PreparedUpdate` +is also one-shot. Call `discard()` on either object if you decide not to +continue. Call `installAndRelaunch()` only after the host has saved its state, stopped background work, and ceased loading bundle code or resources. The running @@ -120,6 +124,10 @@ AppUpdater aims to prevent privilege amplification. A same-user attacker must not be able to replace a downloaded candidate and then borrow Finder's authorization to modify an app that the user cannot otherwise replace. +The result of `check()` is advisory GitHub metadata, not an authenticated app. +Only `prepareInstallation()` downloads and authenticates the candidate. Do not +grant privileges or stop security services based only on an available update. + For every candidate, AppUpdater requires a valid Developer ID Application signature. The installed and candidate apps must have the same Team ID, signing identifier, and bundle identifier. Validation covers all architectures, nested diff --git a/Tests/AppUpdaterTests/AppUpdaterTests.swift b/Tests/AppUpdaterTests/AppUpdaterTests.swift index 7e5a4f9..0036e2e 100644 --- a/Tests/AppUpdaterTests/AppUpdaterTests.swift +++ b/Tests/AppUpdaterTests/AppUpdaterTests.swift @@ -175,7 +175,8 @@ final class AppUpdaterTests: XCTestCase { } @MainActor - func testCheckUpdatesSelectedAsset() async throws { + func testCheckReturnsMetadataWithoutStaging() async throws { + var staged = false let releases = try [ release("2.0.0", prerelease: false, assetName: "AppUpdater-2.0.0.dmg"), ] @@ -184,12 +185,19 @@ final class AppUpdaterTests: XCTestCase { repo: "AppUpdater", currentVersion: { Version(1, 0, 0) }, fetchReleases: { releases }, - stageAsset: { asset in stagedUpdate(assetName: asset.name) } + prepareAsset: { asset in + staged = true + return preparedUpdate(assetName: asset.name) + } ) let update = try await updater.check() + XCTAssertEqual(update?.version, "2.0.0") XCTAssertEqual(update?.assetName, "AppUpdater-2.0.0.dmg") + XCTAssertFalse(staged) + _ = try await update?.prepareInstallation() + XCTAssertTrue(staged) } @MainActor @@ -200,9 +208,9 @@ final class AppUpdaterTests: XCTestCase { hasExecutable: { false }, currentVersion: { Version(1, 0, 0) }, fetchReleases: { [] }, - stageAsset: { _ in + prepareAsset: { _ in XCTFail("update should not run") - return stagedUpdate() + return preparedUpdate() } ) @@ -224,9 +232,9 @@ final class AppUpdaterTests: XCTestCase { repo: "AppUpdater", currentVersion: { Version(1, 0, 0) }, fetchReleases: { releases }, - stageAsset: { _ in + prepareAsset: { _ in XCTFail("update should not run") - return stagedUpdate() + return preparedUpdate() } ) @@ -235,6 +243,58 @@ final class AppUpdaterTests: XCTestCase { XCTAssertNil(update) } + @MainActor + func testCheckRejectsUninstallableAssetMetadata() async throws { + let cases: [(URL, Int64, AppUpdater.Configuration, AppUpdaterError)] = [ + ( + URL(string: "http://example.com/AppUpdater-2.0.0.dmg")!, + 42, + .init(), + .insecureDownloadURL + ), + ( + URL(string: "https://example.com/AppUpdater-2.0.0.dmg")!, + 0, + .init(), + .invalidGitHubResponse + ), + ( + URL(string: "https://example.com/AppUpdater-2.0.0.dmg")!, + 42, + .init(maximumDownloadBytes: 41), + .resourceLimitExceeded("download size") + ), + ] + + for (url, size, configuration, expectedError) in cases { + let release = try release( + "2.0.0", + prerelease: false, + assetName: "AppUpdater-2.0.0.dmg", + downloadURL: url, + size: size + ) + let updater = AppUpdater( + owner: "mxcl", + repo: "AppUpdater", + configuration: configuration, + currentVersion: { Version(1, 0, 0) }, + fetchReleases: { [release] }, + prepareAsset: { _ in + XCTFail("invalid metadata must not prepare an update") + return preparedUpdate() + } + ) + + do { + _ = try await updater.check() + XCTFail("check should reject invalid asset metadata") + } catch { + XCTAssertEqual(error as? AppUpdaterError, expectedError) + } + } + } + @MainActor func testCheckRespectsPrereleaseOptIn() async throws { let releases = try [ @@ -245,7 +305,7 @@ final class AppUpdaterTests: XCTestCase { repo: "AppUpdater", currentVersion: { Version(1, 0, 0) }, fetchReleases: { releases }, - stageAsset: { asset in stagedUpdate(assetName: asset.name) } + prepareAsset: { asset in preparedUpdate(assetName: asset.name) } ) updater.allowPrereleases = true @@ -270,7 +330,7 @@ final class AppUpdaterTests: XCTestCase { await gate.wait() return [release] }, - stageAsset: { asset in stagedUpdate(assetName: asset.name) } + prepareAsset: { asset in preparedUpdate(assetName: asset.name) } ) async let first = updater.check() @@ -406,7 +466,7 @@ final class AppUpdaterTests: XCTestCase { prerelease: false ) - XCTAssertEqual(asset?.name, "AppUpdater-1.9.0.dmg") + XCTAssertEqual(asset?.asset.name, "AppUpdater-1.9.0.dmg") } func testFindViableUpdateCanSelectPrerelease() throws { @@ -421,7 +481,7 @@ final class AppUpdaterTests: XCTestCase { prerelease: true ) - XCTAssertEqual(asset?.name, "AppUpdater-2.0.0-beta.1.dmg") + XCTAssertEqual(asset?.asset.name, "AppUpdater-2.0.0-beta.1.dmg") } func testFindViableUpdateCanSelectDiskImage() throws { @@ -440,8 +500,8 @@ final class AppUpdaterTests: XCTestCase { prerelease: false ) - XCTAssertEqual(asset?.name, "AppUpdater-2.0.0.dmg") - XCTAssertEqual(asset?.contentType, .dmg) + XCTAssertEqual(asset?.asset.name, "AppUpdater-2.0.0.dmg") + XCTAssertEqual(asset?.asset.contentType, .dmg) } func testFindViableUpdateSkipsReleasesWithoutMatchingAssets() throws { @@ -456,7 +516,7 @@ final class AppUpdaterTests: XCTestCase { prerelease: false ) - XCTAssertEqual(asset?.name, "AppUpdater-1.9.0.dmg") + XCTAssertEqual(asset?.asset.name, "AppUpdater-1.9.0.dmg") } func testFindViableUpdateReturnsNilWhenAlreadyCurrent() throws { @@ -881,6 +941,7 @@ final class AppUpdaterTests: XCTestCase { func testUpdateAndPreparedUpdateAreOneShot() async throws { var installs = 0 let update = Update( + version: "2.0.0", assetName: "AppUpdater-2.0.0.dmg", prepare: { PreparedUpdate( @@ -888,8 +949,7 @@ final class AppUpdaterTests: XCTestCase { install: { installs += 1 }, discard: {} ) - }, - discard: {} + } ) let prepared = try await update.prepareInstallation() @@ -1172,8 +1232,11 @@ final class AppUpdaterTests: XCTestCase { _ version: String, prerelease: Bool, assetName: String, - contentType: String = "application/x-apple-diskimage" + contentType: String = "application/x-apple-diskimage", + downloadURL: URL? = nil, + size: Int64 = 42 ) throws -> Release { + let downloadURL = downloadURL ?? URL(string: "https://example.com/\(assetName)")! let json = """ { "tag_name": "\(version)", @@ -1181,9 +1244,9 @@ final class AppUpdaterTests: XCTestCase { "assets": [ { "name": "\(assetName)", - "browser_download_url": "https://example.com/\(assetName)", + "browser_download_url": "\(downloadURL.absoluteString)", "content_type": "\(contentType)", - "size": 42 + "size": \(size) } ] } @@ -1367,12 +1430,10 @@ private extension URLSession { } @MainActor -private func stagedUpdate(assetName: String = "AppUpdater-2.0.0.dmg") -> Update { - Update( +private func preparedUpdate(assetName: String = "AppUpdater-2.0.0.dmg") -> PreparedUpdate { + PreparedUpdate( assetName: assetName, - prepare: { - PreparedUpdate(assetName: assetName, install: {}, discard: {}) - }, + install: {}, discard: {} ) }