From 4ea0d7fa98e7f3c86b89f181287e0956d6ee0feb Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:03:39 -0700 Subject: [PATCH 1/3] fix: bind app installs to inspected packages --- App/iOSDeveloperToolkit/Model/AppModel.swift | 9 + .../Model/FeatureModels.swift | 180 ++++++++-- App/iOSDeveloperToolkit/Views/AppsViews.swift | 41 ++- .../iOSDeveloperToolkitApp.swift | 52 ++- Sources/DeviceKit/Services/AFCClient.swift | 12 + .../ToolkitFeatures/IPA/IPAInspector.swift | 10 +- .../IPA/PreparedAppPackage.swift | 339 ++++++++++++++++++ Tests/DeviceKitTests/BackupAndAFCTests.swift | 40 ++- .../AppPackagePreparationTests.swift | 245 +++++++++++++ .../RealSimulatorTests.swift | 12 +- 10 files changed, 892 insertions(+), 48 deletions(-) create mode 100644 Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift create mode 100644 Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift diff --git a/App/iOSDeveloperToolkit/Model/AppModel.swift b/App/iOSDeveloperToolkit/Model/AppModel.swift index 5cfacf9..b842b3c 100644 --- a/App/iOSDeveloperToolkit/Model/AppModel.swift +++ b/App/iOSDeveloperToolkit/Model/AppModel.swift @@ -92,6 +92,7 @@ final class AppModel { } var journalRecords: [OperationRecord] = [] var operations: [RunningOperation] = [] + private(set) var isTerminating = false var presentedError: PresentedError? var developerTools: DeveloperToolsStatus? var readinessResults: [String: [CapabilityResult]] = [:] @@ -186,6 +187,14 @@ final class AppModel { security.cancel() } + func beginTermination() { + isTerminating = true + } + + func cancelTermination() { + isTerminating = false + } + private func apply(_ snapshot: DiscoverySnapshot) { let previous = Set(self.snapshot.devices.map(\.id)) self.snapshot = snapshot diff --git a/App/iOSDeveloperToolkit/Model/FeatureModels.swift b/App/iOSDeveloperToolkit/Model/FeatureModels.swift index 860db24..0e7f32e 100644 --- a/App/iOSDeveloperToolkit/Model/FeatureModels.swift +++ b/App/iOSDeveloperToolkit/Model/FeatureModels.swift @@ -109,66 +109,204 @@ final class AppsModel { // MARK: - Install +/// Immutable input captured before the installation confirmation is presented. +struct AppInstallPlan: Sendable { + let package: PreparedAppPackage + let device: Device + let developerPackage: Bool +} + @Observable @MainActor final class InstallModel { - var packageURL: URL? - var inspection: IPAInspection? - var appBundleName: String? + private(set) var packageURL: URL? + private(set) var preparedPackage: PreparedAppPackage? + private var stalePackages: [PreparedAppPackage] = [] + private(set) var isInspecting = false + private(set) var inspectionError: String? + private(set) var isInstalling = false var installAsDeveloperPackage = false + private var selectionID = UUID() + private var inspectionOperation: RunningOperation? + private var installationOperation: RunningOperation? + private var activePreparations = 0 + private var isShuttingDown = false + private var cleanupWaiters: [CheckedContinuation] = [] + + var hasPreparedResources: Bool { preparedPackage != nil || !stalePackages.isEmpty || activePreparations > 0 || isInstalling } - var isAppBundle: Bool { packageURL?.pathExtension == "app" } + var inspection: IPAInspection? { preparedPackage?.inspection } + var appBundleName: String? { isAppBundle ? preparedPackage?.appName : nil } + var isAppBundle: Bool { packageURL?.pathExtension.lowercased() == "app" } func choose(_ url: URL, app: AppModel) async { + guard !isShuttingDown && !app.isTerminating else { + app.present(ToolkitError.invalidInput("The app is quitting. Cancel quitting before choosing another package.")) + return + } + guard !isInstalling else { + app.present(ToolkitError(.invalidInput, message: "Wait for the current installation to finish before choosing another package.", recovery: nil, technicalDetail: nil)) + return + } + let generation = UUID() + selectionID = generation + inspectionOperation?.cancel() + if let previous = preparedPackage { + do { try previous.remove() } + catch { + stalePackages.append(previous) + preparedPackage = nil + packageURL = nil + inspectionError = "The previous prepared package could not be removed. Review the reported file error before choosing another package." + app.present(error) + return + } + } packageURL = url - inspection = nil - appBundleName = nil - if url.pathExtension == "app" { - appBundleName = url.deletingPathExtension().lastPathComponent + preparedPackage = nil + inspectionError = nil + isInspecting = true + activePreparations += 1 + defer { + if selectionID == generation { + isInspecting = false + inspectionOperation = nil + } + activePreparations -= 1 + resumeCleanupIfIdle() + } + let result = await app.run("Inspect \(url.lastPathComponent)", workspace: .installApp, target: nil, transport: "Local inspection (Security.framework)", outputPaths: [url.path], onStart: { self.inspectionOperation = $0 }) { _ in + let preparation = Task.detached(priority: .userInitiated) { try PreparedAppPackage.prepare(url) } + return try await withTaskCancellationHandler { + try await preparation.value + } onCancel: { preparation.cancel() } + } + guard selectionID == generation else { + if let result { + do { try result.remove() } + catch { + stalePackages.append(result) + app.present(error) + } + } return } - let result = await app.run("Inspect \(url.lastPathComponent)", workspace: .installApp, target: nil, transport: "Local inspection (Security.framework)", outputPaths: [url.path]) { _ in - try IPAInspector.inspect(url) + preparedPackage = result + if result == nil { inspectionError = "Package inspection did not finish successfully. Choose the package again before installing." } + } + + func blockReason(for device: Device) -> String? { + if isShuttingDown { return "Package cleanup is running while the app quits." } + if isInstalling { return "An installation is already running." } + if isInspecting { return "Wait for package inspection to finish." } + guard let preparedPackage else { return inspectionError ?? "Choose and inspect a package before installing." } + do { + try preparedPackage.validate(for: device.target, supportsCoreDevice: device.supportsCoreDevice, simulatorBooted: device.simulatorState == .booted) + return nil + } catch { return (error as? ToolkitError)?.message ?? error.localizedDescription } + } + + func prepareInstallation(for device: Device, app: AppModel) throws -> AppInstallPlan { + guard !isInstalling && !isInspecting && !isShuttingDown, let preparedPackage else { + throw ToolkitError(.invalidInput, message: "Package inspection must finish successfully before installation.", recovery: "Choose the package again and wait for inspection.", technicalDetail: nil) + } + guard !app.isTerminating, app.selectedTarget == device.target else { + throw ToolkitError(.invalidInput, message: "The installation target changed before confirmation.", recovery: "Select the target again and review a new installation confirmation.", technicalDetail: nil) } - inspection = result + try preparedPackage.validate(for: device.target, supportsCoreDevice: device.supportsCoreDevice, simulatorBooted: device.simulatorState == .booted) + return AppInstallPlan(package: preparedPackage, device: device, developerPackage: installAsDeveloperPackage) } - func install(app: AppModel, device: Device) async { - guard let url = packageURL else { return } + func install(_ plan: AppInstallPlan, app: AppModel) async { + guard !isShuttingDown && !app.isTerminating else { + app.present(ToolkitError.invalidInput("The app is quitting. Cancel quitting before starting an app installation.")) + return + } + guard !isInstalling && !isInspecting, preparedPackage?.id == plan.package.id else { + app.present(ToolkitError(.invalidInput, message: "The package changed after this installation was confirmed.", recovery: "Inspect and confirm the selected package again.", technicalDetail: nil)) + return + } + guard app.selectedTarget == plan.device.target else { + app.present(ToolkitError(.invalidInput, message: "The installation target changed after confirmation.", recovery: "Select the target again and review a new installation confirmation.", technicalDetail: nil)) + return + } + let device = plan.device let target = device.target let runner = app.runner - let developer = installAsDeveloperPackage - let succeeded = await app.run("Install \(url.lastPathComponent)", workspace: .installApp, target: target, transport: device.kind == .simulator ? "simctl install" : (device.supportsCoreDevice ? "devicectl device install app" : "AFC + installation_proxy"), argv: [url.path]) { operation in + let url = plan.package.packageURL + isInstalling = true + defer { + isInstalling = false + installationOperation = nil + resumeCleanupIfIdle() + } + let succeeded = await app.run("Install \(plan.package.appName)", workspace: .installApp, target: target, transport: device.kind == .simulator ? "simctl install" : (device.supportsCoreDevice ? "devicectl device install app" : "AFC + installation_proxy"), argv: [plan.package.originalURL.path], onStart: { self.installationOperation = $0 }) { operation in + let validation = Task.detached(priority: .userInitiated) { try plan.package.revalidate() } + try await withTaskCancellationHandler { + try await validation.value + } onCancel: { validation.cancel() } + try Task.checkCancellation() + try plan.package.validate(for: target, supportsCoreDevice: device.supportsCoreDevice, simulatorBooted: device.simulatorState == .booted) switch device.kind { case .simulator: try await SimulatorClient(runner: runner).install(appAt: url, on: target) case .physical where device.supportsCoreDevice: _ = try await CoreDeviceClient(runner: runner).install(appAt: url, on: target) case .physical: - guard url.pathExtension == "ipa" else { throw ToolkitError(.unsupported, message: "Without Xcode, only .ipa packages can be installed.") } try await DeviceSession.with(target) { session in let afc = try await AFCClient.openMedia(session) let stagedName = "idt-\(UUID().uuidString.prefix(8)).ipa" - try? await afc.makeDirectory("/PublicStaging") + try await afc.ensureDirectory("/PublicStaging") try await afc.upload(url, to: "/PublicStaging/\(stagedName)") { sent, total in operation.report("Uploading \(ByteFormatting.string(sent)) of \(ByteFormatting.string(total))", progress: total > 0 ? Double(sent) / Double(total) * 0.5 : nil) } await afc.close() let proxy = try await InstallationProxy.open(session) defer { Task { await proxy.close() } } - try await proxy.install(stagedPackagePath: "PublicStaging/\(stagedName)", developerPackage: developer) { percent in + try await proxy.install(stagedPackagePath: "PublicStaging/\(stagedName)", developerPackage: plan.developerPackage) { percent in operation.report("Installing \(percent)%", progress: 0.5 + Double(percent) / 200) } } case .demo: - throw ToolkitError(.unsupported, message: "Demo Mode cannot install apps.") + throw ToolkitError(.unsupported, message: "Demo Mode cannot install apps.", recovery: nil, technicalDetail: nil) } return true } if succeeded == true { - app.statusMessage = "Installed \(inspection?.appName ?? url.lastPathComponent) on \(target.name)." - await app.apps.refresh(app: app, device: device) + app.statusMessage = "Installed \(plan.package.appName) on \(target.name)." + if app.selectedTarget == target { await app.apps.refresh(app: app, device: device) } + } + } + + /// Waits for this model's package readers before removing its private copy on normal quit. + func cleanupForTermination() async throws { + isShuttingDown = true + defer { isShuttingDown = false } + selectionID = UUID() + inspectionOperation?.cancel() + installationOperation?.cancel() + if activePreparations > 0 || isInstalling { + await withCheckedContinuation { cleanupWaiters.append($0) } } + if let preparedPackage { + try preparedPackage.remove() + self.preparedPackage = nil + } + while let stale = stalePackages.first { + try stale.remove() + stalePackages.removeFirst() + } + inspectionOperation = nil + packageURL = nil + inspectionError = nil + isInspecting = false + } + + private func resumeCleanupIfIdle() { + guard activePreparations == 0 && !isInstalling else { return } + let waiters = cleanupWaiters + cleanupWaiters.removeAll() + for waiter in waiters { waiter.resume() } } } diff --git a/App/iOSDeveloperToolkit/Views/AppsViews.swift b/App/iOSDeveloperToolkit/Views/AppsViews.swift index 06597e8..4987540 100644 --- a/App/iOSDeveloperToolkit/Views/AppsViews.swift +++ b/App/iOSDeveloperToolkit/Views/AppsViews.swift @@ -110,12 +110,21 @@ struct InstallAppView: View { Task { await install.choose(url, app: model) } } } + .disabled(install.isInstalling) .accessibilityIdentifier("choose-package") if let url = install.packageURL { Text(url.lastPathComponent).font(.callout.monospaced()).lineLimit(1).truncationMode(.middle) } } } + if install.isInspecting { + ProgressView("Inspecting the selected package…") + .accessibilityIdentifier("package-inspection-running") + } else if let error = install.inspectionError { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + .accessibilityIdentifier("package-inspection-failed") + } if let inspection = install.inspection { InspectionView(inspection: inspection, device: model.selectedDevice) } else if let name = install.appBundleName { @@ -130,37 +139,33 @@ struct InstallAppView: View { Toggle("Install as developer package", isOn: $install.installAsDeveloperPackage) .help("Asks iOS to treat the package as a development build. Only meaningful for development-signed apps.") } - let blocked = blockReason(device) + let blocked = install.blockReason(for: device) if let blocked { Label(blocked, systemImage: "hand.raised").foregroundStyle(.orange) } Button("Install on \(device.name)…") { - confirmation = PendingConfirmation(title: "Install \(install.inspection?.appName ?? install.packageURL?.lastPathComponent ?? "app")?", detail: "The app will be installed on \(device.name). iOS still checks the signature, provisioning, and entitlements.", requirement: .make(for: .deviceChange, target: device.target), target: device.target) { - Task { await install.install(app: model, device: device) } - } + do { + let plan = try install.prepareInstallation(for: device, app: model) + let developerOption = plan.device.kind == .physical && !plan.device.supportsCoreDevice + ? " Install as developer package: \(plan.developerPackage ? "on" : "off")." + : "" + confirmation = PendingConfirmation(title: "Install \(plan.package.appName)?", detail: "\(plan.package.originalURL.lastPathComponent) will be installed on \(plan.device.name).\(developerOption) iOS still checks the signature, provisioning, and entitlements.", requirement: .make(for: .deviceChange, target: plan.device.target), target: plan.device.target) { + Task { await install.install(plan, app: model) } + } + } catch { model.present(error) } } .disabled(blocked != nil) + .accessibilityIdentifier("install-package") } } } + .onChange(of: model.selectedTarget) { _, _ in confirmation = nil } + .onChange(of: install.preparedPackage?.id) { _, _ in confirmation = nil } + .onChange(of: install.installAsDeveloperPackage) { _, _ in confirmation = nil } .sheet(item: $confirmation) { pending in ConfirmationSheet(title: pending.title, detail: pending.detail, requirement: pending.requirement, target: pending.target, commandPreview: nil, onConfirm: pending.action) } } - - private func blockReason(_ device: Device) -> String? { - let install = model.install - switch device.kind { - case .demo: return "Demo Mode cannot install apps." - case .simulator: - if !install.isAppBundle { return "Simulators install .app bundles built for the simulator, not .ipa files." } - if device.simulatorState != .booted { return "Start the simulator first." } - return nil - case .physical: - if let inspection = install.inspection, !inspection.isInstallable { return inspection.installabilityExplanation } - return nil - } - } } struct InspectionView: View { diff --git a/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift b/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift index 9269709..96f67df 100644 --- a/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift +++ b/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift @@ -53,6 +53,8 @@ struct IOSDeveloperToolkitApp: App { final class AppDelegate: NSObject, NSApplicationDelegate { weak var model: AppModel? + private var terminationRequestID: UUID? + private var terminationDeadlineTask: Task? func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { MainActor.assumeIsolated { @@ -65,6 +67,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { MainActor.assumeIsolated { guard let model else { return .terminateNow } + guard !model.isTerminating else { return .terminateCancel } let busy = !model.operations.isEmpty || model.firmware.isInstalling || model.logs.sessions.contains { $0.state.isActive } let decision = FirmwareTerminationPolicy.decision(critical: model.firmware.terminationBlocked, busy: busy) if decision == .deny { @@ -87,14 +90,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate { explainFirmwareWriting() return .terminateCancel } - if model.firmware.isInstalling { + if model.firmware.isInstalling || model.install.hasPreparedResources { + model.beginTermination() + let requestID = UUID() + terminationRequestID = requestID Task { @MainActor in - guard await model.firmware.cancelForTermination() else { - self.explainFirmwareWriting() - sender.reply(toApplicationShouldTerminate: false) + var ready = true + var packageCleanupFailed = false + if model.firmware.isInstalling { + ready = await model.firmware.cancelForTermination() + if !ready { self.explainFirmwareWriting() } + } + if ready { + do { try await model.install.cleanupForTermination() } + catch { model.present(error); packageCleanupFailed = true; ready = false } + } + guard self.terminationRequestID == requestID else { + model.cancelTermination() return } - sender.reply(toApplicationShouldTerminate: self.finishTermination(model) == .terminateNow) + if ready { ready = self.finishTermination(model) == .terminateNow } + self.terminationRequestID = nil + self.terminationDeadlineTask?.cancel() + self.terminationDeadlineTask = nil + if !ready { model.cancelTermination() } + sender.reply(toApplicationShouldTerminate: ready) + if packageCleanupFailed { self.showTerminationFailureWithoutWindow(model) } + } + terminationDeadlineTask = Task { @MainActor in + do { try await Task.sleep(for: .seconds(8)) } + catch is CancellationError { return } + catch { model.present(error); return } + guard self.terminationRequestID == requestID else { return } + self.terminationRequestID = nil + model.present(ToolkitError(.timedOut, message: "Quit cleanup is still running after eight seconds. The app will remain open.", recovery: "Wait for the active operation to finish, then try quitting again.")) + sender.reply(toApplicationShouldTerminate: false) + self.showTerminationFailureWithoutWindow(model) } return .terminateLater } @@ -102,6 +133,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + @MainActor + private func showTerminationFailureWithoutWindow(_ model: AppModel) { + guard !NSApp.windows.contains(where: { $0.isVisible && $0.contentView != nil }), + let error = model.presentedError else { return } + let alert = NSAlert() + alert.messageText = error.title + alert.informativeText = [error.message, error.recovery].compactMap { $0 }.joined(separator: "\n\n") + alert.addButton(withTitle: "Keep Running") + alert.runModal() + } + @MainActor private func explainFirmwareWriting() { let alert = NSAlert() diff --git a/Sources/DeviceKit/Services/AFCClient.swift b/Sources/DeviceKit/Services/AFCClient.swift index 86ebe75..4ac5c60 100644 --- a/Sources/DeviceKit/Services/AFCClient.swift +++ b/Sources/DeviceKit/Services/AFCClient.swift @@ -154,6 +154,18 @@ public actor AFCClient { _ = try await transact(.makeDirectory, header: Self.path(path)) } + /// Accepts an existing real directory or creates a missing one. Other AFC failures propagate. + public func ensureDirectory(_ path: String) async throws { + do { + let info = try await fileInfo(path) + guard info.isDirectory, !info.isSymbolicLink else { + throw ToolkitError(.fileSystem, message: "The requested device directory is a file or symbolic link.", recovery: "Choose a regular device directory before continuing.") + } + } catch let error as ToolkitError where error == Self.error(code: 8) { + try await makeDirectory(path) + } + } + public func remove(_ path: String) async throws { _ = try await transact(.removePath, header: Self.path(path)) } diff --git a/Sources/ToolkitFeatures/IPA/IPAInspector.swift b/Sources/ToolkitFeatures/IPA/IPAInspector.swift index c6d812f..ee20cd9 100644 --- a/Sources/ToolkitFeatures/IPA/IPAInspector.swift +++ b/Sources/ToolkitFeatures/IPA/IPAInspector.swift @@ -95,6 +95,14 @@ public enum IPAInspector { } let appRoot = String(infoEntry.name.dropLast("/Info.plist".count)) let bundleIdentifier = try required("CFBundleIdentifier") + guard let platformValues = info["CFBundleSupportedPlatforms"]?.arrayValue, !platformValues.isEmpty else { + throw ToolkitError.invalidInput("The app's Info.plist must declare CFBundleSupportedPlatforms as a nonempty string array.") + } + let supportedPlatforms = platformValues.compactMap(\.stringValue) + guard supportedPlatforms.count == platformValues.count, + supportedPlatforms.allSatisfy({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { + throw ToolkitError.invalidInput("CFBundleSupportedPlatforms contains a non-string or empty value.") + } let provisioning: ProvisioningProfileSummary if let profileEntry = archive.entry(named: "\(appRoot)/embedded.mobileprovision") { @@ -122,7 +130,7 @@ public enum IPAInspector { build: try required("CFBundleVersion"), minimumOSVersion: info["MinimumOSVersion"]?.stringValue, executableName: try required("CFBundleExecutable"), - supportedPlatforms: info["CFBundleSupportedPlatforms"]?.arrayValue?.compactMap(\.stringValue) ?? [], + supportedPlatforms: supportedPlatforms, provisioning: provisioning, signature: signature ) diff --git a/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift b/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift new file mode 100644 index 0000000..2f35967 --- /dev/null +++ b/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift @@ -0,0 +1,339 @@ +import CryptoKit +import Darwin +import DeviceKit +import Foundation +import ToolkitCore + +/// An inspected private copy bound to the original package's content. Installation consumes +/// `packageURL`, after revalidation, and cleanup removes only this preparation's scratch folder. +public struct PreparedAppPackage: Sendable { + public let id: UUID + public let originalURL: URL + public let packageURL: URL + public let directory: URL + public let inspection: IPAInspection? + public let appName: String + public let supportedPlatforms: [String] + public let signature: CodeSignatureSummary? + + private let expectedIdentity: String + private let directoryDevice: dev_t + private let directoryInode: ino_t + private static let maximumBytes = ZipArchive.maximumTotalUncompressedBytes + + public static func prepare(_ source: URL) throws -> PreparedAppPackage { + try Task.checkCancellation() + guard source.isFileURL, ["ipa", "app"].contains(source.pathExtension.lowercased()) else { + throw ToolkitError.invalidInput("Choose an .ipa package or an iOS .app bundle.") + } + let original = source.standardizedFileURL + let identity = try packageIdentity(original) + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "idt-prepared-app") + let copy = directory.appendingPathComponent(original.lastPathComponent) + do { + try FileManager.default.copyItem(at: original, to: copy) + try Task.checkCancellation() + guard try packageIdentity(original) == identity, try packageIdentity(copy) == identity else { + throw ToolkitError(.invalidInput, message: "The package changed while it was being prepared.", recovery: "Choose the package again after its build or copy has finished.", technicalDetail: original.path) + } + let inspection: IPAInspection? + let appName: String + let platforms: [String] + let signature: CodeSignatureSummary? + if original.pathExtension.lowercased() == "ipa" { + let inspected = try IPAInspector.inspect(copy) + inspection = inspected + appName = inspected.appName + platforms = try validatedPlatforms(inspected.supportedPlatforms) + signature = inspected.signature + } else { + let info = try bundleInfo(copy) + inspection = nil + appName = info.name + platforms = info.platforms + signature = CodeSignatureInspector.inspect(bundle: copy) + } + guard try packageIdentity(copy) == identity, try packageIdentity(original) == identity else { + throw ToolkitError(.invalidInput, message: "The package changed during inspection.", recovery: "Choose the completed package again.", technicalDetail: original.path) + } + let ownership = try metadata(directory) + return PreparedAppPackage(id: UUID(), originalURL: original, packageURL: copy, directory: directory, inspection: inspection, appName: appName, supportedPlatforms: platforms, signature: signature, expectedIdentity: identity, directoryDevice: ownership.st_dev, directoryInode: ownership.st_ino) + } catch { + do { try FileManager.default.removeItem(at: directory) } + catch let cleanupError { + throw ToolkitError.wrap(error, message: "Could not prepare the app package.", recovery: "Choose a valid package again.").appendingDetail("Could not remove the incomplete private copy at \(directory.path): \(cleanupError.localizedDescription)") + } + throw error + } + } + + /// Checks captured metadata for the existing installation routes without filesystem I/O. + /// Call `revalidate()` immediately before execution. A physical device still decides provisioning. + public func validate(for target: DeviceTarget, supportsCoreDevice: Bool, simulatorBooted: Bool) throws { + switch target.kind { + case .demo: + throw ToolkitError(.unsupported, message: "Demo Mode does not install packages.", recovery: "Select a real simulator or an authorized physical device.", technicalDetail: nil) + case .simulator: + guard inspection == nil, packageURL.pathExtension.lowercased() == "app" else { + throw ToolkitError(.unsupported, message: "Simulators install .app bundles built for the simulator, not .ipa files.", recovery: "Build for an iOS Simulator destination and choose the resulting .app bundle.", technicalDetail: nil) + } + guard supportedPlatforms.contains("iPhoneSimulator") else { + throw ToolkitError(.unsupported, message: "This app bundle was not built for an iOS Simulator.", recovery: "Choose the .app from an iOS Simulator build.", technicalDetail: nil) + } + guard simulatorBooted else { + throw ToolkitError(.invalidInput, message: "Boot the selected simulator before installing the app.", recovery: "Boot it in Device & DDI, then confirm installation again.", technicalDetail: nil) + } + case .physical: + guard supportedPlatforms.contains("iPhoneOS") else { + throw ToolkitError(.unsupported, message: "This package was not built for a physical iOS device.", recovery: "Choose an iPhoneOS build with the required device signing and provisioning.", technicalDetail: nil) + } + if let inspection { + guard inspection.isInstallable else { + throw ToolkitError(.invalidInput, message: inspection.installabilityExplanation, recovery: "Rebuild and sign the package in Xcode, then choose it again.", technicalDetail: nil) + } + } else { + guard supportsCoreDevice else { + throw ToolkitError(.unsupported, message: "Installing a .app bundle on a physical device requires Xcode CoreDevice tools.", recovery: "Select Xcode or choose a signed .ipa for native installation.", technicalDetail: nil) + } + guard signature?.status == .valid else { + throw ToolkitError(.invalidInput, message: "The app bundle does not have an intact code signature.", recovery: "Rebuild and sign the iOS app in Xcode, then choose it again.", technicalDetail: signature?.detail) + } + } + } + } + + public func revalidate() throws { + try verifyDirectoryOwnership() + guard try Self.packageIdentity(originalURL) == expectedIdentity, try Self.packageIdentity(packageURL) == expectedIdentity else { + throw ToolkitError(.invalidInput, message: "The selected package or its prepared copy changed after inspection.", recovery: "Choose the package again and review a new installation confirmation.", technicalDetail: originalURL.path) + } + } + + public func remove() throws { + try verifyDirectoryOwnership() + do { try FileManager.default.removeItem(at: directory) } + catch { throw ToolkitError.fileSystem("Could not remove the prepared app package.", path: directory.path, underlying: error) } + } + + private func verifyDirectoryOwnership() throws { + let current = try Self.metadata(directory) + guard current.st_mode & S_IFMT == S_IFDIR, current.st_mode & 0o777 == 0o700, current.st_dev == directoryDevice, current.st_ino == directoryInode else { + throw ToolkitError.fileSystem("The private app-package folder was replaced. It will not be used or removed.", path: directory.path, underlying: nil) + } + } + + private static func validatedPlatforms(_ values: [String]) throws -> [String] { + guard !values.isEmpty, values.allSatisfy({ !$0.isEmpty }) else { + throw ToolkitError.invalidInput("The app's Info.plist must declare CFBundleSupportedPlatforms.") + } + return values + } + + private static func bundleInfo(_ bundle: URL) throws -> (name: String, platforms: [String]) { + let descriptor = try openChild(root: bundle, relativePath: "Info.plist") + let data = try readFile(descriptor, maximumBytes: IPAInspector.maximumInfoPlistBytes) + let info = try PlistValue.decode(data) + guard info.dictionaryValue != nil else { throw ToolkitError.invalidInput("The app's Info.plist is not a dictionary.") } + func required(_ key: String) throws -> String { + guard let value = info[key]?.stringValue, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ToolkitError.invalidInput("The app's Info.plist is missing \(key).") + } + return value + } + let identifier = try required("CFBundleIdentifier") + try BundleIdentifier.validate(identifier) + _ = try required("CFBundleShortVersionString") + _ = try required("CFBundleVersion") + let executable = try required("CFBundleExecutable") + guard !executable.contains("/"), !executable.contains("\0"), executable != ".", executable != ".." else { + throw ToolkitError.invalidInput("The app's CFBundleExecutable is not a safe filename.") + } + let executableInfo = try metadata(bundle.appendingPathComponent(executable)) + guard executableInfo.st_mode & S_IFMT == S_IFREG, executableInfo.st_mode & 0o111 != 0 else { + throw ToolkitError.invalidInput("The app's declared executable is missing or is not an executable regular file.") + } + guard let platformValues = info["CFBundleSupportedPlatforms"]?.arrayValue else { + throw ToolkitError.invalidInput("The app's Info.plist must declare CFBundleSupportedPlatforms as a string array.") + } + let platforms = platformValues.compactMap(\.stringValue) + guard platforms.count == platformValues.count else { throw ToolkitError.invalidInput("CFBundleSupportedPlatforms contains a non-string value.") } + let name: String + if info["CFBundleDisplayName"] != nil { name = try required("CFBundleDisplayName") } + else { name = try required("CFBundleName") } + return (name, try validatedPlatforms(platforms)) + } + + private struct TreeEntry: Codable { + let path: String + let kind: String + let permissions: mode_t + let hash: String? + let link: String? + } + + private static func packageIdentity(_ url: URL) throws -> String { + try Task.checkCancellation() + let info = try metadata(url) + if url.pathExtension.lowercased() == "ipa" { + guard info.st_mode & S_IFMT == S_IFREG else { throw ToolkitError.invalidInput("The IPA package must be a regular file, not a symbolic link.") } + let descriptor = open(url.path, O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC) + guard descriptor >= 0 else { throw fileError("Could not open the IPA package safely.", url: url) } + return try hashFile(descriptor, url: url, maximumBytes: maximumBytes).hash + } + guard info.st_mode & S_IFMT == S_IFDIR else { throw ToolkitError.invalidInput("The app bundle must be a directory, not a symbolic link.") } + let root = url.resolvingSymlinksInPath() + var enumerationFailure: ToolkitError? + guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: [], options: [], errorHandler: { failedURL, error in + enumerationFailure = ToolkitError.fileSystem("Could not enumerate every app-bundle file.", path: failedURL.path, underlying: error) + return false + }) else { + throw ToolkitError.fileSystem("Could not inspect the app bundle's files.", path: root.path, underlying: nil) + } + var entries = [TreeEntry(path: "", kind: "directory", permissions: info.st_mode & 0o7777, hash: nil, link: nil)] + var totalBytes: UInt64 = 0 + for case let enumeratedURL as URL in enumerator { + try Task.checkCancellation() + guard entries.count < ZipArchive.maximumEntries else { throw ToolkitError.invalidInput("The app bundle exceeds the package file-count limit.") } + // FileManager may return /var URLs for a canonical /private/var root. Resolve only + // the parent so a leaf symbolic link remains visible to lstat and the identity. + let child = enumeratedURL.deletingLastPathComponent().resolvingSymlinksInPath().appendingPathComponent(enumeratedURL.lastPathComponent) + guard child.path.hasPrefix(root.path + "/") else { throw ToolkitError.invalidInput("An app-bundle path escaped its root.") } + let relativePath = String(child.path.dropFirst(root.path.count + 1)) + let fileMetadata = try metadata(child) + let kind = fileMetadata.st_mode & S_IFMT + switch kind { + case S_IFREG: + let descriptor = try openChild(root: root, relativePath: relativePath) + let file = try hashFile(descriptor, url: child, maximumBytes: maximumBytes - totalBytes) + totalBytes += file.bytes + entries.append(TreeEntry(path: relativePath, kind: "file", permissions: fileMetadata.st_mode & 0o7777, hash: file.hash, link: nil)) + case S_IFDIR: + guard child.resolvingSymlinksInPath().path.hasPrefix(root.path + "/") else { throw ToolkitError.invalidInput("An app-bundle directory escaped its root.") } + entries.append(TreeEntry(path: relativePath, kind: "directory", permissions: fileMetadata.st_mode & 0o7777, hash: nil, link: nil)) + case S_IFLNK: + enumerator.skipDescendants() + let link = try FileManager.default.destinationOfSymbolicLink(atPath: child.path) + let destination = child.deletingLastPathComponent().appendingPathComponent(link).standardizedFileURL + let resolved = child.resolvingSymlinksInPath() + guard !link.hasPrefix("/"), !link.contains("\0"), destination.path.hasPrefix(root.standardizedFileURL.path + "/"), resolved.path.hasPrefix(root.path + "/"), FileManager.default.fileExists(atPath: resolved.path) else { + throw ToolkitError.invalidInput("The app bundle contains an absolute, escaping, broken, or cyclic symbolic link: \(relativePath)") + } + entries.append(TreeEntry(path: relativePath, kind: "symlink", permissions: fileMetadata.st_mode & 0o7777, hash: nil, link: link)) + default: + throw ToolkitError.invalidInput("The app bundle contains a non-regular file: \(relativePath)") + } + } + if let enumerationFailure { throw enumerationFailure } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return SecureFileIO.sha256(of: try encoder.encode(entries.sorted { $0.path < $1.path })) + } + + /// Opens every directory component without following links, so a changed bundle path cannot + /// redirect a content read outside the bundle. Internal framework links are inventoried as links. + private static func openChild(root: URL, relativePath: String) throws -> Int32 { + var directory = open(root.path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard directory >= 0 else { throw fileError("Could not open the app bundle safely.", url: root) } + var ownedDescriptors = [directory] + do { + let components = relativePath.split(separator: "/").map(String.init) + guard let last = components.last, !components.contains(".."), !components.contains(".") else { throw ToolkitError.invalidInput("An unsafe app-bundle path was rejected.") } + for component in components.dropLast() { + let next = openat(directory, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard next >= 0 else { throw fileError("Could not open an app-bundle directory safely.", url: root.appendingPathComponent(relativePath)) } + ownedDescriptors.append(next) + ownedDescriptors.removeAll { $0 == directory } + try closeDescriptor(directory, root: root) + directory = next + } + let descriptor = openat(directory, last, O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC) + guard descriptor >= 0 else { throw fileError("Could not open an app-bundle file safely.", url: root.appendingPathComponent(relativePath)) } + ownedDescriptors.append(descriptor) + ownedDescriptors.removeAll { $0 == directory } + try closeDescriptor(directory, root: root) + return descriptor + } catch { + var cleanupFailures: [String] = [] + for descriptor in ownedDescriptors { + do { try closeDescriptor(descriptor, root: root) } + catch { cleanupFailures.append((error as? ToolkitError)?.technicalDetail ?? error.localizedDescription) } + } + guard cleanupFailures.isEmpty else { + throw ToolkitError.wrap(error, message: "Could not open the app-bundle file safely.", recovery: "Choose the package again.").appendingDetail("Closing app-bundle descriptors also failed: \(cleanupFailures.joined(separator: "; "))") + } + throw error + } + } + + /// A close failure leaves descriptor ownership platform-dependent; surface it without retrying + /// a descriptor number that could already have been released and reused by another thread. + private static func closeDescriptor(_ descriptor: Int32, root: URL) throws { + guard close(descriptor) == 0 else { throw fileError("Could not close an app-bundle descriptor.", url: root) } + } + + private static func hashFile(_ descriptor: Int32, url: URL, maximumBytes: UInt64) throws -> (hash: String, bytes: UInt64) { + try withFileHandle(descriptor) { handle in + var info = stat() + guard fstat(descriptor, &info) == 0 else { throw fileError("Could not inspect the package file.", url: url) } + guard info.st_mode & S_IFMT == S_IFREG, info.st_size >= 0, UInt64(info.st_size) <= maximumBytes else { + throw ToolkitError.invalidInput("The package contains an invalid file or exceeds the 8 GB package preparation limit.") + } + var hasher = SHA256() + var bytes: UInt64 = 0 + while let data = try handle.read(upToCount: 1 << 20), !data.isEmpty { + try Task.checkCancellation() + guard UInt64(data.count) <= maximumBytes - bytes else { throw ToolkitError.invalidInput("The package changed size or exceeds the package preparation limit.") } + bytes += UInt64(data.count) + hasher.update(data: data) + } + return (hasher.finalize().map { String(format: "%02x", $0) }.joined(), bytes) + } + } + + private static func readFile(_ descriptor: Int32, maximumBytes: UInt64) throws -> Data { + try withFileHandle(descriptor) { handle in + var info = stat() + guard fstat(descriptor, &info) == 0 else { + throw ToolkitError.fileSystem("Could not read the app's Info.plist metadata.", path: nil, underlying: POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)) + } + guard info.st_mode & S_IFMT == S_IFREG, info.st_size >= 0, UInt64(info.st_size) <= maximumBytes else { + throw ToolkitError.invalidInput("The app's Info.plist must be a regular file within the 10 MB inspection limit.") + } + var data = Data() + while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty { + try Task.checkCancellation() + guard UInt64(chunk.count) <= maximumBytes - UInt64(data.count) else { + throw ToolkitError.invalidInput("The app's Info.plist exceeds the 10 MB inspection limit.") + } + data.append(chunk) + } + return data + } + } + + private static func withFileHandle(_ descriptor: Int32, operation: (FileHandle) throws -> Value) throws -> Value { + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + let value: Value + do { value = try operation(handle) } + catch { + do { try handle.close() } + catch let cleanupError { + throw ToolkitError.wrap(error, message: "Could not read the app package.", recovery: "Choose the package again.").appendingDetail("Closing the package file also failed: \(cleanupError.localizedDescription)") + } + throw error + } + do { try handle.close() } + catch { throw ToolkitError.fileSystem("Could not close the app-package file after reading it.", path: nil, underlying: error) } + return value + } + + private static func metadata(_ url: URL) throws -> stat { + var info = stat() + guard lstat(url.path, &info) == 0 else { throw fileError("Could not read the package's file metadata.", url: url) } + return info + } + + private static func fileError(_ message: String, url: URL) -> ToolkitError { + ToolkitError.fileSystem(message, path: url.path, underlying: POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)) + } +} diff --git a/Tests/DeviceKitTests/BackupAndAFCTests.swift b/Tests/DeviceKitTests/BackupAndAFCTests.swift index 5995b4c..ea49472 100644 --- a/Tests/DeviceKitTests/BackupAndAFCTests.swift +++ b/Tests/DeviceKitTests/BackupAndAFCTests.swift @@ -103,7 +103,15 @@ struct BackupAndAFCTests { #expect(try Data(contentsOf: destination).count == 3000) let source = directory.appendingPathComponent("upload.ipa") try SecureFileIO.writeNewFile(Data(repeating: 7, count: 2500), to: source) - try await afc.makeDirectory("/PublicStaging") + try await afc.ensureDirectory("/PublicStaging") + try await afc.ensureDirectory("/PublicStaging") + #expect(try await afc.fileInfo("/PublicStaging").isDirectory) + do { + try await afc.ensureDirectory("/Downloads/readme.txt") + Issue.record("A regular file was accepted as a staging directory") + } catch let error as ToolkitError { + #expect(error.kind == .fileSystem) + } try await afc.upload(source, to: "/PublicStaging/upload.ipa", chunkSize: 1000) let walk = try await afc.walk("/") #expect(walk.contains("/PublicStaging/upload.ipa")) @@ -120,6 +128,36 @@ struct BackupAndAFCTests { } } + @Test func ensureDirectoryPropagatesDeniedInspectionWithoutCreating() async throws { + try await withFakeDevice({ _ in }) { server in + let operations = LockedValue<[UInt64]>([]) + server.register(service: AFCClient.mediaServiceName) { channel in + while try await channel.hasMoreData() { + let header = try await channel.read(exactly: 40, timeout: 5) + let length = Int(header.readLittleEndianUInt64(at: 8)) + let number = header.readLittleEndianUInt64(at: 24) + let operation = header.readLittleEndianUInt64(at: 32) + _ = try await channel.read(exactly: length - 40, timeout: 5) + operations.withLock { $0.append(operation) } + var status = Data() + status.appendLittleEndian(UInt64(10)) + try await channel.write(AFCClient.encode(operation: 0x01, packetNumber: number, header: status, payload: Data())) + } + } + try await DeviceSession.with(server.target, usbmux: server.client) { session in + let afc = try await AFCClient.openMedia(session) + do { + try await afc.ensureDirectory("/PublicStaging") + Issue.record("A denied staging-directory inspection succeeded") + } catch let error as ToolkitError { + #expect(error.kind == .permissionDenied) + } + await afc.close() + } + #expect(operations.current == [0x0A]) + } + } + @Test func fullBackupConversation() async throws { let statusPlist = try PlistValue(dictionaryLiteral: ("SnapshotState", "finished")).encoded() try await withFakeDevice({ $0.domainValues["com.apple.mobile.backup"] = ["WillEncrypt": true] }) { server in diff --git a/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift b/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift new file mode 100644 index 0000000..d4b8df1 --- /dev/null +++ b/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift @@ -0,0 +1,245 @@ +import Darwin +import DeviceKit +import Foundation +import Testing +import ToolkitCore +@testable import ToolkitFeatures + +@Suite("App package preparation") +struct AppPackagePreparationTests { + @Test func unsignedSimulatorBundleHasAnImmutablePrivateCopyAndDistinctRouteValidation() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-simulator") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "SimulatorDemo", platform: "iPhoneSimulator") + let originalInfo = try Data(contentsOf: app.appendingPathComponent("Info.plist")) + let prepared = try PreparedAppPackage.prepare(app) + defer { removeTestPackage(prepared) } + + #expect(prepared.originalURL == app) + #expect(prepared.packageURL != app) + #expect(prepared.packageURL.lastPathComponent == app.lastPathComponent) + #expect(prepared.inspection == nil) + #expect(prepared.appName == "SimulatorDemo") + #expect(prepared.supportedPlatforms == ["iPhoneSimulator"]) + #expect((try FileManager.default.attributesOfItem(atPath: prepared.directory.path)[.posixPermissions] as? NSNumber)?.intValue == 0o700) + try prepared.revalidate() + try prepared.validate(for: target(.simulator), supportsCoreDevice: false, simulatorBooted: true) + #expect(throws: ToolkitError.self) { try prepared.validate(for: target(.simulator), supportsCoreDevice: true, simulatorBooted: false) } + #expect(throws: ToolkitError.self) { try prepared.validate(for: target(.physical), supportsCoreDevice: true, simulatorBooted: true) } + #expect(throws: ToolkitError.self) { try prepared.validate(for: target(.demo), supportsCoreDevice: true, simulatorBooted: true) } + #expect(try Data(contentsOf: app.appendingPathComponent("Info.plist")) == originalInfo) + } + + private enum ChangedLocation: String, CaseIterable { + case original, prepared + } + + @Test(arguments: ChangedLocation.allCases) + private func bundleReplacementAtEitherLocationRequiresNewInspection(_ location: ChangedLocation) throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-app-change") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneSimulator") + let prepared = try PreparedAppPackage.prepare(app) + defer { removeTestPackage(prepared) } + let changed = location == .original ? app : prepared.packageURL + try Data("different resource".utf8).write(to: changed.appendingPathComponent("resource.txt")) + #expect(throws: ToolkitError.self) { try prepared.revalidate() } + let unchanged = location == .original ? prepared.packageURL : app + #expect(try Data(contentsOf: unchanged.appendingPathComponent("resource.txt")) == Data("resource".utf8)) + } + + @Test func bundleAddedFilesAndExecutablePermissionChangesAreDetected() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-app-mode") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneSimulator") + let prepared = try PreparedAppPackage.prepare(app) + defer { removeTestPackage(prepared) } + try SecureFileIO.writeNewFile(Data("new".utf8), to: prepared.packageURL.appendingPathComponent("new.txt")) + #expect(throws: ToolkitError.self) { try prepared.revalidate() } + try FileManager.default.removeItem(at: prepared.packageURL.appendingPathComponent("new.txt")) + try prepared.revalidate() + try FileManager.default.setAttributes([.posixPermissions: NSNumber(value: 0o600)], ofItemAtPath: app.appendingPathComponent("Demo").path) + #expect(throws: ToolkitError.self) { try prepared.revalidate() } + } + + @Test(.enabled(if: FileManager.default.isExecutableFile(atPath: "/usr/bin/codesign"))) + func physicalAppRequiresCoreDeviceAndAnIntactSignature() async throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-physical") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "DeviceDemo", platform: "iPhoneOS") + let unsigned = try PreparedAppPackage.prepare(app) + defer { removeTestPackage(unsigned) } + #expect(throws: ToolkitError.self) { try unsigned.validate(for: target(.physical), supportsCoreDevice: true, simulatorBooted: true) } + try await sign(app, in: root) + let signed = try PreparedAppPackage.prepare(app) + defer { removeTestPackage(signed) } + #expect(signed.signature?.status == .valid) + try signed.validate(for: target(.physical), supportsCoreDevice: true, simulatorBooted: false) + #expect(throws: ToolkitError.self) { try signed.validate(for: target(.physical), supportsCoreDevice: false, simulatorBooted: false) } + #expect(throws: ToolkitError.self) { try signed.validate(for: target(.simulator), supportsCoreDevice: true, simulatorBooted: true) } + try Data("tampered".utf8).write(to: signed.packageURL.appendingPathComponent("resource.txt")) + #expect(throws: ToolkitError.self) { try signed.revalidate() } + } + + @Test func unsignedIPAStaysInspectableButCannotAuthorizeInstallation() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-unsigned-ipa") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneOS") + let ipa = try makeIPA(app, in: root, name: "Unsigned.ipa") + let prepared = try PreparedAppPackage.prepare(ipa) + defer { removeTestPackage(prepared) } + let inspection = try #require(prepared.inspection) + #expect(!inspection.isInstallable) + #expect(inspection.packagePath == prepared.packageURL.path) + #expect(inspection.appName == "Demo") + #expect(throws: ToolkitError.self) { try prepared.validate(for: target(.physical), supportsCoreDevice: false, simulatorBooted: false) } + #expect(throws: ToolkitError.self) { try prepared.validate(for: target(.simulator), supportsCoreDevice: true, simulatorBooted: true) } + try prepared.revalidate() + try Data("replaced IPA".utf8).write(to: ipa) + #expect(throws: ToolkitError.self) { try prepared.revalidate() } + #expect(try Data(contentsOf: prepared.packageURL) != Data("replaced IPA".utf8)) + } + + @Test(.enabled(if: FileManager.default.isExecutableFile(atPath: "/usr/bin/codesign"))) + func signedIPAUsesTheApprovedCopyAndRetainsNativePhysicalInstallation() async throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-signed-ipa") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneOS") + try await sign(app, in: root) + let ipa = try makeIPA(app, in: root, name: "Signed.ipa") + let prepared = try PreparedAppPackage.prepare(ipa) + defer { removeTestPackage(prepared) } + #expect(prepared.inspection?.isInstallable == true) + try prepared.revalidate() + try prepared.validate(for: target(.physical), supportsCoreDevice: false, simulatorBooted: false) + try prepared.validate(for: target(.physical), supportsCoreDevice: true, simulatorBooted: false) + #expect(throws: ToolkitError.self) { try prepared.validate(for: target(.simulator), supportsCoreDevice: true, simulatorBooted: true) } + try Data("tampered copy".utf8).write(to: prepared.packageURL) + #expect(throws: ToolkitError.self) { try prepared.revalidate() } + } + + @Test func rejectsMalformedPackagesAndTypedBundleMetadata() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-malformed") + defer { removeTestItem(root) } + let malformedIPA = root.appendingPathComponent("Malformed.ipa") + try SecureFileIO.writeNewFile(Data("not ZIP".utf8), to: malformedIPA) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(malformedIPA) } + let unsupported = root.appendingPathComponent("Unsupported.zip") + try SecureFileIO.writeNewFile(Data(), to: unsupported) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(unsupported) } + let app = try makeApp(root, name: "Bad", platform: "iPhoneSimulator") + var info = try #require(infoPlist(name: "Bad", platform: "iPhoneSimulator").dictionaryValue) + info["CFBundleSupportedPlatforms"] = .array([.string("iPhoneSimulator"), .integer(1)]) + try PlistValue.dictionary(info).encoded(format: .xml).write(to: app.appendingPathComponent("Info.plist")) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(app) } + let malformedPlatformIPA = try makeIPA(app, in: root, name: "MalformedPlatform.ipa") + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(malformedPlatformIPA) } + info["CFBundleSupportedPlatforms"] = .array([.string("iPhoneSimulator")]) + info["CFBundleExecutable"] = .string("../outside") + try PlistValue.dictionary(info).encoded(format: .xml).write(to: app.appendingPathComponent("Info.plist")) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(app) } + try Data(repeating: 0x41, count: Int(IPAInspector.maximumInfoPlistBytes) + 1).write(to: app.appendingPathComponent("Info.plist")) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(app) } + } + + @Test func preservesSafeInternalFrameworkLinksWithoutFollowingThem() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-framework") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneSimulator") + let framework = app.appendingPathComponent("Frameworks/Demo.framework") + try SecureFileIO.createPrivateDirectory(at: framework.appendingPathComponent("Versions/A")) + try SecureFileIO.writeNewFile(Data("framework resource".utf8), to: framework.appendingPathComponent("Versions/A/value.txt")) + try FileManager.default.createSymbolicLink(atPath: framework.appendingPathComponent("Versions/Current").path, withDestinationPath: "A") + try FileManager.default.createSymbolicLink(atPath: framework.appendingPathComponent("value.txt").path, withDestinationPath: "Versions/Current/value.txt") + let prepared = try PreparedAppPackage.prepare(app) + defer { removeTestPackage(prepared) } + let copied = prepared.packageURL.appendingPathComponent("Frameworks/Demo.framework") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: copied.appendingPathComponent("Versions/Current").path) == "A") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: copied.appendingPathComponent("value.txt").path) == "Versions/Current/value.txt") + #expect(try Data(contentsOf: copied.appendingPathComponent("value.txt")) == Data("framework resource".utf8)) + try prepared.revalidate() + try prepared.validate(for: target(.simulator), supportsCoreDevice: false, simulatorBooted: true) + } + + @Test(arguments: ["../secret.txt", "/etc/passwd", "missing.txt", "link"]) + func deniesEscapingAbsoluteBrokenAndCyclicBundleLinks(_ destination: String) throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-unsafe-link") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneSimulator") + let secret = root.appendingPathComponent("secret.txt") + try SecureFileIO.writeNewFile(Data("task-owned confidential test bytes".utf8), to: secret) + try FileManager.default.createSymbolicLink(atPath: app.appendingPathComponent("link").path, withDestinationPath: destination) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(app) } + #expect(try Data(contentsOf: secret) == Data("task-owned confidential test bytes".utf8)) + } + + @Test func rootSymbolicLinksAreDeniedAndCleanupDoesNotDeleteSource() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-root-link") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneSimulator") + let linked = root.appendingPathComponent("Linked.app") + try FileManager.default.createSymbolicLink(at: linked, withDestinationURL: app) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(linked) } + let prepared = try PreparedAppPackage.prepare(app) + try prepared.remove() + #expect(!FileManager.default.fileExists(atPath: prepared.directory.path)) + #expect(FileManager.default.fileExists(atPath: app.appendingPathComponent("Demo").path)) + } + + private func removeTestItem(_ url: URL) { + do { try FileManager.default.removeItem(at: url) } + catch { Issue.record("Could not remove the task-owned package fixture: \(error.localizedDescription)") } + } + + private func removeTestPackage(_ package: PreparedAppPackage) { + do { try package.remove() } + catch { Issue.record("Could not remove the prepared package fixture: \(error.localizedDescription)") } + } + + private func infoPlist(name: String, platform: String) -> PlistValue { + .dictionary([ + "CFBundleIdentifier": .string("com.example.preparation"), + "CFBundleName": .string(name), + "CFBundleShortVersionString": .string("1.0"), + "CFBundleVersion": .string("1"), + "CFBundleExecutable": .string(name), + "CFBundleSupportedPlatforms": .array([.string(platform)]), + ]) + } + + private func makeApp(_ root: URL, name: String, platform: String) throws -> URL { + let app = root.appendingPathComponent("\(name).app") + try SecureFileIO.createPrivateDirectory(at: app) + try SecureFileIO.writeNewFile(try infoPlist(name: name, platform: platform).encoded(format: .xml), to: app.appendingPathComponent("Info.plist")) + try FileManager.default.copyItem(atPath: "/bin/echo", toPath: app.appendingPathComponent(name).path) + try SecureFileIO.writeNewFile(Data("resource".utf8), to: app.appendingPathComponent("resource.txt")) + return app + } + + private func makeIPA(_ app: URL, in root: URL, name: String) throws -> URL { + var writer = ZipWriter(date: Date(timeIntervalSince1970: 0)) + let appRoot = app.resolvingSymlinksInPath() + let enumerator = try #require(FileManager.default.enumerator(at: appRoot, includingPropertiesForKeys: [.isRegularFileKey], options: [], errorHandler: nil)) + for case let enumeratedURL as URL in enumerator { + let file = enumeratedURL.resolvingSymlinksInPath() + guard try file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else { continue } + guard file.path.hasPrefix(appRoot.path + "/") else { throw ToolkitError.invalidInput("The task-owned IPA fixture path escaped the app root.") } + let path = "Payload/\(app.lastPathComponent)/" + file.path.dropFirst(appRoot.path.count + 1) + let permissions = try #require(try FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? NSNumber) + try writer.add(name: path, data: Data(contentsOf: file), method: .stored, mode: permissions.uint32Value) + } + let url = root.appendingPathComponent(name) + try SecureFileIO.writeNewFile(writer.finalized(), to: url) + return url + } + + private func sign(_ app: URL, in root: URL) async throws { + let result = try await ProcessCommandRunner().run(CommandRequest(executable: URL(fileURLWithPath: "/usr/bin/codesign"), arguments: ["--force", "--sign", "-", app.path], workingDirectory: root, timeout: 60)) + #expect(result.succeeded, "\(result.standardErrorText)") + guard result.succeeded else { throw ToolkitError(.commandFailed, message: "Could not sign the task-owned test bundle.", recovery: nil, technicalDetail: result.technicalSummary) } + } + + private func target(_ kind: DeviceKind) -> DeviceTarget { + DeviceTarget(kind: kind, udid: "TASK-OWNED-TEST", name: "Synthetic Test Target", osVersion: "27.0", usbmuxDeviceID: nil, coreDeviceIdentifier: nil, transport: kind == .physical ? .usb : .local) + } +} diff --git a/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift b/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift index b8ca232..bc8e27f 100644 --- a/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift +++ b/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift @@ -112,8 +112,16 @@ struct RealSimulatorTests { // Install, list, launch, and remove a real (minimal) simulator app. step("building fixture app") let fixture = try await Self.buildFixtureApp(in: directory) - step("installing fixture app") - try await client.install(appAt: fixture, on: target) + let prepared = try PreparedAppPackage.prepare(fixture) + defer { + do { try prepared.remove() } + catch { Issue.record("Could not remove the prepared simulator fixture: \(error.localizedDescription)") } + } + #expect(prepared.packageURL != fixture) + try prepared.revalidate() + try prepared.validate(for: target, supportsCoreDevice: false, simulatorBooted: booted) + step("installing prepared fixture app") + try await client.install(appAt: prepared.packageURL, on: target) #expect(try await client.apps(target).contains { $0.bundleIdentifier == Self.fixtureBundleID }) let launched = try await client.launch(bundleIdentifier: Self.fixtureBundleID, on: target, terminateExisting: true) #expect(launched.contains(Self.fixtureBundleID)) From e1d35cbb52eb4b6b75e0126cef97f36c0f8237fe Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:03:31 -0700 Subject: [PATCH 2/3] test: make owned cancellation fixture signal responsive A blocking FIFO read can defer the shell TERM trap under full-suite scheduling. Wait on a task-owned child instead, then hold the signal handler at the FIFO barrier and reap the child after release. --- Tests/ToolkitCoreTests/CommandRunnerTests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/ToolkitCoreTests/CommandRunnerTests.swift b/Tests/ToolkitCoreTests/CommandRunnerTests.swift index c0506bf..8f18776 100644 --- a/Tests/ToolkitCoreTests/CommandRunnerTests.swift +++ b/Tests/ToolkitCoreTests/CommandRunnerTests.swift @@ -102,9 +102,10 @@ struct CommandRunnerTests { // The child acknowledges SIGTERM but waits for a test-controlled release before exit. // A FIFO makes each assertion depend on a real process state rather than a delay. let script = """ - trap 'printf "cancel received\\n"; IFS= read -r release < "$1"; printf "after release\\n"; exit 0' TERM + trap 'printf "cancel received\\n"; IFS= read -r release < "$1"; kill "$child" 2>/dev/null; wait "$child" 2>/dev/null; printf "after release\\n"; exit 0' TERM + sleep 30 & child=$! printf 'ready:%s\\n' "$$" - IFS= read -r hold < "$1" + wait "$child" exit 1 """ let request = CommandRequest(executable: URL(fileURLWithPath: "/bin/sh"), arguments: ["-c", script, "owned-command", barrier.path], From 681bc3e112cc0ffb4190f2be2f5bde29cc9ad387 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:30:48 -0700 Subject: [PATCH 3/3] fix: keep signature verification off cooperative workers Run asynchronous package preparation and IPA inspection on a dedicated Dispatch queue while preserving strict nested signature validation. Carry cancellation through preparation checkpoints and clean an owned result before reporting late cancellation. Exercise concurrent signed inspection and the pre-cancelled preparation path. --- .../Model/FeatureModels.swift | 5 +- .../ToolkitFeatures/IPA/IPAInspector.swift | 9 +++ .../IPA/PreparedAppPackage.swift | 57 +++++++++++++------ .../IPA/SigningInspection.swift | 29 ++++++++++ Sources/idt/IDT.swift | 2 +- .../AppPackagePreparationTests.swift | 31 +++++++++- .../IPAInspectorTests.swift | 4 +- .../RealSimulatorTests.swift | 2 +- 8 files changed, 112 insertions(+), 27 deletions(-) diff --git a/App/iOSDeveloperToolkit/Model/FeatureModels.swift b/App/iOSDeveloperToolkit/Model/FeatureModels.swift index 0e7f32e..db3e67c 100644 --- a/App/iOSDeveloperToolkit/Model/FeatureModels.swift +++ b/App/iOSDeveloperToolkit/Model/FeatureModels.swift @@ -176,10 +176,7 @@ final class InstallModel { resumeCleanupIfIdle() } let result = await app.run("Inspect \(url.lastPathComponent)", workspace: .installApp, target: nil, transport: "Local inspection (Security.framework)", outputPaths: [url.path], onStart: { self.inspectionOperation = $0 }) { _ in - let preparation = Task.detached(priority: .userInitiated) { try PreparedAppPackage.prepare(url) } - return try await withTaskCancellationHandler { - try await preparation.value - } onCancel: { preparation.cancel() } + try await PreparedAppPackage.prepareAsync(url) } guard selectionID == generation else { if let result { diff --git a/Sources/ToolkitFeatures/IPA/IPAInspector.swift b/Sources/ToolkitFeatures/IPA/IPAInspector.swift index ee20cd9..0cb3682 100644 --- a/Sources/ToolkitFeatures/IPA/IPAInspector.swift +++ b/Sources/ToolkitFeatures/IPA/IPAInspector.swift @@ -68,8 +68,17 @@ public struct IPAInspection: Sendable, Hashable, Codable { /// Inspects an `.ipa` package locally before any installation is offered. public enum IPAInspector { + /// Suspends the caller while blocking archive and native signature checks finish. Cancellation + /// waits for native validation and scratch cleanup before it is reported. + public static func inspectAsync(_ url: URL) async throws -> IPAInspection { + let inspection = try await PackageInspectionExecutor.run { _ in try inspect(url) } + try Task.checkCancellation() + return inspection + } + public static let maximumInfoPlistBytes: UInt64 = 10 * 1024 * 1024 + /// Blocking entry point for synchronous callers. Swift tasks should use `inspectAsync`. public static func inspect(_ url: URL) throws -> IPAInspection { guard url.pathExtension.lowercased() == "ipa" else { throw ToolkitError.invalidInput("Choose an .ipa package.") diff --git a/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift b/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift index 2f35967..03d23f1 100644 --- a/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift +++ b/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift @@ -21,19 +21,40 @@ public struct PreparedAppPackage: Sendable { private let directoryInode: ino_t private static let maximumBytes = ZipArchive.maximumTotalUncompressedBytes + /// Suspends during blocking package and signature work. A cancelled caller keeps ownership + /// until native validation finishes, then removes its private copy before receiving cancellation. + public static func prepareAsync(_ source: URL) async throws -> PreparedAppPackage { + let package = try await PackageInspectionExecutor.run { checkCancellation in + try prepare(source, checkCancellation: checkCancellation) + } + guard !Task.isCancelled else { + do { try package.remove() } + catch { + throw ToolkitError.fileSystem("Package inspection was stopped, but its private copy could not be removed.", path: package.directory.path, underlying: error) + } + throw CancellationError() + } + return package + } + + /// Blocking entry point for synchronous callers. Swift tasks should use `prepareAsync`. public static func prepare(_ source: URL) throws -> PreparedAppPackage { - try Task.checkCancellation() + try prepare(source, checkCancellation: { try Task.checkCancellation() }) + } + + private static func prepare(_ source: URL, checkCancellation: PackageInspectionExecutor.CancellationCheck) throws -> PreparedAppPackage { + try checkCancellation() guard source.isFileURL, ["ipa", "app"].contains(source.pathExtension.lowercased()) else { throw ToolkitError.invalidInput("Choose an .ipa package or an iOS .app bundle.") } let original = source.standardizedFileURL - let identity = try packageIdentity(original) + let identity = try packageIdentity(original, checkCancellation: checkCancellation) let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "idt-prepared-app") let copy = directory.appendingPathComponent(original.lastPathComponent) do { try FileManager.default.copyItem(at: original, to: copy) - try Task.checkCancellation() - guard try packageIdentity(original) == identity, try packageIdentity(copy) == identity else { + try checkCancellation() + guard try packageIdentity(original, checkCancellation: checkCancellation) == identity, try packageIdentity(copy, checkCancellation: checkCancellation) == identity else { throw ToolkitError(.invalidInput, message: "The package changed while it was being prepared.", recovery: "Choose the package again after its build or copy has finished.", technicalDetail: original.path) } let inspection: IPAInspection? @@ -47,13 +68,13 @@ public struct PreparedAppPackage: Sendable { platforms = try validatedPlatforms(inspected.supportedPlatforms) signature = inspected.signature } else { - let info = try bundleInfo(copy) + let info = try bundleInfo(copy, checkCancellation: checkCancellation) inspection = nil appName = info.name platforms = info.platforms signature = CodeSignatureInspector.inspect(bundle: copy) } - guard try packageIdentity(copy) == identity, try packageIdentity(original) == identity else { + guard try packageIdentity(copy, checkCancellation: checkCancellation) == identity, try packageIdentity(original, checkCancellation: checkCancellation) == identity else { throw ToolkitError(.invalidInput, message: "The package changed during inspection.", recovery: "Choose the completed package again.", technicalDetail: original.path) } let ownership = try metadata(directory) @@ -129,9 +150,9 @@ public struct PreparedAppPackage: Sendable { return values } - private static func bundleInfo(_ bundle: URL) throws -> (name: String, platforms: [String]) { + private static func bundleInfo(_ bundle: URL, checkCancellation: PackageInspectionExecutor.CancellationCheck) throws -> (name: String, platforms: [String]) { let descriptor = try openChild(root: bundle, relativePath: "Info.plist") - let data = try readFile(descriptor, maximumBytes: IPAInspector.maximumInfoPlistBytes) + let data = try readFile(descriptor, maximumBytes: IPAInspector.maximumInfoPlistBytes, checkCancellation: checkCancellation) let info = try PlistValue.decode(data) guard info.dictionaryValue != nil else { throw ToolkitError.invalidInput("The app's Info.plist is not a dictionary.") } func required(_ key: String) throws -> String { @@ -172,13 +193,17 @@ public struct PreparedAppPackage: Sendable { } private static func packageIdentity(_ url: URL) throws -> String { - try Task.checkCancellation() + try packageIdentity(url, checkCancellation: { try Task.checkCancellation() }) + } + + private static func packageIdentity(_ url: URL, checkCancellation: PackageInspectionExecutor.CancellationCheck) throws -> String { + try checkCancellation() let info = try metadata(url) if url.pathExtension.lowercased() == "ipa" { guard info.st_mode & S_IFMT == S_IFREG else { throw ToolkitError.invalidInput("The IPA package must be a regular file, not a symbolic link.") } let descriptor = open(url.path, O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC) guard descriptor >= 0 else { throw fileError("Could not open the IPA package safely.", url: url) } - return try hashFile(descriptor, url: url, maximumBytes: maximumBytes).hash + return try hashFile(descriptor, url: url, maximumBytes: maximumBytes, checkCancellation: checkCancellation).hash } guard info.st_mode & S_IFMT == S_IFDIR else { throw ToolkitError.invalidInput("The app bundle must be a directory, not a symbolic link.") } let root = url.resolvingSymlinksInPath() @@ -192,7 +217,7 @@ public struct PreparedAppPackage: Sendable { var entries = [TreeEntry(path: "", kind: "directory", permissions: info.st_mode & 0o7777, hash: nil, link: nil)] var totalBytes: UInt64 = 0 for case let enumeratedURL as URL in enumerator { - try Task.checkCancellation() + try checkCancellation() guard entries.count < ZipArchive.maximumEntries else { throw ToolkitError.invalidInput("The app bundle exceeds the package file-count limit.") } // FileManager may return /var URLs for a canonical /private/var root. Resolve only // the parent so a leaf symbolic link remains visible to lstat and the identity. @@ -204,7 +229,7 @@ public struct PreparedAppPackage: Sendable { switch kind { case S_IFREG: let descriptor = try openChild(root: root, relativePath: relativePath) - let file = try hashFile(descriptor, url: child, maximumBytes: maximumBytes - totalBytes) + let file = try hashFile(descriptor, url: child, maximumBytes: maximumBytes - totalBytes, checkCancellation: checkCancellation) totalBytes += file.bytes entries.append(TreeEntry(path: relativePath, kind: "file", permissions: fileMetadata.st_mode & 0o7777, hash: file.hash, link: nil)) case S_IFDIR: @@ -271,7 +296,7 @@ public struct PreparedAppPackage: Sendable { guard close(descriptor) == 0 else { throw fileError("Could not close an app-bundle descriptor.", url: root) } } - private static func hashFile(_ descriptor: Int32, url: URL, maximumBytes: UInt64) throws -> (hash: String, bytes: UInt64) { + private static func hashFile(_ descriptor: Int32, url: URL, maximumBytes: UInt64, checkCancellation: PackageInspectionExecutor.CancellationCheck) throws -> (hash: String, bytes: UInt64) { try withFileHandle(descriptor) { handle in var info = stat() guard fstat(descriptor, &info) == 0 else { throw fileError("Could not inspect the package file.", url: url) } @@ -281,7 +306,7 @@ public struct PreparedAppPackage: Sendable { var hasher = SHA256() var bytes: UInt64 = 0 while let data = try handle.read(upToCount: 1 << 20), !data.isEmpty { - try Task.checkCancellation() + try checkCancellation() guard UInt64(data.count) <= maximumBytes - bytes else { throw ToolkitError.invalidInput("The package changed size or exceeds the package preparation limit.") } bytes += UInt64(data.count) hasher.update(data: data) @@ -290,7 +315,7 @@ public struct PreparedAppPackage: Sendable { } } - private static func readFile(_ descriptor: Int32, maximumBytes: UInt64) throws -> Data { + private static func readFile(_ descriptor: Int32, maximumBytes: UInt64, checkCancellation: PackageInspectionExecutor.CancellationCheck) throws -> Data { try withFileHandle(descriptor) { handle in var info = stat() guard fstat(descriptor, &info) == 0 else { @@ -301,7 +326,7 @@ public struct PreparedAppPackage: Sendable { } var data = Data() while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty { - try Task.checkCancellation() + try checkCancellation() guard UInt64(chunk.count) <= maximumBytes - UInt64(data.count) else { throw ToolkitError.invalidInput("The app's Info.plist exceeds the 10 MB inspection limit.") } diff --git a/Sources/ToolkitFeatures/IPA/SigningInspection.swift b/Sources/ToolkitFeatures/IPA/SigningInspection.swift index a00966d..b049479 100644 --- a/Sources/ToolkitFeatures/IPA/SigningInspection.swift +++ b/Sources/ToolkitFeatures/IPA/SigningInspection.swift @@ -1,3 +1,4 @@ +import Dispatch import Foundation import Security import ToolkitCore @@ -164,3 +165,31 @@ public enum CodeSignatureInspector { } } } + +/// Security.framework can wait for Dispatch work while validating nested code. Keep package +/// inspection off Swift's cooperative executor and retain its worker until native work finishes. +enum PackageInspectionExecutor { + typealias CancellationCheck = @Sendable () throws -> Void + + private static let queue = DispatchQueue(label: "io.hideouts.package-inspection", qos: .userInitiated) + + static func run(_ operation: @escaping @Sendable (CancellationCheck) throws -> Value) async throws -> Value { + let cancelled = LockedValue(false) + let checkCancellation: CancellationCheck = { + if cancelled.current { throw CancellationError() } + } + return try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await withCheckedThrowingContinuation { continuation in + queue.async { + continuation.resume(with: Result { + try checkCancellation() + return try operation(checkCancellation) + }) + } + } + } onCancel: { + cancelled.withLock { $0 = true } + } + } +} diff --git a/Sources/idt/IDT.swift b/Sources/idt/IDT.swift index 102b51a..916453a 100644 --- a/Sources/idt/IDT.swift +++ b/Sources/idt/IDT.swift @@ -134,7 +134,7 @@ struct InspectIPA: AsyncParsableCommand { func run() async throws { do { - let inspection = try IPAInspector.inspect(URL(fileURLWithPath: (path as NSString).expandingTildeInPath)) + let inspection = try await IPAInspector.inspectAsync(URL(fileURLWithPath: (path as NSString).expandingTildeInPath)) if json { FileHandle.standardOutput.write(try JSONOutput.encode(inspection)) } else { diff --git a/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift b/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift index d4b8df1..ecb63e4 100644 --- a/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift +++ b/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift @@ -30,6 +30,29 @@ struct AppPackagePreparationTests { #expect(try Data(contentsOf: app.appendingPathComponent("Info.plist")) == originalInfo) } + @Test func cancelledAsyncPreparationDoesNotReturnAnOwnedPackage() async throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-cancelled") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneSimulator") + let observedCancellation = try await withThrowingTaskGroup(of: Bool.self) { group in + group.cancelAll() + group.addTask { + do { + let package = try await PreparedAppPackage.prepareAsync(app) + try package.remove() + return false + } catch is CancellationError { + return true + } + } + var cancelled = false + for try await result in group { cancelled = result } + return cancelled + } + #expect(observedCancellation) + #expect(FileManager.default.fileExists(atPath: app.appendingPathComponent("Demo").path)) + } + private enum ChangedLocation: String, CaseIterable { case original, prepared } @@ -67,11 +90,11 @@ struct AppPackagePreparationTests { let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-physical") defer { removeTestItem(root) } let app = try makeApp(root, name: "DeviceDemo", platform: "iPhoneOS") - let unsigned = try PreparedAppPackage.prepare(app) + let unsigned = try await PreparedAppPackage.prepareAsync(app) defer { removeTestPackage(unsigned) } #expect(throws: ToolkitError.self) { try unsigned.validate(for: target(.physical), supportsCoreDevice: true, simulatorBooted: true) } try await sign(app, in: root) - let signed = try PreparedAppPackage.prepare(app) + let signed = try await PreparedAppPackage.prepareAsync(app) defer { removeTestPackage(signed) } #expect(signed.signature?.status == .valid) try signed.validate(for: target(.physical), supportsCoreDevice: true, simulatorBooted: false) @@ -107,8 +130,10 @@ struct AppPackagePreparationTests { let app = try makeApp(root, name: "Demo", platform: "iPhoneOS") try await sign(app, in: root) let ipa = try makeIPA(app, in: root, name: "Signed.ipa") - let prepared = try PreparedAppPackage.prepare(ipa) + async let inspection = IPAInspector.inspectAsync(ipa) + let prepared = try await PreparedAppPackage.prepareAsync(ipa) defer { removeTestPackage(prepared) } + #expect(try await inspection.isInstallable) #expect(prepared.inspection?.isInstallable == true) try prepared.revalidate() try prepared.validate(for: target(.physical), supportsCoreDevice: false, simulatorBooted: false) diff --git a/Tests/ToolkitFeaturesTests/IPAInspectorTests.swift b/Tests/ToolkitFeaturesTests/IPAInspectorTests.swift index 20a8c84..78133bf 100644 --- a/Tests/ToolkitFeaturesTests/IPAInspectorTests.swift +++ b/Tests/ToolkitFeaturesTests/IPAInspectorTests.swift @@ -135,14 +135,14 @@ struct IPAInspectorTests { #expect(signing.succeeded, "\(signing.standardErrorText)") #expect(try await run("/usr/bin/zip", ["-qr", "signed.ipa", "Payload"], in: directory).succeeded) - let signed = try IPAInspector.inspect(directory.appendingPathComponent("signed.ipa")) + let signed = try await IPAInspector.inspectAsync(directory.appendingPathComponent("signed.ipa")) #expect(signed.signature.status == .valid, "\(signed.signature.detail)") #expect(signed.signature.identifier == "com.example.demo") #expect(signed.isInstallable) try Data("tampered".utf8).write(to: app.appendingPathComponent("resource.txt")) #expect(try await run("/usr/bin/zip", ["-qr", "tampered.ipa", "Payload"], in: directory).succeeded) - let tampered = try IPAInspector.inspect(directory.appendingPathComponent("tampered.ipa")) + let tampered = try await IPAInspector.inspectAsync(directory.appendingPathComponent("tampered.ipa")) #expect(tampered.signature.status == .invalid) #expect(!tampered.isInstallable) } diff --git a/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift b/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift index bc8e27f..e71e430 100644 --- a/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift +++ b/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift @@ -112,7 +112,7 @@ struct RealSimulatorTests { // Install, list, launch, and remove a real (minimal) simulator app. step("building fixture app") let fixture = try await Self.buildFixtureApp(in: directory) - let prepared = try PreparedAppPackage.prepare(fixture) + let prepared = try await PreparedAppPackage.prepareAsync(fixture) defer { do { try prepared.remove() } catch { Issue.record("Could not remove the prepared simulator fixture: \(error.localizedDescription)") }