diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f2ee08..fd54086 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,122 @@ jobs: -configuration Debug -destination 'platform=macOS' \ -derivedDataPath "$RUNNER_TEMP/dd" -resultBundlePath "$RUNNER_TEMP/UITests.xcresult" \ test-without-building + - name: Build the package fixture host + run: | + xcrun swiftc -swift-version 6 -warnings-as-errors -parse-as-library \ + -I "$RUNNER_TEMP/dd/Build/Products/Debug" \ + "$RUNNER_TEMP/dd/Build/Products/Debug/ToolkitCore.o" \ + scripts/PackageInspectionFixtures.swift scripts/PackageProcessObservation.swift \ + -o "$RUNNER_TEMP/package-fixtures" + - name: Prepare one owned package simulator and immutable package inputs + id: prepare_package + run: | + "$RUNNER_TEMP/package-fixtures" prepare "$RUNNER_TEMP/package-fixtures-data" + python3 - "$RUNNER_TEMP/package-fixtures-data/environment.env" <<'PY' + import os + import sys + import uuid + from pathlib import Path + + path = Path(sys.argv[1]) + if path.is_symlink() or not path.is_file() or not 0 < path.stat().st_size <= 8192: + raise SystemExit("The package fixture environment must be a bounded regular file.") + values = {} + for line in path.read_text().splitlines(): + key, separator, value = line.partition("=") + if not separator or not value or key in values or "\r" in value or "\x00" in value: + raise SystemExit("The package fixture environment has an invalid or duplicate entry.") + values[key] = value + expected_keys = {"IDT_PACKAGE_FIXTURE_DIRECTORY", "IDT_PACKAGE_FIXTURE_SIMULATOR", "IDT_PACKAGE_FIXTURE_OWNER", "IDT_PACKAGE_FIXTURE_VALID", "IDT_PACKAGE_FIXTURE_VALID_B", "IDT_PACKAGE_FIXTURE_INVALID"} + if set(values) != expected_keys: + raise SystemExit("The package fixture environment has an unexpected schema.") + for key in ("IDT_PACKAGE_FIXTURE_SIMULATOR", "IDT_PACKAGE_FIXTURE_OWNER"): + if str(uuid.UUID(values[key])).upper() != values[key]: + raise SystemExit("The package fixture environment requires canonical UUIDs.") + directory = (Path(os.environ["RUNNER_TEMP"]) / "package-fixtures-data").resolve(strict=True) + expected_paths = {"IDT_PACKAGE_FIXTURE_DIRECTORY": directory, "IDT_PACKAGE_FIXTURE_VALID": directory / "ValidSimulator.app", "IDT_PACKAGE_FIXTURE_VALID_B": directory / "ValidSimulatorB.app", "IDT_PACKAGE_FIXTURE_INVALID": directory / "Malformed.ipa"} + for key, expected in expected_paths.items(): + candidate = Path(values[key]) + if not candidate.is_absolute() or candidate.is_symlink() or candidate.resolve(strict=True) != expected: + raise SystemExit("The package fixture environment contains an unexpected input path.") + with open(os.environ["GITHUB_ENV"], "a") as output: + for key in sorted(values): + output.write("TEST_RUNNER_" + key + "=" + values[key] + "\n") + PY + - name: Hosted package recovery, late completion and incomplete reader Quit acceptance + env: + TEST_RUNNER_IDT_PACKAGE_UI_TESTS: "1" + run: | + xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit \ + -configuration Debug -destination 'platform=macOS' \ + -derivedDataPath "$RUNNER_TEMP/dd" -resultBundlePath "$RUNNER_TEMP/PackageUITests.xcresult" \ + -only-testing:iOSDeveloperToolkitUITests/PackageInspectionUITests test-without-building + xcrun xcresulttool get test-results summary --path "$RUNNER_TEMP/PackageUITests.xcresult" \ + > "$RUNNER_TEMP/PackageUITests-summary.json" + python3 - "$RUNNER_TEMP/PackageUITests-summary.json" <<'PY' + import json + import sys + from pathlib import Path + + summary = json.loads(Path(sys.argv[1]).read_text()) + expected = {"totalTestCount": 1, "passedTests": 1, "failedTests": 0, "skippedTests": 0} + if not isinstance(summary, dict) or any(type(summary.get(key)) is not int for key in expected): + raise SystemExit("Package acceptance did not produce valid execution counts.") + observed = {key: summary[key] for key in expected} + if observed != expected: + raise SystemExit(f"Package acceptance must execute and pass exactly one test: {observed}") + print(f"Native package acceptance executed: {observed}") + PY + - name: Independently verify package cleanup and original input integrity + run: | + xcrun xcresulttool export attachments --path "$RUNNER_TEMP/PackageUITests.xcresult" \ + --output-path "$RUNNER_TEMP/package-ui-attachments" + python3 - "$RUNNER_TEMP/package-ui-attachments" "$RUNNER_TEMP/PackageUITests-cleanup-receipt.json" <<'PY' + import json + import os + import re + import sys + from pathlib import Path + + directory = Path(sys.argv[1]).resolve(strict=True) + manifest_path = directory / "manifest.json" + if manifest_path.is_symlink() or not manifest_path.is_file() or not 0 < manifest_path.stat().st_size <= 16_777_216: + raise SystemExit("The exported native attachment manifest is missing or invalid.") + manifest = json.loads(manifest_path.read_text()) + if not isinstance(manifest, list) or not 0 < len(manifest) <= 64: + raise SystemExit("The exported native attachment manifest has an invalid test array.") + receipts = [] + for test in manifest: + if not isinstance(test, dict) or not isinstance(test.get("attachments"), list): + raise SystemExit("The exported native attachment manifest has an invalid test entry.") + if test.get("testIdentifier") != "PackageInspectionUITests/testPackageInspectionRecoveryAndNormalQuitCleanup()": + continue + for attachment in test["attachments"]: + if not isinstance(attachment, dict): + raise SystemExit("The exported native attachment manifest has an invalid attachment entry.") + name = attachment.get("suggestedHumanReadableName") + if isinstance(name, str) and name.startswith("package-cleanup-receipt"): + filename = attachment.get("exportedFileName") + if not isinstance(filename, str) or re.fullmatch(r"[A-Za-z0-9_.-]{1,255}", filename) is None or filename in (".", ".."): + raise SystemExit("The native cleanup receipt has an unsafe exported basename.") + path = directory / filename + if path.is_symlink() or not path.is_file() or path.resolve(strict=True).parent != directory or not 0 < path.stat().st_size <= 16384: + raise SystemExit("The native cleanup receipt must be a bounded exported regular file.") + receipts.append(path) + if len(receipts) != 1: + raise SystemExit("Native acceptance must retain exactly one actual package cleanup receipt.") + data = receipts[0].read_bytes() + receipt = json.loads(data) + if not isinstance(receipt, dict) or set(receipt) != {"preparedDirectories"}: + raise SystemExit("The actual native cleanup receipt has an invalid schema.") + with open(sys.argv[2], "xb") as output: + os.fchmod(output.fileno(), 0o600) + output.write(data) + PY + "$RUNNER_TEMP/package-fixtures" verify-cleanup "$RUNNER_TEMP/package-fixtures-data" "$RUNNER_TEMP/PackageUITests-cleanup-receipt.json" + - name: Verify cleanup of the owned package simulator + if: ${{ always() && (steps.prepare_package.outcome == 'success' || steps.prepare_package.outcome == 'failure' || steps.prepare_package.outcome == 'cancelled') }} + run: '"$RUNNER_TEMP/package-fixtures" cleanup "$RUNNER_TEMP/package-fixtures-data"' - name: Render every page at the default and minimum window sizes run: | scripts/check-layout.sh \ @@ -98,6 +214,9 @@ jobs: name: ui-test-results path: | ${{ runner.temp }}/UITests.xcresult + ${{ runner.temp }}/PackageUITests.xcresult + ${{ runner.temp }}/PackageUITests-summary.json + ${{ runner.temp }}/PackageUITests-cleanup-receipt.json ${{ runner.temp }}/layout if-no-files-found: ignore diff --git a/App/UITests/PackageInspectionUITests.swift b/App/UITests/PackageInspectionUITests.swift new file mode 100644 index 0000000..fdcc91f --- /dev/null +++ b/App/UITests/PackageInspectionUITests.swift @@ -0,0 +1,513 @@ +import Darwin +import Foundation +import XCTest + +/// Explicit hosted acceptance keeps native file panels and termination out of ordinary runs. +@MainActor +final class PackageInspectionUITests: XCTestCase { + private var application: XCUIApplication? + + struct Fixtures { + let simulator: String + let owner: String + let valid: String + let validB: String + let invalid: String + + static func load(environment: [String: String]) throws -> Fixtures { + let simulator = try XCTUnwrap(environment["IDT_PACKAGE_FIXTURE_SIMULATOR"]) + let owner = try XCTUnwrap(environment["IDT_PACKAGE_FIXTURE_OWNER"]) + let directory = try XCTUnwrap(environment["IDT_PACKAGE_FIXTURE_DIRECTORY"]) + let valid = try XCTUnwrap(environment["IDT_PACKAGE_FIXTURE_VALID"]) + let validB = try XCTUnwrap(environment["IDT_PACKAGE_FIXTURE_VALID_B"]) + let invalid = try XCTUnwrap(environment["IDT_PACKAGE_FIXTURE_INVALID"]) + XCTAssertEqual(UUID(uuidString: simulator)?.uuidString, simulator) + XCTAssertEqual(UUID(uuidString: owner)?.uuidString, owner) + let root = URL(fileURLWithPath: directory, isDirectory: true).standardizedFileURL + XCTAssertTrue(directory.hasPrefix("/")) + XCTAssertTrue([directory, valid, validB, invalid].allSatisfy { $0.utf8.count <= 4096 && !$0.contains("\0") && !$0.contains("\n") && !$0.contains("\r") }) + XCTAssertEqual(valid, root.appendingPathComponent("ValidSimulator.app").path) + XCTAssertEqual(validB, root.appendingPathComponent("ValidSimulatorB.app").path) + XCTAssertEqual(invalid, root.appendingPathComponent("Malformed.ipa").path) + return Fixtures(simulator: simulator, owner: owner, valid: valid, validB: validB, invalid: invalid) + } + } + + struct Preparation { + let directory: String + let identifier: String + } + + struct CleanupReceipt: Encodable { + let preparedDirectories: [String] + } + + override func setUp() async throws { + continueAfterFailure = false + try XCTSkipUnless(ProcessInfo.processInfo.environment["IDT_PACKAGE_UI_TESTS"] == "1", + "Requires the explicit hosted package inspection acceptance opt-in.") + } + + override func tearDown() async throws { + application?.terminate() + } + + func testPackageInspectionRecoveryAndNormalQuitCleanup() throws { + let fixtures = try Fixtures.load(environment: ProcessInfo.processInfo.environment) + let app = XCUIApplication() + application = app + launchOwnedApplication(app, fixtures: fixtures) + + // Cancel normal Quit while a genuine unfinished reader owns its native handle and copy. + let firstReaderArm = element("package-reader-arm") + reveal(firstReaderArm) + XCTAssertTrue(firstReaderArm.isEnabled) + firstReaderArm.click() + wait(element("package-reader-phase"), predicate: "value == 'armed'", timeout: 5) + try choosePackage(fixtures.valid) + wait(element("package-reader-phase"), predicate: "value == 'native-handle-held-before-validation'", timeout: 120) + let firstReaderDirectory = try value("package-reader-directory") + XCTAssertEqual(try value("package-reader-original-url"), fixtures.valid) + XCTAssertEqual(try value("package-reader-package-url"), URL(fileURLWithPath: firstReaderDirectory).appendingPathComponent("ValidSimulator.app").path) + try requirePrivateDirectory(firstReaderDirectory) + try requirePackageCopy(directory: firstReaderDirectory, original: fixtures.valid) + XCTAssertEqual(try value("package-reader-cancelled"), "false") + XCTAssertEqual(try value("package-reader-signature-status"), "pending") + XCTAssertEqual(try value("package-reader-choice-returned"), "false") + XCTAssertEqual(try value("package-is-terminating"), "false") + XCTAssertEqual(try value("package-prepared-id"), "none") + wait(element("package-active-operations"), predicate: "value == '1'", timeout: 10) + wait(element("install-package"), predicate: "exists == true AND enabled == false", timeout: 10) + attachState("package-quit-cancel-reader-held") + + app.typeKey("q", modifierFlags: [.command]) + let quitCancel = element("quit-operations-cancel") + wait(quitCancel, predicate: "exists == true AND hittable == true", timeout: 10) + attach(app.debugDescription, name: "package-quit-cancel-alert") + quitCancel.click() + wait(quitCancel, predicate: "exists == false", timeout: 10) + XCTAssertEqual(app.state, .runningForeground) + XCTAssertEqual(try value("package-is-terminating"), "false") + XCTAssertEqual(try value("package-reader-phase"), "native-handle-held-before-validation") + XCTAssertEqual(try value("package-reader-cancelled"), "false") + XCTAssertEqual(try value("package-reader-signature-status"), "pending") + XCTAssertEqual(try value("package-reader-choice-returned"), "false") + XCTAssertEqual(try value("package-active-operations"), "1") + XCTAssertEqual(try value("package-reader-directory"), firstReaderDirectory) + XCTAssertEqual(try value("package-prepared-id"), "none") + XCTAssertFalse(element("error-message").exists) + XCTAssertFalse(element("package-inspection-failed").exists) + wait(element("install-package"), predicate: "exists == true AND enabled == false", timeout: 10) + try requirePrivateDirectory(firstReaderDirectory) + try requirePackageCopy(directory: firstReaderDirectory, original: fixtures.valid) + attachState("package-quit-cancel-reader-owned") + + let firstReaderRelease = element("package-reader-release") + reveal(firstReaderRelease) + XCTAssertTrue(firstReaderRelease.isEnabled) + firstReaderRelease.click() + wait(element("package-reader-phase"), predicate: "value == 'validation-returned'", timeout: 120) + wait(element("package-reader-choice-returned"), predicate: "value == 'true'", timeout: 10) + wait(element("package-active-operations"), predicate: "value == '0'", timeout: 10) + XCTAssertEqual(try value("package-is-terminating"), "false") + XCTAssertEqual(try value("package-reader-cancelled"), "false") + XCTAssertEqual(try value("package-reader-signature-status"), "valid") + let first = try requirePreparation(original: fixtures.valid, activeOperations: 0) + XCTAssertEqual(first.directory, firstReaderDirectory) + XCTAssertEqual(try value("package-selection"), "ValidSimulator.app") + XCTAssertFalse(element("package-inspection-failed").exists) + attachState("package-first-valid") + + // This is a genuine malformed archive. No error or preparation result is injected. + try choosePackage(fixtures.invalid) + wait(element("error-message"), predicate: "exists == true", timeout: 120) + XCTAssertEqual(try value("error-message"), "The file is not a valid ZIP-based package.") + attachState("package-malformed-error") + let dismiss = element("error-dismiss") + wait(dismiss, predicate: "exists == true AND hittable == true", timeout: 10) + dismiss.click() + wait(element("error-message"), predicate: "exists == false", timeout: 10) + wait(element("package-inspection-failed"), predicate: "exists == true", timeout: 10) + wait(element("package-active-operations"), predicate: "value == '0'", timeout: 10) + XCTAssertEqual(try value("package-selection"), "Malformed.ipa") + XCTAssertEqual(try value("package-prepared-id"), "none") + XCTAssertEqual(try value("package-prepared-directory"), "none") + XCTAssertEqual(try value("package-original-url"), "none") + wait(element("install-package"), predicate: "exists == true AND enabled == false", timeout: 10) + try requireAbsent(first.directory) + XCTAssertFalse(element("running-operations").exists) + attachState("package-malformed-denied") + + try choosePackage(fixtures.valid) + let recovered = try requirePreparation(original: fixtures.valid, activeOperations: 0) + XCTAssertNotEqual(recovered.directory, first.directory) + XCTAssertNotEqual(recovered.identifier, first.identifier) + XCTAssertFalse(element("package-inspection-failed").exists) + let install = element("install-package") + reveal(install) + install.click() + wait(element("confirmation-detail"), predicate: "exists == true", timeout: 10) + XCTAssertEqual(try value("confirmation-title"), "Install Package Fixture?") + XCTAssertEqual(try value("confirmation-detail"), "ValidSimulator.app will be installed on IDT Package \(fixtures.owner). iOS still checks the signature, provisioning, and entitlements.") + XCTAssertEqual(app.sheets.count, 1) + wait(element("confirm-button"), predicate: "exists == true AND enabled == false", timeout: 10) + XCTAssertTrue(element("confirmation-field").exists) + attachState("package-confirmation") + let cancel = element("confirmation-cancel") + wait(cancel, predicate: "exists == true AND hittable == true", timeout: 10) + cancel.click() + wait(element("confirm-button"), predicate: "exists == false", timeout: 10) + XCTAssertEqual(app.sheets.count, 0) + XCTAssertEqual(try value("package-prepared-directory"), recovered.directory) + XCTAssertEqual(try value("package-prepared-id"), recovered.identifier) + XCTAssertTrue(install.isEnabled) + XCTAssertFalse(element("running-operations").exists) + try requirePrivateDirectory(recovered.directory) + attachState("package-cancel-preserved") + + // Only completion order is controlled. A and B both perform genuine native inspection. + let arm = element("package-completion-arm") + reveal(arm) + XCTAssertTrue(arm.isEnabled) + arm.click() + wait(element("package-completion-state"), predicate: "value == 'armed'", timeout: 5) + try choosePackage(fixtures.valid) + wait(element("package-completion-state"), predicate: "value == 'held'", timeout: 120) + let late = try heldPreparation(original: fixtures.valid) + XCTAssertNotEqual(late.directory, recovered.directory) + XCTAssertNotEqual(late.identifier, recovered.identifier) + try requireAbsent(recovered.directory) + XCTAssertEqual(try value("package-prepared-id"), "none") + XCTAssertEqual(try value("package-selection"), "ValidSimulator.app") + wait(install, predicate: "exists == true AND enabled == false", timeout: 10) + wait(element("package-active-operations"), predicate: "value == '1'", timeout: 10) + XCTAssertTrue(element("package-inspection-running").exists) + attachState("package-completed-a-held") + + try choosePackage(fixtures.validB) + let current = try requirePreparation(original: fixtures.validB, activeOperations: 1) + XCTAssertEqual(try value("package-selection"), "ValidSimulatorB.app") + XCTAssertNotEqual(current.directory, late.directory) + XCTAssertNotEqual(current.identifier, late.identifier) + XCTAssertEqual(try value("package-completion-state"), "held") + XCTAssertEqual(try value("package-held-cancelled"), "pending") + try requirePrivateDirectory(late.directory) + attachState("package-b-ready-a-held") + + let release = element("package-completion-release") + reveal(release) + XCTAssertTrue(release.isEnabled) + release.click() + wait(element("package-completion-state"), predicate: "value == 'delivered'", timeout: 10) + XCTAssertEqual(try value("package-held-cancelled"), "true", "A's actual inspection work task must observe the cancellation caused by selecting B") + wait(element("package-active-operations"), predicate: "value == '0'", timeout: 10) + try requireAbsent(late.directory) + XCTAssertEqual(try value("package-prepared-directory"), current.directory) + XCTAssertEqual(try value("package-prepared-id"), current.identifier) + XCTAssertEqual(try value("package-original-url"), fixtures.validB) + XCTAssertEqual(try value("package-selection"), "ValidSimulatorB.app") + XCTAssertFalse(element("package-inspection-running").exists) + XCTAssertFalse(element("package-inspection-failed").exists) + XCTAssertFalse(element("error-message").exists) + XCTAssertTrue(install.isEnabled) + XCTAssertFalse(element("running-operations").exists) + try requirePrivateDirectory(current.directory) + attachState("package-late-a-disposed-b-preserved") + + reveal(install) + install.click() + wait(element("confirmation-detail"), predicate: "exists == true", timeout: 10) + XCTAssertEqual(try value("confirmation-title"), "Install Package Fixture B?") + XCTAssertEqual(try value("confirmation-detail"), "ValidSimulatorB.app will be installed on IDT Package \(fixtures.owner). iOS still checks the signature, provisioning, and entitlements.") + XCTAssertEqual(app.sheets.count, 1) + wait(element("confirm-button"), predicate: "exists == true AND enabled == false", timeout: 10) + XCTAssertTrue(element("confirmation-field").exists) + attachState("package-b-confirmation-after-late-a") + wait(cancel, predicate: "exists == true AND hittable == true", timeout: 10) + cancel.click() + wait(element("confirm-button"), predicate: "exists == false", timeout: 10) + XCTAssertEqual(app.sheets.count, 0) + XCTAssertEqual(try value("package-prepared-directory"), current.directory) + XCTAssertEqual(try value("package-prepared-id"), current.identifier) + XCTAssertTrue(install.isEnabled) + XCTAssertFalse(element("running-operations").exists) + try requirePrivateDirectory(current.directory) + attachState("package-b-cancel-preserved") + + // Normal Quit exercises AppDelegate's termination reply and production cleanup. + // terminate() in tearDown is failure cleanup and cannot establish this assertion. + app.typeKey("q", modifierFlags: [.command]) + XCTAssertTrue(app.wait(for: .notRunning, timeout: 15), "Normal Command-Q must exit after removing its idle prepared package") + try requireAbsent(first.directory) + try requireAbsent(recovered.directory) + try requireAbsent(late.directory) + try requireAbsent(current.directory) + + // A new launch owns a real unfinished worker and native code handle, before validation. + // Holding a completed result above does not establish this reader-lifetime behavior. + launchOwnedApplication(app, fixtures: fixtures) + let armReader = element("package-reader-arm") + reveal(armReader) + XCTAssertTrue(armReader.isEnabled) + armReader.click() + wait(element("package-reader-phase"), predicate: "value == 'armed'", timeout: 5) + try choosePackage(fixtures.valid) + wait(element("package-reader-phase"), predicate: "value == 'native-handle-held-before-validation'", timeout: 120) + let readerDirectory = try value("package-reader-directory") + XCTAssertEqual(try value("package-reader-original-url"), fixtures.valid) + XCTAssertEqual(try value("package-reader-package-url"), URL(fileURLWithPath: readerDirectory).appendingPathComponent("ValidSimulator.app").path) + try requirePrivateDirectory(readerDirectory) + try requirePackageCopy(directory: readerDirectory, original: fixtures.valid) + XCTAssertEqual(try value("package-reader-cancelled"), "false") + XCTAssertEqual(try value("package-reader-signature-status"), "pending") + XCTAssertEqual(try value("package-reader-choice-returned"), "false") + XCTAssertEqual(try value("package-is-terminating"), "false") + XCTAssertEqual(try value("package-prepared-id"), "none") + wait(element("package-active-operations"), predicate: "value == '1'", timeout: 10) + wait(element("install-package"), predicate: "exists == true AND enabled == false", timeout: 10) + attachState("package-native-handle-held-before-validation") + + app.typeKey("q", modifierFlags: [.command]) + let quitAnyway = element("quit-operations-confirm") + wait(quitAnyway, predicate: "exists == true AND hittable == true", timeout: 10) + attach(app.debugDescription, name: "package-quit-operations-alert") + quitAnyway.click() + // The production deadline remains eight seconds. It must refuse this incomplete Quit. + wait(element("error-message"), predicate: "exists == true", timeout: 20) + XCTAssertEqual(try value("error-message"), "Quit cleanup is still running after eight seconds. The app will remain open.") + XCTAssertEqual(try value("error-recovery"), "Wait for the active operation to finish, then try quitting again.") + XCTAssertEqual(app.state, .runningForeground) + XCTAssertEqual(try value("package-is-terminating"), "true") + XCTAssertEqual(try value("package-reader-phase"), "native-handle-held-before-validation") + XCTAssertEqual(try value("package-reader-cancelled"), "true") + XCTAssertEqual(try value("package-reader-choice-returned"), "false") + XCTAssertEqual(try value("package-active-operations"), "1") + try requirePrivateDirectory(readerDirectory) + try requirePackageCopy(directory: readerDirectory, original: fixtures.valid) + attachState("package-quit-timeout-reader-owned") + wait(dismiss, predicate: "exists == true AND hittable == true", timeout: 10) + dismiss.click() + wait(element("error-message"), predicate: "exists == false", timeout: 10) + + // The real chooser must refuse a new package while the old Quit still owns cleanup. + try choosePackage(fixtures.valid) + wait(element("error-message"), predicate: "exists == true", timeout: 10) + XCTAssertEqual(try value("error-message"), "The app is quitting. Cancel quitting before choosing another package.") + XCTAssertEqual(try value("package-is-terminating"), "true") + XCTAssertEqual(try value("package-reader-choice-returned"), "false") + XCTAssertEqual(try value("package-active-operations"), "1") + XCTAssertEqual(try value("package-reader-directory"), readerDirectory) + try requirePrivateDirectory(readerDirectory) + attachState("package-choice-denied-during-reader-cleanup") + wait(dismiss, predicate: "exists == true AND hittable == true", timeout: 10) + dismiss.click() + wait(element("error-message"), predicate: "exists == false", timeout: 10) + + let releaseReader = element("package-reader-release") + reveal(releaseReader) + XCTAssertTrue(releaseReader.isEnabled) + releaseReader.click() + wait(element("package-reader-phase"), predicate: "value == 'validation-returned'", timeout: 120) + wait(element("package-reader-choice-returned"), predicate: "value == 'true'", timeout: 10) + wait(element("package-active-operations"), predicate: "value == '0'", timeout: 10) + wait(element("package-is-terminating"), predicate: "value == 'false'", timeout: 10) + XCTAssertEqual(try value("package-reader-cancelled"), "true") + XCTAssertEqual(try value("package-reader-signature-status"), "valid") + XCTAssertEqual(try value("package-prepared-id"), "none") + XCTAssertEqual(try value("package-prepared-directory"), "none") + XCTAssertEqual(try value("package-original-url"), "none") + XCTAssertFalse(element("error-message").exists) + XCTAssertFalse(element("install-package").exists) + try requireAbsent(readerDirectory) + attachState("package-reader-returned-and-cleaned") + + try choosePackage(fixtures.valid) + let readerRecovery = try requirePreparation(original: fixtures.valid, activeOperations: 0) + XCTAssertNotEqual(readerRecovery.directory, readerDirectory) + XCTAssertEqual(try value("package-is-terminating"), "false") + attachState("package-recovered-after-quit-timeout") + app.typeKey("q", modifierFlags: [.command]) + XCTAssertTrue(app.wait(for: .notRunning, timeout: 15), "Normal Quit must recover after the incomplete reader finishes") + try requireAbsent(readerDirectory) + try requireAbsent(readerRecovery.directory) + let data = try JSONEncoder().encode(CleanupReceipt(preparedDirectories: [first.directory, recovered.directory, late.directory, current.directory, readerDirectory, readerRecovery.directory])) + attach(String(decoding: data, as: UTF8.self), name: "package-cleanup-receipt") + } + + private func launchOwnedApplication(_ app: XCUIApplication, fixtures: Fixtures) { + app.launchArguments = ["-ui-testing", "YES", "-demo-mode", "NO", "-workspace", "installApp", + "-package-acceptance-simulator", fixtures.simulator, + "-package-acceptance-owner", fixtures.owner, + "-ApplePersistenceIgnoreState", "YES"] + app.launchEnvironment["IDT_RESTORE_HELPERS"] = "" + app.launch() + app.activate() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) + XCTAssertFalse(element("demo-banner").exists) + let readiness = element("package-target-readiness") + attachReadiness("package-discovery-before-wait") + wait(readiness, predicate: "exists == true AND value == 'ready'", timeout: 75) + attachReadiness("package-owned-target-ready") + let picker = element("target-picker") + wait(picker, predicate: "exists == true AND hittable == true", timeout: 10) + picker.click() + let simulator = element("target-simulator-\(fixtures.simulator)") + wait(simulator, predicate: "exists == true AND hittable == true", timeout: 10) + simulator.click() + wait(simulator, predicate: "exists == false", timeout: 10) + wait(picker, predicate: "exists == true AND hittable == true", timeout: 10) + XCTAssertFalse(element("install-package").exists) + } + + /// Uses the identifiers recorded from the real AppKit panel on the hosted Mac. + private func choosePackage(_ path: String) throws { + let choose = element("choose-package") + reveal(choose) + choose.click() + let panel = element("open-panel") + wait(panel, predicate: "exists == true", timeout: 10) + panel.typeKey("g", modifierFlags: [.command, .shift]) + let goToSheets = application!.sheets.matching(identifier: "GoToWindow") + let goTo = goToSheets.element + wait(goTo, predicate: "exists == true", timeout: 5) + XCTAssertEqual(goToSheets.count, 1, "The native Go to Folder sheet must resolve uniquely from the application") + let fields = application!.textFields.matching(identifier: "PathTextField") + let field = fields.element + wait(field, predicate: "exists == true AND hittable == true", timeout: 5) + XCTAssertEqual(fields.count, 1, "The native Go to Folder field must resolve uniquely from the application") + field.click() + field.typeKey("a", modifierFlags: [.command]) + field.typeText(path) + XCTAssertEqual(try XCTUnwrap(field.value as? String), path) + field.typeKey(.return, modifierFlags: []) + wait(application!.textFields["PathTextField"], predicate: "exists == false", timeout: 5) + let openButtons = application!.buttons.matching(identifier: "OKButton") + let open = openButtons.element + wait(open, predicate: "exists == true AND enabled == true AND hittable == true", timeout: 10) + XCTAssertEqual(openButtons.count, 1, "The native Open button must resolve uniquely from the application") + XCTAssertEqual(try value("package-target-readiness"), "ready", "Choosing a local package must preserve the owned simulator target") + attach(application!.debugDescription, name: "package-native-selected-input") + open.click() + wait(panel, predicate: "exists == false", timeout: 10) + } + + private func element(_ identifier: String) -> XCUIElement { + application!.descendants(matching: .any)[identifier] + } + + private func value(_ identifier: String) throws -> String { + try XCTUnwrap(element(identifier).value as? String, "The identified native control must expose a String value: \(identifier)") + } + + private func wait(_ control: XCUIElement, predicate: String, timeout: TimeInterval) { + let expectation = XCTNSPredicateExpectation(predicate: NSPredicate(format: predicate), object: control) + let result = XCTWaiter.wait(for: [expectation], timeout: timeout) + if result != .completed, let application, application.state != .notRunning { + attachReadiness("package-wait-readiness") + attach(application.debugDescription, name: "package-wait-native-hierarchy") + } + XCTAssertEqual(result, .completed, + "The native control did not satisfy \(predicate): \(control)") + } + + private func attachReadiness(_ name: String) { + let summary = element("connection-summary") + let label = summary.exists ? String(summary.label.prefix(4096)) : "" + let summaryValue = diagnosticValue("connection-summary") + let readiness = diagnosticValue("package-target-readiness") + let status = diagnosticValue("package-discovery-status") + attach("label: \(label)\nvalue: \(summaryValue)\nowned-target: \(readiness)\nsource-status: \(status)", name: name) + } + + private func diagnosticValue(_ identifier: String) -> String { + let control = element(identifier) + guard control.exists else { return "" } + guard let value = control.value as? String else { return "" } + return String(value.prefix(4096)) + } + + private func requirePreparation(original: String, activeOperations: Int) throws -> Preparation { + wait(element("package-prepared-id"), predicate: "exists == true AND value != 'none'", timeout: 120) + wait(element("package-active-operations"), predicate: "value == '\(activeOperations)'", timeout: 10) + wait(element("install-package"), predicate: "exists == true AND enabled == true", timeout: 10) + XCTAssertFalse(element("package-inspection-running").exists) + XCTAssertFalse(element("error-message").exists) + XCTAssertEqual(try value("package-original-url"), original) + let directory = try value("package-prepared-directory") + let identifier = try value("package-prepared-id") + XCTAssertEqual(UUID(uuidString: identifier)?.uuidString, identifier) + try requirePrivateDirectory(directory) + try requirePackageCopy(directory: directory, original: original) + return Preparation(directory: directory, identifier: identifier) + } + + private func heldPreparation(original: String) throws -> Preparation { + XCTAssertEqual(try value("package-held-original-url"), original) + let directory = try value("package-held-directory") + let identifier = try value("package-held-id") + XCTAssertEqual(UUID(uuidString: identifier)?.uuidString, identifier) + try requirePrivateDirectory(directory) + try requirePackageCopy(directory: directory, original: original) + return Preparation(directory: directory, identifier: identifier) + } + + private func requirePackageCopy(directory: String, original: String) throws { + let copy = URL(fileURLWithPath: directory).appendingPathComponent(URL(fileURLWithPath: original).lastPathComponent) + var metadata = stat() + XCTAssertEqual(lstat(copy.path, &metadata), 0, "The actual private package copy must exist while the app is alive") + XCTAssertEqual(metadata.st_mode & S_IFMT, S_IFDIR) + XCTAssertNotEqual(copy.path, original) + } + + private func requirePrivateDirectory(_ path: String) throws { + let directory = URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + XCTAssertTrue(path.hasPrefix("/")) + XCTAssertTrue(directory.lastPathComponent.hasPrefix("idt-prepared-app.")) + var metadata = stat() + XCTAssertEqual(lstat(directory.path, &metadata), 0, "The recorded private preparation must actually exist before Quit") + XCTAssertEqual(metadata.st_mode & S_IFMT, S_IFDIR) + XCTAssertEqual(metadata.st_uid, getuid()) + XCTAssertEqual(metadata.st_mode & 0o777, 0o700) + } + + private func requireAbsent(_ path: String) throws { + var metadata = stat() + let result = lstat(path, &metadata) + let code = errno + XCTAssertEqual(result, -1, "The exact recorded preparation must have been removed") + XCTAssertEqual(code, ENOENT, "Absence must be verified without masking an access failure") + } + + private func reveal(_ control: XCUIElement) { + let app = application! + let pages = app.scrollViews.containing(.button, identifier: "choose-package") + XCTAssertEqual(pages.count, 1, "Package controls must belong to one identified page scroll view") + let page = pages.element + for _ in 0..<8 { + if control.isHittable { break } + let viewport = page.frame + let frame = control.frame + XCTAssertGreaterThan(viewport.height, 0) + XCTAssertGreaterThan(frame.height, 0) + let deltaY = frame.maxY > viewport.maxY ? -viewport.height / 2 : viewport.height / 2 + page.scroll(byDeltaX: 0, deltaY: deltaY) + } + wait(control, predicate: "exists == true AND hittable == true", timeout: 5) + } + + private func attachState(_ name: String) { + let app = application! + attach(app.debugDescription, name: name) + let screenshot = XCTAttachment(screenshot: app.windows["main-AppWindow-1"].screenshot()) + screenshot.name = name + "-window" + screenshot.lifetime = .keepAlways + add(screenshot) + } + + private func attach(_ text: String, name: String) { + let attachment = XCTAttachment(string: String(text.prefix(120_000))) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/App/iOSDeveloperToolkit/Components/Components.swift b/App/iOSDeveloperToolkit/Components/Components.swift index 0853fa6..acd2586 100644 --- a/App/iOSDeveloperToolkit/Components/Components.swift +++ b/App/iOSDeveloperToolkit/Components/Components.swift @@ -238,12 +238,15 @@ struct ConfirmationSheet: View { VStack(alignment: .leading, spacing: 14) { HStack { Text(title).font(.title2.bold()) + .accessibilityIdentifier("confirmation-title") Spacer() RiskBadge(risk: requirement.risk) } Text(detail).fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("confirmation-detail") if let target { InfoRow("Target", "\(target.name) — \(target.kind.label)") + .accessibilityIdentifier("confirmation-target") InfoRow("UDID", target.udid, monospaced: true) } if let commandPreview { @@ -272,6 +275,7 @@ struct ConfirmationSheet: View { Spacer() Button("Cancel", role: .cancel) { dismiss() } .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("confirmation-cancel") Button(requirement.risk == .readOnly ? "Run" : "Continue") { dismiss() onConfirm() @@ -297,8 +301,10 @@ struct ErrorSheet: View { .font(.title2.bold()) .foregroundStyle(.orange) Text(error.message).font(.body).fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("error-message") if let recovery = error.recovery { Text(recovery).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("error-recovery") } if let details = error.details, !details.isEmpty { DisclosureGroup("Technical details") { @@ -316,11 +322,11 @@ struct ErrorSheet: View { HStack { Spacer() Button("OK") { dismiss() }.keyboardShortcut(.defaultAction) + .accessibilityIdentifier("error-dismiss") } } .padding(20) .frame(width: 520) - .accessibilityIdentifier("error-sheet") } } diff --git a/App/iOSDeveloperToolkit/Model/AppModel.swift b/App/iOSDeveloperToolkit/Model/AppModel.swift index 5cfacf9..8a47c41 100644 --- a/App/iOSDeveloperToolkit/Model/AppModel.swift +++ b/App/iOSDeveloperToolkit/Model/AppModel.swift @@ -6,6 +6,32 @@ import SwiftUI import ToolkitCore import ToolkitFeatures +#if DEBUG +/// Restricts package acceptance to the simulator created by its owning fixture run. +struct PackageAcceptanceTarget: Sendable { + let simulator: String + let owner: String + + static func parse(arguments: [String], uiTesting: Bool, demoMode: Bool) throws(ToolkitError) -> PackageAcceptanceTarget? { + let simulatorFlags = arguments.indices.filter { arguments[$0] == "-package-acceptance-simulator" } + let ownerFlags = arguments.indices.filter { arguments[$0] == "-package-acceptance-owner" } + guard !simulatorFlags.isEmpty || !ownerFlags.isEmpty else { return nil } + guard uiTesting, !demoMode, simulatorFlags.count == 1, ownerFlags.count == 1, + let simulatorIndex = simulatorFlags.first, arguments.indices.contains(simulatorIndex + 1), + let ownerIndex = ownerFlags.first, arguments.indices.contains(ownerIndex + 1), + let simulator = UUID(uuidString: arguments[simulatorIndex + 1]), + let owner = UUID(uuidString: arguments[ownerIndex + 1]) else { + throw ToolkitError(.invalidInput, message: "Package acceptance requires a simulator UUID and an owner UUID.", recovery: "Use a Debug build with -ui-testing YES, -demo-mode NO and one value for each package acceptance target and owner.", technicalDetail: nil) + } + return PackageAcceptanceTarget(simulator: simulator.uuidString, owner: owner.uuidString) + } + + func contains(_ device: Device) -> Bool { + device.kind == .simulator && device.udid.uppercased() == simulator && device.name == "IDT Package \(owner)" + } +} +#endif + /// An error ready for an alert: plain message first, technical details on request. struct PresentedError: Identifiable, Equatable { let id = UUID() @@ -68,6 +94,9 @@ final class AppModel { let runner: OwnedCommandRunning = ProcessCommandRunner() let journal = OperationJournal() let discovery: DeviceDiscovery +#if DEBUG + let packageAcceptanceTarget: PackageAcceptanceTarget? +#endif /// A fresh executor carries the current developer-image folders. var executor: ActionExecutor { ActionExecutor(runner: runner, developerImageFolders: developerImage.folders) } let logger = ToolkitLog.application @@ -92,6 +121,7 @@ final class AppModel { } var journalRecords: [OperationRecord] = [] var operations: [RunningOperation] = [] + private(set) var isTerminating = false var presentedError: PresentedError? var developerTools: DeveloperToolsStatus? var readinessResults: [String: [CapabilityResult]] = [:] @@ -130,11 +160,28 @@ final class AppModel { } let uiTesting = flag("-ui-testing") ?? false let forceDemo = flag("-demo-mode") ?? false - demoMode = forceDemo || (!uiTesting && UserDefaults.standard.bool(forKey: "demoMode")) + let isDemoMode = forceDemo || (!uiTesting && UserDefaults.standard.bool(forKey: "demoMode")) + demoMode = isDemoMode selectedDeviceID = forceDemo ? DemoMode.device.id : UserDefaults.standard.string(forKey: "selectedDeviceID") - // UI tests run without touching real discovery sources. - let configuration: DeviceDiscovery.Configuration = uiTesting - ? .init(usbmux: USBMuxClient(socketPath: "/nonexistent/ui-testing-usbmuxd"), coreDevice: nil, simulators: nil, enrichWithLockdown: false) + let acceptanceRequested = arguments.contains("-package-acceptance-simulator") || arguments.contains("-package-acceptance-owner") + let acceptanceError: ToolkitError? + let acceptanceSimulators: SimulatorClient? +#if DEBUG + do { + packageAcceptanceTarget = try PackageAcceptanceTarget.parse(arguments: arguments, uiTesting: uiTesting, demoMode: isDemoMode) + acceptanceError = nil + } catch { + packageAcceptanceTarget = nil + acceptanceError = error + } + acceptanceSimulators = packageAcceptanceTarget == nil ? nil : SimulatorClient(runner: runner) +#else + acceptanceSimulators = nil + acceptanceError = acceptanceRequested ? ToolkitError(.unsupported, message: "Package acceptance requires a Debug build.", recovery: "Build the Debug configuration and rerun the native acceptance test.", technicalDetail: nil) : nil +#endif + // UI tests exclude physical discovery; only explicit package acceptance enables simulators. + let configuration: DeviceDiscovery.Configuration = uiTesting || acceptanceRequested + ? .init(usbmux: USBMuxClient(socketPath: "/nonexistent/ui-testing-usbmuxd"), coreDevice: nil, simulators: acceptanceSimulators, enrichWithLockdown: false) : .init() discovery = DeviceDiscovery(configuration: configuration) developerImage = DeveloperImageModel() @@ -150,6 +197,7 @@ final class AppModel { if let destination = arguments.firstIndex(of: "-workspace").flatMap({ arguments.indices.contains($0 + 1) ? arguments[$0 + 1] : nil }).flatMap(Workspace.init(rawValue:)) { workspace = destination } + if let acceptanceError { presentedError = PresentedError(acceptanceError) } } // MARK: Lifecycle @@ -186,6 +234,14 @@ final class AppModel { security.cancel() } + func beginTermination() { + isTerminating = true + } + + func cancelTermination() { + isTerminating = false + } + private func apply(_ snapshot: DiscoverySnapshot) { let previous = Set(self.snapshot.devices.map(\.id)) self.snapshot = snapshot @@ -208,7 +264,12 @@ final class AppModel { // MARK: Devices var allDevices: [Device] { - demoMode ? [DemoMode.device] + snapshot.devices : snapshot.devices +#if DEBUG + if let packageAcceptanceTarget { + return snapshot.devices.filter { packageAcceptanceTarget.contains($0) } + } +#endif + return demoMode ? [DemoMode.device] + snapshot.devices : snapshot.devices } var physicalDevices: [Device] { allDevices.filter { $0.kind == .physical || $0.kind == .demo } } diff --git a/App/iOSDeveloperToolkit/Model/FeatureModels.swift b/App/iOSDeveloperToolkit/Model/FeatureModels.swift index 860db24..a2d933e 100644 --- a/App/iOSDeveloperToolkit/Model/FeatureModels.swift +++ b/App/iOSDeveloperToolkit/Model/FeatureModels.swift @@ -109,66 +109,323 @@ final class AppsModel { // MARK: - Install +/// Immutable input captured before the installation confirmation is presented. +struct AppInstallPlan: Sendable { + let package: PreparedAppPackage + let device: Device + let developerPackage: Bool +} + @Observable @MainActor final class InstallModel { - var packageURL: URL? - var inspection: IPAInspection? - var appBundleName: String? + private(set) var packageURL: URL? + private(set) var preparedPackage: PreparedAppPackage? + private var stalePackages: [PreparedAppPackage] = [] + private(set) var isInspecting = false + private(set) var inspectionError: String? + private(set) var isInstalling = false var installAsDeveloperPackage = false + private var selectionID = UUID() + private var inspectionOperation: RunningOperation? + private var installationOperation: RunningOperation? + private var activePreparations = 0 + private var isShuttingDown = false + private var cleanupWaiters: [CheckedContinuation] = [] +#if DEBUG + enum CompletionAcceptanceState: String { + case idle, armed, held, released, delivered, expired + } - var isAppBundle: Bool { packageURL?.pathExtension == "app" } + private(set) var completionAcceptanceState: CompletionAcceptanceState = .idle + private(set) var heldPreparationDirectory: String? + private(set) var heldPreparationID: UUID? + private(set) var heldPreparationOriginalURL: URL? + private(set) var heldPreparationWasCancelled: Bool? + private var completionContinuation: CheckedContinuation? + private var completionDeadline: Task? + private var readerAcceptance: PackageInspectionReaderAcceptance? + private var readerAcceptanceID: UUID? + private(set) var readerSnapshot: PackageInspectionReaderAcceptance.Snapshot? + private(set) var readerChoiceReturned = false + + func armReaderAcceptance(app: AppModel) throws(ToolkitError) { + guard app.packageAcceptanceTarget != nil, readerAcceptance == nil else { + throw ToolkitError(.invalidInput, message: "The native package reader acceptance gate cannot be armed.", recovery: "Launch a fresh owned-simulator package acceptance session before arming its one-shot reader gate.", technicalDetail: nil) + } + let identifier = UUID() + readerAcceptanceID = identifier + let reader = PackageInspectionReaderAcceptance { [weak self] snapshot in + Task { @MainActor in + guard let self, self.readerAcceptanceID == identifier, + snapshot.revision > (self.readerSnapshot?.revision ?? 0) else { return } + self.readerSnapshot = snapshot + } + } + readerAcceptance = reader + readerSnapshot = reader.snapshot + } + + func releaseReaderAcceptance(app: AppModel) throws { + guard app.packageAcceptanceTarget != nil, let readerAcceptance else { + throw ToolkitError(.invalidInput, message: "There is no native package reader to release.", recovery: "Arm the owned-simulator reader acceptance gate and wait for its native handle.", technicalDetail: nil) + } + try readerAcceptance.release() + } + + func armCompletionAcceptance(app: AppModel) throws(ToolkitError) { + guard app.packageAcceptanceTarget != nil, completionAcceptanceState == .idle else { + throw ToolkitError(.invalidInput, message: "The package completion acceptance gate cannot be armed.", recovery: "Launch a fresh owned-simulator package acceptance session before arming its one-shot gate.", technicalDetail: nil) + } + completionAcceptanceState = .armed + } + + func releaseCompletionAcceptance(app: AppModel) throws(ToolkitError) { + guard app.packageAcceptanceTarget != nil, completionAcceptanceState == .held, + let continuation = completionContinuation else { + throw ToolkitError(.invalidInput, message: "There is no completed package preparation to release.", recovery: "Wait for the owned-simulator package acceptance gate to report held.", technicalDetail: nil) + } + completionContinuation = nil + completionDeadline?.cancel() + completionDeadline = nil + completionAcceptanceState = .released + continuation.resume() + } + + /// Acknowledges the real chooser caller after its production selection handling returned. + func packageChoiceReturned(_ url: URL) { + if completionAcceptanceState == .released, url == heldPreparationOriginalURL { + completionAcceptanceState = .delivered + } + if let readerAcceptance { + let snapshot = readerAcceptance.snapshot + if snapshot.phase == .validationReturned, snapshot.originalURL == url { + readerChoiceReturned = true + } + } + } + + /// Holds a genuine completed preparation outside the serial Security.framework executor. + /// Its async frame owns the package until delivery, including cancellation and expiry, so + /// the production selection-generation check must dispose of an actually delivered stale A. + private func holdCompletedPreparationIfArmed(_ package: PreparedAppPackage, app: AppModel) async { + guard app.packageAcceptanceTarget != nil, completionAcceptanceState == .armed else { return } + heldPreparationDirectory = package.directory.path + heldPreparationID = package.id + heldPreparationOriginalURL = package.originalURL + await withCheckedContinuation { continuation in + completionContinuation = continuation + completionAcceptanceState = .held + completionDeadline = Task { @MainActor [weak self, weak app] in + do { + try await Task.sleep(for: .seconds(180)) + } catch is CancellationError { + return + } catch { + self?.expireCompletionAcceptance(app: app, cause: error.localizedDescription) + return + } + self?.expireCompletionAcceptance(app: app, cause: nil) + } + } + heldPreparationWasCancelled = Task.isCancelled + } + + private func expireCompletionAcceptance(app: AppModel?, cause: String?) { + guard completionAcceptanceState == .held, let continuation = completionContinuation else { return } + completionContinuation = nil + completionDeadline = nil + completionAcceptanceState = .expired + app?.present(ToolkitError(.timedOut, message: "The completed package acceptance result was not released within 180 seconds.", recovery: "Inspect the native acceptance failure and rerun the test; the actual package result will finish through normal ownership cleanup.", technicalDetail: cause)) + continuation.resume() + } +#endif + + var hasPreparedResources: Bool { preparedPackage != nil || !stalePackages.isEmpty || activePreparations > 0 || isInstalling } + + var inspection: IPAInspection? { preparedPackage?.inspection } + var appBundleName: String? { isAppBundle ? preparedPackage?.appName : nil } + var isAppBundle: Bool { packageURL?.pathExtension.lowercased() == "app" } func choose(_ url: URL, app: AppModel) async { + guard !isShuttingDown && !app.isTerminating else { + app.present(ToolkitError.invalidInput("The app is quitting. Cancel quitting before choosing another package.")) + return + } + guard !isInstalling else { + app.present(ToolkitError(.invalidInput, message: "Wait for the current installation to finish before choosing another package.", recovery: nil, technicalDetail: nil)) + return + } + let generation = UUID() + selectionID = generation + inspectionOperation?.cancel() + if let previous = preparedPackage { + do { try previous.remove() } + catch { + stalePackages.append(previous) + preparedPackage = nil + packageURL = nil + inspectionError = "The previous prepared package could not be removed. Review the reported file error before choosing another package." + app.present(error) + return + } + } packageURL = url - inspection = nil - appBundleName = nil - if url.pathExtension == "app" { - appBundleName = url.deletingPathExtension().lastPathComponent + preparedPackage = nil + inspectionError = nil + isInspecting = true + activePreparations += 1 + defer { + if selectionID == generation { + isInspecting = false + inspectionOperation = nil + } + activePreparations -= 1 + resumeCleanupIfIdle() + } +#if DEBUG + let reader = readerAcceptance?.snapshot.phase == .armed ? readerAcceptance : nil +#endif + let result = await app.run("Inspect \(url.lastPathComponent)", workspace: .installApp, target: nil, transport: "Local inspection (Security.framework)", outputPaths: [url.path], onStart: { self.inspectionOperation = $0 }) { _ in +#if DEBUG + let package: PreparedAppPackage + if let reader { + package = try await PreparedAppPackage.prepareAsyncForReaderAcceptance(url, reader: reader) + } else { + package = try await PreparedAppPackage.prepareAsync(url) + } + await self.holdCompletedPreparationIfArmed(package, app: app) +#else + let package = try await PreparedAppPackage.prepareAsync(url) +#endif + return package + } + guard selectionID == generation else { + if let result { + do { try result.remove() } + catch { + stalePackages.append(result) + app.present(error) + } + } return } - let result = await app.run("Inspect \(url.lastPathComponent)", workspace: .installApp, target: nil, transport: "Local inspection (Security.framework)", outputPaths: [url.path]) { _ in - try IPAInspector.inspect(url) + preparedPackage = result + if result == nil { inspectionError = "Package inspection did not finish successfully. Choose the package again before installing." } + } + + func blockReason(for device: Device) -> String? { + if isShuttingDown { return "Package cleanup is running while the app quits." } + if isInstalling { return "An installation is already running." } + if isInspecting { return "Wait for package inspection to finish." } + guard let preparedPackage else { return inspectionError ?? "Choose and inspect a package before installing." } + do { + try preparedPackage.validate(for: device.target, supportsCoreDevice: device.supportsCoreDevice, simulatorBooted: device.simulatorState == .booted) + return nil + } catch { return (error as? ToolkitError)?.message ?? error.localizedDescription } + } + + func prepareInstallation(for device: Device, app: AppModel) throws -> AppInstallPlan { + guard !isInstalling && !isInspecting && !isShuttingDown, let preparedPackage else { + throw ToolkitError(.invalidInput, message: "Package inspection must finish successfully before installation.", recovery: "Choose the package again and wait for inspection.", technicalDetail: nil) + } + guard !app.isTerminating, app.selectedTarget == device.target else { + throw ToolkitError(.invalidInput, message: "The installation target changed before confirmation.", recovery: "Select the target again and review a new installation confirmation.", technicalDetail: nil) } - inspection = result + try preparedPackage.validate(for: device.target, supportsCoreDevice: device.supportsCoreDevice, simulatorBooted: device.simulatorState == .booted) + return AppInstallPlan(package: preparedPackage, device: device, developerPackage: installAsDeveloperPackage) } - func install(app: AppModel, device: Device) async { - guard let url = packageURL else { return } + func install(_ plan: AppInstallPlan, app: AppModel) async { + guard !isShuttingDown && !app.isTerminating else { + app.present(ToolkitError.invalidInput("The app is quitting. Cancel quitting before starting an app installation.")) + return + } + guard !isInstalling && !isInspecting, preparedPackage?.id == plan.package.id else { + app.present(ToolkitError(.invalidInput, message: "The package changed after this installation was confirmed.", recovery: "Inspect and confirm the selected package again.", technicalDetail: nil)) + return + } + guard app.selectedTarget == plan.device.target else { + app.present(ToolkitError(.invalidInput, message: "The installation target changed after confirmation.", recovery: "Select the target again and review a new installation confirmation.", technicalDetail: nil)) + return + } + let device = plan.device let target = device.target let runner = app.runner - let developer = installAsDeveloperPackage - let succeeded = await app.run("Install \(url.lastPathComponent)", workspace: .installApp, target: target, transport: device.kind == .simulator ? "simctl install" : (device.supportsCoreDevice ? "devicectl device install app" : "AFC + installation_proxy"), argv: [url.path]) { operation in + let url = plan.package.packageURL + isInstalling = true + defer { + isInstalling = false + installationOperation = nil + resumeCleanupIfIdle() + } + let succeeded = await app.run("Install \(plan.package.appName)", workspace: .installApp, target: target, transport: device.kind == .simulator ? "simctl install" : (device.supportsCoreDevice ? "devicectl device install app" : "AFC + installation_proxy"), argv: [plan.package.originalURL.path], onStart: { self.installationOperation = $0 }) { operation in + let validation = Task.detached(priority: .userInitiated) { try plan.package.revalidate() } + try await withTaskCancellationHandler { + try await validation.value + } onCancel: { validation.cancel() } + try Task.checkCancellation() + try plan.package.validate(for: target, supportsCoreDevice: device.supportsCoreDevice, simulatorBooted: device.simulatorState == .booted) switch device.kind { case .simulator: try await SimulatorClient(runner: runner).install(appAt: url, on: target) case .physical where device.supportsCoreDevice: _ = try await CoreDeviceClient(runner: runner).install(appAt: url, on: target) case .physical: - guard url.pathExtension == "ipa" else { throw ToolkitError(.unsupported, message: "Without Xcode, only .ipa packages can be installed.") } try await DeviceSession.with(target) { session in let afc = try await AFCClient.openMedia(session) let stagedName = "idt-\(UUID().uuidString.prefix(8)).ipa" - try? await afc.makeDirectory("/PublicStaging") + try await afc.ensureDirectory("/PublicStaging") try await afc.upload(url, to: "/PublicStaging/\(stagedName)") { sent, total in operation.report("Uploading \(ByteFormatting.string(sent)) of \(ByteFormatting.string(total))", progress: total > 0 ? Double(sent) / Double(total) * 0.5 : nil) } await afc.close() let proxy = try await InstallationProxy.open(session) defer { Task { await proxy.close() } } - try await proxy.install(stagedPackagePath: "PublicStaging/\(stagedName)", developerPackage: developer) { percent in + try await proxy.install(stagedPackagePath: "PublicStaging/\(stagedName)", developerPackage: plan.developerPackage) { percent in operation.report("Installing \(percent)%", progress: 0.5 + Double(percent) / 200) } } case .demo: - throw ToolkitError(.unsupported, message: "Demo Mode cannot install apps.") + throw ToolkitError(.unsupported, message: "Demo Mode cannot install apps.", recovery: nil, technicalDetail: nil) } return true } if succeeded == true { - app.statusMessage = "Installed \(inspection?.appName ?? url.lastPathComponent) on \(target.name)." - await app.apps.refresh(app: app, device: device) + app.statusMessage = "Installed \(plan.package.appName) on \(target.name)." + if app.selectedTarget == target { await app.apps.refresh(app: app, device: device) } + } + } + + /// Waits for this model's package readers before removing its private copy on normal quit. + func cleanupForTermination() async throws { + isShuttingDown = true + defer { isShuttingDown = false } + selectionID = UUID() + inspectionOperation?.cancel() + installationOperation?.cancel() + if activePreparations > 0 || isInstalling { + await withCheckedContinuation { cleanupWaiters.append($0) } } + if let preparedPackage { + try preparedPackage.remove() + self.preparedPackage = nil + } + while let stale = stalePackages.first { + try stale.remove() + stalePackages.removeFirst() + } + inspectionOperation = nil + packageURL = nil + inspectionError = nil + isInspecting = false + } + + private func resumeCleanupIfIdle() { + guard activePreparations == 0 && !isInstalling else { return } + let waiters = cleanupWaiters + cleanupWaiters.removeAll() + for waiter in waiters { waiter.resume() } } } diff --git a/App/iOSDeveloperToolkit/Views/AppsViews.swift b/App/iOSDeveloperToolkit/Views/AppsViews.swift index 06597e8..99a6b3a 100644 --- a/App/iOSDeveloperToolkit/Views/AppsViews.swift +++ b/App/iOSDeveloperToolkit/Views/AppsViews.swift @@ -107,15 +107,106 @@ struct InstallAppView: View { HStack { Button("Choose .ipa or .app…") { if let url = FilePanels.chooseFile(title: "Choose an app package", allowedExtensions: ["ipa", "app"]) { - Task { await install.choose(url, app: model) } + Task { + await install.choose(url, app: model) +#if DEBUG + if model.packageAcceptanceTarget != nil { install.packageChoiceReturned(url) } +#endif + } } } + .disabled(install.isInstalling) .accessibilityIdentifier("choose-package") if let url = install.packageURL { Text(url.lastPathComponent).font(.callout.monospaced()).lineLimit(1).truncationMode(.middle) + .accessibilityIdentifier("package-selection") } } } +#if DEBUG + if model.packageAcceptanceTarget != nil { + VStack(alignment: .leading, spacing: 2) { + let targets = model.simulatorDevices + Text(model.snapshot.simulators.isAvailable && targets.count == 1 && targets.first?.simulatorState == .booted ? "ready" : "waiting") + .accessibilityIdentifier("package-target-readiness") + Text(model.snapshot.simulators.summary) + .accessibilityIdentifier("package-discovery-status") + Text(install.preparedPackage?.directory.path ?? "none") + .accessibilityIdentifier("package-prepared-directory") + Text(install.preparedPackage?.id.uuidString ?? "none") + .accessibilityIdentifier("package-prepared-id") + Text(install.preparedPackage?.originalURL.path ?? "none") + .accessibilityIdentifier("package-original-url") + Text(String(model.operations.count)) + .accessibilityIdentifier("package-active-operations") + Text(install.completionAcceptanceState.rawValue) + .accessibilityIdentifier("package-completion-state") + Text(install.heldPreparationDirectory ?? "none") + .accessibilityIdentifier("package-held-directory") + Text(install.heldPreparationID?.uuidString ?? "none") + .accessibilityIdentifier("package-held-id") + Text(install.heldPreparationOriginalURL?.path ?? "none") + .accessibilityIdentifier("package-held-original-url") + Text(install.heldPreparationWasCancelled.map(String.init) ?? "pending") + .accessibilityIdentifier("package-held-cancelled") + HStack { + Button("Arm completed package acceptance") { + do { try install.armCompletionAcceptance(app: model) } + catch { model.present(error) } + } + .disabled(install.completionAcceptanceState != .idle) + .accessibilityIdentifier("package-completion-arm") + Button("Release completed package") { + do { try install.releaseCompletionAcceptance(app: model) } + catch { model.present(error) } + } + .disabled(install.completionAcceptanceState != .held) + .accessibilityIdentifier("package-completion-release") + } + Text(model.isTerminating ? "true" : "false") + .accessibilityIdentifier("package-is-terminating") + Text(install.readerSnapshot?.phase.rawValue ?? "idle") + .accessibilityIdentifier("package-reader-phase") + Text(install.readerSnapshot?.directory?.path ?? "none") + .accessibilityIdentifier("package-reader-directory") + Text(install.readerSnapshot?.packageURL?.path ?? "none") + .accessibilityIdentifier("package-reader-package-url") + Text(install.readerSnapshot?.originalURL?.path ?? "none") + .accessibilityIdentifier("package-reader-original-url") + Text(install.readerSnapshot?.cancellationRequested == true ? "true" : "false") + .accessibilityIdentifier("package-reader-cancelled") + Text(install.readerSnapshot?.signatureStatus?.rawValue ?? "pending") + .accessibilityIdentifier("package-reader-signature-status") + Text(install.readerChoiceReturned ? "true" : "false") + .accessibilityIdentifier("package-reader-choice-returned") + HStack { + Button("Arm native reader acceptance") { + do { try install.armReaderAcceptance(app: model) } + catch { model.present(error) } + } + .disabled(install.readerSnapshot != nil) + .accessibilityIdentifier("package-reader-arm") + Button("Release native reader") { + do { try install.releaseReaderAcceptance(app: model) } + catch { model.present(error) } + } + .disabled(install.readerSnapshot?.phase != .nativeHandleHeldBeforeValidation) + .accessibilityIdentifier("package-reader-release") + } + } + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + } +#endif + if install.isInspecting { + ProgressView("Inspecting the selected package…") + .accessibilityIdentifier("package-inspection-running") + } else if let error = install.inspectionError { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + .accessibilityIdentifier("package-inspection-failed") + } if let inspection = install.inspection { InspectionView(inspection: inspection, device: model.selectedDevice) } else if let name = install.appBundleName { @@ -130,37 +221,33 @@ struct InstallAppView: View { Toggle("Install as developer package", isOn: $install.installAsDeveloperPackage) .help("Asks iOS to treat the package as a development build. Only meaningful for development-signed apps.") } - let blocked = blockReason(device) + let blocked = install.blockReason(for: device) if let blocked { Label(blocked, systemImage: "hand.raised").foregroundStyle(.orange) } Button("Install on \(device.name)…") { - confirmation = PendingConfirmation(title: "Install \(install.inspection?.appName ?? install.packageURL?.lastPathComponent ?? "app")?", detail: "The app will be installed on \(device.name). iOS still checks the signature, provisioning, and entitlements.", requirement: .make(for: .deviceChange, target: device.target), target: device.target) { - Task { await install.install(app: model, device: device) } - } + do { + let plan = try install.prepareInstallation(for: device, app: model) + let developerOption = plan.device.kind == .physical && !plan.device.supportsCoreDevice + ? " Install as developer package: \(plan.developerPackage ? "on" : "off")." + : "" + confirmation = PendingConfirmation(title: "Install \(plan.package.appName)?", detail: "\(plan.package.originalURL.lastPathComponent) will be installed on \(plan.device.name).\(developerOption) iOS still checks the signature, provisioning, and entitlements.", requirement: .make(for: .deviceChange, target: plan.device.target), target: plan.device.target) { + Task { await install.install(plan, app: model) } + } + } catch { model.present(error) } } .disabled(blocked != nil) + .accessibilityIdentifier("install-package") } } } + .onChange(of: model.selectedTarget) { _, _ in confirmation = nil } + .onChange(of: install.preparedPackage?.id) { _, _ in confirmation = nil } + .onChange(of: install.installAsDeveloperPackage) { _, _ in confirmation = nil } .sheet(item: $confirmation) { pending in ConfirmationSheet(title: pending.title, detail: pending.detail, requirement: pending.requirement, target: pending.target, commandPreview: nil, onConfirm: pending.action) } } - - private func blockReason(_ device: Device) -> String? { - let install = model.install - switch device.kind { - case .demo: return "Demo Mode cannot install apps." - case .simulator: - if !install.isAppBundle { return "Simulators install .app bundles built for the simulator, not .ipa files." } - if device.simulatorState != .booted { return "Start the simulator first." } - return nil - case .physical: - if let inspection = install.inspection, !inspection.isInstallable { return inspection.installabilityExplanation } - return nil - } - } } struct InspectionView: View { diff --git a/App/iOSDeveloperToolkit/Views/ContentView.swift b/App/iOSDeveloperToolkit/Views/ContentView.swift index 903ad9c..fdec42d 100644 --- a/App/iOSDeveloperToolkit/Views/ContentView.swift +++ b/App/iOSDeveloperToolkit/Views/ContentView.swift @@ -232,6 +232,7 @@ struct MainToolbar: ToolbarContent { } label: { Label("Refresh Devices", systemImage: "arrow.clockwise") } + .labelStyle(.iconOnly) .help("Look for devices again (⌘R)") .disabled(model.isRefreshing) .accessibilityIdentifier("retry-scan") @@ -240,6 +241,7 @@ struct MainToolbar: ToolbarContent { } label: { Label("Action Palette", systemImage: "command") } + .labelStyle(.iconOnly) .help("Search every workspace and action (⌘K)") .accessibilityIdentifier("toolbar-action-palette") Menu { @@ -255,6 +257,7 @@ struct MainToolbar: ToolbarContent { } label: { Label("Workspace Controls", systemImage: "ellipsis.circle") } + .labelStyle(.iconOnly) .accessibilityIdentifier("workspace-controls") } } @@ -289,6 +292,7 @@ struct TargetPicker: View { } label: { Label("\(device.name) — \(device.displayVersion)\(device.simulatorState == .booted ? " (running)" : "")", systemImage: "\(device.family.symbolName)") } + .accessibilityIdentifier("target-simulator-\(device.target.udid)") } } if model.selectedDeviceID != nil { @@ -302,6 +306,7 @@ struct TargetPicker: View { Text(model.selectedDevice?.name ?? "Choose a Device") .font(.headline) .lineLimit(1) + .truncationMode(.middle) if let device = model.selectedDevice { Text(device.kind.label) .font(.caption2) @@ -311,7 +316,9 @@ struct TargetPicker: View { } } .menuIndicator(.visible) - .fixedSize() + // Bound the complete menu so long target names cannot displace minimum-width controls. + .frame(width: 220) + .fixedSize(horizontal: false, vertical: true) .help("Choose the device or simulator that actions affect") .accessibilityIdentifier("target-picker") } diff --git a/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift b/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift index 9269709..b52c816 100644 --- a/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift +++ b/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift @@ -53,6 +53,8 @@ struct IOSDeveloperToolkitApp: App { final class AppDelegate: NSObject, NSApplicationDelegate { weak var model: AppModel? + private var terminationRequestID: UUID? + private var terminationDeadlineTask: Task? func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { MainActor.assumeIsolated { @@ -65,6 +67,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { MainActor.assumeIsolated { guard let model else { return .terminateNow } + guard !model.isTerminating else { return .terminateCancel } let busy = !model.operations.isEmpty || model.firmware.isInstalling || model.logs.sessions.contains { $0.state.isActive } let decision = FirmwareTerminationPolicy.decision(critical: model.firmware.terminationBlocked, busy: busy) if decision == .deny { @@ -80,6 +83,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } alert.addButton(withTitle: "Quit Anyway") alert.addButton(withTitle: "Cancel") + alert.buttons[0].setAccessibilityIdentifier("quit-operations-confirm") + alert.buttons[1].setAccessibilityIdentifier("quit-operations-cancel") guard alert.runModal() == .alertFirstButtonReturn else { return .terminateCancel } } // A modal alert runs a nested event loop. Writing may have started while it was open. @@ -87,14 +92,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate { explainFirmwareWriting() return .terminateCancel } - if model.firmware.isInstalling { + if model.firmware.isInstalling || model.install.hasPreparedResources { + model.beginTermination() + let requestID = UUID() + terminationRequestID = requestID Task { @MainActor in - guard await model.firmware.cancelForTermination() else { - self.explainFirmwareWriting() - sender.reply(toApplicationShouldTerminate: false) + var ready = true + var packageCleanupFailed = false + if model.firmware.isInstalling { + ready = await model.firmware.cancelForTermination() + if !ready { self.explainFirmwareWriting() } + } + if ready { + do { try await model.install.cleanupForTermination() } + catch { model.present(error); packageCleanupFailed = true; ready = false } + } + guard self.terminationRequestID == requestID else { + model.cancelTermination() return } - sender.reply(toApplicationShouldTerminate: self.finishTermination(model) == .terminateNow) + if ready { ready = self.finishTermination(model) == .terminateNow } + self.terminationRequestID = nil + self.terminationDeadlineTask?.cancel() + self.terminationDeadlineTask = nil + if !ready { model.cancelTermination() } + sender.reply(toApplicationShouldTerminate: ready) + if packageCleanupFailed { self.showTerminationFailureWithoutWindow(model) } + } + terminationDeadlineTask = Task { @MainActor in + do { try await Task.sleep(for: .seconds(8)) } + catch is CancellationError { return } + catch { model.present(error); return } + guard self.terminationRequestID == requestID else { return } + self.terminationRequestID = nil + model.present(ToolkitError(.timedOut, message: "Quit cleanup is still running after eight seconds. The app will remain open.", recovery: "Wait for the active operation to finish, then try quitting again.")) + sender.reply(toApplicationShouldTerminate: false) + self.showTerminationFailureWithoutWindow(model) } return .terminateLater } @@ -102,6 +135,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + @MainActor + private func showTerminationFailureWithoutWindow(_ model: AppModel) { + guard !NSApp.windows.contains(where: { $0.isVisible && $0.contentView != nil }), + let error = model.presentedError else { return } + let alert = NSAlert() + alert.messageText = error.title + alert.informativeText = [error.message, error.recovery].compactMap { $0 }.joined(separator: "\n\n") + alert.addButton(withTitle: "Keep Running") + alert.runModal() + } + @MainActor private func explainFirmwareWriting() { let alert = NSAlert() diff --git a/Sources/DeviceKit/Discovery/DeviceDiscovery.swift b/Sources/DeviceKit/Discovery/DeviceDiscovery.swift index afed333..175731a 100644 --- a/Sources/DeviceKit/Discovery/DeviceDiscovery.swift +++ b/Sources/DeviceKit/Discovery/DeviceDiscovery.swift @@ -406,12 +406,16 @@ public actor DeviceDiscovery { } } + /// A cancelled refresh cannot replace the last completed simulator snapshot. private func refreshSimulators() async { - guard let client = configuration.simulators else { return } + guard !Task.isCancelled, let client = configuration.simulators else { return } do { - simulatorRecords = try await client.list() + let records = try await client.list() + guard !Task.isCancelled else { return } + simulatorRecords = records snapshot.simulators = .available(count: simulatorRecords.filter(\.isAvailable).count) } catch { + guard !Task.isCancelled else { return } simulatorRecords = [] let reason = (error as? ToolkitError).map { $0.kind == .toolMissing ? "Xcode is not installed, so simulators are unavailable." : $0.message } ?? error.localizedDescription snapshot.simulators = .unavailable(reason: reason) diff --git a/Sources/DeviceKit/Services/AFCClient.swift b/Sources/DeviceKit/Services/AFCClient.swift index 86ebe75..4ac5c60 100644 --- a/Sources/DeviceKit/Services/AFCClient.swift +++ b/Sources/DeviceKit/Services/AFCClient.swift @@ -154,6 +154,18 @@ public actor AFCClient { _ = try await transact(.makeDirectory, header: Self.path(path)) } + /// Accepts an existing real directory or creates a missing one. Other AFC failures propagate. + public func ensureDirectory(_ path: String) async throws { + do { + let info = try await fileInfo(path) + guard info.isDirectory, !info.isSymbolicLink else { + throw ToolkitError(.fileSystem, message: "The requested device directory is a file or symbolic link.", recovery: "Choose a regular device directory before continuing.") + } + } catch let error as ToolkitError where error == Self.error(code: 8) { + try await makeDirectory(path) + } + } + public func remove(_ path: String) async throws { _ = try await transact(.removePath, header: Self.path(path)) } diff --git a/Sources/ToolkitFeatures/IPA/IPAInspector.swift b/Sources/ToolkitFeatures/IPA/IPAInspector.swift index c6d812f..0cb3682 100644 --- a/Sources/ToolkitFeatures/IPA/IPAInspector.swift +++ b/Sources/ToolkitFeatures/IPA/IPAInspector.swift @@ -68,8 +68,17 @@ public struct IPAInspection: Sendable, Hashable, Codable { /// Inspects an `.ipa` package locally before any installation is offered. public enum IPAInspector { + /// Suspends the caller while blocking archive and native signature checks finish. Cancellation + /// waits for native validation and scratch cleanup before it is reported. + public static func inspectAsync(_ url: URL) async throws -> IPAInspection { + let inspection = try await PackageInspectionExecutor.run { _ in try inspect(url) } + try Task.checkCancellation() + return inspection + } + public static let maximumInfoPlistBytes: UInt64 = 10 * 1024 * 1024 + /// Blocking entry point for synchronous callers. Swift tasks should use `inspectAsync`. public static func inspect(_ url: URL) throws -> IPAInspection { guard url.pathExtension.lowercased() == "ipa" else { throw ToolkitError.invalidInput("Choose an .ipa package.") @@ -95,6 +104,14 @@ public enum IPAInspector { } let appRoot = String(infoEntry.name.dropLast("/Info.plist".count)) let bundleIdentifier = try required("CFBundleIdentifier") + guard let platformValues = info["CFBundleSupportedPlatforms"]?.arrayValue, !platformValues.isEmpty else { + throw ToolkitError.invalidInput("The app's Info.plist must declare CFBundleSupportedPlatforms as a nonempty string array.") + } + let supportedPlatforms = platformValues.compactMap(\.stringValue) + guard supportedPlatforms.count == platformValues.count, + supportedPlatforms.allSatisfy({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { + throw ToolkitError.invalidInput("CFBundleSupportedPlatforms contains a non-string or empty value.") + } let provisioning: ProvisioningProfileSummary if let profileEntry = archive.entry(named: "\(appRoot)/embedded.mobileprovision") { @@ -122,7 +139,7 @@ public enum IPAInspector { build: try required("CFBundleVersion"), minimumOSVersion: info["MinimumOSVersion"]?.stringValue, executableName: try required("CFBundleExecutable"), - supportedPlatforms: info["CFBundleSupportedPlatforms"]?.arrayValue?.compactMap(\.stringValue) ?? [], + supportedPlatforms: supportedPlatforms, provisioning: provisioning, signature: signature ) diff --git a/Sources/ToolkitFeatures/IPA/PackageInspectionReaderAcceptance.swift b/Sources/ToolkitFeatures/IPA/PackageInspectionReaderAcceptance.swift new file mode 100644 index 0000000..2bb0ece --- /dev/null +++ b/Sources/ToolkitFeatures/IPA/PackageInspectionReaderAcceptance.swift @@ -0,0 +1,155 @@ +#if DEBUG +import Dispatch +import Foundation +import ToolkitCore + +/// Connects explicit native acceptance controls to a genuine inspection worker. Only its +/// Dispatch thread waits; the real native code handle and incomplete copy stay owned until +/// release or finite expiry. This observes a handle before validation, not an active C call. +public struct PackageInspectionReaderAcceptance: Sendable { + public enum Phase: String, Sendable { + case armed + case nativeHandleHeldBeforeValidation = "native-handle-held-before-validation" + case released + case validationReturned = "validation-returned" + case expired + case aborted + } + + public struct Snapshot: Sendable { + public let revision: UInt64 + public let phase: Phase + public let originalURL: URL? + public let packageURL: URL? + public let directory: URL? + public let cancellationRequested: Bool + public let signatureStatus: CodeSignatureSummary.Status? + } + + private struct State { + var revision: UInt64 = 0 + var phase: Phase = .armed + var originalURL: URL? + var packageURL: URL? + var cancellationRequested = false + var signatureStatus: CodeSignatureSummary.Status? + + var snapshot: Snapshot { + Snapshot(revision: revision, phase: phase, originalURL: originalURL, + packageURL: packageURL, directory: packageURL?.deletingLastPathComponent(), + cancellationRequested: cancellationRequested, signatureStatus: signatureStatus) + } + } + + private let state: LockedValue + private let barrier: DispatchSemaphore + private let observer: @Sendable (Snapshot) -> Void + + public init(observer: @escaping @Sendable (Snapshot) -> Void) { + state = LockedValue(State()) + barrier = DispatchSemaphore(value: 0) + self.observer = observer + } + + public var snapshot: Snapshot { state.current.snapshot } + + public func release() throws { + let update = try state.withLock { value -> Snapshot in + guard value.phase == .nativeHandleHeldBeforeValidation else { + throw ToolkitError(.invalidInput, message: "No native package reader is waiting for release.", recovery: "Wait for the explicit owned-simulator acceptance reader to open its real code handle.", technicalDetail: nil) + } + value.phase = .released + value.revision += 1 + return value.snapshot + } + observer(update) + barrier.signal() + } + + func begin(source: URL) throws { + let update = try state.withLock { value -> Snapshot in + if value.phase == .aborted { throw CancellationError() } + guard value.phase == .armed, value.originalURL == nil else { + throw ToolkitError(.invalidInput, message: "The native package reader acceptance gate has already been used.", recovery: "Launch a fresh owned-simulator acceptance session.", technicalDetail: nil) + } + value.originalURL = source.standardizedFileURL + value.revision += 1 + return value.snapshot + } + observer(update) + } + + /// Failure teardown must close the armed-to-held race before a structured task group + /// drains its worker. A later real native handle cannot park after this atomic close. + func finishAfterFailure() { + let update = state.withLock { value -> (snapshot: Snapshot, signal: Bool) in + switch value.phase { + case .armed: + value.phase = .aborted + value.revision += 1 + return (value.snapshot, false) + case .nativeHandleHeldBeforeValidation: + value.phase = .released + value.revision += 1 + return (value.snapshot, true) + case .released, .validationReturned, .expired, .aborted: + return (value.snapshot, false) + } + } + observer(update.snapshot) + if update.signal { barrier.signal() } + } + + /// Called only after the executor has set its actual cancellation token. + func cancellationRequested() { + let update = state.withLock { value -> Snapshot in + value.cancellationRequested = true + value.revision += 1 + return value.snapshot + } + observer(update) + } + + func holdNativeHandle(bundle: URL) throws { + let update = try state.withLock { value -> Snapshot in + if value.phase == .aborted { throw CancellationError() } + guard value.phase == .armed, value.originalURL != nil else { + throw ToolkitError(.internalInconsistency, message: "The native package reader acceptance gate is not armed.", recovery: "Inspect the acceptance launch and choose the genuine fixture again.", technicalDetail: nil) + } + value.packageURL = bundle + value.phase = .nativeHandleHeldBeforeValidation + value.revision += 1 + return value.snapshot + } + observer(update) + + let outcome = barrier.wait(timeout: .now() + 180) + let final = try state.withLock { value -> Snapshot in + // Release and expiry race under the same lock. A release that won remains valid + // even if its signal reached this thread at the wait's exact deadline. + if outcome == .timedOut, value.phase == .nativeHandleHeldBeforeValidation { + value.phase = .expired + value.revision += 1 + } + guard value.phase == .released || value.phase == .expired else { + throw ToolkitError(.internalInconsistency, message: "The native package reader ended in an invalid acceptance phase.", recovery: "Inspect the native acceptance failure before retrying.", technicalDetail: nil) + } + return value.snapshot + } + if final.phase == .expired { + observer(final) + throw ToolkitError(.timedOut, message: "The native package reader was not released within 180 seconds.", recovery: "Inspect the native acceptance failure; the worker will release its native handle and remove the incomplete private copy.", technicalDetail: nil) + } + } + + func validationReturned(status: CodeSignatureSummary.Status) { + let update = state.withLock { value -> Snapshot in + value.signatureStatus = status + value.phase = .validationReturned + value.revision += 1 + return value.snapshot + } + observer(update) + } +} +#endif diff --git a/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift b/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift new file mode 100644 index 0000000..6d78f94 --- /dev/null +++ b/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift @@ -0,0 +1,383 @@ +import CryptoKit +import Darwin +import DeviceKit +import Foundation +import ToolkitCore + +/// An inspected private copy bound to the original package's content. Installation consumes +/// `packageURL`, after revalidation, and cleanup removes only this preparation's scratch folder. +public struct PreparedAppPackage: Sendable { + public let id: UUID + public let originalURL: URL + public let packageURL: URL + public let directory: URL + public let inspection: IPAInspection? + public let appName: String + public let supportedPlatforms: [String] + public let signature: CodeSignatureSummary? + + private let expectedIdentity: String + private let directoryDevice: dev_t + private let directoryInode: ino_t + private static let maximumBytes = ZipArchive.maximumTotalUncompressedBytes + + /// Suspends during blocking package and signature work. A cancelled caller keeps ownership + /// until native validation finishes, then removes its private copy before receiving cancellation. + public static func prepareAsync(_ source: URL) async throws -> PreparedAppPackage { + let package = try await PackageInspectionExecutor.run { checkCancellation in + try prepare(source, checkCancellation: checkCancellation, inspectSignature: { CodeSignatureInspector.inspect(bundle: $0) }) + } + return try finishPreparation(package) + } + +#if DEBUG + /// Explicit acceptance keeps a genuine native code handle alive before validation. The + /// ordinary signature verifier and subsequent cancellation/ownership cleanup still run. + public static func prepareAsyncForReaderAcceptance(_ source: URL, reader: PackageInspectionReaderAcceptance) async throws -> PreparedAppPackage { + guard source.isFileURL, source.pathExtension.lowercased() == "app" else { + throw ToolkitError.invalidInput("Native reader acceptance requires a genuine .app bundle.") + } + try reader.begin(source: source) + let package = try await PackageInspectionExecutor.runForReaderAcceptance(reader: reader) { checkCancellation in + try prepare(source, checkCancellation: checkCancellation, inspectSignature: { try CodeSignatureInspector.inspectForReaderAcceptance(bundle: $0, reader: reader) }) + } + return try finishPreparation(package) + } +#endif + + private static func finishPreparation(_ package: PreparedAppPackage) throws -> PreparedAppPackage { + guard !Task.isCancelled else { + do { try package.remove() } + catch { + throw ToolkitError.fileSystem("Package inspection was stopped, but its private copy could not be removed.", path: package.directory.path, underlying: error) + } + throw CancellationError() + } + return package + } + + /// Blocking entry point for synchronous callers. Swift tasks should use `prepareAsync`. + public static func prepare(_ source: URL) throws -> PreparedAppPackage { + try prepare(source, checkCancellation: { try Task.checkCancellation() }, inspectSignature: { CodeSignatureInspector.inspect(bundle: $0) }) + } + + private static func prepare(_ source: URL, checkCancellation: PackageInspectionExecutor.CancellationCheck, inspectSignature: @Sendable (URL) throws -> CodeSignatureSummary) throws -> PreparedAppPackage { + try checkCancellation() + guard source.isFileURL, ["ipa", "app"].contains(source.pathExtension.lowercased()) else { + throw ToolkitError.invalidInput("Choose an .ipa package or an iOS .app bundle.") + } + let original = source.standardizedFileURL + let identity = try packageIdentity(original, checkCancellation: checkCancellation) + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "idt-prepared-app") + let copy = directory.appendingPathComponent(original.lastPathComponent) + do { + try FileManager.default.copyItem(at: original, to: copy) + try checkCancellation() + guard try packageIdentity(original, checkCancellation: checkCancellation) == identity, try packageIdentity(copy, checkCancellation: checkCancellation) == identity else { + throw ToolkitError(.invalidInput, message: "The package changed while it was being prepared.", recovery: "Choose the package again after its build or copy has finished.", technicalDetail: original.path) + } + let inspection: IPAInspection? + let appName: String + let platforms: [String] + let signature: CodeSignatureSummary? + if original.pathExtension.lowercased() == "ipa" { + let inspected = try IPAInspector.inspect(copy) + inspection = inspected + appName = inspected.appName + platforms = try validatedPlatforms(inspected.supportedPlatforms) + signature = inspected.signature + } else { + let info = try bundleInfo(copy, checkCancellation: checkCancellation) + inspection = nil + appName = info.name + platforms = info.platforms + signature = try inspectSignature(copy) + } + guard try packageIdentity(copy, checkCancellation: checkCancellation) == identity, try packageIdentity(original, checkCancellation: checkCancellation) == identity else { + throw ToolkitError(.invalidInput, message: "The package changed during inspection.", recovery: "Choose the completed package again.", technicalDetail: original.path) + } + let ownership = try metadata(directory) + return PreparedAppPackage(id: UUID(), originalURL: original, packageURL: copy, directory: directory, inspection: inspection, appName: appName, supportedPlatforms: platforms, signature: signature, expectedIdentity: identity, directoryDevice: ownership.st_dev, directoryInode: ownership.st_ino) + } catch { + do { try FileManager.default.removeItem(at: directory) } + catch let cleanupError { + throw ToolkitError.wrap(error, message: "Could not prepare the app package.", recovery: "Choose a valid package again.").appendingDetail("Could not remove the incomplete private copy at \(directory.path): \(cleanupError.localizedDescription)") + } + throw error + } + } + + /// Checks captured metadata for the existing installation routes without filesystem I/O. + /// Call `revalidate()` immediately before execution. A physical device still decides provisioning. + public func validate(for target: DeviceTarget, supportsCoreDevice: Bool, simulatorBooted: Bool) throws { + switch target.kind { + case .demo: + throw ToolkitError(.unsupported, message: "Demo Mode does not install packages.", recovery: "Select a real simulator or an authorized physical device.", technicalDetail: nil) + case .simulator: + guard inspection == nil, packageURL.pathExtension.lowercased() == "app" else { + throw ToolkitError(.unsupported, message: "Simulators install .app bundles built for the simulator, not .ipa files.", recovery: "Build for an iOS Simulator destination and choose the resulting .app bundle.", technicalDetail: nil) + } + guard supportedPlatforms.contains("iPhoneSimulator") else { + throw ToolkitError(.unsupported, message: "This app bundle was not built for an iOS Simulator.", recovery: "Choose the .app from an iOS Simulator build.", technicalDetail: nil) + } + guard simulatorBooted else { + throw ToolkitError(.invalidInput, message: "Boot the selected simulator before installing the app.", recovery: "Boot it in Device & DDI, then confirm installation again.", technicalDetail: nil) + } + case .physical: + guard supportedPlatforms.contains("iPhoneOS") else { + throw ToolkitError(.unsupported, message: "This package was not built for a physical iOS device.", recovery: "Choose an iPhoneOS build with the required device signing and provisioning.", technicalDetail: nil) + } + if let inspection { + guard inspection.isInstallable else { + throw ToolkitError(.invalidInput, message: inspection.installabilityExplanation, recovery: "Rebuild and sign the package in Xcode, then choose it again.", technicalDetail: nil) + } + } else { + guard supportsCoreDevice else { + throw ToolkitError(.unsupported, message: "Installing a .app bundle on a physical device requires Xcode CoreDevice tools.", recovery: "Select Xcode or choose a signed .ipa for native installation.", technicalDetail: nil) + } + guard signature?.status == .valid else { + throw ToolkitError(.invalidInput, message: "The app bundle does not have an intact code signature.", recovery: "Rebuild and sign the iOS app in Xcode, then choose it again.", technicalDetail: signature?.detail) + } + } + } + } + + public func revalidate() throws { + try verifyDirectoryOwnership() + guard try Self.packageIdentity(originalURL) == expectedIdentity, try Self.packageIdentity(packageURL) == expectedIdentity else { + throw ToolkitError(.invalidInput, message: "The selected package or its prepared copy changed after inspection.", recovery: "Choose the package again and review a new installation confirmation.", technicalDetail: originalURL.path) + } + } + + public func remove() throws { + try verifyDirectoryOwnership() + do { try FileManager.default.removeItem(at: directory) } + catch { throw ToolkitError.fileSystem("Could not remove the prepared app package.", path: directory.path, underlying: error) } + } + + private func verifyDirectoryOwnership() throws { + let current = try Self.metadata(directory) + guard current.st_mode & S_IFMT == S_IFDIR, current.st_mode & 0o777 == 0o700, current.st_dev == directoryDevice, current.st_ino == directoryInode else { + throw ToolkitError.fileSystem("The private app-package folder was replaced. It will not be used or removed.", path: directory.path, underlying: nil) + } + } + + private static func validatedPlatforms(_ values: [String]) throws -> [String] { + guard !values.isEmpty, values.allSatisfy({ !$0.isEmpty }) else { + throw ToolkitError.invalidInput("The app's Info.plist must declare CFBundleSupportedPlatforms.") + } + return values + } + + private static func bundleInfo(_ bundle: URL, checkCancellation: PackageInspectionExecutor.CancellationCheck) throws -> (name: String, platforms: [String]) { + let descriptor = try openChild(root: bundle, relativePath: "Info.plist") + let data = try readFile(descriptor, maximumBytes: IPAInspector.maximumInfoPlistBytes, checkCancellation: checkCancellation) + let info = try PlistValue.decode(data) + guard info.dictionaryValue != nil else { throw ToolkitError.invalidInput("The app's Info.plist is not a dictionary.") } + func required(_ key: String) throws -> String { + guard let value = info[key]?.stringValue, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ToolkitError.invalidInput("The app's Info.plist is missing \(key).") + } + return value + } + let identifier = try required("CFBundleIdentifier") + try BundleIdentifier.validate(identifier) + _ = try required("CFBundleShortVersionString") + _ = try required("CFBundleVersion") + let executable = try required("CFBundleExecutable") + guard !executable.contains("/"), !executable.contains("\0"), executable != ".", executable != ".." else { + throw ToolkitError.invalidInput("The app's CFBundleExecutable is not a safe filename.") + } + let executableInfo = try metadata(bundle.appendingPathComponent(executable)) + guard executableInfo.st_mode & S_IFMT == S_IFREG, executableInfo.st_mode & 0o111 != 0 else { + throw ToolkitError.invalidInput("The app's declared executable is missing or is not an executable regular file.") + } + guard let platformValues = info["CFBundleSupportedPlatforms"]?.arrayValue else { + throw ToolkitError.invalidInput("The app's Info.plist must declare CFBundleSupportedPlatforms as a string array.") + } + let platforms = platformValues.compactMap(\.stringValue) + guard platforms.count == platformValues.count else { throw ToolkitError.invalidInput("CFBundleSupportedPlatforms contains a non-string value.") } + let name: String + if info["CFBundleDisplayName"] != nil { name = try required("CFBundleDisplayName") } + else { name = try required("CFBundleName") } + return (name, try validatedPlatforms(platforms)) + } + + private struct TreeEntry: Codable { + let path: String + let kind: String + let permissions: mode_t + let hash: String? + let link: String? + } + + private static func packageIdentity(_ url: URL) throws -> String { + try packageIdentity(url, checkCancellation: { try Task.checkCancellation() }) + } + + private static func packageIdentity(_ url: URL, checkCancellation: PackageInspectionExecutor.CancellationCheck) throws -> String { + try checkCancellation() + let info = try metadata(url) + if url.pathExtension.lowercased() == "ipa" { + guard info.st_mode & S_IFMT == S_IFREG else { throw ToolkitError.invalidInput("The IPA package must be a regular file, not a symbolic link.") } + let descriptor = open(url.path, O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC) + guard descriptor >= 0 else { throw fileError("Could not open the IPA package safely.", url: url) } + return try hashFile(descriptor, url: url, maximumBytes: maximumBytes, checkCancellation: checkCancellation).hash + } + guard info.st_mode & S_IFMT == S_IFDIR else { throw ToolkitError.invalidInput("The app bundle must be a directory, not a symbolic link.") } + let root = url.resolvingSymlinksInPath() + var enumerationFailure: ToolkitError? + guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: [], options: [], errorHandler: { failedURL, error in + enumerationFailure = ToolkitError.fileSystem("Could not enumerate every app-bundle file.", path: failedURL.path, underlying: error) + return false + }) else { + throw ToolkitError.fileSystem("Could not inspect the app bundle's files.", path: root.path, underlying: nil) + } + var entries = [TreeEntry(path: "", kind: "directory", permissions: info.st_mode & 0o7777, hash: nil, link: nil)] + var totalBytes: UInt64 = 0 + for case let enumeratedURL as URL in enumerator { + try checkCancellation() + guard entries.count < ZipArchive.maximumEntries else { throw ToolkitError.invalidInput("The app bundle exceeds the package file-count limit.") } + // FileManager may return /var URLs for a canonical /private/var root. Resolve only + // the parent so a leaf symbolic link remains visible to lstat and the identity. + let child = enumeratedURL.deletingLastPathComponent().resolvingSymlinksInPath().appendingPathComponent(enumeratedURL.lastPathComponent) + guard child.path.hasPrefix(root.path + "/") else { throw ToolkitError.invalidInput("An app-bundle path escaped its root.") } + let relativePath = String(child.path.dropFirst(root.path.count + 1)) + let fileMetadata = try metadata(child) + let kind = fileMetadata.st_mode & S_IFMT + switch kind { + case S_IFREG: + let descriptor = try openChild(root: root, relativePath: relativePath) + let file = try hashFile(descriptor, url: child, maximumBytes: maximumBytes - totalBytes, checkCancellation: checkCancellation) + totalBytes += file.bytes + entries.append(TreeEntry(path: relativePath, kind: "file", permissions: fileMetadata.st_mode & 0o7777, hash: file.hash, link: nil)) + case S_IFDIR: + guard child.resolvingSymlinksInPath().path.hasPrefix(root.path + "/") else { throw ToolkitError.invalidInput("An app-bundle directory escaped its root.") } + entries.append(TreeEntry(path: relativePath, kind: "directory", permissions: fileMetadata.st_mode & 0o7777, hash: nil, link: nil)) + case S_IFLNK: + enumerator.skipDescendants() + let link = try FileManager.default.destinationOfSymbolicLink(atPath: child.path) + let destination = child.deletingLastPathComponent().appendingPathComponent(link).standardizedFileURL + let resolved = child.resolvingSymlinksInPath() + guard !link.hasPrefix("/"), !link.contains("\0"), destination.path.hasPrefix(root.standardizedFileURL.path + "/"), resolved.path.hasPrefix(root.path + "/"), FileManager.default.fileExists(atPath: resolved.path) else { + throw ToolkitError.invalidInput("The app bundle contains an absolute, escaping, broken, or cyclic symbolic link: \(relativePath)") + } + entries.append(TreeEntry(path: relativePath, kind: "symlink", permissions: fileMetadata.st_mode & 0o7777, hash: nil, link: link)) + default: + throw ToolkitError.invalidInput("The app bundle contains a non-regular file: \(relativePath)") + } + } + if let enumerationFailure { throw enumerationFailure } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return SecureFileIO.sha256(of: try encoder.encode(entries.sorted { $0.path < $1.path })) + } + + /// Opens every directory component without following links, so a changed bundle path cannot + /// redirect a content read outside the bundle. Internal framework links are inventoried as links. + private static func openChild(root: URL, relativePath: String) throws -> Int32 { + var directory = open(root.path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard directory >= 0 else { throw fileError("Could not open the app bundle safely.", url: root) } + var ownedDescriptors = [directory] + do { + let components = relativePath.split(separator: "/").map(String.init) + guard let last = components.last, !components.contains(".."), !components.contains(".") else { throw ToolkitError.invalidInput("An unsafe app-bundle path was rejected.") } + for component in components.dropLast() { + let next = openat(directory, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard next >= 0 else { throw fileError("Could not open an app-bundle directory safely.", url: root.appendingPathComponent(relativePath)) } + ownedDescriptors.append(next) + ownedDescriptors.removeAll { $0 == directory } + try closeDescriptor(directory, root: root) + directory = next + } + let descriptor = openat(directory, last, O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC) + guard descriptor >= 0 else { throw fileError("Could not open an app-bundle file safely.", url: root.appendingPathComponent(relativePath)) } + ownedDescriptors.append(descriptor) + ownedDescriptors.removeAll { $0 == directory } + try closeDescriptor(directory, root: root) + return descriptor + } catch { + var cleanupFailures: [String] = [] + for descriptor in ownedDescriptors { + do { try closeDescriptor(descriptor, root: root) } + catch { cleanupFailures.append((error as? ToolkitError)?.technicalDetail ?? error.localizedDescription) } + } + guard cleanupFailures.isEmpty else { + throw ToolkitError.wrap(error, message: "Could not open the app-bundle file safely.", recovery: "Choose the package again.").appendingDetail("Closing app-bundle descriptors also failed: \(cleanupFailures.joined(separator: "; "))") + } + throw error + } + } + + /// A close failure leaves descriptor ownership platform-dependent; surface it without retrying + /// a descriptor number that could already have been released and reused by another thread. + private static func closeDescriptor(_ descriptor: Int32, root: URL) throws { + guard close(descriptor) == 0 else { throw fileError("Could not close an app-bundle descriptor.", url: root) } + } + + private static func hashFile(_ descriptor: Int32, url: URL, maximumBytes: UInt64, checkCancellation: PackageInspectionExecutor.CancellationCheck) throws -> (hash: String, bytes: UInt64) { + try withFileHandle(descriptor) { handle in + var info = stat() + guard fstat(descriptor, &info) == 0 else { throw fileError("Could not inspect the package file.", url: url) } + guard info.st_mode & S_IFMT == S_IFREG, info.st_size >= 0, UInt64(info.st_size) <= maximumBytes else { + throw ToolkitError.invalidInput("The package contains an invalid file or exceeds the 8 GB package preparation limit.") + } + var hasher = SHA256() + var bytes: UInt64 = 0 + while let data = try handle.read(upToCount: 1 << 20), !data.isEmpty { + try checkCancellation() + guard UInt64(data.count) <= maximumBytes - bytes else { throw ToolkitError.invalidInput("The package changed size or exceeds the package preparation limit.") } + bytes += UInt64(data.count) + hasher.update(data: data) + } + return (hasher.finalize().map { String(format: "%02x", $0) }.joined(), bytes) + } + } + + private static func readFile(_ descriptor: Int32, maximumBytes: UInt64, checkCancellation: PackageInspectionExecutor.CancellationCheck) throws -> Data { + try withFileHandle(descriptor) { handle in + var info = stat() + guard fstat(descriptor, &info) == 0 else { + throw ToolkitError.fileSystem("Could not read the app's Info.plist metadata.", path: nil, underlying: POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)) + } + guard info.st_mode & S_IFMT == S_IFREG, info.st_size >= 0, UInt64(info.st_size) <= maximumBytes else { + throw ToolkitError.invalidInput("The app's Info.plist must be a regular file within the 10 MB inspection limit.") + } + var data = Data() + while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty { + try checkCancellation() + guard UInt64(chunk.count) <= maximumBytes - UInt64(data.count) else { + throw ToolkitError.invalidInput("The app's Info.plist exceeds the 10 MB inspection limit.") + } + data.append(chunk) + } + return data + } + } + + private static func withFileHandle(_ descriptor: Int32, operation: (FileHandle) throws -> Value) throws -> Value { + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + let value: Value + do { value = try operation(handle) } + catch { + do { try handle.close() } + catch let cleanupError { + throw ToolkitError.wrap(error, message: "Could not read the app package.", recovery: "Choose the package again.").appendingDetail("Closing the package file also failed: \(cleanupError.localizedDescription)") + } + throw error + } + do { try handle.close() } + catch { throw ToolkitError.fileSystem("Could not close the app-package file after reading it.", path: nil, underlying: error) } + return value + } + + private static func metadata(_ url: URL) throws -> stat { + var info = stat() + guard lstat(url.path, &info) == 0 else { throw fileError("Could not read the package's file metadata.", url: url) } + return info + } + + private static func fileError(_ message: String, url: URL) -> ToolkitError { + ToolkitError.fileSystem(message, path: url.path, underlying: POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)) + } +} diff --git a/Sources/ToolkitFeatures/IPA/SigningInspection.swift b/Sources/ToolkitFeatures/IPA/SigningInspection.swift index a00966d..2617614 100644 --- a/Sources/ToolkitFeatures/IPA/SigningInspection.swift +++ b/Sources/ToolkitFeatures/IPA/SigningInspection.swift @@ -1,3 +1,4 @@ +import Dispatch import Foundation import Security import ToolkitCore @@ -141,6 +142,25 @@ public enum CodeSignatureInspector { guard SecStaticCodeCreateWithPath(url as CFURL, [], &codeReference) == errSecSuccess, let code = codeReference else { return CodeSignatureSummary(status: .missing, identifier: nil, teamIdentifier: nil, authorities: [], detail: "The app bundle could not be opened for signature checking.") } + return verify(code) + } + +#if DEBUG + static func inspectForReaderAcceptance(bundle url: URL, reader: PackageInspectionReaderAcceptance) throws -> CodeSignatureSummary { + var codeReference: SecStaticCode? + guard SecStaticCodeCreateWithPath(url as CFURL, [], &codeReference) == errSecSuccess, let code = codeReference else { + throw ToolkitError(.invalidInput, message: "The acceptance app bundle could not be opened for native signature checking.", recovery: "Choose the genuine signed simulator fixture again.", technicalDetail: nil) + } + return try withExtendedLifetime(code) { + try reader.holdNativeHandle(bundle: url) + let summary = verify(code) + reader.validationReturned(status: summary.status) + return summary + } + } +#endif + + private static func verify(_ code: SecStaticCode) -> CodeSignatureSummary { var errors: Unmanaged? let flags = SecCSFlags(rawValue: kSecCSCheckAllArchitectures | kSecCSStrictValidate | kSecCSCheckNestedCode) let status = SecStaticCodeCheckValidityWithErrors(code, flags, nil, &errors) @@ -164,3 +184,42 @@ public enum CodeSignatureInspector { } } } + +/// Security.framework can wait for Dispatch work while validating nested code. Keep package +/// inspection off Swift's cooperative executor and retain its worker until native work finishes. +enum PackageInspectionExecutor { + typealias CancellationCheck = @Sendable () throws -> Void + + private static let queue = DispatchQueue(label: "io.hideouts.package-inspection", qos: .userInitiated) + + static func run(_ operation: @escaping @Sendable (CancellationCheck) throws -> Value) async throws -> Value { + try await perform(onCancellation: {}, operation) + } + +#if DEBUG + static func runForReaderAcceptance(reader: PackageInspectionReaderAcceptance, _ operation: @escaping @Sendable (CancellationCheck) throws -> Value) async throws -> Value { + try await perform(onCancellation: { reader.cancellationRequested() }, operation) + } +#endif + + private static func perform(onCancellation: @escaping @Sendable () -> Void, _ operation: @escaping @Sendable (CancellationCheck) throws -> Value) async throws -> Value { + let cancelled = LockedValue(false) + let checkCancellation: CancellationCheck = { + if cancelled.current { throw CancellationError() } + } + return try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await withCheckedThrowingContinuation { continuation in + queue.async { + continuation.resume(with: Result { + try checkCancellation() + return try operation(checkCancellation) + }) + } + } + } onCancel: { + cancelled.withLock { $0 = true } + onCancellation() + } + } +} diff --git a/Sources/idt/IDT.swift b/Sources/idt/IDT.swift index 102b51a..916453a 100644 --- a/Sources/idt/IDT.swift +++ b/Sources/idt/IDT.swift @@ -134,7 +134,7 @@ struct InspectIPA: AsyncParsableCommand { func run() async throws { do { - let inspection = try IPAInspector.inspect(URL(fileURLWithPath: (path as NSString).expandingTildeInPath)) + let inspection = try await IPAInspector.inspectAsync(URL(fileURLWithPath: (path as NSString).expandingTildeInPath)) if json { FileHandle.standardOutput.write(try JSONOutput.encode(inspection)) } else { diff --git a/Tests/DeviceKitTests/BackupAndAFCTests.swift b/Tests/DeviceKitTests/BackupAndAFCTests.swift index 5995b4c..ea49472 100644 --- a/Tests/DeviceKitTests/BackupAndAFCTests.swift +++ b/Tests/DeviceKitTests/BackupAndAFCTests.swift @@ -103,7 +103,15 @@ struct BackupAndAFCTests { #expect(try Data(contentsOf: destination).count == 3000) let source = directory.appendingPathComponent("upload.ipa") try SecureFileIO.writeNewFile(Data(repeating: 7, count: 2500), to: source) - try await afc.makeDirectory("/PublicStaging") + try await afc.ensureDirectory("/PublicStaging") + try await afc.ensureDirectory("/PublicStaging") + #expect(try await afc.fileInfo("/PublicStaging").isDirectory) + do { + try await afc.ensureDirectory("/Downloads/readme.txt") + Issue.record("A regular file was accepted as a staging directory") + } catch let error as ToolkitError { + #expect(error.kind == .fileSystem) + } try await afc.upload(source, to: "/PublicStaging/upload.ipa", chunkSize: 1000) let walk = try await afc.walk("/") #expect(walk.contains("/PublicStaging/upload.ipa")) @@ -120,6 +128,36 @@ struct BackupAndAFCTests { } } + @Test func ensureDirectoryPropagatesDeniedInspectionWithoutCreating() async throws { + try await withFakeDevice({ _ in }) { server in + let operations = LockedValue<[UInt64]>([]) + server.register(service: AFCClient.mediaServiceName) { channel in + while try await channel.hasMoreData() { + let header = try await channel.read(exactly: 40, timeout: 5) + let length = Int(header.readLittleEndianUInt64(at: 8)) + let number = header.readLittleEndianUInt64(at: 24) + let operation = header.readLittleEndianUInt64(at: 32) + _ = try await channel.read(exactly: length - 40, timeout: 5) + operations.withLock { $0.append(operation) } + var status = Data() + status.appendLittleEndian(UInt64(10)) + try await channel.write(AFCClient.encode(operation: 0x01, packetNumber: number, header: status, payload: Data())) + } + } + try await DeviceSession.with(server.target, usbmux: server.client) { session in + let afc = try await AFCClient.openMedia(session) + do { + try await afc.ensureDirectory("/PublicStaging") + Issue.record("A denied staging-directory inspection succeeded") + } catch let error as ToolkitError { + #expect(error.kind == .permissionDenied) + } + await afc.close() + } + #expect(operations.current == [0x0A]) + } + } + @Test func fullBackupConversation() async throws { let statusPlist = try PlistValue(dictionaryLiteral: ("SnapshotState", "finished")).encoded() try await withFakeDevice({ $0.domainValues["com.apple.mobile.backup"] = ["WillEncrypt": true] }) { server in diff --git a/Tests/ToolkitCoreTests/CommandRunnerTests.swift b/Tests/ToolkitCoreTests/CommandRunnerTests.swift index c0506bf..76578c3 100644 --- a/Tests/ToolkitCoreTests/CommandRunnerTests.swift +++ b/Tests/ToolkitCoreTests/CommandRunnerTests.swift @@ -101,10 +101,12 @@ struct CommandRunnerTests { defer { close(descriptor) } // The child acknowledges SIGTERM but waits for a test-controlled release before exit. // A FIFO makes each assertion depend on a real process state rather than a delay. + // After release, force-stop the owned sleep: its SIGTERM policy is not the parent's contract. let script = """ - trap 'printf "cancel received\\n"; IFS= read -r release < "$1"; printf "after release\\n"; exit 0' TERM + trap 'printf "cancel received\\n"; IFS= read -r release < "$1"; printf "phase: fifo-read=%s\\n" "$?" >&2; kill -KILL "$child" 2>/dev/null; printf "phase: child-kill=%s\\n" "$?" >&2; wait "$child" 2>/dev/null; printf "phase: child-wait=%s\\n" "$?" >&2; printf "after release\\n"; exit 0' TERM + sleep 30 & child=$! printf 'ready:%s\\n' "$$" - IFS= read -r hold < "$1" + wait "$child" exit 1 """ let request = CommandRequest(executable: URL(fileURLWithPath: "/bin/sh"), arguments: ["-c", script, "owned-command", barrier.path], @@ -112,6 +114,7 @@ struct CommandRunnerTests { let cancellation = CommandCancellation() let (lines, delivered) = AsyncStream.makeStream() let ended = LockedValue(false) + let diagnostics = LockedValue(Data()) let task = Task { defer { ended.withLock { $0 = true }; delivered.finish() } var buffered = "" @@ -122,6 +125,8 @@ struct CommandRunnerTests { delivered.yield(String(buffered[...makeStream() + let reader = PackageInspectionReaderAcceptance(observer: { continuation.yield($0) }) + try await withThrowingTaskGroup(of: PreparedAppPackage.self) { group in + group.addTask { + defer { continuation.finish() } + return try await PreparedAppPackage.prepareAsyncForReaderAcceptance(app, reader: reader) + } + do { + let held = try await withTimeout(10, operation: "observe a genuine native reader") { + for await snapshot in events { + if snapshot.phase == .nativeHandleHeldBeforeValidation { return snapshot } + } + throw ToolkitError(.internalInconsistency, message: "The native reader ended before opening its code handle.", recovery: nil, technicalDetail: nil) + } + let directory = try #require(held.directory) + let copy = try #require(held.packageURL) + #expect(held.originalURL == app) + var metadata = stat() + #expect(lstat(directory.path, &metadata) == 0) + #expect(metadata.st_mode & 0o777 == 0o700) + #expect(try Data(contentsOf: copy.appendingPathComponent("ReaderDemo")) == original) + + group.cancelAll() + let cancelled = try await withTimeout(10, operation: "observe native reader cancellation") { + for await snapshot in events { + if snapshot.cancellationRequested { return snapshot } + } + throw ToolkitError(.internalInconsistency, message: "The native reader ended before observing cancellation.", recovery: nil, technicalDetail: nil) + } + #expect(cancelled.phase == .nativeHandleHeldBeforeValidation) + #expect(lstat(directory.path, &metadata) == 0) + try reader.release() + var observedCancellation = false + do { + if let unexpected = try await group.next() { + try unexpected.remove() + Issue.record("A cancelled native reader returned an installable preparation.") + } + } catch is CancellationError { + observedCancellation = true + } + #expect(observedCancellation) + #expect(reader.snapshot.phase == .validationReturned) + #expect(reader.snapshot.signatureStatus == .valid) + let removal = lstat(directory.path, &metadata) + let removalError = errno + #expect(removal == -1) + #expect(removalError == ENOENT) + #expect(try Data(contentsOf: app.appendingPathComponent("ReaderDemo")) == original) + } catch { + group.cancelAll() + reader.finishAfterFailure() + throw error + } + } + } + + private enum ReaderObservationFailure: Error { + case interrupted + } + + @Test(.timeLimit(.minutes(1)), .enabled(if: FileManager.default.isExecutableFile(atPath: "/usr/bin/codesign"))) + func failedCallerReleasesItsHeldNativeReaderBeforeRethrowing() async throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-reader-failure") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "FailedReaderDemo", platform: "iPhoneSimulator") + try await sign(app, in: root) + let filenames: [String] = ["Info.plist", "FailedReaderDemo", "resource.txt"] + let originals: [Data] = try filenames.map { try Data(contentsOf: app.appendingPathComponent($0)) } + let (events, continuation) = AsyncStream.makeStream() + let reader = PackageInspectionReaderAcceptance(observer: { continuation.yield($0) }) + let observedWorkerCancellation = LockedValue(false) + var heldDirectory: URL? + defer { + if let directory = heldDirectory { + var metadata = stat() + let result = lstat(directory.path, &metadata) + let code = errno + if result == 0 { removeTestItem(directory) } + else { #expect(code == ENOENT) } + } + } + + var observedCallerFailure = false + do { + try await withThrowingTaskGroup(of: PreparedAppPackage.self) { group in + group.addTask { + defer { continuation.finish() } + do { + let unexpected = try await PreparedAppPackage.prepareAsyncForReaderAcceptance(app, reader: reader) + Issue.record("Failure teardown returned an installable preparation.") + try unexpected.remove() + return unexpected + } catch let error as CancellationError { + observedWorkerCancellation.withLock { $0 = true } + throw error + } + } + do { + let held = try await withTimeout(10, operation: "observe a native reader before caller failure") { + for await snapshot in events { + if snapshot.phase == .nativeHandleHeldBeforeValidation { return snapshot } + } + throw ToolkitError(.internalInconsistency, message: "The native reader ended before the caller failure could be exercised.", recovery: nil, technicalDetail: nil) + } + let directory = try #require(held.directory) + let copy = try #require(held.packageURL) + heldDirectory = directory + #expect(held.originalURL == app) + #expect(!held.cancellationRequested) + #expect(held.signatureStatus == nil) + var metadata = stat() + #expect(lstat(directory.path, &metadata) == 0) + #expect(metadata.st_mode & S_IFMT == S_IFDIR) + #expect(metadata.st_uid == getuid()) + #expect(metadata.st_mode & 0o777 == 0o700) + for (filename, original) in zip(filenames, originals) { + #expect(try Data(contentsOf: copy.appendingPathComponent(filename)) == original) + } + // Only the caller failure is controlled; the worker and validation are real. + throw ReaderObservationFailure.interrupted + } catch { + group.cancelAll() + reader.finishAfterFailure() + throw error + } + } + } catch ReaderObservationFailure.interrupted { + observedCallerFailure = true + } + + #expect(observedCallerFailure) + #expect(observedWorkerCancellation.current) + #expect(reader.snapshot.phase == .validationReturned) + #expect(reader.snapshot.signatureStatus == .valid) + #expect(reader.snapshot.cancellationRequested) + let directory = try #require(heldDirectory) + #expect(reader.snapshot.directory == directory) + var metadata = stat() + let removal = lstat(directory.path, &metadata) + let removalError = errno + #expect(removal == -1) + #expect(removalError == ENOENT) + for (filename, original) in zip(filenames, originals) { + #expect(try Data(contentsOf: app.appendingPathComponent(filename)) == original) + } + } +#endif + + private enum ChangedLocation: String, CaseIterable { + case original, prepared + } + + @Test(arguments: ChangedLocation.allCases) + private func bundleReplacementAtEitherLocationRequiresNewInspection(_ location: ChangedLocation) throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-app-change") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneSimulator") + let prepared = try PreparedAppPackage.prepare(app) + defer { removeTestPackage(prepared) } + let changed = location == .original ? app : prepared.packageURL + try Data("different resource".utf8).write(to: changed.appendingPathComponent("resource.txt")) + #expect(throws: ToolkitError.self) { try prepared.revalidate() } + let unchanged = location == .original ? prepared.packageURL : app + #expect(try Data(contentsOf: unchanged.appendingPathComponent("resource.txt")) == Data("resource".utf8)) + } + + @Test func bundleAddedFilesAndExecutablePermissionChangesAreDetected() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-app-mode") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneSimulator") + let prepared = try PreparedAppPackage.prepare(app) + defer { removeTestPackage(prepared) } + try SecureFileIO.writeNewFile(Data("new".utf8), to: prepared.packageURL.appendingPathComponent("new.txt")) + #expect(throws: ToolkitError.self) { try prepared.revalidate() } + try FileManager.default.removeItem(at: prepared.packageURL.appendingPathComponent("new.txt")) + try prepared.revalidate() + try FileManager.default.setAttributes([.posixPermissions: NSNumber(value: 0o600)], ofItemAtPath: app.appendingPathComponent("Demo").path) + #expect(throws: ToolkitError.self) { try prepared.revalidate() } + } + + @Test(.enabled(if: FileManager.default.isExecutableFile(atPath: "/usr/bin/codesign"))) + func physicalAppRequiresCoreDeviceAndAnIntactSignature() async throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-physical") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "DeviceDemo", platform: "iPhoneOS") + let unsigned = try await PreparedAppPackage.prepareAsync(app) + defer { removeTestPackage(unsigned) } + #expect(throws: ToolkitError.self) { try unsigned.validate(for: target(.physical), supportsCoreDevice: true, simulatorBooted: true) } + try await sign(app, in: root) + let signed = try await PreparedAppPackage.prepareAsync(app) + defer { removeTestPackage(signed) } + #expect(signed.signature?.status == .valid) + try signed.validate(for: target(.physical), supportsCoreDevice: true, simulatorBooted: false) + #expect(throws: ToolkitError.self) { try signed.validate(for: target(.physical), supportsCoreDevice: false, simulatorBooted: false) } + #expect(throws: ToolkitError.self) { try signed.validate(for: target(.simulator), supportsCoreDevice: true, simulatorBooted: true) } + try Data("tampered".utf8).write(to: signed.packageURL.appendingPathComponent("resource.txt")) + #expect(throws: ToolkitError.self) { try signed.revalidate() } + } + + @Test func unsignedIPAStaysInspectableButCannotAuthorizeInstallation() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-unsigned-ipa") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneOS") + let ipa = try makeIPA(app, in: root, name: "Unsigned.ipa") + let prepared = try PreparedAppPackage.prepare(ipa) + defer { removeTestPackage(prepared) } + let inspection = try #require(prepared.inspection) + #expect(!inspection.isInstallable) + #expect(inspection.packagePath == prepared.packageURL.path) + #expect(inspection.appName == "Demo") + #expect(throws: ToolkitError.self) { try prepared.validate(for: target(.physical), supportsCoreDevice: false, simulatorBooted: false) } + #expect(throws: ToolkitError.self) { try prepared.validate(for: target(.simulator), supportsCoreDevice: true, simulatorBooted: true) } + try prepared.revalidate() + try Data("replaced IPA".utf8).write(to: ipa) + #expect(throws: ToolkitError.self) { try prepared.revalidate() } + #expect(try Data(contentsOf: prepared.packageURL) != Data("replaced IPA".utf8)) + } + + @Test(.enabled(if: FileManager.default.isExecutableFile(atPath: "/usr/bin/codesign"))) + func signedIPAUsesTheApprovedCopyAndRetainsNativePhysicalInstallation() async throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-signed-ipa") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneOS") + try await sign(app, in: root) + let ipa = try makeIPA(app, in: root, name: "Signed.ipa") + async let inspection = IPAInspector.inspectAsync(ipa) + let prepared = try await PreparedAppPackage.prepareAsync(ipa) + defer { removeTestPackage(prepared) } + #expect(try await inspection.isInstallable) + #expect(prepared.inspection?.isInstallable == true) + try prepared.revalidate() + try prepared.validate(for: target(.physical), supportsCoreDevice: false, simulatorBooted: false) + try prepared.validate(for: target(.physical), supportsCoreDevice: true, simulatorBooted: false) + #expect(throws: ToolkitError.self) { try prepared.validate(for: target(.simulator), supportsCoreDevice: true, simulatorBooted: true) } + try Data("tampered copy".utf8).write(to: prepared.packageURL) + #expect(throws: ToolkitError.self) { try prepared.revalidate() } + } + + @Test func rejectsMalformedPackagesAndTypedBundleMetadata() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-malformed") + defer { removeTestItem(root) } + let malformedIPA = root.appendingPathComponent("Malformed.ipa") + try SecureFileIO.writeNewFile(Data("not ZIP".utf8), to: malformedIPA) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(malformedIPA) } + let unsupported = root.appendingPathComponent("Unsupported.zip") + try SecureFileIO.writeNewFile(Data(), to: unsupported) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(unsupported) } + let app = try makeApp(root, name: "Bad", platform: "iPhoneSimulator") + var info = try #require(infoPlist(name: "Bad", platform: "iPhoneSimulator").dictionaryValue) + info["CFBundleSupportedPlatforms"] = .array([.string("iPhoneSimulator"), .integer(1)]) + try PlistValue.dictionary(info).encoded(format: .xml).write(to: app.appendingPathComponent("Info.plist")) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(app) } + let malformedPlatformIPA = try makeIPA(app, in: root, name: "MalformedPlatform.ipa") + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(malformedPlatformIPA) } + info["CFBundleSupportedPlatforms"] = .array([.string("iPhoneSimulator")]) + info["CFBundleExecutable"] = .string("../outside") + try PlistValue.dictionary(info).encoded(format: .xml).write(to: app.appendingPathComponent("Info.plist")) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(app) } + try Data(repeating: 0x41, count: Int(IPAInspector.maximumInfoPlistBytes) + 1).write(to: app.appendingPathComponent("Info.plist")) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(app) } + } + + @Test func preservesSafeInternalFrameworkLinksWithoutFollowingThem() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-framework") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneSimulator") + let framework = app.appendingPathComponent("Frameworks/Demo.framework") + try SecureFileIO.createPrivateDirectory(at: framework.appendingPathComponent("Versions/A")) + try SecureFileIO.writeNewFile(Data("framework resource".utf8), to: framework.appendingPathComponent("Versions/A/value.txt")) + try FileManager.default.createSymbolicLink(atPath: framework.appendingPathComponent("Versions/Current").path, withDestinationPath: "A") + try FileManager.default.createSymbolicLink(atPath: framework.appendingPathComponent("value.txt").path, withDestinationPath: "Versions/Current/value.txt") + let prepared = try PreparedAppPackage.prepare(app) + defer { removeTestPackage(prepared) } + let copied = prepared.packageURL.appendingPathComponent("Frameworks/Demo.framework") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: copied.appendingPathComponent("Versions/Current").path) == "A") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: copied.appendingPathComponent("value.txt").path) == "Versions/Current/value.txt") + #expect(try Data(contentsOf: copied.appendingPathComponent("value.txt")) == Data("framework resource".utf8)) + try prepared.revalidate() + try prepared.validate(for: target(.simulator), supportsCoreDevice: false, simulatorBooted: true) + } + + @Test(arguments: ["../secret.txt", "/etc/passwd", "missing.txt", "link"]) + func deniesEscapingAbsoluteBrokenAndCyclicBundleLinks(_ destination: String) throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-unsafe-link") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneSimulator") + let secret = root.appendingPathComponent("secret.txt") + try SecureFileIO.writeNewFile(Data("task-owned confidential test bytes".utf8), to: secret) + try FileManager.default.createSymbolicLink(atPath: app.appendingPathComponent("link").path, withDestinationPath: destination) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(app) } + #expect(try Data(contentsOf: secret) == Data("task-owned confidential test bytes".utf8)) + } + + @Test func rootSymbolicLinksAreDeniedAndCleanupDoesNotDeleteSource() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-root-link") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneSimulator") + let linked = root.appendingPathComponent("Linked.app") + try FileManager.default.createSymbolicLink(at: linked, withDestinationURL: app) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(linked) } + let prepared = try PreparedAppPackage.prepare(app) + try prepared.remove() + #expect(!FileManager.default.fileExists(atPath: prepared.directory.path)) + #expect(FileManager.default.fileExists(atPath: app.appendingPathComponent("Demo").path)) + } + + private func removeTestItem(_ url: URL) { + do { try FileManager.default.removeItem(at: url) } + catch { Issue.record("Could not remove the task-owned package fixture: \(error.localizedDescription)") } + } + + private func removeTestPackage(_ package: PreparedAppPackage) { + do { try package.remove() } + catch { Issue.record("Could not remove the prepared package fixture: \(error.localizedDescription)") } + } + + private func infoPlist(name: String, platform: String) -> PlistValue { + .dictionary([ + "CFBundleIdentifier": .string("com.example.preparation"), + "CFBundleName": .string(name), + "CFBundleShortVersionString": .string("1.0"), + "CFBundleVersion": .string("1"), + "CFBundleExecutable": .string(name), + "CFBundleSupportedPlatforms": .array([.string(platform)]), + ]) + } + + private func makeApp(_ root: URL, name: String, platform: String) throws -> URL { + let app = root.appendingPathComponent("\(name).app") + try SecureFileIO.createPrivateDirectory(at: app) + try SecureFileIO.writeNewFile(try infoPlist(name: name, platform: platform).encoded(format: .xml), to: app.appendingPathComponent("Info.plist")) + try FileManager.default.copyItem(atPath: "/bin/echo", toPath: app.appendingPathComponent(name).path) + try SecureFileIO.writeNewFile(Data("resource".utf8), to: app.appendingPathComponent("resource.txt")) + return app + } + + private func makeIPA(_ app: URL, in root: URL, name: String) throws -> URL { + var writer = ZipWriter(date: Date(timeIntervalSince1970: 0)) + let appRoot = app.resolvingSymlinksInPath() + let enumerator = try #require(FileManager.default.enumerator(at: appRoot, includingPropertiesForKeys: [.isRegularFileKey], options: [], errorHandler: nil)) + for case let enumeratedURL as URL in enumerator { + let file = enumeratedURL.resolvingSymlinksInPath() + guard try file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else { continue } + guard file.path.hasPrefix(appRoot.path + "/") else { throw ToolkitError.invalidInput("The task-owned IPA fixture path escaped the app root.") } + let path = "Payload/\(app.lastPathComponent)/" + file.path.dropFirst(appRoot.path.count + 1) + let permissions = try #require(try FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? NSNumber) + try writer.add(name: path, data: Data(contentsOf: file), method: .stored, mode: permissions.uint32Value) + } + let url = root.appendingPathComponent(name) + try SecureFileIO.writeNewFile(writer.finalized(), to: url) + return url + } + + private func sign(_ app: URL, in root: URL) async throws { + let result = try await ProcessCommandRunner().run(CommandRequest(executable: URL(fileURLWithPath: "/usr/bin/codesign"), arguments: ["--force", "--sign", "-", app.path], workingDirectory: root, timeout: 60)) + #expect(result.succeeded, "\(result.standardErrorText)") + guard result.succeeded else { throw ToolkitError(.commandFailed, message: "Could not sign the task-owned test bundle.", recovery: nil, technicalDetail: result.technicalSummary) } + } + + private func target(_ kind: DeviceKind) -> DeviceTarget { + DeviceTarget(kind: kind, udid: "TASK-OWNED-TEST", name: "Synthetic Test Target", osVersion: "27.0", usbmuxDeviceID: nil, coreDeviceIdentifier: nil, transport: kind == .physical ? .usb : .local) + } +} diff --git a/Tests/ToolkitFeaturesTests/IPAInspectorTests.swift b/Tests/ToolkitFeaturesTests/IPAInspectorTests.swift index 20a8c84..78133bf 100644 --- a/Tests/ToolkitFeaturesTests/IPAInspectorTests.swift +++ b/Tests/ToolkitFeaturesTests/IPAInspectorTests.swift @@ -135,14 +135,14 @@ struct IPAInspectorTests { #expect(signing.succeeded, "\(signing.standardErrorText)") #expect(try await run("/usr/bin/zip", ["-qr", "signed.ipa", "Payload"], in: directory).succeeded) - let signed = try IPAInspector.inspect(directory.appendingPathComponent("signed.ipa")) + let signed = try await IPAInspector.inspectAsync(directory.appendingPathComponent("signed.ipa")) #expect(signed.signature.status == .valid, "\(signed.signature.detail)") #expect(signed.signature.identifier == "com.example.demo") #expect(signed.isInstallable) try Data("tampered".utf8).write(to: app.appendingPathComponent("resource.txt")) #expect(try await run("/usr/bin/zip", ["-qr", "tampered.ipa", "Payload"], in: directory).succeeded) - let tampered = try IPAInspector.inspect(directory.appendingPathComponent("tampered.ipa")) + let tampered = try await IPAInspector.inspectAsync(directory.appendingPathComponent("tampered.ipa")) #expect(tampered.signature.status == .invalid) #expect(!tampered.isInstallable) } diff --git a/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift b/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift index b8ca232..e71e430 100644 --- a/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift +++ b/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift @@ -112,8 +112,16 @@ struct RealSimulatorTests { // Install, list, launch, and remove a real (minimal) simulator app. step("building fixture app") let fixture = try await Self.buildFixtureApp(in: directory) - step("installing fixture app") - try await client.install(appAt: fixture, on: target) + let prepared = try await PreparedAppPackage.prepareAsync(fixture) + defer { + do { try prepared.remove() } + catch { Issue.record("Could not remove the prepared simulator fixture: \(error.localizedDescription)") } + } + #expect(prepared.packageURL != fixture) + try prepared.revalidate() + try prepared.validate(for: target, supportsCoreDevice: false, simulatorBooted: booted) + step("installing prepared fixture app") + try await client.install(appAt: prepared.packageURL, on: target) #expect(try await client.apps(target).contains { $0.bundleIdentifier == Self.fixtureBundleID }) let launched = try await client.launch(bundleIdentifier: Self.fixtureBundleID, on: target, terminateExisting: true) #expect(launched.contains(Self.fixtureBundleID)) diff --git a/iOSDeveloperToolkit.xcodeproj/project.pbxproj b/iOSDeveloperToolkit.xcodeproj/project.pbxproj index 0880dad..a94e200 100644 --- a/iOSDeveloperToolkit.xcodeproj/project.pbxproj +++ b/iOSDeveloperToolkit.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 1BEAAB3DA748A2FF696A37BE /* LiveLogsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1BCA4BFD074C07318C65CC /* LiveLogsModel.swift */; }; 244023633FC5B7270C45C371 /* ActionsViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64CADCDAC59205AFA532EF87 /* ActionsViews.swift */; }; 25D059CF99AA7F74E66147F4 /* SmokeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2095C064BD8BE67389318E84 /* SmokeUITests.swift */; }; + 25EFD518CF902622809873B1 /* PackageInspectionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AAA035D99201791C82AA906 /* PackageInspectionUITests.swift */; }; 26019A7B916688FEE3A23585 /* LogViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B81EECBCB7D517436001EFF8 /* LogViews.swift */; }; 296F9F5D4685AB10A7B27CA6 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 791078D9EF3EC6308839270F /* AppModel.swift */; }; 42E12E11EB7BA75B9D50C3E8 /* ReconnectGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B26E835D20074A0489176A15 /* ReconnectGuideView.swift */; }; @@ -58,6 +59,7 @@ /* Begin PBXFileReference section */ 117839C38E19BA74C82D8382 /* ReferenceViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceViews.swift; sourceTree = ""; }; 192082EDB2AA8A31B4DE9A43 /* DeviceViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceViews.swift; sourceTree = ""; }; + 1AAA035D99201791C82AA906 /* PackageInspectionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageInspectionUITests.swift; sourceTree = ""; }; 2095C064BD8BE67389318E84 /* SmokeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmokeUITests.swift; sourceTree = ""; }; 32872FC2AF66DD024EA035E2 /* FirmwareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirmwareView.swift; sourceTree = ""; }; 3DDC0E48182D2C63DFB57618 /* SecurityAnalysisModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityAnalysisModel.swift; sourceTree = ""; }; @@ -139,6 +141,7 @@ 60C4D44F4E03EAEE6FC8028C /* UITests */ = { isa = PBXGroup; children = ( + 1AAA035D99201791C82AA906 /* PackageInspectionUITests.swift */, 2095C064BD8BE67389318E84 /* SmokeUITests.swift */, ); path = UITests; @@ -433,6 +436,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 25EFD518CF902622809873B1 /* PackageInspectionUITests.swift in Sources */, 25D059CF99AA7F74E66147F4 /* SmokeUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/scripts/PackageInspectionFixtures.swift b/scripts/PackageInspectionFixtures.swift new file mode 100644 index 0000000..a749bc4 --- /dev/null +++ b/scripts/PackageInspectionFixtures.swift @@ -0,0 +1,518 @@ +import Foundation +import ToolkitCore + +/// Provisions only this hosted run's simulator and immutable package inputs. Native UI tests +/// choose those files themselves; this host verifies their hashes, inventory and post-Quit cleanup. +@main +struct PackageInspectionFixtures { + struct SourceDigest: Codable { + let path: String + let sha256: String + } + + struct Manifest: Codable { + let owner: String + let simulator: String? + let sources: [SourceDigest]? + let inventory: [String]? + let existingPreparations: [String] + + enum CodingKeys: String, CodingKey { case owner, simulator, sources, inventory, existingPreparations } + + func encode(to encoder: Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(owner, forKey: .owner) + try values.encode(simulator, forKey: .simulator) + try values.encode(sources, forKey: .sources) + try values.encode(inventory, forKey: .inventory) + try values.encode(existingPreparations, forKey: .existingPreparations) + } + } + + struct Simulator { + let identifier: String + let name: String + let state: String + let type: String + let runtime: String + let available: Bool + } + + struct CleanupReceipt: Decodable { + let preparedDirectories: [String] + } + + enum PreparationPhase: String { + case templateBeforeAllocation = "template-before-allocation" + case ownedVerificationAfterBoot = "owned-verification-after-boot" + } + + enum PreparationEvent: String { + case begin, complete, failed + } + + enum CommandObservationOutcome: String { + case returned, threw + } + + struct OutputDelivery { + let bytes: Int + let first: Duration? + let last: Duration? + + static let empty = OutputDelivery(bytes: 0, first: nil, last: nil) + + func adding(_ data: Data, at elapsed: Duration) -> OutputDelivery { + guard !data.isEmpty else { return self } + return OutputDelivery(bytes: bytes + data.count, first: first ?? elapsed, last: elapsed) + } + } + + /// Observes only the prepared simulator's post-boot query. Delivery times describe when + /// this consumer received nonempty bytes, not JSON validity or the child's exit time. + /// Owned-process sampling can perturb scheduling. Consumer timing excludes observer + /// teardown; the request's own deadline and process behavior remain unchanged. + struct PostBootObservedRunner: CommandRunning { + let base: ProcessCommandRunner + + func run(_ request: CommandRequest) async throws -> CommandResult { + let observation = Task.detached(priority: .utility) { + await PackageProcessObservation(runner: base).collect() + } + let clock = ContinuousClock() + let started = clock.now + var standardOutput = OutputDelivery.empty + var standardError = OutputDelivery.empty + var final: CommandResult? + let result: CommandResult + do { + for try await event in base.stream(request) { + switch event { + case .standardOutput(let data): + standardOutput = standardOutput.adding(data, at: started.duration(to: clock.now)) + case .standardError(let data): + standardError = standardError.adding(data, at: started.duration(to: clock.now)) + case .finished(let completed): + final = completed + } + } + if Task.isCancelled { throw ToolkitError.cancelled(request.displayName) } + guard let final else { + throw ToolkitError(.internalInconsistency, message: "\(request.displayName) ended without a result.", recovery: nil, technicalDetail: nil) + } + result = final + } catch { + let primaryError = error + let consumerElapsed = started.duration(to: clock.now) + observation.cancel() + let processReport = await observation.value + do { + try writePostBootObservation(outcome: .threw, standardOutput: standardOutput, standardError: standardError, finishedSeen: final != nil, elapsed: consumerElapsed) + try PackageProcessObservation.write(processReport) + } catch let reportingError { + throw ToolkitError.wrap(primaryError, message: "The post-boot simulator query failed.", recovery: "Inspect the delivery summary and original command failure.") + .appendingDetail("Writing the post-boot observations also failed: \(bounded(reportingError))") + } + throw primaryError + } + let consumerElapsed = started.duration(to: clock.now) + observation.cancel() + let processReport = await observation.value + try writePostBootObservation(outcome: .returned, standardOutput: standardOutput, standardError: standardError, finishedSeen: final != nil, elapsed: consumerElapsed) + try PackageProcessObservation.write(processReport) + return result + } + + func stream(_ request: CommandRequest) -> AsyncThrowingStream { + base.stream(request) + } + } + + static let validName = "ValidSimulator.app" + static let validBName = "ValidSimulatorB.app" + static let invalidName = "Malformed.ipa" + static let fixtureBundle = "io.hideouts.idt.package.fixture" + static let fixtureBBundle = "io.hideouts.idt.package.fixture.b" + + static func main() async throws { + let arguments = CommandLine.arguments + guard arguments.count >= 3, arguments.count <= 4 else { + throw failure("Use package-fixtures prepare DIRECTORY, verify-cleanup DIRECTORY RECEIPT_JSON, or cleanup DIRECTORY.", cause: nil) + } + let directory = URL(fileURLWithPath: arguments[2], isDirectory: true).standardizedFileURL + guard let temporary = ProcessInfo.processInfo.environment["RUNNER_TEMP"], + directory.resolvingSymlinksInPath().path.hasPrefix(URL(fileURLWithPath: temporary).resolvingSymlinksInPath().path + "/") else { + throw failure("The fixture directory must be a dedicated child of RUNNER_TEMP.", cause: nil) + } + let runner = ProcessCommandRunner() + switch arguments[1] { + case "prepare" where arguments.count == 3: + try await prepare(directory: directory, runner: runner) + case "verify-cleanup" where arguments.count == 4: + try await verifyCleanup(directory: directory, receipt: URL(fileURLWithPath: arguments[3]), runner: runner) + case "cleanup" where arguments.count == 3: + try await cleanup(directory: directory, runner: runner) + default: + throw failure("The fixture command or its argument count is invalid.", cause: nil) + } + } + + static func prepare(directory: URL, runner: ProcessCommandRunner) async throws { + try SecureFileIO.createNewPrivateDirectory(at: directory) + let owner = UUID().uuidString + let existingPreparations = try FileManager.default.contentsOfDirectory(atPath: NSTemporaryDirectory()) + .filter { $0.hasPrefix("idt-prepared-app.") }.sorted() + var manifest = Manifest(owner: owner, simulator: nil, sources: nil, inventory: nil, existingPreparations: existingPreparations) + try save(manifest, directory: directory) + let template = try await tracePreparationPhase(.templateBeforeAllocation) { + try await simulatorTemplate(runner: runner) + } + let result = try await command(["simctl", "create", "IDT Package \(owner)", template.type, template.runtime], timeout: 120, runner: runner) + guard let identifier = UUID(uuidString: result.standardOutputText.trimmingCharacters(in: .whitespacesAndNewlines))?.uuidString else { + throw failure("simctl create did not return a valid fixture UUID; cleanup must reconcile its exact owner name before retrying.", cause: nil) + } + manifest = Manifest(owner: owner, simulator: identifier, sources: nil, inventory: nil, existingPreparations: existingPreparations) + try await register(manifest, directory: directory, runner: runner) + _ = try await command(["simctl", "bootstatus", identifier, "-b"], timeout: 600, runner: runner) + try await tracePreparationPhase(.ownedVerificationAfterBoot) { + try await requireOwnedSimulator(manifest, runner: PostBootObservedRunner(base: runner)) + } + let app = try await fixture(directory: directory, name: validName, bundle: fixtureBundle, displayName: "Package Fixture", runner: runner) + let appB = try await fixture(directory: directory, name: validBName, bundle: fixtureBBundle, displayName: "Package Fixture B", runner: runner) + let malformed = directory.appendingPathComponent(invalidName) + try SecureFileIO.writeNewFile(Data("This is deliberately not a ZIP archive.\n".utf8), to: malformed, mode: 0o600) + let sources = try [validName, validBName, invalidName].map { name in + SourceDigest(path: name, sha256: try digest(directory.appendingPathComponent(name))) + } + let inventory = try await inventory(identifier: identifier, runner: runner) + guard !inventory.contains(fixtureBundle), !inventory.contains(fixtureBBundle) else { + throw failure("The new simulator already contains an acceptance package; installation cancellation cannot be measured.", cause: nil) + } + manifest = Manifest(owner: owner, simulator: identifier, sources: sources, inventory: inventory, existingPreparations: existingPreparations) + try save(manifest, directory: directory) + let environment = [ + "IDT_PACKAGE_FIXTURE_DIRECTORY": directory.path, + "IDT_PACKAGE_FIXTURE_SIMULATOR": identifier, + "IDT_PACKAGE_FIXTURE_OWNER": owner, + "IDT_PACKAGE_FIXTURE_VALID": app.path, + "IDT_PACKAGE_FIXTURE_VALID_B": appB.path, + "IDT_PACKAGE_FIXTURE_INVALID": malformed.path, + ] + guard environment.values.allSatisfy({ !$0.contains("\n") && !$0.contains("\r") }) else { + throw failure("Fixture paths cannot contain newline characters in the hosted environment file.", cause: nil) + } + let lines = environment.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)\n" }.joined() + try SecureFileIO.writeNewFile(Data(lines.utf8), to: directory.appendingPathComponent("environment.env"), mode: 0o600) + print("[package fixtures] One owner-matched booted simulator, two distinct genuine signed simulator apps, malformed IPA and source/inventory baselines are ready.") + } + + static func verifyCleanup(directory: URL, receipt: URL, runner: ProcessCommandRunner) async throws { + let manifest = try load(directory: directory) + guard let sources = manifest.sources, sources.count == 3, + Set(sources.map(\.path)) == Set([validName, validBName, invalidName]), + let expectedInventory = manifest.inventory, let identifier = manifest.simulator else { + throw failure("Package acceptance verification requires a completely prepared ownership registry.", cause: nil) + } + try await requireOwnedSimulator(manifest, runner: runner) + for source in sources { + guard source.sha256 == (try digest(directory.appendingPathComponent(source.path))) else { + throw failure("An original acceptance package changed during inspection, cancellation or normal Quit.", cause: nil) + } + } + guard try await inventory(identifier: identifier, runner: runner) == expectedInventory else { + throw failure("The owned simulator's bundle-identifier inventory changed despite cancelling installation.", cause: nil) + } + let receiptData = try readOwnedFile(receipt, maximumBytes: 16384) + guard let object = try JSONValue.parse(receiptData).object, Set(object.keys) == Set(["preparedDirectories"]) else { + throw failure("The native cleanup receipt has an invalid schema.", cause: nil) + } + let recorded = try JSONDecoder().decode(CleanupReceipt.self, from: receiptData).preparedDirectories + guard recorded.count == 6, Set(recorded).count == 6 else { + throw failure("The native receipt must contain six distinct actual preparation paths from initial inspection, recovery, late completion, the current package, the incomplete reader and reader recovery.", cause: nil) + } + let temporary = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).resolvingSymlinksInPath() + for path in recorded { + let prepared = URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + let name = prepared.lastPathComponent + let suffix = String(name.dropFirst("idt-prepared-app.".count)) + guard path.hasPrefix("/"), prepared.deletingLastPathComponent().resolvingSymlinksInPath() == temporary, + name.hasPrefix("idt-prepared-app."), suffix.count == 8, + suffix.utf8.allSatisfy({ (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) }), + !manifest.existingPreparations.contains(name) else { + throw failure("The native receipt contains a path outside this run's possible new private preparations.", cause: nil) + } + var metadata = stat() + guard lstat(prepared.path, &metadata) != 0 else { + throw failure("A recorded private preparation remains after re-selection, stale completion, reader cancellation or normal Quit.", cause: nil) + } + guard errno == ENOENT else { + throw failure("A recorded private preparation could not be checked for absence.", cause: POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)) + } + } + print("[package fixtures] Independent verification passed: all six recorded private preparations are absent, all three source hashes are unchanged and the owned simulator inventory is unchanged.") + } + + static func cleanup(directory: URL, runner: ProcessCommandRunner) async throws { + let manifest = try load(directory: directory) + let name = "IDT Package \(manifest.owner)" + let records = try await simulators(runner: runner) + let named = records.filter { $0.name == name } + guard named.count <= 1 else { throw failure("Multiple simulators have the exact fixture owner name; cleanup was refused.", cause: nil) } + let record: Simulator? + if let identifier = manifest.simulator { + record = records.first(where: { $0.identifier == identifier }) + guard record == nil ? named.isEmpty : record?.name == name else { + throw failure("The registered simulator UUID and its exact owner name no longer identify the same target; cleanup was refused.", cause: nil) + } + } else { + // A create response or registry write can fail after allocation. Reconcile one exact + // owner name; creation is never blindly retried and other simulators are untouched. + record = named.first + } + if let record { + if record.state == "Booted" { + _ = try await command(["simctl", "shutdown", record.identifier], timeout: 120, runner: runner) + } + _ = try await command(["simctl", "delete", record.identifier], timeout: 120, runner: runner) + } + guard try await !simulators(runner: runner).contains(where: { $0.identifier == manifest.simulator || $0.name == name }) else { + throw failure("Fresh discovery still contains the fixture UUID or exact owner name; the registry was retained.", cause: nil) + } + try FileManager.default.removeItem(at: directory) + print("[package fixtures] Fresh discovery verified the owned simulator UUID and exact owner name absent; the owned fixture directory was removed.") + } + + static func fixture(directory: URL, name: String, bundle: String, displayName: String, runner: ProcessCommandRunner) async throws -> URL { + let app = directory.appendingPathComponent(name, isDirectory: true) + try SecureFileIO.createNewPrivateDirectory(at: app) + let source = directory.appendingPathComponent("\(app.deletingPathExtension().lastPathComponent).c") + try SecureFileIO.writeNewFile(Data("int main(void) { return 0; }\n".utf8), to: source, mode: 0o600) + #if arch(arm64) + let triple = "arm64-apple-ios17.0-simulator" + #else + let triple = "x86_64-apple-ios17.0-simulator" + #endif + _ = try await command(["--sdk", "iphonesimulator", "clang", "-target", triple, "-o", app.appendingPathComponent("Fixture").path, source.path], timeout: 300, runner: runner) + let info: PlistValue = ["CFBundleIdentifier": .string(bundle), "CFBundleExecutable": "Fixture", "CFBundleName": .string(displayName), + "CFBundleDisplayName": .string(displayName), "CFBundlePackageType": "APPL", "CFBundleVersion": "1", + "CFBundleShortVersionString": "1.0", "MinimumOSVersion": "17.0", "CFBundleSupportedPlatforms": ["iPhoneSimulator"], "UIDeviceFamily": [1, 2]] + try SecureFileIO.writeNewFile(info.encoded(format: .xml), to: app.appendingPathComponent("Info.plist"), mode: 0o600) + let request = CommandRequest(executable: try AppleTool.codesign.locate(), arguments: ["--sign", "-", app.path], timeout: 60, displayName: "sign package fixture") + let signed = try await runner.run(request) + guard signed.succeeded else { throw failure("Signing the genuine simulator package failed.", cause: nil).appendingDetail(String(signed.technicalSummary.prefix(1536))) } + return app + } + + /// Includes each relative entry name, kind, mode and file digest; original package metadata + /// and nested bytes must survive inspection and cancellation, rather than only its root file. + static func digest(_ source: URL) throws -> String { + var paths = [source] + var metadata = stat() + var lines: [String] = [] + var index = 0 + while index < paths.count { + let path = paths[index] + index += 1 + guard lstat(path.path, &metadata) == 0, metadata.st_uid == getuid() else { + throw failure("An original package entry cannot be verified for hashing.", cause: nil) + } + let kind = metadata.st_mode & S_IFMT + guard kind == S_IFREG || kind == S_IFDIR else { throw failure("Generated package inputs must contain only owned regular files and directories.", cause: nil) } + if kind == S_IFDIR { + paths.append(contentsOf: try FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil).sorted(by: { $0.path < $1.path })) + guard paths.count <= 4096 else { throw failure("The generated package exceeds the acceptance fixture entry bound.", cause: nil) } + } + let relative = String(path.path.dropFirst(source.path.count)) + let content = kind == S_IFREG ? try SecureFileIO.sha256(of: path) : "directory" + lines.append("\(relative)\u{0}\(kind)\u{0}\(metadata.st_mode & 0o777)\u{0}\(content)\n") + } + return SecureFileIO.sha256(of: Data(lines.sorted().joined().utf8)) + } + + static func inventory(identifier: String, runner: ProcessCommandRunner) async throws -> [String] { + let result = try await command(["simctl", "listapps", identifier], timeout: 60, runner: runner) + guard let applications = try PlistValue.decode(result.standardOutput).dictionaryValue, + applications.count <= 4096, applications.keys.allSatisfy({ !$0.isEmpty && $0.utf8.count <= 512 }) else { + throw failure("The owned simulator returned an invalid application inventory.", cause: nil) + } + return applications.keys.sorted() + } + + static func simulatorTemplate(runner: ProcessCommandRunner) async throws -> Simulator { + let runtimeJSON = try JSONValue.parse(try await command(["simctl", "list", "runtimes", "--json"], timeout: 60, runner: runner).standardOutput) + let typeJSON = try JSONValue.parse(try await command(["simctl", "list", "devicetypes", "--json"], timeout: 60, runner: runner).standardOutput) + guard let runtimes = runtimeJSON["runtimes"]?.array, let types = typeJSON["devicetypes"]?.array else { + throw failure("simctl did not return runtime and device-type arrays.", cause: nil) + } + let availableRuntimes = try runtimes.filter { try boolean($0, field: "isAvailable") }.map { try string($0, field: "identifier") } + let iphoneTypes = try types.filter { try string($0, field: "name").hasPrefix("iPhone") }.map { try string($0, field: "identifier") } + guard let template = try await simulators(runner: runner).first(where: { + $0.available && availableRuntimes.contains($0.runtime) && iphoneTypes.contains($0.type) + && $0.runtime.hasPrefix("com.apple.CoreSimulator.SimRuntime.iOS-") + && $0.type.hasPrefix("com.apple.CoreSimulator.SimDeviceType.iPhone-") + }) else { + throw failure("The hosted runner needs an available iPhone simulator runtime and device type.", cause: nil) + } + return template + } + + static func simulators(runner: some CommandRunning) async throws -> [Simulator] { + let json = try JSONValue.parse(try await command(["simctl", "list", "devices", "--json"], timeout: 60, runner: runner).standardOutput) + guard let groups = json["devices"]?.object else { throw failure("simctl returned no device groups.", cause: nil) } + return try groups.keys.sorted().flatMap { runtime -> [Simulator] in + guard let devices = groups[runtime]?.array else { throw failure("simctl returned a non-array device group.", cause: nil) } + return try devices.map { value in + guard let identifier = UUID(uuidString: try string(value, field: "udid"))?.uuidString else { throw failure("simctl returned an invalid UUID.", cause: nil) } + return Simulator(identifier: identifier, name: try string(value, field: "name"), state: try string(value, field: "state"), + type: try string(value, field: "deviceTypeIdentifier"), runtime: runtime, available: try boolean(value, field: "isAvailable")) + } + } + } + + static func requireOwnedSimulator(_ manifest: Manifest, runner: some CommandRunning) async throws { + guard let identifier = manifest.simulator, + try await simulators(runner: runner).contains(where: { + $0.identifier == identifier && $0.name == "IDT Package \(manifest.owner)" && $0.available && $0.state == "Booted" + }) else { throw failure("Acceptance requires this run's booted simulator with both registered UUID and exact owner name.", cause: nil) } + } + + static func save(_ manifest: Manifest, directory: URL) throws { + try SecureFileIO.writeAtomically(JSONEncoder().encode(manifest), to: directory.appendingPathComponent("manifest.json"), mode: 0o600) + } + + static func register(_ manifest: Manifest, directory: URL, runner: ProcessCommandRunner) async throws { + do { try save(manifest, directory: directory) } + catch { + let registrationError = error + do { + guard let identifier = manifest.simulator else { throw failure("No newly allocated simulator was recorded for registration cleanup.", cause: nil) } + if let record = try await simulators(runner: runner).first(where: { $0.identifier == identifier }) { + guard record.name == "IDT Package \(manifest.owner)" else { throw failure("The newly allocated simulator changed owner name; immediate cleanup was refused.", cause: nil) } + _ = try await command(["simctl", "delete", identifier], timeout: 120, runner: runner) + } + guard try await !simulators(runner: runner).contains(where: { $0.identifier == identifier || $0.name == "IDT Package \(manifest.owner)" }) else { + throw failure("The unregistered simulator remains after deletion.", cause: nil) + } + } catch { + throw failure("Fixture registration and immediate owned cleanup both failed; the earlier registry was retained.", cause: registrationError).appendingDetail(bounded(error)) + } + throw failure("Fixture registration failed; its newly allocated simulator was removed and the earlier registry retained.", cause: registrationError) + } + } + + static func load(directory: URL) throws -> Manifest { + var metadata = stat() + guard lstat(directory.path, &metadata) == 0, metadata.st_mode & S_IFMT == S_IFDIR, + metadata.st_uid == getuid(), metadata.st_mode & 0o777 == 0o700 else { + throw failure("Cleanup requires the original owner-only fixture directory without symbolic links.", cause: nil) + } + let data = try readOwnedFile(directory.appendingPathComponent("manifest.json"), maximumBytes: 65536) + guard let object = try JSONValue.parse(data).object, + Set(object.keys) == Set(["owner", "simulator", "sources", "inventory", "existingPreparations"]) else { + throw failure("The ownership registry has an invalid schema.", cause: nil) + } + let manifest = try JSONDecoder().decode(Manifest.self, from: data) + let validSimulator = manifest.simulator.map { UUID(uuidString: $0)?.uuidString == $0 } ?? true + guard UUID(uuidString: manifest.owner)?.uuidString == manifest.owner, validSimulator, + manifest.existingPreparations.count <= 4096, + manifest.existingPreparations.allSatisfy({ $0.hasPrefix("idt-prepared-app.") && !$0.contains("/") }) else { + throw failure("The ownership registry contains invalid target or preparation records.", cause: nil) + } + return manifest + } + + static func readOwnedFile(_ url: URL, maximumBytes: Int) throws -> Data { + let descriptor = open(url.path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard descriptor >= 0 else { throw failure("An acceptance registry or receipt is missing or unreadable.", cause: nil) } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + var metadata = stat() + do { + guard fstat(descriptor, &metadata) == 0, metadata.st_mode & S_IFMT == S_IFREG, + metadata.st_uid == getuid(), metadata.st_size > 0, metadata.st_size <= maximumBytes else { + throw failure("Acceptance registries and receipts must be bounded owner-owned regular files.", cause: nil) + } + let data = try handle.read(upToCount: maximumBytes + 1) ?? Data() + try handle.close() + guard data.count <= maximumBytes else { throw failure("An acceptance registry or receipt grew beyond its size bound.", cause: nil) } + return data + } catch { + do { try handle.close() } + catch let closeError { throw failure("Reading and closing an acceptance record failed.", cause: error).appendingDetail(bounded(closeError)) } + throw error + } + } + + /// Records only static preparation stages and monotonic duration. Cleanup never depends + /// on these writes, and a reporting failure cannot replace an existing command failure. + static func tracePreparationPhase(_ phase: PreparationPhase, operation: () async throws -> Value) async throws -> Value { + let clock = ContinuousClock() + let started = clock.now + try writePreparationEvent(phase, event: .begin, elapsed: .zero) + let value: Value + do { + value = try await operation() + } catch { + let primaryError = error + do { + try writePreparationEvent(phase, event: .failed, elapsed: started.duration(to: clock.now)) + } catch let reportingError { + throw ToolkitError.wrap(primaryError, message: "Hosted fixture preparation failed.", recovery: "Inspect the preparation phase and retained ownership registry.") + .appendingDetail("Writing the failed preparation phase also failed: \(bounded(reportingError))") + } + throw primaryError + } + try writePreparationEvent(phase, event: .complete, elapsed: started.duration(to: clock.now)) + return value + } + + static func writePreparationEvent(_ phase: PreparationPhase, event: PreparationEvent, elapsed: Duration) throws { + let duration = elapsed.components + let record = "[package fixtures] preparation phase=\(phase.rawValue) event=\(event.rawValue) elapsed_seconds=\(duration.seconds) elapsed_attoseconds=\(duration.attoseconds)\n" + do { + try FileHandle.standardError.write(contentsOf: Data(record.utf8)) + } catch { + throw ToolkitError.fileSystem("Could not write hosted preparation phase diagnostics to standard error.", path: nil, underlying: error) + } + } + + /// First/last fields are consumer-relative seconds:attoseconds, or `none` when no bytes + /// were delivered. A returned result may be nonzero; normal command validation still runs. + static func writePostBootObservation(outcome: CommandObservationOutcome, standardOutput: OutputDelivery, standardError: OutputDelivery, finishedSeen: Bool, elapsed: Duration) throws { + let duration = elapsed.components + let record = "[package fixtures] postboot-list-stream outcome=\(outcome.rawValue) stdout_bytes=\(standardOutput.bytes) stdout_first=\(relativeDeliveryTime(standardOutput.first)) stdout_last=\(relativeDeliveryTime(standardOutput.last)) stderr_bytes=\(standardError.bytes) stderr_first=\(relativeDeliveryTime(standardError.first)) stderr_last=\(relativeDeliveryTime(standardError.last)) finished_seen=\(finishedSeen) elapsed_seconds=\(duration.seconds) elapsed_attoseconds=\(duration.attoseconds)\n" + do { + try FileHandle.standardError.write(contentsOf: Data(record.utf8)) + } catch { + throw ToolkitError.fileSystem("Could not write post-boot command delivery diagnostics to standard error.", path: nil, underlying: error) + } + } + + static func relativeDeliveryTime(_ duration: Duration?) -> String { + guard let duration else { return "none" } + let components = duration.components + return "\(components.seconds):\(components.attoseconds)" + } + + static func command(_ arguments: [String], timeout: TimeInterval, runner: some CommandRunning) async throws -> CommandResult { + let request = CommandRequest(executable: try AppleTool.xcrun.locate(), arguments: arguments, timeout: timeout, displayName: "package fixture tool") + let result = try await runner.run(request) + guard result.succeeded else { throw failure("A package fixture tool failed.", cause: nil).appendingDetail(String(result.technicalSummary.prefix(1536))) } + return result + } + + static func string(_ value: JSONValue, field: String) throws -> String { + guard case .string(let text) = value[field], !text.isEmpty, text.utf8.count <= 512 else { throw failure("simctl returned an invalid \(field) string.", cause: nil) } + return text + } + + static func boolean(_ value: JSONValue, field: String) throws -> Bool { + guard case .bool(let result) = value[field] else { throw failure("simctl returned an invalid \(field) boolean.", cause: nil) } + return result + } + + static func bounded(_ error: Error) -> String { String(String(describing: error).prefix(1536)) } + + static func failure(_ message: String, cause: Error?) -> ToolkitError { + ToolkitError(.commandFailed, message: message, recovery: "Inspect the hosted fixture step and retained ownership registry before retrying.", technicalDetail: cause.map(bounded)) + } +} diff --git a/scripts/PackageProcessObservation.swift b/scripts/PackageProcessObservation.swift new file mode 100644 index 0000000..3e9a99b --- /dev/null +++ b/scripts/PackageProcessObservation.swift @@ -0,0 +1,321 @@ +import Darwin +import Foundation +import ToolkitCore + +/// Observes only this helper and its direct xcrun/simctl child. Sampling perturbs execution; +/// PID identity checks bracket attachment but cannot make the external sample attach atomic. +struct PackageProcessObservation: Sendable { + let runner: ProcessCommandRunner + + enum Target: String, Encodable, Sendable { case helper, xcrun, simctl } + enum Stage: String, Encodable, Sendable { case identity, children, sample, read, parse, cleanup } + enum Code: String, Encodable, Sendable { + case unavailable, invalidMetadata, capacityReached, ambiguous, changedIdentity + case commandFailed, unreadable, oversized, missingCallGraph, cleanupFailed + } + enum SampleStatus: String, Encodable, Sendable { case captured, cancelled, unavailable } + + struct Failure: Encodable, Sendable { + let stage: Stage + let code: Code + let systemCode: Int32? + let toolkitKind: String? + } + struct ChildState: Encodable, Sendable { + let pid: Int32 + let parent: Int32 + let uid: UInt32 + let birthSeconds: UInt64 + let birthMicroseconds: UInt64 + let state: UInt32 + let target: Target + } + struct Snapshot: Encodable, Sendable { + let elapsedMilliseconds: Int64 + let helperState: UInt32 + let matchingChildren: Int + let child: ChildState? + } + struct Counts: Encodable, Sendable { + var frameLines = 0 + var machMessage = 0 + var xpc = 0 + var coreSimulator = 0 + var read = 0 + var write = 0 + var fileHandle = 0 + var consume = 0 + var finish = 0 + var lock = 0 + var semaphore = 0 + var dispatch = 0 + var processWait = 0 + } + struct Sample: Encodable, Sendable { + let target: Target + let elapsedMilliseconds: Int64 + let status: SampleStatus + let counts: Counts? + } + struct Report: Encodable, Sendable { + let helperPID: Int32 + let elapsedMilliseconds: Int64 + let snapshots: [Snapshot] + let samples: [Sample] + let failures: [Failure] + let omittedFailures: Int + let observerCancelled: Bool + let iterationLimitReached: Bool + } + private struct Identity: Equatable, Sendable { + let pid: Int32 + let parent: Int32 + let uid: UInt32 + let birthSeconds: UInt64 + let birthMicroseconds: UInt64 + let name: String + let path: String + + var queryTarget: Target? { + guard URL(fileURLWithPath: path).lastPathComponent == name else { return nil } + if name == "xcrun" { return .xcrun } + if name == "simctl" { return .simctl } + return nil + } + } + private struct ProcessState { + let identity: Identity + let state: UInt32 + } + private struct Fault: Error { + let failure: Failure + let closeFailure: Failure? + } + + func collect() async -> Report { + let clock = ContinuousClock() + let started = clock.now + let helperPID = getpid() + var snapshots: [Snapshot] = [] + var samples: [Sample] = [] + var failures: [Failure] = [] + var iterations = 0 + var sampled = false + var cancelled = false + for _ in 0..<70 { + if Task.isCancelled { cancelled = true; break } + iterations += 1 + do { + let helper = try Self.identity(helperPID) + guard helper.identity.uid == geteuid() else { throw Self.fault(.identity, .invalidMetadata, nil) } + let children = try Self.children(helperPID).compactMap { pid -> ProcessState? in + let child = try Self.identity(pid) + guard child.identity.parent == helperPID, child.identity.uid == geteuid(), + child.identity.queryTarget != nil else { return nil } + return child + } + let elapsed = Self.milliseconds(started.duration(to: clock.now)) + let child = children.count == 1 ? children[0] : nil + snapshots.append(Snapshot(elapsedMilliseconds: elapsed, helperState: helper.state, + matchingChildren: children.count, child: child.flatMap(Self.childState))) + if children.count > 1 { throw Self.fault(.children, .ambiguous, nil) } + if !sampled, elapsed >= 20_000, let child, let target = child.identity.queryTarget { + sampled = true + let childResult = await sample(child.identity, target: target, started: started, clock: clock) + samples.append(childResult.sample) + failures.append(contentsOf: childResult.failures) + if !Task.isCancelled { + let helperResult = await sample(helper.identity, target: .helper, started: started, clock: clock) + samples.append(helperResult.sample) + failures.append(contentsOf: helperResult.failures) + } + } + } catch let error as Fault { + failures.append(error.failure) + } catch { + failures.append(Failure(stage: .identity, code: .unavailable, systemCode: nil, toolkitKind: nil)) + } + do { try await Task.sleep(nanoseconds: 1_000_000_000) } + catch { cancelled = true; break } + } + return Report(helperPID: helperPID, elapsedMilliseconds: Self.milliseconds(started.duration(to: clock.now)), + snapshots: snapshots, samples: samples, failures: Array(failures.prefix(16)), omittedFailures: max(0, failures.count - 16), + observerCancelled: cancelled || Task.isCancelled, iterationLimitReached: !cancelled && !Task.isCancelled && iterations == 70) + } + + static func write(_ report: Report) throws { + let encoded = try JSONEncoder().encode(report) + guard encoded.count <= 32_768 else { + throw ToolkitError(.internalInconsistency, message: "Post-boot process diagnostics exceeded their output bound.", recovery: "Inspect the bounded observation schema.", technicalDetail: nil) + } + var line = Data("[package fixtures] postboot-process ".utf8) + line.append(encoded) + line.append(10) + try FileHandle.standardError.write(contentsOf: line) + } + + private static func childState(_ process: ProcessState) -> ChildState? { + let id = process.identity + guard let target = id.queryTarget else { return nil } + return ChildState(pid: id.pid, parent: id.parent, uid: id.uid, birthSeconds: id.birthSeconds, + birthMicroseconds: id.birthMicroseconds, state: process.state, target: target) + } + + private static func children(_ parent: Int32) throws -> [Int32] { + var pids = [pid_t](repeating: 0, count: 32) + errno = 0 + let count = pids.withUnsafeMutableBytes { proc_listchildpids(parent, $0.baseAddress, Int32($0.count)) } + let code = errno + guard count >= 0, count < pids.count else { throw fault(.children, .capacityReached, code) } + guard count != 0 || code == 0 else { throw fault(.children, .unavailable, code) } + let selected = Array(pids.prefix(Int(count))) + guard selected.allSatisfy({ $0 > 0 }) else { throw fault(.children, .invalidMetadata, nil) } + return selected + } + + private static func identity(_ pid: Int32) throws -> ProcessState { + var info = proc_bsdinfo() + errno = 0 + let size = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, Int32(MemoryLayout.size)) + guard size == MemoryLayout.size else { throw fault(.identity, .unavailable, errno) } + let nameBytes = withUnsafeBytes(of: info.pbi_comm) { Array($0.prefix { $0 != 0 }) } + // proc_info.h defines the maximum as 4 * MAXPATHLEN; Swift cannot import that macro. + var pathBytes = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN)) + errno = 0 + let length = pathBytes.withUnsafeMutableBytes { proc_pidpath(pid, $0.baseAddress, UInt32($0.count)) } + guard length > 0, length < pathBytes.count, let end = pathBytes.firstIndex(of: 0), end > 0, + let path = String(bytes: pathBytes.prefix(end).map { UInt8(bitPattern: $0) }, encoding: .utf8), + let name = String(bytes: nameBytes, encoding: .utf8), !name.isEmpty, + path.hasPrefix("/"), info.pbi_pid == UInt32(pid), info.pbi_ppid <= UInt32(Int32.max), + info.pbi_start_tvsec > 0, info.pbi_start_tvusec < 1_000_000 else { throw fault(.identity, .invalidMetadata, nil) } + return ProcessState(identity: Identity(pid: pid, parent: Int32(info.pbi_ppid), uid: info.pbi_uid, + birthSeconds: info.pbi_start_tvsec, birthMicroseconds: info.pbi_start_tvusec, + name: name, path: path), state: info.pbi_status) + } + + private func sample(_ expected: Identity, target: Target, started: ContinuousClock.Instant, clock: ContinuousClock) async -> (sample: Sample, failures: [Failure]) { + let elapsed = Self.milliseconds(started.duration(to: clock.now)) + var directory: URL? + var counts: Counts? + var status = SampleStatus.unavailable + var failures: [Failure] = [] + do { + guard try Self.identity(expected.pid).identity == expected else { throw Self.fault(.sample, .changedIdentity, nil) } + try Task.checkCancellation() + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "idt-postboot-sample") + directory = root + let file = root.appendingPathComponent("sample.txt") + try SecureFileIO.writeNewFile(Data(), to: file, mode: 0o600) + let request = CommandRequest(executable: URL(fileURLWithPath: "/usr/bin/sample"), + arguments: [String(expected.pid), "1", "10", "-mayDie", "-file", file.path], + environment: CommandEnvironment.minimal(adding: [:]), workingDirectory: root, standardInput: nil, + timeout: 8, outputLimit: 8192, displayName: "postboot process sample", terminationGracePeriod: 1) + let result = try await runSample(request) + try Task.checkCancellation() + guard result.succeeded else { throw Self.fault(.sample, .commandFailed, result.exitCode) } + guard try Self.identity(expected.pid).identity == expected else { throw Self.fault(.sample, .changedIdentity, nil) } + counts = try Self.parse(Self.read(file)) + status = .captured + } catch is CancellationError { + status = .cancelled + } catch let error as Fault { + failures.append(error.failure) + if let close = error.closeFailure { failures.append(close) } + } catch let error as ToolkitError { + if Task.isCancelled { status = .cancelled } + else { failures.append(Failure(stage: .sample, code: .commandFailed, systemCode: nil, toolkitKind: error.kind.rawValue)) } + } catch { + failures.append(Failure(stage: .sample, code: .unavailable, systemCode: nil, toolkitKind: nil)) + } + if let directory { + do { try FileManager.default.removeItem(at: directory) } + catch { failures.append(Failure(stage: .cleanup, code: .cleanupFailed, systemCode: nil, toolkitKind: nil)) } + } + return (Sample(target: target, elapsedMilliseconds: elapsed, status: status, counts: counts), failures) + } + + /// Cancellation signals the sampler while its independent consumer retains ownership + /// through actual exit. The caller can then remove the report without a live writer. + private func runSample(_ request: CommandRequest) async throws -> CommandResult { + let cancellation = CommandCancellation() + let consumer = Task.detached(priority: .utility) { + var final: CommandResult? + for try await event in runner.stream(request, cancellation: cancellation) { + if case .finished(let result) = event { final = result } + } + guard let final else { + throw ToolkitError(.internalInconsistency, message: "The process sampler ended without a result.", recovery: "Inspect the sampler's lifecycle.", technicalDetail: nil) + } + return final + } + return try await withTaskCancellationHandler { + try await consumer.value + } onCancel: { + cancellation.cancel() + } + } + + private static func read(_ file: URL) throws -> Data { + let descriptor = open(file.path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard descriptor >= 0 else { throw fault(.read, .unreadable, errno) } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + var outcome: Result + do { + var metadata = stat() + guard fstat(descriptor, &metadata) == 0 else { throw fault(.read, .unreadable, errno) } + guard metadata.st_mode & S_IFMT == S_IFREG, metadata.st_nlink == 1, + metadata.st_uid == getuid(), metadata.st_mode & 0o777 == 0o600, + metadata.st_size > 0, metadata.st_size <= 2_097_152 else { throw fault(.read, .invalidMetadata, nil) } + guard let data = try handle.read(upToCount: 2_097_153) else { throw fault(.read, .unreadable, nil) } + guard !data.isEmpty, data.count <= 2_097_152 else { throw fault(.read, .oversized, nil) } + outcome = .success(data) + } catch let error as Fault { outcome = .failure(error) } + catch { outcome = .failure(fault(.read, .unreadable, nil)) } + do { try handle.close() } + catch { + let close = Failure(stage: .read, code: .unreadable, systemCode: nil, toolkitKind: nil) + switch outcome { + case .success: throw Fault(failure: close, closeFailure: nil) + case .failure(let primary): throw Fault(failure: primary.failure, closeFailure: close) + } + } + return try outcome.get() + } + + private static func parse(_ data: Data) throws -> Counts { + guard let text = String(data: data, encoding: .utf8) else { throw fault(.parse, .invalidMetadata, nil) } + var active = false + var ended = false + var counts = Counts() + for line in text.split(separator: "\n", omittingEmptySubsequences: false) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed == "Call graph:" { active = true; continue } + if active, trimmed.hasPrefix("Total number in stack") || trimmed.hasPrefix("Binary Images:") || trimmed.hasPrefix("Sort by top of stack") { ended = true; break } + guard active, line.contains("(in ") else { continue } + counts.frameLines += 1 + if line.contains("mach_msg") { counts.machMessage += 1 } + if line.contains("xpc_") || line.contains("XPC") { counts.xpc += 1 } + if line.contains("CoreSimulator") || line.contains("SimService") { counts.coreSimulator += 1 } + if line.contains("__read") || line.contains(" read +") { counts.read += 1 } + if line.contains("__write") || line.contains(" write +") { counts.write += 1 } + if line.contains("FileHandle") { counts.fileHandle += 1 } + if line.contains("consume(") || line.contains("consume_") { counts.consume += 1 } + if line.contains("ProcessExecution.finish") { counts.finish += 1 } + if ["NSLock", "NSRecursiveLock", "pthread_mutex", "pthread_rwlock", "os_unfair_lock", "__ulock_wait", "__psynch_mutexwait", "__psynch_rw"].contains(where: line.contains) { counts.lock += 1 } + if line.contains("semaphore") { counts.semaphore += 1 } + if line.contains("dispatch_") { counts.dispatch += 1 } + if line.contains("wait4") || line.contains("waitpid") { counts.processWait += 1 } + } + guard active, ended, counts.frameLines > 0 else { throw fault(.parse, .missingCallGraph, nil) } + return counts + } + + private static func milliseconds(_ duration: Duration) -> Int64 { + let parts = duration.components + return parts.seconds * 1000 + parts.attoseconds / 1_000_000_000_000_000 + } + + private static func fault(_ stage: Stage, _ code: Code, _ systemCode: Int32?) -> Fault { + Fault(failure: Failure(stage: stage, code: code, systemCode: systemCode, toolkitKind: nil), closeFailure: nil) + } +}