diff --git a/macos/ForgePlatformInstaller/README.md b/macos/ForgePlatformInstaller/README.md index 3b6f5f9a..c75af107 100644 --- a/macos/ForgePlatformInstaller/README.md +++ b/macos/ForgePlatformInstaller/README.md @@ -236,6 +236,15 @@ An `ENABLED` system job is accepted only after a fixed-label native readback confirms the registered parent bundle, Team, helper program and exact current installer version. An older registered helper fails closed; registration does not silently replace it. A separate reviewed transition is required. +For the earlier 0.2.4 qualification daemon only, `helper +replace-qualification` requires the fixed registered parent identity plus a +fresh idle/zero-run system readback before `SMAppService.unregister()`. +It then demands independent absence of that exact system job and a native +`NOT_REGISTERED` or `NOT_FOUND` status before registering and reattesting the +bundled helper. Registration and transition share the installer's exclusive +host lock with self-update. An interrupted transition can resume through the ordinary +`helper register` command once the system job is absent; another enabled +parent or an ambiguous status remains blocked. On a Mac where the service has never been seen, native status can initially be `NOT_FOUND`; the fixed bundled service is registered once and its status read again. A registration error accompanied by native `REQUIRES_APPROVAL` remains diff --git a/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCLI/ForgePlatformInstallerCLI.swift b/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCLI/ForgePlatformInstallerCLI.swift index 0e553f71..4e2d10b3 100644 --- a/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCLI/ForgePlatformInstallerCLI.swift +++ b/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCLI/ForgePlatformInstallerCLI.swift @@ -66,6 +66,8 @@ enum ForgePlatformInstallerCLIApplication { confirm: Confirmation, registerHelper: @escaping InstallerCLIHelperRegistration.Registrar = InstallerCLIHelperRegistration.liveRegistrar, + replaceQualificationHelper: @escaping InstallerCLIHelperRegistration.Registrar = + InstallerCLIHelperRegistration.liveQualificationReplacer, challengeWriter: ChallengeWriter = writeChallengeToTTY, stdout: Writer, stderr: Writer @@ -196,6 +198,16 @@ enum ForgePlatformInstallerCLIApplication { confirm: confirm, register: registerHelper ) + case .helperReplaceQualification: + result = await InstallerCLIHelperRegistration.run( + startup: startup, + currentVersion: currentVersion, + currentRelease: currentRelease, + options: invocation.options, + confirm: confirm, + register: replaceQualificationHelper, + replacingQualification: true + ) case .deploymentList: result = await workflow.listDeployments() case .deploymentPlan(let deployment): diff --git a/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCLI/InstallerCLIHelperRegistration.swift b/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCLI/InstallerCLIHelperRegistration.swift index 3b1dcfa5..b038506c 100644 --- a/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCLI/InstallerCLIHelperRegistration.swift +++ b/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCLI/InstallerCLIHelperRegistration.swift @@ -7,9 +7,18 @@ enum InstallerCLIHelperRegistration { static let liveRegistrar: Registrar = { expectedVersion in let service = MacOSManagedInstallerPrivilegedHelperServiceController() - return await ManagedInstallerPrivilegedHelperRegistrationCoordinator( + guard let coordinator = ManagedInstallerPrivilegedHelperRegistrationCoordinator.production( service: service - ).ensureRegistered(expectedVersion: expectedVersion) + ) else { return .failed(.transitionBusy) } + return await coordinator.ensureRegistered(expectedVersion: expectedVersion) + } + + static let liveQualificationReplacer: Registrar = { expectedVersion in + let service = MacOSManagedInstallerPrivilegedHelperServiceController() + guard let coordinator = ManagedInstallerPrivilegedHelperRegistrationCoordinator.production( + service: service + ) else { return .failed(.transitionBusy) } + return await coordinator.replaceLegacyQualification(expectedVersion: expectedVersion) } static func run( @@ -18,11 +27,14 @@ enum InstallerCLIHelperRegistration { currentRelease: VerifiedInstallerRelease, options: InstallerCLIOptions, confirm: ForgePlatformInstallerCLIApplication.Confirmation, - register: Registrar + register: Registrar, + replacingQualification: Bool = false ) async -> InstallerCLIResult { if !options.assumeYes { let accepted = options.nonInteractive ? false : await confirm( - "Registreer de geverifieerde installer-helper als systeemdaemon?" + replacingQualification + ? "Vervang de exacte inactieve 0.2.4-kwalificatiehelper door de geverifieerde systeemhelper?" + : "Registreer de geverifieerde installer-helper als systeemdaemon?" ) if !accepted { return InstallerCLIResult( @@ -72,6 +84,8 @@ enum InstallerCLIHelperRegistration { case .serviceUnavailable: reason = "service-unavailable" case .statusDrift: reason = "status-drift" case .registeredParentMismatch: reason = "registered-parent-mismatch" + case .unregistrationFailed: reason = "unregistration-failed" + case .transitionBusy: reason = "transition-busy" } return InstallerCLIResult( exitCode: .executionFailed, diff --git a/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCore/InstallerCLI.swift b/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCore/InstallerCLI.swift index a193bd8c..a8d5e803 100644 --- a/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCore/InstallerCLI.swift +++ b/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCore/InstallerCLI.swift @@ -5,6 +5,7 @@ public enum InstallerCLICommand: Equatable, Sendable { case version case status case helperRegister + case helperReplaceQualification case selfUpdateCheck case selfUpdateApply case deploymentList @@ -113,6 +114,7 @@ public enum InstallerCLIParser { forge-platform-installer version [--json] forge-platform-installer status [--json] forge-platform-installer helper register [--yes] [--non-interactive] [--json] + forge-platform-installer helper replace-qualification [--yes] [--non-interactive] [--json] forge-platform-installer self-update check [--json] forge-platform-installer self-update apply [--yes] [--json] forge-platform-installer deployment list [--json] @@ -237,6 +239,8 @@ public enum InstallerCLIParser { command = .status case ["helper", "register"]: command = .helperRegister + case ["helper", "replace-qualification"]: + command = .helperReplaceQualification case ["self-update", "check"]: command = .selfUpdateCheck case ["self-update", "apply"]: diff --git a/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCore/ManagedInstallerPrivilegedHelperRegistration.swift b/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCore/ManagedInstallerPrivilegedHelperRegistration.swift index 8c56e929..f32e0d38 100644 --- a/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCore/ManagedInstallerPrivilegedHelperRegistration.swift +++ b/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCore/ManagedInstallerPrivilegedHelperRegistration.swift @@ -26,6 +26,8 @@ public enum ManagedInstallerPrivilegedHelperRegistrationFailure: case serviceUnavailable case statusDrift case registeredParentMismatch + case unregistrationFailed + case transitionBusy } public struct ManagedInstallerPrivilegedHelperRegistrationReceipt: @@ -58,7 +60,10 @@ public enum ManagedInstallerPrivilegedHelperRegistrationResult: public protocol ManagedInstallerPrivilegedHelperServiceControlling: Sendable { func readStatus() -> ManagedInstallerPrivilegedHelperStatus func readRegisteredParentVersion() -> InstallerVersion? + func readIdleRegisteredParentVersion() -> InstallerVersion? + func readSystemJobAbsent() -> Bool func register() throws + func unregister() throws } /// Thin ServiceManagement adapter. The daemon plist name is fixed by the @@ -68,7 +73,10 @@ public final class MacOSManagedInstallerPrivilegedHelperServiceController: ManagedInstallerPrivilegedHelperServiceControlling, @unchecked Sendable { private let statusReader: @Sendable () -> SMAppService.Status private let registrar: @Sendable () throws -> Void + private let unregistrar: @Sendable () throws -> Void private let parentVersionReader: @Sendable () -> InstallerVersion? + private let idleParentVersionReader: @Sendable () -> InstallerVersion? + private let jobAbsenceReader: @Sendable () -> Bool public init() { let service = SMAppService.daemon( @@ -76,17 +84,26 @@ public final class MacOSManagedInstallerPrivilegedHelperServiceController: ) statusReader = { service.status } registrar = { try service.register() } + unregistrar = { try service.unregister() } parentVersionReader = { RegisteredInstallerHelperParentReader().readVersion() } + idleParentVersionReader = { RegisteredInstallerHelperParentReader().readIdleVersion() } + jobAbsenceReader = { RegisteredInstallerHelperParentReader().readAbsent() } } init( statusReader: @escaping @Sendable () -> SMAppService.Status, registrar: @escaping @Sendable () throws -> Void, - parentVersionReader: @escaping @Sendable () -> InstallerVersion? = { nil } + parentVersionReader: @escaping @Sendable () -> InstallerVersion? = { nil }, + idleParentVersionReader: @escaping @Sendable () -> InstallerVersion? = { nil }, + jobAbsenceReader: @escaping @Sendable () -> Bool = { false }, + unregistrar: @escaping @Sendable () throws -> Void = {} ) { self.statusReader = statusReader self.registrar = registrar + self.unregistrar = unregistrar self.parentVersionReader = parentVersionReader + self.idleParentVersionReader = idleParentVersionReader + self.jobAbsenceReader = jobAbsenceReader } public func readStatus() -> ManagedInstallerPrivilegedHelperStatus { @@ -106,12 +123,39 @@ public final class MacOSManagedInstallerPrivilegedHelperServiceController: public func readRegisteredParentVersion() -> InstallerVersion? { parentVersionReader() } + + public func readIdleRegisteredParentVersion() -> InstallerVersion? { + idleParentVersionReader() + } + + public func readSystemJobAbsent() -> Bool { + jobAbsenceReader() + } + + public func unregister() throws { + try unregistrar() + } } /// Inspects only the fixed system job. The parent identity is an independent /// ServiceManagement readback, never a version supplied by the CLI caller. struct RegisteredInstallerHelperParentReader: Sendable { func readVersion() -> InstallerVersion? { + guard let observation = readObservation(), observation.status == 0 else { return nil } + return Self.parse(observation.output) + } + + func readIdleVersion() -> InstallerVersion? { + guard let observation = readObservation(), observation.status == 0 else { return nil } + return Self.parseIdle(observation.output) + } + + func readAbsent() -> Bool { + guard let observation = readObservation() else { return false } + return Self.isAbsent(status: observation.status, output: observation.output) + } + + private func readObservation() -> (status: Int32, output: String)? { let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/launchctl") process.arguments = ["print", "system/" + ManagedInstallerPrivilegedHelperContract.label] @@ -138,10 +182,22 @@ struct RegisteredInstallerHelperParentReader: Sendable { } guard drained.wait(timeout: .now() + .seconds(2)) == .success, process.terminationReason == .exit, - process.terminationStatus == 0, let data = collector.collectedData(), let output = String(data: data, encoding: .utf8) else { return nil } - return Self.parse(output) + return (process.terminationStatus, output) + } + + static func isAbsent(status: Int32, output: String) -> Bool { + guard status == 113 else { return false } + return output.trimmingCharacters(in: .whitespacesAndNewlines) == + "Bad request.\nCould not find service \"\(ManagedInstallerPrivilegedHelperContract.label)\" in domain for system" + } + + static func parseIdle(_ output: String) -> InstallerVersion? { + let lines = output.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) } + guard lines.filter({ $0 == "state = not running" }).count == 1, + lines.filter({ $0 == "runs = 0" }).count == 1 else { return nil } + return parse(output) } static func parse(_ output: String) -> InstallerVersion? { @@ -155,7 +211,7 @@ struct RegisteredInstallerHelperParentReader: Sendable { } guard lines.first == "system/\(ManagedInstallerPrivilegedHelperContract.label) = {", uniqueValue("managed_by") == "com.apple.xpc.ServiceManagement", - lines.filter({ $0 == "\"team-identifier\" => \"ZEML4LPXH4\"" }).count == 1, + lines.filter({ $0 == "\"team-identifier\" => \"\(ManagedInstallerHelperSignedParentBundleLocator.teamIdentifier)\"" }).count == 1, lines.filter({ $0 == "\"signing-identifier\" => \"\(ManagedInstallerPrivilegedHelperContract.label)\"" }).count == 1, uniqueValue("parent bundle identifier") == ManagedInstallerHelperSignedParentBundleLocator.bundleIdentifier, @@ -172,13 +228,34 @@ struct RegisteredInstallerHelperParentReader: Sendable { /// route. User approval remains an explicit non-ready state. public actor ManagedInstallerPrivilegedHelperRegistrationCoordinator { private let service: any ManagedInstallerPrivilegedHelperServiceControlling + private let operationLock: (any InstallerSelfUpdateOperationLocking)? - public init(service: any ManagedInstallerPrivilegedHelperServiceControlling) { + public init( + service: any ManagedInstallerPrivilegedHelperServiceControlling, + operationLock: (any InstallerSelfUpdateOperationLocking)? = nil + ) { self.service = service + self.operationLock = operationLock + } + + public static func production( + service: any ManagedInstallerPrivilegedHelperServiceControlling + ) -> ManagedInstallerPrivilegedHelperRegistrationCoordinator? { + guard let root = try? MacOSInstallerUserStateRoot.prepare() else { return nil } + return Self(service: service, + operationLock: FileInstallerSelfUpdateOperationLock(rootDirectory: root)) } public func ensureRegistered( expectedVersion: InstallerVersion + ) -> ManagedInstallerPrivilegedHelperRegistrationResult { + withRegistrationLock { + ensureRegisteredWhileLocked(expectedVersion: expectedVersion) + } + } + + private func ensureRegisteredWhileLocked( + expectedVersion: InstallerVersion ) -> ManagedInstallerPrivilegedHelperRegistrationResult { switch service.readStatus() { case .enabled: @@ -211,6 +288,62 @@ public actor ManagedInstallerPrivilegedHelperRegistrationCoordinator { } } + /// One-time transition from the known idle 0.2.4 qualification daemon. + /// A crash after unregister leaves a visible NOT_REGISTERED state; the + /// ordinary fixed-label `register` route can safely finish from there. + public func replaceLegacyQualification( + expectedVersion: InstallerVersion + ) -> ManagedInstallerPrivilegedHelperRegistrationResult { + withRegistrationLock { + replaceLegacyQualificationWhileLocked(expectedVersion: expectedVersion) + } + } + + private func replaceLegacyQualificationWhileLocked( + expectedVersion: InstallerVersion + ) -> ManagedInstallerPrivilegedHelperRegistrationResult { + let initialStatus = service.readStatus() + guard let legacy = try? InstallerVersion("0.2.4"), + legacy < expectedVersion, + initialStatus != .requiresApproval, + service.readRegisteredParentVersion() == legacy, + service.readIdleRegisteredParentVersion() == legacy, + service.readStatus() == initialStatus, + service.readRegisteredParentVersion() == legacy, + service.readIdleRegisteredParentVersion() == legacy else { + return .failed(.registeredParentMismatch) + } + do { + try service.unregister() + } catch { + return .failed(.unregistrationFailed) + } + guard service.readSystemJobAbsent() else { + return .failed(.statusDrift) + } + switch service.readStatus() { + case .notRegistered, .notFound: + return ensureRegisteredWhileLocked(expectedVersion: expectedVersion) + case .enabled, .requiresApproval: + return .failed(.statusDrift) + } + } + + private func withRegistrationLock( + _ operation: () -> ManagedInstallerPrivilegedHelperRegistrationResult + ) -> ManagedInstallerPrivilegedHelperRegistrationResult { + guard let operationLock else { return operation() } + guard case .success(let lease) = operationLock + .acquireExclusiveSelfUpdateOperationLock() else { + return .failed(.transitionBusy) + } + let result = operation() + guard case .success = lease.releaseExclusiveSelfUpdateOperationLock() else { + return .failed(.transitionBusy) + } + return result + } + private func verifiedReady( expectedVersion: InstallerVersion ) -> ManagedInstallerPrivilegedHelperRegistrationResult { diff --git a/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCLITests/ForgePlatformInstallerCLIApplicationTests.swift b/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCLITests/ForgePlatformInstallerCLIApplicationTests.swift index 175fa42d..116a0a21 100644 --- a/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCLITests/ForgePlatformInstallerCLIApplicationTests.swift +++ b/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCLITests/ForgePlatformInstallerCLIApplicationTests.swift @@ -570,6 +570,51 @@ final class ForgePlatformInstallerCLIApplicationTests: XCTestCase { XCTAssertEqual(readyCalls, 1) } + func testLegacyHelperReplacementRequiresConsentAndExactNativeResult() async throws { + let current = try release("1.2.3") + let registrar = CLIHelperRegistrarSpy(result: .ready( + try ManagedInstallerPrivilegedHelperRegistrationReceipt(status: .enabled) + )) + let startup = CLIStartupSpy( + outcome: .ready(currentRelease: current, coordinator: CLIReadyCoordinator()) + ) + let denied = await run( + ["helper", "replace-qualification", "--non-interactive"], + startup: startup, version: "1.2.3", + replacement: { _ in await registrar.invoke() } + ) + XCTAssertEqual(denied.code, InstallerCLIExitCode.confirmationRequired.rawValue) + let callsAfterDenial = await registrar.calls() + XCTAssertEqual(callsAfterDenial, 0) + + let replaced = await run( + ["helper", "replace-qualification", "--yes", "--non-interactive", "--json"], + startup: startup, version: "1.2.3", + replacement: { _ in await registrar.invoke() } + ) + XCTAssertEqual(replaced.code, InstallerCLIExitCode.success.rawValue) + XCTAssertTrue(replaced.stdout.joined().contains("helper-enabled")) + let callsAfterReplacement = await registrar.calls() + XCTAssertEqual(callsAfterReplacement, 1) + + let interactivelyConfirmed = await run( + ["helper", "replace-qualification", "--json"], + startup: startup, version: "1.2.3", + replacement: { _ in await registrar.invoke() } + ) + XCTAssertEqual(interactivelyConfirmed.code, InstallerCLIExitCode.success.rawValue) + let callsAfterInteractiveConfirmation = await registrar.calls() + XCTAssertEqual(callsAfterInteractiveConfirmation, 2) + + let mismatch = await run( + ["helper", "replace-qualification", "--yes", "--json"], + startup: startup, version: "1.2.3", + replacement: { _ in .failed(.registeredParentMismatch) } + ) + XCTAssertEqual(mismatch.code, InstallerCLIExitCode.executionFailed.rawValue) + XCTAssertTrue(mismatch.stdout.joined().contains("registered-parent-mismatch")) + } + func testHelperRegistrationPreservesApprovalFailureAndReleaseDrift() async throws { let current = try release("1.2.3") let next = try release("1.2.4") @@ -592,6 +637,8 @@ final class ForgePlatformInstallerCLIApplicationTests: XCTestCase { .serviceUnavailable, .statusDrift, .registeredParentMismatch, + .unregistrationFailed, + .transitionBusy, ] { let failed = await run( ["helper", "register", "--yes"], @@ -642,6 +689,9 @@ final class ForgePlatformInstallerCLIApplicationTests: XCTestCase { confirmation: Bool = true, registration: @escaping InstallerCLIHelperRegistration.Registrar = { _ in .failed(.serviceUnavailable) + }, + replacement: @escaping InstallerCLIHelperRegistration.Registrar = { _ in + .failed(.serviceUnavailable) } ) async -> (code: Int32, stdout: [String], stderr: [String]) { let output = LockedStrings() @@ -655,6 +705,7 @@ final class ForgePlatformInstallerCLIApplicationTests: XCTestCase { }, confirm: { _ in confirmation }, registerHelper: registration, + replaceQualificationHelper: replacement, stdout: { output.append($0) }, stderr: { errors.append($0) } ) diff --git a/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCoreTests/InstallerCLITests.swift b/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCoreTests/InstallerCLITests.swift index cd282575..bea199fc 100644 --- a/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCoreTests/InstallerCLITests.swift +++ b/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCoreTests/InstallerCLITests.swift @@ -57,6 +57,10 @@ final class InstallerCLITests: XCTestCase { try InstallerCLIParser.parse(["helper", "register", "--yes"]).command, .helperRegister ) + XCTAssertEqual( + try InstallerCLIParser.parse(["helper", "replace-qualification", "--yes"]).command, + .helperReplaceQualification + ) XCTAssertEqual(try InstallerCLIParser.parse(["self-update", "check"]).command, .selfUpdateCheck) XCTAssertEqual(try InstallerCLIParser.parse(["self-update", "apply"]).command, .selfUpdateApply) XCTAssertEqual(try InstallerCLIParser.parse(["deployment", "list"]).command, .deploymentList) diff --git a/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCoreTests/ManagedInstallerPrivilegedHelperRegistrationTests.swift b/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCoreTests/ManagedInstallerPrivilegedHelperRegistrationTests.swift index 8b38d0c0..bd0b545a 100644 --- a/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCoreTests/ManagedInstallerPrivilegedHelperRegistrationTests.swift +++ b/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCoreTests/ManagedInstallerPrivilegedHelperRegistrationTests.swift @@ -109,13 +109,19 @@ final class ManagedInstallerPrivilegedHelperRegistrationTests: XCTestCase { let controller = MacOSManagedInstallerPrivilegedHelperServiceController( statusReader: { native }, registrar: { registered.increment() }, - parentVersionReader: { try? InstallerVersion("0.3.5") } + parentVersionReader: { try? InstallerVersion("0.3.5") }, + idleParentVersionReader: { try? InstallerVersion("0.2.4") }, + jobAbsenceReader: { false }, + unregistrar: { registered.increment() } ) XCTAssertEqual(controller.readStatus(), expected) XCTAssertEqual(controller.readRegisteredParentVersion(), try InstallerVersion("0.3.5")) + XCTAssertEqual(controller.readIdleRegisteredParentVersion(), try InstallerVersion("0.2.4")) + XCTAssertFalse(controller.readSystemJobAbsent()) try controller.register() + try controller.unregister() } - XCTAssertEqual(registered.value(), 4) + XCTAssertEqual(registered.value(), 8) } func testEnabledOldOrUnreadableParentFailsClosed() async throws { @@ -143,6 +149,23 @@ final class ManagedInstallerPrivilegedHelperRegistrationTests: XCTestCase { """ XCTAssertEqual(RegisteredInstallerHelperParentReader.parse(valid), try InstallerVersion("0.3.5")) + XCTAssertEqual(RegisteredInstallerHelperParentReader.parseIdle( + valid + "\nstate = not running\nruns = 0\n" + ), try InstallerVersion("0.3.5")) + XCTAssertNil(RegisteredInstallerHelperParentReader.parseIdle(valid)) + XCTAssertNil(RegisteredInstallerHelperParentReader.parseIdle( + valid + "\nstate = running\nruns = 1\n" + )) + let absentOutput = "Bad request.\nCould not find service \"\(label)\" in domain for system\n" + XCTAssertTrue(RegisteredInstallerHelperParentReader.isAbsent( + status: 113, output: absentOutput + )) + XCTAssertFalse(RegisteredInstallerHelperParentReader.isAbsent( + status: 0, output: absentOutput + )) + XCTAssertFalse(RegisteredInstallerHelperParentReader.isAbsent( + status: 113, output: absentOutput + "extra" + )) XCTAssertNil(RegisteredInstallerHelperParentReader.parse( valid.replacingOccurrences(of: "0.3.5", with: "garbage") )) @@ -161,10 +184,216 @@ final class ManagedInstallerPrivilegedHelperRegistrationTests: XCTestCase { // CI hosts may have no installer daemon. Either result is an observation; // the coordinator still rejects nil or a version other than its own. let observed = RegisteredInstallerHelperParentReader().readVersion() + _ = RegisteredInstallerHelperParentReader().readIdleVersion() + let absent = RegisteredInstallerHelperParentReader().readAbsent() + if absent { XCTAssertNil(observed) } if let observed { XCTAssertFalse(observed.description.isEmpty) } } + + func testLegacyQualificationTransitionRequiresIdleExactParentAndIsIdempotent() async throws { + let current = try InstallerVersion("0.3.6") + let service = LegacyTransitionService() + let coordinator = ManagedInstallerPrivilegedHelperRegistrationCoordinator(service: service) + let first = await coordinator.replaceLegacyQualification(expectedVersion: current) + XCTAssertNotNil(first.readyReceipt) + XCTAssertEqual(service.counts(), [1, 1]) + let absent = LegacyTransitionService(notFoundAfterUnregister: true) + let absentResult = await ManagedInstallerPrivilegedHelperRegistrationCoordinator( + service: absent + ).replaceLegacyQualification(expectedVersion: current) + XCTAssertNotNil(absentResult.readyReceipt) + XCTAssertEqual(absent.counts(), [1, 1]) + let appScopedStatus = LegacyTransitionService(reportedStatusInitiallyNotRegistered: true) + let appScopedResult = await ManagedInstallerPrivilegedHelperRegistrationCoordinator( + service: appScopedStatus + ).replaceLegacyQualification(expectedVersion: current) + XCTAssertNotNil(appScopedResult.readyReceipt) + XCTAssertEqual(appScopedStatus.counts(), [1, 1]) + let duplicate = await coordinator.replaceLegacyQualification(expectedVersion: current) + XCTAssertEqual(duplicate.failure, .registeredParentMismatch) + XCTAssertEqual(service.counts(), [1, 1]) + + for candidate in [ + LegacyTransitionService(idle: false), + LegacyTransitionService(oldVersion: "0.2.3"), + ] { + let result = await ManagedInstallerPrivilegedHelperRegistrationCoordinator( + service: candidate + ).replaceLegacyQualification(expectedVersion: current) + XCTAssertEqual(result.failure, .registeredParentMismatch) + XCTAssertEqual(candidate.counts(), [0, 0]) + } + } + + func testLegacyQualificationTransitionFailureAndRegistrationResume() async throws { + let current = try InstallerVersion("0.3.6") + let denied = LegacyTransitionService(unregisterFails: true) + let deniedResult = await ManagedInstallerPrivilegedHelperRegistrationCoordinator( + service: denied + ).replaceLegacyQualification(expectedVersion: current) + XCTAssertEqual(deniedResult.failure, .unregistrationFailed) + XCTAssertEqual(denied.counts(), [1, 0]) + + let drift = LegacyTransitionService(staysEnabledAfterUnregister: true) + let driftResult = await ManagedInstallerPrivilegedHelperRegistrationCoordinator( + service: drift + ).replaceLegacyQualification(expectedVersion: current) + XCTAssertEqual(driftResult.failure, .statusDrift) + XCTAssertEqual(drift.counts(), [1, 0]) + let ambiguousAbsence = LegacyTransitionService(jobAbsentReadbackFails: true) + let ambiguousResult = await ManagedInstallerPrivilegedHelperRegistrationCoordinator( + service: ambiguousAbsence + ).replaceLegacyQualification(expectedVersion: current) + XCTAssertEqual(ambiguousResult.failure, .statusDrift) + XCTAssertEqual(ambiguousAbsence.counts(), [1, 0]) + + let interrupted = LegacyTransitionService(registerFails: true) + let coordinator = ManagedInstallerPrivilegedHelperRegistrationCoordinator(service: interrupted) + let interruptedResult = await coordinator.replaceLegacyQualification( + expectedVersion: current + ) + XCTAssertEqual(interruptedResult.failure, .registrationFailed) + XCTAssertEqual(interrupted.counts(), [1, 1]) + interrupted.allowRegistration() + let resumed = await coordinator.ensureRegistered(expectedVersion: current) + XCTAssertNotNil(resumed.readyReceipt) + XCTAssertEqual(interrupted.counts(), [1, 2]) + } + + func testRegistrationAndTransitionShareCrossProcessLease() async throws { + let version = try InstallerVersion("0.3.6") + let busy = RegistrationLockStub(failsAcquisition: true) + let old = LegacyTransitionService() + let blocked = await ManagedInstallerPrivilegedHelperRegistrationCoordinator( + service: old, operationLock: busy + ).replaceLegacyQualification(expectedVersion: version) + XCTAssertEqual(blocked.failure, .transitionBusy) + XCTAssertEqual(old.counts(), [0, 0]) + + let lease = RegistrationLockStub() + let transition = await ManagedInstallerPrivilegedHelperRegistrationCoordinator( + service: old, operationLock: lease + ).replaceLegacyQualification(expectedVersion: version) + XCTAssertNotNil(transition.readyReceipt) + XCTAssertEqual(lease.counts(), [1, 1]) + let ready = await ManagedInstallerPrivilegedHelperRegistrationCoordinator( + service: old, operationLock: lease + ).ensureRegistered(expectedVersion: version) + XCTAssertNotNil(ready.readyReceipt) + XCTAssertEqual(lease.counts(), [2, 2]) + + let badRelease = RegistrationLockStub(failsRelease: true) + let releaseFailure = await ManagedInstallerPrivilegedHelperRegistrationCoordinator( + service: old, operationLock: badRelease + ).ensureRegistered(expectedVersion: version) + XCTAssertEqual(releaseFailure.failure, .transitionBusy) + } +} + +private final class RegistrationLockStub: + InstallerSelfUpdateOperationLocking, InstallerSelfUpdateOperationLock, @unchecked Sendable { + private let lock = NSLock() + private let failsAcquisition: Bool + private let failsRelease: Bool + private var acquisitions = 0 + private var releases = 0 + + init(failsAcquisition: Bool = false, failsRelease: Bool = false) { + self.failsAcquisition = failsAcquisition + self.failsRelease = failsRelease + } + + func acquireExclusiveSelfUpdateOperationLock() + -> Result { + lock.withLock { acquisitions += 1 } + return failsAcquisition + ? .failure(InstallerSelfUpdateFailure(.selfUpdateOperationLockUnavailable)) + : .success(self) + } + + func releaseExclusiveSelfUpdateOperationLock() -> Result { + lock.withLock { releases += 1 } + return failsRelease + ? .failure(InstallerSelfUpdateFailure(.selfUpdateOperationLockUnavailable)) + : .success(()) + } + + func counts() -> [Int] { lock.withLock { [acquisitions, releases] } } +} + +private final class LegacyTransitionService: + ManagedInstallerPrivilegedHelperServiceControlling, @unchecked Sendable { + private let lock = NSLock() + private var enabled = true + private var currentIsNew = false + private var unregisterCount = 0 + private var registerCount = 0 + private var registerFails: Bool + private let unregisterFails: Bool + private let staysEnabledAfterUnregister: Bool + private let notFoundAfterUnregister: Bool + private let reportedStatusInitiallyNotRegistered: Bool + private let jobAbsentReadbackFails: Bool + private let idle: Bool + private let oldVersion: String + + init(idle: Bool = true, oldVersion: String = "0.2.4", + unregisterFails: Bool = false, registerFails: Bool = false, + staysEnabledAfterUnregister: Bool = false, + notFoundAfterUnregister: Bool = false, + reportedStatusInitiallyNotRegistered: Bool = false, + jobAbsentReadbackFails: Bool = false) { + self.idle = idle + self.oldVersion = oldVersion + self.unregisterFails = unregisterFails + self.registerFails = registerFails + self.staysEnabledAfterUnregister = staysEnabledAfterUnregister + self.notFoundAfterUnregister = notFoundAfterUnregister + self.reportedStatusInitiallyNotRegistered = reportedStatusInitiallyNotRegistered + self.jobAbsentReadbackFails = jobAbsentReadbackFails + } + + func readStatus() -> ManagedInstallerPrivilegedHelperStatus { + lock.withLock { + if enabled && currentIsNew { return .enabled } + if enabled { return reportedStatusInitiallyNotRegistered ? .notRegistered : .enabled } + return notFoundAfterUnregister ? .notFound : .notRegistered + } + } + + func readRegisteredParentVersion() -> InstallerVersion? { + lock.withLock { try? InstallerVersion(currentIsNew ? "0.3.6" : oldVersion) } + } + + func readIdleRegisteredParentVersion() -> InstallerVersion? { + lock.withLock { idle && enabled && !currentIsNew ? try? InstallerVersion(oldVersion) : nil } + } + + func readSystemJobAbsent() -> Bool { + lock.withLock { !enabled && !jobAbsentReadbackFails } + } + + func unregister() throws { + try lock.withLock { + unregisterCount += 1 + if unregisterFails { throw ManagedInstallerPrivilegedHelperRegistrationFailure.unregistrationFailed } + if !staysEnabledAfterUnregister { enabled = false } + } + } + + func register() throws { + try lock.withLock { + registerCount += 1 + if registerFails { throw ManagedInstallerPrivilegedHelperRegistrationFailure.registrationFailed } + enabled = true + currentIsNew = true + } + } + + func allowRegistration() { lock.withLock { registerFails = false } } + func counts() -> [Int] { lock.withLock { [unregisterCount, registerCount] } } } private final class LockedCounter: @unchecked Sendable { @@ -202,6 +431,8 @@ private final class HelperServiceController: } func readRegisteredParentVersion() -> InstallerVersion? { parentVersion } + func readIdleRegisteredParentVersion() -> InstallerVersion? { parentVersion } + func readSystemJobAbsent() -> Bool { false } func register() throws { try lock.withLock { @@ -212,6 +443,8 @@ private final class HelperServiceController: } } + func unregister() throws {} + func registerCount() -> Int { lock.withLock { registrations } } func statusReadCount() -> Int { lock.withLock { statusReads } } }