From acd08d25af63b8b94671f24b9491615341822560 Mon Sep 17 00:00:00 2001 From: firejackson1 <98654507+firejackson1@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:07:51 -0500 Subject: [PATCH] signing: support tvOS and signing existing apps --- Documentation/xtool.docc/Signing.md | 32 ++++ Documentation/xtool.docc/xtool.md | 1 + ...operServicesAssignAppGroupsOperation.swift | 6 +- .../DeveloperServicesAddAppOperation.swift | 2 +- ...veloperServicesFetchProfileOperation.swift | 35 ++-- .../Integration/IntegratedInstaller.swift | 4 + Sources/XKit/Signer/SigningContext.swift | 13 +- Sources/XKit/Signer/SigningPlatform.swift | 29 ++++ .../Utilities/ProvisioningIdentifiers.swift | 5 + Sources/XToolSupport/SignCommand.swift | 32 ++++ Sources/XToolSupport/SigningPackage.swift | 140 +++++++++++++++ Sources/XToolSupport/XTool.swift | 1 + Tests/XToolTests/ProfilePlatformTests.swift | 121 +++++++++++++ .../ProvisioningIdentifiersTests.swift | 17 ++ Tests/XToolTests/SigningPackageTests.swift | 162 ++++++++++++++++++ Tests/XToolTests/SigningPlatformTests.swift | 27 +++ 16 files changed, 607 insertions(+), 20 deletions(-) create mode 100644 Documentation/xtool.docc/Signing.md create mode 100644 Sources/XKit/Signer/SigningPlatform.swift create mode 100644 Sources/XToolSupport/SignCommand.swift create mode 100644 Sources/XToolSupport/SigningPackage.swift create mode 100644 Tests/XToolTests/ProfilePlatformTests.swift create mode 100644 Tests/XToolTests/ProvisioningIdentifiersTests.swift create mode 100644 Tests/XToolTests/SigningPackageTests.swift create mode 100644 Tests/XToolTests/SigningPlatformTests.swift diff --git a/Documentation/xtool.docc/Signing.md b/Documentation/xtool.docc/Signing.md new file mode 100644 index 00000000..e08cb4c4 --- /dev/null +++ b/Documentation/xtool.docc/Signing.md @@ -0,0 +1,32 @@ +# Sign an existing app + +Create a development-signed copy of an iOS or tvOS device app without rebuilding it. + +## Overview + +`xtool sign` accepts an existing `.app` directory or `.ipa` archive. It stages a copy, provisions and signs it using your saved `xtool auth` account, and writes a new `.app` or `.ipa`. The input remains unchanged. No Apple SDK is required for this command. + +```bash +xtool sign Example.app --output Example-signed.ipa +xtool sign Example.ipa --output Example-signed.app --platform tvos +``` + +The output path must not exist, its parent directory must exist, and it must be outside the input app. IPA inputs must contain exactly one app directly inside `Payload`. + +The signing platform is detected from the app's `CFBundleSupportedPlatforms`, or `UIDeviceFamily` when platform metadata is absent. Use `--platform ios` or `--platform tvos` if neither is present. An explicit platform must agree with the app's metadata. Simulator and macOS apps are not supported. + +## Keep an existing bundle identifier + +By default, signing adds xtool's account-specific prefix to the app and extension bundle identifiers. To keep their exact existing identifiers, use: + +```bash +xtool sign Example.ipa --output Example-renewed.ipa --preserve-bundle-id +``` + +This also preserves identifiers that already start with `XTL-`. The identifiers must be available to the selected Apple Developer team. Keeping a bundle identifier does not transfer ownership of an app to another team. App-group identifiers still follow xtool's existing rewriting rules; this option does not promise continuity of shared containers or keychain data. + +## Devices and renewal + +The development profile includes compatible devices already registered with the account: Apple TVs for tvOS, and iPhones, iPads and iPods for iOS. Register the target device first using Xcode or the Apple Developer website, as supported by your account. Use `xtool ds devices list` to check registered devices. This command does not connect to, register or install on a device. + +Signing uses xtool's existing certificate management and may prompt before revoking a certificate. Revocation can affect other installed development apps. Profile expiration and account limits remain Apple's limits, including short-lived profiles for free accounts. Run the command again with a fresh output path when renewing, then install the result using a deployment tool that supports your device and OS version. diff --git a/Documentation/xtool.docc/xtool.md b/Documentation/xtool.docc/xtool.md index fea43522..5474bc9a 100644 --- a/Documentation/xtool.docc/xtool.md +++ b/Documentation/xtool.docc/xtool.md @@ -32,3 +32,4 @@ xtool is a cross-platform (Linux/WSL/macOS) tool that replicates Xcode functiona - - +- diff --git a/Sources/XKit/DeveloperServices/App Groups/DeveloperServicesAssignAppGroupsOperation.swift b/Sources/XKit/DeveloperServices/App Groups/DeveloperServicesAssignAppGroupsOperation.swift index 5516bd10..402223bb 100644 --- a/Sources/XKit/DeveloperServices/App Groups/DeveloperServicesAssignAppGroupsOperation.swift +++ b/Sources/XKit/DeveloperServices/App Groups/DeveloperServicesAssignAppGroupsOperation.swift @@ -45,7 +45,7 @@ public struct DeveloperServicesAssignAppGroupsOperation: DeveloperServicesOperat let groupID = ProvisioningIdentifiers.groupID(fromSanitized: sanitized, context: context) let name = ProvisioningIdentifiers.groupName(fromSanitized: sanitized) let request = DeveloperServicesAddAppGroupRequest( - platform: .iOS, + platform: context.platform.developerServicesPlatform, teamID: xcodeAuthData.teamID, name: name, groupID: groupID @@ -55,7 +55,7 @@ public struct DeveloperServicesAssignAppGroupsOperation: DeveloperServicesOperat public func perform() async throws -> [DeveloperServicesAppGroup.GroupID] { let existing = try await client.send(DeveloperServicesListAppGroupsRequest( - platform: .iOS, teamID: xcodeAuthData.teamID + platform: context.platform.developerServicesPlatform, teamID: xcodeAuthData.teamID )) let sanitized = existing.map { (ProvisioningIdentifiers.sanitize(groupID: $0.groupID), $0) } let dict = Dictionary(sanitized, uniquingKeysWith: { $1 }) @@ -65,7 +65,7 @@ public struct DeveloperServicesAssignAppGroupsOperation: DeveloperServicesOperat _ = try await client.send( DeveloperServicesAssignAppGroupRequest( - platform: .iOS, + platform: context.platform.developerServicesPlatform, teamID: xcodeAuthData.teamID, appIDID: appID.id, groupIDs: groups.map(\.id) diff --git a/Sources/XKit/DeveloperServices/App IDs/DeveloperServicesAddAppOperation.swift b/Sources/XKit/DeveloperServices/App IDs/DeveloperServicesAddAppOperation.swift index d009ad39..6191e1d4 100644 --- a/Sources/XKit/DeveloperServices/App IDs/DeveloperServicesAddAppOperation.swift +++ b/Sources/XKit/DeveloperServices/App IDs/DeveloperServicesAddAppOperation.swift @@ -50,7 +50,7 @@ public struct DeveloperServicesAddAppOperation: DeveloperServicesOperation { entitlements: Entitlements, isFreeTeam: Bool ) async throws -> Components.Schemas.BundleId { - let newBundleID = ProvisioningIdentifiers.identifier(fromSanitized: bundleID, context: self.context) + let newBundleID = ProvisioningIdentifiers.bundleID(from: bundleID, context: self.context) let existing = try await context.developerAPIClient .bundleIdsGetCollection(query: .init( diff --git a/Sources/XKit/DeveloperServices/Profiles/DeveloperServicesFetchProfileOperation.swift b/Sources/XKit/DeveloperServices/Profiles/DeveloperServicesFetchProfileOperation.swift index c9ac8a3f..071f3d2d 100644 --- a/Sources/XKit/DeveloperServices/Profiles/DeveloperServicesFetchProfileOperation.swift +++ b/Sources/XKit/DeveloperServices/Profiles/DeveloperServicesFetchProfileOperation.swift @@ -31,7 +31,7 @@ public struct DeveloperServicesFetchProfileOperation: DeveloperServicesOperation let bundleIDs = try await context.developerAPIClient .bundleIdsGetCollection(query: .init( filter_lbrack_identifier_rbrack_: [bundleID], - fields_lbrack_profiles_rbrack_: [.bundleId], + fields_lbrack_profiles_rbrack_: [.profileType], include: [.profiles] )) .ok.body.json @@ -49,7 +49,14 @@ public struct DeveloperServicesFetchProfileOperation: DeveloperServicesOperation throw Errors.tooManyMatchingBundleIDs } - let profiles = bundleID.relationships?.profiles?.data ?? [] + let relatedIDs = Set((bundleID.relationships?.profiles?.data ?? []).map(\.id)) + let profiles = (bundleIDs.included ?? []).compactMap { resource -> Components.Schemas.Profile? in + guard case .profiles(let profile) = resource, + relatedIDs.contains(profile.id), + profile.attributes?.profileType?.value1?.rawValue == context.platform.developmentProfileType.rawValue + else { return nil } + return profile + } switch profiles.count { case 0: // we're good @@ -72,9 +79,16 @@ public struct DeveloperServicesFetchProfileOperation: DeveloperServicesOperation .reduce(into: [], +=) .filter { $0.attributes?.serialNumber.map(signingInfo.certificate.hasSerialNumber) == true } - let allDevices = try await context.developerAPIClient.devicesGetCollection() - .ok.body.json.data - .filter { $0.attributes?.deviceClass?.value1?.supportsIOSApps == true } + let allDevices = try await DeveloperAPIPages { + try await context.developerAPIClient.devicesGetCollection().ok.body.json + } next: { + $0.links.next + } + .map(\.data) + .reduce(into: [], +=) + .filter { device in + device.attributes?.deviceClass?.value1.map(context.platform.supports(deviceClass:)) == true + } let response = try await context.developerAPIClient.profilesCreateInstance( body: .json( @@ -83,7 +97,7 @@ public struct DeveloperServicesFetchProfileOperation: DeveloperServicesOperation _type: .profiles, attributes: .init( name: "XTL profile \(bundleID.id)", - profileType: .init(.iosAppDevelopment) + profileType: .init(context.platform.developmentProfileType) ), relationships: .init( bundleId: .init( @@ -110,12 +124,3 @@ public struct DeveloperServicesFetchProfileOperation: DeveloperServicesOperation } } - -extension Components.Schemas.Device.AttributesPayload.DeviceClassPayload.Value1Payload { - fileprivate var supportsIOSApps: Bool { - switch self { - case .ipad, .iphone, .ipod: true - default: false - } - } -} diff --git a/Sources/XKit/Integration/IntegratedInstaller.swift b/Sources/XKit/Integration/IntegratedInstaller.swift index ad070539..5fcc28ce 100644 --- a/Sources/XKit/Integration/IntegratedInstaller.swift +++ b/Sources/XKit/Integration/IntegratedInstaller.swift @@ -293,10 +293,14 @@ public actor IntegratedInstaller { public func signInPlace( app: URL, targetDevice: SigningContext.TargetDevice? = nil, + platform: SigningPlatform = .iOS, + preserveBundleID: Bool = false, ) async throws -> String { let context = SigningContext( auth: auth, targetDevice: targetDevice, + platform: platform, + preserveBundleID: preserveBundleID, ) let signer = AutoSigner(context: context) { certs in diff --git a/Sources/XKit/Signer/SigningContext.swift b/Sources/XKit/Signer/SigningContext.swift index 4400eccd..5134708a 100644 --- a/Sources/XKit/Signer/SigningContext.swift +++ b/Sources/XKit/Signer/SigningContext.swift @@ -14,10 +14,17 @@ public struct SigningContext: Sendable { public struct TargetDevice: Sendable, Hashable { public var udid: String public var name: String + + public init(udid: String, name: String) { + self.udid = udid + self.name = name + } } public var auth: DeveloperAPIAuthData public var targetDevice: TargetDevice? + public var platform: SigningPlatform + public var preserveBundleID: Bool public var developerAPIClient: DeveloperAPIClient { DeveloperAPIClient(auth: auth) @@ -25,10 +32,14 @@ public struct SigningContext: Sendable { public init( auth: DeveloperAPIAuthData, - targetDevice: TargetDevice? = nil + targetDevice: TargetDevice? = nil, + platform: SigningPlatform = .iOS, + preserveBundleID: Bool = false ) { self.auth = auth self.targetDevice = targetDevice + self.platform = platform + self.preserveBundleID = preserveBundleID } } diff --git a/Sources/XKit/Signer/SigningPlatform.swift b/Sources/XKit/Signer/SigningPlatform.swift new file mode 100644 index 00000000..03813316 --- /dev/null +++ b/Sources/XKit/Signer/SigningPlatform.swift @@ -0,0 +1,29 @@ +import DeveloperAPI + +/// The platform whose development provisioning profile will be requested. +public enum SigningPlatform: String, CaseIterable, Sendable { + case iOS = "ios" + case tvOS = "tvos" + + var developmentProfileType: + Components.Schemas.ProfileCreateRequest.DataPayload.AttributesPayload.ProfileTypePayload.Value1Payload { + switch self { + case .iOS: .iosAppDevelopment + case .tvOS: .tvosAppDevelopment + } + } + + var developerServicesPlatform: DeveloperServicesPlatform { + switch self { + case .iOS: .iOS + case .tvOS: .tvOS + } + } + + func supports(deviceClass: Components.Schemas.Device.AttributesPayload.DeviceClassPayload.Value1Payload) -> Bool { + switch (self, deviceClass) { + case (.iOS, .iphone), (.iOS, .ipad), (.iOS, .ipod), (.tvOS, .appleTv): true + default: false + } + } +} diff --git a/Sources/XKit/Utilities/ProvisioningIdentifiers.swift b/Sources/XKit/Utilities/ProvisioningIdentifiers.swift index 7f180a93..6c57b72d 100644 --- a/Sources/XKit/Utilities/ProvisioningIdentifiers.swift +++ b/Sources/XKit/Utilities/ProvisioningIdentifiers.swift @@ -26,6 +26,11 @@ extension ProvisioningIdentifiers { return "\(Self.idPrefix)\(uuid).\(sanitized)" } + static func bundleID(from original: String, context: SigningContext) -> String { + if context.preserveBundleID { return original } + return identifier(fromSanitized: original, context: context) + } + static func safeify(identifier: String) -> String { identifier.replacingOccurrences(of: ".", with: " ") } diff --git a/Sources/XToolSupport/SignCommand.swift b/Sources/XToolSupport/SignCommand.swift new file mode 100644 index 00000000..9a23ee19 --- /dev/null +++ b/Sources/XToolSupport/SignCommand.swift @@ -0,0 +1,32 @@ +import Foundation +import ArgumentParser +import XKit +import XUtils + +extension SigningPlatform: ExpressibleByArgument {} + +struct SignCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "sign", + abstract: "Sign an existing iOS or tvOS app without building or installing it" + ) + + @Argument(help: "The .app directory or .ipa archive to sign") var path: String + @Option(name: [.short, .long], help: "A new .app or .ipa output path (must not exist)") var output: String + @Option(help: "Signing platform: ios or tvos (default: detect from the app)") var platform: SigningPlatform? + @Flag(help: "Keep the existing bundle identifiers instead of adding the xtool prefix") var preserveBundleID = false + + func run() async throws { + let output = URL(fileURLWithPath: output) + let package = try await SigningPackage(input: URL(fileURLWithPath: path), output: output, platform: platform) + // Validate and stage before loading credentials or making any provisioning requests. + let token = try AuthToken.saved() + let delegate = XToolInstallerDelegate() + let installer = IntegratedInstaller(auth: token.authData(), delegate: delegate) + let bundleID = try await installer.signInPlace( + app: package.app, platform: package.platform, preserveBundleID: preserveBundleID + ) + try await package.export(to: output) + d.out.print("\nSigned \(bundleID): \(output.path)") + } +} diff --git a/Sources/XToolSupport/SigningPackage.swift b/Sources/XToolSupport/SigningPackage.swift new file mode 100644 index 00000000..38db1d09 --- /dev/null +++ b/Sources/XToolSupport/SigningPackage.swift @@ -0,0 +1,140 @@ +import Foundation +import Dependencies +import XKit +import XUtils + +/// Owns the disposable copy used for signing. The caller's input is never modified. +struct SigningPackage: ~Copyable { + private let directory: TemporaryDirectory + let app: URL + let platform: SigningPlatform + + init(input: URL, output: URL, platform requestedPlatform: SigningPlatform?) async throws { + let input = input.standardizedFileURL.resolvingSymlinksInPath() + let output = output.standardizedFileURL.resolvingSymlinksInPath() + try Self.validateOutput(output) + guard output != input, !output.path.hasPrefix(input.path + "/") else { + throw PackageError("Output must be outside the input app.") + } + + let directory = try TemporaryDirectory(name: "signing") + let payload = directory.url.appendingPathComponent("Payload") + switch input.pathExtension.lowercased() { + case "app": + try FileManager.default.createDirectory(at: payload, withIntermediateDirectories: false) + try FileManager.default.copyItem(at: input, to: payload.appendingPathComponent(input.lastPathComponent)) + case "ipa": + @Dependency(\.zipCompressor) var compressor + try await compressor.decompress(file: input, in: directory.url, progress: { _ in }) + default: + throw PackageError("Input must be an .app directory or .ipa archive.") + } + try Self.validateSymlinks(in: directory.url) + + let apps = try FileManager.default.contentsOfDirectory(at: payload, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "app" } + guard apps.count == 1, let app = apps.first else { + throw PackageError("Expected exactly one app in Payload.") + } + guard app.resolvingSymlinksInPath().path.hasPrefix(payload.path + "/") else { + throw PackageError("The app must be contained in Payload.") + } + let info = try Data(contentsOf: app.appendingPathComponent("Info.plist")) + guard let plist = try PropertyListSerialization.propertyList(from: info, format: nil) as? [String: Any], + let identifier = plist["CFBundleIdentifier"] as? String, !identifier.isEmpty, + let executable = plist["CFBundleExecutable"] as? String, !executable.isEmpty, + executable != ".", executable != "..", !executable.contains("/") + else { throw PackageError("The app needs a valid bundle identifier and executable in Info.plist.") } + let executableURL = app.appendingPathComponent(executable).resolvingSymlinksInPath() + guard executableURL.path.hasPrefix(app.path + "/"), FileManager.default.fileExists(atPath: executableURL.path) else { + throw PackageError("The app executable is missing or outside the app.") + } + self.platform = try Self.detectPlatform(plist: plist, requested: requestedPlatform) + self.app = app + self.directory = directory + } + + private static func detectPlatform(plist: [String: Any], requested: SigningPlatform?) throws -> SigningPlatform { + var platformMetadata: SigningPlatform? + if let value = plist["CFBundleSupportedPlatforms"] { + guard let platforms = value as? [String], !platforms.isEmpty else { + throw PackageError("Invalid CFBundleSupportedPlatforms metadata.") + } + switch platforms { + case ["AppleTVOS"]: platformMetadata = .tvOS + case ["iPhoneOS"]: platformMetadata = .iOS + default: throw PackageError("Only iOS and tvOS device apps can be signed.") + } + } + var familyMetadata: SigningPlatform? + if let value = plist["UIDeviceFamily"] { + guard let families = value as? [Int], !families.isEmpty else { + throw PackageError("Invalid UIDeviceFamily metadata.") + } + if families == [3] { + familyMetadata = .tvOS + } else if families.allSatisfy({ [1, 2].contains($0) }) { + familyMetadata = .iOS + } else { + throw PackageError("Unsupported device family.") + } + } + if let platformMetadata, let familyMetadata, platformMetadata != familyMetadata { + throw PackageError("The app's platform and device family do not agree.") + } + let detected = platformMetadata ?? familyMetadata + if let detected, let requested, detected != requested { + throw PackageError("The requested platform does not match the app.") + } + guard let platform = detected ?? requested else { + throw PackageError("Cannot determine the platform. Specify --platform ios or --platform tvos.") + } + return platform + } + + private static func validateSymlinks(in directory: URL) throws { + guard let entries = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: [.isSymbolicLinkKey]) + else { throw PackageError("Cannot inspect the staged app.") } + let root = directory.resolvingSymlinksInPath().path + for case let entry as URL in entries { + if try entry.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink == true { + let target = entry.resolvingSymlinksInPath().path + guard target == root || target.hasPrefix(root + "/") else { + throw PackageError("App symlinks must stay inside the staged copy: \(entry.lastPathComponent)") + } + } + } + } + + private static func validateOutput(_ output: URL) throws { + guard ["app", "ipa"].contains(output.pathExtension.lowercased()) else { + throw PackageError("Output must have an .app or .ipa extension.") + } + guard !FileManager.default.fileExists(atPath: output.path) else { + throw PackageError("Output already exists: \(output.path)") + } + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: output.deletingLastPathComponent().path, isDirectory: &isDirectory), + isDirectory.boolValue else { + throw PackageError("The output's parent directory must exist.") + } + } + + borrowing func export(to output: URL) async throws { + try Self.validateOutput(output) + let artifact: URL + if output.pathExtension.lowercased() == "app" { + artifact = app + } else { + @Dependency(\.zipCompressor) var compressor + artifact = try await compressor.compress(directory: app.deletingLastPathComponent(), progress: { _ in }) + } + // moveItem refuses to replace an existing destination, including one created during signing. + try FileManager.default.moveItem(at: artifact, to: output) + } + + struct PackageError: LocalizedError { + let errorDescription: String? + init(_ description: String) { errorDescription = description } + } +} diff --git a/Sources/XToolSupport/XTool.swift b/Sources/XToolSupport/XTool.swift index 4b4cbeb3..81b76caa 100644 --- a/Sources/XToolSupport/XTool.swift +++ b/Sources/XToolSupport/XTool.swift @@ -32,6 +32,7 @@ private struct XToolCommand: AsyncParsableCommand { NewCommand.self, DevCommand.self, DSCommand.self, + SignCommand.self, ] ), CommandGroup( diff --git a/Tests/XToolTests/ProfilePlatformTests.swift b/Tests/XToolTests/ProfilePlatformTests.swift new file mode 100644 index 00000000..c309d73e --- /dev/null +++ b/Tests/XToolTests/ProfilePlatformTests.swift @@ -0,0 +1,121 @@ +import Foundation +import Testing +import Crypto +import X509 +import Dependencies +import OpenAPIRuntime +@testable import XKit + +@Test(arguments: [SigningPlatform.iOS, .tvOS]) +func profileRequestsUsePlatformAndAllDevicePages(platform: SigningPlatform) async throws { + let client = ProfileTestClient(existingProfileType: "TVOS_APP_STORE") + try await exerciseProfileRequest(platform: platform, client: client) + let body = try #require(await client.createdBody) + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] + let data = try #require(json?["data"] as? [String: Any]) + let attributes = try #require(data["attributes"] as? [String: Any]) + #expect(attributes["profileType"] as? String == platform.developmentProfileType.rawValue) + let relationships = try #require(data["relationships"] as? [String: Any]) + let devices = try #require(relationships["devices"] as? [String: Any]) + let ids = try #require(devices["data"] as? [[String: String]]).compactMap { $0["id"] } + #expect(ids == (platform == .tvOS ? ["tv-1", "tv-2"] : ["phone"])) + #expect(await client.deletedIDs.isEmpty) +} + +@Test func renewalReplacesOnlyMatchingDevelopmentProfile() async throws { + let client = ProfileTestClient(existingProfileType: "TVOS_APP_DEVELOPMENT") + try await exerciseProfileRequest(platform: .tvOS, client: client) + #expect(await client.deletedIDs == ["existing"]) +} + +private func exerciseProfileRequest(platform: SigningPlatform, client: ProfileTestClient) async throws { + let key = P256.Signing.PrivateKey() + let name = try DistinguishedName { CommonName("xtool tests") } + let certificate = try X509.Certificate( + version: .v3, serialNumber: .init(bytes: [1]), publicKey: .init(key.publicKey), + notValidBefore: Date(), notValidAfter: Date() + 3600, issuer: name, subject: name, + signatureAlgorithm: .ecdsaWithSHA256, extensions: .init(), issuerPrivateKey: .init(key) + ) + let auth = DeveloperAPIAuthData.appStoreConnect(.init(id: "TEST", issuerID: "TEST", pem: key.pemRepresentation)) + try await withDependencies { + $0.httpClient = client + } operation: { + do { + _ = try await DeveloperServicesFetchProfileOperation( + context: .init(auth: auth, platform: platform), bundleID: "com.example.app", + signingInfo: .init(privateKey: .init(data: Data(key.pemRepresentation.utf8)), + certificate: .init(raw: certificate)) + ).perform() + Issue.record("The mock profile content must be rejected") + } catch DeveloperServicesFetchProfileOperation.Errors.invalidProfileData { + // The request completed; the mock deliberately does not contain an Apple-signed profile. + } + } +} + +private actor ProfileTestClient: HTTPClientProtocol, ClientTransport { + nonisolated var asOpenAPITransport: ClientTransport { self } + let existingProfileType: String + var createdBody: Data? + var deletedIDs: [String] = [] + + init(existingProfileType: String) { self.existingProfileType = existingProfileType } + + func send(_ request: HTTPRequest, body: HTTPBody?, baseURL: URL, + operationID: String) async throws -> (HTTPResponse, HTTPBody?) { + let json: String + var status = HTTPResponse.Status.ok + switch operationID { + case "bundleIds_getCollection": + json = """ + {"data":[{"type":"bundleIds","id":"app","attributes":{"identifier":"com.example.app"}, + "relationships":{"profiles":{"data":[{"type":"profiles","id":"existing"}]}}}], + "included":[{"type":"profiles","id":"existing","attributes":{"profileType":"\(existingProfileType)"}}], + "links":{"self":"https://example.test/bundleIds"}} + """ + case "certificates_getCollection": + json = """ + {"data":[{"type":"certificates","id":"cert","attributes":{"serialNumber":"01"}}], + "links":{"self":"https://example.test/certificates"}} + """ + case "devices_getCollection": + if request.path?.contains("cursor=") == true { + json = """ + {"data":[{"type":"devices","id":"tv-2","attributes":{"deviceClass":"APPLE_TV"}}], + "links":{"self":"https://example.test/devices"}} + """ + } else { + json = """ + {"data":[{"type":"devices","id":"phone","attributes":{"deviceClass":"IPHONE"}}, + {"type":"devices","id":"tv-1","attributes":{"deviceClass":"APPLE_TV"}}, + {"type":"devices","id":"future","attributes":{"deviceClass":"UNKNOWN_FUTURE_DEVICE"}}], + "links":{"self":"https://example.test/devices", + "next":"https://api.appstoreconnect.apple.com/v1/devices?cursor=next&limit=1"}} + """ + } + case "profiles_deleteInstance": + deletedIDs.append("existing") + return (HTTPResponse(status: .noContent), nil) + case "profiles_createInstance": + if let body { createdBody = try await Data(collecting: body, upTo: 1_000_000) } + status = .created + json = """ + {"data":{"type":"profiles","id":"new","attributes":{"profileContent":"!"}}, + "links":{"self":"https://example.test/profiles/new"}} + """ + default: + throw MockError.unexpectedRequest(operationID) + } + return (HTTPResponse(status: status, headerFields: [.contentType: "application/json"]), HTTPBody(Data(json.utf8))) + } + + nonisolated func withEphemeralClient(perform: (any HTTPClientProtocol) async throws -> T) async throws -> T { + try await perform(self) + } + + nonisolated func makeWebSocket(url: URL) async throws -> any WebSocketSession { + throw MockError.unexpectedRequest("websocket") + } + + enum MockError: Error { case unexpectedRequest(String) } +} diff --git a/Tests/XToolTests/ProvisioningIdentifiersTests.swift b/Tests/XToolTests/ProvisioningIdentifiersTests.swift new file mode 100644 index 00000000..52037d9c --- /dev/null +++ b/Tests/XToolTests/ProvisioningIdentifiersTests.swift @@ -0,0 +1,17 @@ +import Testing +@testable import XKit + +private let identifierTestAuth = DeveloperAPIAuthData.appStoreConnect( + .init(id: "TEST", issuerID: "01234567-89AB-CDEF-0123-456789ABCDEF", pem: "unused") +) + +@Test(arguments: ["com.example.app", "com.example.app.extension", "XTL-OLD.com.example.app"]) +func preservesExactExistingBundleIdentifiers(identifier: String) { + let context = SigningContext(auth: identifierTestAuth, preserveBundleID: true) + #expect(ProvisioningIdentifiers.bundleID(from: identifier, context: context) == identifier) +} + +@Test func defaultSigningRetainsScopedBundleIdentifier() { + let context = SigningContext(auth: identifierTestAuth) + #expect(ProvisioningIdentifiers.bundleID(from: "com.example.app", context: context) == "XTL-01234567.com.example.app") +} diff --git a/Tests/XToolTests/SigningPackageTests.swift b/Tests/XToolTests/SigningPackageTests.swift new file mode 100644 index 00000000..4b09cdb9 --- /dev/null +++ b/Tests/XToolTests/SigningPackageTests.swift @@ -0,0 +1,162 @@ +import Foundation +import Testing +import Dependencies +import XKit +@testable import XToolSupport + +@Test func signingStagesCopyAndPublishesWithoutChangingInput() async throws { + let root = try signingFixtureDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let app = try makeSigningFixture(in: root, platforms: ["AppleTVOS"]) + let output = root.appendingPathComponent("Signed.app") + let package = try await SigningPackage(input: app, output: output, platform: nil) + #expect(package.platform == .tvOS) + try Data("signed".utf8).write(to: package.app.appendingPathComponent("Example")) + try await package.export(to: output) + #expect(try String(contentsOf: app.appendingPathComponent("Example"), encoding: .utf8) == "original") + #expect(try String(contentsOf: output.appendingPathComponent("Example"), encoding: .utf8) == "signed") +} + +@Test(arguments: [["MacOSX"], ["AppleTVSimulator"], ["iPhoneSimulator"], ["iPhoneOS", "AppleTVOS"]]) +func signingRejectsUnsupportedPlatformBeforeProvisioning(platforms: [String]) async throws { + let root = try signingFixtureDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let app = try makeSigningFixture(in: root, platforms: platforms) + await #expect(throws: (any Error).self) { + _ = try await SigningPackage(input: app, output: root.appendingPathComponent("out.ipa"), platform: nil) + } +} + +@Test func signingRejectsPlatformMismatchAndExistingOrNestedOutput() async throws { + let root = try signingFixtureDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let app = try makeSigningFixture(in: root, platforms: ["AppleTVOS"]) + for (output, platform) in [ + (root.appendingPathComponent("out.ipa"), SigningPlatform.iOS), + (app, .tvOS), + (app.appendingPathComponent("nested.app"), .tvOS), + ] { + await #expect(throws: (any Error).self) { + _ = try await SigningPackage(input: app, output: output, platform: platform) + } + } +} + +@Test func signingRejectsAmbiguousArchive() async throws { + let root = try signingFixtureDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let input = root.appendingPathComponent("input.ipa") + try Data().write(to: input) + await withDependencies { + $0.zipCompressor.decompress = { _, directory, _ in + let payload = directory.appendingPathComponent("Payload") + try FileManager.default.createDirectory(at: payload, withIntermediateDirectories: true) + _ = try makeSigningFixture(in: payload, platforms: ["AppleTVOS"]) + _ = try makeSigningFixture(in: payload, platforms: ["AppleTVOS"], name: "Other.app") + } + } operation: { + await #expect(throws: (any Error).self) { + _ = try await SigningPackage(input: input, output: root.appendingPathComponent("out.ipa"), platform: nil) + } + } +} + +@Test func signingArchiveRoundTripAndCompressionFailurePreserveInput() async throws { + let root = try signingFixtureDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let input = root.appendingPathComponent("input.ipa") + let output = root.appendingPathComponent("out.ipa") + try Data("original archive".utf8).write(to: input) + try await withDependencies { + $0.zipCompressor.decompress = { _, directory, _ in + let payload = directory.appendingPathComponent("Payload") + try FileManager.default.createDirectory(at: payload, withIntermediateDirectories: true) + _ = try makeSigningFixture(in: payload, platforms: ["AppleTVOS"]) + } + $0.zipCompressor.compress = { _, _ in throw CocoaError(.fileWriteUnknown) } + } operation: { + let package = try await SigningPackage(input: input, output: output, platform: nil) + await #expect(throws: (any Error).self) { try await package.export(to: output) } + #expect(!FileManager.default.fileExists(atPath: output.path)) + try await withDependencies { + $0.zipCompressor.compress = { payload, _ in + #expect(payload.lastPathComponent == "Payload") + let archive = payload.deletingLastPathComponent().appendingPathComponent("app.ipa") + try Data("signed archive".utf8).write(to: archive) + return archive + } + } operation: { + try await package.export(to: output) + } + } + #expect(try String(contentsOf: input, encoding: .utf8) == "original archive") + #expect(try String(contentsOf: output, encoding: .utf8) == "signed archive") +} + +@Test func signingDoesNotOverwriteOutputCreatedWhileSigning() async throws { + let root = try signingFixtureDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let app = try makeSigningFixture(in: root, platforms: ["iPhoneOS"]) + let output = root.appendingPathComponent("out.app") + let package = try await SigningPackage(input: app, output: output, platform: nil) + try Data("keep".utf8).write(to: output) + await #expect(throws: (any Error).self) { try await package.export(to: output) } + #expect(try String(contentsOf: output, encoding: .utf8) == "keep") +} + +private func signingFixtureDirectory() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root +} + +@Test func signingRejectsExtensionSymlinkOutsideStaging() async throws { + let root = try signingFixtureDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let app = try makeSigningFixture(in: root, platforms: ["AppleTVOS"]) + let originalExtension = try makeSigningFixture(in: root, platforms: ["AppleTVOS"], name: "Original.appex") + let originalInfo = try Data(contentsOf: originalExtension.appendingPathComponent("Info.plist")) + let plugins = app.appendingPathComponent("PlugIns") + try FileManager.default.createDirectory(at: plugins, withIntermediateDirectories: false) + try FileManager.default.createSymbolicLink( + at: plugins.appendingPathComponent("Linked.appex"), + withDestinationURL: originalExtension + ) + await #expect(throws: (any Error).self) { + _ = try await SigningPackage(input: app, output: root.appendingPathComponent("out.ipa"), platform: nil) + } + #expect(try Data(contentsOf: originalExtension.appendingPathComponent("Info.plist")) == originalInfo) +} + +@Test(arguments: ["conflict", "unsupportedFamily", "malformedPlatforms", "emptyPlatforms", "malformedFamily", "emptyFamily"]) +func signingRejectsContradictoryOrMalformedMetadata(scenario: String) async throws { + let root = try signingFixtureDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let app = try makeSigningFixture(in: root, platforms: ["AppleTVOS"]) + let info = app.appendingPathComponent("Info.plist") + var plist = try #require(PropertyListSerialization.propertyList(from: Data(contentsOf: info), format: nil) as? [String: Any]) + switch scenario { + case "conflict": plist["UIDeviceFamily"] = [1] + case "unsupportedFamily": plist["UIDeviceFamily"] = [6] + case "malformedPlatforms": plist["CFBundleSupportedPlatforms"] = "AppleTVOS" + case "emptyPlatforms": plist["CFBundleSupportedPlatforms"] = [String]() + case "malformedFamily": plist["UIDeviceFamily"] = "3" + default: plist["UIDeviceFamily"] = [Int]() + } + try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0).write(to: info) + await #expect(throws: (any Error).self) { + _ = try await SigningPackage(input: app, output: root.appendingPathComponent("out.ipa"), platform: .tvOS) + } +} + +@discardableResult +private func makeSigningFixture(in root: URL, platforms: [String], name: String = "Example.app") throws -> URL { + let app = root.appendingPathComponent(name) + try FileManager.default.createDirectory(at: app, withIntermediateDirectories: true) + let plist: [String: Any] = ["CFBundleIdentifier": "com.example.test", "CFBundleExecutable": "Example", + "CFBundleSupportedPlatforms": platforms] + try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + .write(to: app.appendingPathComponent("Info.plist")) + try Data("original".utf8).write(to: app.appendingPathComponent("Example")) + return app +} diff --git a/Tests/XToolTests/SigningPlatformTests.swift b/Tests/XToolTests/SigningPlatformTests.swift new file mode 100644 index 00000000..0a0a2933 --- /dev/null +++ b/Tests/XToolTests/SigningPlatformTests.swift @@ -0,0 +1,27 @@ +import Testing +@testable import XKit + +@Test func signingContextKeepsIOSDefaults() { + let context = SigningContext(auth: .appStoreConnect(.init(id: "TEST", issuerID: "TEST-ISSUER", pem: "unused"))) + #expect(context.platform == .iOS) + #expect(!context.preserveBundleID) +} + +@Test func tvOSUsesTVDevelopmentProfilesAndAppGroups() { + #expect(SigningPlatform.tvOS.developmentProfileType.rawValue == "TVOS_APP_DEVELOPMENT") + #expect(SigningPlatform.tvOS.developerServicesPlatform == .tvOS) + #expect(SigningPlatform.iOS.developmentProfileType.rawValue == "IOS_APP_DEVELOPMENT") + #expect(SigningPlatform.iOS.developerServicesPlatform == .iOS) +} + +@Test func tvOSSelectsAppleTVDevicesOnly() { + #expect(SigningPlatform.tvOS.supports(deviceClass: .appleTv)) + #expect(!SigningPlatform.tvOS.supports(deviceClass: .iphone)) + #expect(!SigningPlatform.tvOS.supports(deviceClass: .ipad)) + #expect(!SigningPlatform.tvOS.supports(deviceClass: .ipod)) + #expect(!SigningPlatform.tvOS.supports(deviceClass: .mac)) + #expect(!SigningPlatform.iOS.supports(deviceClass: .appleTv)) + #expect(SigningPlatform.iOS.supports(deviceClass: .iphone)) + #expect(SigningPlatform.iOS.supports(deviceClass: .ipad)) + #expect(SigningPlatform.iOS.supports(deviceClass: .ipod)) +}