diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8aa9b1f6..6065ed9a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,6 +38,16 @@ jobs: - name: Run tests run: | swift test + - name: Run macOS integration tests + run: | + for lib in bats-core@v1.14.0 bats-support@v0.3.0 bats-assert@v2.2.4; do + git -c advice.detachedHead=false clone -q --depth 1 --branch "${lib#*@}" "https://github.com/bats-core/${lib%@*}" ".build/bats/${lib%@*}" + done + mkdir -p .build/integration-bin + ln -fs "$PWD/.build/debug/xtool" .build/integration-bin/xtool + ln -fs "$(xcrun -f llvm-objdump)" .build/integration-bin/llvm-objdump + PATH="$PWD/.build/integration-bin:$PATH" BATS_LIB_PATH="$PWD/.build/bats" \ + .build/bats/bats-core/bin/bats integration-tests/suite/macos.bats build-ios: runs-on: xcode-27 steps: diff --git a/Documentation/xtool.docc/Configuration.md b/Documentation/xtool.docc/Configuration.md new file mode 100644 index 00000000..f22325ac --- /dev/null +++ b/Documentation/xtool.docc/Configuration.md @@ -0,0 +1,129 @@ +# Configure packaging + +Describe a SwiftPM application's target, products and prepared inputs in `xtool.yml`. + +## Version 2 + +Version 1 retains the existing iOS configuration. Version 2 adds `target`, +`deploymentTarget`, `source`, `resourceCopies` and `nestedProducts`; using these +fields in version 1 is rejected. See for target precedence, supported +backends and build commands. + +The example below packages an app with an extension, XPC service and helper. +Supply the referenced plist, entitlement and icon files alongside your package. + +```yaml +version: 2 +bundleID: com.example.app +product: MyApp +target: arm64-apple-macosx15.0 +deploymentTarget: '15.0' +source: {kind: executable, product: MyApp} +infoPath: Support/App-Info.plist +entitlementsPath: Support/App.entitlements +iconPath: Support/App.icns +nestedProducts: + - product: MyExtension + kind: appExtension + bundleID: com.example.extension + source: {kind: library, product: MyExtension} + infoPath: Support/Extension-Info.plist + entitlementsPath: Support/Extension.entitlements + - product: MyService + kind: xpcService + bundleID: com.example.xpc + source: {kind: executable, product: MyService} + infoPath: Support/XPC-Info.plist + entitlementsPath: Support/XPC.entitlements + - product: MyHelper + kind: helperExecutable + bundleID: com.example.helper + source: {kind: executable, product: MyHelper} +``` + +## Fields and product sources + +| Field | Contract | +| --- | --- | +| `version` | Required schema version, `1` or `2`. | +| `bundleID` / `orgID` | At least one is required. `bundleID` takes precedence; `orgID` derives `.`. | +| `product` | Output/application product name. Use an explicit name when the package has multiple candidates. | +| `target` | Apple target triple, independent of host OS; CLI `--triple` takes precedence. | +| `deploymentTarget` | Numeric OS version; cannot lower the package or platform minimum. | +| `source` | Tagged product source described below; explicit in this example. | +| `infoPath` | Caller plist dictionary merged over generated defaults; caller identifiers and extension/XPC metadata are preserved. | +| `entitlementsPath` | Caller plist dictionary associated with this product for the later signer. | +| `iconPath` | Prepared `.png` or `.icns` in version 2; ICNS requires macOS. | +| `resources` | Legacy list of files/directories copied to the product's resources directory: the bundle root on iOS, `Contents/Resources` on macOS. | +| `resourceCopies` | Explicit copies with `source` and `destination: {location, path}`. | +| `nestedProducts` | Products with required `product`, `kind` and `source`; optional `bundleID`, `infoPath`, `entitlementsPath`, `resourceCopies`. | +| `extensions` | Existing extension list with required `product`/`infoPath`; version 2 also accepts `source`/`resourceCopies`. | +| `skipLSP` | Skip automatic creation of a missing LSP support file. | + +Paths to input files resolve from the package directory. Product names must be +safe single path components; nested names must be unique. Extension and XPC +products require `infoPath`; helpers are bare executables without their own +Info.plist. `xpcService` and `helperExecutable` require a macOS target. + +| `source.kind` | Required value | Build behavior | +| --- | --- | --- | +| `executable` | `product` | Build that SwiftPM executable directly; do not create a target depending on an executable product. | +| `library` | `product` | Create an executable wrapper linking that SwiftPM library; extensions use the extension entry point. | +| `prebuilt` | `path` | Validate and copy an existing target executable; do not compile it. | + +SwiftPM sources cannot carry `path`; prebuilt sources cannot carry `product`. +The requested kind must match the selected package product. Omitted source +retains legacy product selection; explicit sources make multi-product packages +unambiguous. For prebuilt XPC services/helpers use `source.kind: prebuilt` with +a `path` to a compatible macOS Mach-O executable, while retaining the nested +product's `kind` and metadata. This form is covered by planner/builder tests; +the example above builds its XPC service/helper from source. +Prebuilt products with automatically discovered SwiftPM resources are rejected +because xtool cannot rewrite their compiled accessors. Supply explicit copies +and resource lookup implemented by that binary instead. + +## macOS bundle and resource destinations + +| Product kind | Executable relative to the outer app | +| --- | --- | +| `application` | `Contents/MacOS/` | +| `appExtension` | `Contents/PlugIns/.appex/Contents/MacOS/` | +| `xpcService` | `Contents/XPCServices/.xpc/Contents/MacOS/` | +| `helperExecutable` | `Contents/Helpers/` | + +The outer application is selected at the schema root; `nestedProducts` cannot +contain `application`. App, extension and XPC plists live in their respective +`Contents/Info.plist`. Helpers have no plist. Dependency traversal follows +platform-conditional SwiftPM dependencies, collecting resource bundles, dynamic +libraries and compatible binary targets. Frameworks/dylibs are placed in each +owner's `Contents/Frameworks`; helper-owned frameworks use +`Contents/Helpers/Frameworks`, with a matching helper rpath. + +`destination.location` is required in YAML and accepts: + +| Location | Base for app/extension/XPC | Base for helper | +| --- | --- | --- | +| `resources` | The owner's `Contents/Resources` | Outer app's `Contents/Resources` | +| `contents` | The owner's `Contents` | Outer app's `Contents/Helpers` | +| `bundle` | The owner's bundle root | Outer app's `Contents/Helpers` | + +`destination.path` is a nonempty relative path appended to that base. Absolute +paths, empty components, `.`, `..`, backslashes and NUL are rejected. Assembly +also rejects overlapping destinations, including case/Unicode-equivalent +collisions, and unsafe symlinks. Legacy `resources` copies into each product's +resources directory; use `resourceCopies` to choose another destination. Explicit extension resources +belong in the extension's Resources directory. + +Source-built macOS SwiftPM resource accessors are adapted for relocated bundles; +separate relocation tests exercise the adapted `Bundle.module` accessor after +original build inputs are removed. +Prepared assets are copied without invoking `actool` or `ibtool`. Raw +`.xcassets`, `.iconset`, `.storyboard` and `.xib` inputs require preparation elsewhere; +SwiftPM resource bundles are copied as built, with any raw assets left uncompiled. + +Caller plist values override generated defaults, including identifiers. Ensure +`CFBundleExecutable`, bundle kind and minimum OS agree with the actual product; +xtool does not translate an Xcode project's build settings or extension policy. +Deferred signing preserves entitlement dictionaries and their product associations +without applying them. It rejects provisioning-dependent entitlements, which the signer +cannot apply. diff --git a/Documentation/xtool.docc/Control.md b/Documentation/xtool.docc/Control.md index babd0c2e..afea6aae 100644 --- a/Documentation/xtool.docc/Control.md +++ b/Documentation/xtool.docc/Control.md @@ -15,6 +15,23 @@ bundleID: com.example.Hello EOF ``` +## Select the target platform + +Version 1 configurations keep the `arm64-apple-ios` default. Version 2 adds explicit target and deployment settings: + +```yaml +version: 2 +bundleID: com.example.Hello +target: arm64-apple-macosx +deploymentTarget: "15.0" +``` + +`xtool dev build --triple` and `xtool dev build-server --triple` override `target` in `xtool.yml`. When neither is set, both use `arm64-apple-ios`. macOS targets support `arm64` and `x86_64`; `macos` is accepted as an alias for `macosx`. Build macOS apps with `xtool dev build --defer-signing` and sign the resulting app later on a Mac. On Linux, use `--configuration release`; macOS Debug builds require signing support available on macOS. + +The selected triple's OS version takes precedence over `deploymentTarget`, then the package's matching platform minimum, then xtool's platform minimum (iOS 13.0 or macOS 13.0). A package minimum below xtool's platform minimum is raised to it. An explicit version cannot lower the schema, package, or platform minimum. For example, `arm64-apple-macosx16.0` selects macOS 16.0 while the SDK identity remains `arm64-apple-macosx`. + +Editor support uses the same target resolution and package minimum as builds. Swift 6.4 and later use `.bsp/xtool.json`, whose build-server command pins the effective compiler triple (for example `arm64-apple-macosx15.0`), so starting the build server does not inspect the package. Earlier toolchains use `.sourcekit-lsp/config.json` with separate Swift SDK and compiler triple settings. Restart the editor after changing these files. xtool refreshes or migrates only configurations matching its complete generated content; customized files are preserved. Set `skipLSP: true` to disable automatic editor configuration. + ## Change your bundle ID You can update your app's bundle ID by editing the `bundleID` field in `xtool.yml`. diff --git a/Documentation/xtool.docc/MacOS.md b/Documentation/xtool.docc/MacOS.md new file mode 100644 index 00000000..7131b1cf --- /dev/null +++ b/Documentation/xtool.docc/MacOS.md @@ -0,0 +1,64 @@ +# Build macOS applications + +Cross-compile a SwiftPM application on Linux and hand its assembled bundle to a Mac for signing. + +## Supported build contract + +macOS builds use the SwiftPM backend and require explicit deferred signing. + +The CLI requires xtool, a matching Swift toolchain and an installed Darwin Swift +SDK on Linux, or a selected Xcode installation on macOS. + +```sh +# Run in the application's directory beside Package.swift and xtool.yml. +xtool dev build --configuration release --defer-signing +``` + +Set `version: 2` and a macOS `target` in `xtool.yml`; see . +The optional CLI `--triple arm64-apple-macosx15.0` overrides the schema target. +Omitting both retains the existing `arm64-apple-ios` default, even on a Mac. +The deployment version comes from the selected triple, then the schema's +`deploymentTarget`, then the package's platform minimum, then the platform +minimum (macOS 13.0). A selected version below any explicit schema/package +minimum or the platform minimum is rejected. The deployment version is recorded in the +products' binaries; each package's code still compiles for its own declared platforms, as with +`swift build`, so declare `platforms` to use newer APIs without availability checks. + +The parser accepts arm64 and x86_64 macOS triples. Linux macOS Debug builds are rejected because SwiftPM +requires unavailable `codesign` support. Use Release. SwiftBuild is rejected +for macOS; its existing iOS support does not establish macOS support. + +The output is `xtool/.app`, accompanied by `.artifact.json`, +`.signing.json` and, when supplied, `.signing-inputs/`. The CLI preserves copies of +caller entitlement dictionaries outside the app and rebases their paths in +the manifest. Transfer all of them together; `xtool dev verify` and `xtool dev sign` read each one. The manifest has `version: 1`, +`status: deferred`, a resolved target, and product bundle/executable paths with +their entitlement associations. It records signing intent, not release approval. + +This route performs no Apple account provisioning or identity signing. Linker +ad-hoc signatures may already be present. `--sign`, IPA output and Xcode +project generation are unavailable for macOS targets. `xtool dev run` builds +and launches a local Mac app; `--app` launches an existing app without rebuilding. On a Mac, +`xtool dev sign` signs the handoff into a new directory (ad-hoc, Apple Development or +Developer ID, with an explicit certificate fingerprint) and `xtool dev notarize` submits +Developer ID output with an existing `notarytool` keychain profile. The handoff does not +select certificates or publish releases. + +## Tests + +The Swift Testing suite in `Tests/XToolTests` covers product selection, target +validation, nested bundle assembly, resource relocation and signing handoffs. +On macOS it compiles and executes disposable packages to test generated +`Bundle.module` lookup after removing the original build directory. +The Bats suite in `integration-tests/suite` exercises the public CLI using the +installed Darwin SDK. + +## Boundaries + +This support excludes native execution of macOS applications on Linux, an asset +compiler replacement, release publishing, universal binary +production and full Xcode project ingestion. Supply prepared ICNS/PNG, +`Assets.car`, storyboards or nibs where needed; raw asset catalogs, iconsets, storyboards +and xibs you supply are rejected by assembly. SwiftPM resource bundles are copied as +built, so raw assets inside them stay uncompiled. Certificate creation, identity selection and +provisioning-dependent entitlements remain separate work. diff --git a/Documentation/xtool.docc/xtool.md b/Documentation/xtool.docc/xtool.md index fea43522..e266bbaa 100644 --- a/Documentation/xtool.docc/xtool.md +++ b/Documentation/xtool.docc/xtool.md @@ -14,6 +14,8 @@ xtool is a cross-platform (Linux/WSL/macOS) tool that replicates Xcode functiona ✅ Build a SwiftPM package into an iOS app +✅ Build and assemble a macOS SwiftPM app with deferred signing + ✅ Sign and install iOS apps ✅ Interact with Apple Developer Services programmatically @@ -32,3 +34,5 @@ xtool is a cross-platform (Linux/WSL/macOS) tool that replicates Xcode functiona - - +- +- diff --git a/Package.swift b/Package.swift index 199a142c..7388c59a 100644 --- a/Package.swift +++ b/Package.swift @@ -174,7 +174,7 @@ let package = Package( .target( name: "XToolSupport", dependencies: [ - "SwiftyMobileDevice", + .product(name: "SwiftyMobileDevice", package: "SwiftyMobileDevice"), "XKit", "PackLib", .product(name: "ArgumentParser", package: "swift-argument-parser"), @@ -196,6 +196,8 @@ let package = Package( name: "PackLib", dependencies: [ "XUtils", + .product(name: "Crypto", package: "swift-crypto"), + .product(name: "Dependencies", package: "swift-dependencies"), "XcodeProjects", .product(name: "Superutils", package: "xtool-core"), .product(name: "Yams", package: "Yams"), @@ -206,7 +208,7 @@ let package = Package( .executableTarget( name: "xtool", dependencies: [ - "SwiftyMobileDevice", + .product(name: "SwiftyMobileDevice", package: "SwiftyMobileDevice"), "XKit", "XToolSupport", ], diff --git a/README.md b/README.md index a39ca3d6..2d91dace 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,89 @@ DEVICE SUBCOMMANDS: See 'xtool help ' for detailed help. ``` +### macOS development + +For a version 2 project targeting `arm64-apple-macosx15.0`: + +```sh +xtool dev build --configuration release --defer-signing +xtool dev verify xtool/MyApp.app +# On a Mac, build and run without adding signatures: +xtool dev run --configuration release -- --your-app-argument +# Or run an existing app, including one signed externally: +xtool dev run --app /path/to/MyApp.app -- --your-app-argument +``` + +The app runs in the foreground with terminal output and exit-status propagation. Ctrl-C +cancels the child process. Apps requiring entitlements or additional signing must be signed +first; the run command does not add signatures or install applications. + +Copy the `.app`, `.artifact.json`, `.signing.json`, and `.signing-inputs/` together for a +portable handoff. `dev verify` checks the unchanged **pre-signing** bundle, resource files, +executable permissions, internal symlinks and entitlement inputs. Signing changes those bytes; +use `dev run --app` to launch an externally signed app without checking obsolete build hashes. +The inventory also records nested-code signing order and audits static Mach-O dependencies. +It detects changes, but does not authenticate who built the app or prove arbitrary `dlopen` paths. + +Linux builds remain Release-only. A prepared SDK can be selected with `--swift-sdks-path`, +and `--scratch-path` selects a separate macOS build directory. macOS project generation +and provisioning remain separate work. + +### macOS signing + +Sign a verified build handoff on a Mac into a **new** output directory: + +```sh +xtool dev sign xtool/MyApp.app --output signed --mode ad-hoc --dry-run +xtool dev sign xtool/MyApp.app --output signed --mode ad-hoc +xtool dev verify signed/MyApp.app --signed +# Certificate modes require an explicit certificate SHA-1 fingerprint: +xtool dev sign xtool/MyApp.app --output release-signed --mode developer-id --identity "$CERTIFICATE_SHA1" +``` + +`development` selects an Apple Development certificate; `developer-id` requires a +Developer ID Application certificate, hardened runtime and a secure timestamp. The signer +checks the actual certificate fingerprint and category. It does not select identities, +create certificates or provision capabilities. Provisioning-dependent entitlements are rejected +when `dev build --defer-signing` records the handoff, and again by the signer. + +Signing works on a private copy, applies each product's own entitlements in nested-code order, +and verifies signatures and effective entitlements before publishing. Input files remain unchanged; +existing output directories are rejected. The adjacent `.signing-receipt.json` records the original +artifact digest, signed inventory and verified signing metadata. Keep it with the signed app. +`dev verify --signed` checks that receipt and native signatures on a Mac. Inventories cover file +contents, executable bits and symlinks, not extended attributes, and are local integrity records +rather than authenticated build attestations. Old content-addressed entitlement inputs are retained +when replacing a build handoff so an interrupted update cannot delete the previous inputs. + +### macOS notarization + +Use the Developer ID signed output and an existing, explicitly selected `notarytool` +keychain profile. These commands run on a Mac: + +```sh +xtool dev notarize submit release-signed/MyApp.app --output notarized --keychain-profile "$NOTARY_PROFILE" +xtool dev notarize resume notarized --keychain-profile "$NOTARY_PROFILE" --timeout 60 +xtool dev notarize log notarized --keychain-profile "$NOTARY_PROFILE" +# Once accepted: +xtool dev notarize staple notarized +xtool dev notarize verify notarized +``` + +`status` queries without waiting. `submit` never reuses an existing output directory. +An interrupted submission with no recorded UUID remains uncertain: recover with +`resume --submission-id UUID` only when Apple's completed log matches the original upload +SHA-256. Status, resume and log never upload again. A wait timeout leaves the recorded attempt +available for later queries. Credentials are neither created nor selected automatically. + +The release directory retains the original signed snapshot and upload ZIP, append-only receipt +files, and the Apple log binding acceptance to that exact archive. Stapling publishes a separate +`distribution/` containing the app, `distribution.zip` and a recovery receipt. Verification checks +that final inventory, archive hash, native signatures and stapled ticket. Interrupted distribution +publication can be resumed with `staple`. Preserve the complete directory for later verification. +These local receipts are not authenticated attestations or a guarantee of Gatekeeper approval. +The existing Fastlane release of xtool itself remains independent. + ### Library xtool includes a library that you can use to interact with Apple Developer Services, iOS devices, and more from your own app. You can use this by adding `XKit` as a SwiftPM dependency. diff --git a/Sources/PackLib/ArtifactInventory.swift b/Sources/PackLib/ArtifactInventory.swift new file mode 100644 index 00000000..2d1f6612 --- /dev/null +++ b/Sources/PackLib/ArtifactInventory.swift @@ -0,0 +1,76 @@ +import Crypto +import Foundation +import XUtils + +/// Portable, exact-byte inventory. This detects mutation; it is not a signature or attestation. +public struct ArtifactInventory: Codable, Sendable, Equatable { + public var version: Int = 1 + public var files: [String: Entry] + + public struct Entry: Codable, Sendable, Equatable { + public var kind: String + public var sha256: String? + public var executable: Bool + public var link: String? + } + + /// A link is safe when it is relative and resolves to an existing item inside `root`. + /// Both sides resolve symlinks, so a symlinked parent of `root` cannot fail the comparison. + static func isContainedSymlink(_ url: URL, destination link: String, root: URL) -> Bool { + let root = root.resolvingSymlinksInPath().standardizedFileURL.path + let resolved = url.resolvingSymlinksInPath().standardizedFileURL.path + return !link.hasPrefix("/") && resolved.hasPrefix(root + "/") && FileManager.default.fileExists(atPath: resolved) + } + + public init(directory: URL, hashContents: Bool = true) throws { + let manager = FileManager.default + let root = directory.resolvingSymlinksInPath().standardizedFileURL + var pending = try manager.contentsOfDirectory(atPath: root.path) + var entries: [String: Entry] = [:] + while let path = pending.popLast() { + let url = root.appendingPathComponent(path) + try ResourceDestination.validateRelativePath(path) + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey]) + if values.isSymbolicLink == true { + let link = try manager.destinationOfSymbolicLink(atPath: url.path) + guard Self.isContainedSymlink(url, destination: link, root: root) else { + throw StringError("Broken or escaping artifact symlink: \(path)") + } + entries[path] = Entry(kind: "symlink", executable: false, link: link) + } else if values.isDirectory == true { + entries[path] = Entry(kind: "directory", executable: false) + pending += try manager.contentsOfDirectory(atPath: url.path).map { path + "/" + $0 } + } else if values.isRegularFile == true { + let attributes = try manager.attributesOfItem(atPath: url.path) + let permissions = (attributes[.posixPermissions] as? NSNumber)?.intValue ?? 0 + entries[path] = Entry(kind: "file", sha256: hashContents ? try Self.hash(url) : nil, + executable: permissions & 0o111 != 0) + } else { + throw StringError("Unsupported artifact entry: \(path)") + } + } + guard !entries.isEmpty else { throw StringError("Artifact is empty: \(directory.path)") } + files = entries + } + + public func verify(directory: URL) throws { + guard version == 1 else { throw StringError("Unsupported artifact inventory version: \(version)") } + let actual = try Self(directory: directory) + let differences = Set(files.keys).union(actual.files.keys).filter { files[$0] != actual.files[$0] }.sorted() + guard differences.isEmpty else { + throw StringError("Artifact inventory mismatch: \(differences.joined(separator: ", "))") + } + } + + public static func hash(data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + public static func hash(_ url: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var hash = SHA256() + while let data = try handle.read(upToCount: 1_048_576), !data.isEmpty { hash.update(data: data) } + return hash.finalize().map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Sources/PackLib/BuildPlan.swift b/Sources/PackLib/BuildPlan.swift new file mode 100644 index 00000000..9eb062d6 --- /dev/null +++ b/Sources/PackLib/BuildPlan.swift @@ -0,0 +1,87 @@ +import Foundation +import XUtils + +public struct Plan: Sendable { + public let target: ResolvedTarget + public var app: Product + public var extensions: [Product] + public var nestedProducts: [Product] + + public init(target: ResolvedTarget, app: Product, extensions: [Product] = [], nestedProducts: [Product] = []) throws { + let products = [app] + extensions + nestedProducts + guard products.allSatisfy({ $0.target == target }), app.type == .application, + extensions.allSatisfy({ $0.type == .appExtension }), + nestedProducts.allSatisfy({ $0.type != .application }), + Set(products.map(\.product)).count == products.count else { + throw StringError("Build plan has inconsistent targets, product kinds, or duplicate names") + } + self.target = target + self.app = app + self.extensions = extensions + self.nestedProducts = nestedProducts + } + + public var allProducts: [Product] { + [app] + extensions + nestedProducts + } + + public enum Resource: Codable, Sendable, Hashable { + case bundle(package: String, target: String) + /// `xcframework` is the absolute path of a local, unarchived XCFramework; SwiftPM extracts + /// remote and zipped binary targets into its artifacts directory instead. + case binaryTarget(name: String, xcframework: String? = nil) + case library(name: String) + case root(source: String) + case copy(ResourceCopy) + } + + public struct Product: Sendable { + public let target: ResolvedTarget + public var platform: TargetPlatform { target.platform } + public var source: ProductSource? + public var type: ProductType + public var product: String + public var deploymentTarget: String { target.deploymentTarget } + public var bundleID: String + public var infoPlist: [String: any Sendable] + public var resources: [Resource] + public var iconPath: String? + public var entitlementsPath: String? + + public var resolvedSource: ProductSource { source ?? .library(product: product) } + + public var layout: BundleLayout { .init(platform: platform, kind: type, name: product) } + + public var targetName: String { + "\(self.product)-\(self.type.targetSuffix)" + } + + public func directory(inApp baseDir: URL) -> URL { + baseDir.appendingPathComponent(layout.bundlePath, isDirectory: type != .helperExecutable) + } + } + + public enum ProductType: String, Codable, Sendable, Equatable { + case application + case appExtension + case xpcService + case helperExecutable + + var targetSuffix: String { + switch self { + case .application: "App" + case .appExtension: "Extension" + case .xpcService: "XPCService" + case .helperExecutable: "Helper" + } + } + + var fourCharCode: String { + switch self { + case .application: "APPL" + case .appExtension, .xpcService: "XPC!" + case .helperExecutable: "" + } + } + } +} diff --git a/Sources/PackLib/BuildSettings.swift b/Sources/PackLib/BuildSettings.swift index 9d5a041c..6600bfed 100644 --- a/Sources/PackLib/BuildSettings.swift +++ b/Sources/PackLib/BuildSettings.swift @@ -16,6 +16,12 @@ public struct BuildSettings: Sendable { public var sdkOptions: [String] public var sdkEnvironment: [Environment.Key: String?] + /// The versioned triple SwiftPM compiles for; `withTarget` pins the plan's deployment target. + private var compilerTriple: String + + /// Where Linux writes the generated Swift SDK destination. It belongs to the root package: + /// copies from `withPackagePath` inspect dependencies and must not write into their directories. + let swiftSDKCacheDirectory: URL private var configOptions: [String] { return [ @@ -37,10 +43,22 @@ public struct BuildSettings: Sendable { options: [String] = [] ) async throws { self.packagePath = packagePath + self.swiftSDKCacheDirectory = URL(fileURLWithPath: packagePath, isDirectory: true) + .appendingPathComponent(".build/xtool-sdk") self.configuration = configuration self.customOptions = options self.triple = triple - self.buildSystem = if let buildSystem { buildSystem } else { try await .default() } + let target = try TargetTriple(triple) + self.compilerTriple = target.compilerTriple + // SwiftBuild has not been validated for macOS cross-compilation. Keep the + // established iOS default, and use the proven SwiftPM backend for macOS. + self.buildSystem = if let buildSystem { + buildSystem + } else if target.platform == .macOS { + .swiftPM + } else { + try await .default() + } self.sdkEnvironment = [ // xcrun passes an SDKROOT that messes with our sdk configuration @@ -54,8 +72,14 @@ public struct BuildSettings: Sendable { // i.e. we can pass `--swift-sdk arm64-apple-ios` and it // just works. See: // https://github.com/swiftlang/swift-package-manager/pull/6828 - self.sdkOptions = ["--swift-sdk", triple] + self.sdkOptions = ["--swift-sdk", target.sdkIdentity] + if target.deploymentTarget != nil { + self.sdkOptions += ["--triple", compilerTriple] + } case .swiftBuild: + guard target.platform != .macOS else { + throw StringError("macOS builds currently require the SwiftPM backend; SwiftBuild is not validated") + } self.sdkOptions = ["--triple", triple] #if !os(macOS) let darwinSDK = try DarwinSDK.current() @@ -113,6 +137,87 @@ public struct BuildSettings: Sendable { return copy } + /// An SDK's unversioned identity selects its tools and libraries; the + /// compiler triple separately carries the plan's effective minimum OS. + public func withTarget(_ target: ResolvedTarget) throws -> Self { + guard target.triple == triple else { + throw StringError("Build settings target differs from the resolved plan") + } + var copy = self + switch buildSystem { + case .swiftPM: + copy.compilerTriple = target.compilerTriple + copy.sdkOptions = ["--swift-sdk", try TargetTriple(triple).sdkIdentity, "--triple", target.compilerTriple] + case .swiftBuild: + // Keep the initializer's triple and toolset. SwiftBuild applies a versioned triple's + // deployment target to every package, so dependencies would compile for the app's + // minimum instead of their own; the wrapper package's `platforms` carries the plan's. + break + } + return copy + } + + /// Records the plan's deployment target as each product's minimum OS on every host; packages + /// still compile for their own minimums. Also passes the real SDK version, which LLD on Linux + /// would otherwise replace with the deployment version. + /// Keep this target-only: SwiftPM CLI linker flags also affect host macros. + func macOSLinkerArguments() async throws -> [String] { + let target = try TargetTriple(compilerTriple) + let settingsURL = try await macOSSDKRoot(for: target).appendingPathComponent("SDKSettings.json") + let data = try Data(contentsOf: settingsURL) + guard let settings = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = settings["Version"] as? String else { + throw StringError("Missing SDK version in \(settingsURL.path)") + } + try TargetTriple.validateVersion(version) + return ["-Xlinker", "-platform_version", "-Xlinker", "macos", "-Xlinker", + target.deploymentTarget ?? target.platform.minimumDeploymentTarget, "-Xlinker", version] + } + + private var swiftSDKsPath: String? { + for (index, option) in customOptions.enumerated() { + if option.hasPrefix("--swift-sdks-path=") { return String(option.dropFirst("--swift-sdks-path=".count)) } + if option == "--swift-sdks-path", index + 1 < customOptions.count { return customOptions[index + 1] } + } + return nil + } + + private func macOSSDKRoot(for target: TargetTriple) async throws -> URL { + #if os(macOS) + if swiftSDKsPath == nil { + return URL(fileURLWithPath: try await Self.xcrun(["--sdk", "macosx", "--show-sdk-path"])) + } + #endif + let properties = try SwiftSDKConfiguration.properties(in: swiftSDKsDirectory(), for: target) + return URL(fileURLWithPath: try (properties["sdkRootPath"] as? String) + .orThrow(StringError("Missing sdkRootPath for '\(target.sdkIdentity)'"))) + } + + private func swiftSDKsDirectory() throws -> URL { + if let path = swiftSDKsPath { return URL(fileURLWithPath: path, isDirectory: true) } + return try DarwinSDK.current() + .orThrow(StringError("No Darwin SDK configured. Please run `xtool setup`.")) + .bundle.deletingLastPathComponent() + } + + /// Reject combinations whose failure is already known, before a builder + /// creates its wrapper or starts resolving/compiling a package. + func validateProductBuild(for target: ResolvedTarget, hostIsMacOS: Bool) throws { + // `init` already rejects SwiftBuild for macOS, and `withTarget` a different triple. + guard target.platform != .macOS || hostIsMacOS || configuration == .release else { + throw StringError("macOS Debug builds on Linux need unavailable codesign support; use --configuration release") + } + // Build options may tune compilation, but cannot silently replace the + // already-resolved target, product, backend, or package. + let reserved = ["--triple", "--swift-sdk", "--sdk", "--arch", "--destination", "--configuration", "-c", + "--build-system", "--package-path", "--product", "--target", "--show-bin-path", "--build-tests"] + for option in customOptions { + if reserved.contains(where: { option == $0 || option.hasPrefix("\($0)=") }) { + throw StringError("Build option '\(option)' conflicts with the resolved product build settings") + } + } + } + public func swiftPMInvocation( forTool tool: String, arguments: [String], @@ -136,10 +241,22 @@ public struct BuildSettings: Sendable { baseArguments = [tool] } + var options = resolvedBaseOptions + var environment = sdkEnvironment + #if !os(macOS) + let target = try TargetTriple(triple) + if buildSystem == .swiftPM && target.platform == .macOS { + let destination = try SwiftSDKConfiguration.destination(in: swiftSDKsDirectory(), for: target, + cacheDirectory: swiftSDKCacheDirectory) + options = configOptions + ["--destination", destination.url.path, "--triple", compilerTriple] + customOptions + environment["PATH"] = (destination.toolSearchPaths + [ProcessInfo.processInfo.environment["PATH"] ?? ""]) + .joined(separator: ":") + } + #endif return Configuration( executable: executable, - arguments: .init(baseArguments + resolvedBaseOptions + arguments), - environment: .inherit.updating(sdkEnvironment), + arguments: .init(baseArguments + options + arguments), + environment: .inherit.updating(environment), platformOptions: .withGracefulShutDown, ) } diff --git a/Sources/PackLib/BundleAssembler.swift b/Sources/PackLib/BundleAssembler.swift new file mode 100644 index 00000000..a63ba6ba --- /dev/null +++ b/Sources/PackLib/BundleAssembler.swift @@ -0,0 +1,250 @@ +import Foundation +import XUtils + +/// Stages completed artifacts without compiling, provisioning, signing, or publishing. +public struct BundleAssembler: Sendable { + public let plan: Plan + public let sourceDirectory: URL + + public init(plan: Plan, sourceDirectory: URL) { + self.plan = plan + self.sourceDirectory = sourceDirectory + } + + /// The destination must be empty. All inputs and destinations are checked before copying. + public func assemble(artifacts: BuildArtifacts, at outputURL: URL) throws { + let assembly = try operations(artifacts: artifacts) + try validate(assembly, outputURL: outputURL) + let manager = FileManager.default + try manager.createDirectory(at: outputURL, withIntermediateDirectories: true) + for path in assembly.directories.sorted() { + try manager.createDirectory(at: outputURL.appendingPathComponent(path), withIntermediateDirectories: true) + } + for operation in assembly.operations { + try Task.checkCancellation() + let destination = outputURL.appendingPathComponent(operation.path) + try manager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + switch operation.payload { + case .copy(let source): try manager.copyItem(at: source, to: destination) + case .data(let data): try data.write(to: destination, options: .withoutOverwriting) + case .symlink(let target): try manager.createSymbolicLink(atPath: destination.path, withDestinationPath: target) + } + } + } + + private enum Payload: Hashable { + case copy(URL) + case data(Data) + case symlink(String) + } + + private struct Operation: Hashable { + var path: String + var payload: Payload + /// SwiftPM resource bundles are copied as built, including the assets SwiftPM leaves uncompiled. + var allowsRawAssets = false + } + + private struct Assembly { + var directories: Set = [] + var operations: [Operation] = [] + var dependencyProducts: [Operation: Set] = [:] + } + + private func operations(artifacts: BuildArtifacts) throws -> Assembly { + var assembly = Assembly() + for product in plan.allProducts { + try validateName(product.product) + let layout = product.layout + for path in [layout.bundlePath, layout.contentsPath, layout.resourcesPath, layout.frameworksPath, + (layout.executablePath as NSString).deletingLastPathComponent] where !path.isEmpty { + assembly.directories.insert(path) + } + // A helper's bundlePath is its executable, not a directory. + if product.type == .helperExecutable { assembly.directories.remove(layout.bundlePath) } + guard let executable = artifacts.executables[product.product], + FileManager.default.isExecutableFile(atPath: executable.path) else { + throw StringError("Missing built executable for product '\(product.product)'") + } + assembly.operations.append(.init(path: layout.executablePath, payload: .copy(executable))) + if let path = layout.infoPlistPath { + assembly.operations.append(.init(path: path, payload: .data(try plist(for: product)))) + } + for resource in product.resources { + try append(resource, product: product, artifacts: artifacts, to: &assembly) + } + if let icon = product.iconPath { + let source = sourceURL(icon) + let path = layout.joined(layout.resourcesPath, source.lastPathComponent) + assembly.operations.append(.init(path: path, payload: .copy(source))) + } + } + return assembly + } + + private func append( + _ resource: Plan.Resource, product: Plan.Product, artifacts: BuildArtifacts, to assembly: inout Assembly + ) throws { + let layout = product.layout + switch resource { + case .bundle(let package, let target): + try validateName(package) + try validateName(target) + let name = "\(package)_\(target).bundle" + let path = layout.joined(layout.resourcesPath, name) + appendDependency(.init(path: path, payload: .copy(artifacts.directory.appendingPathComponent(name)), + allowsRawAssets: true), + product: product, to: &assembly) + if product.platform == .macOS && product.type == .helperExecutable { + // Bare helpers retain SwiftPM's executable-directory lookup. Real bundles use + // the resourceURL accessor compiled by ProductBuilder; root aliases cannot be signed. + appendDependency(.init(path: layout.joined(layout.contentsPath, name), + payload: .symlink("../Resources/\(name)")), + product: product, to: &assembly) + } + case .binaryTarget(let name, let xcframework): + try validateName(name) + let source = artifacts.directory.appendingPathComponent("\(name).framework") + let binary = source.appendingPathComponent(name) + if artifacts.staticBinaryTargets.contains(name) { + let looseArchive = ProductBuilder.staticArchive(forBinaryTarget: name, xcframework: xcframework, + in: artifacts.directory, target: plan.target) + guard FileManager.default.fileExists(atPath: binary.path) + || FileManager.default.fileExists(atPath: looseArchive.path) else { + throw StringError("Missing validated static binary target '\(name)' in \(artifacts.directory.path)") + } + // ProductBuilder inspected the archive and confirmed that SwiftPM + // linked it into the executable. Static code is not embedded. + return + } + // iOS SwiftPM omits some linked static binary targets from the output tree. + // Retain that legacy omission; macOS declared inputs must be present and classified. + if product.platform != .macOS && !FileManager.default.fileExists(atPath: binary.path) { return } + try validateInput(source) + guard FileManager.default.fileExists(atPath: binary.path) else { + throw StringError("Framework '\(name)' has no readable binary at \(binary.path)") + } + if try !isArchive(binary) { + appendDependency(.init(path: layout.joined(layout.frameworksPath, source.lastPathComponent), payload: .copy(source)), + product: product, to: &assembly) + } + case .library(let name): + try validateName(name) + let source = artifacts.directory.appendingPathComponent("lib\(name).dylib") + try validateInput(source) + if try !isArchive(source) { + appendDependency(.init(path: layout.joined(layout.frameworksPath, source.lastPathComponent), payload: .copy(source)), + product: product, to: &assembly) + } + case .root(let path): + let source = sourceURL(path) + // Legacy resources go where Bundle.main looks: the bundle root on iOS, Contents/Resources on + // macOS. The macOS bundle root is outside the code signature's resource seal. + let destination = ResourceDestination(path: source.lastPathComponent) + assembly.operations.append(.init(path: try layout.path(for: destination), payload: .copy(source))) + case .copy(let copy): + assembly.operations.append(.init(path: try layout.path(for: copy.destination), payload: .copy(sourceURL(copy.source)))) + } + } + + private func appendDependency(_ operation: Operation, product: Plan.Product, to assembly: inout Assembly) { + // Helpers share resources with the app and dependencies with other helpers. Coalesce exact + // generated operations across products, but reject repeated declarations within one product. + let inserted = assembly.dependencyProducts[operation, default: []].insert(product.product).inserted + if inserted && assembly.dependencyProducts[operation]?.count != 1 { return } + assembly.operations.append(operation) + } + + private func plist(for product: Plan.Product) throws -> Data { + var info = product.infoPlist + if product.type == .application && product.platform != .macOS { + info["UIRequiredDeviceCapabilities"] = ["arm64"] + info["LSRequiresIPhoneOS"] = true + info["CFBundleSupportedPlatforms"] = [product.platform.supportedPlatform] + } + if let icon = product.iconPath, info["CFBundleIconFile"] == nil { + info["CFBundleIconFile"] = URL(fileURLWithPath: icon).deletingPathExtension().lastPathComponent + } + return try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + } + + private func validate(_ assembly: Assembly, outputURL: URL) throws { + let manager = FileManager.default + if manager.fileExists(atPath: outputURL.path) { + guard try manager.contentsOfDirectory(atPath: outputURL.path).isEmpty else { + throw StringError("Bundle staging destination must be empty: \(outputURL.path)") + } + } + let directories = assembly.directories.map(canonicalPath) + var destinations: [String] = [] + for operation in assembly.operations { + try ResourceDestination.validateRelativePath(operation.path) + let path = canonicalPath(operation.path) + guard !directories.contains(where: { $0 == path || $0.hasPrefix(path + "/") }), + !destinations.contains(where: { $0 == path || $0.hasPrefix(path + "/") || path.hasPrefix($0 + "/") }) else { + throw StringError("Bundle destination collision at '\(operation.path)'") + } + destinations.append(path) + if case .copy(let source) = operation.payload { + try validateInput(source, allowsRawAssets: operation.allowsRawAssets) + let sourcePath = source.resolvingSymlinksInPath().path + let outputPath = outputURL.resolvingSymlinksInPath().path + guard outputPath != sourcePath, !outputPath.hasPrefix(sourcePath + "/") else { + throw StringError("Bundle staging destination is inside input '\(source.path)'") + } + } + } + } + + private func validateInput(_ source: URL, allowsRawAssets: Bool = false) throws { + let manager = FileManager.default + guard manager.fileExists(atPath: source.path) else { + throw StringError("Missing bundle input: \(source.path)") + } + // Top-level links to a build-tree payload would break after relocation; require an owned payload. + if try source.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink == true { + throw StringError("Bundle input must be a payload rather than a top-level symbolic link: \(source.path)") + } + try validateEntry(source, root: source, allowsRawAssets: allowsRawAssets) + if try source.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true { + guard let enumerator = manager.enumerator(at: source, includingPropertiesForKeys: [.isSymbolicLinkKey]) else { + throw StringError("Cannot enumerate bundle input: \(source.path)") + } + while let entry = enumerator.nextObject() as? URL { + try validateEntry(entry, root: source, allowsRawAssets: allowsRawAssets) + } + } + } + + private func validateEntry(_ entry: URL, root: URL, allowsRawAssets: Bool) throws { + let rawAssets = ["xcassets", "iconset", "storyboard", "xib"] + if !allowsRawAssets && rawAssets.contains(entry.pathExtension.lowercased()) { + throw StringError("Raw asset '\(entry.path)' requires Apple asset compilation; supply prepared " + + "Assets.car, .icns, or compiled resources using resource-copies instead") + } + if try entry.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink == true { + let link = try FileManager.default.destinationOfSymbolicLink(atPath: entry.path) + guard ArtifactInventory.isContainedSymlink(entry, destination: link, root: root) else { + throw StringError("Broken or escaping symbolic link in bundle input: \(entry.path) -> \(link)") + } + } + } + + private func isArchive(_ source: URL) throws -> Bool { + let handle = try FileHandle(forReadingFrom: source) + defer { try? handle.close() } + let bytes = try handle.read(upToCount: 8) + return bytes == Data("!\n".utf8) || bytes == Data("!\n".utf8) + } + + private func sourceURL(_ path: String) -> URL { + if path.hasPrefix("/") { return URL(fileURLWithPath: path).standardizedFileURL } + return sourceDirectory.appendingPathComponent(path).standardizedFileURL + } + + private func validateName(_ name: String) throws { + try ResourceDestination.validateComponent(name, "Bundle input name") + } + + private func canonicalPath(_ path: String) -> String { path.precomposedStringWithCanonicalMapping.lowercased() } +} diff --git a/Sources/PackLib/BundleLayout.swift b/Sources/PackLib/BundleLayout.swift new file mode 100644 index 00000000..ad9e6e6d --- /dev/null +++ b/Sources/PackLib/BundleLayout.swift @@ -0,0 +1,141 @@ +import Foundation +import XUtils + +/// Paths are relative to the outer application bundle. The builder never writes here. +public struct BundleLayout: Sendable, Equatable { + public let platform: TargetPlatform + public let kind: Plan.ProductType + public let name: String + + public init(platform: TargetPlatform, kind: Plan.ProductType, name: String) { + self.platform = platform + self.kind = kind + self.name = name + } + + public var bundlePath: String { + switch kind { + case .application: "" + case .appExtension: "\(platform == .macOS ? "Contents/" : "")PlugIns/\(name).appex" + case .xpcService: "Contents/XPCServices/\(name).xpc" + case .helperExecutable: "Contents/Helpers/\(name)" + } + } + + public var contentsPath: String { + if kind == .helperExecutable { return "Contents/Helpers" } + return joined(bundlePath, platform == .macOS ? "Contents" : "") + } + + public var executablePath: String { + if kind == .helperExecutable { return bundlePath } + return joined(contentsPath, platform == .macOS ? "MacOS/\(name)" : name) + } + + public var infoPlistPath: String? { + kind == .helperExecutable ? nil : joined(contentsPath, "Info.plist") + } + + public var resourcesPath: String { + // Helpers is a nested-code location; codeless payloads belong in the outer resource seal. + if platform == .macOS && kind == .helperExecutable { return "Contents/Resources" } + return joined(contentsPath, platform == .macOS ? "Resources" : "") + } + + public var frameworksPath: String { joined(contentsPath, "Frameworks") } + + public func path(for destination: ResourceDestination) throws -> String { + try destination.validate() + let base = switch destination.location { + case .resources: resourcesPath + case .bundle: kind == .helperExecutable ? contentsPath : bundlePath + case .contents: contentsPath + } + return joined(base, destination.path) + } + + func joined(_ base: String, _ path: String) -> String { + [base, path].filter { !$0.isEmpty }.joined(separator: "/") + } +} + +/// Explicit destinations are relative to the selected product, never the host filesystem. +public struct ResourceDestination: Codable, Sendable, Hashable { + public enum Location: String, Codable, Sendable { + case resources + case bundle + case contents + } + + public var location: Location + public var path: String + + public init(location: Location = .resources, path: String) { + self.location = location + self.path = path + } + + public func validate() throws { + try Self.validateRelativePath(path) + } + + static func validateRelativePath(_ path: String) throws { + let components = path.split(separator: "/", omittingEmptySubsequences: false) + guard !path.isEmpty, !path.contains("\\"), !path.contains("\0"), + components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) else { + throw StringError("Bundle destination must be a safe relative path: '\(path)'") + } + } + + /// One safe path component, such as a product or file name. + static func validateComponent(_ name: String, _ description: String) throws { + try validateRelativePath(name) + guard !name.contains("/") else { throw StringError("\(description) must be a single path component: '\(name)'") } + } +} + +public struct ResourceCopy: Codable, Sendable, Hashable { + public var source: String + public var destination: ResourceDestination + + public init(source: String, destination: ResourceDestination) { + self.source = source + self.destination = destination + } +} + +/// Handoff to a downstream Mac signer. This records intent, not a claim that Mach-O +/// files lack linker-generated ad-hoc signatures. No provisioning or signing occurs here. +public struct DeferredSigningManifest: Codable, Sendable { + public enum Status: String, Codable, Sendable { case deferred } + + public var version: Int = 1 + public var status: Status = .deferred + public var target: ResolvedTarget + public var products: [Product] + + public struct Product: Codable, Sendable { + public var kind: Plan.ProductType + public var bundleID: String + /// Empty for the outer app; all other paths are relative to the app root. + public var bundlePath: String + public var executablePath: String + /// Caller-supplied input, external to the bundle. The handoff writer must + /// preserve this file and rebase its path relative to the manifest location. + public var entitlementsPath: String? + } + + public init(plan: Plan) { + target = plan.target + products = plan.allProducts.map { product in + let layout = product.layout + return Product( + kind: product.type, + bundleID: product.bundleID, + bundlePath: layout.bundlePath, + executablePath: layout.executablePath, + entitlementsPath: product.entitlementsPath + ) + } + } +} diff --git a/Sources/PackLib/MacArtifact.swift b/Sources/PackLib/MacArtifact.swift new file mode 100644 index 00000000..4669c45c --- /dev/null +++ b/Sources/PackLib/MacArtifact.swift @@ -0,0 +1,111 @@ +import Foundation +import XUtils + +/// Build handoff preserved beside the app. Signing changes these bytes and requires a new receipt. +public struct MacArtifact: Codable, Sendable { + public var version: Int = 1 + public var bundleName: String + public var target: ResolvedTarget + public var inventory: ArtifactInventory + public var signingInputs: [String: String] + /// Relative code locations, deepest first, with the outer app represented by an empty path. + public var signingOrder: [String] + + public static func manifestURL(for bundle: URL) -> URL { + bundle.deletingPathExtension().appendingPathExtension("artifact.json") + } + + public static func signingManifestURL(for bundle: URL) -> URL { + bundle.deletingPathExtension().appendingPathExtension("signing.json") + } + + public static func write(bundle: URL, signingManifest: URL) throws { + let manifest = try JSONDecoder().decode(DeferredSigningManifest.self, from: Data(contentsOf: signingManifest)) + try validateProducts(manifest, bundle: bundle) + let inventory = try ArtifactInventory(directory: bundle) + let order = try MacBundleAudit.validate(bundle: bundle, target: manifest.target) + var inputs = [signingManifest.lastPathComponent: try ArtifactInventory.hash(signingManifest)] + for product in manifest.products { + guard let path = product.entitlementsPath else { continue } + let input = try safeInput(path, beside: bundle) + inputs[path] = try ArtifactInventory.hash(input) + } + let artifact = Self(bundleName: bundle.lastPathComponent, target: manifest.target, + inventory: inventory, signingInputs: inputs, signingOrder: order) + try JSONEncoder.sortedPretty().encode(artifact).write(to: manifestURL(for: bundle), options: .atomic) + } + + public static func verify(bundle: URL) throws { + let artifact = try JSONDecoder().decode(Self.self, from: Data(contentsOf: manifestURL(for: bundle))) + guard artifact.version == 1, artifact.bundleName == bundle.lastPathComponent else { + throw StringError("Artifact manifest version or bundle name mismatch") + } + try artifact.inventory.verify(directory: bundle) + let signingName = signingManifestURL(for: bundle).lastPathComponent + guard artifact.signingInputs[signingName] != nil else { throw StringError("Missing signing manifest hash") } + for (path, expected) in artifact.signingInputs { + guard try ArtifactInventory.hash(safeInput(path, beside: bundle)) == expected else { + throw StringError("Signing input changed: \(path)") + } + } + let signingURL = try safeInput(signingName, beside: bundle) + let signing = try JSONDecoder().decode(DeferredSigningManifest.self, from: Data(contentsOf: signingURL)) + guard signing.target == artifact.target else { throw StringError("Signing and artifact targets differ") } + try validateProducts(signing, bundle: bundle) + for product in signing.products { + if let path = product.entitlementsPath, artifact.signingInputs[path] == nil { + throw StringError("Missing entitlement input hash: \(path)") + } + } + let order = try MacBundleAudit.validate(bundle: bundle, target: artifact.target) + guard order == artifact.signingOrder else { throw StringError("Nested code inventory changed") } + } + + private static func safeInput(_ path: String, beside bundle: URL) throws -> URL { + try ResourceDestination.validateRelativePath(path) + let parent = bundle.deletingLastPathComponent().resolvingSymlinksInPath() + let url = parent.appendingPathComponent(path).resolvingSymlinksInPath() + guard url.path.hasPrefix(parent.path + "/") else { throw StringError("Signing input escapes artifact: \(path)") } + return url + } + + private static func validateProducts(_ manifest: DeferredSigningManifest, bundle: URL) throws { + guard manifest.version == 1, manifest.target.platform == .macOS, + manifest.products.filter({ $0.kind == .application && $0.bundlePath.isEmpty }).count == 1, + Set(manifest.products.map(\.executablePath)).count == manifest.products.count else { + throw StringError("Invalid deferred signing product inventory") + } + let root = bundle.resolvingSymlinksInPath() + var nodes: Set = [] + for product in manifest.products { + try ResourceDestination.validateRelativePath(product.executablePath) + if !product.bundlePath.isEmpty { try ResourceDestination.validateRelativePath(product.bundlePath) } + let executable = root.appendingPathComponent(product.executablePath).resolvingSymlinksInPath() + guard executable.path.hasPrefix(root.path + "/"), FileManager.default.isExecutableFile(atPath: executable.path) else { + throw StringError("Missing signing product executable: \(product.executablePath)") + } + let canonicalPath = String(executable.path.dropFirst(root.path.count + 1)) + guard canonicalPath == product.executablePath else { + throw StringError("Signing product executable must use its canonical path") + } + let node = try MacBundleAudit.signingPath(for: canonicalPath, in: root) + guard nodes.insert(node).inserted, node == product.bundlePath else { + throw StringError("Signing products must identify unique signing nodes") + } + let name = executable.lastPathComponent + let layout = BundleLayout(platform: .macOS, kind: product.kind, name: name) + guard product.bundlePath == layout.bundlePath, product.executablePath == layout.executablePath else { + throw StringError("Signing product kind does not match its bundle location") + } + if product.kind != .helperExecutable { + let plist = root.appendingPathComponent(product.bundlePath).appendingPathComponent("Contents/Info.plist") + let info = try PropertyListSerialization.propertyList(from: Data(contentsOf: plist), format: nil) as? [String: Any] + guard info?["CFBundleIdentifier"] as? String == product.bundleID, + info?["CFBundleExecutable"] as? String == name, + info?["CFBundlePackageType"] as? String == product.kind.fourCharCode else { + throw StringError("Signing product identity differs from Info.plist: \(product.bundleID)") + } + } + } + } +} diff --git a/Sources/PackLib/MacBundleAudit.swift b/Sources/PackLib/MacBundleAudit.swift new file mode 100644 index 00000000..8cd9c53f --- /dev/null +++ b/Sources/PackLib/MacBundleAudit.swift @@ -0,0 +1,190 @@ +import Foundation +import XUtils + +/// Structural validation for a relocated Mac bundle, independent of signing and host tools. +public enum MacBundleAudit { + public static func executable(in bundle: URL) throws -> URL { + let root = bundle.resolvingSymlinksInPath().standardizedFileURL + let plist = root.appendingPathComponent("Contents/Info.plist") + let info = try PropertyListSerialization.propertyList(from: Data(contentsOf: plist), format: nil) + guard let info = info as? [String: Any], info["CFBundlePackageType"] as? String == "APPL", + let name = info["CFBundleExecutable"] as? String, !name.contains("/") else { + throw StringError("Expected a macOS application with CFBundleExecutable: \(bundle.path)") + } + try ResourceDestination.validateRelativePath(name) + let executable = root.appendingPathComponent("Contents/MacOS/\(name)").resolvingSymlinksInPath() + guard executable.path.hasPrefix(root.path + "/"), FileManager.default.isExecutableFile(atPath: executable.path) else { + throw StringError("Application executable is missing or escapes its bundle") + } + return executable + } + + /// The target an existing app runs as on this host: the main executable's slice for the host + /// architecture, or its x86_64 slice under Rosetta on arm64, at the app's declared minimum. + /// Without `LSMinimumSystemVersion`, the minimum is the one recorded in that slice. + public static func runTarget(bundle: URL, hostArchitecture: String) throws -> ResolvedTarget { + let main = try executable(in: bundle) + let slices = try MachOBinary.inspect(contentsOf: main) + let candidates = hostArchitecture == "arm64" ? ["arm64", "x86_64"] : [hostArchitecture] + var match: (architecture: String, slice: MachOBinary.Slice)? + for architecture in candidates where match == nil { + let cpu = try MachOBinary.cpuType(for: architecture) + match = slices.first { $0.cpu == cpu }.map { (architecture, $0) } + } + guard let (architecture, slice) = match else { + throw StringError("Application executable has no slice that runs on \(hostArchitecture)") + } + let info = try metadata(at: bundle.appendingPathComponent("Contents/Info.plist")) + let minimum = (info["LSMinimumSystemVersion"] as? String) + ?? "\(slice.minimumOS >> 16).\((slice.minimumOS >> 8) & 0xff).\(slice.minimumOS & 0xff)" + return try ResolvedTarget.existing(triple: TargetPlatform.macOS.triple(architecture: architecture, version: minimum)) + } + + @discardableResult + public static func validate(bundle: URL, target: ResolvedTarget) throws -> [String] { + guard target.platform == .macOS else { throw StringError("Expected a macOS artifact target") } + let root = bundle.resolvingSymlinksInPath().standardizedFileURL + let main = try executable(in: root) + let inventory = try ArtifactInventory(directory: root, hashContents: false) + var code: [String: MachOBinary.Slice] = [:] + var signing: Set = [] + let cpu = try MachOBinary.cpuType(for: target.architecture) + let platform = MachOBinary.platformType(for: .macOS) + for (path, entry) in inventory.files where entry.kind == "file" { + let url = root.appendingPathComponent(path) + let handle = try FileHandle(forReadingFrom: url) + let prefix = try handle.read(upToCount: 4) ?? Data() + try handle.close() + guard [Data([0xcf, 0xfa, 0xed, 0xfe]), Data([0xca, 0xfe, 0xba, 0xbe]), + Data([0xbe, 0xba, 0xfe, 0xca])].contains(prefix) else { continue } + let slices = try MachOBinary.inspect(contentsOf: url) + guard let slice = slices.first(where: { $0.cpu == cpu }), [2, 6, 8].contains(slice.fileType), + slice.platform == platform, slice.minimumOS <= MachOBinary.packedVersion(target.deploymentTarget) else { + throw StringError("Incompatible nested code: \(path)") + } + code[path] = slice + let node = try signingPath(for: path, in: root) + guard signing.insert(node).inserted else { + throw StringError("Multiple principal executables share signing node '\(node)'; keep one framework version") + } + } + let mainPath = String(main.path.dropFirst(root.path.count + 1)) + guard code[mainPath]?.fileType == 2 else { throw StringError("Application executable is not macOS Mach-O code") } + try validateDependencies(code, root: root, main: mainPath) + return signing.sorted { + let left = $0.split(separator: "/").count, right = $1.split(separator: "/").count + return left == right ? $0 < $1 : left > right + } + } + + /// Maps only a bundle's declared principal executable to its signing node. + /// Additional Mach-O files remain separate nodes, even in Contents/MacOS. + public static func signingPath(for executablePath: String, in bundle: URL) throws -> String { + try ResourceDestination.validateRelativePath(executablePath) + let root = bundle.resolvingSymlinksInPath().standardizedFileURL + let executable = root.appendingPathComponent(executablePath).resolvingSymlinksInPath().standardizedFileURL + guard executable.path.hasPrefix(root.path + "/") else { + throw StringError("Signing executable escapes bundle: \(executablePath)") + } + let path = String(executable.path.dropFirst(root.path.count + 1)) + let parts = path.split(separator: "/").map(String.init) + if let index = parts.lastIndex(where: { $0.hasSuffix(".framework") }) { + let frameworkPath = parts[...index].joined(separator: "/") + let framework = root.appendingPathComponent(frameworkPath) + let version = executable.deletingLastPathComponent() + let candidates = [version.appendingPathComponent("Resources/Info.plist"), + framework.appendingPathComponent("Resources/Info.plist"), + framework.appendingPathComponent("Info.plist")] + if let plist = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }) { + let info = try metadata(at: plist) + if let name = info["CFBundleExecutable"] as? String { + try ResourceDestination.validateComponent(name, "Framework executable name") + let expected = version.appendingPathComponent(name).resolvingSymlinksInPath() + // A helper's directory is not a framework principal location. + let principalDirectory = parts.count == index + 2 || + (parts.count == index + 4 && parts[index + 1] == "Versions") + if principalDirectory && expected == executable { return frameworkPath } + } + } else if String(parts[index].dropLast(".framework".count)) == parts.last, + parts.count == index + 2 || (parts.count == index + 4 && parts[index + 1] == "Versions") { + // Preserve support for minimal frameworks without metadata. + return frameworkPath + } + } + if parts.count >= 3, parts[parts.count - 3] == "Contents", parts[parts.count - 2] == "MacOS" { + let bundlePath = parts.dropLast(3).joined(separator: "/") + let plist = root.appendingPathComponent(bundlePath).appendingPathComponent("Contents/Info.plist") + if FileManager.default.fileExists(atPath: plist.path), + try metadata(at: plist)["CFBundleExecutable"] as? String == parts.last { + return bundlePath + } + } + return path + } + + private static func metadata(at url: URL) throws -> [String: Any] { + guard let info = try PropertyListSerialization.propertyList(from: Data(contentsOf: url), format: nil) + as? [String: Any] else { throw StringError("Invalid bundle metadata: \(url.path)") } + return info + } + + private static func validateDependencies(_ code: [String: MachOBinary.Slice], root: URL, main: String) throws { + func systemLibrary(_ path: String) -> Bool { + guard path.hasPrefix("/") else { return false } + let canonical = URL(fileURLWithPath: path).standardizedFileURL.path + return canonical.hasPrefix("/System/Library/") || canonical.hasPrefix("/usr/lib/") + } + func resolved(_ path: String, loader: String, executable: String) -> String? { + if systemLibrary(path) { return path } + let expanded: String + if path.hasPrefix("@loader_path/") { + expanded = (loader as NSString).deletingLastPathComponent + "/" + path.dropFirst(13) + } else if path.hasPrefix("@executable_path/") { + expanded = (executable as NSString).deletingLastPathComponent + "/" + path.dropFirst(17) + } else { return nil } + let url = root.appendingPathComponent(expanded).resolvingSymlinksInPath().standardizedFileURL + guard url.path.hasPrefix(root.path + "/") else { return nil } + return String(url.path.dropFirst(root.path.count + 1)) + } + func visit(_ path: String, executable: String, inherited: [String], visited: inout Set) throws { + guard visited.insert(path).inserted, let slice = code[path] else { return } + let rpaths = slice.rpaths.compactMap { resolved($0 + "/", loader: path, executable: executable) } + inherited + for dependency in slice.dependencies { + let name = dependency.path + if systemLibrary(name) { continue } + let candidates: [String] + if name.hasPrefix("@rpath/") { + candidates = rpaths.map { $0 + "/" + name.dropFirst(7) } + } else { + candidates = resolved(name, loader: path, executable: executable).map { [$0] } ?? [] + } + let match = candidates.compactMap { candidate -> String? in + let url = root.appendingPathComponent(candidate).resolvingSymlinksInPath().standardizedFileURL + guard url.path.hasPrefix(root.path + "/") else { return nil } + let relative = String(url.path.dropFirst(root.path.count + 1)) + return code[relative] != nil ? relative : nil + }.first + guard let match else { + if dependency.weak { continue } + // Swift's back-deployment runtime is referenced through @rpath and ships with the OS. + // Any other @rpath library must be embedded; a system rpath alone does not resolve it. + if name.hasPrefix("@rpath/libswift"), candidates.contains(where: systemLibrary) { continue } + throw StringError("Unresolved runtime dependency '\(name)' in \(path)") + } + try visit(match, executable: executable, inherited: rpaths, visited: &visited) + } + } + var covered: Set = [] + for (path, slice) in code where slice.fileType == 2 { + var visited: Set = [] + try visit(path, executable: path, inherited: [], visited: &visited) + covered.formUnion(visited) + } + // Also check code loaded dynamically and therefore absent from LC_LOAD_DYLIB edges. + let mainRpaths = code[main]?.rpaths.compactMap { resolved($0 + "/", loader: main, executable: main) } ?? [] + for (path, slice) in code where slice.fileType != 2 && !covered.contains(path) { + var visited: Set = [] + try visit(path, executable: main, inherited: mainRpaths, visited: &visited) + } + } +} diff --git a/Sources/PackLib/MacSigningPlan.swift b/Sources/PackLib/MacSigningPlan.swift new file mode 100644 index 00000000..e6e25fe5 --- /dev/null +++ b/Sources/PackLib/MacSigningPlan.swift @@ -0,0 +1,105 @@ +import Crypto +import Foundation +import XUtils + +public enum MacSigningMode: String, Codable, Sendable, CaseIterable { + case adHoc = "ad-hoc" + case development + case developerID = "developer-id" +} + +/// Frozen signing inputs. Entitlements are copied into this plan, never reread during signing. +public struct MacSigningPlan: Codable, Sendable { + public struct Target: Codable, Sendable { + public var path: String + public var entitlements: Data? + + public init(path: String, entitlements: Data?) { + self.path = path + self.entitlements = entitlements + } + } + + public let bundleName: String + public let target: ResolvedTarget + public let mode: MacSigningMode + public let identity: String? + public let inputArtifactSHA256: String + public let inputInventory: ArtifactInventory + public let targets: [Target] + + public static func validateIdentity(mode: MacSigningMode, identity: String?) throws { + if mode == .adHoc { + guard identity == nil else { throw StringError("Ad-hoc signing cannot select a certificate identity") } + } else { + guard let identity, identity.count == 40, + identity.unicodeScalars.allSatisfy({ CharacterSet(charactersIn: "0123456789abcdefABCDEF").contains($0) }) else { + throw StringError("Certificate signing requires an explicit 40-character SHA-1 certificate fingerprint") + } + } + } + + public static func make(bundle: URL, mode: MacSigningMode, identity: String?) throws -> Self { + try validateIdentity(mode: mode, identity: identity) + try MacArtifact.verify(bundle: bundle) + let artifactURL = MacArtifact.manifestURL(for: bundle) + let artifactData = try Data(contentsOf: artifactURL) + let artifact = try JSONDecoder().decode(MacArtifact.self, from: artifactData) + let manifestURL = MacArtifact.signingManifestURL(for: bundle) + let manifestData = try Data(contentsOf: manifestURL) + guard ArtifactInventory.hash(data: manifestData) == artifact.signingInputs[manifestURL.lastPathComponent] else { + throw StringError("Signing manifest changed while planning") + } + let manifest = try JSONDecoder().decode(DeferredSigningManifest.self, from: manifestData) + var entitlementInputs: [String: Data] = [:] + var products = Set() + for product in manifest.products { + // MacArtifact.verify bound each product to its layout; a helper's bundle is its executable. + let path = product.bundlePath + guard artifact.signingOrder.contains(path), products.insert(path).inserted else { + throw StringError("Signing product does not uniquely match nested code: \(path)") + } + if let input = product.entitlementsPath { + let data = try Data(contentsOf: bundle.deletingLastPathComponent().appendingPathComponent(input)) + guard ArtifactInventory.hash(data: data) == artifact.signingInputs[input] else { + throw StringError("Entitlements changed while planning") + } + try validateEntitlements(data) + if mode == .developerID { + let values = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] + guard values?["com.apple.security.get-task-allow"] as? Bool != true, + values?["get-task-allow"] as? Bool != true else { + throw StringError("Developer ID distribution cannot enable get-task-allow") + } + } + entitlementInputs[path] = data + } + } + let result = Self(bundleName: bundle.lastPathComponent, target: artifact.target, mode: mode, + identity: identity?.uppercased(), inputArtifactSHA256: ArtifactInventory.hash(data: artifactData), + inputInventory: artifact.inventory, + targets: artifact.signingOrder.map { Target(path: $0, entitlements: entitlementInputs[$0]) }) + // Detect inputs changing while the plan was read. + try MacArtifact.verify(bundle: bundle) + guard try ArtifactInventory.hash(artifactURL) == result.inputArtifactSHA256 else { + throw StringError("Artifact handoff changed while planning signing") + } + return result + } + + public static func validateEntitlements(_ data: Data) throws { + guard let values = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { + throw StringError("Entitlements must be a property-list dictionary") + } + // These capabilities require provisioning or a separate activation contract. + let restricted = ["application-identifier", "com.apple.application-identifier", "com.apple.developer.team-identifier", + "keychain-access-groups", "com.apple.security.application-groups"] + guard !values.keys.contains(where: { restricted.contains($0) || $0.hasPrefix("com.apple.developer.") }) else { + throw StringError("Provisioning-dependent entitlements are not supported by Mac signing yet") + } + } + + public static func certificateFingerprint(_ data: Data) -> String { + Insecure.SHA1.hash(data: data).map { String(format: "%02X", $0) }.joined() + } +} diff --git a/Sources/PackLib/MacSigningReceipt.swift b/Sources/PackLib/MacSigningReceipt.swift new file mode 100644 index 00000000..f8808097 --- /dev/null +++ b/Sources/PackLib/MacSigningReceipt.swift @@ -0,0 +1,85 @@ +import Foundation +import XUtils + +/// Exact signed bytes and their original build handoff; this is a local receipt, not an attestation. +public struct MacSigningReceipt: Codable, Sendable { + public struct Signature: Codable, Sendable, Equatable { + public let path: String + public let certificateSHA1: String? + public let teamIdentifier: String? + public let authority: String? + public let hardenedRuntime: Bool + public let timestamp: String? + + public init(path: String, certificateSHA1: String?, teamIdentifier: String?, authority: String?, + hardenedRuntime: Bool, timestamp: String?) { + self.path = path + self.certificateSHA1 = certificateSHA1 + self.teamIdentifier = teamIdentifier + self.authority = authority + self.hardenedRuntime = hardenedRuntime + self.timestamp = timestamp + } + } + + public var signatures: [Signature] = [] + public var version: Int = 1 + public let bundleName: String + public let target: ResolvedTarget + public let mode: MacSigningMode + public let identity: String? + public let inputArtifactSHA256: String + public let inventory: ArtifactInventory + public let targets: [MacSigningPlan.Target] + + public init(plan: MacSigningPlan, bundle: URL) throws { + bundleName = plan.bundleName + target = plan.target + mode = plan.mode + identity = plan.identity + inputArtifactSHA256 = plan.inputArtifactSHA256 + inventory = try ArtifactInventory(directory: bundle) + targets = plan.targets + } + + public static func receiptURL(for bundle: URL) -> URL { + bundle.deletingPathExtension().appendingPathExtension("signing-receipt.json") + } + + public func write(beside bundle: URL) throws { + try JSONEncoder.sortedPretty().encode(self).write(to: Self.receiptURL(for: bundle), options: .atomic) + } + + @discardableResult + public static func verify(bundle: URL) throws -> Self { + let receipt = try JSONDecoder().decode(Self.self, from: Data(contentsOf: receiptURL(for: bundle))) + guard receipt.version == 1, receipt.bundleName == bundle.lastPathComponent, + receipt.inputArtifactSHA256.count == 64, + receipt.inputArtifactSHA256.allSatisfy({ $0.isHexDigit }) else { + throw StringError("Invalid Mac signing receipt") + } + try MacSigningPlan.validateIdentity(mode: receipt.mode, identity: receipt.identity) + try receipt.inventory.verify(directory: bundle) + let order = try MacBundleAudit.validate(bundle: bundle, target: receipt.target) + guard receipt.targets.map(\.path) == order else { throw StringError("Signing receipt code inventory mismatch") } + guard receipt.signatures.map(\.path) == order else { + throw StringError("Signing receipt lacks verified signature metadata") + } + for signature in receipt.signatures { + guard signature.certificateSHA1 == receipt.identity, + receipt.mode == .adHoc || signature.teamIdentifier?.isEmpty == false else { + throw StringError("Signing receipt certificate metadata mismatch") + } + if receipt.mode == .developerID { + guard signature.hardenedRuntime, signature.timestamp != nil, + signature.authority?.hasPrefix("Developer ID Application:") == true else { + throw StringError("Signing receipt lacks Developer ID distribution requirements") + } + } + } + for target in receipt.targets { + if let entitlements = target.entitlements { try MacSigningPlan.validateEntitlements(entitlements) } + } + return receipt + } +} diff --git a/Sources/PackLib/PackSchema.swift b/Sources/PackLib/PackSchema.swift index 297c4241..839f82b9 100644 --- a/Sources/PackLib/PackSchema.swift +++ b/Sources/PackLib/PackSchema.swift @@ -5,6 +5,7 @@ import XUtils public struct PackSchemaBase: Codable, Sendable { public enum Version: Int, Codable, Sendable { case v1 = 1 + case v2 = 2 } public var version: Version @@ -14,6 +15,13 @@ public struct PackSchemaBase: Codable, Sendable { public var product: String? + /// Version 2 only. Omission retains the legacy arm64 iOS target. + public var target: String? + public var deploymentTarget: String? + public var source: ProductSource? + public var resourceCopies: [ResourceCopy]? + public var nestedProducts: [NestedProduct]? + public var infoPath: String? public var entitlementsPath: String? @@ -31,6 +39,18 @@ public struct PackSchemaBase: Codable, Sendable { public var infoPath: String public var resources: [String]? public var entitlementsPath: String? + public var source: ProductSource? + public var resourceCopies: [ResourceCopy]? + } + + public struct NestedProduct: Codable, Sendable { + public var product: String + public var kind: Plan.ProductType + public var source: ProductSource + public var bundleID: String? + public var infoPath: String? + public var entitlementsPath: String? + public var resourceCopies: [ResourceCopy]? } } @@ -56,9 +76,7 @@ public struct PackSchema: Sendable { public init(validating base: PackSchemaBase) throws { self.base = base - if base.version != .v1 { - throw StringError("xtool.yml: Unsupported schema version: \(base.version.rawValue)") - } + try Self.validateConfiguration(base) switch (base.bundleID, base.orgID) { case (let bundleID?, _): @@ -71,8 +89,69 @@ public struct PackSchema: Sendable { if let iconPath = base.iconPath { let ext = URL(fileURLWithPath: iconPath).pathExtension - guard ext == "png" else { - throw StringError("xtool.yml: iconPath should have a 'png' path extension. Got '\(ext)'.") + let allowed = base.version == .v1 ? ["png"] : ["png", "icns"] + guard allowed.contains(ext) else { + throw StringError("xtool.yml: iconPath must be a prepared \(allowed.joined(separator: " or ")) file. Got '\(ext)'.") + } + } + } + + private static func validateConfiguration(_ base: PackSchemaBase) throws { + if base.version == .v1 { + guard base.target == nil, base.deploymentTarget == nil, base.source == nil, + base.resourceCopies == nil, base.nestedProducts == nil, + !(base.extensions ?? []).contains(where: { $0.source != nil || $0.resourceCopies != nil }) else { + throw StringError("xtool.yml: target, deploymentTarget, source, resourceCopies and nestedProducts require version: 2") + } + } + if let target = base.target { _ = try TargetTriple(target) } + if let deployment = base.deploymentTarget { try TargetTriple.validateVersion(deployment) } + try base.source?.validate() + try validateProducts(base) + } + + private static func validateProducts(_ base: PackSchemaBase) throws { + for product in base.nestedProducts ?? [] { + try Self.validateName(product.product) + guard product.kind != .application else { + throw StringError("xtool.yml: nestedProducts cannot contain an application") + } + try product.source.validate() + if product.kind == .appExtension || product.kind == .xpcService { + guard product.infoPath != nil else { + throw StringError("xtool.yml: nested extensions and XPC services require infoPath") + } + } + } + let copies = (base.resourceCopies ?? []) + (base.extensions ?? []).flatMap { $0.resourceCopies ?? [] } + + (base.nestedProducts ?? []).flatMap { $0.resourceCopies ?? [] } + for copy in copies { + guard !copy.source.isEmpty else { throw StringError("xtool.yml: resource source cannot be empty") } + try copy.destination.validate() + } + if let product = base.product { try Self.validateName(product) } + for ext in base.extensions ?? [] { + try Self.validateName(ext.product) + try ext.source?.validate() + } + let names = (base.extensions ?? []).map(\.product) + (base.nestedProducts ?? []).map(\.product) + guard Set(names).count == names.count else { + throw StringError("xtool.yml: nested product names must be unique") + } + } + + private static func validateName(_ name: String) throws { + try ResourceDestination.validateComponent(name, "xtool.yml: product name") + } + + /// Validate against the effective CLI/schema target, after precedence is applied. + public func validate(for platform: TargetPlatform) throws { + if platform != .macOS { + guard !(base.nestedProducts ?? []).contains(where: { $0.kind == .xpcService || $0.kind == .helperExecutable }) else { + throw StringError("xtool.yml: XPC services and helper executables require a macOS target") + } + if let iconPath = base.iconPath, URL(fileURLWithPath: iconPath).pathExtension == "icns" { + throw StringError("xtool.yml: icns icons require a macOS target") } } } diff --git a/Sources/PackLib/Packer.swift b/Sources/PackLib/Packer.swift index bd2057b7..f53c112b 100644 --- a/Sources/PackLib/Packer.swift +++ b/Sources/PackLib/Packer.swift @@ -1,261 +1,37 @@ import Foundation import XUtils -import Subprocess +import Dependencies public struct Packer: Sendable { public let buildSettings: BuildSettings public let plan: Plan + @Dependency(\.productBuilder) private var productBuilder + public init(buildSettings: BuildSettings, plan: Plan) { self.plan = plan self.buildSettings = buildSettings } - private func build() async throws { - let xtoolDir = URL(fileURLWithPath: "xtool") - let packageDir = xtoolDir.appendingPathComponent(".xtool-tmp") - try? FileManager.default.removeItem(at: packageDir) - try FileManager.default.createDirectory(at: packageDir, withIntermediateDirectories: true) - - let packageSwift = packageDir.appendingPathComponent("Package.swift") - let contents = """ - // swift-tools-version: 6.0 - import PackageDescription - let package = Package( - name: "\(plan.app.product)-Builder", - platforms: [ - .iOS("\(plan.app.deploymentTarget)"), - ], - dependencies: [ - .package(name: "RootPackage", path: "../.."), - ], - targets: [ - \( - plan.allProducts.map { - """ - .executableTarget( - name: "\($0.targetName)", - dependencies: [ - .product(name: "\($0.product)", package: "RootPackage"), - ], - linkerSettings: \($0.linkerSettings) - ) - """ - } - .joined(separator: ",\n") - ) - ] - )\n - """ - try Data(contents.utf8).write(to: packageSwift) - - for product in plan.allProducts { - let sources: URL = packageDir.appendingPathComponent("Sources/\(product.targetName)", isDirectory: true) - try FileManager.default.createDirectory(at: sources, withIntermediateDirectories: true) - try Data().write(to: sources.appendingPathComponent("stub.c", isDirectory: false)) - } - - let buildConfig = try await buildSettings - .withPackagePath(packageDir.path) - .swiftPMInvocation( - forTool: "build", - arguments: [ - "--scratch-path", ".build", - // resolving can cause SwiftPM to overwrite the root package deps - // with just the deps needed for the builder package (which is to - // say, any "dev dependencies" of the root package may be removed.) - // fortunately we've already resolved the root package by this point - // in order to dump the plan, so we can skip resolution here to skirt - // the issue. - "--disable-automatic-resolution", - ], - ) - try await Subprocess.run( - buildConfig, - output: .currentStandardOutput, - error: .currentStandardError, - ) - .checkSuccess() - } - public func pack() async throws -> URL { - try await build() - - let output = try TemporaryDirectory(name: "\(plan.app.product).app") - - let outputURL = output.url - - let binPath: String - switch buildSettings.buildSystem { - case .swiftPM: - binPath = "\(buildSettings.triple)/\(buildSettings.configuration.rawValue)" - case .swiftBuild: - let platformName = buildSettings.triple.contains("simulator") ? "iphonesimulator" : "iphoneos" - binPath = "out/Products/\(buildSettings.configuration.swiftBuildValue)-\(platformName)" - } - let binDir = URL( - fileURLWithPath: ".build/\(binPath)", - isDirectory: true - ) - - try await withThrowingTaskGroup(of: Void.self) { group in - for product in plan.allProducts { - try pack( - product: product, - binDir: binDir, - outputURL: product.directory(inApp: outputURL), - &group - ) - } - - while !group.isEmpty { - do { - try await group.next() - } catch is CancellationError { - // continue - } catch { - group.cancelAll() - throw error - } - } - } - - let dest = URL(fileURLWithPath: "xtool").appendingPathComponent(outputURL.lastPathComponent) - try? FileManager.default.removeItem(at: dest) - try output.persist(at: dest) - return dest - } - - @Sendable private func pack( - product: Plan.Product, - binDir: URL, - outputURL: URL, - _ group: inout ThrowingTaskGroup - ) throws { - @Sendable func packFileToRoot(srcName: String) async throws { - let srcURL = URL(fileURLWithPath: srcName) - let destURL = outputURL.appendingPathComponent(srcURL.lastPathComponent) - try FileManager.default.copyItem(at: srcURL, to: destURL) - - try Task.checkCancellation() - } - - @Sendable func packFile(srcName: String, dstName: String? = nil, sign: Bool = false) async throws { - let srcURL = URL(fileURLWithPath: srcName, relativeTo: binDir) - let dstURL = URL(fileURLWithPath: dstName ?? srcURL.lastPathComponent, relativeTo: outputURL) - try? FileManager.default.createDirectory(at: dstURL.deletingLastPathComponent(), withIntermediateDirectories: true) - try FileManager.default.copyItem(at: srcURL, to: dstURL) - - try Task.checkCancellation() - } - - // Ensure output directory is available - try? FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) - - for command in product.resources { - group.addTask { - switch command { - case .bundle(let package, let target): - try await packFile(srcName: "\(package)_\(target).bundle") - case .binaryTarget(let name): - let src = URL(fileURLWithPath: "\(name).framework/\(name)", relativeTo: binDir) - let magic = Data("!\n".utf8) - let thinMagic = Data("!\n".utf8) - guard let bytes = try? FileHandle(forReadingFrom: src).read(upToCount: magic.count) else { - // if we can't find the binary, it might be a static framework that SwiftPM - // did not copy into the .build directory. we don't need to pack it anyway. - break - } - // if the magic matches one of these it's a static archive; don't embed it. - // https://github.com/apple/llvm-project/blob/e716ff14c46490d2da6b240806c04e2beef01f40/llvm/include/llvm/Object/Archive.h#L33 - // swiftlint:disable:previous line_length - if bytes != magic && bytes != thinMagic { - try await packFile(srcName: "\(name).framework", dstName: "Frameworks/\(name).framework", sign: true) - } - case .library(let name): - try await packFile(srcName: "lib\(name).dylib", dstName: "Frameworks/lib\(name).dylib", sign: true) - case .root(let source): - try await packFileToRoot(srcName: source) - } - } - } - if let iconPath = product.iconPath { - group.addTask { - try await packFileToRoot(srcName: iconPath) - } - } - group.addTask { - try await packFile(srcName: product.targetName, dstName: product.product) - } - group.addTask { - var info = product.infoPlist - - if product.type == .application { - info["UIRequiredDeviceCapabilities"] = ["arm64"] - info["LSRequiresIPhoneOS"] = true - info["CFBundleSupportedPlatforms"] = Packer.cfBundleSupportedPlatforms(for: buildSettings.triple) - } - - if let iconPath = product.iconPath { - let iconName = URL(fileURLWithPath: iconPath).deletingPathExtension().lastPathComponent - info["CFBundleIconFile"] = iconName - } - - let infoPath = outputURL.appendingPathComponent("Info.plist") - let encodedPlist = try PropertyListSerialization.data( - fromPropertyList: info, - format: .xml, - options: 0 - ) - try encodedPlist.write(to: infoPath) - } - } - - static func cfBundleSupportedPlatforms(for triple: String) -> [String] { - let components = triple.split(separator: "-").map(String.init) - guard let appleIndex = components.firstIndex(of: "apple"), - components.indices.contains(appleIndex + 1) else { - return ["iPhoneOS"] - } - - let os = components[appleIndex + 1].prefix { !$0.isNumber } - switch os { - case "ios" where components.contains("simulator"): - return ["iPhoneSimulator"] - case "ios": - return ["iPhoneOS"] - case "macos", "macosx": - return ["MacOSX"] - default: - return ["iPhoneOS"] - } + let artifacts = try await productBuilder.build(buildSettings, plan) + return try await assemble(artifacts: artifacts) } -} -extension Plan.Product { - fileprivate var linkerSettings: String { - switch self.type { - case .application: """ - [ - .unsafeFlags([ - "-Xlinker", "-rpath", "-Xlinker", "@executable_path/Frameworks", - ]), - ] - """ - case .appExtension: """ - [ - // Link to Foundation framework which implements the _NSExtensionMain entrypoint - .linkedFramework("Foundation"), - .unsafeFlags([ - // Set the entry point to Foundation`_NSExtensionMain - "-Xlinker", "-e", "-Xlinker", "_NSExtensionMain", - // Include frameworks that the host app may use - "-Xlinker", "-rpath", "-Xlinker", "@executable_path/../../Frameworks", - // ...as well as our own - "-Xlinker", "-rpath", "-Xlinker", "@executable_path/Frameworks", - ]), - ] - """ - } + /// Assembly consumes completed build artifacts and never compiles or signs. + public func assemble(artifacts: BuildArtifacts) async throws -> URL { + try ResourceDestination.validateComponent(plan.app.product, "Application name") + let output = try TemporaryDirectory(name: "\(plan.app.product).app") + let root = URL(fileURLWithPath: buildSettings.packagePath, isDirectory: true).standardizedFileURL + let assembler = BundleAssembler(plan: plan, sourceDirectory: root) + try assembler.assemble(artifacts: artifacts, at: output.url) + let directory = root.appendingPathComponent("xtool", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let destination = directory.appendingPathComponent(output.url.lastPathComponent) + if FileManager.default.fileExists(atPath: destination.path) { + try FileManager.default.removeItem(at: destination) + } + try output.persist(at: destination) + return destination } } diff --git a/Sources/PackLib/Planner.swift b/Sources/PackLib/Planner.swift index df2f0125..7b8f95ba 100644 --- a/Sources/PackLib/Planner.swift +++ b/Sources/PackLib/Planner.swift @@ -1,3 +1,4 @@ +import Dependencies import Foundation import XUtils import Subprocess @@ -6,396 +7,343 @@ public struct Planner: Sendable { public var buildSettings: BuildSettings public var schema: PackSchema - public init( - buildSettings: BuildSettings, - schema: PackSchema - ) { + @Dependency(\.packageInspection) private var inspection + + public init(buildSettings: BuildSettings, schema: PackSchema) { self.buildSettings = buildSettings self.schema = schema } - private static let decoder: JSONDecoder = { + fileprivate static let decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return decoder }() - // anything older than this requires bundling the stdlib which - // is doable but probably not worth the effort - private static let minSupportedIOSVersion = "13.0" + /// Packages inspected at once, each by two SwiftPM processes. Unbounded, a large graph + /// would start hundreds of processes; beyond the core count they only compete. + static let inspectionLimit = max(2, ProcessInfo.processInfo.activeProcessorCount) private func buildGraph() async throws -> PackageGraph { - let dependencyRoot = try await dumpDependencies() - + let dependencyRoot = try Self.decoder.decode( + PackageDependency.self, from: try await inspection.dependencies(buildSettings) + ) + let cacheDirectory = URL(fileURLWithPath: buildSettings.packagePath, isDirectory: true) + .appendingPathComponent(".build/xtool-inspection", isDirectory: true) + var nodes: [PackageDependency] = [] + var visited: Set = [] + var dependencies = [dependencyRoot] + while let node = dependencies.popLast() { + guard visited.insert(node.identity).inserted else { continue } + dependencies.append(contentsOf: node.dependencies) + nodes.append(node) + } + let toolchain = nodes.contains(where: \.isPinned) ? try await inspection.toolchain() : "" let packages = try await withThrowingTaskGroup( - of: (PackageDependency, PackageDump).self, - returning: [String: PackageDump].self + of: (String, InspectedPackage).self, returning: [String: InspectedPackage].self ) { group in - var visited: Set = [] - var dependencies: [PackageDependency] = [dependencyRoot] - while let dependencyNode = dependencies.popLast() { - guard visited.insert(dependencyNode.identity).inserted else { continue } - dependencies.append(contentsOf: dependencyNode.dependencies) - group.addTask { (dependencyNode, try await dumpPackage(at: dependencyNode.path)) } - } - - var packages: [String: PackageDump] = [:] - while let result = await group.nextResult() { - switch result { - case .success((let dependency, let dump)): - packages[dependency.identity] = dump - case .failure(_ as CancellationError): - // continue loop - break - case .failure(let error): - group.cancelAll() - throw error + var packages: [String: InspectedPackage] = [:] + for (index, node) in nodes.enumerated() { + if index >= Self.inspectionLimit, let (identity, package) = try await group.next() { + packages[identity] = package + } + group.addTask { + let cache = node.cacheKey(toolchain: toolchain).map { cacheDirectory.appendingPathComponent("\($0).json") } + return try await (node.identity, inspect(node, cache: cache)) } } - + for try await (identity, package) in group { packages[identity] = package } return packages } - - var packagesByProductName: [String: String] = [:] - for (packageID, package) in packages { - for product in package.products ?? [] { - packagesByProductName[product.name] = packageID - } + guard packages[dependencyRoot.identity] != nil else { + throw StringError("Could not inspect root package '\(dependencyRoot.identity)'") } + return PackageGraph(rootIdentity: dependencyRoot.identity, packages: packages) + } - let rootPackage = packages[dependencyRoot.identity]! - - return PackageGraph( - root: rootPackage, - packages: packages, - packagesByProductName: packagesByProductName - ) + /// A checkout pinned to a released version always inspects the same, so its results are reused across + /// builds. Local and branch dependencies change without a new version and are inspected every time. + private func inspect(_ node: PackageDependency, cache: URL?) async throws -> InspectedPackage { + if let cache, let data = try? Data(contentsOf: cache), + let package = try? JSONDecoder().decode(RawInspection.self, from: data).decoded() { + return package + } + let settings = buildSettings.withPackagePath(node.path) + async let description = inspection.describe(settings) + async let manifest = inspection.manifest(settings) + let raw = try await RawInspection(description: jsonContents(description), manifest: jsonContents(manifest)) + let package = try raw.decoded() + if let cache { + // A failed write only costs the next build this inspection. + try? FileManager.default.createDirectory(at: cache.deletingLastPathComponent(), withIntermediateDirectories: true) + try? JSONEncoder().encode(raw).write(to: cache, options: .atomic) + } + return package } public func createPlan() async throws -> Plan { - // TODO: cache plan using (Package.swift+Package.resolved) as the key? - + let platform = try TargetTriple(buildSettings.triple).platform + try schema.validate(for: platform) let graph = try await buildGraph() - - let app = try await product( - from: graph, - matching: schema.product, - type: .application, - plist: schema.infoPath, - idSpecifier: schema.idSpecifier, - iconPath: schema.iconPath, - rootResources: schema.resources, - entitlementsPath: schema.entitlementsPath + let target = try ResolvedTarget.resolve( + cliTarget: buildSettings.triple, schema: schema, + packageDeploymentTargets: Dictionary( + (graph.root.description.platforms ?? []).map { ($0.name, $0.version) }, + uniquingKeysWith: { first, _ in first } + ) ) - - let extensionProducts: [Plan.Product] - if let extensions = schema.extensions, !extensions.isEmpty { - extensionProducts = try await withThrowingTaskGroup(of: Plan.Product.self) { group in - for ext in extensions { - group.addTask { - try await product( - from: graph, - matching: ext.product, - type: .appExtension, - plist: ext.infoPath, - idSpecifier: ext.bundleID.flatMap(PackSchema.IDSpecifier.bundleID) ?? .orgID(app.bundleID), - iconPath: nil, - rootResources: ext.resources, - entitlementsPath: ext.entitlementsPath - ) - } - } - return try await group.reduce(into: []) { $0.append($1) } - } - } else { - extensionProducts = [] + let app = try await product(from: graph, target: target, request: ProductRequest( + name: schema.product, source: schema.source, type: .application, + plist: schema.infoPath, idSpecifier: schema.idSpecifier, iconPath: schema.iconPath, + rootResources: schema.resources, resourceCopies: schema.resourceCopies, entitlementsPath: schema.entitlementsPath + )) + var extensions: [Plan.Product] = [] + for ext in schema.extensions ?? [] { + extensions.append(try await product(from: graph, target: target, request: ProductRequest( + name: ext.product, source: ext.source, type: .appExtension, + plist: ext.infoPath, idSpecifier: ext.bundleID.map(PackSchema.IDSpecifier.bundleID) ?? .orgID(app.bundleID), + rootResources: ext.resources, resourceCopies: ext.resourceCopies, entitlementsPath: ext.entitlementsPath + ))) } + var nested: [Plan.Product] = [] + for item in schema.nestedProducts ?? [] { + nested.append(try await product(from: graph, target: target, request: ProductRequest( + name: item.product, source: item.source, type: item.kind, + plist: item.infoPath, idSpecifier: item.bundleID.map(PackSchema.IDSpecifier.bundleID) ?? .orgID(app.bundleID), + resourceCopies: item.resourceCopies, entitlementsPath: item.entitlementsPath + ))) + } + return try Plan(target: target, app: app, extensions: extensions, nestedProducts: nested) + } - return Plan(app: app, extensions: extensionProducts) + private struct ProductRequest { + var name: String? + var source: ProductSource? + var type: Plan.ProductType + var plist: String? + var idSpecifier: PackSchema.IDSpecifier + var iconPath: String? + var rootResources: [String]? + var resourceCopies: [ResourceCopy]? + var entitlementsPath: String? } - // swiftlint:disable cyclomatic_complexity function_parameter_count private func product( - from graph: PackageGraph, - matching name: String?, - type: Plan.ProductType, - plist: String?, - idSpecifier: PackSchema.IDSpecifier, - iconPath: String?, - rootResources: [String]?, - entitlementsPath: String? + from graph: PackageGraph, target: ResolvedTarget, request: ProductRequest ) async throws -> Plan.Product { - let library = try selectLibrary( - from: graph.root.products?.filter { $0.type == .autoLibrary } ?? [], - matching: name - ) - var resources: [Plan.Resource] = [] - var visited: Set = [] - var targets = library.targets.map { (graph.root, $0) } - while let (targetPackage, targetName) = targets.popLast() { - guard let target = targetPackage.targets?.first(where: { $0.name == targetName }) else { - throw StringError("Could not find target '\(targetName)' in package '\(targetPackage.name)'") - } - guard visited.insert(targetName).inserted else { continue } - // Macro targets are compiler plugins that run on the host, not code - // linked into the iOS app. Skip them to avoid resolving their - // host-only dependencies (e.g. SwiftSyntaxMacros). - if target.type == "macro" { continue } - if target.moduleType == "BinaryTarget" { - resources.append(.binaryTarget(name: targetName)) - } - if target.resources?.isEmpty == false { - resources.append(.bundle(package: targetPackage.name, target: targetName)) - } - for targetName in (target.targetDependencies ?? []) { - targets.append((targetPackage, targetName)) - } - for productName in (target.productDependencies ?? []) { - let (package, product) = try graph.product(name: productName) - if product.type == .dynamicLibrary { - resources.append(.library(name: productName)) - } - targets.append(contentsOf: product.targets.map { (package, $0) }) + let selection = try selectProduct(from: graph.root.description.products ?? [], request: request) + let name = request.name ?? selection.name + try validateProductName(name) + var resources = try dependencyResources(from: graph, target: target, product: selection.product) + resources += (request.rootResources ?? []).map { .root(source: $0) } + resources += (request.resourceCopies ?? []).map { .copy($0) } + var info = defaultInfo(name: name, type: request.type, target: target, idSpecifier: request.idSpecifier) + if let plist = request.plist { + let data = try await Data(reading: URL(fileURLWithPath: plist)) + guard let overrides = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Sendable] else { + throw StringError("Info.plist has invalid format: expected a dictionary.") } + info.merge(overrides, uniquingKeysWith: { $1 }) } - - if let rootResources { - resources += rootResources.map { .root(source: $0) } + guard let bundleID = info["CFBundleIdentifier"] as? String, !bundleID.isEmpty else { + throw StringError("Info.plist CFBundleIdentifier must be a nonempty string") } + return Plan.Product( + target: target, source: selection.source, type: request.type, product: name, bundleID: bundleID, + infoPlist: info, resources: resources, iconPath: request.iconPath, entitlementsPath: request.entitlementsPath + ) + } - let bundleID = idSpecifier.formBundleID(product: library.name) - let deploymentTarget = graph.root.platforms?.first { $0.name == "ios" }?.version - ?? Self.minSupportedIOSVersion - - var infoPlist: [String: Sendable] = [ + private func defaultInfo( + name: String, type: Plan.ProductType, target: ResolvedTarget, idSpecifier: PackSchema.IDSpecifier + ) -> [String: Sendable] { + var info: [String: Sendable] = [ "CFBundleInfoDictionaryVersion": "6.0", "CFBundleDevelopmentRegion": "en", "CFBundleVersion": "1", "CFBundleShortVersionString": "1.0.0", - "MinimumOSVersion": deploymentTarget, - "CFBundleIdentifier": bundleID, - "CFBundleName": library.name, - "CFBundleExecutable": library.name, - "CFBundleDisplayName": library.name, + "CFBundleIdentifier": idSpecifier.formBundleID(product: name), + "CFBundleName": name, + "CFBundleExecutable": name, + "CFBundleDisplayName": name, "CFBundlePackageType": type.fourCharCode, ] - - switch type { - case .application: - infoPlist["UIDeviceFamily"] = [1, 2] - infoPlist["UISupportedInterfaceOrientations"] = ["UIInterfaceOrientationPortrait"] - infoPlist["UISupportedInterfaceOrientations~ipad"] = [ - "UIInterfaceOrientationPortrait", - "UIInterfaceOrientationPortraitUpsideDown", - "UIInterfaceOrientationLandscapeLeft", - "UIInterfaceOrientationLandscapeRight", + let deploymentKey = target.platform == .macOS ? "LSMinimumSystemVersion" : "MinimumOSVersion" + info[deploymentKey] = target.deploymentTarget + if type == .appExtension { info["NSExtension"] = [:] as [String: Sendable] } + if type == .application && target.platform != .macOS { + info["UIDeviceFamily"] = [1, 2] + info["UISupportedInterfaceOrientations"] = ["UIInterfaceOrientationPortrait"] + info["UISupportedInterfaceOrientations~ipad"] = [ + "UIInterfaceOrientationPortrait", "UIInterfaceOrientationPortraitUpsideDown", + "UIInterfaceOrientationLandscapeLeft", "UIInterfaceOrientationLandscapeRight", ] - infoPlist["UILaunchScreen"] = [:] as [String: Sendable] - case .appExtension: - // Should set default parameters? - infoPlist["NSExtension"] = [:] as [String: Sendable] + info["UILaunchScreen"] = [:] as [String: Sendable] } + return info + } - if let plist { - let data = try await Data(reading: URL(fileURLWithPath: plist)) - let info = try PropertyListSerialization.propertyList(from: data, format: nil) - if let info = info as? [String: Sendable] { - infoPlist.merge(info, uniquingKeysWith: { $1 }) - } else { - throw StringError("Info.plist has invalid format: expected a dictionary.") + private struct Selection { + let name: String + let source: ProductSource + let product: PackageDump.Product? + } + + private func selectProduct(from products: [PackageDump.Product], request: ProductRequest) throws -> Selection { + let selectedName: String? + switch request.source { + case .prebuilt(let path): + return try selectPrebuilt(path: path, name: request.name) + case .library(let name), .executable(let name): + try validateProductName(name) + selectedName = name + case nil: selectedName = request.name + } + let product: PackageDump.Product + if let selectedName { + guard let match = products.first(where: { $0.name == selectedName }) else { + throw StringError("No product named '\(selectedName)' was found. Found: \(products.map(\.name).sorted())") + } + product = match + } else { + // Version 1's implicit selection remains limited to automatic libraries. + let libraries = products.filter { $0.type == .autoLibrary } + guard libraries.count == 1 else { + throw StringError("Expected one automatic library product; specify 'product' or 'source' explicitly. " + + "Found: \(libraries.map(\.name).sorted())") } + product = libraries[0] } - - return Plan.Product( - type: type, - product: library.name, - deploymentTarget: deploymentTarget, - bundleID: bundleID, - infoPlist: infoPlist, - resources: resources, - iconPath: iconPath, - entitlementsPath: entitlementsPath - ) + try validateProductName(product.name) + let source: ProductSource + switch product.type { + case .executable: source = .executable(product: product.name) + case .autoLibrary, .staticLibrary, .dynamicLibrary: source = .library(product: product.name) + case .other: throw StringError("Unsupported product type for '\(product.name)'") + } + if let requested = request.source, requested != source { + throw StringError("Product '\(product.name)' does not match the requested source kind") + } + guard !product.targets.isEmpty else { throw StringError("Product '\(product.name)' has no targets") } + return Selection(name: product.name, source: source, product: product) } - private func dumpDependencies() async throws -> PackageDependency { - let tempDir = try TemporaryDirectory(name: "xtool-dump") - let tempFileURL = tempDir.url.appendingPathComponent("dump.json") - - // SwiftPM sometimes prints extraneous data to stdout, so ask - // it to write the JSON to a temp file instead. See: - // https://github.com/xtool-org/xtool/pull/97#discussion_r2203618825 - _ = try await _dumpAction( - arguments: ["-q", "show-dependencies", "--format", "json", "-o", tempFileURL.path], - path: buildSettings.packagePath - ) - - return try Self.decoder.decode( - PackageDependency.self, - from: Data(contentsOf: tempFileURL) - ) + private func selectPrebuilt(path: String, name: String?) throws -> Selection { + try ProductSource.prebuilt(path: path).validate() + guard !path.contains("\0"), !path.hasSuffix("/") else { + throw StringError("Prebuilt source must identify a file") + } + let name = name ?? URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent + try validateProductName(name) + return Selection(name: name, source: .prebuilt(path: path), product: nil) } - private func dumpPackage(at path: String) async throws -> PackageDump { - let data = try await _dumpAction(arguments: ["-q", "describe", "--type", "json"], path: path) - try Task.checkCancellation() - - // As in dumpDependencies, we may end up with extraneous data, but `describe` - // doesn't have a `-o` flag for a clean workaround. Resort to a heuristic, - // looking for the opening brace. - let fromBrace = data.drop(while: { $0 != Character("{").asciiValue }) - return try Self.decoder.decode(PackageDump.self, from: fromBrace) + private func validateProductName(_ name: String) throws { + try ResourceDestination.validateComponent(name, "Product name") } - private func _dumpAction(arguments: [String], path: String) async throws -> Data { - let dumpConfig = try await buildSettings.withPackagePath(path).swiftPMInvocation( - forTool: "package", - arguments: arguments, - ) - return try await Subprocess.run( - dumpConfig, - output: .data(limit: .max), - error: .currentStandardError, - ) - .checkSuccess() - .standardOutput + private struct TargetIdentity: Hashable { + let package: String + let name: String } - private func selectLibrary( - from products: [PackageDump.Product], - matching name: String? - ) throws -> PackageDump.Product { - switch products.count { - case 0: - throw StringError("No library products were found in the package") - case 1: - let product = products[0] - if let name, product.name != name { - throw StringError(""" - Product name ('\(product.name)') does not match the 'product' value in the schema ('\(name)') - """) + private func dependencyResources( + from graph: PackageGraph, target: ResolvedTarget, product: PackageDump.Product? + ) throws -> [Plan.Resource] { + guard let product else { return [] } + var pending = product.targets.map { TargetIdentity(package: graph.rootIdentity, name: $0) } + var visited: Set = [] + var resources: [Plan.Resource] = product.type == .dynamicLibrary ? [.library(name: product.name)] : [] + var seenResources = Set(resources) + func append(_ resource: Plan.Resource) { + if seenResources.insert(resource).inserted { resources.append(resource) } + } + while let identity = pending.popLast() { + guard visited.insert(identity).inserted else { continue } + guard let package = graph.packages[identity.package], + let described = package.description.targets?.first(where: { $0.name == identity.name }), + let manifest = package.manifest.targets.first(where: { $0.name == identity.name }) else { + throw StringError("Could not find target '\(identity.name)' in package '\(identity.package)'") } - return product - default: - guard let name else { - throw StringError(""" - Multiple library products were found (\(products.map(\.name))). Please either: - 1) Expose exactly one library product, or - 2) Specify the product you want via the 'product' key in xtool.yml. - """) + // Compiler plugins and their dependencies run on the host, never in the bundle. + if described.type == "macro" || ["macro", "plugin"].contains(manifest.type) { continue } + if described.moduleType == "BinaryTarget" { + append(.binaryTarget(name: identity.name, xcframework: package.description.localXCFramework(described))) } - guard let product = products.first(where: { $0.name == name }) else { - throw StringError(""" - Schema declares a 'product' name of '\(name)' but no matching product was found. - Found: \(products.map(\.name)). - """) + if described.resources?.isEmpty == false { + append(.bundle(package: package.description.name, target: identity.name)) + } + for dependency in manifest.dependencies where dependency.matches(target.platform) { + switch dependency.kind { + case .target: + pending.append(TargetIdentity(package: identity.package, name: dependency.name)) + case .byName where package.description.targets?.contains(where: { $0.name == dependency.name }) == true: + pending.append(TargetIdentity(package: identity.package, name: dependency.name)) + case .byName, .product: + let (packageID, dependencyProduct) = try graph.product( + name: dependency.name, package: dependency.package, from: identity.package + ) + if dependencyProduct.type == .dynamicLibrary { append(.library(name: dependencyProduct.name)) } + pending += dependencyProduct.targets.map { TargetIdentity(package: packageID, name: $0) } + } } - return product } + return resources } } -public struct Plan: Sendable { - public var app: Product - public var extensions: [Product] +private func jsonContents(_ data: Data) -> Data { + // SwiftPM can print status messages before its JSON output. + Data(data.drop(while: { $0 != Character("{").asciiValue })) +} - public var allProducts: [Product] { - [app] + extensions - } +private struct PackageDependency: Decodable { + let identity: String + let path: String + let url: String? + let version: String? + let traits: [String]? + let dependencies: [PackageDependency] - public enum Resource: Codable, Sendable, Hashable { - case bundle(package: String, target: String) - case binaryTarget(name: String) - case library(name: String) - case root(source: String) + /// Local and branch or revision dependencies report `unspecified`. + var isPinned: Bool { + url != nil && version?.wholeMatch(of: #/\d+\.\d+\.\d+([-+][0-9A-Za-z.+-]*)?/#) != nil } - public struct Product: Sendable { - public var type: ProductType - public var product: String - public var deploymentTarget: String - public var bundleID: String - public var infoPlist: [String: any Sendable] - public var resources: [Resource] - public var iconPath: String? - public var entitlementsPath: String? - - public var targetName: String { - "\(self.product)-\(self.type.targetSuffix)" - } - - public func directory(inApp baseDir: URL) -> URL { - switch type { - case .application: - baseDir - .appendingPathComponent(".", isDirectory: true) - case .appExtension: - baseDir - .appendingPathComponent("PlugIns", isDirectory: true) - .appendingPathComponent(product, isDirectory: true) - .appendingPathExtension("appex") - } - } + func cacheKey(toolchain: String) -> String? { + guard isPinned, let url, let version else { return nil } + let key = [toolchain, identity, url, version, path] + (traits ?? []).sorted() + return ArtifactInventory.hash(data: Data(key.joined(separator: "\n").utf8)) } +} - public enum ProductType: Sendable { - case application - case appExtension - - fileprivate var targetSuffix: String { - switch self { - case .application: "App" - case .appExtension: "Extension" - } - } +/// SwiftPM's output for one package, kept verbatim so a cached copy decodes like a fresh one. +private struct RawInspection: Codable { + let description: Data + let manifest: Data - fileprivate var fourCharCode: String { - switch self { - case .application: "APPL" - case .appExtension: "XPC!" - } - } + func decoded() throws -> InspectedPackage { + InspectedPackage(description: try Planner.decoder.decode(PackageDump.self, from: description), + manifest: try Planner.decoder.decode(PackageManifest.self, from: manifest)) } } -private struct PackageDependency: Decodable { - let identity: String - let name: String - let path: String // on disk - let dependencies: [PackageDependency] -} +private struct PackageDump: Decodable, Sendable { + enum ProductType: Decodable, Sendable { + case executable, dynamicLibrary, staticLibrary, autoLibrary, other -private struct PackageDump: Decodable { - enum ProductType: Decodable { - case executable - case dynamicLibrary - case staticLibrary - case autoLibrary - case other - - private enum CodingKeys: String, CodingKey { - case executable - case library - } + private enum CodingKeys: String, CodingKey { case executable, library } init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - if container.contains(.executable) { + let values = try decoder.container(keyedBy: CodingKeys.self) + if values.contains(.executable) { self = .executable - } else if let library = try container.decodeIfPresent([String].self, forKey: .library) { - if library.count == 1 { - switch library[0] { - case "dynamic": - self = .dynamicLibrary - case "static": - self = .staticLibrary - case "automatic": - self = .autoLibrary - default: - self = .other - } - } else { - self = .other + } else if let library = try values.decodeIfPresent([String].self, forKey: .library), library.count == 1 { + switch library[0] { + case "dynamic": self = .dynamicLibrary + case "static": self = .staticLibrary + case "automatic": self = .autoLibrary + default: self = .other } } else { self = .other @@ -403,51 +351,169 @@ private struct PackageDump: Decodable { } } - struct Product: Decodable { + struct Product: Decodable, Sendable { let name: String let targets: [String] let type: ProductType } - struct Target: Decodable { + struct Target: Decodable, Sendable { let name: String let moduleType: String let type: String? - let productDependencies: [String]? - let targetDependencies: [String]? + let path: String? let resources: [Resource]? } - struct Resource: Decodable { - let path: String - } - - struct Platform: Decodable { + struct Resource: Decodable, Sendable { let path: String } + struct Platform: Decodable, Sendable { let name: String let version: String } let name: String + let path: String? let products: [Product]? let targets: [Target]? let platforms: [Platform]? + + /// Absolute path of a binary target's unarchived XCFramework inside this package. + func localXCFramework(_ target: Target) -> String? { + guard let path, let relative = target.path, relative.hasSuffix(".xcframework") else { return nil } + return URL(fileURLWithPath: relative, relativeTo: URL(fileURLWithPath: path, isDirectory: true)) + .standardizedFileURL.path + } } -private struct PackageGraph { - let root: PackageDump - let packages: [String: PackageDump] - let packagesByProductName: [String: String] +/// `describe` supplies resource bundle names, but only `dump-package` preserves +/// dependency conditions and package qualifiers. Both describe the same package. +private struct PackageManifest: Decodable, Sendable { + struct Target: Decodable, Sendable { + let name: String + let type: String + let dependencies: [Dependency] + } + + struct Dependency: Decodable, Sendable { + enum Kind: String, CodingKey, Sendable { case target, product, byName } + struct Condition: Decodable, Sendable { let platformNames: [String]? } + let kind: Kind + let name: String + let package: String? + let condition: Condition? - func product(name productName: String) throws -> (PackageDump, PackageDump.Product) { - guard let packageID = packagesByProductName[productName] else { - throw StringError("Could not find package containing product '\(productName)'") + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: Kind.self) + guard let kind = values.allKeys.first, values.allKeys.count == 1 else { + throw StringError("Unsupported SwiftPM target dependency") + } + self.kind = kind + var fields = try values.nestedUnkeyedContainer(forKey: kind) + name = try fields.decode(String.self) + if kind == .product { + package = try fields.decodeIfPresent(String.self) + // Module aliases affect Swift import names, not product/resource identity. + _ = try fields.decodeIfPresent([String: String].self) + } else { + package = nil + } + condition = try fields.decodeIfPresent(Condition.self) } - guard let package = packages[packageID] else { - throw StringError("Could not find package by id '\(packageID)'") + + func matches(_ platform: TargetPlatform) -> Bool { + guard let names = condition?.platformNames, !names.isEmpty else { return true } + return names.contains(platform.packagePlatformName) + } + } + + struct PackageReference: Decodable, Sendable { + struct Reference: Decodable, Sendable { + let identity: String + let nameForTargetDependencyResolutionOnly: String? } - guard let product = package.products?.first(where: { $0.name == productName }) else { - throw StringError("Could not find product '\(productName)' in package '\(packageID)'") + let reference: Reference + + private enum CodingKeys: String, CodingKey { case sourceControl, fileSystem, registry } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + guard let kind = values.allKeys.first, + let reference = try values.decode([Reference].self, forKey: kind).first else { + throw StringError("Unsupported SwiftPM package dependency") + } + self.reference = reference } - return (package, product) + } + + let targets: [Target] + let dependencies: [PackageReference] +} + +private struct InspectedPackage: Sendable { + let description: PackageDump + let manifest: PackageManifest +} + +private struct PackageGraph { + let rootIdentity: String + let packages: [String: InspectedPackage] + var root: InspectedPackage { packages[rootIdentity]! } + + func product(name: String, package qualifier: String?, from identity: String) throws -> (String, PackageDump.Product) { + guard let requester = packages[identity] else { throw StringError("Missing package '\(identity)'") } + let references = requester.manifest.dependencies.map(\.reference) + let candidates: [String] + if let qualifier { + candidates = references.filter { + ($0.nameForTargetDependencyResolutionOnly ?? $0.identity).lowercased() == qualifier.lowercased() + }.map(\.identity) + } else { + candidates = [identity] + references.map(\.identity) + } + let matches = candidates.compactMap { identity -> (String, PackageDump.Product)? in + guard let product = packages[identity]?.description.products?.first(where: { $0.name == name }) else { return nil } + return (identity, product) + } + guard matches.count == 1, let match = matches.first else { + throw StringError("Could not uniquely resolve product '\(name)' in package '\(identity)' " + + "(qualifier: \(qualifier ?? "none"))") + } + return match + } +} + +/// Inject package inspection without bypassing decoding, graph resolution or planning. +struct PackageInspectionClient: Sendable { + var dependencies: @Sendable (BuildSettings) async throws -> Data + var describe: @Sendable (BuildSettings) async throws -> Data + var manifest: @Sendable (BuildSettings) async throws -> Data + /// Part of the inspection cache key: SwiftPM releases may describe the same checkout differently. + var toolchain: @Sendable () async throws -> String = { try await SwiftVersion.current.description } + + private static func run(_ settings: BuildSettings, arguments: [String]) async throws -> Data { + let invocation = try await settings.swiftPMInvocation(forTool: "package", arguments: arguments) + return try await Subprocess.run(invocation, output: .data(limit: .max), error: .currentStandardError) + .checkSuccess().standardOutput + } +} + +extension PackageInspectionClient: DependencyKey { + static let liveValue = Self( + dependencies: { settings in + let directory = try TemporaryDirectory(name: "xtool-dump") + let output = directory.url.appendingPathComponent("dump.json") + // Unlike describe/dump-package, show-dependencies can write clean JSON directly. + _ = try await run(settings, arguments: ["-q", "show-dependencies", "--format", "json", "-o", output.path]) + return try Data(contentsOf: output) + }, + describe: { try await run($0, arguments: ["-q", "describe", "--type", "json"]) }, + manifest: { try await run($0, arguments: ["-q", "dump-package"]) } + ) +} + +extension DependencyValues { + var packageInspection: PackageInspectionClient { + get { self[PackageInspectionClient.self] } + set { self[PackageInspectionClient.self] = newValue } } } diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift new file mode 100644 index 00000000..e479f43d --- /dev/null +++ b/Sources/PackLib/ProductBuilder.swift @@ -0,0 +1,659 @@ +import Foundation +import Dependencies +import XUtils +import Subprocess + +/// Compilation owns the generated wrapper package and build tree only. +/// Bundle staging and publication belong exclusively to Packer/BundleAssembler. +public struct ProductBuilder: Sendable { + public let buildSettings: BuildSettings + public let plan: Plan + + public init(buildSettings: BuildSettings, plan: Plan) { + self.buildSettings = buildSettings + self.plan = plan + } + + @Dependency(\.productBuildRunner) private var runner + + public func build() async throws -> BuildArtifacts { + #if os(macOS) + let hostIsMacOS = true + #else + let hostIsMacOS = false + #endif + try buildSettings.validateProductBuild(for: plan.target, hostIsMacOS: hostIsMacOS) + try validateProducts() + let settings = try buildSettings.withTarget(plan.target) + let prebuilt = try validatedPrebuiltProducts() + let sdkArguments = plan.target.platform == .macOS && prebuilt.count != plan.allProducts.count + ? try await runner.sdkLinkerArguments(settings) : [] + let root = URL(fileURLWithPath: settings.packagePath, isDirectory: true).standardizedFileURL + let wrapper = root.appendingPathComponent("xtool/.xtool-tmp", isDirectory: true) + let libraries = plan.allProducts.filter { + if case .library = $0.resolvedSource { return true } + return false + } + if !libraries.isEmpty { try createWrapper(at: wrapper, root: root, products: libraries, sdkArguments: sdkArguments) } + + let builds = try invocations(settings: settings, root: root, wrapper: wrapper, prebuilt: prebuilt, + sdkArguments: sdkArguments) + + // SwiftPM regenerates accessors during planning. A compiler overlay adapts only generated + // target sources, without editing dependencies or racing that regeneration. Every build + // passes the same overlay path, so flags never change and no-op rebuilds stay no-ops. + // The overlay covers the accessors of the previous build. When accessors are missing, as on + // a clean build, a plan-only run generates them without compiling, so each module compiles + // once; a later pass recompiles any accessor that the overlay still did not adapt. + let built = builds.flatMap { $0.outputs.map(\.product) } + let directory = try await outputDirectory(for: builds) ?? root + let overlay = try await initialOverlay(for: builds, in: directory, wrapper: wrapper) + let overlayArguments = overlay.map { ["-Xswiftc", "-vfsoverlay", "-Xswiftc", $0.url.path] } ?? [] + + var executables = prebuilt + for build in builds { + try await runner.build(build.settings, build.arguments + overlayArguments) + for (product, name) in build.outputs { + let executable = directory.appendingPathComponent(name) + guard FileManager.default.isExecutableFile(atPath: executable.path) else { + throw StringError("Build backend did not produce executable '\(name)' at \(executable.path)") + } + executables[product.product] = executable + } + } + if let overlay, let final = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper) { + // An accessor the build's overlay did not cover compiled unadapted, even if its adapted + // copy is unchanged from an earlier build. + let stale = Set(final.changedSources + final.sources.filter { !overlay.sources.contains($0) }) + if !stale.isEmpty { + // Touching the generated sources makes the backend recompile exactly those modules + // (and their dependents) with the now-current adaptation; the flags stay unchanged. + for source in stale { + try FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: source.path) + } + for build in builds { + try await runner.build(build.settings, build.arguments + overlayArguments) + } + let settled = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper) + guard settled?.changedSources.isEmpty != false else { + throw StringError("Generated SwiftPM resource accessors changed during the build") + } + } + } + let staticTargets = try validateBinaryTargets(in: directory) + return BuildArtifacts(directory: directory, executables: executables, staticBinaryTargets: staticTargets) + } + + private typealias Invocation = (outputs: [(product: Plan.Product, name: String)], settings: BuildSettings, arguments: [String]) + + private func initialOverlay( + for builds: [Invocation], in directory: URL, wrapper: URL + ) async throws -> SwiftPMResourceAccessor.Overlay? { + let built = builds.flatMap { $0.outputs.map(\.product) } + guard !SwiftPMResourceAccessor.bundleNames(for: built).isEmpty else { return nil } + let overlay = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper, requireAll: false) + guard let overlay, !overlay.complete else { return overlay } + let arguments = ["-Xswiftc", "-vfsoverlay", "-Xswiftc", overlay.url.path] + for build in builds { try await runner.plan(build.settings, build.arguments + arguments) } + return try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper, requireAll: false) + } + + private func invocations( + settings: BuildSettings, root: URL, wrapper: URL, prebuilt: [String: URL], sdkArguments: [String] + ) throws -> [Invocation] { + // Share one scratch tree so resources and dependency libraries remain + // beside all products, including mixed direct and wrapped products. + // SwiftPM's cached build manifest in that tree tracks neither the package path nor the toolset: + // reusing it would link a product with another invocation's options, or miss the product. + let scratchOptions = (settings.customOptions.contains { + $0 == "--scratch-path" || $0.hasPrefix("--scratch-path=") + } ? [] : ["--scratch-path", root.appendingPathComponent(".build").path]) + ["--disable-build-manifest-caching"] + var builds: [Invocation] = [] + var wrapped: [(product: Plan.Product, name: String)] = [] + for product in plan.allProducts { + if prebuilt[product.product] != nil { continue } + switch product.resolvedSource { + case .library: + wrapped.append((product, product.targetName)) + case .executable(let name): + let options = try toolsetOptions(name: product.targetName, + linkerOptions: Self.linkerOptions(product.linkerArguments + sdkArguments), + in: wrapper) + builds.append(([(product, name)], settings.withPackagePath(root.path), scratchOptions + ["--product", name] + options)) + case .prebuilt: + continue + } + } + if !wrapped.isEmpty { + // The wrapper holds exactly the wrapped products, so one invocation builds them all; each + // further invocation would plan the whole package graph again. Planning already resolved + // root dependencies; avoid dropping its development-only dependencies from Package.resolved. + builds.append((wrapped, settings.withPackagePath(wrapper.path), scratchOptions + ["--disable-automatic-resolution"])) + } + return builds + } + + /// The output directory depends on the package and scratch path, not the product: ask once per package. + private func outputDirectory(for builds: [Invocation]) async throws -> URL? { + var directory: URL? + var packages: Set = [] + for build in builds where packages.insert(build.settings.packagePath).inserted { + let path = try await runner.binaryPath(build.settings, build.arguments).trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty, !path.contains("\n"), path.hasPrefix("/") else { + throw StringError("Build backend returned an invalid binary output path: '\(path)'") + } + let url = URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + if let directory, directory != url { + throw StringError("Build backend produced products in different resource directories") + } + directory = url + } + return directory + } + + private func validatedPrebuiltProducts() throws -> [String: URL] { + var result: [String: URL] = [:] + for product in plan.allProducts { + guard case .prebuilt(let path) = product.resolvedSource else { continue } + let root = URL(fileURLWithPath: buildSettings.packagePath, isDirectory: true) + let url = URL(fileURLWithPath: path, relativeTo: root).standardizedFileURL + guard FileManager.default.isExecutableFile(atPath: url.path) else { + throw StringError("Prebuilt executable is missing or not executable: \(url.path)") + } + try MachOBinary.validate(url, target: plan.target, executable: true) + result[product.product] = url + } + return result + } + + private func validateBinaryTargets(in directory: URL) throws -> Set { + guard plan.target.platform == .macOS else { return [] } + var staticTargets: Set = [] + for resource in Set(plan.allProducts.flatMap(\.resources)) { + switch resource { + case .binaryTarget(let name, let xcframework): + try ResourceDestination.validateRelativePath(name) + let framework = directory.appendingPathComponent("\(name).framework/\(name)") + let archive = Self.staticArchive(forBinaryTarget: name, xcframework: xcframework, + in: directory, target: plan.target) + let url = FileManager.default.fileExists(atPath: framework.path) ? framework : archive + guard FileManager.default.fileExists(atPath: url.path) else { + throw StringError("Missing binary target '\(name)': expected \(framework.path) or \(archive.path)") + } + if try MachOBinary.validate(url, target: plan.target, executable: false) { + staticTargets.insert(name) + } else if url == archive { + throw StringError("Binary target '\(name)' is not a static archive: \(url.path)") + } + case .library(let name): + try ResourceDestination.validateRelativePath(name) + try MachOBinary.validate(directory.appendingPathComponent("lib\(name).dylib"), + target: plan.target, executable: false) + default: break + } + } + return staticTargets + } + + private struct XCFrameworkInfo: Decodable { + struct Library: Decodable { + let LibraryPath: String + let SupportedPlatform: String + let SupportedPlatformVariant: String? + let SupportedArchitectures: [String] + } + + let AvailableLibraries: [Library] + } + + /// SwiftPM copies a static xcframework slice into the bin directory under the slice's own + /// `LibraryPath`, which need not match the target name. Local binary targets name their + /// XCFramework; remote and archived ones are extracted to `/artifacts///`. + /// Otherwise expect `lib.a`. + static func staticArchive( + forBinaryTarget name: String, xcframework: String? = nil, in directory: URL, target: ResolvedTarget + ) -> URL { + let manager = FileManager.default + let artifacts = directory.deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("artifacts", isDirectory: true) + let variant: String? = target.platform == .iOSSimulator ? "simulator" : nil + var candidates = xcframework.map { [URL(fileURLWithPath: $0, isDirectory: true)] } ?? [] + for package in (try? manager.contentsOfDirectory(atPath: artifacts.path).sorted()) ?? [] { + let targetDirectory = artifacts.appendingPathComponent(package).appendingPathComponent(name) + let entries = (try? manager.contentsOfDirectory(atPath: targetDirectory.path).sorted()) ?? [] + candidates += entries.filter { $0.hasSuffix(".xcframework") }.map { targetDirectory.appendingPathComponent($0) } + } + for candidate in candidates { + let plist = candidate.appendingPathComponent("Info.plist") + guard let data = try? Data(contentsOf: plist), + let info = try? PropertyListDecoder().decode(XCFrameworkInfo.self, from: data), + let library = info.AvailableLibraries.first(where: { + $0.SupportedPlatform == target.platform.packagePlatformName + && $0.SupportedPlatformVariant == variant + && $0.SupportedArchitectures.contains(target.architecture) + }), + library.LibraryPath.hasSuffix(".a"), + (try? ResourceDestination.validateComponent(library.LibraryPath, "Library path")) != nil else { continue } + return directory.appendingPathComponent(library.LibraryPath) + } + return directory.appendingPathComponent("lib\(name).a") + } + + private func toolsetOptions(name: String, linkerOptions: [String], in directory: URL) throws -> [String] { + // SwiftPM forwards CLI -Xlinker options to host macro executables too. + // SDK toolset options belong only to the target toolchain instead. + // Compiler options are deliberately absent: a toolset applies to every module, and each + // package must compile for its own platform minimum, as with `swift build` and Xcode. + // Dependencies such as swift-perception declare backports `obsoleted:` at newer releases. + // The product's deployment target reaches the binary through `-platform_version` here. + guard !linkerOptions.isEmpty else { return [] } + let toolsets = directory.appendingPathComponent("toolsets", isDirectory: true) + try FileManager.default.createDirectory(at: toolsets, withIntermediateDirectories: true) + let path = toolsets.appendingPathComponent("\(name).json") + let toolset: [String: Any] = ["schemaVersion": "1.0", "linker": ["extraCLIOptions": linkerOptions]] + let data = try JSONSerialization.data(withJSONObject: toolset, options: [.sortedKeys]) + try data.writeIfChanged(to: path) + return ["--toolset", path.path] + } + + /// Toolset linker options reach the linker directly; drop the compiler driver's `-Xlinker` prefixes. + static func linkerOptions(_ arguments: [String]) -> [String] { + stride(from: 0, to: arguments.count, by: 2).map { index in + precondition(arguments[index] == "-Xlinker" && index + 1 < arguments.count, "Expected -Xlinker pairs") + return arguments[index + 1] + } + } + + private func validateProducts() throws { + let names = plan.allProducts.compactMap { product -> String? in + switch product.resolvedSource { + case .library: product.targetName + case .executable(let name): name + case .prebuilt: nil + } + } + guard Set(names).count == names.count else { + throw StringError("Product build output names collide") + } + for product in plan.allProducts { + try product.resolvedSource.validate() + try ResourceDestination.validateComponent(product.product, "Product name") + switch product.resolvedSource { + case .prebuilt: + if product.platform == .macOS && product.type != .helperExecutable + && product.resources.contains(where: { if case .bundle = $0 { return true }; return false }) { + throw StringError("Prebuilt macOS product '\(product.product)' cannot adapt SwiftPM resource accessors; " + + "build it from source or supply resources with an explicit copy and its own lookup") + } + case .executable where buildSettings.buildSystem != .swiftPM: + throw StringError("Direct executable products currently require the SwiftPM backend") + case .library(let name), .executable(let name): + try ResourceDestination.validateComponent(name, "SwiftPM product name") + } + } + } + + private func createWrapper( + at directory: URL, root: URL, products: [Plan.Product], sdkArguments: [String] + ) throws { + // Keep unchanged files untouched: new timestamps would relink every product, and the + // directory also holds the resource-accessor overlay of the previous build. + let sourcesDirectory = directory.appendingPathComponent("Sources", isDirectory: true) + let stale = Set((try? FileManager.default.contentsOfDirectory(atPath: sourcesDirectory.path)) ?? []) + .subtracting(products.map(\.targetName)) + for name in stale { try FileManager.default.removeItem(at: sourcesDirectory.appendingPathComponent(name)) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let targets = products.map { product in + let sourceName: String + if case .library(let name) = product.resolvedSource { sourceName = name } else { sourceName = product.product } + return """ + .executableTarget( + name: \(product.targetName.swiftLiteral), + dependencies: [.product(name: \(sourceName.swiftLiteral), package: "RootPackage")], + linkerSettings: \(product.linkerSettings(sdkArguments: sdkArguments)) + ) + """ + }.joined(separator: ",\n") + let platform = plan.target.platform == .macOS ? "macOS" : "iOS" + let contents = """ + // swift-tools-version: 6.0 + import PackageDescription + let package = Package( + name: \((plan.app.product + "-Builder").swiftLiteral), + platforms: [.\(platform)(\(plan.target.deploymentTarget.swiftLiteral))], + dependencies: [.package(name: "RootPackage", path: \(root.path.swiftLiteral))], + targets: [ + \(targets) + ] + ) + + """ + try Data(contents.utf8).writeIfChanged(to: directory.appendingPathComponent("Package.swift")) + for product in products { + let sources = sourcesDirectory.appendingPathComponent(product.targetName, isDirectory: true) + try FileManager.default.createDirectory(at: sources, withIntermediateDirectories: true) + try Data().writeIfChanged(to: sources.appendingPathComponent("stub.c")) + } + } +} + +extension String { + /// Encode untrusted package/product names as a literal, never manifest code. + fileprivate var swiftLiteral: String { String(reflecting: self) } +} + +extension Plan.Product { + var linkerArguments: [String] { + var arguments: [String] = [] + if type == .appExtension { + arguments += ["-Xlinker", "-framework", "-Xlinker", "Foundation", + "-Xlinker", "-e", "-Xlinker", "_NSExtensionMain"] + } + for path in runtimeSearchPaths { + arguments += ["-Xlinker", "-rpath", "-Xlinker", path] + } + return arguments + } + + /// Compute paths from the same frozen layout consumed by assembly. + var runtimeSearchPaths: [String] { + let executableDirectory = layout.executablePath.split(separator: "/").dropLast().map(String.init) + let outer = BundleLayout(platform: platform, kind: .application, name: product).frameworksPath + let destinations = [outer, layout.frameworksPath] + var paths: [String] = [] + for destination in destinations { + let components = destination.split(separator: "/").map(String.init) + let shared = zip(executableDirectory, components).prefix { $0 == $1 }.count + let relative = Array(repeating: "..", count: executableDirectory.count - shared) + components.dropFirst(shared) + let path = (["@executable_path"] + relative).joined(separator: "/") + if !paths.contains(path) { paths.append(path) } + } + return paths + } + + fileprivate func linkerSettings(sdkArguments: [String]) -> String { + "[.unsafeFlags([\((linkerArguments + sdkArguments).map(\.swiftLiteral).joined(separator: ", "))])]" + } +} + +/// The build backend owns output discovery. Injection lets focused tests assert +/// target/host separation without starting a compiler or provisioning a device. +struct ProductBuildRunner: Sendable { + var build: @Sendable (BuildSettings, [String]) async throws -> Void + var binaryPath: @Sendable (BuildSettings, [String]) async throws -> String + var sdkLinkerArguments: @Sendable (BuildSettings) async throws -> [String] = { try await $0.macOSLinkerArguments() } + /// Plans a build without compiling; SwiftPM writes generated sources such as resource accessors while planning. + var plan: @Sendable (BuildSettings, [String]) async throws -> Void = { _, _ in } +} + +extension ProductBuildRunner: DependencyKey { + static let liveValue = Self( + build: { settings, arguments in + let invocation = try await settings.swiftPMInvocation(forTool: "build", arguments: arguments) + try await Subprocess.run(invocation, output: .currentStandardOutput, error: .currentStandardError).checkSuccess() + }, + binaryPath: { settings, arguments in + let invocation = try await settings.swiftPMInvocation(forTool: "build", arguments: arguments + ["--show-bin-path"]) + let result = try await Subprocess.run(invocation, output: .string(limit: .max), error: .currentStandardError) + .checkSuccess() + return result.standardOutput + }, + plan: { settings, arguments in + let invocation = try await settings.swiftPMInvocation(forTool: "build", + arguments: arguments + ["--print-manifest-job-graph"]) + try await Subprocess.run(invocation, output: .discarded, error: .currentStandardError).checkSuccess() + } + ) +} + +extension DependencyValues { + var productBuildRunner: ProductBuildRunner { + get { self[ProductBuildRunner.self] } + set { self[ProductBuildRunner.self] = newValue } + } +} + +/// Build outputs are inputs to assembly; the assembler must not invoke SwiftPM. +public struct BuildArtifacts: Sendable { + public var directory: URL + public var executables: [String: URL] + /// Only targets with a present, validated static archive may omit embedding. + public var staticBinaryTargets: Set + + public init(directory: URL, executables: [String: URL], staticBinaryTargets: Set = []) { + self.directory = directory + self.executables = executables + self.staticBinaryTargets = staticBinaryTargets + } +} + +public struct ProductBuilderClient: Sendable { + public var build: @Sendable (BuildSettings, Plan) async throws -> BuildArtifacts + + public init(build: @escaping @Sendable (BuildSettings, Plan) async throws -> BuildArtifacts) { + self.build = build + } +} + +extension ProductBuilderClient: DependencyKey { + public static let liveValue = Self { settings, plan in + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } +} + +extension DependencyValues { + public var productBuilder: ProductBuilderClient { + get { self[ProductBuilderClient.self] } + set { self[ProductBuilderClient.self] = newValue } + } +} + +/// A bounded, host-independent reader: Linux must diagnose Apple inputs without +/// relying on the host's `file`, `otool`, or codesign tools. +struct MachOBinary { + struct Slice { + var cpu: UInt32 + var fileType: UInt32 + var platform: UInt32? + var minimumOS: UInt32 = 0 + var sdk: UInt32 = 0 + var installName: String? + var rpaths: [String] = [] + var hasCodeSignature = false + /// Object files from a static archive, including archives inside a universal binary. + var archiveMember = false + struct Dependency { + var path: String + var weak: Bool + } + var dependencies: [Dependency] = [] + + mutating func read(command: UInt32, load: UnsafeRawBufferPointer) throws { + switch command { + case 0x32: // LC_BUILD_VERSION + guard platform == nil else { throw StringError("Duplicate Mach-O platform metadata") } + platform = try MachOBinary.word(load, 8) + minimumOS = try MachOBinary.word(load, 12) + sdk = try MachOBinary.word(load, 16) + case 0x24, 0x25: // legacy LC_VERSION_MIN_MACOSX / IPHONEOS + guard platform == nil else { throw StringError("Duplicate Mach-O platform metadata") } + platform = command == 0x24 ? 1 : 2 + minimumOS = try MachOBinary.word(load, 8) + sdk = try MachOBinary.word(load, 12) + case 0xd: installName = try MachOBinary.loadString(load) + case 0xc, 0x80000018, 0x8000001f, 0x80000023: + dependencies.append(.init(path: try MachOBinary.loadString(load), weak: command == 0x80000018)) + case 0x8000001c: rpaths.append(try MachOBinary.loadString(load)) + case 0x1d: hasCodeSignature = true + default: break + } + } + } + + @discardableResult + static func validate(_ url: URL, target: ResolvedTarget, executable: Bool) throws -> Bool { + guard FileManager.default.isReadableFile(atPath: url.path) else { + throw StringError("Missing binary artifact: \(url.path)") + } + do { + let slices = try inspect(contentsOf: url) + let cpu = try cpuType(for: target.architecture) + let matching = slices.filter { $0.cpu == cpu } + guard !matching.isEmpty else { throw StringError("Wrong architecture; expected \(target.architecture)") } + let archive = matching.allSatisfy(\.archiveMember) + let platform = platformType(for: target.platform) + for slice in matching { + guard slice.platform == platform else { + throw StringError("Wrong or missing Mach-O platform; expected \(target.platform.rawValue)") + } + guard slice.minimumOS <= packedVersion(target.deploymentTarget) else { + throw StringError("Binary minimum OS exceeds deployment target \(target.deploymentTarget)") + } + let expectedType: UInt32 = executable ? 2 : (slice.archiveMember ? 1 : 6) + guard slice.fileType == expectedType else { throw StringError("Wrong Mach-O file type; expected \(expectedType)") } + if expectedType == 6 { + try validateInstallName(slice.installName, binary: url) + } + } + return archive + } catch { + throw StringError("Incompatible binary '\(url.path)': \(error)") + } + } + + static func platformType(for platform: TargetPlatform) -> UInt32 { + switch platform { + case .macOS: 1 + case .iOS: 2 + case .iOSSimulator: 7 + } + } + + static func cpuType(for architecture: String) throws -> UInt32 { + switch architecture { + case "arm64": return 0x0100000c + case "x86_64": return 0x01000007 + default: throw StringError("Unsupported architecture: \(architecture)") + } + } + + private static func validateInstallName(_ name: String?, binary: URL) throws { + guard let name, name.hasPrefix("@rpath/") else { + throw StringError("Dynamic binary must use an @rpath install name for relocation") + } + let relative = String(name.dropFirst("@rpath/".count)) + try ResourceDestination.validateRelativePath(relative) + var base = binary.deletingLastPathComponent() + if let index = binary.pathComponents.firstIndex(where: { $0.hasSuffix(".framework") }) { + base = URL(fileURLWithPath: NSString.path(withComponents: Array(binary.pathComponents.prefix(index))), + isDirectory: true) + } + guard base.appendingPathComponent(relative).resolvingSymlinksInPath() == binary.resolvingSymlinksInPath() else { + throw StringError("Install name '\(name)' does not resolve to the embedded binary '\(binary.lastPathComponent)'") + } + } + + static func packedVersion(_ version: String) -> UInt32 { + let parts: [UInt32] = version.split(separator: ".").map { UInt32($0) ?? UInt32.max } + [0, 0] + return (min(parts[0], 65535) << 16) | (min(parts[1], 255) << 8) | min(parts[2], 255) + } + + /// Maps the file instead of reading it: static archives can be hundreds of megabytes, + /// and parsing touches only headers and load commands. + static func inspect(contentsOf url: URL) throws -> [Slice] { + try inspect(Data(contentsOf: url, options: .alwaysMapped)) + } + + static func inspect(_ data: Data) throws -> [Slice] { + try data.withUnsafeBytes { try inspect($0) } + } + + /// Nested slices and archive members are parsed in place, without copying. + private static func inspect(_ bytes: UnsafeRawBufferPointer) throws -> [Slice] { + if bytes.starts(with: "!\n".utf8) { return try archiveSlices(bytes) } + let magic = try word(bytes, 0) + if magic == 0xbebafeca || magic == 0xcafebabe { + let bigEndian = magic == 0xbebafeca + let count = Int(try word(bytes, 4, bigEndian: bigEndian)) + guard count <= (bytes.count - 8) / 20 else { throw StringError("Truncated universal binary") } + return try (0..= 8 + count * 20, offset <= bytes.count, size <= bytes.count - offset else { + throw StringError("Invalid universal binary slice") + } + let slices = try inspect(UnsafeRawBufferPointer(rebasing: bytes[offset..<(offset + size)])) + let cpu = try word(bytes, entry, bigEndian: bigEndian) + guard slices.allSatisfy({ $0.cpu == cpu }) else { throw StringError("Universal binary CPU mismatch") } + return slices + } + } + guard magic == 0xfeedfacf else { throw StringError("Expected a 64-bit Mach-O binary") } + return [try thinSlice(bytes)] + } + + private static func archiveSlices(_ bytes: UnsafeRawBufferPointer) throws -> [Slice] { + var offset = 8 + var slices: [Slice] = [] + while offset < bytes.count { + guard bytes.count - offset >= 60, bytes[offset + 58] == 0x60, bytes[offset + 59] == 0x0a else { + throw StringError("Invalid archive member") + } + let name = String(decoding: bytes[offset..<(offset + 16)], as: UTF8.self).trimmingCharacters(in: .whitespaces) + let sizeText = String(decoding: bytes[(offset + 48)..<(offset + 58)], as: UTF8.self) + guard let size = Int(sizeText.trimmingCharacters(in: .whitespaces)), size >= 0, + size <= bytes.count - offset - 60 else { throw StringError("Invalid archive member size") } + let nameSize = name.hasPrefix("#1/") ? Int(name.dropFirst(3)) : 0 + guard let nameSize, nameSize >= 0, nameSize <= size else { throw StringError("Invalid archive member name") } + let actualName = nameSize > 0 + ? String(decoding: bytes[(offset + 60)..<(offset + 60 + nameSize)], as: UTF8.self) : name + if !actualName.hasPrefix("__.SYMDEF") && actualName != "/" && actualName != "//" { + let member = UnsafeRawBufferPointer(rebasing: bytes[(offset + 60 + nameSize)..<(offset + 60 + size)]) + slices += try inspect(member).map { + var member = $0 + member.archiveMember = true + return member + } + } + offset += 60 + size + size % 2 + } + guard !slices.isEmpty else { throw StringError("Archive has no Mach-O object members") } + return slices + } + + private static func thinSlice(_ bytes: UnsafeRawBufferPointer) throws -> Slice { + var slice = Slice(cpu: try word(bytes, 4), fileType: try word(bytes, 12)) + let count = Int(try word(bytes, 16)) + let size = Int(try word(bytes, 20)) + guard bytes.count >= 32, size <= bytes.count - 32, count <= size / 8 else { + throw StringError("Truncated Mach-O load commands") + } + var offset = 32 + for _ in 0..= 8, length <= 32 + size - offset else { throw StringError("Invalid Mach-O load command") } + let load = UnsafeRawBufferPointer(rebasing: bytes[offset..<(offset + length)]) + try slice.read(command: command, load: load) + offset += length + } + guard offset == 32 + size else { throw StringError("Inconsistent Mach-O command size") } + return slice + } + + private static func loadString(_ load: UnsafeRawBufferPointer) throws -> String { + let offset = Int(try word(load, 8)) + guard offset >= 12, offset < load.count, let end = load[offset...].firstIndex(of: 0) else { + throw StringError("Invalid Mach-O load-command string") + } + return String(decoding: load[offset.. UInt32 { + guard offset >= 0, offset <= bytes.count, bytes.count - offset >= 4 else { throw StringError("Truncated Mach-O data") } + return (0..<4).reduce(UInt32(0)) { value, index in + value | (UInt32(bytes[offset + index]) << ((bigEndian ? 3 - index : index) * 8)) + } + } +} diff --git a/Sources/PackLib/ProductSource.swift b/Sources/PackLib/ProductSource.swift new file mode 100644 index 00000000..921ff083 --- /dev/null +++ b/Sources/PackLib/ProductSource.swift @@ -0,0 +1,56 @@ +import Foundation +import XUtils + +/// Library products get an executable wrapper; executable products build directly; +/// prebuilt inputs are copied only after target compatibility validation. +public enum ProductSource: Sendable, Equatable, Codable { + case library(product: String) + case executable(product: String) + case prebuilt(path: String) + + private enum Kind: String, Codable { case library, executable, prebuilt } + private enum CodingKeys: String, CodingKey { case kind, product, path } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + let kind = try values.decode(Kind.self, forKey: .kind) + switch kind { + case .library, .executable: + guard !values.contains(.path) else { + throw StringError("SwiftPM product sources cannot specify a prebuilt path") + } + let name = try values.decode(String.self, forKey: .product) + self = kind == .library ? .library(product: name) : .executable(product: name) + case .prebuilt: + guard !values.contains(.product) else { + throw StringError("Prebuilt sources cannot specify a SwiftPM product") + } + self = .prebuilt(path: try values.decode(String.self, forKey: .path)) + } + try validate() + } + + public func encode(to encoder: Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .library(let product): + try values.encode(Kind.library, forKey: .kind) + try values.encode(product, forKey: .product) + case .executable(let product): + try values.encode(Kind.executable, forKey: .kind) + try values.encode(product, forKey: .product) + case .prebuilt(let path): + try values.encode(Kind.prebuilt, forKey: .kind) + try values.encode(path, forKey: .path) + } + } + + public func validate() throws { + switch self { + case .library(let value), .executable(let value), .prebuilt(let value): + guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw StringError("Product source must specify a nonempty product or path") + } + } + } +} diff --git a/Sources/PackLib/SwiftPMResourceAccessor.swift b/Sources/PackLib/SwiftPMResourceAccessor.swift new file mode 100644 index 00000000..ac0c0580 --- /dev/null +++ b/Sources/PackLib/SwiftPMResourceAccessor.swift @@ -0,0 +1,96 @@ +import Foundation +import XUtils + +/// Native SwiftPM generates bundle-root lookup, which requires unsigned root aliases on macOS. +/// Compile an adapted view of those generated sources; keep package sources and build fallbacks intact. +enum SwiftPMResourceAccessor { + struct Overlay { + var url: URL + /// Generated accessors whose adapted copy changed while writing this overlay. + /// Modules compiled from them before used a stale or missing adaptation. + var changedSources: [URL] + /// Generated accessors this overlay adapts. + var sources: [URL] + /// Whether every resource bundle's generated accessor was found and adapted. + var complete: Bool + } + + static func bundleNames(for products: [Plan.Product]) -> Set { + Set(products.filter { $0.platform == .macOS }.flatMap(\.resources).compactMap { resource -> String? in + guard case .bundle(let package, let target) = resource else { return nil } + return "\(package)_\(target).bundle" + }) + } + + /// One overlay covers every product: a resource target can live in a library that several products share. + /// + /// The overlay path never changes, so compiler flags stay stable across builds. Before a build, + /// pass `requireAll: false` to cover the accessors an earlier build generated; after it, pass + /// `requireAll: true` and recompile whatever `changedSources` lists. + static func overlay( + for products: [Plan.Product], in directory: URL, output: URL, requireAll: Bool = true + ) throws -> Overlay? { + let names = bundleNames(for: products) + guard !names.isEmpty else { return nil } + let manager = FileManager.default + // Keep the backend's spelling (not Foundation's canonicalized /private/var spelling): + // VFS names must match the compiler's source arguments exactly. + let entries = (try? manager.contentsOfDirectory(atPath: directory.path)) ?? [] + let candidates = entries + .filter { $0.hasSuffix(".build") && !$0.hasSuffix("-tool.build") } + .map { directory.appendingPathComponent($0) } + .map { $0.appendingPathComponent("DerivedSources/resource_bundle_accessor.swift") } + .filter { manager.fileExists(atPath: $0.path) } + .sorted { $0.path < $1.path } + var matched: Set = [] + var files: [(URL, String)] = [] + for candidate in candidates { + let source = try String(contentsOf: candidate, encoding: .utf8) + for name in names where source.contains("appendingPathComponent(\(String(reflecting: name)))") { + guard matched.insert(name).inserted else { + throw StringError("Ambiguous generated SwiftPM accessor for '\(name)'") + } + files.append((candidate, try adapted(source, bundleName: name))) + } + } + if requireAll && matched != names { + throw StringError("Missing supported generated SwiftPM resource accessor for: " + + names.subtracting(matched).sorted().joined(separator: ", ") + + "; macOS bundles currently require Swift resource targets") + } + let destination = output.appendingPathComponent("resource-accessors") + try manager.createDirectory(at: destination, withIntermediateDirectories: true) + var roots: [[String: String]] = [] + var changed: [URL] = [] + for (source, contents) in files { + // Name copies after their module's build directory, which is unique within the bin directory. + let name = source.deletingLastPathComponent().deletingLastPathComponent().deletingPathExtension() + let copy = destination.appendingPathComponent("\(name.lastPathComponent).swift") + if try Data(contents.utf8).writeIfChanged(to: copy) { changed.append(source) } + roots.append(["type": "file", "name": source.path, "external-contents": copy.path]) + } + let overlay = destination.appendingPathComponent("overlay.json") + let data = try JSONSerialization.data(withJSONObject: [ + "version": 0, "use-external-names": false, "roots": roots, + ], options: [.sortedKeys]) + try data.writeIfChanged(to: overlay) + return Overlay(url: overlay, changedSources: changed, sources: files.map(\.0), complete: matched == names) + } + + static func adapted(_ source: String, bundleName: String) throws -> String { + let lookup = "let mainPath = Bundle.main.bundleURL.appendingPathComponent(\(String(reflecting: bundleName))).path" + guard source.contains("extension Foundation.Bundle {"), + source.components(separatedBy: lookup).count == 2, + source.components(separatedBy: "Bundle(path: mainPath)").count == 2 else { + throw StringError("Unsupported generated SwiftPM resource accessor for '\(bundleName)'") + } + let replacement = lookup.replacingOccurrences(of: "Bundle.main.bundleURL", + with: "(Bundle.main.resourceURL ?? Bundle.main.bundleURL)") + // Keep directory-based helper lookup too. All products need the same accessor because a + // resource target can live in a dylib shared by app, services, and helpers in one build tree. + let fallback = "Bundle(path: mainPath) ?? Bundle(url: Bundle.main.bundleURL.appendingPathComponent(" + + String(reflecting: bundleName) + "))" + return source.replacingOccurrences(of: lookup, with: replacement) + .replacingOccurrences(of: "Bundle(path: mainPath)", with: fallback) + } +} diff --git a/Sources/PackLib/SwiftSDKConfiguration.swift b/Sources/PackLib/SwiftSDKConfiguration.swift new file mode 100644 index 00000000..e880cc0e --- /dev/null +++ b/Sources/PackLib/SwiftSDKConfiguration.swift @@ -0,0 +1,63 @@ +import Foundation +import XUtils + +enum SwiftSDKConfiguration { + struct Destination: Equatable { + let url: URL + let toolSearchPaths: [String] + } + + /// The `targetTriples` entry of the one macOS Swift SDK in `directory`, with paths made absolute. + static func properties(in directory: URL, for target: TargetTriple) throws -> [String: Any] { + let identity = target.sdkIdentity + let bundles = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "artifactbundle" } + var matches: [[String: Any]] = [] + for bundle in bundles { + let metadata = bundle.appendingPathComponent("swift-sdk.json") + guard FileManager.default.fileExists(atPath: metadata.path) else { continue } + let data = try Data(contentsOf: metadata) + guard let document = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let triples = document["targetTriples"] as? [String: [String: Any]], + var properties = triples[identity] else { continue } + for key in ["sdkRootPath", "swiftResourcesPath", "swiftStaticResourcesPath"] { + if let path = properties[key] as? String { + properties[key] = URL(fileURLWithPath: path, relativeTo: bundle).standardizedFileURL.path + } + } + for key in ["includeSearchPaths", "librarySearchPaths", "toolsetPaths"] { + if let paths = properties[key] as? [String] { + properties[key] = paths.map { URL(fileURLWithPath: $0, relativeTo: bundle).standardizedFileURL.path } + } + } + matches.append(properties) + } + guard matches.count == 1, let properties = matches.first else { + throw StringError("Expected one Swift SDK for '\(identity)' in '\(directory.path)'; found \(matches.count)") + } + return properties + } + + /// SwiftPM's bundle selector requires an exact target triple. A single-target + /// destination lets it apply the requested deployment version after decoding. + static func destination(in directory: URL, for target: TargetTriple, cacheDirectory: URL) throws -> Destination { + let identity = target.sdkIdentity + let properties = try properties(in: directory, for: target) + let data = try JSONSerialization.data(withJSONObject: [ + "schemaVersion": "4.0", "targetTriples": [identity: properties], + ], options: [.sortedKeys]) + let hash = ArtifactInventory.hash(data: data) + var toolSearchPaths: [String] = [] + for path in properties["toolsetPaths"] as? [String] ?? [] { + let toolset = URL(fileURLWithPath: path) + let contents = try JSONSerialization.jsonObject(with: Data(contentsOf: toolset)) as? [String: Any] + if let root = contents?["rootPath"] as? String { + toolSearchPaths.append(URL(fileURLWithPath: root, relativeTo: toolset).standardizedFileURL.path) + } + } + try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) + let destination = cacheDirectory.appendingPathComponent("\(identity)-\(hash).json") + if !FileManager.default.fileExists(atPath: destination.path) { try data.write(to: destination, options: .atomic) } + return Destination(url: destination, toolSearchPaths: toolSearchPaths) + } +} diff --git a/Sources/PackLib/TargetPlatform.swift b/Sources/PackLib/TargetPlatform.swift new file mode 100644 index 00000000..3be95a38 --- /dev/null +++ b/Sources/PackLib/TargetPlatform.swift @@ -0,0 +1,150 @@ +import Foundation +import XUtils + +/// Target identity is independent of the host and of the build backend. +public enum TargetPlatform: String, Codable, Sendable, CaseIterable { + case iOS = "ios" + case iOSSimulator = "ios-simulator" + case macOS = "macos" + + public var minimumDeploymentTarget: String { + switch self { + case .iOS, .iOSSimulator: "13.0" + case .macOS: "13.0" + } + } + + public var packagePlatformName: String { + self == .macOS ? "macos" : "ios" + } + + public var supportedPlatform: String { + switch self { + case .iOS: "iPhoneOS" + case .iOSSimulator: "iPhoneSimulator" + case .macOS: "MacOSX" + } + } + + /// Without a version this is the SDK identity; with one, the compiler triple. + public func triple(architecture: String, version: String = "") -> String { + switch self { + case .iOS: "\(architecture)-apple-ios\(version)" + case .iOSSimulator: "\(architecture)-apple-ios\(version)-simulator" + case .macOS: "\(architecture)-apple-macosx\(version)" + } + } +} + +/// Parse only supported Apple targets; never infer the target from the host. +public struct TargetTriple: Sendable, Equatable { + public let architecture: String + public let platform: TargetPlatform + public let deploymentTarget: String? + + public init(_ triple: String) throws { + let parts = triple.split(separator: "-", omittingEmptySubsequences: false).map(String.init) + guard (3...4).contains(parts.count), parts[1] == "apple" else { + throw StringError("Unsupported target triple: \(triple)") + } + let os = String(parts[2].prefix { !$0.isNumber }) + let version = String(parts[2].dropFirst(os.count)) + switch (os, parts.count == 4 ? parts[3] : "") { + case ("ios", ""): platform = .iOS + case ("ios", "simulator"): platform = .iOSSimulator + case ("macos", ""), ("macosx", ""): platform = .macOS + default: throw StringError("Unsupported target triple: \(triple)") + } + architecture = parts[0] + let supported = platform == .iOS ? ["arm64"] : ["arm64", "x86_64"] + guard supported.contains(architecture) else { + throw StringError("Unsupported architecture '\(architecture)' for \(platform.rawValue)") + } + if !version.isEmpty { try Self.validateVersion(version) } + deploymentTarget = version.isEmpty ? nil : version + } + + var sdkIdentity: String { + platform.triple(architecture: architecture) + } + + var compilerTriple: String { + platform.triple(architecture: architecture, version: deploymentTarget ?? platform.minimumDeploymentTarget) + } + + static func validateVersion(_ value: String) throws { + let parts = value.split(separator: ".", omittingEmptySubsequences: false) + guard (1...3).contains(parts.count), parts.allSatisfy({ !$0.isEmpty && $0.allSatisfy({ $0.isASCII && $0.isNumber }) }) else { + throw StringError("Invalid deployment target: \(value)") + } + } +} + +/// Resolve once, then share this value with planning, compilation, assembly, and signing. +public struct ResolvedTarget: Codable, Sendable, Equatable { + public static let defaultTriple = "arm64-apple-ios" + + public let triple: String + public let platform: TargetPlatform + public let architecture: String + public let deploymentTarget: String + + /// Triple precedence: CLI > schema > iOS default. + /// Deployment precedence: selected triple's version > schema > package > platform minimum. + /// A package minimum below xtool's platform minimum is raised to it; an explicit triple or + /// schema version cannot lower the schema, package or platform minimum. + public static func resolve( + cliTarget: String? = nil, + schema: PackSchema = .default, + packageDeploymentTargets: [String: String] = [:] + ) throws -> Self { + let triple = cliTarget ?? schema.target ?? defaultTriple + let parsed = try TargetTriple(triple) + let platformMinimum = parsed.platform.minimumDeploymentTarget + let packageMinimum = packageDeploymentTargets[parsed.platform.packagePlatformName] + if let packageMinimum { try TargetTriple.validateVersion(packageMinimum) } + let packageDefault = packageMinimum.map { version($0, isBelow: platformMinimum) ? platformMinimum : $0 } + let deployment = parsed.deploymentTarget ?? schema.deploymentTarget ?? packageDefault ?? platformMinimum + try TargetTriple.validateVersion(deployment) + for minimum in [schema.deploymentTarget, packageMinimum, platformMinimum].compactMap({ $0 }) { + try TargetTriple.validateVersion(minimum) + guard !version(deployment, isBelow: minimum) else { + throw StringError("Deployment target \(deployment) is below required minimum \(minimum)") + } + } + try schema.validate(for: parsed.platform) + return Self( + triple: triple, + platform: parsed.platform, + architecture: parsed.architecture, + deploymentTarget: deployment + ) + } + + /// Describes an existing app from its own metadata. Only the triple's syntax is validated: + /// xtool's deployment minimums apply to what it builds, not to apps it inspects. + public static func existing(triple: String) throws -> Self { + let parsed = try TargetTriple(triple) + guard let deployment = parsed.deploymentTarget else { + throw StringError("Expected a versioned target triple: \(triple)") + } + return Self(triple: triple, platform: parsed.platform, architecture: parsed.architecture, + deploymentTarget: deployment) + } + + private static func version(_ value: String, isBelow minimum: String) -> Bool { + let lhs = value.split(separator: ".").map { Int($0) ?? Int.max } + let rhs = minimum.split(separator: ".").map { Int($0) ?? Int.max } + for index in 0.. URL { + try validatePlan() let xtoolDir = root.appendingPathComponent("xtool", isDirectory: true) let projectDir = xtoolDir.appendingPathComponent(".xtool-tmp", isDirectory: true) try? FileManager.default.removeItem(at: projectDir) @@ -233,6 +235,25 @@ public struct XcodePacker { return workspaceURL } + private func validatePlan() throws { + guard plan.target.platform != .macOS else { + throw StringError("Xcode project generation for macOS is not supported") + } + for product in plan.allProducts { + switch product.type { + case .application, .appExtension: break + case .xpcService, .helperExecutable: + throw StringError("Xcode project generation does not support XPC services or helpers") + } + guard case .library = product.resolvedSource else { + throw StringError("Xcode project generation requires library product sources") + } + if product.resources.contains(where: { if case .copy = $0 { return true }; return false }) { + throw StringError("Xcode project generation does not support explicit resource destinations") + } + } + } + private func resourceReferences(for product: Plan.Product, at root: URL) throws -> [XCSchema.Reference] { var paths = product.resources.compactMap { resource -> String? in guard case .root(let source) = resource else { return nil } diff --git a/Sources/XToolSupport/DevBSPCommand.swift b/Sources/XToolSupport/DevBSPCommand.swift index 4d3b9f5f..34eae0a6 100644 --- a/Sources/XToolSupport/DevBSPCommand.swift +++ b/Sources/XToolSupport/DevBSPCommand.swift @@ -10,17 +10,15 @@ struct DevBSPCommand: AsyncParsableCommand { abstract: "Run build server", ) - @Option + @Option(help: "Target triple; overrides xtool.yml target, otherwise defaults to arm64-apple-ios") var triple: String? func run() async throws { guard try await SwiftVersion.current.supportsSwiftBuild else { throw Console.Error("`xtool dev build-server` requires Swift 6.4 or later") } - let settings = try await BuildSettings( - configuration: .debug, - triple: triple ?? PackOperation.defaultTriple - ) + let request = try await DevBuildRequest.load(cliTriple: triple) + let settings = try await EditorTarget.serverSettings(request: request) try await Subprocess.run( settings.buildServerInvocation(), input: .currentStandardInput, diff --git a/Sources/XToolSupport/DevCommand.swift b/Sources/XToolSupport/DevCommand.swift index 369bfa8d..8357e75c 100644 --- a/Sources/XToolSupport/DevCommand.swift +++ b/Sources/XToolSupport/DevCommand.swift @@ -19,9 +19,8 @@ struct PackOperation { } } - static let defaultTriple = "arm64-apple-ios" - - var triple: String? + var schema: PackSchema + var triple: String var buildOptions = BuildOptions(configuration: .debug) var xcode = false @@ -29,34 +28,15 @@ struct PackOperation { func run() async throws -> URL { // TODO: support running inside a child directory - let contents = try FileManager.default.contentsOfDirectory(atPath: ".") - guard contents.contains("Package.swift") - || contents.contains(where: { $0.hasPrefix("Package@") && $0.hasSuffix(".swift") }) - else { - throw Console.Error("Could not find Package.swift in this directory.") - } + try Self.requirePackageManifest(in: URL(fileURLWithPath: ".")) try await EnsureSDKOperation(quiet: true).run() print("Planning...") - let schema: PackSchema - let configPath = URL(fileURLWithPath: "xtool.yml") - if FileManager.default.fileExists(atPath: configPath.path) { - schema = try await PackSchema(url: configPath) - } else { - schema = .default - print(""" - warning: Could not locate configuration file '\(configPath.path)'. Using default \ - configuration with 'com.example' organization ID. - """) - } - - await LSPConfig.tryEnsure(using: schema) - let buildSettings = try await BuildSettings( configuration: buildOptions.configuration, - triple: triple ?? Self.defaultTriple, + triple: triple, options: [] ) @@ -65,6 +45,7 @@ struct PackOperation { schema: schema ) let plan = try await planner.createPlan() + await LSPConfig.tryEnsure(plan: plan, settings: buildSettings, schema: schema) if xcode { return try await XcodePacker(plan: plan).createProject() @@ -102,6 +83,15 @@ struct PackOperation { return bundle } + + static func requirePackageManifest(in directory: URL) throws { + let contents = try FileManager.default.contentsOfDirectory(atPath: directory.path) + guard contents.contains("Package.swift") + || contents.contains(where: { $0.hasPrefix("Package@") && $0.hasSuffix(".swift") }) + else { + throw Console.Error("Could not find Package.swift in this directory.") + } + } } struct DevXcodeCommand: AsyncParsableCommand { @@ -111,7 +101,10 @@ struct DevXcodeCommand: AsyncParsableCommand { ) func run() async throws { - let result = try await PackOperation(xcode: true).run() + let request = try await DevBuildRequest.load() + try request.validate(route: .xcode) + @Dependency(\.devCommandClient) var client + let result = try await client.legacyPack(request.schema, request.triple, .debug, true) print("Wrote to \(result.path)") } } @@ -121,11 +114,15 @@ struct DevBuildCommand: AsyncParsableCommand { commandName: "build", abstract: "Build app with SwiftPM", discussion: """ - This command builds the SwiftPM-based iOS app in the current directory + Build the SwiftPM-based app in the current directory. macOS targets require + --defer-signing and produce an app plus a signing manifest and preserved + entitlement inputs for signing later on a Mac. This command does not issue + Developer ID certificates or notarize apps. """ ) @OptionGroup var packOptions: PackOperation.BuildOptions + @OptionGroup var macOptions: MacBuildOptions @Flag( name: .shortAndLong, @@ -137,14 +134,31 @@ struct DevBuildCommand: AsyncParsableCommand { help: "Output a .ipa file instead of a .app" ) var ipa = false + @Flag(help: "Defer macOS signing and preserve inputs for a downstream Mac signer") + var deferSigning = false + @Option( help: ArgumentHelp( "Custom target triple to build for", - discussion: "Defaults to '\(PackOperation.defaultTriple)'" + discussion: "Overrides xtool.yml target; otherwise defaults to '\(ResolvedTarget.defaultTriple)'" ) ) var triple: String? func run() async throws { + let request = try await DevBuildRequest.load(cliTriple: triple) + try request.validate(route: .build(sign: sign, ipa: ipa, deferSigning: deferSigning)) + + if request.platform == .macOS { + let output = try await DeferredMacBuildOperation( + request: request, configuration: packOptions.configuration, options: macOptions + ).run() + print("Wrote to \(output.bundle.path)") + print("Signing inputs: \(output.manifest.path)") + print("Artifact inventory: \(MacArtifact.manifestURL(for: output.bundle).path)") + return + } + guard macOptions.arguments.isEmpty else { throw Console.Error("SDK and scratch overrides require a macOS target") } + let signingAuthToken: AuthToken? if sign { guard let token = try AuthToken.savedIfPresent() else { @@ -155,10 +169,8 @@ struct DevBuildCommand: AsyncParsableCommand { signingAuthToken = nil } - let url = try await PackOperation( - triple: triple, - buildOptions: packOptions - ).run() + @Dependency(\.devCommandClient) var client + let url = try await client.legacyPack(request.schema, request.triple, packOptions.configuration, false) if let signingAuthToken { let installDelegate = XToolInstallerDelegate() @@ -208,19 +220,30 @@ struct DevRunCommand: AsyncParsableCommand { commandName: "run", abstract: "Build and run app with SwiftPM", discussion: """ - This command deploys the SwiftPM-based iOS app in the current directory + Build and run the configured app. macOS apps run locally without adding signatures. + Use --app to run an existing macOS app without rebuilding. Output is streamed to + this terminal and the app's exit status is returned. """ ) @OptionGroup var packOptions: PackOperation.BuildOptions + @Option(help: "Override the configured target triple") + var triple: String? + + @Option(help: "Run an existing macOS .app without building or signing it") + var app: String? + + @Argument(parsing: .postTerminator, help: "Arguments passed to the macOS app after --") + var appArguments: [String] = [] + #if os(macOS) @Flag( name: .shortAndLong, help: "Target the iOS Simulator" ) var simulator = false - var triple: String? { + var simulatorTriple: String? { if simulator { #if arch(arm64) return "arm64-apple-ios-simulator" @@ -233,13 +256,32 @@ struct DevRunCommand: AsyncParsableCommand { return nil } #else - var triple: String? { nil } + var simulatorTriple: String? { nil } #endif @OptionGroup var connectionOptions: ConnectionOptions func run() async throws { - let output = try await PackOperation(triple: triple, buildOptions: packOptions).run() + if let app { + guard triple == nil, simulatorTriple == nil else { + throw Console.Error("--app cannot be combined with --triple or --simulator") + } + try await MacRunOperation.run(bundle: URL(fileURLWithPath: app), arguments: appArguments) + return + } + // Validate the configured target before the simulator override can hide it. + let request = try await DevBuildRequest.load(cliTriple: triple) + try request.validate(route: .run) + if request.platform == .macOS { + guard simulatorTriple == nil else { throw Console.Error("--simulator cannot run a macOS target") } + try MacRunOperation.requireHost() + let output = try await DeferredMacBuildOperation(request: request, configuration: packOptions.configuration).run() + try await MacRunOperation.run(bundle: output.bundle, arguments: appArguments) + return + } + guard appArguments.isEmpty else { throw Console.Error("App arguments currently require a macOS target") } + @Dependency(\.devCommandClient) var client + let output = try await client.legacyPack(request.schema, simulatorTriple ?? request.triple, packOptions.configuration, false) #if os(macOS) if simulator { @@ -250,8 +292,8 @@ struct DevRunCommand: AsyncParsableCommand { let token = try AuthToken.saved() - let client = try await connectionOptions.client() - print("Installing to device: \(client.deviceName) (udid: \(client.udid))") + let device = try await connectionOptions.client() + print("Installing to device: \(device.deviceName) (udid: \(device.udid))") let installDelegate = XToolInstallerDelegate() let installer = IntegratedInstaller( @@ -264,8 +306,8 @@ struct DevRunCommand: AsyncParsableCommand { do { try await installer.install( app: output, - udid: client.udid, - lookupMode: .only(client.connectionType), + udid: device.udid, + lookupMode: .only(device.connectionType), configureDevice: false, ) } catch let error as CancellationError { @@ -277,6 +319,204 @@ struct DevRunCommand: AsyncParsableCommand { } } +/// Select the effective target before auth, SDK setup, LSP writes, or packing. +/// Planner resolves the deployment minimum later, with package metadata available. +struct DevBuildRequest: Sendable { + enum Route { + case build(sign: Bool, ipa: Bool, deferSigning: Bool) + case run + case xcode + } + + let schema: PackSchema + let triple: String + let platform: TargetPlatform + let directory: URL + + init(schema: PackSchema, cliTriple: String? = nil, directory: URL) throws { + self.schema = schema + self.triple = cliTriple ?? schema.target ?? ResolvedTarget.defaultTriple + self.platform = try TargetTriple(triple).platform + self.directory = directory + try schema.validate(for: platform) + } + + static func load(cliTriple: String? = nil) async throws -> Self { + @Dependency(\.devCommandClient) var client + let directory = client.currentDirectory() + let schema = try await client.loadSchema(directory) + return try Self(schema: schema, cliTriple: cliTriple, directory: directory) + } + + func validate(route: Route) throws { + switch route { + case .build(let sign, let ipa, let deferSigning): + if platform == .macOS { + guard !sign, !ipa else { + throw Console.Error(""" + macOS builds do not support --sign, IPA packaging, or device provisioning; use --defer-signing + """) + } + guard deferSigning else { + throw Console.Error("macOS builds require --defer-signing; sign the resulting app later on a Mac") + } + } else if deferSigning { + throw Console.Error("--defer-signing requires a macOS target") + } + case .run: + break + case .xcode: + guard platform != .macOS else { + throw Console.Error(""" + Xcode project generation for macOS is unsupported + """) + } + } + } +} + +struct MacBuildOptions: ParsableArguments { + @Option(name: .customLong("swift-sdks-path"), help: "Use a prepared Swift SDK directory for macOS builds") + var swiftSDKsPath: String? + + @Option(help: "Use a separate SwiftPM scratch directory for macOS builds") + var scratchPath: String? + + var arguments: [String] { + (swiftSDKsPath.map { ["--swift-sdks-path", $0] } ?? []) + + (scratchPath.map { ["--scratch-path", $0] } ?? []) + } +} + +struct DeferredMacBuildOperation { + let request: DevBuildRequest + let configuration: BuildConfiguration + var options: MacBuildOptions? + + struct Output { + let bundle: URL + let manifest: URL + } + + func run() async throws -> Output { + guard request.platform == .macOS else { + throw Console.Error("Deferred macOS packing requires a macOS target") + } + #if !os(macOS) + guard configuration == .release else { + throw Console.Error("macOS Debug builds on Linux need unavailable codesign support; use --configuration release") + } + #endif + try PackOperation.requirePackageManifest(in: request.directory) + @Dependency(\.devCommandClient) var client + if options?.swiftSDKsPath == nil { try await client.ensureSDK() } + print("Planning...") + let settings = try await BuildSettings( + configuration: configuration, triple: request.triple, + buildSystem: .swiftPM, packagePath: request.directory.path, options: options?.arguments ?? [] + ) + let plan = try await Planner(buildSettings: settings, schema: request.schema).createPlan() + await LSPConfig.tryEnsure(plan: plan, settings: settings, schema: request.schema, currentDirectory: request.directory) + // This deliberately bypasses PackOperation's automatic entitlement signing. + let bundle = try await client.deferredPack(settings, plan) + let manifest = try DeferredSigningHandoff.write(plan: plan, bundle: bundle, sourceDirectory: request.directory) + try MacArtifact.write(bundle: bundle, signingManifest: manifest) + return Output(bundle: bundle, manifest: manifest) + } +} + +enum DeferredSigningHandoff { + /// Keep signing inputs outside the bundle, so they cannot alter bundle resources. + static func write(plan: Plan, bundle: URL, sourceDirectory: URL) throws -> URL { + guard plan.target.platform == .macOS else { + throw Console.Error("Deferred signing handoffs require a macOS plan") + } + var manifest = DeferredSigningManifest(plan: plan) + let name = bundle.deletingPathExtension().lastPathComponent + let parent = bundle.deletingLastPathComponent() + let inputs = parent.appendingPathComponent("\(name).signing-inputs", isDirectory: true) + let manifestURL = MacArtifact.signingManifestURL(for: bundle) + var files: [(String, Data)] = [] + for index in manifest.products.indices { + guard let path = manifest.products[index].entitlementsPath else { continue } + let source = URL(fileURLWithPath: path, relativeTo: sourceDirectory) + let data = try Data(contentsOf: source) + // Fail here, on the build machine, rather than later at signing. + do { + try MacSigningPlan.validateEntitlements(data) + } catch { + throw Console.Error("\(path): \(error)") + } + let filename = "\(index)-\(ArtifactInventory.hash(data: data)).entitlements.plist" + files.append((filename, data)) + manifest.products[index].entitlementsPath = "\(inputs.lastPathComponent)/\(filename)" + } + let data = try JSONEncoder.sortedPretty().encode(manifest) + // Content-addressed inputs keep the previous manifest usable until atomic replacement. + // Retain older generations: an interrupted build must not invalidate existing handoffs. + if !files.isEmpty { + try FileManager.default.createDirectory(at: inputs, withIntermediateDirectories: true) + for (filename, contents) in files { + try contents.write(to: inputs.appendingPathComponent(filename), options: .atomic) + } + } + try data.write(to: manifestURL, options: .atomic) + return manifestURL + } +} + +/// External effects are replaceable through swift-dependencies, including packing. +struct DevCommandClient: Sendable { + var currentDirectory: @Sendable () -> URL + var loadSchema: @Sendable (URL) async throws -> PackSchema + var ensureSDK: @Sendable () async throws -> Void + var legacyPack: @Sendable (PackSchema, String, BuildConfiguration, Bool) async throws -> URL + var deferredPack: @Sendable (BuildSettings, Plan) async throws -> URL +} + +extension DevCommandClient: DependencyKey { + static let liveValue = Self( + currentDirectory: { URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) }, + loadSchema: { directory in + let path = directory.appendingPathComponent("xtool.yml") + if FileManager.default.fileExists(atPath: path.path) { + return try await PackSchema(url: path) + } + // stderr: `dev build-server` speaks the build server protocol on stdout. + FileHandle.standardError.write(Data(""" + warning: Could not locate configuration file '\(path.path)'. Using default \ + configuration with 'com.example' organization ID. + + """.utf8)) + return .default + }, + ensureSDK: { try await EnsureSDKOperation(quiet: true).run() }, + legacyPack: { schema, triple, configuration, xcode in + try await PackOperation( + schema: schema, triple: triple, buildOptions: .init(configuration: configuration), xcode: xcode + ).run() + }, + deferredPack: { settings, plan in + try await Packer(buildSettings: settings, plan: plan).pack() + } + ) + + static let testValue = Self( + currentDirectory: { URL(fileURLWithPath: "/unexpected-command-directory") }, + loadSchema: { _ in throw StringError("Unexpected schema load") }, + ensureSDK: { throw StringError("Unexpected SDK setup") }, + legacyPack: { _, _, _, _ in throw StringError("Unexpected iOS packing") }, + deferredPack: { _, _ in throw StringError("Unexpected deferred packing") } + ) +} + +extension DependencyValues { + var devCommandClient: DevCommandClient { + get { self[DevCommandClient.self] } + set { self[DevCommandClient.self] = newValue } + } +} + struct DevCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "dev", @@ -285,6 +525,9 @@ struct DevCommand: AsyncParsableCommand { DevXcodeCommand.self, DevBuildCommand.self, DevRunCommand.self, + DevVerifyCommand.self, + MacSignCommand.self, + DevNotarizeCommand.self, DevBSPCommand.self, ], defaultSubcommand: DevRunCommand.self diff --git a/Sources/XToolSupport/LSPConfig.swift b/Sources/XToolSupport/LSPConfig.swift index b7482b56..900ef40c 100644 --- a/Sources/XToolSupport/LSPConfig.swift +++ b/Sources/XToolSupport/LSPConfig.swift @@ -28,11 +28,12 @@ enum LSPConfig { nonisolated(nonsending) static func tryEnsure( using schema: PackSchema, + cliTriple: String? = nil, quiet: Bool = false, currentDirectory directory: URL = URL(filePath: FileManager.default.currentDirectoryPath) ) async { do { - try await ensure(using: schema, quiet: quiet, currentDirectory: directory) + try await ensure(using: schema, cliTriple: cliTriple, quiet: quiet, currentDirectory: directory) } catch { print("warning: failed to validate LSP configuration: \(error)") } @@ -40,6 +41,7 @@ enum LSPConfig { nonisolated(nonsending) static func ensure( using schema: PackSchema, + cliTriple: String? = nil, quiet: Bool = false, currentDirectory directory: URL = URL(filePath: FileManager.default.currentDirectoryPath) ) async throws { @@ -47,44 +49,119 @@ enum LSPConfig { return } - if try await SwiftVersion.current.supportsSwiftBuild { - try await ensure(primary: bsp, secondary: legacyLSP, quiet: quiet, currentDirectory: directory) + let request = try DevBuildRequest(schema: schema, cliTriple: cliTriple, directory: directory) + let (settings, target) = try await EditorTarget.buildSettings(request: request) + try ensure(target: target, sdkOptions: settings.sdkOptions, + supportsBSP: try await SwiftVersion.current.supportsSwiftBuild, + quiet: quiet, currentDirectory: directory) + } + + /// Builds already planned the package; reuse that plan instead of inspecting the package again. + nonisolated(nonsending) static func tryEnsure( + plan: Plan, + settings: BuildSettings, + schema: PackSchema, + currentDirectory directory: URL = URL(filePath: FileManager.default.currentDirectoryPath) + ) async { + guard schema.skipLSP != true else { return } + do { + try ensure(target: plan.target, sdkOptions: settings.withTarget(plan.target).sdkOptions, + supportsBSP: try await SwiftVersion.current.supportsSwiftBuild, currentDirectory: directory) + } catch { + print("warning: failed to validate LSP configuration: \(error)") + } + } + + static func configuration(target: ResolvedTarget, sdkOptions: [String], bsp: Bool) throws -> (String, String) { + let object: [String: Any] + let path: String + if bsp { + path = Self.bsp.0 + // Pin the planned deployment version so starting the server needs no package inspection. + object = bspObject(triple: target.compilerTriple) } else { - try await ensure(primary: legacyLSP, secondary: bsp, quiet: quiet, currentDirectory: directory) + path = legacyLSP.0 + let sdkIndex = sdkOptions.firstIndex(of: "--swift-sdk") + // Legacy SourceKit uses SwiftPM even when builds default to SwiftBuild. + let sdk = sdkIndex.map { sdkOptions[$0 + 1] } ?? target.platform.triple(architecture: target.architecture) + object = ["swiftPM": ["swiftSDK": sdk, "triple": target.compilerTriple]] } + let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) + return (path, String(decoding: data, as: UTF8.self)) } - // creates the primary LSP-support file if it doesn't exist, and cleans up old files - private nonisolated(nonsending) static func ensure( - primary: (String, String), - secondary: (String, String), - quiet: Bool, - currentDirectory directory: URL - ) async throws { + private static func bspObject(triple: String) -> [String: Any] { + [ + "name": "xtool", "version": "1.0", "bspVersion": "2.2.0", + "languages": ["c", "cpp", "objective-c", "objective-cpp", "swift"], + "argv": ["/usr/bin/env", "xtool", "dev", "build-server", "--triple", triple], + ] + } + + static func ensure( + target: ResolvedTarget, sdkOptions: [String], supportsBSP: Bool, quiet: Bool = false, currentDirectory directory: URL + ) throws { + let primary = try configuration(target: target, sdkOptions: sdkOptions, bsp: supportsBSP) + let secondary = try configuration(target: target, sdkOptions: sdkOptions, bsp: !supportsBSP) let primaryURL = directory.appendingPathComponent(primary.0) if FileManager.default.fileExists(atPath: primaryURL.path) { - return + let data = try Data(contentsOf: primaryURL) + guard isGenerated(data, bsp: supportsBSP) else { return } } - - try? FileManager.default.createDirectory( - at: primaryURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try Data((primary.1 + "\n").utf8).write(to: primaryURL) + try FileManager.default.createDirectory(at: primaryURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data((primary.1 + "\n").utf8).write(to: primaryURL, options: .atomic) let secondaryURL = directory.appendingPathComponent(secondary.0) - if let secondaryData = try? await Data(reading: secondaryURL), - let secondaryString = String(data: secondaryData, encoding: .utf8), - secondaryString.trimmingCharacters(in: .whitespacesAndNewlines) == secondary.1 { - try? FileManager.default.removeItem(at: secondaryURL) + if FileManager.default.fileExists(atPath: secondaryURL.path), + isGenerated(try Data(contentsOf: secondaryURL), bsp: !supportsBSP) { + try FileManager.default.removeItem(at: secondaryURL) } - if !quiet { print(""" - [LSP] Created \(primary.0) for enhanced IDE support. You may + [LSP] Configured \(primary.0) for enhanced IDE support. You may need to restart your editor for the change to take effect. You can disable this with `skipLSP: true` in xtool.yml. """) } } + + /// Recognize complete generated shapes; any extra key or changed value is user-owned. + private static func isGenerated(_ data: Data, bsp: Bool) -> Bool { + guard let object = try? JSONSerialization.jsonObject(with: data) as? NSDictionary else { return false } + let original = bsp ? Self.bsp.1 : legacyLSP.1 + if let old = try? JSONSerialization.jsonObject(with: Data(original.utf8)) as? NSDictionary, object == old { + return true + } + if bsp { + // Earlier generated configurations pinned the unversioned triple; recognize any valid one. + let argv = object["argv"] as? [String] + guard argv?.count == 6, let triple = argv?.last, (try? TargetTriple(triple)) != nil else { return false } + return object == bspObject(triple: triple) as NSDictionary + } + let triple = (object["swiftPM"] as? [String: String])?["triple"] + guard let triple, let target = try? ResolvedTarget.resolve(cliTarget: triple), + let expected = try? configuration(target: target, sdkOptions: [], bsp: bsp), + let generated = try? JSONSerialization.jsonObject(with: Data(expected.1.utf8)) as? NSDictionary else { + return false + } + return object == generated + } +} + +/// Use the same package deployment minima and target contract as app builds. +enum EditorTarget { + nonisolated(nonsending) static func buildSettings(request: DevBuildRequest) async throws -> (BuildSettings, ResolvedTarget) { + let settings = try await BuildSettings(configuration: .debug, triple: request.triple, packagePath: request.directory.path) + let plan = try await Planner(buildSettings: settings, schema: request.schema).createPlan() + return (try settings.withTarget(plan.target), plan.target) + } + + /// The build server starts with the compiler triple pinned in the generated configuration. + /// Resolve it without the planner, so a broken manifest or a locked or unresolved `.build` + /// cannot keep the editor's build server from starting. + nonisolated(nonsending) static func serverSettings(request: DevBuildRequest) async throws -> BuildSettings { + let target = try ResolvedTarget.resolve(cliTarget: request.triple, schema: request.schema) + let settings = try await BuildSettings(configuration: .debug, triple: request.triple, packagePath: request.directory.path) + return try settings.withTarget(target) + } } diff --git a/Sources/XToolSupport/MacHostClient.swift b/Sources/XToolSupport/MacHostClient.swift new file mode 100644 index 00000000..14c03cf6 --- /dev/null +++ b/Sources/XToolSupport/MacHostClient.swift @@ -0,0 +1,72 @@ +import Dependencies +import Foundation +import PackLib +import Subprocess +import XUtils + +struct MacToolOutput: Sendable { + var output: Data + var error: Data +} + +/// The Mac host tools that signing, notarization and `dev run` invoke. +struct MacHostClient: DependencyKey, Sendable { + var isMacOS: @Sendable () -> Bool = { false } + /// Runs a tool to completion, capturing both streams; codesign reports metadata on stderr. + var capture: @Sendable (String, [String]) async throws -> MacToolOutput = { tool, _ in + throw StringError("Unexpected host tool invocation: \(tool)") + } + /// Runs a tool to completion, capturing stdout while stderr progress reaches the terminal. + var stream: @Sendable (String, [String]) async throws -> Data = { tool, _ in + throw StringError("Unexpected host tool invocation: \(tool)") + } + /// Runs an app attached to this terminal and returns its exit status. + var launch: @Sendable (URL, [String]) async throws -> Int32 = { _, _ in throw StringError("Unexpected Mac launch") } + + static let liveValue = Self( + isMacOS: { + #if os(macOS) + true + #else + false + #endif + }, + capture: { executable, arguments in + let result = try await Subprocess.run( + .path(.init(executable)), arguments: .init(arguments), + platformOptions: .withGracefulShutDown, output: .data(limit: .max), error: .data(limit: .max) + ) + do { try result.checkSuccess() } catch { + throw StringError("\(executable) failed: \(String(decoding: result.standardError, as: UTF8.self))") + } + return MacToolOutput(output: result.standardOutput, error: result.standardError) + }, + stream: { executable, arguments in + let result = try await Subprocess.run( + .path(.init(executable)), arguments: .init(arguments), + output: .string(limit: .max), error: .currentStandardError + ).checkSuccess() + return Data(result.standardOutput.utf8) + }, + launch: { executable, arguments in + let result = try await Subprocess.run( + .path(.init(executable.path)), arguments: .init(arguments), + platformOptions: .withGracefulShutDown, + output: .currentStandardOutput, error: .currentStandardError + ) + switch result.terminationStatus { + case .exited(let code): return code + case .signaled(let signal): return 128 + signal + } + } + ) + + static let testValue = Self() +} + +extension DependencyValues { + var macHostClient: MacHostClient { + get { self[MacHostClient.self] } + set { self[MacHostClient.self] = newValue } + } +} diff --git a/Sources/XToolSupport/MacNotarizationCommand.swift b/Sources/XToolSupport/MacNotarizationCommand.swift new file mode 100644 index 00000000..6c81b7d2 --- /dev/null +++ b/Sources/XToolSupport/MacNotarizationCommand.swift @@ -0,0 +1,68 @@ +import ArgumentParser +import Foundation +import PackLib +import XUtils + +struct DevNotarizeCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "notarize", abstract: "Submit, resume, and verify a Developer ID signed macOS app", + discussion: "Submit creates a new private release directory. Other actions use that directory. " + + "Credentials must already exist in an explicitly selected notarytool keychain profile. " + + "No signing or credential provisioning is performed." + ) + + enum Action: String, ExpressibleByArgument, CaseIterable { case submit, status, resume, log, staple, verify } + + @Argument(help: "submit, status, resume, log, staple, or verify") + var action: Action + @Argument(help: "Signed .app for submit; release directory for other actions") + var path: String + @Option(help: "New release directory (submit only)") + var output: String? + @Option(help: "Explicit existing notarytool keychain profile for network actions") + var keychainProfile: String? + @Option(help: "Bounded wait in seconds (resume only)") + var timeout: Int = 60 + @Option(help: "Recover unknown submission using a completed Apple log matching the archive (resume only)") + var submissionID: String? + + func validate() throws { + guard (action == .submit) == (output != nil) else { + throw ValidationError("--output is required for submit and unavailable for other actions") + } + guard timeout > 0, action == .resume || (timeout == 60 && submissionID == nil) else { + throw ValidationError("--timeout and --submission-id are supported only by resume") + } + if action == .staple || action == .verify { + guard keychainProfile == nil else { throw ValidationError("This action does not use keychain credentials") } + } else { + _ = try MacNotarizationOperation.authentication(keychainProfile) + } + } + + func run() async throws { + let url = URL(fileURLWithPath: path) + switch action { + case .submit: + guard let output else { throw StringError("Missing --output") } + try await MacNotarizationOperation.submit(bundle: url, directory: URL(fileURLWithPath: output), + profile: keychainProfile) + print("Submission recorded in \(output); use status or resume with that directory") + case .status, .resume: + let status = try await MacNotarizationOperation.update(directory: url, profile: keychainProfile, + wait: action == .resume, timeout: timeout, + recoveryID: submissionID) + print("Notarization status: \(status)") + if status == "Invalid" || status == "Rejected" { throw ExitCode.failure } + case .log: + let data = try await MacNotarizationOperation.log(directory: url, profile: keychainProfile) + print(String(decoding: data, as: UTF8.self)) + case .staple: + try await MacNotarizationOperation.staple(directory: url) + print("Stapled and sealed distribution in \(path)/distribution") + case .verify: + try await MacNotarizationOperation.verify(directory: url) + print("Verified archive, signatures, and stapled ticket in \(path)") + } + } +} diff --git a/Sources/XToolSupport/MacNotarizationOperation.swift b/Sources/XToolSupport/MacNotarizationOperation.swift new file mode 100644 index 00000000..606a36a4 --- /dev/null +++ b/Sources/XToolSupport/MacNotarizationOperation.swift @@ -0,0 +1,335 @@ +import Dependencies +import Foundation +import PackLib +import XUtils + +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + +/// Tests replace native signature verification, which needs genuinely signed apps. +struct MacNotarizationClient: DependencyKey, Sendable { + var verifySignature: @Sendable (URL, MacSigningReceipt) async throws -> Void + + static let liveValue = Self( + verifySignature: { _ = try await MacSigningOperation.verify(bundle: $0, receipt: $1, verifyInventory: false) } + ) + static let testValue = Self(verifySignature: { _, _ in throw StringError("Unexpected signing verification") }) +} + +extension DependencyValues { + var macNotarizationClient: MacNotarizationClient { + get { self[MacNotarizationClient.self] } + set { self[MacNotarizationClient.self] = newValue } + } +} + +/// Append-only local evidence, not a cryptographic attestation. Upload bytes are never rewritten. +struct MacNotarizationReceipt: Codable, Sendable { + var version = 1 + var sequence = 0 + var bundleName: String + var signingReceiptSHA256: String + var uploadSHA256: String + var submissionID: String? + var status: String + var logSHA256: String? + var distributionSHA256: String? + var distributionInventory: ArtifactInventory? +} + +enum MacNotarizationOperation { + static func requireHost() throws { + @Dependency(\.macHostClient) var host + guard host.isMacOS() else { throw StringError("Notarization requires a Mac") } + } + + static func authentication(_ profile: String?) throws -> [String] { + guard let profile, !profile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw StringError("Network notarization commands require an explicit --keychain-profile") + } + return ["--keychain-profile", profile] + } + + static func submit(bundle: URL, directory: URL, profile: String?) async throws { + try requireHost() + let auth = try authentication(profile) + let source = bundle.resolvingSymlinksInPath().standardizedFileURL + let output = directory.deletingLastPathComponent().resolvingSymlinksInPath() + .appendingPathComponent(directory.lastPathComponent).standardizedFileURL + guard output.path != source.path, !output.path.hasPrefix(source.path + "/") else { + throw StringError("Release directory must be outside the input app") + } + let receipt = try MacSigningReceipt.verify(bundle: bundle) + guard receipt.mode == .developerID else { throw StringError("Notarization requires Developer ID signing") } + @Dependency(\.macNotarizationClient) var client + @Dependency(\.macHostClient) var host + try await client.verifySignature(bundle, receipt) + // Creation is exclusive. An existing output, including an interrupted attempt, is never resubmitted. + guard mkdir(directory.path, 0o700) == 0 else { + throw StringError("Release directory must be new; existing attempts are never resubmitted") + } + let lock = try acquireLock(directory) + defer { releaseLock(lock) } + let snapshot = directory.appendingPathComponent("signed", isDirectory: true) + try FileManager.default.createDirectory(at: snapshot, withIntermediateDirectories: false) + let app = snapshot.appendingPathComponent(bundle.lastPathComponent) + try FileManager.default.copyItem(at: bundle, to: app) + try FileManager.default.copyItem(at: MacSigningReceipt.receiptURL(for: bundle), + to: MacSigningReceipt.receiptURL(for: app)) + let copied = try MacSigningReceipt.verify(bundle: app) + try await client.verifySignature(app, copied) + let upload = directory.appendingPathComponent("upload.zip") + _ = try await host.stream("/usr/bin/ditto", ["-c", "-k", "--keepParent", "--sequesterRsrc", app.path, upload.path]) + var state = MacNotarizationReceipt( + bundleName: app.lastPathComponent, + signingReceiptSHA256: try ArtifactInventory.hash(MacSigningReceipt.receiptURL(for: app)), + uploadSHA256: try ArtifactInventory.hash(upload), status: "submission-started" + ) + try append(&state, directory: directory) + let response = try await host.stream("/usr/bin/xcrun", ["notarytool", "submit", upload.path, + "--no-wait", "--output-format", "json"] + auth) + let value = try JSONDecoder().decode(Submission.self, from: response) + guard UUID(uuidString: value.id) != nil else { throw StringError("Invalid notarization submission UUID") } + state.submissionID = value.id + state.status = "submitted" + try response.write(to: directory.appendingPathComponent("submission.json"), options: .atomic) + try append(&state, directory: directory) + } + + static func update(directory: URL, profile: String?, wait: Bool = false, timeout: Int = 60, + recoveryID: String? = nil) async throws -> String { + try requireHost() + let auth = try authentication(profile) + guard timeout > 0 else { throw StringError("Timeout must be positive") } + let lock = try acquireLock(directory) + defer { releaseLock(lock) } + var state = try load(directory) + try validateInputs(state, directory: directory) + if let recoveryID { + guard state.submissionID == nil, UUID(uuidString: recoveryID) != nil else { + throw StringError("Recovery requires an ambiguous submission and a valid explicit submission UUID") + } + // A completed Apple log must establish exact archive binding before adopting the supplied UUID. + _ = try await fetchLog(state, id: recoveryID, directory: directory, authentication: auth) + state.submissionID = recoveryID + state.status = "recovered" + try append(&state, directory: directory) + } + guard let id = state.submissionID else { + throw StringError("Submission outcome is unknown; use --submission-id with a completed matching Apple log. " + + "Do not resubmit.") + } + @Dependency(\.macHostClient) var host + var arguments = ["notarytool", wait ? "wait" : "info", id, "--output-format", "json"] + auth + if wait { arguments += ["--timeout", "\(timeout)s"] } + let response = try await host.stream("/usr/bin/xcrun", arguments) + let value = try JSONDecoder().decode(Submission.self, from: response) + guard value.id.lowercased() == id.lowercased(), let status = value.status, + ["Accepted", "Invalid", "In Progress", "Rejected"].contains(status) else { + throw StringError("Unexpected notarization response identity or status") + } + // Once stapled, observations cannot discard the distribution evidence. + if state.status == "stapled" { return state.status } + state.status = status + if status == "Accepted" || status == "Invalid" || status == "Rejected" { + let log = try await fetchLog(state, id: id, directory: directory, authentication: auth) + guard log.status == status else { throw StringError("Notarization status and log disagree") } + state.logSHA256 = try ArtifactInventory.hash(logURL(directory)) + } + try append(&state, directory: directory) + return state.status + } + + static func log(directory: URL, profile: String?) async throws -> Data { + try requireHost() + let auth = try authentication(profile) + let lock = try acquireLock(directory) + defer { releaseLock(lock) } + let state = try load(directory) + try validateInputs(state, directory: directory) + guard let id = state.submissionID else { throw StringError("Submission UUID is unknown") } + _ = try await fetchLog(state, id: id, directory: directory, authentication: auth) + return try Data(contentsOf: logURL(directory)) + } + + static func staple(directory: URL) async throws { + try requireHost() + let lock = try acquireLock(directory) + defer { releaseLock(lock) } + var state = try load(directory) + try validateInputs(state, directory: directory) + if state.status == "stapled" { try await verifyDistribution(state, directory: directory); return } + guard state.status == "Accepted" else { + throw StringError("Stapling requires an accepted archive-bound notarization log") + } + try verifyAcceptedLog(state, directory: directory) + let destination = directory.appendingPathComponent("distribution") + if FileManager.default.fileExists(atPath: destination.path) { + var recovered = try JSONDecoder().decode(MacNotarizationReceipt.self, + from: Data(contentsOf: destination.appendingPathComponent("receipt.json"))) + guard recovered.bundleName == state.bundleName, + recovered.signingReceiptSHA256 == state.signingReceiptSHA256, + recovered.uploadSHA256 == state.uploadSHA256, recovered.submissionID == state.submissionID, + recovered.logSHA256 == state.logSHA256 else { + throw StringError("Existing distribution is not bound to this submission") + } + try await verifyDistribution(recovered, directory: directory) + recovered.sequence = state.sequence + try append(&recovered, directory: directory) + return + } + let work = directory.appendingPathComponent("staple-" + UUID().uuidString) + try FileManager.default.createDirectory(at: work, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: work) } + let source = signedApp(state, directory: directory) + let app = work.appendingPathComponent(state.bundleName) + try FileManager.default.copyItem(at: source, to: app) + @Dependency(\.macNotarizationClient) var client + @Dependency(\.macHostClient) var host + let signing = try MacSigningReceipt.verify(bundle: source) + try await client.verifySignature(app, signing) + _ = try await host.stream("/usr/bin/xcrun", ["stapler", "staple", app.path]) + _ = try await host.stream("/usr/bin/xcrun", ["stapler", "validate", app.path]) + try await client.verifySignature(app, signing) + let zip = work.appendingPathComponent("distribution.zip") + _ = try await host.stream("/usr/bin/ditto", ["-c", "-k", "--keepParent", "--sequesterRsrc", app.path, zip.path]) + state.distributionInventory = try ArtifactInventory(directory: app) + state.distributionSHA256 = try ArtifactInventory.hash(zip) + state.status = "stapled" + // Publish recoverable evidence together with the app; a crash before the event append is resumable. + try JSONEncoder.sortedPretty().encode(state).write(to: work.appendingPathComponent("receipt.json"), options: .atomic) + try FileManager.default.moveItem(at: work, to: destination) + try append(&state, directory: directory) + } + + static func verify(directory: URL) async throws { + try requireHost() + let lock = try acquireLock(directory) + defer { releaseLock(lock) } + let state = try load(directory) + try validateInputs(state, directory: directory) + try await verifyDistribution(state, directory: directory) + } + + private static func verifyDistribution(_ state: MacNotarizationReceipt, directory: URL) async throws { + guard state.status == "stapled", let inventory = state.distributionInventory, + let expected = state.distributionSHA256 else { + throw StringError("No verified stapled distribution receipt") + } + try verifyAcceptedLog(state, directory: directory) + let distribution = directory.appendingPathComponent("distribution") + let app = distribution.appendingPathComponent(state.bundleName) + try inventory.verify(directory: app) + guard try ArtifactInventory.hash(distribution.appendingPathComponent("distribution.zip")) == expected else { + throw StringError("Distribution archive changed") + } + let signing = try MacSigningReceipt.verify(bundle: signedApp(state, directory: directory)) + @Dependency(\.macNotarizationClient) var client + @Dependency(\.macHostClient) var host + try await client.verifySignature(app, signing) + _ = try await host.stream("/usr/bin/xcrun", ["stapler", "validate", app.path]) + } + + /// The stored log must still match its recorded hash, bind this archive, and be accepted. + private static func verifyAcceptedLog(_ state: MacNotarizationReceipt, directory: URL) throws { + guard let logHash = state.logSHA256, try ArtifactInventory.hash(logURL(directory)) == logHash else { + throw StringError("Stored notarization log is missing or changed") + } + let log = try JSONDecoder().decode(NotaryLog.self, from: Data(contentsOf: logURL(directory))) + try validateLog(log, state: state, id: state.submissionID ?? "") + guard log.status == "Accepted" else { throw StringError("Notarization log is not accepted") } + } + + private static func logURL(_ directory: URL) -> URL { + directory.appendingPathComponent("notary-log.json") + } + + private static func signedApp(_ state: MacNotarizationReceipt, directory: URL) -> URL { + directory.appendingPathComponent("signed").appendingPathComponent(state.bundleName) + } + + struct Submission: Decodable { var id: String; var status: String? } + struct NotaryLog: Codable { var jobId: String; var sha256: String; var status: String } + + private static func fetchLog(_ state: MacNotarizationReceipt, id: String, directory: URL, + authentication: [String]) async throws -> NotaryLog { + @Dependency(\.macHostClient) var host + let data = try await host.stream("/usr/bin/xcrun", ["notarytool", "log", id] + authentication) + let log = try JSONDecoder().decode(NotaryLog.self, from: data) + try validateLog(log, state: state, id: id) + let destination = logURL(directory) + if let previous = state.logSHA256 { + guard try ArtifactInventory.hash(destination) == previous else { throw StringError("Stored notary log changed") } + let existing = try JSONDecoder().decode(NotaryLog.self, from: Data(contentsOf: destination)) + guard existing.jobId == log.jobId, existing.sha256 == log.sha256, existing.status == log.status else { + throw StringError("Notarization log changed") + } + } else { + try data.write(to: destination, options: .atomic) + } + return log + } + + static func validateLog(_ log: NotaryLog, state: MacNotarizationReceipt, id: String) throws { + guard log.jobId.lowercased() == id.lowercased(), log.sha256.lowercased() == state.uploadSHA256, + ["Accepted", "Invalid", "Rejected"].contains(log.status) else { + throw StringError("Apple notarization log does not bind this submission and exact archive") + } + } + + static func validateInputs(_ state: MacNotarizationReceipt, directory: URL) throws { + guard state.version == 1, state.bundleName.hasSuffix(".app"), + state.bundleName == URL(fileURLWithPath: state.bundleName).lastPathComponent, + !state.bundleName.contains("/"), !state.bundleName.contains("\\") else { + throw StringError("Invalid notarization receipt") + } + let app = signedApp(state, directory: directory) + guard try ArtifactInventory.hash(MacSigningReceipt.receiptURL(for: app)) == state.signingReceiptSHA256, + try ArtifactInventory.hash(directory.appendingPathComponent("upload.zip")) == state.uploadSHA256 else { + throw StringError("Signed receipt or submitted archive changed") + } + let signed = try MacSigningReceipt.verify(bundle: app) + guard signed.mode == .developerID else { throw StringError("Notarization requires Developer ID signing") } + } + + static func acquireLock(_ directory: URL) throws -> Int32 { + let path = directory.appendingPathComponent("operation.lock").path + let descriptor = open(path, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0o600) + guard descriptor >= 0 else { throw StringError("Cannot open notarization lock") } + guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else { + close(descriptor) + throw StringError("Another notarization operation is running") + } + // Never unlink this inode. The kernel releases the lock even when the process crashes. + return descriptor + } + + static func releaseLock(_ descriptor: Int32) { + // Explicit unlock also releases copies inherited during a concurrent fork before exec. + _ = flock(descriptor, LOCK_UN) + close(descriptor) + } + + static func append(_ state: inout MacNotarizationReceipt, directory: URL) throws { + state.sequence += 1 + let url = directory.appendingPathComponent(String(format: "receipt-%06d.json", state.sequence)) + guard !FileManager.default.fileExists(atPath: url.path) else { throw StringError("Receipt event already exists") } + try JSONEncoder.sortedPretty().encode(state).write(to: url, options: .atomic) + } + + static func load(_ directory: URL) throws -> MacNotarizationReceipt { + let names = try FileManager.default.contentsOfDirectory(atPath: directory.path) + .filter { $0.hasPrefix("receipt-") && $0.hasSuffix(".json") }.sorted() + guard let last = names.last else { throw StringError("No notarization receipt; do not retry an ambiguous submission") } + let state = try JSONDecoder().decode(MacNotarizationReceipt.self, + from: Data(contentsOf: directory.appendingPathComponent(last))) + guard state.version == 1, state.sequence == names.count, + names.enumerated().allSatisfy({ $0.element == String(format: "receipt-%06d.json", $0.offset + 1) }) else { + throw StringError("Invalid notarization receipt event sequence") + } + return state + } +} diff --git a/Sources/XToolSupport/MacRunOperation.swift b/Sources/XToolSupport/MacRunOperation.swift new file mode 100644 index 00000000..952b9827 --- /dev/null +++ b/Sources/XToolSupport/MacRunOperation.swift @@ -0,0 +1,56 @@ +import ArgumentParser +import Dependencies +import Foundation +import PackLib +import XUtils + +enum MacRunOperation { + static func requireHost() throws { + @Dependency(\.macHostClient) var client + guard client.isMacOS() else { + throw Console.Error("macOS apps must run on a Mac; transfer the app and its handoff files to a Mac first") + } + } + + static func run(bundle: URL, arguments: [String]) async throws { + try requireHost() + let executable = try MacBundleAudit.executable(in: bundle) + // Runtime validation is independent of the pre-signing byte inventory: an externally + // signed app is expected to differ from that inventory, and is never rewritten here. + #if arch(arm64) + let host = "arm64" + #else + let host = "x86_64" + #endif + let target = try MacBundleAudit.runTarget(bundle: bundle, hostArchitecture: host) + try MacBundleAudit.validate(bundle: bundle, target: target) + print("Launching \(bundle.lastPathComponent)...") + @Dependency(\.macHostClient) var client + let status = try await client.launch(executable, arguments) + if status != 0 { throw ExitCode(status) } + } +} + +struct DevVerifyCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "verify", abstract: "Verify a macOS build handoff or signed artifact" + ) + @Argument(help: "Path to the .app; its .artifact.json and signing inputs must remain beside it") + var app: String + + @Flag(help: "Verify the adjacent signing receipt and native signatures instead of the pre-signing handoff") + var signed = false + + func run() async throws { + let bundle = URL(fileURLWithPath: app) + if signed { + let receipt = try MacSigningReceipt.verify(bundle: bundle) + // The receipt check already verified the inventory. + _ = try await MacSigningOperation.verify(bundle: bundle, receipt: receipt, verifyInventory: false) + print("Verified signed artifact and signatures: \(app)") + } else { + try MacArtifact.verify(bundle: bundle) + print("Verified artifact and signing inputs: \(app)") + } + } +} diff --git a/Sources/XToolSupport/MacSignCommand.swift b/Sources/XToolSupport/MacSignCommand.swift new file mode 100644 index 00000000..950ef157 --- /dev/null +++ b/Sources/XToolSupport/MacSignCommand.swift @@ -0,0 +1,35 @@ +import ArgumentParser +import Foundation +import PackLib + +struct MacSignCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "sign", abstract: "Sign a verified macOS build handoff into a new output directory" + ) + + @Argument(help: "Input .app with its unchanged artifact and deferred signing handoff") + var app: String + + @Option(help: "New output directory; its parent must exist, and existing output is never replaced") + var output: String + + @Option(help: "Explicit signing mode") + var mode: MacSigningMode + + @Option(help: "Exact 40-character certificate SHA-1 fingerprint; required for certificate signing") + var identity: String? + + @Flag(help: "Verify inputs and print the signing plan without modifying files or invoking signing tools") + var dryRun = false + + func validate() throws { + try MacSigningPlan.validateIdentity(mode: mode, identity: identity) + } + + func run() async throws { + try await MacSigningOperation.run(bundle: URL(fileURLWithPath: app), output: URL(fileURLWithPath: output), + mode: mode, identity: identity, dryRun: dryRun) + } +} + +extension MacSigningMode: ExpressibleByArgument {} diff --git a/Sources/XToolSupport/MacSigningOperation.swift b/Sources/XToolSupport/MacSigningOperation.swift new file mode 100644 index 00000000..895101e8 --- /dev/null +++ b/Sources/XToolSupport/MacSigningOperation.swift @@ -0,0 +1,173 @@ +import Dependencies +import Foundation +import PackLib +import XUtils + +enum MacSigningOperation { + static func run(bundle: URL, output: URL, mode: MacSigningMode, identity: String?, dryRun: Bool) async throws { + let plan = try MacSigningPlan.make(bundle: bundle, mode: mode, identity: identity) + try validateOutput(output, source: bundle) + if dryRun { + print(String(decoding: try JSONEncoder.sortedPretty().encode(plan), as: UTF8.self)) + return + } + @Dependency(\.macHostClient) var client + guard client.isMacOS() else { throw StringError("Mac signing requires a macOS host") } + let manager = FileManager.default + let stage = output.deletingLastPathComponent().appendingPathComponent(".xtool-sign-\(UUID().uuidString)") + try manager.createDirectory(at: stage, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + defer { try? manager.removeItem(at: stage) } + let stagedBundle = stage.appendingPathComponent(plan.bundleName) + try manager.copyItem(at: bundle, to: stagedBundle) + try plan.inputInventory.verify(directory: stagedBundle) + try checkSource(bundle, plan: plan) + let inputs = stage.appendingPathComponent(".entitlements") + try manager.createDirectory(at: inputs, withIntermediateDirectories: false) + for (index, target) in plan.targets.enumerated() { + try Task.checkCancellation() + var arguments = ["--force", "--sign", plan.identity ?? "-"] + if mode == .developerID { + arguments += ["--options", "runtime", "--timestamp"] + } else { + arguments += ["--options", "0", "--timestamp=none"] + } + if let entitlements = target.entitlements { + let file = inputs.appendingPathComponent("\(index).plist") + try entitlements.write(to: file, options: .atomic) + arguments += ["--entitlements", file.path, "--generate-entitlement-der"] + } + arguments.append(codeURL(target.path, bundle: stagedBundle).path) + _ = try await client.capture("/usr/bin/codesign", arguments) + } + try manager.removeItem(at: inputs) + var receipt = try MacSigningReceipt(plan: plan, bundle: stagedBundle) + receipt.signatures = try await verify(bundle: stagedBundle, receipt: receipt) + try checkSource(bundle, plan: plan) + try receipt.write(beside: stagedBundle) + try MacSigningReceipt.verify(bundle: stagedBundle) + try validateOutput(output, source: bundle) + try Task.checkCancellation() + try manager.moveItem(at: stage, to: output) + print("Signed app: \(output.appendingPathComponent(plan.bundleName).path)") + } + + @discardableResult + static func verify(bundle: URL, receipt: MacSigningReceipt, + verifyInventory: Bool = true) async throws -> [MacSigningReceipt.Signature] { + @Dependency(\.macHostClient) var client + guard client.isMacOS() else { throw StringError("Native signature verification requires macOS") } + if verifyInventory { try receipt.inventory.verify(directory: bundle) } + let order = try MacBundleAudit.validate(bundle: bundle, target: receipt.target) + guard order == receipt.targets.map(\.path) else { throw StringError("Signed code inventory mismatch") } + let scratch = FileManager.default.temporaryDirectory.appendingPathComponent("xtool-sign-check-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700]) + defer { try? FileManager.default.removeItem(at: scratch) } + var signatures: [MacSigningReceipt.Signature] = [] + for (index, target) in receipt.targets.enumerated() { + let code = codeURL(target.path, bundle: bundle) + _ = try await client.capture("/usr/bin/codesign", ["--verify", "--strict", "--verbose=2", code.path]) + let display = try await client.capture("/usr/bin/codesign", ["--display", "--verbose=4", code.path]) + let metadata = String(decoding: display.error + display.output, as: UTF8.self) + try validateMetadata(metadata, mode: receipt.mode) + var fingerprint: String? + if let identity = receipt.identity { + let prefix = scratch.appendingPathComponent("\(index)-certificate").path + _ = try await client.capture("/usr/bin/codesign", ["--display", "--extract-certificates", prefix, code.path]) + let leaf = try Data(contentsOf: URL(fileURLWithPath: prefix + "0")) + fingerprint = MacSigningPlan.certificateFingerprint(leaf) + guard fingerprint == identity.uppercased() else { + throw StringError("Signed certificate differs from requested fingerprint: \(target.path)") + } + } + let effective = try await client.capture("/usr/bin/codesign", ["--display", "--entitlements", "-", "--xml", code.path]) + try validateEffectiveEntitlements(effective.output, expected: target.entitlements, path: target.path) + let lines = metadata.components(separatedBy: .newlines) + func value(_ prefix: String) -> String? { + lines.first(where: { $0.hasPrefix(prefix) }).map { String($0.dropFirst(prefix.count)) } + } + signatures.append(.init(path: target.path, certificateSHA1: fingerprint, + teamIdentifier: receipt.mode == .adHoc ? nil : value("TeamIdentifier="), + authority: value("Authority="), + hardenedRuntime: lines.contains { $0.hasPrefix("CodeDirectory ") && $0.contains("runtime") }, + timestamp: value("Timestamp="))) + } + guard Set(signatures.compactMap(\.teamIdentifier)).count <= 1 else { + throw StringError("Nested code has inconsistent signing teams") + } + if !receipt.signatures.isEmpty, !sameSignatures(receipt.signatures, signatures) { + throw StringError("Actual signature metadata differs from signing receipt") + } + if verifyInventory { try receipt.inventory.verify(directory: bundle) } + return signatures + } + + // codesign renders Timestamp in the host locale/timezone. Exact artifact bytes bind the + // timestamp token; compare its presence here so receipts remain portable between Macs. + private static func sameSignatures(_ expected: [MacSigningReceipt.Signature], + _ actual: [MacSigningReceipt.Signature]) -> Bool { + guard expected.count == actual.count else { return false } + return zip(expected, actual).allSatisfy { left, right in + left.path == right.path && left.certificateSHA1 == right.certificateSHA1 + && left.teamIdentifier == right.teamIdentifier && left.authority == right.authority + && left.hardenedRuntime == right.hardenedRuntime && (left.timestamp == nil) == (right.timestamp == nil) + } + } + + private static func validateMetadata(_ text: String, mode: MacSigningMode) throws { + let lines = text.components(separatedBy: .newlines) + if mode == .adHoc { + guard lines.contains("Signature=adhoc") else { throw StringError("Expected an ad-hoc signature") } + return + } + let prefix = mode == .developerID ? "Authority=Developer ID Application:" : "Authority=Apple Development:" + guard lines.contains(where: { $0.hasPrefix(prefix) }), + lines.contains(where: { $0.hasPrefix("TeamIdentifier=") && $0 != "TeamIdentifier=not set" }) else { + throw StringError("Signing certificate category does not match requested \(mode.rawValue) mode") + } + if mode == .developerID { + guard lines.contains(where: { $0.hasPrefix("CodeDirectory ") && $0.contains("runtime") }), + lines.contains(where: { $0.hasPrefix("Timestamp=") && $0 != "Timestamp=none" }) else { + throw StringError("Developer ID signing requires hardened runtime and a secure timestamp") + } + } + } + + private static func validateEffectiveEntitlements(_ actual: Data, expected: Data?, path: String) throws { + func dictionary(_ data: Data?) throws -> NSDictionary { + guard let data, !data.isEmpty else { return NSDictionary() } + guard let value = try PropertyListSerialization.propertyList(from: data, format: nil) as? NSDictionary else { + throw StringError("Signed entitlements are not a dictionary: \(path)") + } + return value + } + guard try dictionary(actual).isEqual(dictionary(expected)) else { + throw StringError("Effective signed entitlements differ from the handoff: \(path)") + } + } + + private static func checkSource(_ bundle: URL, plan: MacSigningPlan) throws { + try MacArtifact.verify(bundle: bundle) + guard try ArtifactInventory.hash(MacArtifact.manifestURL(for: bundle)) == plan.inputArtifactSHA256 else { + throw StringError("Source handoff changed during signing") + } + try plan.inputInventory.verify(directory: bundle) + } + + private static func validateOutput(_ output: URL, source: URL) throws { + let resolved = output.deletingLastPathComponent().resolvingSymlinksInPath() + .appendingPathComponent(output.lastPathComponent).standardizedFileURL + let source = source.resolvingSymlinksInPath().standardizedFileURL + guard !FileManager.default.fileExists(atPath: output.path), + !resolved.path.hasPrefix(source.path + "/"), resolved.path != source.path else { + throw StringError("Signing output must be a new directory outside the input app") + } + var directory: ObjCBool = false + guard FileManager.default.fileExists(atPath: output.deletingLastPathComponent().path, isDirectory: &directory), + directory.boolValue else { throw StringError("Signing output parent directory must already exist") } + } + + private static func codeURL(_ path: String, bundle: URL) -> URL { + path.isEmpty ? bundle : bundle.appendingPathComponent(path) + } +} diff --git a/Sources/XUtils/Foundation+Utils.swift b/Sources/XUtils/Foundation+Utils.swift index c3863d35..e91f4347 100644 --- a/Sources/XUtils/Foundation+Utils.swift +++ b/Sources/XUtils/Foundation+Utils.swift @@ -21,6 +21,24 @@ extension Data { try self.init(contentsOf: file) #endif } + + /// Writes atomically unless the file already holds these bytes, so build systems + /// that compare timestamps see no change. Returns whether it wrote. + @discardableResult + package func writeIfChanged(to url: URL) throws -> Bool { + guard (try? Data(contentsOf: url)) != self else { return false } + try write(to: url, options: .atomic) + return true + } +} + +extension JSONEncoder { + /// Stable, reviewable output for the manifests, receipts and plans xtool writes. + package static func sortedPretty() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } } extension FileManager { diff --git a/Tests/XToolTests/BinaryLinkTests.swift b/Tests/XToolTests/BinaryLinkTests.swift new file mode 100644 index 00000000..eed9f312 --- /dev/null +++ b/Tests/XToolTests/BinaryLinkTests.swift @@ -0,0 +1,290 @@ +import Dependencies +import Foundation +import Testing +@testable import PackLib + +struct BinaryLinkTests { + private func temporaryRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private func product(_ name: String, kind: Plan.ProductType, target: ResolvedTarget) -> Plan.Product { + Plan.Product(target: target, source: .prebuilt(path: name), type: kind, product: name, + bundleID: "test.\(name)", infoPlist: [:], resources: []) + } + + private func binary(cpu: UInt32 = 0x0100000c, platform: UInt32 = 1, + minimum: UInt32 = 0x0f0000, type: UInt32 = 2) -> Data { + let words: [UInt32] = [0xfeedfacf, cpu, 0, type, 1, 24, 0, 0, 0x32, 24, platform, minimum, 0x1a0200, 0] + return Data(words.flatMap { value in (0..<4).map { UInt8(truncatingIfNeeded: value >> ($0 * 8)) } }) + } + + private func write(_ data: Data, to url: URL) throws { + try data.write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + @Test(arguments: [Plan.ProductType.application, .appExtension, .xpcService, .helperExecutable]) + func rpathsResolveAgainstFrozenLayout(kind: Plan.ProductType) throws { + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let product = product("Item", kind: kind, target: target) + let root = URL(fileURLWithPath: "/Relocated/App.app") + let executable = root.appendingPathComponent(product.layout.executablePath).deletingLastPathComponent() + let paths = product.runtimeSearchPaths.map { + executable.appendingPathComponent(String($0.dropFirst("@executable_path/".count))).standardizedFileURL.path + } + #expect(paths.first == root.appendingPathComponent("Contents/Frameworks").path) + #expect(paths.contains(root.appendingPathComponent(product.layout.frameworksPath).path)) + #expect(Set(paths).count == paths.count) + } + + @Test func helperOnlyDynamicDependencyResolvesFromEmittedRpath() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + try write(binary(), to: root.appendingPathComponent("App")) + try write(binary(), to: root.appendingPathComponent("Helper")) + let installName = "@rpath/libHelperRuntime.dylib" + var library = binary(type: 6) + let string = Array(installName.utf8) + [0] + let size = (24 + string.count + 7) / 8 * 8 + let command: [UInt32] = [0xd, UInt32(size), 24, 0, 0, 0] + library[16] = 2 + library[20] = UInt8(24 + size) + library += Data(command.flatMap { value in (0..<4).map { UInt8(truncatingIfNeeded: value >> ($0 * 8)) } }) + library += Data(string + Array(repeating: 0, count: size - 24 - string.count)) + try library.write(to: root.appendingPathComponent("libHelperRuntime.dylib")) + let helper = Plan.Product(target: target, source: .executable(product: "Helper"), + type: .helperExecutable, product: "Helper", bundleID: "test.Helper", + infoPlist: [:], resources: [.library(name: "HelperRuntime")]) + let plan = try Plan(target: target, app: product("App", kind: .application, target: target), + nestedProducts: [helper]) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, packagePath: root.path) + let artifacts = try await withDependencies { + $0.productBuildRunner = .init(build: { _, _ in }, binaryPath: { _, _ in root.path }, + sdkLinkerArguments: { _ in [] }) + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + let toolsetURL = root.appendingPathComponent("xtool/.xtool-tmp/toolsets/\(helper.targetName).json") + let toolset = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: toolsetURL)) as? [String: Any]) + let linker = try #require(toolset["linker"] as? [String: Any]) + let arguments = try #require(linker["extraCLIOptions"] as? [String]) + let rpaths = arguments.indices.filter { arguments[$0] == "-rpath" }.map { arguments[$0 + 1] } + // Compiler options would apply to every package; deployment reaches only the link. + #expect(Set(toolset.keys) == ["schemaVersion", "linker"]) + #expect(rpaths == ["@executable_path/../Frameworks", "@executable_path/Frameworks"]) + + let output = root.appendingPathComponent("Relocated/App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + let dependency = output.appendingPathComponent("Contents/Helpers/Frameworks/libHelperRuntime.dylib") + #expect(try Data(contentsOf: dependency) == library) + let outerDependency = output.appendingPathComponent("Contents/Frameworks/libHelperRuntime.dylib") + #expect(!FileManager.default.fileExists(atPath: outerDependency.path)) + let executableDirectory = output.appendingPathComponent(helper.layout.executablePath).deletingLastPathComponent() + let relativeLibrary = String(installName.dropFirst("@rpath/".count)) + let candidates = rpaths.map { + executableDirectory.appendingPathComponent(String($0.dropFirst("@executable_path/".count))) + .appendingPathComponent(relativeLibrary).standardizedFileURL + } + #expect(candidates.filter { FileManager.default.fileExists(atPath: $0.path) } == [dependency]) + } + + @Test func prebuiltOnlyBuildPreservesPathsWithoutCompiler() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + try write(binary(), to: root.appendingPathComponent("App")) + try write(binary(), to: root.appendingPathComponent("Helper")) + let plan = try Plan(target: target, app: product("App", kind: .application, target: target), + nestedProducts: [product("Helper", kind: .helperExecutable, target: target)]) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, packagePath: root.path) + let artifacts = try await withDependencies { + $0.productBuildRunner = .init(build: { _, _ in Issue.record("Unexpected build") }, + binaryPath: { _, _ in Issue.record("Unexpected output query"); return "" }, + sdkLinkerArguments: { _ in Issue.record("Unexpected SDK query"); return [] }) + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + #expect(artifacts.executables["App"] == root.appendingPathComponent("App")) + #expect(artifacts.executables["Helper"] == root.appendingPathComponent("Helper")) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("xtool").path)) + } + + @Test func incompatiblePrebuiltDiagnostics() throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let url = root.appendingPathComponent("Input") + let cases: [(Data, String)] = [ + (binary(cpu: 0x01000007), "Wrong architecture"), + (binary(platform: 2), "platform"), + (binary(minimum: 0x100000), "minimum OS"), + (binary(type: 6), "file type"), + (Data("#!/bin/sh\n".utf8), "Mach-O"), + (Data([0xcf, 0xfa, 0xed, 0xfe]), "Truncated"), + ] + for (data, diagnostic) in cases { + try write(data, to: url) + do { + try MachOBinary.validate(url, target: target, executable: true) + Issue.record("Accepted invalid binary: \(diagnostic)") + } catch { + #expect(String(describing: error).contains(diagnostic)) + #expect(String(describing: error).contains(url.path)) + } + } + } + + @Test func universalBinarySelectsMatchingSliceAndRejectsTruncation() throws { + let slices = [binary(cpu: 0x01000007), binary()] + var data = Data() + let header: [UInt32] = [0xcafebabe, 2, 0x01000007, 0, 48, 56, 0, 0x0100000c, 0, 104, 56, 0] + for word in header { data += Data((0..<4).map { UInt8(truncatingIfNeeded: word >> ((3 - $0) * 8)) }) } + data += slices[0] + slices[1] + #expect(try MachOBinary.inspect(data).count == 2) + #expect(throws: (any Error).self) { try MachOBinary.inspect(data.dropLast()) } + } + + @Test func universalStaticArchiveIsStatic() throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + func archive(_ object: Data) -> Data { + let size = String(object.count).padding(toLength: 10, withPad: " ", startingAt: 0) + return Data("!\nfile.o/ 0 0 0 100644 \(size)`\n".utf8) + object + } + let slices = [archive(binary(cpu: 0x01000007, type: 1)), archive(binary(type: 1))] + let header: [UInt32] = [0xcafebabe, 2, 0x01000007, 0, 48, UInt32(slices[0].count), 0, + 0x0100000c, 0, UInt32(48 + slices[0].count), UInt32(slices[1].count), 0] + var data = Data() + for word in header { data += Data((0..<4).map { UInt8(truncatingIfNeeded: word >> ((3 - $0) * 8)) }) } + let url = root.appendingPathComponent("Fat") + try (data + slices[0] + slices[1]).write(to: url) + for architecture in ["arm64", "x86_64"] { + let target = try ResolvedTarget.resolve(cliTarget: "\(architecture)-apple-macosx15.0") + #expect(try MachOBinary.validate(url, target: target, executable: false)) + #expect(throws: (any Error).self) { try MachOBinary.validate(url, target: target, executable: true) } + } + } + + @Test func staticBinaryTargetsRequireRealArchive() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + try write(binary(), to: root.appendingPathComponent("App")) + var app = product("App", kind: .application, target: target) + app.resources = [.binaryTarget(name: "Static")] + let plan = try Plan(target: target, app: app) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, packagePath: root.path) + await #expect(throws: (any Error).self) { try await ProductBuilder(buildSettings: settings, plan: plan).build() } + let object = binary(type: 1) + let size = String(object.count).padding(toLength: 10, withPad: " ", startingAt: 0) + let header = "file.o/ 0 0 0 100644 \(size)`\n" + let archive = Data("!\n\(header)".utf8) + object + try archive.write(to: root.appendingPathComponent("libStatic.a")) + let artifacts = try await ProductBuilder(buildSettings: settings, plan: plan).build() + #expect(artifacts.staticBinaryTargets == ["Static"]) + try Data("!\n".utf8).write(to: root.appendingPathComponent("libStatic.a")) + await #expect(throws: (any Error).self) { try await ProductBuilder(buildSettings: settings, plan: plan).build() } + } + + @Test func staticBinaryTargetArchiveUsesXCFrameworkLibraryPath() async throws { + let scratch = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: scratch) } + let root = scratch.appendingPathComponent("arm64-apple-macosx/release") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let xcframework = scratch.appendingPathComponent("artifacts/package/Static/Static.xcframework") + try FileManager.default.createDirectory(at: xcframework, withIntermediateDirectories: true) + let libraries: [[String: Any]] = [ + ["LibraryPath": "libstatic-ios.a", "SupportedPlatform": "ios", "SupportedArchitectures": ["arm64"]], + ["LibraryPath": "libstatic-fat.a", "SupportedPlatform": "macos", "SupportedArchitectures": ["arm64", "x86_64"]], + ] + try PropertyListSerialization.data(fromPropertyList: ["AvailableLibraries": libraries], format: .xml, options: 0) + .write(to: xcframework.appendingPathComponent("Info.plist")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + try write(binary(), to: root.appendingPathComponent("App")) + var app = product("App", kind: .application, target: target) + app.resources = [.binaryTarget(name: "Static")] + let plan = try Plan(target: target, app: app) + let object = binary(type: 1) + let size = String(object.count).padding(toLength: 10, withPad: " ", startingAt: 0) + let header = "file.o/ 0 0 0 100644 \(size)`\n" + try (Data("!\n\(header)".utf8) + object).write(to: root.appendingPathComponent("libstatic-fat.a")) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, packagePath: root.path) + let artifacts = try await ProductBuilder(buildSettings: settings, plan: plan).build() + #expect(artifacts.staticBinaryTargets == ["Static"]) + let output = scratch.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + #expect(!FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Frameworks/Static.framework").path)) + } + + @Test func staticBinaryTargetArchiveUsesLocalXCFrameworkLibraryPath() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + // SwiftPM leaves local XCFrameworks in place; only the slice's library reaches the bin directory. + let xcframework = root.appendingPathComponent("Vendor/Static.xcframework") + try FileManager.default.createDirectory(at: xcframework, withIntermediateDirectories: true) + let libraries: [[String: Any]] = [ + ["LibraryPath": "libvendor.a", "SupportedPlatform": "macos", "SupportedArchitectures": ["arm64"]], + ] + try PropertyListSerialization.data(fromPropertyList: ["AvailableLibraries": libraries], format: .xml, options: 0) + .write(to: xcframework.appendingPathComponent("Info.plist")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + try write(binary(), to: root.appendingPathComponent("App")) + var app = product("App", kind: .application, target: target) + app.resources = [.binaryTarget(name: "Static", xcframework: xcframework.path)] + let plan = try Plan(target: target, app: app) + let object = binary(type: 1) + let size = String(object.count).padding(toLength: 10, withPad: " ", startingAt: 0) + let header = "file.o/ 0 0 0 100644 \(size)`\n" + try (Data("!\n\(header)".utf8) + object).write(to: root.appendingPathComponent("libvendor.a")) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, packagePath: root.path) + let artifacts = try await ProductBuilder(buildSettings: settings, plan: plan).build() + #expect(artifacts.staticBinaryTargets == ["Static"]) + try BundleAssembler(plan: plan, sourceDirectory: root) + .assemble(artifacts: artifacts, at: root.appendingPathComponent("App.app")) + } + + @Test func dynamicInstallNameMustResolveInsideEmbeddedFramework() throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let framework = root.appendingPathComponent("Runtime.framework") + try FileManager.default.createDirectory(at: framework, withIntermediateDirectories: true) + let url = framework.appendingPathComponent("Runtime") + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + for name in ["@rpath/Runtime.framework/Runtime", "/tmp/Runtime", "@rpath/Other.framework/Runtime", "@rpath/../Runtime"] { + var data = binary(type: 6) + let string = Array(name.utf8) + [0] + let size = (24 + string.count + 7) / 8 * 8 + let command: [UInt32] = [0xd, UInt32(size), 24, 0, 0, 0] + data[16] = 2 + data[20] = UInt8(24 + size) + data += Data(command.flatMap { value in (0..<4).map { UInt8(truncatingIfNeeded: value >> ($0 * 8)) } }) + data += Data(string + Array(repeating: 0, count: size - 24 - string.count)) + try data.write(to: url) + if name == "@rpath/Runtime.framework/Runtime" { + #expect(try !MachOBinary.validate(url, target: target, executable: false)) + } else { + #expect(throws: (any Error).self) { try MachOBinary.validate(url, target: target, executable: false) } + } + } + } + + @Test func sdkVersionUsesSDKMetadataAndEffectiveDeployment() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let bundle = root.appendingPathComponent("darwin.artifactbundle") + let sdk = bundle.appendingPathComponent("SDK") + try FileManager.default.createDirectory(at: sdk, withIntermediateDirectories: true) + try Data(#"{"targetTriples":{"arm64-apple-macosx":{"sdkRootPath":"SDK"}}}"#.utf8) + .write(to: bundle.appendingPathComponent("swift-sdk.json")) + try Data(#"{"Version":"26.2"}"#.utf8).write(to: sdk.appendingPathComponent("SDKSettings.json")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx", packageDeploymentTargets: ["macos": "16.0"]) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, + options: ["--swift-sdks-path", root.path]).withTarget(target) + #expect(try await settings.macOSLinkerArguments() == ["-Xlinker", "-platform_version", "-Xlinker", "macos", + "-Xlinker", "16.0", "-Xlinker", "26.2"]) + } +} diff --git a/Tests/XToolTests/BundleAssemblerTests.swift b/Tests/XToolTests/BundleAssemblerTests.swift new file mode 100644 index 00000000..3c0c09d9 --- /dev/null +++ b/Tests/XToolTests/BundleAssemblerTests.swift @@ -0,0 +1,521 @@ +import Dependencies +import Foundation +import Testing +@testable import PackLib + +struct BundleAssemblerTests { + private func root() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func write(_ text: String = "fixture", at url: URL, executable: Bool = false) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(text.utf8).write(to: url) + if executable { try FileManager.default.setAttributes([.posixPermissions: 0o751], ofItemAtPath: url.path) } + } + + private func product( + _ name: String = "App", kind: Plan.ProductType = .application, resources: [Plan.Resource] = [] + ) throws -> Plan.Product { + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + return Plan.Product(target: target, type: kind, product: name, bundleID: "test.\(name)", + infoPlist: ["CFBundleExecutable": name, "CFBundleIdentifier": "test.\(name)", + "CallerKey": "preserved"], resources: resources) + } + + private func artifacts(_ plan: Plan, in root: URL) throws -> BuildArtifacts { + let directory = root.appendingPathComponent("build") + var executables: [String: URL] = [:] + for product in plan.allProducts { + let executable = directory.appendingPathComponent(product.targetName) + try write(at: executable, executable: true) + executables[product.product] = executable + } + return BuildArtifacts(directory: directory, executables: executables) + } + + @Test func nestedProductsAndMetadataPreserveInputs() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let agent = root.appendingPathComponent("agent.plist") + try write("caller launch agent metadata", at: agent) + let destination = ResourceDestination(location: .contents, path: "Library/LaunchAgents/test.plist") + let app = try product(resources: [.copy(.init(source: "agent.plist", destination: destination))]) + let ext = try product("Preview", kind: .appExtension) + let xpc = try product("Service", kind: .xpcService) + let helper = try product("Helper", kind: .helperExecutable, + resources: [.copy(.init(source: "agent.plist", destination: .init(path: "Helper.plist")))]) + let plan = try Plan(target: app.target, app: app, extensions: [ext], nestedProducts: [xpc, helper]) + let artifacts = try artifacts(plan, in: root) + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + for product in plan.allProducts { + let executable = output.appendingPathComponent(product.layout.executablePath) + #expect(FileManager.default.isExecutableFile(atPath: executable.path)) + let permissions = try FileManager.default.attributesOfItem(atPath: executable.path)[.posixPermissions] as? NSNumber + #expect(permissions?.intValue == 0o751) + if let path = product.layout.infoPlistPath { + let data = try Data(contentsOf: output.appendingPathComponent(path)) + let info = try #require(PropertyListSerialization.propertyList(from: data, format: nil) as? [String: String]) + #expect(info["CallerKey"] == "preserved") + #expect(info["CFBundleExecutable"] == product.product) + #expect(info["LSRequiresIPhoneOS"] == nil) + } + } + let extPath = "Contents/PlugIns/Preview.appex/Contents/MacOS/Preview" + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent(extPath).path)) + let xpcPath = "Contents/XPCServices/Service.xpc/Contents/MacOS/Service" + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent(xpcPath).path)) + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Helpers/Helper").path)) + let agentCopy = output.appendingPathComponent("Contents/Library/LaunchAgents/test.plist") + #expect(try Data(contentsOf: agentCopy) == Data(contentsOf: agent)) + #expect(try Data(contentsOf: output.appendingPathComponent("Contents/Resources/Helper.plist")) == Data(contentsOf: agent)) + #expect(!FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Helpers/Info.plist").path)) + } + + @Test func macOSResourceBundlesNeverCreateBundleRootAliases() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let resources: [Plan.Resource] = [.bundle(package: "Package", target: "Resources")] + let app = try product(resources: resources) + let ext = try product("Preview", kind: .appExtension, resources: resources) + let xpc = try product("Service", kind: .xpcService, resources: resources) + let plan = try Plan(target: app.target, app: app, extensions: [ext], nestedProducts: [xpc]) + let artifacts = try artifacts(plan, in: root) + try write(at: artifacts.directory.appendingPathComponent("Package_Resources.bundle/message.txt")) + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + for product in plan.allProducts { + let bundle = output.appendingPathComponent(product.layout.bundlePath) + #expect(try FileManager.default.contentsOfDirectory(atPath: bundle.path) == ["Contents"]) + let resource = output.appendingPathComponent(product.layout.resourcesPath + "/Package_Resources.bundle/message.txt") + #expect(try String(contentsOf: resource, encoding: .utf8) == "fixture") + } + } + + private func framework(_ name: String, in directory: URL, binary: String = "dynamic fixture") throws -> URL { + let framework = directory.appendingPathComponent("\(name).framework") + try write(binary, at: framework.appendingPathComponent("Versions/A/\(name)"), executable: true) + try write("framework metadata", at: framework.appendingPathComponent("Versions/A/Resources/Info.plist")) + for (path, target) in [("Versions/Current", "A"), (name, "Versions/Current/\(name)"), + ("Resources", "Versions/Current/Resources")] { + try FileManager.default.createSymbolicLink(atPath: framework.appendingPathComponent(path).path, + withDestinationPath: target) + } + return framework + } + + @Test func internalSymlinksAreAcceptedUnderASymlinkedPackagePath() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let real = root.appendingPathComponent("real", isDirectory: true) + try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true) + let linked = root.appendingPathComponent("linked", isDirectory: true) + try FileManager.default.createSymbolicLink(at: linked, withDestinationURL: real) + let app = try product(resources: [.binaryTarget(name: "Dynamic")]) + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: linked) + _ = try framework("Dynamic", in: artifacts.directory) + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: linked).assemble(artifacts: artifacts, at: output) + let binary = output.appendingPathComponent("Contents/Frameworks/Dynamic.framework/Dynamic") + #expect(FileManager.default.fileExists(atPath: binary.path)) + } + + @Test func frameworksStayIntactAndArchivesAreExcluded() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let app = try product(resources: [.binaryTarget(name: "Dynamic"), .binaryTarget(name: "Static"), + .binaryTarget(name: "Thin"), .library(name: "Shared")]) + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: root) + _ = try framework("Dynamic", in: artifacts.directory) + _ = try framework("Static", in: artifacts.directory, binary: "!\narchive fixture") + _ = try framework("Thin", in: artifacts.directory, binary: "!\narchive fixture") + try write("dynamic library", at: artifacts.directory.appendingPathComponent("libShared.dylib")) + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + let copied = output.appendingPathComponent("Contents/Frameworks/Dynamic.framework") + let current = copied.appendingPathComponent("Versions/Current") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: current.path) == "A") + let binary = copied.appendingPathComponent("Dynamic") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: binary.path) == "Versions/Current/Dynamic") + #expect(try String(contentsOf: copied.appendingPathComponent("Resources/Info.plist"), encoding: .utf8) == "framework metadata") + #expect(FileManager.default.isExecutableFile(atPath: copied.appendingPathComponent("Dynamic").path)) + #expect(!FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Frameworks/Static.framework").path)) + #expect(!FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Frameworks/Thin.framework").path)) + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Frameworks/libShared.dylib").path)) + } + + @Test func helpersShareGeneratedDependenciesAfterRelocation() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let resources: [Plan.Resource] = [.binaryTarget(name: "Dynamic"), .library(name: "Shared"), + .bundle(package: "Shared", target: "Resources")] + let app = try product() + let helpers = try ["FirstHelper", "SecondHelper"].map { + try product($0, kind: .helperExecutable, resources: resources) + } + let plan = try Plan(target: app.target, app: app, nestedProducts: helpers) + let artifacts = try artifacts(plan, in: root) + _ = try framework("Dynamic", in: artifacts.directory) + try write("dynamic library", at: artifacts.directory.appendingPathComponent("libShared.dylib")) + try write("shared resource", at: artifacts.directory.appendingPathComponent("Shared_Resources.bundle/message.txt")) + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + + let relocated = root.appendingPathComponent("Relocated.app") + try FileManager.default.moveItem(at: output, to: relocated) + try FileManager.default.removeItem(at: artifacts.directory) + for helper in helpers { + #expect(FileManager.default.isExecutableFile(atPath: relocated.appendingPathComponent(helper.layout.executablePath).path)) + } + let framework = relocated.appendingPathComponent("Contents/Helpers/Frameworks/Dynamic.framework") + #expect(try String(contentsOf: framework.appendingPathComponent("Dynamic"), encoding: .utf8) == "dynamic fixture") + let current = framework.appendingPathComponent("Versions/Current") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: current.path) == "A") + let library = relocated.appendingPathComponent("Contents/Helpers/Frameworks/libShared.dylib") + #expect(try String(contentsOf: library, encoding: .utf8) == "dynamic library") + let alias = relocated.appendingPathComponent("Contents/Helpers/Shared_Resources.bundle") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: alias.path) == "../Resources/Shared_Resources.bundle") + #expect(try String(contentsOf: alias.appendingPathComponent("message.txt"), encoding: .utf8) == "shared resource") + #expect(!FileManager.default.fileExists(atPath: relocated.appendingPathComponent("Contents/Helpers/Resources").path)) + } + + @Test(arguments: [Plan.Resource.bundle(package: "Shared", target: "Resources"), + .binaryTarget(name: "Dynamic"), .library(name: "Shared")]) + func duplicateGeneratedDependenciesWithinHelperReject(resource: Plan.Resource) throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let app = try product() + let helper = try product("Helper", kind: .helperExecutable, resources: [resource, resource]) + let plan = try Plan(target: app.target, app: app, nestedProducts: [helper]) + let artifacts = try artifacts(plan, in: root) + _ = try framework("Dynamic", in: artifacts.directory) + try write(at: artifacts.directory.appendingPathComponent("libShared.dylib")) + try write(at: artifacts.directory.appendingPathComponent("Shared_Resources.bundle/message.txt")) + let output = root.appendingPathComponent("App.app") + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + } + #expect(!FileManager.default.fileExists(atPath: output.path)) + } + + @Test(arguments: ["build/Shared_Resources.bundle", "alternate/Shared_Resources.bundle"], + ["Shared_Resources.bundle", "shared_resources.bundle", "Shared_Resources.bundle/child"]) + func explicitHelperCopiesCollidingWithGeneratedDependenciesReject(source: String, path: String) throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let app = try product() + let first = try product("FirstHelper", kind: .helperExecutable, + resources: [.bundle(package: "Shared", target: "Resources")]) + let second = try product("SecondHelper", kind: .helperExecutable, + resources: [.copy(.init(source: source, destination: .init(path: path)))]) + let plan = try Plan(target: app.target, app: app, nestedProducts: [first, second]) + let artifacts = try artifacts(plan, in: root) + try write(at: artifacts.directory.appendingPathComponent("Shared_Resources.bundle/message.txt")) + try write(at: root.appendingPathComponent("alternate/Shared_Resources.bundle/message.txt")) + let output = root.appendingPathComponent("App.app") + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + } + #expect(!FileManager.default.fileExists(atPath: output.path)) + } + + @Test func validatedLooseStaticTargetIsNotEmbeddedAndMissingArchiveFails() throws { + let packageRoot = try root() + defer { try? FileManager.default.removeItem(at: packageRoot) } + let app = try product(resources: [.binaryTarget(name: "StaticProbe")]) + let plan = try Plan(target: app.target, app: app) + let built = try artifacts(plan, in: packageRoot) + try write("!\nstatic archive fixture", at: built.directory.appendingPathComponent("libStaticProbe.a")) + let marked = BuildArtifacts(directory: built.directory, executables: built.executables, + staticBinaryTargets: ["StaticProbe"]) + let output = packageRoot.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: packageRoot).assemble(artifacts: marked, at: output) + let embeddedStaticFramework = output.appendingPathComponent("Contents/Frameworks/StaticProbe.framework") + #expect(!FileManager.default.fileExists(atPath: embeddedStaticFramework.path)) + + let missingRoot = try self.root() + defer { try? FileManager.default.removeItem(at: missingRoot) } + let missingBuilt = try artifacts(plan, in: missingRoot) + let missing = BuildArtifacts(directory: missingBuilt.directory, executables: missingBuilt.executables, + staticBinaryTargets: ["StaticProbe"]) + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: missingRoot) + .assemble(artifacts: missing, at: missingRoot.appendingPathComponent("Missing.app")) + } + } + + @Test func preparedAssetsAndIconAreCopied() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + try write("prepared icon", at: root.appendingPathComponent("Icon.icns")) + try write("prepared assets", at: root.appendingPathComponent("Assets.car")) + var app = try product(resources: [.copy(.init(source: "Assets.car", destination: .init(path: "Assets.car")))]) + app.iconPath = "Icon.icns" + app.infoPlist["CFBundleIconFile"] = "CallerIconName" + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: root) + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + let iconCopy = output.appendingPathComponent("Contents/Resources/Icon.icns") + #expect(try String(contentsOf: iconCopy, encoding: .utf8) == "prepared icon") + let assetsCopy = output.appendingPathComponent("Contents/Resources/Assets.car") + #expect(try String(contentsOf: assetsCopy, encoding: .utf8) == "prepared assets") + let info = try #require(PropertyListSerialization.propertyList( + from: Data(contentsOf: output.appendingPathComponent("Contents/Info.plist")), format: nil) as? [String: String]) + #expect(info["CFBundleIconFile"] == "CallerIconName") + } + + @Test(arguments: ["Assets.xcassets", "Icon.iconset", "Window.storyboard", "Window.xib"]) + func rawAssetsRejectWithActionableMessage(name: String) throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + try write(at: root.appendingPathComponent(name)) + let app = try product(resources: [.root(source: name)]) + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: root) + do { + try BundleAssembler(plan: plan, sourceDirectory: root) + .assemble(artifacts: artifacts, at: root.appendingPathComponent("App.app")) + Issue.record("Expected raw asset rejection") + } catch { + #expect(String(describing: error).contains("supply prepared Assets.car, .icns")) + } + } + + @Test(arguments: ["../escape", "/absolute", "Contents/../escape", "a//b", "a\\b"]) + func traversalRejectsBeforeWriting(path: String) throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let app = try product(resources: [.copy(.init(source: "input", destination: .init(path: path)))]) + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: root) + let output = root.appendingPathComponent("App.app") + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + } + #expect(!FileManager.default.fileExists(atPath: output.path)) + } + + @Test(arguments: ["same", "SAME", "same/child"]) + func duplicateAndAncestorDestinationsReject(path: String) throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + try write(at: root.appendingPathComponent("input")) + let app = try product(resources: ["same", path].map { + .copy(.init(source: "input", destination: .init(path: $0))) + }) + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: root) + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: root) + .assemble(artifacts: artifacts, at: root.appendingPathComponent("App.app")) + } + } + + @Test func resourcesCannotReplaceBundleStructure() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + try write(at: root.appendingPathComponent("input")) + let app = try product(resources: [.copy(.init(source: "input", destination: .init(location: .bundle, path: "Contents")))]) + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: root) + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: root) + .assemble(artifacts: artifacts, at: root.appendingPathComponent("App.app")) + } + } + + @Test(arguments: [Plan.Resource.root(source: "absent"), .bundle(package: "Missing", target: "Resources"), + .binaryTarget(name: "Missing"), .library(name: "Missing")]) + func missingInputsReject(resource: Plan.Resource) throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let app = try product(resources: [resource]) + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: root) + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: root) + .assemble(artifacts: artifacts, at: root.appendingPathComponent("App.app")) + } + } + + @Test(arguments: ["Missing", "../../../outside", "/tmp/outside"]) + func brokenAndEscapingFrameworkSymlinksReject(target: String) throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let app = try product(resources: [.binaryTarget(name: "Dynamic")]) + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: root) + let framework = try framework("Dynamic", in: artifacts.directory) + try FileManager.default.createSymbolicLink(atPath: framework.appendingPathComponent("BadLink").path, + withDestinationPath: target) + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: root) + .assemble(artifacts: artifacts, at: root.appendingPathComponent("App.app")) + } + } + + @Test func missingExecutableAndUnsafeProductNamesReject() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + for name in ["App", "../escape"] { + let app = try product(name) + let plan = try Plan(target: app.target, app: app) + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: root) + .assemble(artifacts: .init(directory: root, executables: [:]), at: root.appendingPathComponent("App.app")) + } + } + } + + @Test func macOSLegacyResourcesLandInsideTheResourceSeal() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + try write(at: root.appendingPathComponent("legacy.txt")) + let app = try product(resources: [.root(source: "legacy.txt")]) + let ext = try product("Preview", kind: .appExtension, resources: [.root(source: "legacy.txt")]) + let plan = try Plan(target: app.target, app: app, extensions: [ext]) + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: try artifacts(plan, in: root), at: output) + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Resources/legacy.txt").path)) + #expect(FileManager.default.fileExists( + atPath: output.appendingPathComponent("Contents/PlugIns/Preview.appex/Contents/Resources/legacy.txt").path)) + let rootEntries = try FileManager.default.contentsOfDirectory(atPath: output.path) + #expect(rootEntries == ["Contents"]) + } + + @Test(arguments: ["arm64-apple-ios17.0", "arm64-apple-macosx15.0"]) + func swiftPMBundlesKeepRawAssets(triple: String) throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: triple) + let app = Plan.Product(target: target, type: .application, product: "App", bundleID: "test.App", infoPlist: [:], + resources: [.bundle(package: "Package", target: "Target")]) + let plan = try Plan(target: target, app: app) + let artifacts = try artifacts(plan, in: root) + // SwiftPM's native build copies asset catalogs and XIBs into resource bundles uncompiled. + let bundle = artifacts.directory.appendingPathComponent("Package_Target.bundle") + try write("{}", at: bundle.appendingPathComponent("Media.xcassets/Contents.json")) + try write(at: bundle.appendingPathComponent("View.xib")) + let output = root.appendingPathComponent("App.app") + let assemble = { try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) } + try assemble() + let resources = target.platform == .macOS ? "Contents/Resources/" : "" + #expect(FileManager.default.fileExists( + atPath: output.appendingPathComponent("\(resources)Package_Target.bundle/View.xib").path)) + } + + @Test func iosLayoutAndLegacyRootResourcesRemainFlat() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-ios17.0") + let app = Plan.Product(target: target, type: .application, product: "App", bundleID: "test.App", infoPlist: [:], + resources: [.root(source: "legacy.txt"), .bundle(package: "Package", target: "Target")]) + let plan = try Plan(target: target, app: app) + let artifacts = try artifacts(plan, in: root) + try write(at: root.appendingPathComponent("legacy.txt")) + try write(at: artifacts.directory.appendingPathComponent("Package_Target.bundle/resource.txt")) + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("App").path)) + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("legacy.txt").path)) + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("Package_Target.bundle/resource.txt").path)) + let info = try #require(PropertyListSerialization.propertyList( + from: Data(contentsOf: output.appendingPathComponent("Info.plist")), format: nil) as? [String: Any]) + #expect(info["LSRequiresIPhoneOS"] as? Bool == true) + #expect(info["CFBundleSupportedPlatforms"] as? [String] == ["iPhoneOS"]) + } + + #if os(macOS) + @Test(arguments: [BuildConfiguration.debug, .release]) + func generatedSwiftPMAccessorFindsRelocatedResources(configuration: BuildConfiguration) async throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let package = root.appendingPathComponent("package") + try write(""" + // swift-tools-version: 6.0 + import PackageDescription + let package = Package(name: "RelocationFixture", platforms: [.macOS("15.0")], + products: [.library(name: "ResourceLibrary", type: .dynamic, targets: ["Resources"])], + targets: [.target(name: "Resources", resources: [.copy("message.txt")])]) + """, at: package.appendingPathComponent("Dependency/Package.swift")) + let manifest = """ + // swift-tools-version: 6.0 + import PackageDescription + let dependency: Target.Dependency = .product(name: "ResourceLibrary", package: "Dependency") + let package = Package(name: "Readers", platforms: [.macOS("15.0")], dependencies: [.package(path: "Dependency")], + targets: [.executableTarget(name: "Reader", dependencies: [dependency]), + .executableTarget(name: "Service", dependencies: [dependency]), + .executableTarget(name: "Helper", dependencies: [dependency])]) + """ + try write(manifest, at: package.appendingPathComponent("Package.swift")) + try write("relocated resource", at: package.appendingPathComponent("Dependency/Sources/Resources/message.txt")) + try write(""" + import Foundation + public func readResource() throws -> String { + let file = Bundle.module.url(forResource: "message", withExtension: "txt")! + return try String(contentsOf: file, encoding: .utf8) + } + """, at: package.appendingPathComponent("Dependency/Sources/Resources/Resources.swift")) + for name in ["Reader", "Service", "Helper"] { + try write("import Resources\nprint(try readResource())", at: package.appendingPathComponent("Sources/\(name)/main.swift")) + } + let resources: [Plan.Resource] = [.bundle(package: "RelocationFixture", target: "Resources"), + .library(name: "ResourceLibrary")] + var app = try product(resources: resources) + app.source = .executable(product: "Reader") + var service = try product("Service", kind: .xpcService, resources: resources) + service.source = .executable(product: "Service") + var helper = try product("Helper", kind: .helperExecutable, resources: resources) + helper.source = .executable(product: "Helper") + let plan = try Plan(target: app.target, app: app, nestedProducts: [service, helper]) + let settings = try await BuildSettings(configuration: configuration, triple: app.target.triple, buildSystem: .swiftPM, + packagePath: package.path) + let artifacts = try await withDependencies { + $0.productBuildRunner = .liveValue + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: package).assemble(artifacts: artifacts, at: output) + let relocated = root.appendingPathComponent("elsewhere/App.app") + try FileManager.default.createDirectory(at: relocated.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.moveItem(at: output, to: relocated) + try FileManager.default.removeItem(at: package) + #expect(try FileManager.default.contentsOfDirectory(atPath: relocated.path) == ["Contents"]) + let alias = relocated.appendingPathComponent("Contents/Helpers/RelocationFixture_Resources.bundle") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: alias.path) + == "../Resources/RelocationFixture_Resources.bundle") + #expect(!FileManager.default.fileExists(atPath: relocated.appendingPathComponent("Contents/Helpers/Resources").path)) + for product in [helper, service, app] { + // Disposable local fixture only. Sign inside out, then verify the complete resource seal. + let library = relocated.appendingPathComponent(product.layout.frameworksPath + "/libResourceLibrary.dylib") + _ = try run(URL(fileURLWithPath: "/usr/bin/codesign"), arguments: ["--force", "--sign", "-", library.path]) + let signedPath = relocated.appendingPathComponent(product.layout.bundlePath).path + _ = try run(URL(fileURLWithPath: "/usr/bin/codesign"), arguments: ["--force", "--sign", "-", signedPath]) + } + _ = try run(URL(fileURLWithPath: "/usr/bin/codesign"), arguments: ["--verify", "--deep", "--strict", relocated.path]) + for product in plan.allProducts { + let executable = relocated.appendingPathComponent(product.layout.executablePath) + #expect(try run(executable) == "relocated resource\n") + } + } + + private func run(_ executable: URL, arguments: [String] = []) throws -> String { + let process = Process() + process.executableURL = executable + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + try process.run() + process.waitUntilExit() + try #require(process.terminationStatus == 0) + return try #require(String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)) + } + #endif +} diff --git a/Tests/XToolTests/DevCommandTests.swift b/Tests/XToolTests/DevCommandTests.swift new file mode 100644 index 00000000..069721a3 --- /dev/null +++ b/Tests/XToolTests/DevCommandTests.swift @@ -0,0 +1,459 @@ +import ArgumentParser +import Dependencies +import Foundation +import Testing +import XKit +import XUtils +@testable import PackLib +@testable import XToolSupport + +struct DevCommandTests { + private struct ForbiddenAuthStorage: KeyValueStorage { + func data(forKey key: String) throws -> Data? { + Issue.record("Unexpected authentication read: \(key)") + throw StringError("Unexpected authentication") + } + + func setData(_ data: Data?, forKey key: String) throws { + Issue.record("Unexpected authentication write: \(key)") + throw StringError("Unexpected authentication") + } + } + + private actor Recorder { + var events: [String] = [] + func record(_ event: String) { events.append(event) } + } + + private func root() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func schema(target: String? = nil, entitlements: String? = nil) throws -> PackSchema { + try PackSchema(validating: .init( + version: .v2, orgID: "test", product: "App", target: target, + source: .executable(product: "Executable"), entitlementsPath: entitlements + )) + } + + private func client(schema: PackSchema, root: URL) -> DevCommandClient { + var client = DevCommandClient.testValue + client.currentDirectory = { root } + client.loadSchema = { _ in schema } + client.ensureSDK = { Issue.record("Unexpected SDK setup"); throw StringError("Unexpected SDK setup") } + client.legacyPack = { _, _, _, _ in Issue.record("Unexpected legacy packing"); throw StringError("Unexpected legacy packing") } + client.deferredPack = { _, _ in Issue.record("Unexpected deferred packing"); throw StringError("Unexpected deferred packing") } + return client + } + + @Test func buildParserKeepsIOSDefaults() throws { + let command = try DevBuildCommand.parse([]) + #expect(command.triple == nil) + #expect(command.packOptions.configuration == .debug) + #expect(!command.sign) + #expect(!command.ipa) + #expect(!command.deferSigning) + let request = try DevBuildRequest(schema: .default, directory: URL(fileURLWithPath: "/project")) + #expect(request.triple == "arm64-apple-ios") + #expect(request.platform == .iOS) + try request.validate(route: .build(sign: false, ipa: false, deferSigning: false)) + try request.validate(route: .build(sign: true, ipa: true, deferSigning: false)) + try request.validate(route: .run) + try request.validate(route: .xcode) + } + + @Test func schemaTargetAndCLIOverrideSelectRoute() throws { + let directory = URL(fileURLWithPath: "/project") + let macSchema = try schema(target: "arm64-apple-macosx15.0") + let configured = try DevBuildRequest(schema: macSchema, directory: directory) + #expect(configured.triple == "arm64-apple-macosx15.0") + #expect(configured.platform == .macOS) + let overridden = try DevBuildRequest(schema: macSchema, cliTriple: "arm64-apple-ios17.0", directory: directory) + #expect(overridden.triple == "arm64-apple-ios17.0") + #expect(overridden.platform == .iOS) + let macOverride = try DevBuildRequest(schema: .default, cliTriple: "x86_64-apple-macosx15.0", directory: directory) + #expect(macOverride.platform == .macOS) + try macOverride.validate(route: .build(sign: false, ipa: false, deferSigning: true)) + } + + @Test func runAcceptsTripleAndXcodeStillRejectsIt() throws { + #expect(try DevRunCommand.parse(["--triple", "arm64-apple-macosx"]).triple == "arm64-apple-macosx") + #expect(try DevRunCommand.parse(["--", "--cofiguration", "x"]).appArguments == ["--cofiguration", "x"]) + #expect(try DevRunCommand.parse(["--"]).appArguments.isEmpty) + for typo in [["--cofiguration", "release"], ["biuld"], ["--app", "App.app", "--cofiguration", "release"]] { + #expect(throws: (any Error).self) { try DevRunCommand.parse(typo) } + } + let help = #expect(throws: (any Error).self) { try DevRunCommand.parse(["--help"]) } + #expect(help.map { DevRunCommand.exitCode(for: $0) } == .success) + #expect(throws: (any Error).self) { + try DevXcodeCommand.parse(["--triple", "arm64-apple-macosx"]) + } + } + + @Test(arguments: [[], ["--sign"], ["--ipa"], ["--sign", "--defer-signing"], ["--ipa", "--defer-signing"]]) + func macOSInvalidBuildRoutesFailBeforeEffects(arguments: [String]) async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let command = try DevBuildCommand.parse(arguments) + let effects = client(schema: try schema(target: "arm64-apple-macosx"), root: directory) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await command.run() + } + } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } + + @Test func macOSRunAndXcodeRejectBeforeEffects() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let effects = client(schema: try schema(target: "arm64-apple-macosx"), root: directory) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + // A non-Mac host, explicitly: resolving the implicit test value crashes with the + // swift.org Swift 6.4.0 macOS toolchain (not Xcode 27); pointfreeco/swift-dependencies#474. + $0.macHostClient = .testValue + } operation: { + try await DevRunCommand.parse([]).run() + } + } + #if os(macOS) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevRunCommand.parse(["--simulator"]).run() + } + } + #endif + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevXcodeCommand.parse([]).run() + } + } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } + + @Test(arguments: ["arm64-unknown-linux", "arm64-apple-macosx-invalid"]) + func invalidCLITripleRejectsBeforeEffects(triple: String) async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let effects = client(schema: .default, root: directory) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevBuildCommand.parse(["--triple", triple, "--sign"]).run() + } + } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } + + @Test func macOSCLIOverrideRejectsSigningBeforeAuthentication() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let effects = client(schema: .default, root: directory) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevBuildCommand.parse(["--triple", "arm64-apple-macosx", "--sign"]).run() + } + } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } + + @Test func deferredSigningRejectsIOSBeforeEffects() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let effects = client(schema: .default, root: directory) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevBuildCommand.parse(["--defer-signing"]).run() + } + } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } + + private func packageInspection(root: URL) throws -> PackageInspectionClient { + let dependencies = try JSONSerialization.data(withJSONObject: [ + "identity": "root", "path": root.path, "dependencies": [], + ]) + let description = try JSONSerialization.data(withJSONObject: [ + "name": "Fixture", + "platforms": [["name": "macos", "version": "15.0"]], + "products": [["name": "Executable", "targets": ["Main"], "type": ["executable": NSNull()]]], + "targets": [["name": "Main", "type": "executable", "module_type": "SwiftTarget", "resources": []]], + ]) + let manifest = try JSONSerialization.data(withJSONObject: [ + "targets": [["name": "Main", "type": "executable", "dependencies": []]], "dependencies": [], + ]) + return PackageInspectionClient( + dependencies: { _ in dependencies }, describe: { _ in description }, manifest: { _ in manifest } + ) + } + + @Test func deferredBuildUsesSchemaAndPreservesSigningInputsWithoutAuth() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + try Data("fixture".utf8).write(to: directory.appendingPathComponent("Package.swift")) + let entitlementData = try PropertyListSerialization.data( + fromPropertyList: ["com.apple.security.app-sandbox": true], format: .xml, options: 0 + ) + try entitlementData.write(to: directory.appendingPathComponent("App.entitlements")) + let recorder = Recorder() + let configured = try schema(target: "arm64-apple-macosx", entitlements: "App.entitlements") + var effects = client(schema: configured, root: directory) + effects.ensureSDK = { await recorder.record("sdk") } + effects.loadSchema = { _ in await recorder.record("schema"); return configured } + effects.deferredPack = { settings, plan in + #expect(settings.triple == "arm64-apple-macosx") + #expect(settings.configuration == .release) + #expect(settings.packagePath == directory.path) + #expect(settings.buildSystem == .swiftPM) + #expect(plan.target.platform == .macOS) + #expect(plan.target.deploymentTarget == "15.0") + #expect(plan.app.resolvedSource == .executable(product: "Executable")) + await recorder.record("deferred-pack") + let bundle = directory.appendingPathComponent("App.app", isDirectory: true) + try MacArtifactTests.fixture(at: bundle) + return bundle + } + let inspection = try packageInspection(root: directory) + try await withDependencies { + $0.devCommandClient = effects + $0.packageInspection = inspection + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevBuildCommand.parse(["--defer-signing", "--configuration", "release"]).run() + } + #expect(await recorder.events == ["schema", "sdk", "deferred-pack"]) + let manifestURL = directory.appendingPathComponent("App.signing.json") + let handoff = try JSONDecoder().decode(DeferredSigningManifest.self, from: Data(contentsOf: manifestURL)) + #expect(handoff.status == .deferred) + #expect(handoff.products[0].bundlePath == "") + #expect(handoff.products[0].executablePath == "Contents/MacOS/App") + let relative = try #require(handoff.products[0].entitlementsPath) + let copied = manifestURL.deletingLastPathComponent().appendingPathComponent(relative) + #expect(try Data(contentsOf: copied) == entitlementData) + let supportsBSP = try await SwiftVersion.current.supportsSwiftBuild + let editorConfigPath = supportsBSP ? LSPConfig.bsp.0 : LSPConfig.legacyLSP.0 + let alternateConfigPath = supportsBSP ? LSPConfig.legacyLSP.0 : LSPConfig.bsp.0 + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(alternateConfigPath).path)) + let editorConfigURL = directory.appendingPathComponent(editorConfigPath) + let editorConfig = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: editorConfigURL)) as? [String: Any]) + if supportsBSP { + #expect(editorConfig["argv"] as? [String] == [ + "/usr/bin/env", "xtool", "dev", "build-server", "--triple", "arm64-apple-macosx15.0", + ]) + } else { + #expect(editorConfig["swiftPM"] as? [String: String] == [ + "swiftSDK": "arm64-apple-macosx", "triple": "arm64-apple-macosx15.0", + ]) + } + } + + private func product(_ name: String, kind: Plan.ProductType, target: ResolvedTarget, entitlements: String?) -> Plan.Product { + Plan.Product(target: target, type: kind, product: name, bundleID: "test.\(name)", + infoPlist: [:], resources: [], entitlementsPath: entitlements) + } + + @Test func handoffKeepsNestedAssociationsAndMovesWithArtifact() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("source", isDirectory: true) + let output = directory.appendingPathComponent("output", isDirectory: true) + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + let entitlementData = try PropertyListSerialization.data(fromPropertyList: ["test.flag": true], format: .binary, options: 0) + try entitlementData.write(to: source.appendingPathComponent("shared.plist")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let plan = try Plan( + target: target, app: product("App", kind: .application, target: target, entitlements: "shared.plist"), + extensions: [product("Preview", kind: .appExtension, target: target, entitlements: "shared.plist")], + nestedProducts: [product("Service", kind: .xpcService, target: target, entitlements: "shared.plist"), + product("Helper", kind: .helperExecutable, target: target, entitlements: nil)] + ) + let manifestURL = try DeferredSigningHandoff.write( + plan: plan, bundle: output.appendingPathComponent("App.app"), sourceDirectory: source + ) + let moved = directory.appendingPathComponent("moved", isDirectory: true) + try FileManager.default.moveItem(at: output, to: moved) + try FileManager.default.removeItem(at: source) + let manifest = try JSONDecoder().decode( + DeferredSigningManifest.self, from: Data(contentsOf: moved.appendingPathComponent(manifestURL.lastPathComponent)) + ) + #expect(manifest.products.map(\.bundleID) == ["test.App", "test.Preview", "test.Service", "test.Helper"]) + #expect(manifest.products.map(\.bundlePath) == ["", "Contents/PlugIns/Preview.appex", "Contents/XPCServices/Service.xpc", + "Contents/Helpers/Helper"]) + #expect(manifest.products[3].entitlementsPath == nil) + for product in manifest.products.prefix(3) { + let path = try #require(product.entitlementsPath) + #expect(!path.hasPrefix("/")) + #expect(try Data(contentsOf: moved.appendingPathComponent(path)) == entitlementData) + } + } + + @Test func replacingHandoffPreservesPreviousEntitlementGeneration() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("entitlements.plist") + let first = try PropertyListSerialization.data(fromPropertyList: ["test.flag": true], format: .xml, options: 0) + try first.write(to: source) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let app = product("App", kind: .application, target: target, entitlements: "entitlements.plist") + let plan = try Plan(target: target, app: app) + let bundle = directory.appendingPathComponent("App.app") + let url = try DeferredSigningHandoff.write(plan: plan, bundle: bundle, sourceDirectory: directory) + let previous = try JSONDecoder().decode(DeferredSigningManifest.self, from: Data(contentsOf: url)) + let previousPath = try #require(previous.products.first?.entitlementsPath) + let second = try PropertyListSerialization.data(fromPropertyList: ["test.flag": false], format: .xml, options: 0) + try second.write(to: source) + _ = try DeferredSigningHandoff.write(plan: plan, bundle: bundle, sourceDirectory: directory) + let current = try JSONDecoder().decode(DeferredSigningManifest.self, from: Data(contentsOf: url)) + let currentPath = try #require(current.products.first?.entitlementsPath) + #expect(currentPath != previousPath) + #expect(try Data(contentsOf: directory.appendingPathComponent(previousPath)) == first) + #expect(try Data(contentsOf: directory.appendingPathComponent(currentPath)) == second) + } + + @Test(arguments: ["malformed", "array", "com.apple.developer.homekit", "keychain-access-groups"]) + func unsupportedEntitlementsRejectWithoutWritingHandoff(entitlement: String) throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let data = switch entitlement { + case "malformed": Data("not a plist".utf8) + case "array": try PropertyListSerialization.data(fromPropertyList: ["array"], format: .xml, options: 0) + default: try PropertyListSerialization.data(fromPropertyList: [entitlement: true], format: .xml, options: 0) + } + try data.write(to: directory.appendingPathComponent("bad.plist")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let plan = try Plan(target: target, app: product("App", kind: .application, target: target, entitlements: "bad.plist")) + #expect(throws: (any Error).self) { + try DeferredSigningHandoff.write(plan: plan, bundle: directory.appendingPathComponent("App.app"), + sourceDirectory: directory) + } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path) == ["bad.plist"]) + } + + @Test(arguments: [nil, "arm64-apple-ios17.0"]) + func iosBuildForwardsEffectiveTripleWithoutAuthentication(override: String?) async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let configured = try schema(target: "arm64-apple-ios16.0") + let recorder = Recorder() + var effects = client(schema: configured, root: directory) + effects.legacyPack = { _, triple, configuration, xcode in + #expect(triple == (override ?? "arm64-apple-ios16.0")) + #expect(configuration == .debug) + #expect(!xcode) + await recorder.record("ios-pack") + return directory.appendingPathComponent("App.app") + } + let arguments = override.map { ["--triple", $0] } ?? [] + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevBuildCommand.parse(arguments).run() + } + #expect(await recorder.events == ["ios-pack"]) + } + + @Test func iosIPAPackagingStillUsesCompressor() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let recorder = Recorder() + let app = directory.appendingPathComponent("App.app", isDirectory: true) + try FileManager.default.createDirectory(at: app, withIntermediateDirectories: true) + try Data("app executable".utf8).write(to: app.appendingPathComponent("App")) + var effects = client(schema: .default, root: directory) + effects.legacyPack = { _, triple, _, xcode in + #expect(triple == "arm64-apple-ios") + #expect(!xcode) + return app + } + let compressor = ZIPCompressor( + compress: { payload, _ in + #expect(payload.lastPathComponent == "Payload") + #expect(FileManager.default.fileExists(atPath: payload.appendingPathComponent("App.app/App").path)) + await recorder.record("ipa") + let compressed = directory.appendingPathComponent("compressed.zip") + try Data("compressed app".utf8).write(to: compressed) + return compressed + }, + decompress: { _, _, _ in Issue.record("Unexpected decompression") } + ) + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + $0.zipCompressor = compressor + } operation: { + try await DevBuildCommand.parse(["--ipa"]).run() + } + #expect(await recorder.events == ["ipa"]) + #expect(try Data(contentsOf: directory.appendingPathComponent("App.ipa")) == Data("compressed app".utf8)) + } + + @Test func iosRunAndXcodeRetainLegacyPacking() async throws { + struct BuildStopped: Error {} + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let recorder = Recorder() + var effects = client(schema: .default, root: directory) + effects.legacyPack = { _, triple, configuration, xcode in + #expect(triple == "arm64-apple-ios") + #expect(configuration == .debug) + await recorder.record(xcode ? "xcode" : "run") + throw BuildStopped() + } + await #expect(throws: BuildStopped.self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevRunCommand.parse([]).run() + } + } + await #expect(throws: BuildStopped.self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevXcodeCommand.parse([]).run() + } + } + #expect(await recorder.events == ["run", "xcode"]) + } + + @Test func iosSignStillRequiresAuthenticationBeforePacking() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let effects = client(schema: .default, root: directory) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = MemoryKeyValueStorage() + } operation: { + try await DevBuildCommand.parse(["--sign"]).run() + } + } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } +} diff --git a/Tests/XToolTests/EditorConfigTests.swift b/Tests/XToolTests/EditorConfigTests.swift new file mode 100644 index 00000000..ca33d06a --- /dev/null +++ b/Tests/XToolTests/EditorConfigTests.swift @@ -0,0 +1,244 @@ +import ArgumentParser +import Dependencies +import Foundation +import Testing +@testable import PackLib +@testable import XToolSupport + +struct EditorConfigTests { + private func root() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func write(_ config: (String, String), to root: URL) throws { + let url = root.appendingPathComponent(config.0) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(config.1.utf8).write(to: url) + } + + private func read(_ path: String, in root: URL) throws -> Data { + try Data(contentsOf: root.appendingPathComponent(path)) + } + + @Test(arguments: [true, false]) + func newConfigurationsKeepIOSDefaultAndSelectMacOS(bsp: Bool) throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let ios = try ResolvedTarget.resolve() + try LSPConfig.ensure(target: ios, sdkOptions: [], supportsBSP: bsp, quiet: true, currentDirectory: directory) + let path = bsp ? LSPConfig.bsp.0 : LSPConfig.legacyLSP.0 + let iosText = String(decoding: try read(path, in: directory), as: UTF8.self) + #expect(iosText.contains("arm64-apple-ios")) + let mac = try ResolvedTarget.resolve(cliTarget: "x86_64-apple-macos15.0") + try LSPConfig.ensure(target: mac, sdkOptions: [], supportsBSP: bsp, quiet: true, currentDirectory: directory) + let object = try #require(JSONSerialization.jsonObject(with: read(path, in: directory)) as? [String: Any]) + if bsp { + #expect(object["argv"] as? [String] == [ + "/usr/bin/env", "xtool", "dev", "build-server", "--triple", "x86_64-apple-macosx15.0", + ]) + } else { + #expect(object["swiftPM"] as? [String: String] == [ + "swiftSDK": "x86_64-apple-macosx", "triple": "x86_64-apple-macosx15.0", + ]) + } + } + + @Test(arguments: [true, false]) + func oldGeneratedConfigsMigrateInBothDirections(bsp: Bool) throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + try write(LSPConfig.bsp, to: directory) + try write(LSPConfig.legacyLSP, to: directory) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx16.0") + try LSPConfig.ensure(target: target, sdkOptions: [], supportsBSP: bsp, quiet: true, currentDirectory: directory) + let primary = bsp ? LSPConfig.bsp.0 : LSPConfig.legacyLSP.0 + let secondary = bsp ? LSPConfig.legacyLSP.0 : LSPConfig.bsp.0 + #expect(String(decoding: try read(primary, in: directory), as: UTF8.self).contains("arm64-apple-macosx16.0")) + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(secondary).path)) + // Reversing direction recognizes the newly generated content too. + try LSPConfig.ensure(target: target, sdkOptions: [], supportsBSP: !bsp, quiet: true, currentDirectory: directory) + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(primary).path)) + #expect(FileManager.default.fileExists(atPath: directory.appendingPathComponent(secondary).path)) + } + + @Test(arguments: [true, false]) + func userPrimaryPreservesBothFiles(bsp: Bool) throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let primary = bsp ? LSPConfig.bsp.0 : LSPConfig.legacyLSP.0 + let secondary = bsp ? LSPConfig.legacyLSP : LSPConfig.bsp + let userContent = "{\"custom\": true}" + try write((primary, userContent), to: directory) + try write(secondary, to: directory) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx") + try LSPConfig.ensure(target: target, sdkOptions: [], supportsBSP: bsp, quiet: true, currentDirectory: directory) + #expect(try read(primary, in: directory) == Data(userContent.utf8)) + #expect(try read(secondary.0, in: directory) == Data(secondary.1.utf8)) + } + + @Test(arguments: [true, false]) + func userSecondaryIsNeverDeleted(bsp: Bool) throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx") + let secondary = try LSPConfig.configuration(target: target, sdkOptions: [], bsp: !bsp) + var object = try #require(JSONSerialization.jsonObject(with: Data(secondary.1.utf8)) as? [String: Any]) + object["custom"] = true + let contents = try JSONSerialization.data(withJSONObject: object) + try write((secondary.0, String(decoding: contents, as: UTF8.self)), to: directory) + try LSPConfig.ensure(target: target, sdkOptions: [], supportsBSP: bsp, quiet: true, currentDirectory: directory) + #expect(try read(secondary.0, in: directory) == contents) + } + + @Test(arguments: [true, false]) + func changedGeneratedValuesAreUserOwned(bsp: Bool) throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let target = try ResolvedTarget.resolve() + let generated = try LSPConfig.configuration(target: target, sdkOptions: [], bsp: bsp) + let customized = bsp ? generated.1.replacingOccurrences(of: "xtool", with: "custom-tool") + : generated.1.replacingOccurrences(of: "arm64-apple-ios13.0", with: "arm64-apple-ios12.0") + try write((generated.0, customized), to: directory) + let mac = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx") + try LSPConfig.ensure(target: mac, sdkOptions: [], supportsBSP: bsp, quiet: true, currentDirectory: directory) + #expect(try read(generated.0, in: directory) == Data(customized.utf8)) + } + + @Test func malformedPrimaryIsPreserved() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + try write((LSPConfig.legacyLSP.0, "not JSON"), to: directory) + let target = try ResolvedTarget.resolve() + try LSPConfig.ensure(target: target, sdkOptions: [], supportsBSP: false, quiet: true, currentDirectory: directory) + #expect(try read(LSPConfig.legacyLSP.0, in: directory) == Data("not JSON".utf8)) + } + + @Test func skipLSPAvoidsInspectionAndWrites() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let schema = try PackSchema(validating: .init(version: .v1, orgID: "test", skipLSP: true)) + try await LSPConfig.ensure(using: schema, quiet: true, currentDirectory: directory) + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } + + private func inspection(root: URL, platform: String, minimum: String) throws -> PackageInspectionClient { + let dependencies = try JSONSerialization.data(withJSONObject: [ + "identity": "root", "path": root.path, "dependencies": [], + ]) + let description = try JSONSerialization.data(withJSONObject: [ + "name": "Fixture", "platforms": [["name": platform, "version": minimum]], + "products": [["name": "App", "targets": ["Main"], "type": ["executable": NSNull()]]], + "targets": [["name": "Main", "type": "executable", "module_type": "SwiftTarget", "resources": []]], + ]) + let manifest = try JSONSerialization.data(withJSONObject: [ + "targets": [["name": "Main", "type": "executable", "dependencies": []]], "dependencies": [], + ]) + return PackageInspectionClient( + dependencies: { _ in dependencies }, describe: { _ in description }, manifest: { _ in manifest } + ) + } + + @Test(arguments: [nil, "x86_64-apple-macosx17.0"]) + func buildServerMatchesPlannerTargetAndDeployment(override: String?) async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let schema = try PackSchema(validating: .init( + version: .v2, orgID: "test", product: "App", target: "arm64-apple-macosx", deploymentTarget: "16.0" + )) + let command = try DevBSPCommand.parse(override.map { ["--triple", $0] } ?? []) + let request = try DevBuildRequest(schema: schema, cliTriple: command.triple, directory: directory) + let inspector = try inspection(root: directory, platform: "macos", minimum: "15.0") + try await withDependencies { + $0.packageInspection = inspector + } operation: { + let (settings, target) = try await EditorTarget.buildSettings(request: request) + #expect(settings.buildSystem == .swiftPM) + #expect(settings.sdkOptions == [ + "--swift-sdk", override == nil ? "arm64-apple-macosx" : "x86_64-apple-macosx", + "--triple", override ?? "arm64-apple-macosx16.0", + ]) + let plan = try await Planner(buildSettings: settings, schema: schema).createPlan() + #expect(plan.target == target) + #expect(settings.buildServerArguments.contains(target.compilerTriple)) + let config = try LSPConfig.configuration(target: target, sdkOptions: settings.sdkOptions, bsp: false) + #expect(config.1.contains(target.compilerTriple)) + } + } + + @Test func buildServerStartsWithoutInspectingThePackage() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let schema = try PackSchema(validating: .init(version: .v2, orgID: "test", product: "App", target: "arm64-apple-macosx")) + let request = try DevBuildRequest(schema: schema, cliTriple: "arm64-apple-macosx16.0", directory: directory) + let settings = try await withDependencies { + $0.packageInspection = PackageInspectionClient( + dependencies: { _ in Issue.record("Unexpected package inspection"); return Data() }, + describe: { _ in Issue.record("Unexpected package inspection"); return Data() }, + manifest: { _ in Issue.record("Unexpected package inspection"); return Data() } + ) + } operation: { + try await EditorTarget.serverSettings(request: request) + } + #expect(settings.sdkOptions == ["--swift-sdk", "arm64-apple-macosx", "--triple", "arm64-apple-macosx16.0"]) + // Configurations generated with the unversioned triple are still recognized and refreshed. + let old = (LSPConfig.bsp.0, """ + {"argv": ["/usr/bin/env", "xtool", "dev", "build-server", "--triple", "arm64-apple-macosx"], + "bspVersion": "2.2.0", "languages": ["c", "cpp", "objective-c", "objective-cpp", "swift"], + "name": "xtool", "version": "1.0"} + """) + try write(old, to: directory) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx16.0") + try LSPConfig.ensure(target: target, sdkOptions: [], supportsBSP: true, quiet: true, currentDirectory: directory) + #expect(String(decoding: try read(LSPConfig.bsp.0, in: directory), as: UTF8.self).contains("arm64-apple-macosx16.0")) + } + + @Test(arguments: [true, false]) + func buildServerKeepsIOSDefaultAndPackageMinimum(bsp: Bool) async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let schema = try PackSchema(validating: .init(version: .v1, orgID: "test", product: "App")) + let inspector = try inspection(root: directory, platform: "ios", minimum: "14.0") + try await withDependencies { + $0.packageInspection = inspector + } operation: { + let request = try DevBuildRequest(schema: schema, directory: directory) + let (settings, target) = try await EditorTarget.buildSettings(request: request) + #expect(request.triple == ResolvedTarget.defaultTriple) + #expect(target.compilerTriple == "arm64-apple-ios14.0") + // SwiftBuild keeps the unversioned triple so packages index at their own minimums, as they build. + let indexTriple = settings.buildSystem == .swiftBuild ? "arm64-apple-ios" : "arm64-apple-ios14.0" + #expect(settings.buildServerArguments.suffix(2) == ["--triple", indexTriple]) + let config = try LSPConfig.configuration(target: target, sdkOptions: settings.sdkOptions, bsp: bsp) + let object = try #require(JSONSerialization.jsonObject(with: Data(config.1.utf8)) as? [String: Any]) + if bsp { + #expect(config.0 == LSPConfig.bsp.0) + #expect(object["argv"] as? [String] == [ + "/usr/bin/env", "xtool", "dev", "build-server", "--triple", "arm64-apple-ios14.0", + ]) + } else { + #expect(config.0 == LSPConfig.legacyLSP.0) + #expect(object["swiftPM"] as? [String: String] == [ + "swiftSDK": "arm64-apple-ios", "triple": "arm64-apple-ios14.0", + ]) + } + } + } + + @Test func packageMinimumControlsEditorAndRejectsLowerOverrides() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let schema = try PackSchema(validating: .init(version: .v2, orgID: "test", product: "App", target: "arm64-apple-macosx")) + let inspector = try inspection(root: directory, platform: "macos", minimum: "16.0") + try await withDependencies { + $0.packageInspection = inspector + } operation: { + let request = try DevBuildRequest(schema: schema, directory: directory) + let (_, target) = try await EditorTarget.buildSettings(request: request) + #expect(target.compilerTriple == "arm64-apple-macosx16.0") + let lower = try DevBuildRequest(schema: schema, cliTriple: "arm64-apple-macosx15.0", directory: directory) + await #expect(throws: (any Error).self) { try await EditorTarget.buildSettings(request: lower) } + } + } +} diff --git a/Tests/XToolTests/MacArtifactTests.swift b/Tests/XToolTests/MacArtifactTests.swift new file mode 100644 index 00000000..2d9b0c8a --- /dev/null +++ b/Tests/XToolTests/MacArtifactTests.swift @@ -0,0 +1,241 @@ +import ArgumentParser +import Dependencies +import Foundation +import Testing +@testable import PackLib +@testable import XToolSupport + +struct MacArtifactTests { + static func fixture(at bundle: URL, minimumSystemVersion: String? = "15.0") throws { + let directory = bundle.appendingPathComponent("Contents/MacOS") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + var info: [String: Any] = ["CFBundleExecutable": "App", "CFBundlePackageType": "APPL", + "CFBundleIdentifier": "test.App"] + info["LSMinimumSystemVersion"] = minimumSystemVersion + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + .write(to: bundle.appendingPathComponent("Contents/Info.plist")) + try binary().write(to: directory.appendingPathComponent("App")) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory.appendingPathComponent("App").path) + } + + static func binary( + dependency: String? = nil, type: UInt32 = 2, rpath: String? = nil, + cpu: UInt32 = 0x0100000c, minimumOS: UInt32 = 0x000f0000 + ) -> Data { + func words(_ values: [UInt32]) -> Data { + values.reduce(into: Data()) { result, value in + var little = value.littleEndian + withUnsafeBytes(of: &little) { result.append(contentsOf: $0) } + } + } + var commands = words([0x32, 24, 1, minimumOS, 0x001a0000, 0]) + var count: UInt32 = 1 + for (command, path) in [(UInt32(0xc), dependency), (UInt32(0x8000001c), rpath)] { + guard let path else { continue } + let header: UInt32 = command == 0xc ? 24 : 12 + var string = Data(path.utf8) + Data([0]) + while (Int(header) + string.count) % 8 != 0 { string.append(0) } + commands += words([command, header + UInt32(string.count), header]) + if header == 24 { commands += words([0, 0, 0]) } + commands += string + count += 1 + } + return words([0xfeedfacf, cpu, 0, type, count, UInt32(commands.count), 0, 0]) + commands + } + + private func root() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func handoff(at root: URL) throws -> URL { + let bundle = root.appendingPathComponent("App.app") + try Self.fixture(at: bundle) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let entitlements = root.appendingPathComponent("App.entitlements") + try PropertyListSerialization.data(fromPropertyList: ["test.flag": true], format: .xml, options: 0).write(to: entitlements) + let app = Plan.Product(target: target, type: .application, product: "App", bundleID: "test.App", + infoPlist: [:], resources: [], entitlementsPath: entitlements.path) + let manifest = try DeferredSigningHandoff.write(plan: Plan(target: target, app: app), bundle: bundle, sourceDirectory: root) + try MacArtifact.write(bundle: bundle, signingManifest: manifest) + return bundle + } + + @Test func relocatedHandoffVerifiesAndDetectsResourceMutation() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("source") + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + _ = try handoff(at: source) + let moved = directory.appendingPathComponent("moved") + try FileManager.default.moveItem(at: source, to: moved) + let app = moved.appendingPathComponent("App.app") + try MacArtifact.verify(bundle: app) + try Data("changed".utf8).write(to: app.appendingPathComponent("Contents/extra.txt")) + #expect(throws: (any Error).self) { try MacArtifact.verify(bundle: app) } + } + + @Test func entitlementMutationIsDetected() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(at: directory) + let manifest = try JSONDecoder().decode(DeferredSigningManifest.self, + from: Data(contentsOf: directory.appendingPathComponent("App.signing.json"))) + let input = try #require(manifest.products.first?.entitlementsPath) + try Data("changed".utf8).write(to: directory.appendingPathComponent(input)) + #expect(throws: (any Error).self) { try MacArtifact.verify(bundle: app) } + } + + @Test func executableBitMutationIsDetected() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(at: directory) + try FileManager.default.setAttributes([.posixPermissions: 0o644], + ofItemAtPath: app.appendingPathComponent("Contents/MacOS/App").path) + #expect(throws: (any Error).self) { try MacArtifact.verify(bundle: app) } + } + + @Test func escapingSymlinkIsRejected() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createSymbolicLink( + atPath: directory.appendingPathComponent("escape").path, + withDestinationPath: "/tmp" + ) + #expect(throws: (any Error).self) { try ArtifactInventory(directory: directory) } + } + + @Test func dependencyClosureRejectsMissingLibraryAndAcceptsRelocatedLibrary() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = directory.appendingPathComponent("App.app") + try Self.fixture(at: app) + try Self.binary(dependency: "@rpath/libDependency.dylib", rpath: "@executable_path/../Frameworks") + .write(to: app.appendingPathComponent("Contents/MacOS/App")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + #expect(throws: (any Error).self) { try MacBundleAudit.validate(bundle: app, target: target) } + let frameworks = app.appendingPathComponent("Contents/Frameworks") + try FileManager.default.createDirectory(at: frameworks, withIntermediateDirectories: true) + try Self.binary(type: 6).write(to: frameworks.appendingPathComponent("libDependency.dylib")) + #expect(try MacBundleAudit.validate(bundle: app, target: target) == ["Contents/Frameworks/libDependency.dylib", ""]) + } + + @Test func systemRpathDoesNotResolveEmbeddedLibraries() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = directory.appendingPathComponent("App.app") + try Self.fixture(at: app) + let executable = app.appendingPathComponent("Contents/MacOS/App") + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + // swiftc emits LC_RPATH /usr/lib/swift for every macOS executable. + try Self.binary(dependency: "@rpath/libMissing.dylib", rpath: "/usr/lib/swift").write(to: executable) + #expect(throws: (any Error).self) { try MacBundleAudit.validate(bundle: app, target: target) } + try Self.binary(dependency: "@rpath/libswift_Concurrency.dylib", rpath: "/usr/lib/swift").write(to: executable) + #expect(try MacBundleAudit.validate(bundle: app, target: target) == [""]) + } + + @Test func existingAppRunForwardsArgumentsAndExitStatusWithoutBuilding() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = directory.appendingPathComponent("App.app") + try Self.fixture(at: app) + let before = try ArtifactInventory(directory: app) + await #expect(throws: ExitCode(7)) { + try await withDependencies { + $0.macHostClient = MacHostClient(isMacOS: { true }, launch: { executable, arguments in + #expect(executable.lastPathComponent == "App") + #expect(arguments == ["--probe", "value with spaces"]) + return 7 + }) + } operation: { + try await DevRunCommand.parse(["--app", app.path, "--", "--probe", "value with spaces"]).run() + } + } + try before.verify(directory: app) + } + + @Test func existingAppTargetComesFromTheAppNotFromBuildPolicy() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = directory.appendingPathComponent("App.app") + let executable = app.appendingPathComponent("Contents/MacOS/App") + + // An app older than xtool's macOS 13.0 build floor still validates. + try Self.fixture(at: app, minimumSystemVersion: "11.0") + try Self.binary(minimumOS: 0x000b0000).write(to: executable) + let old = try MacBundleAudit.runTarget(bundle: app, hostArchitecture: "arm64") + #expect(old.architecture == "arm64" && old.deploymentTarget == "11.0") + try MacBundleAudit.validate(bundle: app, target: old) + + // Without LSMinimumSystemVersion, the executable's recorded minimum applies. + try Self.fixture(at: app, minimumSystemVersion: nil) + try Self.binary(minimumOS: 0x000b0000).write(to: executable) + #expect(try MacBundleAudit.runTarget(bundle: app, hostArchitecture: "arm64").deploymentTarget == "11.0.0") + + // An Intel-only app runs under Rosetta on Apple silicon; an arm64-only app has no Intel slice. + try Self.binary(cpu: 0x01000007, minimumOS: 0x000b0000).write(to: executable) + let intel = try MacBundleAudit.runTarget(bundle: app, hostArchitecture: "arm64") + #expect(intel.architecture == "x86_64") + try MacBundleAudit.validate(bundle: app, target: intel) + try Self.binary().write(to: executable) + #expect(throws: (any Error).self) { try MacBundleAudit.runTarget(bundle: app, hostArchitecture: "x86_64") } + } + + @Test func frameworkHelpersPrecedeTheirContainingFrameworkInSigningOrder() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = directory.appendingPathComponent("App.app") + try Self.fixture(at: app) + let version = app.appendingPathComponent("Contents/Frameworks/Example.framework/Versions/A") + try FileManager.default.createDirectory(at: version.appendingPathComponent("Helpers"), withIntermediateDirectories: true) + try Self.binary(type: 6).write(to: version.appendingPathComponent("Example")) + try Self.binary().write(to: version.appendingPathComponent("Helpers/Nested")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + #expect(try MacBundleAudit.validate(bundle: app, target: target) == [ + "Contents/Frameworks/Example.framework/Versions/A/Helpers/Nested", + "Contents/Frameworks/Example.framework", "", + ]) + } + + #if os(macOS) + static func nativeFixture(at bundle: URL) throws { + try fixture(at: bundle) + let source = bundle.deletingLastPathComponent().appendingPathComponent("main.swift") + try """ + import Foundation + guard Array(CommandLine.arguments.dropFirst()) == ["first", "two words"] else { exit(2) } + exit(7) + """.write(to: source, atomically: true, encoding: .utf8) + #if arch(arm64) + let architecture = "arm64" + #else + let architecture = "x86_64" + #endif + let compiler = Process() + compiler.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + compiler.arguments = ["swiftc", "-target", "\(architecture)-apple-macosx15.0", source.path, + "-o", bundle.appendingPathComponent("Contents/MacOS/App").path] + try compiler.run() + compiler.waitUntilExit() + try #require(compiler.terminationStatus == 0) + try FileManager.default.removeItem(at: source) + } + + @Test func nativeAppRunForwardsArgumentsAndExitStatus() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = directory.appendingPathComponent("App.app") + try Self.nativeFixture(at: app) + let before = try ArtifactInventory(directory: app) + await #expect(throws: ExitCode(7)) { + try await withDependencies { + $0.macHostClient = .liveValue + } operation: { + try await DevRunCommand.parse(["--app", app.path, "--", "first", "two words"]).run() + } + } + try before.verify(directory: app) + } + #endif +} diff --git a/Tests/XToolTests/MacNotarizationTests.swift b/Tests/XToolTests/MacNotarizationTests.swift new file mode 100644 index 00000000..ee56e26a --- /dev/null +++ b/Tests/XToolTests/MacNotarizationTests.swift @@ -0,0 +1,217 @@ +import ArgumentParser +import Dependencies +import Foundation +import Testing +import XUtils +@testable import PackLib +@testable import XToolSupport + +struct MacNotarizationTests { + actor Commands { + var calls: [[String]] = [] + var interruptSubmit = false + var wrongLog = false + var status = "Accepted" + let id = "12345678-1234-1234-1234-123456789012" + let release: URL + + init(release: URL) { self.release = release } + func interrupt() { interruptSubmit = true } + func mismatch() { wrongLog = true } + func setStatus(_ value: String) { status = value } + + func run(_ executable: String, _ arguments: [String]) throws -> Data { + calls.append([executable] + arguments) + if executable == "/usr/bin/ditto" { + try Data("archive".utf8).write(to: URL(fileURLWithPath: arguments.last!)) + return Data() + } + if arguments.first == "stapler" { + if arguments[1] == "staple" { + try Data("ticket".utf8).write(to: URL(fileURLWithPath: arguments[2]).appendingPathComponent("ticket")) + } + return Data() + } + if arguments[1] == "submit", interruptSubmit { throw StringError("Interrupted after upload") } + if arguments[1] == "log" { + let hash = try ArtifactInventory.hash(release.appendingPathComponent("upload.zip")) + return try JSONEncoder().encode(MacNotarizationOperation.NotaryLog( + jobId: id, sha256: wrongLog ? String(repeating: "0", count: 64) : hash, status: status + )) + } + return Data("{\"id\":\"\(id)\",\"status\":\"\(status)\"}".utf8) + } + } + + func fixture() throws -> (URL, URL, URL) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + let app = root.appendingPathComponent("Example.app") + try MacArtifactTests.fixture(at: app) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let inventory = try ArtifactInventory(directory: app) + let plan = MacSigningPlan(bundleName: app.lastPathComponent, target: target, mode: .developerID, + identity: String(repeating: "A", count: 40), + inputArtifactSHA256: String(repeating: "b", count: 64), inputInventory: inventory, + targets: [.init(path: "", entitlements: nil)]) + var receipt = try MacSigningReceipt(plan: plan, bundle: app) + receipt.signatures = [.init(path: "", certificateSHA1: plan.identity, teamIdentifier: "TESTTEAM", + authority: "Developer ID Application: Test", hardenedRuntime: true, + timestamp: "test timestamp")] + try receipt.write(beside: app) + return (root, app, root.appendingPathComponent("release")) + } + + func stub(_ dependencies: inout DependencyValues, _ commands: Commands) { + dependencies.macHostClient = MacHostClient(isMacOS: { true }, stream: { try await commands.run($0, $1) }) + dependencies.macNotarizationClient = MacNotarizationClient(verifySignature: { _, _ in }) + } + + @Test func completeFlowPreservesUploadAndVerifiesDistribution() async throws { + let (root, app, release) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let commands = Commands(release: release) + try await withDependencies { stub(&$0, commands) } operation: { () async throws in + try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "chosen-profile") + let original = try ArtifactInventory.hash(release.appendingPathComponent("upload.zip")) + #expect(try await MacNotarizationOperation.update(directory: release, profile: "chosen-profile") == "Accepted") + try await MacNotarizationOperation.staple(directory: release) + try await MacNotarizationOperation.verify(directory: release) + try await MacNotarizationOperation.staple(directory: release) + #expect(try ArtifactInventory.hash(release.appendingPathComponent("upload.zip")) == original) + #expect(try MacNotarizationOperation.load(release).status == "stapled") + let calls = await commands.calls + #expect(calls.filter { $0.contains("submit") }.count == 1) + #expect(calls.filter { $0.contains("staple") }.count == 1) + #expect(calls.contains { $0.contains("--no-wait") && $0.contains("chosen-profile") }) + try Data("tampered".utf8).write(to: release.appendingPathComponent("distribution/distribution.zip")) + await #expect(throws: (any Error).self) { try await MacNotarizationOperation.verify(directory: release) } + } + } + + @Test func interruptedSubmitNeverResubmitsAndCanRecoverBoundUUID() async throws { + let (root, app, release) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let commands = Commands(release: release) + await commands.interrupt() + try await withDependencies { stub(&$0, commands) } operation: { () async throws in + await #expect(throws: (any Error).self) { + try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "profile") + } + #expect(try MacNotarizationOperation.load(release).status == "submission-started") + await #expect(throws: (any Error).self) { + try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "profile") + } + await #expect(throws: (any Error).self) { + try await MacNotarizationOperation.update(directory: release, profile: "profile") + } + #expect(try await MacNotarizationOperation.update(directory: release, profile: "profile", wait: true, + timeout: 12, recoveryID: commands.id) == "Accepted") + let calls = await commands.calls + #expect(calls.filter { $0.contains("submit") }.count == 1) + #expect(calls.contains { $0.contains("wait") && $0.contains("12s") }) + } + } + + @Test func wrongAppleArchiveHashCannotAuthorizeStapling() async throws { + let (root, app, release) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let commands = Commands(release: release) + try await withDependencies { stub(&$0, commands) } operation: { () async throws in + try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "profile") + await commands.mismatch() + await #expect(throws: (any Error).self) { + try await MacNotarizationOperation.update(directory: release, profile: "profile") + } + await #expect(throws: (any Error).self) { try await MacNotarizationOperation.staple(directory: release) } + #expect(try MacNotarizationOperation.load(release).status == "submitted") + } + } + + @Test func changedUploadPreventsNetworkRequests() async throws { + let (root, app, release) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let commands = Commands(release: release) + try await withDependencies { stub(&$0, commands) } operation: { () async throws in + try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "profile") + let count = await commands.calls.count + try Data("changed".utf8).write(to: release.appendingPathComponent("upload.zip")) + await #expect(throws: (any Error).self) { + try await MacNotarizationOperation.update(directory: release, profile: "profile") + } + #expect(await commands.calls.count == count) + } + } + + @Test func invalidStatusPersistsWithoutDistribution() async throws { + let (root, app, release) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let commands = Commands(release: release) + try await withDependencies { stub(&$0, commands) } operation: { () async throws in + try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "profile") + await commands.setStatus("Invalid") + #expect(try await MacNotarizationOperation.update(directory: release, profile: "profile") == "Invalid") + await #expect(throws: (any Error).self) { try await MacNotarizationOperation.staple(directory: release) } + } + } + + @Test func outputInsideSourceRejectsBeforeMutationOrTools() async throws { + let (root, app, release) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let before = try ArtifactInventory(directory: app) + let alias = root.appendingPathComponent("alias") + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: app) + let commands = Commands(release: release) + try await withDependencies { stub(&$0, commands) } operation: { + for directory in [app, app.appendingPathComponent("release"), alias.appendingPathComponent("release")] { + await #expect(throws: (any Error).self) { + try await MacNotarizationOperation.submit(bundle: app, directory: directory, profile: "profile") + } + } + try before.verify(directory: app) + #expect(await commands.calls.isEmpty) + } + } + + @Test func commandRejectsMissingProfileAndCrossActionFlags() throws { + #expect(throws: (any Error).self) { try DevNotarizeCommand.parse(["submit", "App.app", "--output", "release"]) } + #expect(throws: (any Error).self) { + try DevNotarizeCommand.parse(["verify", "release", "--keychain-profile", "profile"]) + } + #expect(throws: (any Error).self) { + try DevNotarizeCommand.parse(["status", "release", "--keychain-profile", "profile", "--submission-id", "id"]) + } + let parsed = try DevNotarizeCommand.parse(["resume", "release", "--keychain-profile", "profile", "--timeout", "12"]) + #expect(parsed.timeout == 12) + } + + @Test func promotedDistributionRecoversAfterReceiptWriteInterruption() async throws { + let (root, app, release) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let commands = Commands(release: release) + try await withDependencies { stub(&$0, commands) } operation: { () async throws in + try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "profile") + _ = try await MacNotarizationOperation.update(directory: release, profile: "profile") + try await MacNotarizationOperation.staple(directory: release) + let final = try MacNotarizationOperation.load(release) + // Model a crash after the distribution rename but before appending its final event. + try FileManager.default.removeItem(at: release.appendingPathComponent( + String(format: "receipt-%06d.json", final.sequence) + )) + try await MacNotarizationOperation.staple(directory: release) + try await MacNotarizationOperation.verify(directory: release) + #expect(await commands.calls.filter { $0.contains("staple") }.count == 1) + } + } + + @Test func advisoryLockExcludesConcurrentOperations() throws { + let (root, _, release) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: release, withIntermediateDirectories: false) + let first = try MacNotarizationOperation.acquireLock(release) + #expect(throws: (any Error).self) { try MacNotarizationOperation.acquireLock(release) } + MacNotarizationOperation.releaseLock(first) + let next = try MacNotarizationOperation.acquireLock(release) + MacNotarizationOperation.releaseLock(next) + } +} diff --git a/Tests/XToolTests/MacSigningAuditTests.swift b/Tests/XToolTests/MacSigningAuditTests.swift new file mode 100644 index 00000000..491504c7 --- /dev/null +++ b/Tests/XToolTests/MacSigningAuditTests.swift @@ -0,0 +1,95 @@ +import Foundation +import Testing +@testable import PackLib + +struct MacSigningAuditTests { + private func fixture() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let app = root.appendingPathComponent("App.app") + try MacArtifactTests.fixture(at: app) + return app + } + + @Test func additionalMacOSExecutableIsAnIndependentSigningNode() throws { + let app = try fixture() + defer { try? FileManager.default.removeItem(at: app.deletingLastPathComponent()) } + try MacArtifactTests.binary().write(to: app.appendingPathComponent("Contents/MacOS/Helper")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + #expect(try MacBundleAudit.validate(bundle: app, target: target) == ["Contents/MacOS/Helper", ""]) + } + + @Test func differentlyNamedVersionedFrameworkUsesMetadata() throws { + let app = try fixture() + defer { try? FileManager.default.removeItem(at: app.deletingLastPathComponent()) } + let framework = app.appendingPathComponent("Contents/Frameworks/Example.framework") + let version = framework.appendingPathComponent("Versions/A") + try FileManager.default.createDirectory(at: version.appendingPathComponent("Resources"), withIntermediateDirectories: true) + try PropertyListSerialization.data(fromPropertyList: ["CFBundleExecutable": "Actual", "CFBundlePackageType": "FMWK"], + format: .xml, options: 0).write(to: version.appendingPathComponent("Resources/Info.plist")) + try MacArtifactTests.binary(type: 6).write(to: version.appendingPathComponent("Actual")) + try FileManager.default.createSymbolicLink(atPath: framework.appendingPathComponent("Versions/Current").path, + withDestinationPath: "A") + try FileManager.default.createSymbolicLink(atPath: framework.appendingPathComponent("Actual").path, + withDestinationPath: "Versions/Current/Actual") + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + #expect(try MacBundleAudit.validate(bundle: app, target: target) == ["Contents/Frameworks/Example.framework", ""]) + #expect(try MacBundleAudit.signingPath(for: "Contents/Frameworks/Example.framework/Actual", in: app) + == "Contents/Frameworks/Example.framework") + } + + @Test func multiplePhysicalFrameworkVersionsAreRejected() throws { + let app = try fixture() + defer { try? FileManager.default.removeItem(at: app.deletingLastPathComponent()) } + for version in ["A", "B"] { + let directory = app.appendingPathComponent("Contents/Frameworks/Example.framework/Versions/" + version) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try MacArtifactTests.binary(type: 6).write(to: directory.appendingPathComponent("Example")) + } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + #expect(throws: (any Error).self) { try MacBundleAudit.validate(bundle: app, target: target) } + } + + @Test func swappedSameNameExecutableIsRejected() throws { + let app = try fixture() + defer { try? FileManager.default.removeItem(at: app.deletingLastPathComponent()) } + let nested = app.appendingPathComponent("Contents/XPCServices/Other.xpc") + try MacArtifactTests.fixture(at: nested) + let product = try appProduct() + var manifest = try DeferredSigningManifest(plan: Plan(target: product.target, app: product)) + manifest.products[0].executablePath = "Contents/XPCServices/Other.xpc/Contents/MacOS/App" + try reject(manifest, app: app) + } + + @Test func executableAliasIsRejected() throws { + let app = try fixture() + defer { try? FileManager.default.removeItem(at: app.deletingLastPathComponent()) } + try FileManager.default.createSymbolicLink(atPath: app.appendingPathComponent("Contents/MacOS/Alias").path, + withDestinationPath: "App") + let product = try appProduct() + var manifest = try DeferredSigningManifest(plan: Plan(target: product.target, app: product)) + manifest.products[0].executablePath = "Contents/MacOS/Alias" + try reject(manifest, app: app) + } + + @Test func incorrectPackageTypeIsRejected() throws { + let app = try fixture() + defer { try? FileManager.default.removeItem(at: app.deletingLastPathComponent()) } + let plist = app.appendingPathComponent("Contents/Info.plist") + var info = try #require(PropertyListSerialization.propertyList(from: Data(contentsOf: plist), format: nil) as? [String: Any]) + info["CFBundlePackageType"] = "XPC!" + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0).write(to: plist) + let product = try appProduct() + try reject(DeferredSigningManifest(plan: Plan(target: product.target, app: product)), app: app) + } + + private func appProduct() throws -> Plan.Product { + Plan.Product(target: try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0"), type: .application, + product: "App", bundleID: "test.App", infoPlist: [:], resources: []) + } + + private func reject(_ manifest: DeferredSigningManifest, app: URL) throws { + let url = app.deletingPathExtension().appendingPathExtension("signing.json") + try JSONEncoder().encode(manifest).write(to: url) + #expect(throws: (any Error).self) { try MacArtifact.write(bundle: app, signingManifest: url) } + } +} diff --git a/Tests/XToolTests/MacSigningTests.swift b/Tests/XToolTests/MacSigningTests.swift new file mode 100644 index 00000000..73348b2e --- /dev/null +++ b/Tests/XToolTests/MacSigningTests.swift @@ -0,0 +1,259 @@ +import ArgumentParser +import ConcurrencyExtras +import Dependencies +import Foundation +import Testing +@testable import PackLib +@testable import XToolSupport + +struct MacSigningTests { + private func root() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private func handoff(_ root: URL, entitlements: [String: Any] = [:]) throws -> URL { + let bundle = root.appendingPathComponent("App.app") + try MacArtifactTests.fixture(at: bundle) + let input = root.appendingPathComponent("input.plist") + try PropertyListSerialization.data(fromPropertyList: entitlements, format: .xml, options: 0).write(to: input) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let app = Plan.Product(target: target, type: .application, product: "App", bundleID: "test.App", + infoPlist: [:], resources: [], entitlementsPath: input.path) + let manifest = try DeferredSigningHandoff.write(plan: Plan(target: target, app: app), bundle: bundle, sourceDirectory: root) + try MacArtifact.write(bundle: bundle, signingManifest: manifest) + return bundle + } + + @Test func requiresUnambiguousIdentityAndRejectsRestrictedEntitlements() throws { + #expect(throws: (any Error).self) { try MacSigningPlan.validateIdentity(mode: .development, identity: nil) } + #expect(throws: (any Error).self) { + try MacSigningPlan.validateIdentity(mode: .developerID, identity: "Developer ID Application: Someone") + } + #expect(throws: (any Error).self) { try MacSigningPlan.validateIdentity(mode: .adHoc, identity: "-") } + try MacSigningPlan.validateIdentity(mode: .development, identity: String(repeating: "a", count: 40)) + let restricted = ["com.apple.developer.icloud-services": ["CloudDocuments"]] + let rejected = try root() + defer { try? FileManager.default.removeItem(at: rejected) } + #expect(throws: (any Error).self) { try handoff(rejected, entitlements: restricted) } + // The signer still rejects restricted entitlements in a handoff edited after the build. + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(directory, entitlements: ["com.apple.security.app-sandbox": true]) + let manifestURL = MacArtifact.signingManifestURL(for: app) + let manifest = try JSONDecoder().decode(DeferredSigningManifest.self, from: Data(contentsOf: manifestURL)) + let input = directory.appendingPathComponent(try #require(manifest.products.first?.entitlementsPath)) + try PropertyListSerialization.data(fromPropertyList: restricted, format: .xml, options: 0).write(to: input) + try MacArtifact.write(bundle: app, signingManifest: manifestURL) + #expect(throws: (any Error).self) { try MacSigningPlan.make(bundle: app, mode: .adHoc, identity: nil) } + } + + @Test func dryRunDoesNotCallToolsOrCreateOutput() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(directory) + let before = try ArtifactInventory(directory: directory) + try await MacSigningOperation.run(bundle: app, output: directory.appendingPathComponent("signed"), + mode: .adHoc, identity: nil, dryRun: true) + try before.verify(directory: directory) + } + + @Test func signsCopyAndReceiptDetectsLaterMutation() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(directory, entitlements: ["com.apple.security.app-sandbox": true]) + let original = try ArtifactInventory(directory: app) + let output = directory.appendingPathComponent("signed") + let captured = LockIsolated(nil) + let calls = LockIsolated<[[String]]>([]) + try await withDependencies { + $0.macHostClient = MacHostClient(isMacOS: { true }, capture: { _, arguments in + calls.withValue { $0.append(arguments) } + if arguments.contains("--sign") { + let input = try #require(arguments.firstIndex(of: "--entitlements")) + let entitlementData = try Data(contentsOf: URL(fileURLWithPath: arguments[input + 1])) + captured.setValue(entitlementData) + let target = URL(fileURLWithPath: try #require(arguments.last)) + #expect(target.path != app.path) + try Data("signature".utf8).write(to: target.appendingPathComponent("Contents/mock-signature")) + } + if arguments.contains("--entitlements"), !arguments.contains("--sign") { + return MacToolOutput(output: captured.value ?? Data(), error: Data()) + } + return MacToolOutput(output: Data(), error: Data("Signature=adhoc\n".utf8)) + }) + } operation: { + try await MacSigningOperation.run(bundle: app, output: output, mode: .adHoc, identity: nil, dryRun: false) + } + try original.verify(directory: app) + try MacArtifact.verify(bundle: app) + let signed = output.appendingPathComponent("App.app") + let receipt = try MacSigningReceipt.verify(bundle: signed) + #expect(receipt.mode == .adHoc) + #expect(receipt.signatures.count == 1) + #expect(receipt.inputArtifactSHA256 == (try ArtifactInventory.hash(MacArtifact.manifestURL(for: app)))) + #expect(!calls.value.contains { $0.contains("--deep") }) + try Data("tampered".utf8).write(to: signed.appendingPathComponent("Contents/mock-signature")) + #expect(throws: (any Error).self) { try MacSigningReceipt.verify(bundle: signed) } + } + + @Test func signingFailureLeavesNoOutputOrStagingAndPreservesSource() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(directory) + let before = try ArtifactInventory(directory: directory) + let output = directory.appendingPathComponent("signed") + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.macHostClient = MacHostClient(isMacOS: { true }, capture: { _, _ in + throw CocoaError(.fileWriteUnknown) + }) + } operation: { + try await MacSigningOperation.run(bundle: app, output: output, mode: .adHoc, identity: nil, dryRun: false) + } + } + try before.verify(directory: directory) + } + + @Test func existingOutputAndWrongEffectiveEntitlementsFailClosed() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(directory, entitlements: ["com.apple.security.app-sandbox": true]) + await #expect(throws: (any Error).self) { + try await MacSigningOperation.run(bundle: app, output: directory, mode: .adHoc, identity: nil, dryRun: true) + } + let output = directory.appendingPathComponent("signed") + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.macHostClient = MacHostClient(isMacOS: { true }, capture: { _, _ in + MacToolOutput(output: Data(), error: Data("Signature=adhoc\n".utf8)) + }) + } operation: { + try await MacSigningOperation.run(bundle: app, output: output, mode: .adHoc, identity: nil, dryRun: false) + } + } + #expect(!FileManager.default.fileExists(atPath: output.path)) + try MacArtifact.verify(bundle: app) + } + + @Test func outputThroughSymlinkIntoSourceIsRejected() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(directory) + let alias = directory.appendingPathComponent("alias") + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: app) + await #expect(throws: (any Error).self) { + try await MacSigningOperation.run(bundle: app, output: alias.appendingPathComponent("signed"), + mode: .adHoc, identity: nil, dryRun: true) + } + try MacArtifact.verify(bundle: app) + } + + @Test func entitlementInputsAreFrozenAndExtraCodeHasNoInheritedEntitlements() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(directory, entitlements: ["com.apple.security.app-sandbox": true]) + let extra = app.appendingPathComponent("Contents/MacOS/extra") + try MacArtifactTests.binary().write(to: extra) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: extra.path) + try MacArtifact.write(bundle: app, signingManifest: directory.appendingPathComponent("App.signing.json")) + let plan = try MacSigningPlan.make(bundle: app, mode: .adHoc, identity: nil) + #expect(plan.targets.map(\.path) == ["Contents/MacOS/extra", ""]) + #expect(plan.targets.first?.entitlements == nil) + let entitlements = try #require(plan.targets.last?.entitlements) + let manifest = try JSONDecoder().decode(DeferredSigningManifest.self, + from: Data(contentsOf: directory.appendingPathComponent("App.signing.json"))) + let input = try #require(manifest.products.first?.entitlementsPath) + try Data("changed".utf8).write(to: directory.appendingPathComponent(input)) + #expect(plan.targets.last?.entitlements == entitlements) + #expect(throws: (any Error).self) { try MacSigningPlan.make(bundle: app, mode: .adHoc, identity: nil) } + } + + @Test(arguments: ["success", "certificate", "runtime", "timestamp", "category"]) + func certificateEvidenceMustMatchPolicy(failure: String) async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(directory) + let certificate = Data("mock certificate from native codesign extraction".utf8) + let identity = failure == "certificate" ? String(repeating: "A", count: 40) + : MacSigningPlan.certificateFingerprint(certificate) + let output = directory.appendingPathComponent("signed") + let metadata = "Authority=\(failure == "category" ? "Apple Development:" : "Developer ID Application:") Example\n" + + "TeamIdentifier=TEAM12345\n" + + (failure == "runtime" ? "" : "CodeDirectory v=20500 flags=0x10000(runtime)\n") + + (failure == "timestamp" ? "" : "Timestamp=Oct 13, 2026 at 12:00:00\n") + let calls = LockIsolated<[[String]]>([]) + let client = MacHostClient(isMacOS: { true }, capture: { _, arguments in + calls.withValue { $0.append(arguments) } + if let index = arguments.firstIndex(of: "--extract-certificates") { + try certificate.write(to: URL(fileURLWithPath: arguments[index + 1] + "0")) + } + return MacToolOutput(output: Data(), error: Data(metadata.utf8)) + }) + if failure == "success" { + try await withDependencies { $0.macHostClient = client } operation: { + try await MacSigningOperation.run(bundle: app, output: output, mode: .developerID, + identity: identity, dryRun: false) + } + let receipt = try MacSigningReceipt.verify(bundle: output.appendingPathComponent("App.app")) + #expect(receipt.signatures.first?.certificateSHA1 == identity) + #expect(receipt.signatures.first?.teamIdentifier == "TEAM12345") + #expect(receipt.signatures.first?.hardenedRuntime == true) + let signing = try #require(calls.value.first { $0.contains("--sign") }) + #expect(signing.contains("--timestamp")) + #expect(signing.contains("runtime")) + } else { + await #expect(throws: (any Error).self) { + try await withDependencies { $0.macHostClient = client } operation: { + try await MacSigningOperation.run(bundle: app, output: output, mode: .developerID, + identity: identity, dryRun: false) + } + } + #expect(!FileManager.default.fileExists(atPath: output.path)) + } + try MacArtifact.verify(bundle: app) + } + + #if os(macOS) + @Test func nativeAdHocSigningPreservesSourceAndRejectsTampering() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = directory.appendingPathComponent("App.app") + try MacArtifactTests.nativeFixture(at: app) + #if arch(arm64) + let architecture = "arm64" + #else + let architecture = "x86_64" + #endif + let target = try ResolvedTarget.resolve(cliTarget: "\(architecture)-apple-macosx15.0") + let product = Plan.Product(target: target, type: .application, product: "App", bundleID: "test.App", + infoPlist: [:], resources: []) + let manifest = try DeferredSigningHandoff.write(plan: Plan(target: target, app: product), + bundle: app, sourceDirectory: directory) + try MacArtifact.write(bundle: app, signingManifest: manifest) + let before = try ArtifactInventory(directory: app) + let output = directory.appendingPathComponent("signed") + try await withDependencies { + $0.macHostClient = .liveValue + } operation: { + let arguments = [app.path, "--output", output.path, "--mode", "ad-hoc"] + try await MacSignCommand.parse(arguments + ["--dry-run"]).run() + #expect(!FileManager.default.fileExists(atPath: output.path)) + try await MacSignCommand.parse(arguments).run() + let signed = output.appendingPathComponent("App.app") + try await DevVerifyCommand.parse([signed.path, "--signed"]).run() + await #expect(throws: ExitCode(7)) { + try await DevRunCommand.parse(["--app", signed.path, "--", "first", "two words"]).run() + } + await #expect(throws: (any Error).self) { try await MacSignCommand.parse(arguments).run() } + try Data("tampered".utf8).write(to: signed.appendingPathComponent("Contents/unexpected.txt")) + await #expect(throws: (any Error).self) { + try await DevVerifyCommand.parse([signed.path, "--signed"]).run() + } + } + try before.verify(directory: app) + try MacArtifact.verify(bundle: app) + } + #endif +} diff --git a/Tests/XToolTests/PackContractTests.swift b/Tests/XToolTests/PackContractTests.swift new file mode 100644 index 00000000..bc26c550 --- /dev/null +++ b/Tests/XToolTests/PackContractTests.swift @@ -0,0 +1,196 @@ +import Dependencies +import Foundation +import Testing +import Yams +@testable import PackLib + +struct PackContractTests { + private func schema(_ yaml: String) throws -> PackSchema { + try PackSchema(validating: YAMLDecoder().decode(PackSchemaBase.self, from: yaml)) + } + + @Test func legacyConfigurationsRemainValid() throws { + let examples = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Examples") + let enumerator = try #require(FileManager.default.enumerator(at: examples, includingPropertiesForKeys: nil)) + var count = 0 + for case let url as URL in enumerator where url.lastPathComponent == "xtool.yml" { + let config = try schema(String(contentsOf: url, encoding: .utf8)) + #expect(config.version == .v1) + #expect(try ResolvedTarget.resolve(schema: config).platform == .iOS) + count += 1 + } + #expect(count > 0) + let target = try ResolvedTarget.resolve() + #expect(target.triple == "arm64-apple-ios") + #expect(target.deploymentTarget == "13.0") + } + + @Test func schemaVersionControlsNewFields() throws { + #expect(throws: (any Error).self) { + try schema("version: 1\norgID: test\ntarget: arm64-apple-macosx") + } + #expect(throws: (any Error).self) { + try schema("version: 3\norgID: test") + } + #expect(throws: (any Error).self) { + try schema("version: 2\norgID: test\nsource: {kind: prebuilt, path: helper, product: Wrong}") + } + } + + @Test func effectiveTargetAndDeploymentPrecedence() throws { + let config = try schema("version: 2\norgID: test\ntarget: arm64-apple-macosx\ndeploymentTarget: '15.0'") + let fromSchema = try ResolvedTarget.resolve(schema: config, packageDeploymentTargets: ["macos": "14.0"]) + #expect(fromSchema.platform == .macOS) + #expect(fromSchema.compilerTriple == "arm64-apple-macosx15.0") + let fromCLI = try ResolvedTarget.resolve(cliTarget: "x86_64-apple-ios17.0-simulator", schema: config) + #expect(fromCLI.platform == .iOSSimulator) + #expect(fromCLI.architecture == "x86_64") + #expect(fromCLI.deploymentTarget == "17.0") + let fromPackage = try ResolvedTarget.resolve(packageDeploymentTargets: ["ios": "16.0"]) + #expect(fromPackage.deploymentTarget == "16.0") + // Packages declaring an older minimum than xtool supports, such as `.iOS(.v12)`, still build. + #expect(try ResolvedTarget.resolve(packageDeploymentTargets: ["ios": "12.0"]).deploymentTarget == "13.0") + #expect(try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx", + packageDeploymentTargets: ["macos": "12.0"]).deploymentTarget == "13.0") + #expect(throws: (any Error).self) { try ResolvedTarget.resolve(cliTarget: "arm64-apple-ios12.0") } + #expect(throws: (any Error).self) { try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx12.0") } + #expect(throws: (any Error).self) { + try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx14.0", schema: config) + } + #expect(throws: (any Error).self) { + try ResolvedTarget.resolve(schema: config, packageDeploymentTargets: ["macos": "16.0"]) + } + } + + @Test(arguments: ["unknown-triple", "arm64-apple-ios-macabi", "arm64-apple-tvos", + "x86_64-apple-ios", "arm64-apple-macosx-simulator", "arm64-apple-ios17..0", + "arm64-apple-macosx15.0-extra", "arm64--ios"]) + func unsupportedTriplesReject(triple: String) { + #expect(throws: (any Error).self) { try TargetTriple(triple) } + } + + @Test func versionTwoModelsExternalInputs() throws { + let config = try schema(""" + version: 2 + orgID: test + target: arm64-apple-macosx + source: {kind: executable, product: App} + entitlementsPath: ../signing/App.entitlements + resourceCopies: + - source: agent.plist + destination: {location: contents, path: Library/LaunchAgents/test.agent.plist} + nestedProducts: + - product: Service + kind: xpcService + source: {kind: library, product: ServiceLibrary} + infoPath: Service.plist + entitlementsPath: /external/Service.entitlements + - product: Helper + kind: helperExecutable + source: {kind: prebuilt, path: /external/helper} + """) + #expect(config.source == .executable(product: "App")) + #expect(config.nestedProducts?.count == 2) + #expect(config.nestedProducts?.last?.source == .prebuilt(path: "/external/helper")) + #expect(config.entitlementsPath == "../signing/App.entitlements") + #expect(try ResolvedTarget.resolve(schema: config).platform == .macOS) + #expect(throws: (any Error).self) { + try ResolvedTarget.resolve(cliTarget: "arm64-apple-ios", schema: config) + } + } + + @Test func layoutsMatchPlatformAndProductKind() throws { + let app = BundleLayout(platform: .macOS, kind: .application, name: "App") + #expect(app.executablePath == "Contents/MacOS/App") + #expect(app.infoPlistPath == "Contents/Info.plist") + #expect(app.resourcesPath == "Contents/Resources") + #expect(app.frameworksPath == "Contents/Frameworks") + #expect(try app.path(for: .init(location: .contents, path: "Library/LaunchAgents/agent.plist")) + == "Contents/Library/LaunchAgents/agent.plist") + let ext = BundleLayout(platform: .macOS, kind: .appExtension, name: "Preview") + #expect(ext.executablePath == "Contents/PlugIns/Preview.appex/Contents/MacOS/Preview") + let service = BundleLayout(platform: .macOS, kind: .xpcService, name: "Service") + #expect(service.infoPlistPath == "Contents/XPCServices/Service.xpc/Contents/Info.plist") + let helper = BundleLayout(platform: .macOS, kind: .helperExecutable, name: "Helper") + #expect(helper.executablePath == "Contents/Helpers/Helper") + #expect(helper.infoPlistPath == nil) + #expect(helper.resourcesPath == "Contents/Resources") + let ios = BundleLayout(platform: .iOS, kind: .appExtension, name: "Extension") + #expect(ios.executablePath == "PlugIns/Extension.appex/Extension") + #expect(ios.resourcesPath == "PlugIns/Extension.appex") + } + + @Test(arguments: ["", "/absolute", "../escape", "a/../../escape", "a//b", "a/./b", "a\\b", "a\0b"]) + func unsafeResourceDestinationsReject(path: String) { + #expect(throws: (any Error).self) { try ResourceDestination(path: path).validate() } + } + + private func product(_ name: String, kind: Plan.ProductType, target: ResolvedTarget) -> Plan.Product { + Plan.Product( + target: target, + type: kind, + product: name, + bundleID: "test.\(name)", + infoPlist: [:], + resources: [], + entitlementsPath: "/external/\(name).entitlements" + ) + } + + @Test func signingManifestKeepsProductAssociations() throws { + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx") + let plan = try Plan( + target: target, + app: product("App", kind: .application, target: target), + extensions: [product("Preview", kind: .appExtension, target: target)], + nestedProducts: [product("Service", kind: .xpcService, target: target), + product("Helper", kind: .helperExecutable, target: target)] + ) + let data = try JSONEncoder().encode(DeferredSigningManifest(plan: plan)) + let manifest = try JSONDecoder().decode(DeferredSigningManifest.self, from: data) + #expect(manifest.version == 1) + #expect(manifest.status == .deferred) + #expect(manifest.target == target) + #expect(manifest.products.count == 4) + #expect(manifest.products[2].bundlePath == "Contents/XPCServices/Service.xpc") + #expect(manifest.products[2].entitlementsPath == "/external/Service.entitlements") + #expect(manifest.products[3].executablePath == "Contents/Helpers/Helper") + } + + @Test func inconsistentPlansReject() throws { + let ios = try ResolvedTarget.resolve() + let mac = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx") + #expect(throws: (any Error).self) { + try Plan(target: ios, app: product("App", kind: .application, target: mac)) + } + #expect(throws: (any Error).self) { + try Plan(target: ios, app: product("App", kind: .application, target: ios), + extensions: [product("App", kind: .appExtension, target: ios)]) + } + } + + @Test func packerUsesInjectedBuilder() async throws { + struct BuildStopped: Error {} + let target = try ResolvedTarget.resolve() + let plan = try Plan(target: target, app: product("App", kind: .application, target: target)) + let settings = try await BuildSettings(configuration: .debug, triple: target.triple, buildSystem: .swiftPM) + await #expect(throws: BuildStopped.self) { + try await withDependencies { + $0.productBuilder = ProductBuilderClient { _, _ in throw BuildStopped() } + } operation: { + try await Packer(buildSettings: settings, plan: plan).pack() + } + } + } + + @Test func macOSXcodeGenerationRejectsBeforeWriting() async throws { + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx") + let plan = try Plan(target: target, app: product("App", kind: .application, target: target)) + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + await #expect(throws: (any Error).self) { + try await XcodePacker(plan: plan).createProject(at: root) + } + #expect(!FileManager.default.fileExists(atPath: root.path)) + } +} diff --git a/Tests/XToolTests/PackerTests.swift b/Tests/XToolTests/PackerTests.swift deleted file mode 100644 index 5aabda83..00000000 --- a/Tests/XToolTests/PackerTests.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Testing -@testable import PackLib - -@Test func cfBundleSupportedPlatformsFollowsBuildTriple() { - #expect(Packer.cfBundleSupportedPlatforms(for: "arm64-apple-ios") == ["iPhoneOS"]) - #expect(Packer.cfBundleSupportedPlatforms(for: "arm64-apple-ios-simulator") == ["iPhoneSimulator"]) - - #expect(Packer.cfBundleSupportedPlatforms(for: "arm64-apple-ios17.0") == ["iPhoneOS"]) - #expect(Packer.cfBundleSupportedPlatforms(for: "arm64-apple-ios17.0-simulator") == ["iPhoneSimulator"]) - #expect(Packer.cfBundleSupportedPlatforms(for: "x86_64-apple-ios-simulator") == ["iPhoneSimulator"]) - #expect(Packer.cfBundleSupportedPlatforms(for: "arm64-apple-macosx") == ["MacOSX"]) - #expect(Packer.cfBundleSupportedPlatforms(for: "x86_64-apple-macosx") == ["MacOSX"]) - - #expect(Packer.cfBundleSupportedPlatforms(for: "unknown-triple") == ["iPhoneOS"]) -} diff --git a/Tests/XToolTests/PlannerTests.swift b/Tests/XToolTests/PlannerTests.swift new file mode 100644 index 00000000..7d55dd72 --- /dev/null +++ b/Tests/XToolTests/PlannerTests.swift @@ -0,0 +1,414 @@ +import Dependencies +import Foundation +import Testing +import Yams +@testable import PackLib + +struct PlannerTests { + private struct Package: Sendable { + let description: Data + let manifest: Data + } + + private func json(_ value: Any) throws -> Data { try JSONSerialization.data(withJSONObject: value) } + + private func package( + _ name: String = "Root", products: [[String: Any]]? = nil, targets: [[String: Any]]? = nil, + dependencies: [[String: Any]] = [], platforms: [[String: String]] = [], path: String? = nil + ) throws -> Package { + let targets = targets ?? [target("App")] + return try Package( + description: json([ + "name": name, "path": path as Any? ?? NSNull(), "products": products ?? [product("App")], + "platforms": platforms, "targets": targets.map { target -> [String: Any] in + var described = target + described.removeValue(forKey: "dependencies") + return described + }, + ]), + manifest: json(["targets": targets, "dependencies": dependencies]) + ) + } + + private func product(_ name: String, kind: String = "automatic", target: String? = nil) -> [String: Any] { + ["name": name, "targets": [target ?? name], + "type": kind == "executable" ? ["executable": NSNull()] : ["library": [kind]]] + } + + private func target( + _ name: String, kind: String = "regular", resources: Bool = false, dependencies: [[String: Any]] = [] + ) -> [String: Any] { + ["name": name, "type": kind, "module_type": kind == "binary" ? "BinaryTarget" : "SwiftTarget", + "resources": resources ? [["path": "message.txt"]] : [], "dependencies": dependencies] + } + + private func reference(_ identity: String, alias: String? = nil) -> [String: Any] { + var value = ["identity": identity] + value["nameForTargetDependencyResolutionOnly"] = alias + return ["fileSystem": [value]] + } + + private func dependency(_ name: String, package: String? = nil, platforms: [String]? = nil) -> [String: Any] { + let condition: Any = platforms.map { ["platformNames": $0] } ?? NSNull() + return ["product": [name, package as Any? ?? NSNull(), NSNull(), condition]] + } + + private func configuration(_ text: String = "") throws -> PackSchema { + try PackSchema(validating: YAMLDecoder().decode(PackSchemaBase.self, from: "version: 2\norgID: test\n" + text)) + } + + private func plan( + _ packages: [String: Package], schema: PackSchema = .default, triple: String = "arm64-apple-ios", + clientOverride: PackageInspectionClient? = nil + ) async throws -> Plan { + let dependencyTree = try json([ + "identity": "root", "path": "/root", "dependencies": packages.keys.filter { $0 != "root" }.map { + ["identity": $0, "path": "/\($0)", "dependencies": []] as [String: Any] + }, + ]) + let client = PackageInspectionClient( + dependencies: { _ in dependencyTree }, + describe: { settings in try #require(packages[URL(fileURLWithPath: settings.packagePath).lastPathComponent]).description }, + manifest: { settings in try #require(packages[URL(fileURLWithPath: settings.packagePath).lastPathComponent]).manifest } + ) + let settings = try await BuildSettings(configuration: .release, triple: triple, buildSystem: .swiftPM, packagePath: "/root") + return try await withDependencies { + $0.packageInspection = clientOverride ?? client + } operation: { + try await Planner(buildSettings: settings, schema: schema).createPlan() + } + } + + @Test func inspectionConcurrencyIsBounded() async throws { + actor Gauge { + var current = 0, peak = 0 + func enter() { current += 1; peak = max(peak, current) } + func leave() { current -= 1 } + } + let gauge = Gauge() + let names = (0..<(Planner.inspectionLimit * 3)).map { "dep\($0)" } + let root = try package() + let dependencyTree = try json([ + "identity": "root", "path": "/root", + "dependencies": names.map { ["identity": $0, "path": "/\($0)", "dependencies": []] as [String: Any] }, + ]) + let client = PackageInspectionClient( + dependencies: { _ in dependencyTree }, + describe: { _ in + await gauge.enter() + try await Task.sleep(for: .milliseconds(5)) + await gauge.leave() + return root.description + }, + manifest: { _ in root.manifest } + ) + _ = try await plan(["root": root], clientOverride: client) + #expect(await gauge.peak <= Planner.inspectionLimit) + #expect(await gauge.peak > 1) + } + + @Test func pinnedDependencyInspectionIsReused() async throws { + actor Calls { + var paths: [String] = [] + func record(_ path: String) { paths.append(path) } + func take() -> [String] { defer { paths = [] }; return paths.sorted() } + } + let calls = Calls() + let root = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let packages = try [root.path: package(), "/pinned": package("Pinned"), "/local": package("Local")] + func plan(version: String) async throws { + let tree = try json([ + "identity": "root", "url": root.path, "version": "unspecified", "path": root.path, "dependencies": [ + ["identity": "pinned", "url": "https://example.com/pinned", "version": version, "traits": ["default"], + "path": "/pinned", "dependencies": []] as [String: Any], + ["identity": "local", "url": "/local", "version": "unspecified", "path": "/local", "dependencies": []], + ], + ]) + let client = PackageInspectionClient( + dependencies: { _ in tree }, + describe: { settings in + await calls.record(settings.packagePath) + return try #require(packages[settings.packagePath]).description + }, + manifest: { settings in try #require(packages[settings.packagePath]).manifest }, + toolchain: { "6.2.0" } + ) + let settings = try await BuildSettings(configuration: .release, triple: "arm64-apple-ios", buildSystem: .swiftPM, + packagePath: root.path) + _ = try await withDependencies { + $0.packageInspection = client + } operation: { + try await Planner(buildSettings: settings, schema: .default).createPlan() + } + } + try await plan(version: "1.0.0") + #expect(await calls.take() == ["/local", "/pinned", root.path].sorted()) + try await plan(version: "1.0.0") + #expect(await calls.take() == ["/local", root.path].sorted()) + try await plan(version: "1.1.0") + #expect(await calls.take() == ["/local", "/pinned", root.path].sorted()) + } + + @Test func sameNamedTargetsInDifferentPackagesBothContributeResources() async throws { + let root = try package(targets: [target("App", dependencies: [ + dependency("First", package: "one"), dependency("Second", package: "two"), + ])], dependencies: [reference("one"), reference("two")]) + let one = try package("FirstPackage", products: [product("First", target: "Shared")], + targets: [target("Shared", resources: true)]) + let two = try package("SecondPackage", products: [product("Second", target: "Shared")], + targets: [target("Shared", resources: true)]) + let app = try await plan(["root": root, "one": one, "two": two]).app + #expect(Set(app.resources) == [.bundle(package: "FirstPackage", target: "Shared"), + .bundle(package: "SecondPackage", target: "Shared")]) + } + + @Test(arguments: ["arm64-apple-macosx", "arm64-apple-ios", "x86_64-apple-ios-simulator"]) + func conditionsUseTargetRatherThanHost(triple: String) async throws { + let root = try package(targets: [target("App", dependencies: [ + dependency("MacLibrary", package: "mac", platforms: ["macos"]), + dependency("IOSLibrary", package: "ios", platforms: ["ios"]), + dependency("MissingHostLibrary", package: "absent", platforms: ["linux"]), + ["target": ["MacLocal", ["platformNames": ["macos"]]]], + ["byName": ["IOSLocal", ["platformNames": ["ios"]]]], + ]), target("MacLocal", resources: true), target("IOSLocal", resources: true)], + dependencies: [reference("mac"), reference("ios")]) + let mac = try package("Mac", products: [product("MacLibrary", kind: "dynamic")], + targets: [target("MacLibrary", resources: true)]) + let ios = try package("IOS", products: [product("IOSLibrary", kind: "static")], + targets: [target("IOSLibrary", resources: true)]) + let app = try await plan(["root": root, "mac": mac, "ios": ios], triple: triple).app + if app.platform == .macOS { + #expect(Set(app.resources) == [.bundle(package: "Root", target: "MacLocal"), + .bundle(package: "Mac", target: "MacLibrary"), .library(name: "MacLibrary")]) + } else { + #expect(Set(app.resources) == [.bundle(package: "Root", target: "IOSLocal"), + .bundle(package: "IOS", target: "IOSLibrary")]) + } + } + + @Test func macroDependenciesNeverContributeResourcesOrResolveHostProducts() async throws { + let root = try package(targets: [target("App", dependencies: [["target": ["Macro", NSNull()]]]), + target("Macro", kind: "macro", resources: true, dependencies: [ + dependency("SwiftSyntaxMacros", package: "host-only"), + ])]) + #expect(try await plan(["root": root]).app.resources.isEmpty) + } + + @Test(arguments: ["automatic", "static", "dynamic", "executable"]) + func explicitProductSelectionResolvesSourceKinds(kind: String) async throws { + let root = try package(products: [product("Selected", kind: kind), product("Other")], + targets: [target("Selected"), target("Other")]) + let app = try await plan(["root": root], schema: configuration("product: Selected")).app + #expect(app.product == "Selected") + #expect(app.resources == (kind == "dynamic" ? [.library(name: "Selected")] : [])) + #expect(app.resolvedSource == (kind == "executable" ? .executable(product: "Selected") : .library(product: "Selected"))) + } + + @Test func explicitSourceAllowsDistinctBundleName() async throws { + let root = try package(products: [product("CLI", kind: "executable")], targets: [target("CLI")]) + let app = try await plan(["root": root], schema: configuration(""" + product: BundleName + source: {kind: executable, product: CLI} + """)).app + #expect(app.product == "BundleName") + #expect(app.resolvedSource == .executable(product: "CLI")) + #expect(app.infoPlist["CFBundleExecutable"] as? String == "BundleName") + } + + @Test func macOSDefaultsUseSelectedPackageDeploymentAndExcludeIOSKeys() async throws { + let root = try package(platforms: [["name": "ios", "version": "19.0"], ["name": "macos", "version": "16.2"]]) + let app = try await plan(["root": root], triple: "arm64-apple-macosx").app + #expect(app.deploymentTarget == "16.2") + #expect(app.infoPlist["LSMinimumSystemVersion"] as? String == "16.2") + for key in ["MinimumOSVersion", "UIDeviceFamily", "UISupportedInterfaceOrientations", "UILaunchScreen"] { + #expect(app.infoPlist[key] == nil) + } + } + + private func plist(_ dictionary: Any, in root: URL, name: String) throws -> String { + let url = root.appendingPathComponent(name + ".plist") + try PropertyListSerialization.data(fromPropertyList: dictionary, format: .xml, options: 0).write(to: url) + return url.path + } + + private func temporaryRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + @Test func legacyIOSLibraryAndPlistOverridesRemainSupported() async throws { + let directory = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: directory) } + let path = try plist(["CFBundleIdentifier": "custom.identity", "CFBundleDisplayName": "Custom Name", + "UIDeviceFamily": [2], "MinimumOSVersion": "17.0", "NSExtension": ["Custom": true]], + in: directory, name: "App") + let schema = try PackSchema(validating: .init(version: .v1, orgID: "test", infoPath: path)) + let app = try await plan(["root": package(platforms: [["name": "ios", "version": "16.0"]])], schema: schema).app + #expect(app.resolvedSource == .library(product: "App")) + #expect(app.bundleID == "custom.identity") + #expect(app.infoPlist["CFBundleDisplayName"] as? String == "Custom Name") + #expect(app.infoPlist["MinimumOSVersion"] as? String == "17.0") + #expect(app.infoPlist["UIDeviceFamily"] as? [Int] == [2]) + #expect(app.infoPlist["NSExtension"] as? [String: Bool] == ["Custom": true]) + #expect(app.infoPlist["UILaunchScreen"] != nil) + } + + @Test func nestedProductsRetainCallerDictionariesAndSources() async throws { + let directory = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: directory) } + let appPlist = try plist(["CFBundleIdentifier": "caller.App"], in: directory, name: "App") + let extPlist = try plist( + ["CFBundleIdentifier": "caller.Extension", "NSExtension": ["NSExtensionPointIdentifier": "test"]], + in: directory, name: "Extension" + ) + let servicePlist = try plist( + ["XPCService": ["ServiceType": "Application", "RunLoopType": "dispatch_main"]], + in: directory, name: "Service" + ) + let schema = try configuration(""" + product: App + infoPath: \(appPlist) + extensions: + - product: Extension + source: {kind: library, product: ExtensionLibrary} + infoPath: \(extPlist) + nestedProducts: + - product: Service + kind: xpcService + source: {kind: executable, product: ServiceExecutable} + infoPath: \(servicePlist) + - product: Helper + kind: helperExecutable + source: {kind: prebuilt, path: /external/helper} + entitlementsPath: /external/helper.entitlements + """) + let root = try package(products: [product("App"), product("ExtensionLibrary"), + product("ServiceExecutable", kind: "executable")], + targets: [target("App"), target("ExtensionLibrary"), target("ServiceExecutable")]) + let plan = try await plan(["root": root], schema: schema, triple: "arm64-apple-macosx") + #expect(plan.extensions[0].bundleID == "caller.Extension") + #expect(plan.extensions[0].infoPlist["NSExtension"] as? [String: String] == ["NSExtensionPointIdentifier": "test"]) + #expect(plan.nestedProducts[0].bundleID == "caller.App.Service") + #expect(plan.nestedProducts[0].infoPlist["XPCService"] as? [String: String] + == ["ServiceType": "Application", "RunLoopType": "dispatch_main"]) + #expect(plan.nestedProducts[1].resolvedSource == .prebuilt(path: "/external/helper")) + #expect(plan.nestedProducts[1].entitlementsPath == "/external/helper.entitlements") + #expect(plan.allProducts.allSatisfy { $0.target == plan.target }) + } + + @Test func resourcesAreDeduplicatedAndExplicitDestinationsSurvive() async throws { + let root = try package(targets: [target("App", resources: true, dependencies: [ + ["target": ["Local", NSNull()]], ["byName": ["Local", NSNull()]], ["target": ["Binary", NSNull()]], + ]), target("Local", resources: true), target("Binary", kind: "binary")]) + let schema = try configuration(""" + resources: [Assets] + resourceCopies: + - source: agent.plist + destination: {location: contents, path: Library/LaunchAgents/agent.plist} + """) + let app = try await plan(["root": root], schema: schema, triple: "arm64-apple-macosx").app + #expect(app.resources.count == 5) + #expect(Set(app.resources) == [.bundle(package: "Root", target: "App"), .bundle(package: "Root", target: "Local"), + .binaryTarget(name: "Binary"), .root(source: "Assets"), + .copy(.init(source: "agent.plist", destination: .init( + location: .contents, path: "Library/LaunchAgents/agent.plist")))]) + #expect(app.resources.contains(.binaryTarget(name: "Binary"))) + } + + @Test func localXCFrameworkBinaryTargetsRecordTheirPath() async throws { + func binary(_ name: String, path: String) -> [String: Any] { + var value = target(name, kind: "binary") + value["path"] = path + return value + } + let root = try package(targets: [ + target("App", dependencies: [["target": ["Local", NSNull()]], ["target": ["Zipped", NSNull()]]]), + binary("Local", path: "Vendor/Local.xcframework"), binary("Zipped", path: "Vendor/Zipped.zip"), + ], path: "/root") + let app = try await plan(["root": root], triple: "arm64-apple-macosx").app + #expect(Set(app.resources) == [.binaryTarget(name: "Local", xcframework: "/root/Vendor/Local.xcframework"), + .binaryTarget(name: "Zipped")]) + } + + @Test func packageAliasesDisambiguateSameNamedProducts() async throws { + let root = try package(targets: [target("App", dependencies: [dependency("Shared", package: "Alias")])], + dependencies: [reference("one", alias: "Alias"), reference("two")]) + let one = try package("One", products: [product("Shared")], targets: [target("Shared", resources: true)]) + let two = try package("Two", products: [product("Shared")], targets: [target("Shared", resources: true)]) + #expect(try await plan(["root": root, "one": one, "two": two]).app.resources == [.bundle(package: "One", target: "Shared")]) + } + + @Test(arguments: ["product: Missing", "source: {kind: executable, product: App}", + "source: {kind: library, product: Missing}", "source: {kind: executable, product: ../escape}"]) + func invalidProductSelectionsReject(text: String) async throws { + await #expect(throws: (any Error).self) { + try await plan(["root": package()], schema: configuration(text)) + } + } + + @Test func ambiguousLibrariesMissingTargetsAndWrongPlistTypesReject() async throws { + let ambiguous = try package(products: [product("App"), product("Other")], targets: [target("App"), target("Other")]) + await #expect(throws: (any Error).self) { try await plan(["root": ambiguous]) } + let missing = try package(targets: []) + await #expect(throws: (any Error).self) { try await plan(["root": missing]) } + let directory = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: directory) } + let path = try plist(["not a dictionary"], in: directory, name: "Invalid") + await #expect(throws: (any Error).self) { + try await plan(["root": package()], schema: configuration("infoPath: \(path)")) + } + } + + #if os(macOS) + @Test func liveSwiftPMInspectionPreservesConditionsAndAliases() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + func write(_ contents: String, path: String) throws { + let url = root.appendingPathComponent(path) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(contents.utf8).write(to: url) + } + try write(""" + // swift-tools-version: 6.0 + import PackageDescription + let package = Package( + name: "LivePlanner", platforms: [.macOS("16.0"), .iOS("19.0")], + products: [.executable(name: "CLI", targets: ["Entry"])], + dependencies: [.package(name: "SupportAlias", path: "Dependency")], + targets: [ + .executableTarget(name: "Entry", dependencies: [ + .target(name: "IOSOnly", condition: .when(platforms: [.iOS])), + .product(name: "SupportLibrary", package: "SupportAlias", condition: .when(platforms: [.macOS])), + ]), + .target(name: "IOSOnly", resources: [.copy("message.txt")]), + ] + ) + """, path: "Package.swift") + try write("print(\"fixture\")", path: "Sources/Entry/main.swift") + try write("public struct IOSOnly {}", path: "Sources/IOSOnly/IOSOnly.swift") + try write("ios resource", path: "Sources/IOSOnly/message.txt") + try write(""" + // swift-tools-version: 6.0 + import PackageDescription + let package = Package(name: "Support", products: [.library(name: "SupportLibrary", targets: ["Support"])], + targets: [.target(name: "Support", resources: [.copy("message.txt")])]) + """, path: "Dependency/Package.swift") + try write("public struct Support {}", path: "Dependency/Sources/Support/Support.swift") + try write("mac resource", path: "Dependency/Sources/Support/message.txt") + let settings = try await BuildSettings(configuration: .release, triple: "arm64-apple-macosx", buildSystem: .swiftPM, + packagePath: root.path) + let schema = try configuration("product: CLI") + let plan = try await withDependencies { + $0.packageInspection = .liveValue + } operation: { + try await Planner(buildSettings: settings, schema: schema).createPlan() + } + #expect(plan.app.resolvedSource == .executable(product: "CLI")) + #expect(plan.app.deploymentTarget == "16.0") + #expect(plan.app.resources == [.bundle(package: "Support", target: "Support")]) + } + #endif + +} diff --git a/Tests/XToolTests/ProductBuilderTests.swift b/Tests/XToolTests/ProductBuilderTests.swift new file mode 100644 index 00000000..00a92d0c --- /dev/null +++ b/Tests/XToolTests/ProductBuilderTests.swift @@ -0,0 +1,355 @@ +import Dependencies +import Foundation +import Testing +@testable import PackLib +import XUtils + +struct ProductBuilderTests { + private func product( + _ name: String, source: ProductSource, kind: Plan.ProductType = .application, target: ResolvedTarget + ) -> Plan.Product { + Plan.Product(target: target, source: source, type: kind, product: name, + bundleID: "test.\(name)", infoPlist: [:], resources: []) + } + + private func temporaryRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private actor Recorder { + var builds: [(BuildSettings, [String])] = [] + var queries: [(BuildSettings, [String])] = [] + let output: URL + + init(output: URL) { self.output = output } + + func build(_ settings: BuildSettings, arguments: [String]) throws { + builds.append((settings, arguments)) + // Without --product, SwiftPM builds every target of the package: the wrapper's products. + let sources = URL(fileURLWithPath: settings.packagePath).appendingPathComponent("Sources").path + let names = try arguments.firstIndex(of: "--product").map { [arguments[$0 + 1]] } + ?? FileManager.default.contentsOfDirectory(atPath: sources) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + for name in names { + let executable = output.appendingPathComponent(name) + try Data("fixture".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + } + } + + func binaryPath(_ settings: BuildSettings, arguments: [String]) -> String { + queries.append((settings, arguments)) + return output.path + "\n" + } + + nonisolated var runner: ProductBuildRunner { + ProductBuildRunner(build: { try await self.build($0, arguments: $1) }, + binaryPath: { await self.binaryPath($0, arguments: $1) }, + sdkLinkerArguments: { _ in [] }) + } + } + + @Test(arguments: ["arm64-apple-macosx15.0", "arm64-apple-macos15.0", "arm64-apple-macos", "arm64-apple-macosx"]) + func sdkIdentityAndDeploymentAreSeparate(triple: String) async throws { + let target = try ResolvedTarget.resolve(cliTarget: triple, packageDeploymentTargets: ["macos": "15.0"]) + let settings = try await BuildSettings(configuration: .release, triple: triple) + let resolved = try settings.withTarget(target) + #expect(resolved.buildSystem == .swiftPM) + #expect(resolved.sdkOptions == ["--swift-sdk", "arm64-apple-macosx", "--triple", "arm64-apple-macosx15.0"]) + #expect(resolved.sdkEnvironment["SDKROOT"] != nil) + } + + @Test func packageMinimumAndUserOptionsSurvive() async throws { + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-ios", packageDeploymentTargets: ["ios": "17.2"]) + let settings = try await BuildSettings(configuration: .debug, triple: target.triple, buildSystem: .swiftPM, + options: ["-Xswiftc", "-DUSER_OPTION", "--jobs", "2"]) + let resolved = try settings.withTarget(target) + #expect(resolved.sdkOptions == ["--swift-sdk", "arm64-apple-ios", "--triple", "arm64-apple-ios17.2"]) + #expect(resolved.customOptions == settings.customOptions) + #expect(resolved.configuration == .debug) + } + + @Test(arguments: [("arm64-apple-ios", "arm64-apple-ios17.2"), ("arm64-apple-ios18.0", "arm64-apple-ios18.0")]) + func swiftBuildKeepsTheRequestedTriple(triple: String, planned: String) async throws { + // A versioned triple makes SwiftBuild compile every package for that deployment target; + // dependencies must keep their own minimum, so the plan's version stays out of `--triple`. + let target = try ResolvedTarget.resolve(cliTarget: triple, packageDeploymentTargets: ["ios": "17.2"]) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, buildSystem: .swiftBuild) + let resolved = try settings.withTarget(target) + #expect(target.compilerTriple == planned) + #expect(resolved.sdkOptions == settings.sdkOptions) + #expect(Array(resolved.sdkOptions.prefix(2)) == ["--triple", triple]) + } + + @Test(arguments: [BuildConfiguration.debug, .release]) + func directExecutablesUseBackendPaths(configuration: BuildConfiguration) async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-ios17.0") + let plan = try Plan(target: target, app: product("App", source: .executable(product: "ActualExecutable"), target: target)) + let settings = try await BuildSettings(configuration: configuration, triple: target.triple, buildSystem: .swiftPM, + packagePath: root.path, options: ["--jobs", "2"]) + let output = root.appendingPathComponent("backend-selected/\(configuration.rawValue)") + let recorder = Recorder(output: output) + let artifacts = try await withDependencies { + $0.productBuildRunner = recorder.runner + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + #expect(artifacts.directory.path == output.path) + #expect(artifacts.executables["App"] == output.appendingPathComponent("ActualExecutable")) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("xtool/.xtool-tmp/Package.swift").path)) + let builds = await recorder.builds + let queries = await recorder.queries + #expect(builds.count == 1) + #expect(builds[0].0.packagePath == root.path) + #expect(builds[0].0.customOptions == ["--jobs", "2"]) + #expect(builds[0].0.sdkOptions == ["--swift-sdk", "arm64-apple-ios", "--triple", target.compilerTriple]) + #expect(builds[0].1.contains("ActualExecutable")) + #expect(!builds[0].1.contains("-Xlinker")) + let toolsetIndex = try #require(builds[0].1.firstIndex(of: "--toolset")) + let toolsetData = try Data(contentsOf: URL(fileURLWithPath: builds[0].1[toolsetIndex + 1])) + let toolset = try #require(JSONSerialization.jsonObject(with: toolsetData) as? [String: Any]) + #expect(toolset["schemaVersion"] as? String == "1.0") + #expect(queries[0].1 == builds[0].1) + #expect(queries[0].0.configuration == configuration) + } + + @Test(arguments: ["arm64-apple-ios17.0", "arm64-apple-macosx15.0"]) + func wrappersLinkSelectedLibraryAndExtension(triple: String) async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: triple) + let plan = try Plan(target: target, app: product("App", source: .library(product: "AppLibrary"), target: target), + extensions: [product("Preview", source: .library(product: "PreviewLibrary"), + kind: .appExtension, target: target)]) + let settings = try await BuildSettings(configuration: .release, triple: triple, buildSystem: .swiftPM, + packagePath: root.path) + let recorder = Recorder(output: root.appendingPathComponent("target-output")) + let artifacts = try await withDependencies { + $0.productBuildRunner = recorder.runner + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + let manifest = try String(contentsOf: root.appendingPathComponent("xtool/.xtool-tmp/Package.swift"), encoding: .utf8) + #expect(manifest.contains(".product(name: \"AppLibrary\"")) + #expect(manifest.contains(".product(name: \"PreviewLibrary\"")) + #expect(manifest.contains("_NSExtensionMain")) + #expect(manifest.contains("Foundation")) + #expect(manifest.contains(target.platform == .macOS ? ".macOS(\"15.0\")" : ".iOS(\"17.0\")")) + #expect(manifest.contains(target.platform == .macOS + ? "@executable_path/../../../../Frameworks" : "@executable_path/../../Frameworks")) + #expect(artifacts.executables["App"]?.lastPathComponent == "App-App") + #expect(artifacts.executables["Preview"]?.lastPathComponent == "Preview-Extension") + #expect(await recorder.builds.allSatisfy { !$0.1.contains("-Xlinker") }) + // Both products share the wrapper package: one invocation builds them, one query finds their directory. + #expect(await recorder.builds.count == 1) + #expect(await recorder.queries.count == 1) + } + + @Test func macOSResourceAccessorsBuildOnceWithStableFlags() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + var app = product("App", source: .library(product: "AppLibrary"), target: target) + app.resources = [.bundle(package: "Package", target: "Resources")] + var helper = product("Helper", source: .library(product: "AppLibrary"), kind: .xpcService, target: target) + helper.resources = app.resources + let plan = try Plan(target: target, app: app, nestedProducts: [helper]) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, buildSystem: .swiftPM, + packagePath: root.path) + let output = root.appendingPathComponent("target-output") + let accessor = output.appendingPathComponent("Resources.build/DerivedSources/resource_bundle_accessor.swift") + let generated = """ + import Foundation + extension Foundation.Bundle { + static let module: Bundle = { + let mainPath = Bundle.main.bundleURL.appendingPathComponent("Package_Resources.bundle").path + return Bundle(path: mainPath)! + }() + } + """ + @Sendable func generate() throws { + // SwiftPM generates the accessor while planning, before compiling. + try FileManager.default.createDirectory(at: accessor.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data(generated.utf8).writeIfChanged(to: accessor) + } + func build(planGenerates: Bool = true) async throws -> (plans: [[String]], builds: [[String]]) { + let recorder = Recorder(output: output) + let plans = Recorder(output: output) + _ = try await withDependencies { + $0.productBuildRunner = ProductBuildRunner( + build: { settings, arguments in + try generate() + try await recorder.build(settings, arguments: arguments) + }, + binaryPath: { await recorder.binaryPath($0, arguments: $1) }, + sdkLinkerArguments: { _ in [] }, + plan: { settings, arguments in + if planGenerates { try generate() } + try await plans.build(settings, arguments: arguments) + }) + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + return (await plans.builds.map(\.1), await recorder.builds.map(\.1)) + } + + // A clean build plans first, so the overlay adapts the accessors before anything compiles: + // one pass for both wrapped products, with the flags it plans with. + let clean = try await build() + #expect(clean.plans == clean.builds) + #expect(clean.builds.count == 1) + #expect(clean.builds.allSatisfy { $0.contains("-vfsoverlay") }) + let cleanDate = try FileManager.default.attributesOfItem(atPath: accessor.path)[.modificationDate] as? Date + + // A no-op rebuild skips planning and runs one pass with exactly the previous flags. + let rebuilt = try await build() + #expect(rebuilt.plans.isEmpty) + #expect(rebuilt.builds == clean.builds) + let rebuiltDate = try FileManager.default.attributesOfItem(atPath: accessor.path)[.modificationDate] as? Date + #expect(cleanDate == rebuiltDate) + + // If planning does not produce an accessor, the build adapts it afterwards and compiles it + // again with identical flags, so other modules do not recompile. This holds even though + // the adapted copy from the earlier build is unchanged: the build itself did not use it. + try FileManager.default.removeItem(at: output) + let fallback = try await build(planGenerates: false) + #expect(fallback.builds.count == 2) + #expect(fallback.builds[0] == fallback.builds[1]) + } + + @Test func unsupportedCombinationsReject() async throws { + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let debug = try await BuildSettings(configuration: .debug, triple: target.triple, buildSystem: .swiftPM) + #expect(throws: (any Error).self) { try debug.validateProductBuild(for: target, hostIsMacOS: false) } + try debug.validateProductBuild(for: target, hostIsMacOS: true) + let release = try await BuildSettings(configuration: .release, triple: target.triple, buildSystem: .swiftPM) + try release.validateProductBuild(for: target, hostIsMacOS: false) + await #expect(throws: (any Error).self) { + try await BuildSettings(configuration: .release, triple: target.triple, buildSystem: .swiftBuild) + } + } + + @Test(arguments: [ProductSource.prebuilt(path: "/external/helper"), .executable(product: "../escape")]) + func invalidSourcesRejectBeforeWriting(source: ProductSource) async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx") + let plan = try Plan(target: target, app: product("App", source: source, target: target)) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, buildSystem: .swiftPM, + packagePath: root.path) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.productBuildRunner = ProductBuildRunner(build: { _, _ in Issue.record("Unexpected build") }, + binaryPath: { _, _ in Issue.record("Unexpected query"); return "" }) + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + } + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + } + + @Test(arguments: ["--triple=arm64-apple-ios", "--arch", "--configuration=debug", "--product", "--show-bin-path"]) + func conflictingOptionsReject(option: String) async throws { + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx") + let settings = try await BuildSettings(configuration: .release, triple: target.triple, buildSystem: .swiftPM, + options: [option]) + #expect(throws: (any Error).self) { try settings.validateProductBuild(for: target, hostIsMacOS: false) } + } + + @Test func missingBackendExecutableRejects() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-ios") + let plan = try Plan(target: target, app: product("App", source: .executable(product: "Missing"), target: target)) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, buildSystem: .swiftPM, + packagePath: root.path) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.productBuildRunner = ProductBuildRunner(build: { _, _ in }, binaryPath: { _, _ in root.path }) + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + } + } + #if os(macOS) + @Test(arguments: [BuildConfiguration.debug, .release]) + func nativeExecutableAndLibraryFixture(configuration: BuildConfiguration) async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let manifest = """ + // swift-tools-version: 6.0 + import PackageDescription + let package = Package( + name: "BuilderFixture", + platforms: [.macOS(.v10_15)], + products: [.executable(name: "DirectExecutable", targets: ["Direct"]), + .executable(name: "ServiceExecutable", targets: ["ServiceMain"]), + .library(name: "HelperLibrary", targets: ["Helper", "Gate"])], + targets: [.executableTarget(name: "Direct", resources: [.copy("message.txt")]), + .executableTarget(name: "ServiceMain"), .target(name: "Helper"), .target(name: "Gate")] + ) + """ + try Data(manifest.utf8).write(to: root.appendingPathComponent("Package.swift")) + let direct = root.appendingPathComponent("Sources/Direct") + let helper = root.appendingPathComponent("Sources/Helper") + try FileManager.default.createDirectory(at: direct, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: helper.appendingPathComponent("include"), withIntermediateDirectories: true) + try Data("import AppKit\nprint(NSApplication.self)".utf8).write(to: direct.appendingPathComponent("main.swift")) + let service = root.appendingPathComponent("Sources/ServiceMain") + try FileManager.default.createDirectory(at: service, withIntermediateDirectories: true) + try Data("print(1)".utf8).write(to: service.appendingPathComponent("main.swift")) + // Modules compile for their package's own minimum (macOS 10.15), not the plan's 15.0: + // like swift-perception's Bindable, this backport's extension is unavailable from macOS 14. + let gate = root.appendingPathComponent("Sources/Gate") + try FileManager.default.createDirectory(at: gate, withIntermediateDirectories: true) + try Data(""" + @available(macOS, introduced: 10.15, obsoleted: 14) + public struct Backport {} + extension Backport: Equatable {} + """.utf8).write(to: gate.appendingPathComponent("Gate.swift")) + try Data("resource fixture".utf8).write(to: direct.appendingPathComponent("message.txt")) + try Data("#include \nint main() { std::vector values(3, 1); return values.at(1); }".utf8) + .write(to: helper.appendingPathComponent("Entry.cpp")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macos15.0") + // Direct and wrapped products alternate, so each build follows another package's build in the shared scratch. + let plan = try Plan(target: target, + app: product("App", source: .executable(product: "DirectExecutable"), target: target), + extensions: [product("Preview", source: .library(product: "HelperLibrary"), + kind: .appExtension, target: target)], + nestedProducts: [product("Helper", source: .library(product: "HelperLibrary"), + kind: .helperExecutable, target: target), + product("Service", source: .executable(product: "ServiceExecutable"), + kind: .xpcService, target: target)]) + let settings = try await BuildSettings(configuration: configuration, triple: target.triple, buildSystem: .swiftPM, + packagePath: root.path) + let artifacts = try await withDependencies { + $0.productBuildRunner = .liveValue + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + #expect(artifacts.executables.count == 4) + #expect(artifacts.directory.lastPathComponent == configuration.rawValue) + #expect(FileManager.default.isExecutableFile(atPath: try #require(artifacts.executables["App"]).path)) + #expect(FileManager.default.isExecutableFile(atPath: try #require(artifacts.executables["Helper"]).path)) + let expectedSDK = try #require(try await settings.withTarget(target).macOSLinkerArguments().last) + for product in plan.allProducts { + let executable = try #require(artifacts.executables[product.product]) + let slice = try #require(MachOBinary.inspect(contentsOf: executable).first) + #expect(slice.platform == 1) + #expect(slice.minimumOS == MachOBinary.packedVersion("15.0")) + #expect(slice.sdk == MachOBinary.packedVersion(expectedSDK)) + #expect(Set(product.runtimeSearchPaths).isSubset(of: Set(slice.rpaths))) + // An ARM64 linker signature is not Developer ID or release signing. + #expect(slice.hasCodeSignature) + } + let resource = artifacts.directory.appendingPathComponent("BuilderFixture_Direct.bundle/message.txt") + #expect(try String(contentsOf: resource, encoding: .utf8) == "resource fixture") + } + #endif + +} diff --git a/Tests/XToolTests/SwiftPMResourceAccessorTests.swift b/Tests/XToolTests/SwiftPMResourceAccessorTests.swift new file mode 100644 index 00000000..7c91a391 --- /dev/null +++ b/Tests/XToolTests/SwiftPMResourceAccessorTests.swift @@ -0,0 +1,108 @@ +import Dependencies +import Foundation +import Testing +@testable import PackLib + +struct SwiftPMResourceAccessorTests { + private let bundleName = "Package_Resources.bundle" + + private var generatedSource: String { + """ + import Foundation + extension Foundation.Bundle { + static nonisolated let module: Bundle = { + let mainPath = Bundle.main.bundleURL.appendingPathComponent("Package_Resources.bundle").path + let buildPath = "/build/Package_Resources.bundle" + return Bundle(path: mainPath) ?? Bundle(path: buildPath)! + }() + } + """ + } + + private func product(platform: String = "macosx", kind: Plan.ProductType = .application) throws -> Plan.Product { + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-\(platform)15.0") + return Plan.Product(target: target, type: kind, product: "App", bundleID: "test.App", infoPlist: [:], + resources: [.bundle(package: "Package", target: "Resources")]) + } + + @Test func adaptsOnlyGeneratedMainLookupAndRetainsBuildFallback() throws { + let result = try SwiftPMResourceAccessor.adapted(generatedSource, bundleName: bundleName) + #expect(result.contains("(Bundle.main.resourceURL ?? Bundle.main.bundleURL).appendingPathComponent")) + #expect(result.contains("let buildPath = \"/build/Package_Resources.bundle\"")) + #expect(result.contains("static nonisolated let module: Bundle")) + #expect(result.contains("Bundle(url: Bundle.main.bundleURL.appendingPathComponent(\"Package_Resources.bundle\"))")) + #expect(result.contains("?? Bundle(path: buildPath)!")) + } + + @Test(arguments: ["", "extension Foundation.Bundle {}", "let mainPath = Bundle.main.bundleURL"]) + func unknownGeneratedSourceRejects(source: String) { + #expect(throws: (any Error).self) { + try SwiftPMResourceAccessor.adapted(source, bundleName: bundleName) + } + } + + @Test func overlayOwnsCopiesAndLeavesGeneratedAndHostSourcesUntouched() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let manager = FileManager.default + let sources = ["Resources.build", "Resources-tool.build"].map { + root.appendingPathComponent("\($0)/DerivedSources/resource_bundle_accessor.swift") + } + for source in sources { + try manager.createDirectory(at: source.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(generatedSource.utf8).write(to: source) + } + let prepared = try SwiftPMResourceAccessor.overlay(for: [product()], in: root, output: root.appendingPathComponent("owned")) + let overlay = try #require(prepared).url + #expect(prepared?.changedSources == [sources[0]]) + let json = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: overlay)) as? [String: Any]) + #expect(json["use-external-names"] as? Bool == false) + let roots = try #require(json["roots"] as? [[String: String]]) + #expect(roots.count == 1) + #expect(roots.first?["name"] == sources[0].path) + let copy = URL(fileURLWithPath: try #require(roots.first?["external-contents"])) + #expect(try String(contentsOf: copy, encoding: .utf8).contains("Bundle.main.resourceURL")) + for source in sources { + #expect(try String(contentsOf: source, encoding: .utf8) == generatedSource) + } + // Repeated preparation stays deterministic and supports incremental/retry builds. + let first = try Data(contentsOf: overlay) + let repeated = try SwiftPMResourceAccessor.overlay(for: [product()], in: root, output: root.appendingPathComponent("owned")) + #expect(try Data(contentsOf: overlay) == first) + #expect(repeated?.changedSources.isEmpty == true) + } + + @Test func iosKeepsNativeAccessors() throws { + let missing = URL(fileURLWithPath: "/does-not-exist") + #expect(try SwiftPMResourceAccessor.overlay(for: [product(platform: "ios")], in: missing, output: missing) == nil) + } + + @Test func missingResourceAccessorRejectsBeforeWriting() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + #expect(throws: (any Error).self) { + try SwiftPMResourceAccessor.overlay(for: [product()], in: root, output: root.appendingPathComponent("owned")) + } + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + } + + @Test func prebuiltMacOSSwiftPMResourcesRejectBeforeBuilding() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + var app = try product() + app.source = .prebuilt(path: "App") + let plan = try Plan(target: app.target, app: app) + let settings = try await BuildSettings(configuration: .release, triple: app.target.triple, buildSystem: .swiftPM, + packagePath: root.path) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.productBuildRunner = ProductBuildRunner(build: { _, _ in Issue.record("Unexpected build") }, + binaryPath: { _, _ in Issue.record("Unexpected query"); return "" }) + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + } + #expect(!FileManager.default.fileExists(atPath: root.path)) + } +} diff --git a/Tests/XToolTests/SwiftSDKConfigurationTests.swift b/Tests/XToolTests/SwiftSDKConfigurationTests.swift new file mode 100644 index 00000000..3b0667e2 --- /dev/null +++ b/Tests/XToolTests/SwiftSDKConfigurationTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing +@testable import PackLib + +struct SwiftSDKConfigurationTests { + private func root() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true).standardizedFileURL + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root.resolvingSymlinksInPath() + } + + private func metadata(in bundle: URL) throws -> URL { + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) + for path in ["Developer/SDKs/MacOSX.sdk", "swift_static", "include", "lib"] { + let directory = bundle.appendingPathComponent(path, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data(path.utf8).write(to: directory.appendingPathComponent("marker.txt")) + } + let metadata = bundle.appendingPathComponent("swift-sdk.json") + try Data(#"{"schemaVersion":"1.0","rootPath":"tools/bin"}"#.utf8) + .write(to: bundle.appendingPathComponent("toolset.json")) + try JSONSerialization.data(withJSONObject: [ + "schemaVersion": "4.0", "targetTriples": [ + "arm64-apple-macosx": [ + "sdkRootPath": "Developer/SDKs/MacOSX.sdk", + "swiftResourcesPath": "/absolute/swift", + "swiftStaticResourcesPath": "swift_static", + "includeSearchPaths": ["include"], + "librarySearchPaths": ["lib"], + "toolsetPaths": ["toolset.json"], + ], + "arm64-apple-ios": ["sdkRootPath": "Developer/SDKs/iPhoneOS.sdk"], + ], + ]).write(to: metadata) + return metadata + } + + @Test func dependencyInspectionKeepsTheRootPackagesSDKCache() async throws { + // The planner inspects each dependency through a copy rebased onto its directory. + let settings = try await BuildSettings(configuration: .release, triple: "arm64-apple-macosx15.0", + packagePath: "/work/App") + let dependency = settings.withPackagePath("/work/App/.build/checkouts/Dependency") + #expect(dependency.swiftSDKCacheDirectory.path == "/work/App/.build/xtool-sdk") + } + + @Test func destinationSelectsOneTargetAndPreservesSDKPathsWithoutChangingInstallation() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let bundle = directory.appendingPathComponent("darwin.artifactbundle", isDirectory: true) + let original = try metadata(in: bundle) + let before = try Data(contentsOf: original) + let target = try TargetTriple("arm64-apple-macosx16.0") + let cache = directory.appendingPathComponent("cache", isDirectory: true) + let destination = try SwiftSDKConfiguration.destination(in: directory, for: target, cacheDirectory: cache) + let document = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: destination.url)) as? [String: Any]) + let triples = try #require(document["targetTriples"] as? [String: [String: Any]]) + #expect(Set(triples.keys) == ["arm64-apple-macosx"]) + let properties = try #require(triples["arm64-apple-macosx"]) + #expect(properties["swiftResourcesPath"] as? String == "/absolute/swift") + for (key, marker) in [("sdkRootPath", "Developer/SDKs/MacOSX.sdk"), ("swiftStaticResourcesPath", "swift_static")] { + let path = try #require(properties[key] as? String) + #expect((path as NSString).isAbsolutePath) + #expect(try String(contentsOfFile: path + "/marker.txt", encoding: .utf8) == marker) + } + for (key, marker) in [("includeSearchPaths", "include"), ("librarySearchPaths", "lib")] { + let paths = try #require(properties[key] as? [String]) + #expect(paths.count == 1) + let path = try #require(paths.first) + #expect((path as NSString).isAbsolutePath) + #expect(try String(contentsOfFile: path + "/marker.txt", encoding: .utf8) == marker) + } + #expect(properties["toolsetPaths"] as? [String] == [bundle.appendingPathComponent("toolset.json").path]) + #expect(destination.toolSearchPaths == [bundle.appendingPathComponent("tools/bin").path]) + #expect(try Data(contentsOf: original) == before) + #expect(try SwiftSDKConfiguration.destination(in: directory, for: target, cacheDirectory: cache) == destination) + } + + @Test func missingAndAmbiguousSDKsFailWithoutPublishingConfiguration() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let target = try TargetTriple("arm64-apple-macosx15.0") + let cache = directory.appendingPathComponent("cache", isDirectory: true) + #expect(throws: (any Error).self) { + try SwiftSDKConfiguration.destination(in: directory, for: target, cacheDirectory: cache) + } + _ = try metadata(in: directory.appendingPathComponent("first.artifactbundle", isDirectory: true)) + _ = try metadata(in: directory.appendingPathComponent("second.artifactbundle", isDirectory: true)) + #expect(throws: (any Error).self) { + try SwiftSDKConfiguration.destination(in: directory, for: target, cacheDirectory: cache) + } + #expect(!FileManager.default.fileExists(atPath: cache.path)) + } +} diff --git a/integration-tests/run.sh b/integration-tests/run.sh index eb70d5d7..19712e78 100755 --- a/integration-tests/run.sh +++ b/integration-tests/run.sh @@ -34,7 +34,6 @@ cd "$(dirname "$0")/.." project_dir="$PWD" swift build --product xtool -swift test ln -fs $PWD/.build/debug/xtool /usr/local/bin/xtool hash -r @@ -46,4 +45,5 @@ echo "Installing SDK" xtool sdk install --slim "$XTL_TEST_XCODE_APP" echo "Running tests..." +swift test --package-path "$project_dir" bats "$project_dir/integration-tests/suite" diff --git a/integration-tests/suite/macos.bats b/integration-tests/suite/macos.bats new file mode 100644 index 00000000..2900eae7 --- /dev/null +++ b/integration-tests/suite/macos.bats @@ -0,0 +1,51 @@ +setup() { + bats_require_minimum_version 1.5.0 + bats_load_library bats-support + bats_load_library bats-assert + cd "$BATS_TEST_TMPDIR" +} + +@test "can cross-build and verify a relocated macOS app" { + mkdir -p MacSmoke/Sources/MacSmoke + cd MacSmoke + cat > Package.swift <<'SWIFT' +// swift-tools-version: 6.0 +import PackageDescription +let package = Package(name: "MacSmoke", platforms: [.macOS(.v15)], + products: [.executable(name: "MacSmoke", targets: ["MacSmoke"])], + targets: [.executableTarget(name: "MacSmoke", resources: [.copy("message.txt")])]) +SWIFT + cat > Sources/MacSmoke/main.swift <<'SWIFT' +import Foundation +let file = Bundle.module.url(forResource: "message", withExtension: "txt")! +print(try String(contentsOf: file, encoding: .utf8)) +SWIFT + echo 'macOS resource' > Sources/MacSmoke/message.txt + architecture="$(uname -m)" + [[ "$architecture" == aarch64 ]] && architecture=arm64 + cat > xtool.yml < relocated/MacSmoke.app/Contents/Resources/unexpected.txt + run xtool dev verify relocated/MacSmoke.app + assert_failure + assert_output --partial 'inventory mismatch' +}