From aea3d5468ba3d1044ab20cdd9488112c28682c4a Mon Sep 17 00:00:00 2001 From: Peter van Tol Date: Thu, 1 Oct 2026 21:14:13 +0200 Subject: [PATCH] Add idle signed helper upgrade route --- .../ForgePlatformInstallerCLI.swift | 12 +++ .../InstallerCLIHelperRegistration.swift | 27 ++++--- .../InstallerCLI.swift | 4 + ...nstallerPrivilegedHelperRegistration.swift | 45 +++++++++-- ...PlatformInstallerCLIApplicationTests.swift | 74 +++++++++++++++++++ .../InstallerCLITests.swift | 7 ++ ...lerPrivilegedHelperRegistrationTests.swift | 47 +++++++++++- 7 files changed, 196 insertions(+), 20 deletions(-) diff --git a/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCLI/ForgePlatformInstallerCLI.swift b/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCLI/ForgePlatformInstallerCLI.swift index 4e2d10b3..95643cb0 100644 --- a/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCLI/ForgePlatformInstallerCLI.swift +++ b/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCLI/ForgePlatformInstallerCLI.swift @@ -68,6 +68,8 @@ enum ForgePlatformInstallerCLIApplication { InstallerCLIHelperRegistration.liveRegistrar, replaceQualificationHelper: @escaping InstallerCLIHelperRegistration.Registrar = InstallerCLIHelperRegistration.liveQualificationReplacer, + replaceIdleHelper: @escaping InstallerCLIHelperRegistration.Registrar = + InstallerCLIHelperRegistration.liveIdleReplacer, challengeWriter: ChallengeWriter = writeChallengeToTTY, stdout: Writer, stderr: Writer @@ -208,6 +210,16 @@ enum ForgePlatformInstallerCLIApplication { register: replaceQualificationHelper, replacingQualification: true ) + case .helperReplaceIdle: + result = await InstallerCLIHelperRegistration.run( + startup: startup, + currentVersion: currentVersion, + currentRelease: currentRelease, + options: invocation.options, + confirm: confirm, + register: replaceIdleHelper, + replacingOlder: 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 b038506c..484b89e8 100644 --- a/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCLI/InstallerCLIHelperRegistration.swift +++ b/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCLI/InstallerCLIHelperRegistration.swift @@ -6,21 +6,25 @@ enum InstallerCLIHelperRegistration { typealias Registrar = @Sendable (InstallerVersion) async -> ManagedInstallerPrivilegedHelperRegistrationResult static let liveRegistrar: Registrar = { expectedVersion in - let service = MacOSManagedInstallerPrivilegedHelperServiceController() - guard let coordinator = ManagedInstallerPrivilegedHelperRegistrationCoordinator.production( - service: service - ) else { return .failed(.transitionBusy) } + guard let coordinator = liveCoordinator() 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) } + guard let coordinator = liveCoordinator() else { return .failed(.transitionBusy) } return await coordinator.replaceLegacyQualification(expectedVersion: expectedVersion) } + static let liveIdleReplacer: Registrar = { expectedVersion in + guard let coordinator = liveCoordinator() else { return .failed(.transitionBusy) } + return await coordinator.replaceIdleOlderRegistration(expectedVersion: expectedVersion) + } + + private static func liveCoordinator() -> ManagedInstallerPrivilegedHelperRegistrationCoordinator? { + let service = MacOSManagedInstallerPrivilegedHelperServiceController() + return ManagedInstallerPrivilegedHelperRegistrationCoordinator.production(service: service) + } + static func run( startup: any InstallerCLIStarting, currentVersion: InstallerVersion, @@ -28,13 +32,16 @@ enum InstallerCLIHelperRegistration { options: InstallerCLIOptions, confirm: ForgePlatformInstallerCLIApplication.Confirmation, register: Registrar, - replacingQualification: Bool = false + replacingQualification: Bool = false, + replacingOlder: Bool = false ) async -> InstallerCLIResult { if !options.assumeYes { let accepted = options.nonInteractive ? false : await confirm( replacingQualification ? "Vervang de exacte inactieve 0.2.4-kwalificatiehelper door de geverifieerde systeemhelper?" - : "Registreer de geverifieerde installer-helper als systeemdaemon?" + : replacingOlder + ? "Vervang de exacte inactieve oudere installer-helper door de geverifieerde actuele systeemhelper?" + : "Registreer de geverifieerde installer-helper als systeemdaemon?" ) if !accepted { return InstallerCLIResult( diff --git a/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCore/InstallerCLI.swift b/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCore/InstallerCLI.swift index a8d5e803..b37beb5f 100644 --- a/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCore/InstallerCLI.swift +++ b/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCore/InstallerCLI.swift @@ -6,6 +6,7 @@ public enum InstallerCLICommand: Equatable, Sendable { case status case helperRegister case helperReplaceQualification + case helperReplaceIdle case selfUpdateCheck case selfUpdateApply case deploymentList @@ -115,6 +116,7 @@ public enum InstallerCLIParser { 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 helper replace-idle [--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] @@ -241,6 +243,8 @@ public enum InstallerCLIParser { command = .helperRegister case ["helper", "replace-qualification"]: command = .helperReplaceQualification + case ["helper", "replace-idle"]: + command = .helperReplaceIdle 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 1df1bab3..25d11384 100644 --- a/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCore/ManagedInstallerPrivilegedHelperRegistration.swift +++ b/macos/ForgePlatformInstaller/Sources/ForgePlatformInstallerCore/ManagedInstallerPrivilegedHelperRegistration.swift @@ -313,22 +313,51 @@ public actor ManagedInstallerPrivilegedHelperRegistrationCoordinator { expectedVersion: InstallerVersion ) -> ManagedInstallerPrivilegedHelperRegistrationResult { withRegistrationLock { - replaceLegacyQualificationWhileLocked(expectedVersion: expectedVersion) + guard let legacy = try? InstallerVersion("0.2.4") else { + return .failed(.registeredParentMismatch) + } + return replaceIdleParentWhileLocked( + expectedVersion: expectedVersion, + minimumPriorVersion: legacy, + maximumPriorVersion: legacy + ) } } - private func replaceLegacyQualificationWhileLocked( + /// Upgrade a previously released, signed installer helper only while its + /// fixed ServiceManagement job has never run since boot. The sealed CLI + /// supplies the target version; no request can choose a label or parent. + public func replaceIdleOlderRegistration( expectedVersion: InstallerVersion + ) -> ManagedInstallerPrivilegedHelperRegistrationResult { + withRegistrationLock { + guard let firstSupported = try? InstallerVersion("0.3.7") else { + return .failed(.registeredParentMismatch) + } + return replaceIdleParentWhileLocked( + expectedVersion: expectedVersion, + minimumPriorVersion: firstSupported, + maximumPriorVersion: nil + ) + } + } + + private func replaceIdleParentWhileLocked( + expectedVersion: InstallerVersion, + minimumPriorVersion: InstallerVersion, + maximumPriorVersion: InstallerVersion? ) -> ManagedInstallerPrivilegedHelperRegistrationResult { let initialStatus = service.readStatus() - guard let legacy = try? InstallerVersion("0.2.4"), - legacy < expectedVersion, + guard let prior = service.readIdleRegisteredParentVersion(), + prior >= minimumPriorVersion, + maximumPriorVersion.map({ prior <= $0 }) ?? true, + prior < expectedVersion, initialStatus != .requiresApproval, - service.readRegisteredParentVersion() == legacy, - service.readIdleRegisteredParentVersion() == legacy, + service.readRegisteredParentVersion() == prior, + service.readIdleRegisteredParentVersion() == prior, service.readStatus() == initialStatus, - service.readRegisteredParentVersion() == legacy, - service.readIdleRegisteredParentVersion() == legacy else { + service.readRegisteredParentVersion() == prior, + service.readIdleRegisteredParentVersion() == prior else { return .failed(.registeredParentMismatch) } do { diff --git a/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCLITests/ForgePlatformInstallerCLIApplicationTests.swift b/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCLITests/ForgePlatformInstallerCLIApplicationTests.swift index 116a0a21..0f2e2105 100644 --- a/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCLITests/ForgePlatformInstallerCLIApplicationTests.swift +++ b/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCLITests/ForgePlatformInstallerCLIApplicationTests.swift @@ -615,6 +615,76 @@ final class ForgePlatformInstallerCLIApplicationTests: XCTestCase { XCTAssertTrue(mismatch.stdout.joined().contains("registered-parent-mismatch")) } + func testIdleOlderHelperReplacementRequiresConsentAndExactNativeResult() 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-idle", "--non-interactive"], + startup: startup, version: "1.2.3", + idleReplacement: { _ in await registrar.invoke() } + ) + XCTAssertEqual(denied.code, InstallerCLIExitCode.confirmationRequired.rawValue) + let callsAfterDenial = await registrar.calls() + XCTAssertEqual(callsAfterDenial, 0) + + let replaced = await run( + ["helper", "replace-idle", "--yes", "--non-interactive", "--json"], + startup: startup, version: "1.2.3", + idleReplacement: { _ 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 interactive = await run( + ["helper", "replace-idle", "--json"], + startup: startup, version: "1.2.3", + idleReplacement: { _ in await registrar.invoke() } + ) + XCTAssertEqual(interactive.code, InstallerCLIExitCode.success.rawValue) + let callsAfterInteractive = await registrar.calls() + XCTAssertEqual(callsAfterInteractive, 2) + + let approvalReceipt = try ManagedInstallerPrivilegedHelperRegistrationReceipt( + status: .requiresApproval + ) + let approval = await run( + ["helper", "replace-idle", "--yes", "--json"], + startup: startup, version: "1.2.3", + idleReplacement: { _ in .requiresApproval(approvalReceipt) } + ) + XCTAssertEqual(approval.code, InstallerCLIExitCode.interactionRequired.rawValue) + XCTAssertTrue(approval.stdout.joined().contains("REQUIRES_APPROVAL")) + + let newer = try release("1.2.4") + let stale = await run( + ["helper", "replace-idle", "--yes", "--json"], + startup: CLIStartupSpy( + outcome: .ready(currentRelease: current, coordinator: CLIReadyCoordinator()), + recheckOutcome: .updateRequired(newer) + ), + version: "1.2.3", + idleReplacement: { _ in await registrar.invoke() } + ) + XCTAssertEqual(stale.code, InstallerCLIExitCode.installerUpdateRequired.rawValue) + let callsAfterStale = await registrar.calls() + XCTAssertEqual(callsAfterStale, 2) + + let mismatch = await run( + ["helper", "replace-idle", "--yes", "--json"], + startup: startup, version: "1.2.3", + idleReplacement: { _ 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") @@ -692,6 +762,9 @@ final class ForgePlatformInstallerCLIApplicationTests: XCTestCase { }, replacement: @escaping InstallerCLIHelperRegistration.Registrar = { _ in .failed(.serviceUnavailable) + }, + idleReplacement: @escaping InstallerCLIHelperRegistration.Registrar = { _ in + .failed(.serviceUnavailable) } ) async -> (code: Int32, stdout: [String], stderr: [String]) { let output = LockedStrings() @@ -706,6 +779,7 @@ final class ForgePlatformInstallerCLIApplicationTests: XCTestCase { confirm: { _ in confirmation }, registerHelper: registration, replaceQualificationHelper: replacement, + replaceIdleHelper: idleReplacement, 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 bea199fc..4ae939fa 100644 --- a/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCoreTests/InstallerCLITests.swift +++ b/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCoreTests/InstallerCLITests.swift @@ -61,6 +61,13 @@ final class InstallerCLITests: XCTestCase { try InstallerCLIParser.parse(["helper", "replace-qualification", "--yes"]).command, .helperReplaceQualification ) + XCTAssertEqual( + try InstallerCLIParser.parse(["helper", "replace-idle", "--yes"]).command, + .helperReplaceIdle + ) + XCTAssertThrowsError(try InstallerCLIParser.parse([ + "helper", "replace-idle", "--deployment", "another-target", + ])) 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 95934ccb..e3ce1586 100644 --- a/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCoreTests/ManagedInstallerPrivilegedHelperRegistrationTests.swift +++ b/macos/ForgePlatformInstaller/Tests/ForgePlatformInstallerCoreTests/ManagedInstallerPrivilegedHelperRegistrationTests.swift @@ -279,6 +279,47 @@ final class ManagedInstallerPrivilegedHelperRegistrationTests: XCTestCase { XCTAssertEqual(interrupted.counts(), [1, 2]) } + func testIdleSignedOlderReleaseTransitionRequiresExactMonotonicParent() async throws { + let current = try InstallerVersion("0.3.8") + let service = LegacyTransitionService(oldVersion: "0.3.7", newVersion: "0.3.8") + let coordinator = ManagedInstallerPrivilegedHelperRegistrationCoordinator(service: service) + let first = await coordinator.replaceIdleOlderRegistration(expectedVersion: current) + XCTAssertNotNil(first.readyReceipt) + XCTAssertEqual(service.counts(), [1, 1]) + let duplicate = await coordinator.replaceIdleOlderRegistration(expectedVersion: current) + XCTAssertEqual(duplicate.failure, .registeredParentMismatch) + XCTAssertEqual(service.counts(), [1, 1]) + + for candidate in [ + LegacyTransitionService(idle: false, oldVersion: "0.3.7"), + LegacyTransitionService(oldVersion: "0.3.6"), + LegacyTransitionService(oldVersion: "0.3.8"), + LegacyTransitionService(oldVersion: "0.3.9"), + LegacyTransitionService(oldVersion: "0.2.4"), + ] { + let result = await ManagedInstallerPrivilegedHelperRegistrationCoordinator( + service: candidate + ).replaceIdleOlderRegistration(expectedVersion: current) + XCTAssertEqual(result.failure, .registeredParentMismatch) + XCTAssertEqual(candidate.counts(), [0, 0]) + } + } + + func testIdleSignedOlderReleaseTransitionResumesAfterRegistrationFailure() async throws { + let current = try InstallerVersion("0.3.8") + let service = LegacyTransitionService( + oldVersion: "0.3.7", newVersion: "0.3.8", registerFails: true + ) + let coordinator = ManagedInstallerPrivilegedHelperRegistrationCoordinator(service: service) + let interrupted = await coordinator.replaceIdleOlderRegistration(expectedVersion: current) + XCTAssertEqual(interrupted.failure, .registrationFailed) + XCTAssertEqual(service.counts(), [1, 1]) + service.allowRegistration() + let resumed = await coordinator.ensureRegistered(expectedVersion: current) + XCTAssertNotNil(resumed.readyReceipt) + XCTAssertEqual(service.counts(), [1, 2]) + } + func testRegistrationAndTransitionShareCrossProcessLease() async throws { let version = try InstallerVersion("0.3.6") let busy = RegistrationLockStub(failsAcquisition: true) @@ -355,8 +396,9 @@ private final class LegacyTransitionService: private let jobAbsentReadbackFails: Bool private let idle: Bool private let oldVersion: String + private let newVersion: String - init(idle: Bool = true, oldVersion: String = "0.2.4", + init(idle: Bool = true, oldVersion: String = "0.2.4", newVersion: String = "0.3.6", unregisterFails: Bool = false, registerFails: Bool = false, staysEnabledAfterUnregister: Bool = false, notFoundAfterUnregister: Bool = false, @@ -364,6 +406,7 @@ private final class LegacyTransitionService: jobAbsentReadbackFails: Bool = false) { self.idle = idle self.oldVersion = oldVersion + self.newVersion = newVersion self.unregisterFails = unregisterFails self.registerFails = registerFails self.staysEnabledAfterUnregister = staysEnabledAfterUnregister @@ -381,7 +424,7 @@ private final class LegacyTransitionService: } func readRegisteredParentVersion() -> InstallerVersion? { - lock.withLock { try? InstallerVersion(currentIsNew ? "0.3.6" : oldVersion) } + lock.withLock { try? InstallerVersion(currentIsNew ? newVersion : oldVersion) } } func readIdleRegisteredParentVersion() -> InstallerVersion? {