From 5c9c37ca6ee584c17611c4f5ecde6ed2c5ab8e57 Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:18:25 +0530 Subject: [PATCH 1/4] refactor(macos): replace bespoke runtime installer with thin shell installer wrapper --- macos/Package.swift | 14 +- macos/Sources/ForgeMenuBar/AppDelegate.swift | 7 +- .../DeveloperRuntimeOverride.swift | 11 +- .../ForgeMenuCore/ForgeProcessHost.swift | 66 +- .../ForgeMenuCore/MachOValidator.swift | 389 ----- .../ForgeMenuCore/RuntimeArchive.swift | 280 ---- .../Sources/ForgeMenuCore/RuntimeCrypto.swift | 162 --- .../ForgeMenuCore/RuntimeInstaller.swift | 1296 +++-------------- .../RuntimeInstallerModels.swift | 338 +---- .../ForgeMenuCore/RuntimeLaunchSecurity.swift | 322 ---- .../ForgeMenuCore/RuntimeNetwork.swift | 237 --- .../ForgeMenuCore/RuntimeProcess.swift | 570 -------- .../Sources/ForgeMenuCore/RuntimeStore.swift | 653 --------- .../ForgeMenuCore/ServiceSupervisor.swift | 25 +- .../ForgeRuntimeLeaseTestHelper/main.swift | 44 - .../ForgeRuntimeSmokeHelper/main.swift | 71 - 16 files changed, 250 insertions(+), 4235 deletions(-) delete mode 100644 macos/Sources/ForgeMenuCore/MachOValidator.swift delete mode 100644 macos/Sources/ForgeMenuCore/RuntimeArchive.swift delete mode 100644 macos/Sources/ForgeMenuCore/RuntimeCrypto.swift delete mode 100644 macos/Sources/ForgeMenuCore/RuntimeLaunchSecurity.swift delete mode 100644 macos/Sources/ForgeMenuCore/RuntimeNetwork.swift delete mode 100644 macos/Sources/ForgeMenuCore/RuntimeProcess.swift delete mode 100644 macos/Sources/ForgeMenuCore/RuntimeStore.swift delete mode 100644 macos/Sources/ForgeRuntimeLeaseTestHelper/main.swift delete mode 100644 macos/Sources/ForgeRuntimeSmokeHelper/main.swift diff --git a/macos/Package.swift b/macos/Package.swift index 950b4ba..1071d04 100644 --- a/macos/Package.swift +++ b/macos/Package.swift @@ -8,9 +8,7 @@ let package = Package( ], products: [ .library(name: "ForgeMenuCore", targets: ["ForgeMenuCore"]), - .executable(name: "ForgeMenuBar", targets: ["ForgeMenuBar"]), - .executable(name: "ForgeRuntimeSmokeHelper", targets: ["ForgeRuntimeSmokeHelper"]), - .executable(name: "ForgeRuntimeLeaseTestHelper", targets: ["ForgeRuntimeLeaseTestHelper"]) + .executable(name: "ForgeMenuBar", targets: ["ForgeMenuBar"]) ], dependencies: [], targets: [ @@ -35,16 +33,6 @@ let package = Package( .unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks"]) ] ), - .executableTarget( - name: "ForgeRuntimeSmokeHelper", - dependencies: ["ForgeMenuCore"], - path: "Sources/ForgeRuntimeSmokeHelper" - ), - .executableTarget( - name: "ForgeRuntimeLeaseTestHelper", - dependencies: ["ForgeMenuCore"], - path: "Sources/ForgeRuntimeLeaseTestHelper" - ), .testTarget( name: "ForgeMenuCoreTests", dependencies: ["ForgeMenuCore"], diff --git a/macos/Sources/ForgeMenuBar/AppDelegate.swift b/macos/Sources/ForgeMenuBar/AppDelegate.swift index eec17c2..ce3660f 100644 --- a/macos/Sources/ForgeMenuBar/AppDelegate.swift +++ b/macos/Sources/ForgeMenuBar/AppDelegate.swift @@ -58,16 +58,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { do { let paths = try RuntimePaths.resolve() - let runtimeRoot = RuntimeStore.defaultRoot() - let runtimeLease = RuntimeStoreLease(rootURL: runtimeRoot) let runtimeInstaller = DeveloperOverrideRuntimeInstaller.wrapIfOverridden( - base: RuntimeInstaller(rootURL: runtimeRoot), + base: RuntimeInstaller(), logger: logger ) let processHost = ForgeProcessHost( configuration: .init(logURL: paths.serviceLog), - logger: logger, - lease: runtimeLease + logger: logger ) let supervisor = ServiceSupervisor( processHost: processHost, diff --git a/macos/Sources/ForgeMenuCore/DeveloperRuntimeOverride.swift b/macos/Sources/ForgeMenuCore/DeveloperRuntimeOverride.swift index 47b157f..e08d2fc 100644 --- a/macos/Sources/ForgeMenuCore/DeveloperRuntimeOverride.swift +++ b/macos/Sources/ForgeMenuCore/DeveloperRuntimeOverride.swift @@ -1,11 +1,10 @@ import Foundation /// Debug-only escape hatch that points the supervisor at a locally built -/// `forge3` instead of the managed runtime store. +/// `forge3` instead of running the upstream shell installer. /// /// This exists so a developer can iterate on the runtime without publishing a -/// release and without hand-editing the private store (which is checksum- and -/// permission-validated, and silently re-downloaded when validation fails). +/// release and without running the installer at all. /// /// The entire mechanism is compiled out of release builds: `resolve` returns /// `nil` unconditionally when `DEBUG` is not defined, so a shipped app cannot be @@ -15,11 +14,7 @@ import Foundation /// /// FORGE_RUNTIME_BINARY=/path/to/forge3 .build/debug/ForgeMenuBar /// -/// Note that the launch-time safety checks in `RuntimePinnedExecutable` still -/// apply to the override: the binary must be a regular file owned by the current -/// user, with no hard links, no group/other write permission, the owner execute -/// bit set, and no symlink or group/other-writable directory anywhere along the -/// path below the user boundary. +/// The override must be an absolute path to an executable regular file. public enum DeveloperRuntimeOverride { public static let environmentKey = "FORGE_RUNTIME_BINARY" diff --git a/macos/Sources/ForgeMenuCore/ForgeProcessHost.swift b/macos/Sources/ForgeMenuCore/ForgeProcessHost.swift index 3224a88..a2bb333 100644 --- a/macos/Sources/ForgeMenuCore/ForgeProcessHost.swift +++ b/macos/Sources/ForgeMenuCore/ForgeProcessHost.swift @@ -7,27 +7,6 @@ public protocol ForgeProcessHosting: AnyObject, Sendable { func stop(gracePeriod: TimeInterval) async } -public protocol InstalledRuntimeIdentityValidating: Sendable { - func validate(_ runtime: InstalledRuntime) throws -} - -public struct InstalledRuntimeIdentityValidator: InstalledRuntimeIdentityValidating { - private let expectedUserID: uid_t - - public init(expectedUserID: uid_t = geteuid()) { - self.expectedUserID = expectedUserID - } - - public func validate(_ runtime: InstalledRuntime) throws { - let pinned = try RuntimePinnedExecutable( - url: runtime.executableURL, - expectedUserID: expectedUserID - ) - defer { pinned.close() } - try pinned.validateCurrentFile() - } -} - public final class ForgeProcessHost: ForgeProcessHosting, @unchecked Sendable { public struct Configuration: Sendable { public let environment: [String: String] @@ -35,22 +14,19 @@ public final class ForgeProcessHost: ForgeProcessHosting, @unchecked Sendable { public let maximumLogBytes: UInt64 public let retainedLogFiles: Int public let maximumBufferedOutputBytes: Int - public let launchHooks: RuntimePinnedLaunchHooks public init( environment: [String: String] = [:], logURL: URL, maximumLogBytes: UInt64 = 5_000_000, retainedLogFiles: Int = 3, - maximumBufferedOutputBytes: Int = 256_000, - launchHooks: RuntimePinnedLaunchHooks = RuntimePinnedLaunchHooks() + maximumBufferedOutputBytes: Int = 256_000 ) { self.environment = environment self.logURL = logURL self.maximumLogBytes = maximumLogBytes self.retainedLogFiles = retainedLogFiles self.maximumBufferedOutputBytes = max(4_096, maximumBufferedOutputBytes) - self.launchHooks = launchHooks } } @@ -61,8 +37,6 @@ public final class ForgeProcessHost: ForgeProcessHosting, @unchecked Sendable { private let configuration: Configuration private let logger: AppLogger - private let runtimeIdentityValidator: any InstalledRuntimeIdentityValidating - private let lease: RuntimeStoreLease? private let queue = DispatchQueue(label: "dev.forgecode.menubar.process-host") private let logWriter: RotatingLogWriter private var exitHandler: (@Sendable (_ status: Int32, _ runtime: TimeInterval, _ generation: UInt64) -> Void)? @@ -78,18 +52,13 @@ public final class ForgeProcessHost: ForgeProcessHosting, @unchecked Sendable { private var outputDescriptor: Int32 = -1 private var outputBuffer = Data() private var stopWaiters: [CheckedContinuation] = [] - private var executionLeaseToken: RuntimeStoreLease.Token? public init( configuration: Configuration, - logger: AppLogger = .shared, - runtimeIdentityValidator: any InstalledRuntimeIdentityValidating = InstalledRuntimeIdentityValidator(), - lease: RuntimeStoreLease? = nil + logger: AppLogger = .shared ) { self.configuration = configuration self.logger = logger - self.runtimeIdentityValidator = runtimeIdentityValidator - self.lease = lease self.logWriter = RotatingLogWriter( fileManager: .default, logURL: configuration.logURL, @@ -150,15 +119,7 @@ public final class ForgeProcessHost: ForgeProcessHosting, @unchecked Sendable { private func startLocked(runtime: InstalledRuntime, endpoint: LoopbackEndpoint, generation: UInt64) throws { guard pid == 0 else { throw ForgeCoreError.processAlreadyRunning } - try runtimeIdentityValidator.validate(runtime) - let executionLease = try lease?.acquire(.sharedExecution) - var retainExecutionLease = false - defer { - if !retainExecutionLease { executionLease?.release() } - } let selectedExecutable = runtime.executableURL.standardizedFileURL - let pinnedExecutable = try RuntimePinnedExecutable(url: selectedExecutable) - defer { pinnedExecutable.close() } guard FileManager.default.isExecutableFile(atPath: selectedExecutable.path) else { throw ForgeCoreError.missingExecutable(selectedExecutable.path) } @@ -178,7 +139,6 @@ public final class ForgeProcessHost: ForgeProcessHosting, @unchecked Sendable { throw ForgeCoreError.processLaunch("could not initialize process file actions") } defer { posix_spawn_file_actions_destroy(&actions) } - try pinnedExecutable.addDirectoryActions(to: &actions) try checkPOSIX( posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0), operation: "redirect stdin" @@ -215,7 +175,7 @@ public final class ForgeProcessHost: ForgeProcessHosting, @unchecked Sendable { ) let arguments = [ - pinnedExecutable.basename, + selectedExecutable.lastPathComponent, "--log-format", "json", "ws", "--addr", endpoint.address ] @@ -234,13 +194,13 @@ public final class ForgeProcessHost: ForgeProcessHosting, @unchecked Sendable { } var spawnedPID: pid_t = 0 - let result = try pinnedExecutable.spawn( - pid: &spawnedPID, - actions: &actions, - attributes: &attributes, - argv: &argv, - envp: &envp, - hooks: configuration.launchHooks + let result = posix_spawn( + &spawnedPID, + selectedExecutable.path, + &actions, + &attributes, + &argv, + &envp ) close(writeDescriptor) writeDescriptor = -1 @@ -249,8 +209,6 @@ public final class ForgeProcessHost: ForgeProcessHosting, @unchecked Sendable { } pid = spawnedPID - executionLeaseToken = executionLease - retainExecutionLease = true startedAt = Date() lifecycleGeneration = generation nextLifecycleToken &+= 1 @@ -368,8 +326,6 @@ public final class ForgeProcessHost: ForgeProcessHosting, @unchecked Sendable { outputDescriptor = -1 flushRemainingOutputLocked() pid = 0 - executionLeaseToken?.release() - executionLeaseToken = nil startedAt = nil lifecycleGeneration = 0 lifecycleToken = 0 @@ -404,7 +360,7 @@ public final class ForgeProcessHost: ForgeProcessHosting, @unchecked Sendable { "LANG", "LC_ALL", "LC_CTYPE", "TZ", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME" ] - let allowedPrefixes = ["FORGE_", "RUST_LOG"] + let allowedPrefixes = ["FORGE_", "FORGE3_", "RUST_LOG"] var result = inherited.filter { key, _ in allowedExact.contains(key) || allowedPrefixes.contains { key.hasPrefix($0) } } diff --git a/macos/Sources/ForgeMenuCore/MachOValidator.swift b/macos/Sources/ForgeMenuCore/MachOValidator.swift deleted file mode 100644 index b3f5c48..0000000 --- a/macos/Sources/ForgeMenuCore/MachOValidator.swift +++ /dev/null @@ -1,389 +0,0 @@ -import Darwin -import Foundation -import Security - -public protocol RuntimeExecutableValidating: Sendable { - func validate(executableURL: URL, expectedArchitecture: RuntimeArchitecture) throws -} - -public protocol RuntimeCodeSignatureInspecting: Sendable { - func inspectSignature(of executableURL: URL) throws -> RuntimeCodeSignatureInspection -} - -public extension RuntimeCodeSignatureInspecting { - func signatureClass(of executableURL: URL) throws -> RuntimeCodeSignatureClass { - try inspectSignature(of: executableURL).signatureClass - } -} - -public struct SecurityRuntimeDeveloperIDRequirementEvaluator: RuntimeDeveloperIDRequirementEvaluating { - public init() {} - - public func satisfiesDeveloperIDApplicationRequirement( - executableURL: URL, - expectedTeamIdentifier: String? - ) throws -> Bool { - var staticCode: SecStaticCode? - let createCodeStatus = SecStaticCodeCreateWithPath(executableURL as CFURL, [], &staticCode) - guard createCodeStatus == errSecSuccess, let staticCode else { - throw RuntimeInstallerError.developerIDAuthenticationFailed( - "could not create static code for Developer ID requirement evaluation (OSStatus \(createCodeStatus))" - ) - } - - let requirementText = try Self.requirementText(expectedTeamIdentifier: expectedTeamIdentifier) - var requirement: SecRequirement? - let createRequirementStatus = SecRequirementCreateWithString( - requirementText as CFString, - [], - &requirement - ) - guard createRequirementStatus == errSecSuccess, let requirement else { - throw RuntimeInstallerError.developerIDAuthenticationFailed( - "could not create the Apple Developer ID Application requirement (OSStatus \(createRequirementStatus))" - ) - } - - let checkStatus = SecStaticCodeCheckValidity( - staticCode, - SecCSFlags(rawValue: UInt32(kSecCSStrictValidate)), - requirement - ) - if checkStatus == errSecSuccess { return true } - if checkStatus == errSecCSReqFailed { return false } - throw RuntimeInstallerError.developerIDAuthenticationFailed( - "could not evaluate the Apple Developer ID Application requirement (OSStatus \(checkStatus))" - ) - } - - static func requirementText(expectedTeamIdentifier: String?) throws -> String { - var requirement = "anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists" - if let expectedTeamIdentifier { - guard expectedTeamIdentifier.count == 10, - expectedTeamIdentifier.utf8.allSatisfy({ - ($0 >= 0x41 && $0 <= 0x5a) || ($0 >= 0x30 && $0 <= 0x39) - }) - else { - throw RuntimeInstallerError.developerIDAuthenticationFailed( - "the configured Developer ID Team Identifier is not exactly 10 uppercase ASCII letters or digits" - ) - } - requirement += " and certificate leaf[subject.OU] = \"\(expectedTeamIdentifier)\"" - } - return requirement - } -} - -public struct UnavailableRuntimeDeveloperIDRequirementEvaluator: RuntimeDeveloperIDRequirementEvaluating { - public init() {} - - public func satisfiesDeveloperIDApplicationRequirement( - executableURL: URL, - expectedTeamIdentifier: String? - ) throws -> Bool { - throw RuntimeInstallerError.developerIDAuthenticationFailed( - "Developer ID requirement evaluation is unavailable" - ) - } -} - -public struct UnsignedRuntimeCodeSignatureInspector: RuntimeCodeSignatureInspecting { - public init() {} - - public func inspectSignature(of executableURL: URL) throws -> RuntimeCodeSignatureInspection { - RuntimeCodeSignatureInspection(signatureClass: .unsigned) - } -} - -public struct MachORuntimeValidator: RuntimeExecutableValidating, RuntimeCodeSignatureInspecting { - private static let mhMagic64: UInt32 = 0xfeedfacf - private static let mhCigam64: UInt32 = 0xcffaedfe - private static let mhExecute: UInt32 = 0x2 - private static let cpuTypeX86_64: UInt32 = 0x01000007 - private static let cpuTypeArm64: UInt32 = 0x0100000c - private static let lcSegment64: UInt32 = 0x19 - private static let lcMain: UInt32 = 0x80000028 - private static let lcCodeSignature: UInt32 = 0x1d - - private let processRunner: any RuntimeProcessRunning - private let codeSignTimeout: TimeInterval - private let codeSignOutputBytes: Int - private let developerIDRequirementEvaluator: any RuntimeDeveloperIDRequirementEvaluating - - public init( - processRunner: any RuntimeProcessRunning = POSIXRuntimeProcessRunner(), - codeSignTimeout: TimeInterval = 5, - codeSignOutputBytes: Int = 4_096, - developerIDRequirementEvaluator: any RuntimeDeveloperIDRequirementEvaluating = SecurityRuntimeDeveloperIDRequirementEvaluator() - ) { - self.processRunner = processRunner - self.codeSignTimeout = codeSignTimeout - self.codeSignOutputBytes = codeSignOutputBytes - self.developerIDRequirementEvaluator = developerIDRequirementEvaluator - } - - public func inspectSignature(of executableURL: URL) throws -> RuntimeCodeSignatureInspection { - var staticCode: SecStaticCode? - let createStatus = SecStaticCodeCreateWithPath(executableURL as CFURL, [], &staticCode) - guard createStatus == errSecSuccess, let staticCode else { - throw RuntimeInstallerError.invalidMachO("could not inspect code signature (OSStatus \(createStatus))") - } - var signingInformation: CFDictionary? - let informationStatus = SecCodeCopySigningInformation( - staticCode, - SecCSFlags(rawValue: UInt32(kSecCSSigningInformation)), - &signingInformation - ) - guard informationStatus == errSecSuccess, - let information = signingInformation as? [CFString: Any] - else { - throw RuntimeInstallerError.invalidMachO( - "could not read code-signature information (OSStatus \(informationStatus))" - ) - } - - let flags = (information[kSecCodeInfoFlags] as? NSNumber)?.uint32Value ?? 0 - let teamIdentifier = information[kSecCodeInfoTeamIdentifier] as? String - let certificates = information[kSecCodeInfoCertificates] as? [SecCertificate] ?? [] - let signingIdentity = certificates.first.flatMap { - SecCertificateCopySubjectSummary($0) as String? - } - let signatureClass = try classifySignature( - flags: flags, - hasCertificates: !certificates.isEmpty, - executableURL: executableURL - ) - return RuntimeCodeSignatureInspection( - signatureClass: signatureClass, - teamIdentifier: teamIdentifier, - signingIdentity: signingIdentity - ) - } - - func classifySignature( - flags: UInt32, - hasCertificates: Bool, - executableURL: URL - ) throws -> RuntimeCodeSignatureClass { - if flags & 0x2 != 0 { return .adHoc } // CS_ADHOC. - guard hasCertificates else { return .unsigned } - return try developerIDRequirementEvaluator.satisfiesDeveloperIDApplicationRequirement( - executableURL: executableURL, - expectedTeamIdentifier: nil - ) ? .developerID : .otherSigned - } - - public func validate(executableURL: URL, expectedArchitecture: RuntimeArchitecture) throws { - var info = stat() - guard lstat(executableURL.path, &info) == 0, - (info.st_mode & S_IFMT) == S_IFREG - else { throw RuntimeInstallerError.invalidMachO("not a regular file") } - guard info.st_size >= 32, info.st_size <= Int64(Int.max) else { - throw RuntimeInstallerError.invalidMachO("file has an invalid size") - } - let data = try Data(contentsOf: executableURL, options: [.mappedIfSafe]) - guard data.count == Int(info.st_size), data.count >= 32 else { - throw RuntimeInstallerError.invalidMachO("file changed or is truncated") - } - - let magic = data.uint32(at: 0, littleEndian: true) - let littleEndian: Bool - switch magic { - case Self.mhMagic64: littleEndian = true - case Self.mhCigam64: littleEndian = false - default: throw RuntimeInstallerError.invalidMachO("unexpected magic 0x\(String(magic, radix: 16))") - } - let cpuType = data.uint32(at: 4, littleEndian: littleEndian) - let fileType = data.uint32(at: 12, littleEndian: littleEndian) - guard fileType == Self.mhExecute else { - throw RuntimeInstallerError.invalidMachO("Mach-O file type is not executable") - } - let expectedCPU = expectedArchitecture == .arm64 ? Self.cpuTypeArm64 : Self.cpuTypeX86_64 - guard cpuType == expectedCPU else { - let actual: String - switch cpuType { - case Self.cpuTypeArm64: actual = RuntimeArchitecture.arm64.rawValue - case Self.cpuTypeX86_64: actual = RuntimeArchitecture.x86_64.rawValue - default: actual = "cpu-0x\(String(cpuType, radix: 16))" - } - throw RuntimeInstallerError.wrongArchitecture(expected: expectedArchitecture, actual: actual) - } - - let commandCount = Int(data.uint32(at: 16, littleEndian: littleEndian)) - let commandsSize = Int(data.uint32(at: 20, littleEndian: littleEndian)) - guard commandCount > 0, - commandsSize >= commandCount * 8, - 32 <= data.count, - commandsSize <= data.count - 32 - else { throw RuntimeInstallerError.invalidMachO("load-command table is incoherent") } - - var offset = 32 - let commandsEnd = 32 + commandsSize - var sawExecutableSegment = false - var sawEntryPoint = false - var codeSignature: (offset: Int, size: Int)? - for _ in 0..= 8, - commandSize % 8 == 0, - commandSize <= commandsEnd - offset - else { throw RuntimeInstallerError.invalidMachO("invalid load-command size") } - - switch command { - case Self.lcSegment64: - guard commandSize >= 72 else { throw RuntimeInstallerError.invalidMachO("truncated LC_SEGMENT_64") } - let fileOffset = data.uint64(at: offset + 40, littleEndian: littleEndian) - let fileSize = data.uint64(at: offset + 48, littleEndian: littleEndian) - let initProtection = data.uint32(at: offset + 60, littleEndian: littleEndian) - guard fileOffset <= UInt64(data.count), fileSize <= UInt64(data.count) - fileOffset else { - throw RuntimeInstallerError.invalidMachO("segment lies outside the file") - } - if fileSize > 0, initProtection & 0x4 != 0 { sawExecutableSegment = true } - case Self.lcMain: - guard commandSize >= 24 else { throw RuntimeInstallerError.invalidMachO("truncated LC_MAIN") } - let entryOffset = data.uint64(at: offset + 8, littleEndian: littleEndian) - guard entryOffset < UInt64(data.count) else { - throw RuntimeInstallerError.invalidMachO("entry point lies outside the file") - } - sawEntryPoint = true - case Self.lcCodeSignature: - guard commandSize >= 16, codeSignature == nil else { - throw RuntimeInstallerError.invalidMachO("invalid or duplicate code-signature command") - } - let signatureOffset = Int(data.uint32(at: offset + 8, littleEndian: littleEndian)) - let signatureSize = Int(data.uint32(at: offset + 12, littleEndian: littleEndian)) - guard signatureSize > 0, - signatureOffset >= commandsEnd, - signatureOffset <= data.count, - signatureSize <= data.count - signatureOffset - else { throw RuntimeInstallerError.invalidMachO("code signature lies outside the file") } - codeSignature = (signatureOffset, signatureSize) - default: - break - } - offset += commandSize - } - guard offset == commandsEnd else { - throw RuntimeInstallerError.invalidMachO("load-command table size does not match commands") - } - guard sawExecutableSegment else { - throw RuntimeInstallerError.invalidMachO("no executable file-backed segment") - } - guard sawEntryPoint else { - throw RuntimeInstallerError.invalidMachO("missing LC_MAIN entry point") - } - if let codeSignature { - try validateEmbeddedCodeSignature(data, range: codeSignature.offset..<(codeSignature.offset + codeSignature.size)) - try verifyCodeSignature(executableURL) - } - } - - private func validateEmbeddedCodeSignature(_ data: Data, range: Range) throws { - guard range.count >= 12 else { throw RuntimeInstallerError.invalidMachO("truncated code signature") } - let magic = data.uint32(at: range.lowerBound, littleEndian: false) - let length = Int(data.uint32(at: range.lowerBound + 4, littleEndian: false)) - guard length >= 8, length <= range.count else { - throw RuntimeInstallerError.invalidMachO("code-signature length is inconsistent") - } - let blobRange = range.lowerBound..<(range.lowerBound + length) - switch magic { - case 0xfade0cc0: // CodeDirectory - guard blobRange.count >= 44 else { throw RuntimeInstallerError.invalidMachO("truncated CodeDirectory") } - case 0xfade0cc1: // SuperBlob - let count = Int(data.uint32(at: range.lowerBound + 8, littleEndian: false)) - guard count >= 1, count <= (blobRange.count - 12) / 8 else { - throw RuntimeInstallerError.invalidMachO("invalid code-signature blob index") - } - for index in 0..= 12 + count * 8, - blobOffset <= blobRange.count - 8 - else { throw RuntimeInstallerError.invalidMachO("code-signature blob lies outside superblob") } - let blobLength = Int(data.uint32(at: range.lowerBound + blobOffset + 4, littleEndian: false)) - guard blobLength >= 8, blobLength <= blobRange.count - blobOffset else { - throw RuntimeInstallerError.invalidMachO("invalid code-signature blob length") - } - } - default: - throw RuntimeInstallerError.invalidMachO("unexpected code-signature magic") - } - } - - private func verifyCodeSignature(_ executableURL: URL) throws { - let semaphore = DispatchSemaphore(value: 0) - let result = LockedCodeSignResult() - Task.detached(priority: .utility) { [processRunner, codeSignTimeout, codeSignOutputBytes] in - do { - let processResult = try await processRunner.run( - executable: URL(fileURLWithPath: "/usr/bin/codesign"), - arguments: ["--verify", "--strict", "--verbose=2", executableURL.path], - standardOutput: nil, - maximumStandardOutputBytes: codeSignOutputBytes, - timeout: codeSignTimeout, - terminationGracePeriod: 0.2 - ) - result.set(.success(processResult)) - } catch { - result.set(.failure(error)) - } - semaphore.signal() - } - semaphore.wait() - switch result.get() { - case .success(let processResult): - guard !processResult.standardOutputLimitExceeded else { - throw RuntimeInstallerError.invalidMachO("codesign output exceeded the verification limit") - } - guard processResult.status == 0 else { - let diagnostic = String(decoding: processResult.stderr.prefix(codeSignOutputBytes), as: UTF8.self) - .trimmingCharacters(in: .whitespacesAndNewlines) - throw RuntimeInstallerError.invalidMachO(diagnostic.isEmpty ? "invalid code signature" : diagnostic) - } - case .failure(RuntimeInstallerError.processTimeout): - throw RuntimeInstallerError.invalidMachO("code-signature verification timed out") - case .failure(let error): - throw RuntimeInstallerError.invalidMachO("could not verify code signature: \(error.localizedDescription)") - case nil: - throw RuntimeInstallerError.invalidMachO("code-signature verification returned no result") - } - } -} - -private final class LockedCodeSignResult: @unchecked Sendable { - private let lock = NSLock() - private var value: Result? - - func set(_ value: Result) { - lock.lock() - self.value = value - lock.unlock() - } - - func get() -> Result? { - lock.lock() - defer { lock.unlock() } - return value - } -} - -private extension Data { - func uint32(at offset: Int, littleEndian: Bool) -> UInt32 { - let bytes = self[offset..<(offset + 4)] - if littleEndian { - return bytes.enumerated().reduce(0) { $0 | (UInt32($1.element) << UInt32($1.offset * 8)) } - } - return bytes.reduce(0) { ($0 << 8) | UInt32($1) } - } - - func uint64(at offset: Int, littleEndian: Bool) -> UInt64 { - let bytes = self[offset..<(offset + 8)] - if littleEndian { - return bytes.enumerated().reduce(0) { $0 | (UInt64($1.element) << UInt64($1.offset * 8)) } - } - return bytes.reduce(0) { ($0 << 8) | UInt64($1) } - } -} diff --git a/macos/Sources/ForgeMenuCore/RuntimeArchive.swift b/macos/Sources/ForgeMenuCore/RuntimeArchive.swift deleted file mode 100644 index 45dd8bb..0000000 --- a/macos/Sources/ForgeMenuCore/RuntimeArchive.swift +++ /dev/null @@ -1,280 +0,0 @@ -import Darwin -import Foundation - -public struct RuntimeArchiveEntry: Equatable, Sendable { - public enum Kind: Equatable, Sendable { - case regularFile - case directory - } - - public let path: String - public let kind: Kind - public let size: Int - fileprivate let dataOffset: Int -} - -public struct RuntimeArchiveInspection: Equatable, Sendable { - public let entries: [RuntimeArchiveEntry] - public let executableEntry: RuntimeArchiveEntry - let tarURL: URL -} - -public protocol RuntimeArchiveHandling: Sendable { - func inspect(archiveURL: URL, temporaryDirectory: URL, limits: RuntimeInstallerLimits) async throws -> RuntimeArchiveInspection - func extractExecutable(from inspection: RuntimeArchiveInspection, to destination: URL) throws -} - -public struct SafeTarXZArchiveHandler: RuntimeArchiveHandling { - private let processRunner: any RuntimeProcessRunning - private let tarExecutable: URL - private let processTimeout: TimeInterval - private let terminationGracePeriod: TimeInterval - - public init( - processRunner: any RuntimeProcessRunning = FoundationRuntimeProcessRunner(), - tarExecutable: URL = URL(fileURLWithPath: "/usr/bin/tar"), - processTimeout: TimeInterval = 60, - terminationGracePeriod: TimeInterval = 1 - ) { - self.processRunner = processRunner - self.tarExecutable = tarExecutable - self.processTimeout = processTimeout - self.terminationGracePeriod = terminationGracePeriod - } - - public func inspect( - archiveURL: URL, - temporaryDirectory: URL, - limits: RuntimeInstallerLimits - ) async throws -> RuntimeArchiveInspection { - let archiveHandle: FileHandle - do { - archiveHandle = try FileHandle(forReadingFrom: archiveURL) - } catch { - throw RuntimeFilesystemError.wrapping(error, operation: "open downloaded runtime archive", path: archiveURL.path) - } - let magic: Data - do { - magic = try archiveHandle.read(upToCount: 6) ?? Data() - try archiveHandle.close() - } catch { - try? archiveHandle.close() - throw RuntimeFilesystemError.wrapping(error, operation: "read downloaded runtime archive", path: archiveURL.path) - } - guard magic == Data([0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]) else { - throw RuntimeInstallerError.malformedArchive("archive is not XZ-compressed data") - } - - let tarURL = temporaryDirectory.appendingPathComponent("inspection.tar") - let maximumTarBytes = limits.expandedArchiveBytes + (limits.archiveEntries + 4) * 1_024 - let result = try await processRunner.run( - executable: tarExecutable, - arguments: ["-cf", "-", "--format", "pax", "@\(archiveURL.path)"], - standardOutput: tarURL, - maximumStandardOutputBytes: maximumTarBytes, - timeout: processTimeout, - terminationGracePeriod: terminationGracePeriod - ) - guard !result.standardOutputLimitExceeded else { - throw RuntimeInstallerError.archiveLimitExceeded("converted archive is too large") - } - guard result.status == 0 else { - let diagnostic = String(decoding: result.stderr.prefix(4_096), as: UTF8.self) - throw RuntimeInstallerError.malformedArchive(diagnostic.isEmpty ? "tar exited with status \(result.status)" : diagnostic) - } - - let attributes: [FileAttributeKey: Any] - do { - attributes = try FileManager.default.attributesOfItem(atPath: tarURL.path) - } catch { - throw RuntimeFilesystemError.wrapping(error, operation: "inspect converted runtime archive", path: tarURL.path) - } - let convertedSize = (attributes[.size] as? NSNumber)?.int64Value ?? 0 - guard convertedSize <= Int64(limits.expandedArchiveBytes) + Int64(limits.archiveEntries + 4) * 1_024 else { - throw RuntimeInstallerError.archiveLimitExceeded("converted archive is too large") - } - let data: Data - do { - data = try Data(contentsOf: tarURL, options: [.mappedIfSafe]) - } catch { - throw RuntimeFilesystemError.wrapping(error, operation: "read converted runtime archive", path: tarURL.path) - } - return try Self.parseTar(data, tarURL: tarURL, limits: limits) - } - - public func extractExecutable(from inspection: RuntimeArchiveInspection, to destination: URL) throws { - let data: Data - do { - data = try Data(contentsOf: inspection.tarURL, options: [.mappedIfSafe]) - } catch { - throw RuntimeFilesystemError.wrapping(error, operation: "read inspected runtime archive", path: inspection.tarURL.path) - } - let entry = inspection.executableEntry - let end = entry.dataOffset + entry.size - guard entry.dataOffset >= 0, end <= data.count else { - throw RuntimeInstallerError.malformedArchive("forge3 data lies outside the archive") - } - let descriptor = open( - destination.path, - O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, - 0o700 - ) - guard descriptor >= 0 else { - throw RuntimeFilesystemError.posix(errno, operation: "create staged runtime executable", path: destination.path) - } - defer { close(descriptor) } - let executable = data[entry.dataOffset.. RuntimeArchiveInspection { - var offset = 0 - var entries: [RuntimeArchiveEntry] = [] - var seen = Set() - var expandedBytes = 0 - var zeroBlocks = 0 - - while offset + 512 <= data.count { - let header = data.subdata(in: offset..<(offset + 512)) - if header.allSatisfy({ $0 == 0 }) { - zeroBlocks += 1 - offset += 512 - if zeroBlocks == 2 { break } - continue - } - guard zeroBlocks == 0 else { - throw RuntimeInstallerError.malformedArchive("non-zero data after an end-of-archive block") - } - guard Self.hasValidChecksum(header) else { - throw RuntimeInstallerError.malformedArchive("invalid tar header checksum") - } - guard entries.count < limits.archiveEntries else { - throw RuntimeInstallerError.archiveLimitExceeded("too many entries") - } - - let name = try Self.string(header, range: 0..<100, field: "name") - let prefix = try Self.string(header, range: 345..<500, field: "prefix") - let path = prefix.isEmpty ? name : "\(prefix)/\(name)" - try Self.validate(path: path) - let collisionKey = String(path.drop(while: { $0 == "/" }).dropLast(path.hasSuffix("/") ? 1 : 0)) - .precomposedStringWithCanonicalMapping - guard seen.insert(collisionKey).inserted else { - throw RuntimeInstallerError.unsafeArchiveEntry("duplicate path \(path)") - } - - let size = try Self.octal(header, range: 124..<136, field: "size") - let type = header[156] - let kind: RuntimeArchiveEntry.Kind - switch type { - case 0, Character("0").asciiValue!: kind = .regularFile - case Character("5").asciiValue!: kind = .directory - case Character("1").asciiValue!, Character("2").asciiValue!: - throw RuntimeInstallerError.unsafeArchiveEntry("links are not allowed: \(path)") - default: - throw RuntimeInstallerError.unsafeArchiveEntry("special or unsupported entry type \(type) at \(path)") - } - if kind == .directory && size != 0 { - throw RuntimeInstallerError.malformedArchive("directory has data: \(path)") - } - guard (kind == .directory) == path.hasSuffix("/") else { - throw RuntimeInstallerError.malformedArchive("entry type and path disagree: \(path)") - } - if kind == .regularFile { - guard size <= limits.executableBytes || URL(fileURLWithPath: path).lastPathComponent != "forge3" else { - throw RuntimeInstallerError.archiveLimitExceeded("forge3 is too large") - } - guard expandedBytes <= limits.expandedArchiveBytes - size else { - throw RuntimeInstallerError.archiveLimitExceeded("expanded files are too large") - } - expandedBytes += size - } - - let dataOffset = offset + 512 - guard dataOffset <= data.count, size <= data.count - dataOffset else { - throw RuntimeInstallerError.malformedArchive("truncated data for \(path)") - } - entries.append(RuntimeArchiveEntry(path: path, kind: kind, size: size, dataOffset: dataOffset)) - let paddedSize = ((size + 511) / 512) * 512 - guard dataOffset <= Int.max - paddedSize else { - throw RuntimeInstallerError.malformedArchive("entry size overflow") - } - offset = dataOffset + paddedSize - } - - guard zeroBlocks == 2 else { - throw RuntimeInstallerError.malformedArchive("missing end-of-archive blocks") - } - let trailing = data[offset...] - guard trailing.allSatisfy({ $0 == 0 }) else { - throw RuntimeInstallerError.malformedArchive("non-zero trailing bytes") - } - guard !entries.isEmpty else { - throw RuntimeInstallerError.malformedArchive("empty archive") - } - let executables = entries.filter { - $0.kind == .regularFile && URL(fileURLWithPath: $0.path).lastPathComponent == "forge3" - } - guard !executables.isEmpty else { throw RuntimeInstallerError.missingRuntimeExecutable } - guard executables.count == 1 else { throw RuntimeInstallerError.duplicateRuntimeExecutable } - return RuntimeArchiveInspection(entries: entries, executableEntry: executables[0], tarURL: tarURL) - } - - private static func validate(path: String) throws { - guard !path.isEmpty, - path.utf8.count <= 1_024, - !path.hasPrefix("/"), - !path.contains("\\"), - !path.contains("\0") - else { throw RuntimeInstallerError.unsafeArchiveEntry(path) } - let normalizedPath = path.hasSuffix("/") ? String(path.dropLast()) : path - guard !normalizedPath.isEmpty else { throw RuntimeInstallerError.unsafeArchiveEntry(path) } - let components = normalizedPath.split(separator: "/", omittingEmptySubsequences: false) - guard components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) else { - throw RuntimeInstallerError.unsafeArchiveEntry(path) - } - } - - private static func string(_ header: Data, range: Range, field: String) throws -> String { - let bytes = header[range].prefix { $0 != 0 } - guard !bytes.contains(where: { $0 < 0x20 || $0 == 0x7f }), - let value = String(bytes: bytes, encoding: .utf8) - else { throw RuntimeInstallerError.malformedArchive("invalid \(field)") } - return value - } - - private static func octal(_ header: Data, range: Range, field: String) throws -> Int { - let raw = header[range] - guard raw.first.map({ $0 & 0x80 == 0 }) ?? false else { - throw RuntimeInstallerError.malformedArchive("base-256 \(field) is not supported") - } - let text = String(bytes: raw, encoding: .ascii)? - .trimmingCharacters(in: CharacterSet(charactersIn: " \0")) ?? "" - guard !text.isEmpty, - text.allSatisfy({ ("0"..."7").contains($0) }), - let value = Int(text, radix: 8) - else { throw RuntimeInstallerError.malformedArchive("invalid \(field)") } - return value - } - - private static func hasValidChecksum(_ header: Data) -> Bool { - guard let stored = try? octal(header, range: 148..<156, field: "checksum") else { return false } - var sum = 0 - for index in 0..<512 { - sum += (148..<156).contains(index) ? 0x20 : Int(header[index]) - } - return stored == sum - } -} diff --git a/macos/Sources/ForgeMenuCore/RuntimeCrypto.swift b/macos/Sources/ForgeMenuCore/RuntimeCrypto.swift deleted file mode 100644 index 4a59976..0000000 --- a/macos/Sources/ForgeMenuCore/RuntimeCrypto.swift +++ /dev/null @@ -1,162 +0,0 @@ -import CommonCrypto -import Darwin -import Foundation - -public enum RuntimeSHA256 { - public static func hexDigest(of data: Data) -> String { - var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) - data.withUnsafeBytes { buffer in - _ = CC_SHA256(buffer.baseAddress, CC_LONG(buffer.count), &digest) - } - return digest.map { String(format: "%02x", $0) }.joined() - } - - public static func hexDigest(ofFile url: URL) throws -> String { - let descriptor = open(url.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) - guard descriptor >= 0 else { - throw RuntimeFilesystemError.posix(errno, operation: "open runtime file", path: url.path) - } - defer { close(descriptor) } - return try hexDigest(ofDescriptor: descriptor, path: url.path) - } - - static func hexDigest(ofDescriptor descriptor: Int32, path: String) throws -> String { - guard lseek(descriptor, 0, SEEK_SET) >= 0 else { - throw RuntimeFilesystemError.posix(errno, operation: "seek runtime file", path: path) - } - var context = CC_SHA256_CTX() - CC_SHA256_Init(&context) - var buffer = [UInt8](repeating: 0, count: 64 * 1_024) - while true { - let count = read(descriptor, &buffer, buffer.count) - if count < 0 { - if errno == EINTR { continue } - throw RuntimeFilesystemError.posix(errno, operation: "read runtime file", path: path) - } - if count == 0 { break } - CC_SHA256_Update(&context, buffer, CC_LONG(count)) - } - var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) - CC_SHA256_Final(&digest, &context) - return digest.map { String(format: "%02x", $0) }.joined() - } -} - -public enum RuntimeExecutableIdentityValidator { - public static func capture(_ url: URL, expectedSHA256: String? = nil) throws -> RuntimeExecutableIdentity { - try validatePathComponentsDoNotContainSymlinks(url) - let descriptor = open(url.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) - guard descriptor >= 0 else { - throw RuntimeInstallerError.untrustedStoreItem("could not safely open runtime executable") - } - defer { close(descriptor) } - let before = try trustedStat(descriptor, path: url.path) - let hash = try RuntimeSHA256.hexDigest(ofDescriptor: descriptor, path: url.path) - let after = try trustedStat(descriptor, path: url.path) - guard stable(before, after) else { - throw RuntimeInstallerError.untrustedStoreItem("runtime executable changed while its identity was captured") - } - if let expectedSHA256, hash != expectedSHA256 { - throw RuntimeInstallerError.untrustedStoreItem("executable checksum does not match receipt") - } - return RuntimeExecutableIdentity( - device: UInt64(before.st_dev), - inode: UInt64(before.st_ino), - size: before.st_size, - sha256: hash - ) - } - - public static func validate(_ url: URL, expected: RuntimeExecutableIdentity) throws { - let actual = try capture(url, expectedSHA256: expected.sha256) - guard actual == expected else { - throw RuntimeInstallerError.untrustedStoreItem("runtime executable identity changed before launch") - } - } - - private static func trustedStat(_ descriptor: Int32, path: String) throws -> stat { - var info = stat() - guard fstat(descriptor, &info) == 0 else { - throw RuntimeFilesystemError.posix(errno, operation: "inspect runtime executable", path: path) - } - guard (info.st_mode & S_IFMT) == S_IFREG, - info.st_uid == geteuid(), - info.st_nlink == 1, - info.st_mode & 0o022 == 0, - info.st_size >= 0 - else { - throw RuntimeInstallerError.untrustedStoreItem("runtime executable is not a private single-link regular file") - } - return info - } - - private static func stable(_ lhs: stat, _ rhs: stat) -> Bool { - lhs.st_dev == rhs.st_dev - && lhs.st_ino == rhs.st_ino - && lhs.st_nlink == rhs.st_nlink - && lhs.st_size == rhs.st_size - && lhs.st_mtimespec.tv_sec == rhs.st_mtimespec.tv_sec - && lhs.st_mtimespec.tv_nsec == rhs.st_mtimespec.tv_nsec - && lhs.st_ctimespec.tv_sec == rhs.st_ctimespec.tv_sec - && lhs.st_ctimespec.tv_nsec == rhs.st_ctimespec.tv_nsec - } - - private static func validatePathComponentsDoNotContainSymlinks(_ url: URL) throws { - let standardized = url.standardizedFileURL - var current = URL(fileURLWithPath: "/", isDirectory: true) - var enteredUserBoundary = false - for component in standardized.pathComponents.dropFirst() { - current.appendPathComponent(component) - var info = stat() - guard lstat(current.path, &info) == 0 else { - throw RuntimeFilesystemError.posix(errno, operation: "inspect runtime path component", path: current.path) - } - if info.st_uid == geteuid() { enteredUserBoundary = true } - if (info.st_mode & S_IFMT) == S_IFLNK, enteredUserBoundary { - throw RuntimeInstallerError.untrustedStoreItem("runtime path contains a symbolic link at \(current.path)") - } - } - } -} - -public enum RuntimeChecksumSidecar { - public static func parse(_ data: Data, expectedFilename: String) throws -> String { - guard data.count <= 1_024, - let text = String(data: data, encoding: .utf8), - !text.contains("\0") - else { - throw RuntimeInstallerError.invalidChecksumSidecar("not a small UTF-8 text record") - } - - var recordBytes = Array(text.utf8) - // The public release sidecar currently ends with a blank line. Permit - // only terminal CR/LF bytes; embedded line breaks still fail below, so - // exactly one checksum record is accepted. - while recordBytes.last == 0x0A || recordBytes.last == 0x0D { - recordBytes.removeLast() - } - guard !recordBytes.isEmpty, - let record = String(bytes: recordBytes, encoding: .utf8) else { - throw RuntimeInstallerError.invalidChecksumSidecar("not valid UTF-8") - } - guard !record.contains("\n"), !record.contains("\r") else { - throw RuntimeInstallerError.invalidChecksumSidecar("expected exactly one line") - } - - let plainPrefix = " " - let binaryPrefix = " *" - guard record.utf8.count == 64 + 2 + expectedFilename.utf8.count else { - throw RuntimeInstallerError.invalidChecksumSidecar("unexpected record length") - } - let hashEnd = record.index(record.startIndex, offsetBy: 64) - let hash = String(record[.. Void - private let hook: any RuntimeInstallerProgressDeliveryHook - private var tail: Task? - private var tasks: [Task] = [] - private var closed = false - - init( - handler: @escaping @Sendable (RuntimeInstallationPhase) async -> Void, - hook: any RuntimeInstallerProgressDeliveryHook - ) { - self.handler = handler - self.hook = hook - } - - func enqueue(_ delivery: RuntimeInstallerProgressDelivery) { - lock.lock() - defer { lock.unlock() } - guard !closed else { return } - let predecessor = tail - let handler = self.handler - let hook = self.hook - let task = Task { - await predecessor?.value - guard !Task.isCancelled else { return } - await hook.beforeDelivery(delivery) - guard !Task.isCancelled else { return } - await handler(delivery.phase) - } - tail = task - tasks.append(task) - } - - func close() async { - let pending: Task? = lock.withLock { - closed = true - return tail - } - await pending?.value - } - - func cancelPending() { - let pending: [Task] = lock.withLock { - closed = true - return tasks - } - pending.forEach { $0.cancel() } - } -} - -public struct AppRuntimeInstallationSecurityLogger: RuntimeInstallationSecurityLogging { - public init() {} - - public func log(_ event: RuntimeInstallationSecurityEvent) { - switch event { - case .refreshedAdHocQuarantinedStagedExecutable: - AppLogger.shared.warning( - "Applied the temporary pre-execution trust policy to a fully validated ad-hoc-signed forge3 artifact by refreshing its staged vnode without com.apple.quarantine" - ) - } - } -} - -public protocol RuntimePreExecutionTrustPolicy: Sendable { - func decision(for context: RuntimePreExecutionTrustContext) throws -> RuntimePreExecutionTrustDecision -} - -public struct TemporaryAdHocRuntimeTrustPolicy: RuntimePreExecutionTrustPolicy { - public init() {} - - public func decision(for context: RuntimePreExecutionTrustContext) throws -> RuntimePreExecutionTrustDecision { - context.signatureClass == .adHoc && context.hasQuarantine - ? .refreshRemovingQuarantine - : .preserve - } -} - -public protocol RuntimeQuarantineManaging: Sendable { - func hasQuarantine( - at executableURL: URL, - expectedIdentity: RuntimeExecutableIdentity - ) throws -> Bool - - /// Atomically replaces the staged executable with a fresh same-directory - /// vnode copied from a descriptor pinned to `expectedIdentity`. Only - /// com.apple.quarantine is omitted; safe permissions and unrelated xattrs - /// are retained. The returned identity describes the replacement vnode. - func refreshExecutableRemovingQuarantine( - from executableURL: URL, - expectedIdentity: RuntimeExecutableIdentity - ) throws -> RuntimeExecutableIdentity -} - -public struct DarwinRuntimeQuarantineManager: RuntimeQuarantineManaging { - private static let quarantineName = "com.apple.quarantine" - private let expectedUserID: uid_t - - public init(expectedUserID: uid_t = geteuid()) { - self.expectedUserID = expectedUserID - } - - public func hasQuarantine( - at executableURL: URL, - expectedIdentity: RuntimeExecutableIdentity - ) throws -> Bool { - let pinned = try Self.openPinnedSource( - executableURL, - expectedIdentity: expectedIdentity, - expectedUserID: expectedUserID - ) - defer { - close(pinned.sourceDescriptor) - close(pinned.directoryDescriptor) - } - let result = Self.quarantineName.withCString { - fgetxattr(pinned.sourceDescriptor, $0, nil, 0, 0, 0) - } - if result >= 0 { return true } - if errno == ENOATTR { return false } - throw Self.failure("could not inspect quarantine on the pinned staged executable") - } - - public func refreshExecutableRemovingQuarantine( - from executableURL: URL, - expectedIdentity: RuntimeExecutableIdentity - ) throws -> RuntimeExecutableIdentity { - let pinned = try Self.openPinnedSource( - executableURL, - expectedIdentity: expectedIdentity, - expectedUserID: expectedUserID - ) - defer { - close(pinned.sourceDescriptor) - close(pinned.directoryDescriptor) - } - guard try Self.hasQuarantine(pinned.sourceDescriptor) else { - throw RuntimeInstallerError.quarantineRemovalFailed( - "the pinned staged executable no longer has com.apple.quarantine" - ) - } - - let temporaryName = ".forge3-quarantine-refresh.\(UUID().uuidString)" - let permissions = pinned.sourceInfo.st_mode & 0o777 - let destinationDescriptor = temporaryName.withCString { - openat( - pinned.directoryDescriptor, - $0, - O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, - permissions - ) - } - guard destinationDescriptor >= 0 else { - throw Self.failure("could not exclusively create the refreshed staged executable") - } - var renamed = false - defer { - close(destinationDescriptor) - if !renamed { - _ = temporaryName.withCString { unlinkat(pinned.directoryDescriptor, $0, 0) } - } - } - - try Self.copyBytes( - from: pinned.sourceDescriptor, - to: destinationDescriptor, - expectedSize: expectedIdentity.size - ) - try Self.copyExtendedAttributes( - from: pinned.sourceDescriptor, - to: destinationDescriptor - ) - guard fchmod(destinationDescriptor, permissions) == 0 else { - throw Self.failure("could not preserve staged executable permissions") - } - guard fsync(destinationDescriptor) == 0 else { - throw Self.failure("could not synchronize the refreshed staged executable") - } - - let sourceAfterCopy = try Self.trustedFileInfo( - pinned.sourceDescriptor, - expectedIdentity: expectedIdentity, - operation: "revalidate pinned staged executable", - expectedUserID: expectedUserID - ) - guard Self.sameFile(pinned.sourceInfo, sourceAfterCopy), - try RuntimeSHA256.hexDigest( - ofDescriptor: pinned.sourceDescriptor, - path: executableURL.path - ) == expectedIdentity.sha256 - else { - throw RuntimeInstallerError.quarantineRemovalFailed( - "the staged executable changed while its trusted vnode was refreshed" - ) - } - - let destinationIdentity = try Self.captureIdentity( - destinationDescriptor, - path: executableURL.path, - expectedSHA256: expectedIdentity.sha256 - ) - guard destinationIdentity.device == expectedIdentity.device, - destinationIdentity.inode != expectedIdentity.inode - else { - throw RuntimeInstallerError.quarantineRemovalFailed( - "the refreshed staged executable did not receive a fresh vnode" - ) - } - guard !(try Self.hasQuarantine(destinationDescriptor)) else { - throw RuntimeInstallerError.quarantineRemovalFailed( - "the refreshed staged executable still has com.apple.quarantine" - ) - } - - try Self.validatePathStillReferencesPinnedSource( - directoryDescriptor: pinned.directoryDescriptor, - name: pinned.name, - expectedIdentity: expectedIdentity - ) - let renameResult = temporaryName.withCString { temporaryPath in - pinned.name.withCString { executablePath in - renameat( - pinned.directoryDescriptor, - temporaryPath, - pinned.directoryDescriptor, - executablePath - ) - } - } - guard renameResult == 0 else { - throw Self.failure("could not atomically activate the refreshed staged executable") - } - renamed = true - guard fsync(pinned.directoryDescriptor) == 0 else { - throw Self.failure("could not synchronize the staged executable directory") - } - - let replacementDescriptor = pinned.name.withCString { - openat(pinned.directoryDescriptor, $0, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) - } - guard replacementDescriptor >= 0 else { - throw Self.failure("could not reopen the refreshed staged executable") - } - defer { close(replacementDescriptor) } - let replacementIdentity = try Self.captureIdentity( - replacementDescriptor, - path: executableURL.path, - expectedSHA256: expectedIdentity.sha256 - ) - guard replacementIdentity == destinationIdentity else { - throw RuntimeInstallerError.quarantineRemovalFailed( - "the refreshed staged executable identity changed during atomic replacement" - ) - } - return replacementIdentity - } - - private struct PinnedSource { - let directoryDescriptor: Int32 - let sourceDescriptor: Int32 - let name: String - let sourceInfo: stat - } - - private static func openPinnedSource( - _ executableURL: URL, - expectedIdentity: RuntimeExecutableIdentity, - expectedUserID: uid_t - ) throws -> PinnedSource { - let executable = executableURL.standardizedFileURL - let directory = executable.deletingLastPathComponent() - let name = executable.lastPathComponent - guard !name.isEmpty, - name != ".", - name != "..", - !name.contains("/"), - directory.appendingPathComponent(name).standardizedFileURL == executable - else { - throw RuntimeInstallerError.quarantineRemovalFailed("invalid staged executable path") - } - let directoryDescriptor = open( - directory.path, - O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW - ) - guard directoryDescriptor >= 0 else { - throw failure("could not safely open the staged executable directory") - } - var directoryInfo = stat() - guard fstat(directoryDescriptor, &directoryInfo) == 0, - (directoryInfo.st_mode & S_IFMT) == S_IFDIR, - directoryInfo.st_uid == expectedUserID, - directoryInfo.st_mode & 0o022 == 0 - else { - close(directoryDescriptor) - throw RuntimeInstallerError.quarantineRemovalFailed( - "the staged executable directory is not private and user-owned" - ) - } - let sourceDescriptor = name.withCString { - openat(directoryDescriptor, $0, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) - } - guard sourceDescriptor >= 0 else { - close(directoryDescriptor) - throw failure("could not safely open the staged executable") - } - do { - let sourceInfo = try trustedFileInfo( - sourceDescriptor, - expectedIdentity: expectedIdentity, - operation: "pin staged executable", - expectedUserID: expectedUserID - ) - try validatePathStillReferencesPinnedSource( - directoryDescriptor: directoryDescriptor, - name: name, - expectedIdentity: expectedIdentity - ) - let hash = try RuntimeSHA256.hexDigest( - ofDescriptor: sourceDescriptor, - path: executable.path - ) - guard hash == expectedIdentity.sha256 else { - throw RuntimeInstallerError.quarantineRemovalFailed( - "the pinned staged executable checksum changed" - ) - } - return PinnedSource( - directoryDescriptor: directoryDescriptor, - sourceDescriptor: sourceDescriptor, - name: name, - sourceInfo: sourceInfo - ) - } catch { - close(sourceDescriptor) - close(directoryDescriptor) - throw error - } - } - - private static func trustedFileInfo( - _ descriptor: Int32, - expectedIdentity: RuntimeExecutableIdentity, - operation: String, - expectedUserID: uid_t - ) throws -> stat { - var info = stat() - guard fstat(descriptor, &info) == 0 else { - throw failure("could not \(operation)") - } - guard (info.st_mode & S_IFMT) == S_IFREG, - info.st_uid == expectedUserID, - info.st_nlink == 1, - info.st_mode & 0o022 == 0, - info.st_mode & 0o100 != 0, - info.st_size == expectedIdentity.size, - UInt64(info.st_dev) == expectedIdentity.device, - UInt64(info.st_ino) == expectedIdentity.inode - else { - throw RuntimeInstallerError.quarantineRemovalFailed( - "the staged executable is not the expected private single-link user-owned vnode" - ) - } - return info - } - - private static func validatePathStillReferencesPinnedSource( - directoryDescriptor: Int32, - name: String, - expectedIdentity: RuntimeExecutableIdentity - ) throws { - var info = stat() - let result = name.withCString { - fstatat(directoryDescriptor, $0, &info, AT_SYMLINK_NOFOLLOW) - } - guard result == 0, - (info.st_mode & S_IFMT) == S_IFREG, - info.st_uid == geteuid(), - info.st_nlink == 1, - info.st_size == expectedIdentity.size, - UInt64(info.st_dev) == expectedIdentity.device, - UInt64(info.st_ino) == expectedIdentity.inode - else { - throw RuntimeInstallerError.quarantineRemovalFailed( - "the staged executable path no longer references the validated vnode" - ) - } - } - - private static func captureIdentity( - _ descriptor: Int32, - path: String, - expectedSHA256: String - ) throws -> RuntimeExecutableIdentity { - var before = stat() - guard fstat(descriptor, &before) == 0, - (before.st_mode & S_IFMT) == S_IFREG, - before.st_uid == geteuid(), - before.st_nlink == 1, - before.st_mode & 0o022 == 0, - before.st_mode & 0o100 != 0, - before.st_size >= 0 - else { - throw RuntimeInstallerError.quarantineRemovalFailed( - "the refreshed staged executable is not a private single-link user-owned executable" - ) - } - let hash = try RuntimeSHA256.hexDigest(ofDescriptor: descriptor, path: path) - var after = stat() - guard fstat(descriptor, &after) == 0, sameFile(before, after), hash == expectedSHA256 else { - throw RuntimeInstallerError.quarantineRemovalFailed( - "the refreshed staged executable identity or checksum is unstable" - ) - } - return RuntimeExecutableIdentity( - device: UInt64(before.st_dev), - inode: UInt64(before.st_ino), - size: before.st_size, - sha256: hash - ) - } - - private static func copyBytes( - from sourceDescriptor: Int32, - to destinationDescriptor: Int32, - expectedSize: Int64 - ) throws { - guard lseek(sourceDescriptor, 0, SEEK_SET) >= 0 else { - throw failure("could not seek the pinned staged executable") - } - var copied: Int64 = 0 - var buffer = [UInt8](repeating: 0, count: 64 * 1_024) - while true { - let count = read(sourceDescriptor, &buffer, buffer.count) - if count < 0 { - if errno == EINTR { continue } - throw failure("could not read the pinned staged executable") - } - if count == 0 { break } - var written = 0 - while written < count { - let result = buffer.withUnsafeBytes { bytes in - write( - destinationDescriptor, - bytes.baseAddress!.advanced(by: written), - count - written - ) - } - if result < 0 { - if errno == EINTR { continue } - throw failure("could not write the refreshed staged executable") - } - written += result - } - copied += Int64(count) - guard copied <= expectedSize else { - throw RuntimeInstallerError.quarantineRemovalFailed( - "the staged executable grew while it was refreshed" - ) - } - } - guard copied == expectedSize else { - throw RuntimeInstallerError.quarantineRemovalFailed( - "the staged executable was truncated while it was refreshed" - ) - } - } - - private static func copyExtendedAttributes(from sourceDescriptor: Int32, to destinationDescriptor: Int32) throws { - let bufferSize = flistxattr(sourceDescriptor, nil, 0, 0) - guard bufferSize >= 0 else { - throw failure("could not enumerate staged executable extended attributes") - } - guard bufferSize > 0 else { return } - var buffer = [CChar](repeating: 0, count: bufferSize) - let readCount = flistxattr(sourceDescriptor, &buffer, buffer.count, 0) - guard readCount == bufferSize else { - throw failure("staged executable extended attributes changed during inspection") - } - - var offset = 0 - while offset < readCount { - let name = buffer.withUnsafeBufferPointer { - String(cString: $0.baseAddress!.advanced(by: offset)) - } - offset += name.utf8.count + 1 - if name == quarantineName { continue } - let valueSize = name.withCString { - fgetxattr(sourceDescriptor, $0, nil, 0, 0, 0) - } - guard valueSize >= 0 else { - throw failure("could not read staged executable extended attribute \(name)") - } - var value = Data(count: valueSize) - let valueReadCount = value.withUnsafeMutableBytes { bytes in - name.withCString { - fgetxattr(sourceDescriptor, $0, bytes.baseAddress, valueSize, 0, 0) - } - } - guard valueReadCount == valueSize else { - throw failure("staged executable extended attribute \(name) changed during copy") - } - let setResult = value.withUnsafeBytes { bytes in - name.withCString { - fsetxattr(destinationDescriptor, $0, bytes.baseAddress, valueSize, 0, 0) - } - } - guard setResult == 0 else { - throw failure("could not preserve staged executable extended attribute \(name)") - } - } - } - - private static func hasQuarantine(_ descriptor: Int32) throws -> Bool { - let result = quarantineName.withCString { - fgetxattr(descriptor, $0, nil, 0, 0, 0) - } - if result >= 0 { return true } - if errno == ENOATTR { return false } - throw failure("could not inspect quarantine on the staged executable descriptor") - } - - private static func sameFile(_ lhs: stat, _ rhs: stat) -> Bool { - lhs.st_dev == rhs.st_dev - && lhs.st_ino == rhs.st_ino - && lhs.st_nlink == rhs.st_nlink - && lhs.st_size == rhs.st_size - && lhs.st_mtimespec.tv_sec == rhs.st_mtimespec.tv_sec - && lhs.st_mtimespec.tv_nsec == rhs.st_mtimespec.tv_nsec - && lhs.st_ctimespec.tv_sec == rhs.st_ctimespec.tv_sec - && lhs.st_ctimespec.tv_nsec == rhs.st_ctimespec.tv_nsec - } - - private static func failure(_ message: String) -> RuntimeInstallerError { - RuntimeInstallerError.quarantineRemovalFailed( - "\(message): \(String(cString: strerror(errno)))" - ) - } -} - -private struct LatestRuntimeManifest: Decodable { - let version: RuntimeReleaseVersion -} - -private actor RuntimeInstallationGate { - static let shared = RuntimeInstallationGate() - - private var owner: UUID? - private var waiters: [(UUID, CheckedContinuation)] = [] - - func acquire(_ id: UUID) async { - if owner == nil { - owner = id - return - } - await withCheckedContinuation { waiters.append((id, $0)) } - } - - func release(_ id: UUID) { - guard owner == id else { return } - if waiters.isEmpty { - owner = nil - } else { - let next = waiters.removeFirst() - owner = next.0 - next.1.resume() - } - } -} - +/// The seam the supervisor and developer override depend on. Two operations: +/// report an already-installed runtime, or install the latest by running the +/// upstream shell installer. public protocol RuntimeInstalling: Sendable { func installedCurrentRuntime() async throws -> InstalledRuntime? func installLatest( @@ -603,115 +11,83 @@ public protocol RuntimeInstalling: Sendable { ) async throws -> InstalledRuntime } +/// Thin wrapper around forge3's cargo-dist shell installer. +/// +/// First run downloads the installer script from `installerURL` (default +/// `https://install.forgecode.dev/server`) and runs it via `/bin/sh` with +/// `FORGE3_INSTALL_DIR` pinned to a deterministic directory. The binary lands +/// at `/bin/forge3` (cargo-home layout) and a receipt is written to +/// `~/.config/forge3/forge3-receipt.json` so forge3's own axoupdater self-update +/// keeps working. No checksum, signature, or Mach-O verification is performed — +/// the installer script is fully trusted. public actor RuntimeInstaller: RuntimeInstalling { - public struct Dependencies: Sendable { - public let network: any RuntimeNetworkClient - public let store: any RuntimeStoreManaging - public let archive: any RuntimeArchiveHandling - public let validator: any RuntimeExecutableValidating - public let signatureInspector: any RuntimeCodeSignatureInspecting - public let trustPolicy: any RuntimePreExecutionTrustPolicy - public let quarantineManager: any RuntimeQuarantineManaging - public let securityLogger: any RuntimeInstallationSecurityLogging - public let validationTracer: any RuntimeInstallerValidationTracing - public let developerIDAuthenticationPolicy: RuntimeDeveloperIDAuthenticationPolicy - public let developerIDRequirementEvaluator: any RuntimeDeveloperIDRequirementEvaluating - public let progressDeliveryHook: any RuntimeInstallerProgressDeliveryHook - public let storeLease: RuntimeStoreLease? - - public init( - network: any RuntimeNetworkClient, - store: any RuntimeStoreManaging, - archive: any RuntimeArchiveHandling, - validator: any RuntimeExecutableValidating, - signatureInspector: (any RuntimeCodeSignatureInspecting)? = nil, - trustPolicy: any RuntimePreExecutionTrustPolicy = TemporaryAdHocRuntimeTrustPolicy(), - quarantineManager: any RuntimeQuarantineManaging = DarwinRuntimeQuarantineManager(), - securityLogger: any RuntimeInstallationSecurityLogging = AppRuntimeInstallationSecurityLogger(), - validationTracer: any RuntimeInstallerValidationTracing = NoOpRuntimeInstallerValidationTracer(), - developerIDAuthenticationPolicy: RuntimeDeveloperIDAuthenticationPolicy = RuntimeDeveloperIDAuthenticationPolicy(), - developerIDRequirementEvaluator: any RuntimeDeveloperIDRequirementEvaluating = SecurityRuntimeDeveloperIDRequirementEvaluator(), - progressDeliveryHook: any RuntimeInstallerProgressDeliveryHook = NoOpRuntimeInstallerProgressDeliveryHook(), - storeLease: RuntimeStoreLease? = nil - ) { - self.network = network - self.store = store - self.archive = archive - self.validator = validator - self.signatureInspector = signatureInspector - ?? (validator as? any RuntimeCodeSignatureInspecting) - ?? UnsignedRuntimeCodeSignatureInspector() - self.trustPolicy = trustPolicy - self.quarantineManager = quarantineManager - self.securityLogger = securityLogger - self.validationTracer = validationTracer - self.developerIDAuthenticationPolicy = developerIDAuthenticationPolicy - self.developerIDRequirementEvaluator = developerIDRequirementEvaluator - self.progressDeliveryHook = progressDeliveryHook - self.storeLease = storeLease - } - } - - private struct Operation { - let id: UUID - let key: String - let task: Task - var resultWaiters: [UUID: CheckedContinuation] - var completionWaiters: [UUID: CheckedContinuation] - var progressHandlers: [UUID: RuntimeInstallerProgressChannel] - var latestProgress: RuntimeInstallationPhase? - var acceptingJoiners: Bool - } - + /// Overrides the installer script source. `FORGE_UPDATE_INSTALLER_URL=...` + /// (e.g. `http://127.0.0.1:9877/install.sh`) points the app at a local + /// script for testing. + public static let installerURLEnvironmentKey = "FORGE_UPDATE_INSTALLER_URL" + public static let defaultInstallerURL = URL(string: "https://install.forgecode.dev/server")! + + private let installDir: URL + private let installerURL: URL private let architecture: RuntimeArchitecture - private let limits: RuntimeInstallerLimits - private let dependencies: Dependencies - private var activeOperation: Operation? - private var cancelledResultWaiters: Set = [] - private var cancelledCompletionWaiters: Set = [] + private let logger: AppLogger + private let session: URLSession + private let maximumScriptBytes: Int public init( - rootURL: URL = RuntimeStore.defaultRoot(), + installDir: URL = RuntimeInstaller.defaultInstallDir(), + installerURL: URL? = nil, architecture: RuntimeArchitecture = .native, - limits: RuntimeInstallerLimits = RuntimeInstallerLimits(), - expectedDeveloperIDTeamIdentifier: String? = nil + environment: [String: String] = ProcessInfo.processInfo.environment, + logger: AppLogger = .shared, + session: URLSession = .shared, + maximumScriptBytes: Int = 2 * 1_024 * 1_024 ) { - let developerIDRequirementEvaluator = SecurityRuntimeDeveloperIDRequirementEvaluator() - let validator = MachORuntimeValidator( - developerIDRequirementEvaluator: developerIDRequirementEvaluator - ) - let lease = RuntimeStoreLease(rootURL: rootURL) + self.installDir = installDir.standardizedFileURL + self.installerURL = installerURL + ?? RuntimeInstaller.resolveInstallerURL(environment: environment) self.architecture = architecture - self.limits = limits - dependencies = Dependencies( - network: URLSessionRuntimeNetworkClient(), - store: RuntimeStore(rootURL: rootURL, validator: validator, lease: lease), - archive: SafeTarXZArchiveHandler(), - validator: validator, - signatureInspector: validator, - trustPolicy: TemporaryAdHocRuntimeTrustPolicy(), - quarantineManager: DarwinRuntimeQuarantineManager(), - securityLogger: AppRuntimeInstallationSecurityLogger(), - developerIDAuthenticationPolicy: RuntimeDeveloperIDAuthenticationPolicy( - expectedTeamIdentifier: expectedDeveloperIDTeamIdentifier - ), - developerIDRequirementEvaluator: developerIDRequirementEvaluator, - storeLease: lease - ) + self.logger = logger + self.session = session + self.maximumScriptBytes = maximumScriptBytes } - public init( - architecture: RuntimeArchitecture, - limits: RuntimeInstallerLimits = RuntimeInstallerLimits(), - dependencies: Dependencies - ) { - self.architecture = architecture - self.limits = limits - self.dependencies = dependencies + /// Deterministic install directory: `~/Library/Application Support/ForgeCode/forge3`. + public static func defaultInstallDir( + libraryDirectory: URL? = nil, + fileManager: FileManager = .default + ) -> URL { + let library = libraryDirectory ?? fileManager.urls(for: .libraryDirectory, in: .userDomainMask)[0] + return library.appendingPathComponent("Application Support/ForgeCode/forge3", isDirectory: true) + } + + /// The absolute path the installed executable is expected at. + public static func executableURL(installDir: URL) -> URL { + installDir + .appendingPathComponent("bin", isDirectory: true) + .appendingPathComponent("forge3") + .standardizedFileURL + } + + public static func resolveInstallerURL( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> URL { + if let raw = environment[installerURLEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty, + let url = URL(string: raw) { + return url + } + return defaultInstallerURL } public func installedCurrentRuntime() throws -> InstalledRuntime? { - try dependencies.store.current(architecture: architecture) + let executable = Self.executableURL(installDir: installDir) + guard FileManager.default.isExecutableFile(atPath: executable.path) else { return nil } + return InstalledRuntime( + version: .unknown, + architecture: architecture, + executableURL: executable + ) } public func installLatest() async throws -> InstalledRuntime { @@ -721,454 +97,160 @@ public actor RuntimeInstaller: RuntimeInstalling { public func installLatest( progress: @escaping @Sendable (RuntimeInstallationPhase) async -> Void ) async throws -> InstalledRuntime { - let key = "latest:\(architecture.rawValue)" - return try await coalesced(key: key, progress: progress) { [architecture, limits, dependencies] emitProgress in - await emitProgress(.resolving) - try Task.checkCancellation() - if let current = try dependencies.store.current(architecture: architecture) { - await emitProgress(.ready) - return current - } - let manifestDownload = try await dependencies.network.download( - RuntimeDownloadRequest( - url: RuntimeReleaseURLs.latestManifest, - maximumBytes: limits.manifestBytes, - maximumRedirects: limits.redirects - ) - ) - let manifest: LatestRuntimeManifest - do { - let decoder = JSONDecoder() - manifest = try decoder.decode(LatestRuntimeManifest.self, from: manifestDownload.data) - } catch { - throw RuntimeInstallerError.invalidManifest(error.localizedDescription) - } - let installed = try await Self.install( - version: manifest.version, - architecture: architecture, - limits: limits, - dependencies: dependencies, - progress: emitProgress - ) - await emitProgress(.ready) - return installed - } - } - - public func install(version: RuntimeReleaseVersion) async throws -> InstalledRuntime { - let key = "\(version.rawValue):\(architecture.rawValue)" - return try await coalesced(key: key) { [architecture, limits, dependencies] _ in - try await Self.install( - version: version, - architecture: architecture, - limits: limits, - dependencies: dependencies, - progress: { _ in } - ) + // Already installed (e.g. a concurrent install won): reuse it. + if let existing = try installedCurrentRuntime() { + await progress(.ready) + return existing } - } - private func coalesced( - key: String, - progress: @escaping @Sendable (RuntimeInstallationPhase) async -> Void = { _ in }, - operation: @escaping @Sendable ( - _ progress: @escaping @Sendable (RuntimeInstallationPhase) async -> Void - ) async throws -> InstalledRuntime - ) async throws -> InstalledRuntime { - while let active = activeOperation, - active.key != key || !active.acceptingJoiners { - try await waitForOperationCompletion(active.id) - try checkCancellation() - } + await progress(.resolving) + try Task.checkCancellation() - let operationID: UUID - if let active = activeOperation { - operationID = active.id - } else { - operationID = startOperation(key: key, operation: operation) - } - return try await waitForOperationResult(operationID: operationID, progress: progress) - } + let scriptURL = try await downloadInstallerScript() + defer { try? FileManager.default.removeItem(at: scriptURL.deletingLastPathComponent()) } - private func startOperation( - key: String, - operation: @escaping @Sendable ( - _ progress: @escaping @Sendable (RuntimeInstallationPhase) async -> Void - ) async throws -> InstalledRuntime - ) -> UUID { - let operationID = UUID() - let owner = self - let task = Task { - await RuntimeInstallationGate.shared.acquire(operationID) - let result: Result - do { - try Task.checkCancellation() - result = .success(try await operation { phase in - await owner.publishProgress(operationID: operationID, phase: phase) - }) - } - catch is CancellationError { result = .failure(RuntimeInstallerError.cancelled) } - catch { result = .failure(error) } - await RuntimeInstallationGate.shared.release(operationID) - await owner.finishOperation(id: operationID, result: result) - } - activeOperation = Operation( - id: operationID, - key: key, - task: task, - resultWaiters: [:], - completionWaiters: [:], - progressHandlers: [:], - latestProgress: nil, - acceptingJoiners: true - ) - return operationID - } + try Task.checkCancellation() + await progress(.installing) + logger.info("Running forge3 installer from \(installerURL.absoluteString) into \(installDir.path)") + try await runInstaller(scriptURL: scriptURL) - private func waitForOperationResult( - operationID: UUID, - progress: @escaping @Sendable (RuntimeInstallationPhase) async -> Void - ) async throws -> InstalledRuntime { - let waiterID = UUID() - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - registerResultWaiter( - continuation, - operationID: operationID, - waiterID: waiterID, - progress: progress - ) - } - } onCancel: { - Task { await self.cancelResultWaiter(operationID: operationID, waiterID: waiterID) } + let executable = Self.executableURL(installDir: installDir) + guard FileManager.default.isExecutableFile(atPath: executable.path) else { + throw ThinInstallerError.missingExecutable(executable.path) } - } + Self.removeQuarantine(at: executable, logger: logger) - private func registerResultWaiter( - _ continuation: CheckedContinuation, - operationID: UUID, - waiterID: UUID, - progress: @escaping @Sendable (RuntimeInstallationPhase) async -> Void - ) { - if cancelledResultWaiters.remove(waiterID) != nil || Task.isCancelled { - continuation.resume(throwing: RuntimeInstallerError.cancelled) - return - } - guard var active = activeOperation, active.id == operationID else { - continuation.resume(throwing: RuntimeInstallerError.processFailure("installation operation ended before the waiter joined")) - return - } - let channel = RuntimeInstallerProgressChannel( - handler: progress, - hook: dependencies.progressDeliveryHook + await progress(.ready) + return InstalledRuntime( + version: .unknown, + architecture: architecture, + executableURL: executable ) - active.resultWaiters[waiterID] = continuation - active.progressHandlers[waiterID] = channel - if let latest = active.latestProgress { - channel.enqueue(RuntimeInstallerProgressDelivery(phase: latest, kind: .replay)) - } - activeOperation = active } - private func cancelResultWaiter(operationID: UUID, waiterID: UUID) { - guard var active = activeOperation, active.id == operationID else { - cancelledResultWaiters.insert(waiterID) - return - } - guard let continuation = active.resultWaiters.removeValue(forKey: waiterID) else { - cancelledResultWaiters.insert(waiterID) - return + private func downloadInstallerScript() async throws -> URL { + var request = URLRequest(url: installerURL) + request.timeoutInterval = 60 + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + if error is CancellationError { throw ThinInstallerError.cancelled } + throw ThinInstallerError.download(error.localizedDescription) } - let progressChannel = active.progressHandlers.removeValue(forKey: waiterID) - let shouldCancelOperation = active.resultWaiters.isEmpty - if shouldCancelOperation { active.acceptingJoiners = false } - activeOperation = active - progressChannel?.cancelPending() - continuation.resume(throwing: RuntimeInstallerError.cancelled) - if shouldCancelOperation { active.task.cancel() } - } - - private func waitForOperationCompletion(_ operationID: UUID) async throws { - let waiterID = UUID() - try await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - registerCompletionWaiter(continuation, operationID: operationID, waiterID: waiterID) - } - try checkCancellation() - } onCancel: { - Task { await self.cancelCompletionWaiter(operationID: operationID, waiterID: waiterID) } + if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + throw ThinInstallerError.download("installer host returned HTTP \(http.statusCode)") } - } - - private func registerCompletionWaiter( - _ continuation: CheckedContinuation, - operationID: UUID, - waiterID: UUID - ) { - if cancelledCompletionWaiters.remove(waiterID) != nil || Task.isCancelled { - continuation.resume() - return + guard !data.isEmpty else { + throw ThinInstallerError.download("the installer script was empty") } - guard var active = activeOperation, active.id == operationID else { - continuation.resume() - return + guard data.count <= maximumScriptBytes else { + throw ThinInstallerError.download("the installer script exceeded \(maximumScriptBytes) bytes") } - active.completionWaiters[waiterID] = continuation - activeOperation = active - } - private func cancelCompletionWaiter(operationID: UUID, waiterID: UUID) { - guard var active = activeOperation, active.id == operationID else { - cancelledCompletionWaiters.insert(waiterID) - return - } - guard let continuation = active.completionWaiters.removeValue(forKey: waiterID) else { - cancelledCompletionWaiters.insert(waiterID) - return + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("forge3-installer-\(UUID().uuidString)", isDirectory: true) + let scriptURL = directory.appendingPathComponent("forge3-installer.sh") + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: scriptURL, options: .atomic) + } catch { + throw RuntimeFilesystemError.thin(error, operation: "stage the forge3 installer script", path: scriptURL.path) } - activeOperation = active - continuation.resume() + return scriptURL } - private func publishProgress(operationID: UUID, phase: RuntimeInstallationPhase) { - guard var active = activeOperation, active.id == operationID else { return } - active.latestProgress = phase - for channel in active.progressHandlers.values { - channel.enqueue(RuntimeInstallerProgressDelivery(phase: phase, kind: .publication)) + private func runInstaller(scriptURL: URL) async throws { + do { + try FileManager.default.createDirectory(at: installDir, withIntermediateDirectories: true) + } catch { + throw RuntimeFilesystemError.thin(error, operation: "create the forge3 install directory", path: installDir.path) } - activeOperation = active - } - private func finishOperation(id: UUID, result: Result) async { - guard var active = activeOperation, active.id == id else { return } - active.acceptingJoiners = false - let progressChannels = Array(active.progressHandlers.values) - activeOperation = active - for channel in progressChannels { await channel.close() } - guard let completed = activeOperation, completed.id == id else { return } - activeOperation = nil - for continuation in completed.resultWaiters.values { - switch result { - case .success(let runtime): continuation.resume(returning: runtime) - case .failure(let error): continuation.resume(throwing: error) - } + let invocation = Self.makeInvocation(scriptURL: scriptURL, installDir: installDir) + let status = try await Self.runShell( + arguments: invocation.arguments, + environmentOverrides: invocation.environmentOverrides, + logger: logger + ) + guard status == 0 else { + throw ThinInstallerError.installerFailed(status: status) } - completed.completionWaiters.values.forEach { $0.resume() } } - private func checkCancellation() throws { - if Task.isCancelled { throw RuntimeInstallerError.cancelled } + /// Pure builder for the installer subprocess invocation, factored out for + /// testing. Uses the default managed install (receipt enabled) targeting a + /// fixed directory, without touching shell rc files. + public struct Invocation: Equatable, Sendable { + public let arguments: [String] + public let environmentOverrides: [String: String] } - private static func install( - version: RuntimeReleaseVersion, - architecture: RuntimeArchitecture, - limits: RuntimeInstallerLimits, - dependencies: Dependencies, - progress: @escaping @Sendable (RuntimeInstallationPhase) async -> Void - ) async throws -> InstalledRuntime { - try Task.checkCancellation() - if let cached = try dependencies.store.cached(version: version, architecture: architecture) { - return cached - } - - await progress(.downloading(progress: 0)) - let temporaryDirectory = try dependencies.store.makePrivateTemporaryDirectory() - var temporaryDirectoryOwned = true - defer { - if temporaryDirectoryOwned { - var cleanupLease: RuntimeStoreLease.Token? - if let storeLease = dependencies.storeLease { - cleanupLease = try? storeLease.acquire(.exclusiveMutation) - } - try? FileManager.default.removeItem(at: temporaryDirectory) - cleanupLease?.release() - } - } - - let archiveURL = RuntimeReleaseURLs.archive(version: version, architecture: architecture) - let sidecarURL = RuntimeReleaseURLs.checksum(version: version, architecture: architecture) - async let archiveDownload = dependencies.network.download( - RuntimeDownloadRequest( - url: archiveURL, - maximumBytes: limits.archiveBytes, - maximumRedirects: limits.redirects - ), - progress: { receivedBytes, expectedBytes in - guard expectedBytes > 0 else { return } - await progress(.downloading(progress: Double(receivedBytes) / Double(expectedBytes))) - } - ) - async let checksumDownload = dependencies.network.download( - RuntimeDownloadRequest( - url: sidecarURL, - maximumBytes: limits.checksumBytes, - maximumRedirects: limits.redirects - ), - progress: { _, _ in } - ) - let (downloadedArchive, downloadedChecksum) = try await (archiveDownload, checksumDownload) - try Task.checkCancellation() - await progress(.verifying) - - let expectedChecksum = try RuntimeChecksumSidecar.parse( - downloadedChecksum.data, - expectedFilename: architecture.archiveName + public static func makeInvocation(scriptURL: URL, installDir: URL) -> Invocation { + Invocation( + arguments: ["/bin/sh", scriptURL.path], + environmentOverrides: [ + "FORGE3_INSTALL_DIR": installDir.path, + "FORGE3_NO_MODIFY_PATH": "1", + "FORGE3_PRINT_QUIET": "1" + ] ) - let actualChecksum = RuntimeSHA256.hexDigest(of: downloadedArchive.data) - guard actualChecksum == expectedChecksum else { - throw RuntimeInstallerError.checksumMismatch(expected: expectedChecksum, actual: actualChecksum) - } + } - let stagingLease = try dependencies.storeLease?.acquire(.exclusiveMutation) - var stagingLeaseReleased = false - defer { - if !stagingLeaseReleased { stagingLease?.release() } + /// Removes `com.apple.quarantine` from the installed binary. Best-effort: + /// the only Gatekeeper workaround retained. A locally-downloaded script's + /// output usually isn't quarantined, but strip it defensively. + static func removeQuarantine(at url: URL, logger: AppLogger) { + let result = url.path.withCString { path in + removexattr(path, "com.apple.quarantine", XATTR_NOFOLLOW) } - - let archiveFile = temporaryDirectory.appendingPathComponent(architecture.archiveName) - do { - try downloadedArchive.data.write(to: archiveFile, options: .atomic) - try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: archiveFile.path) - } catch { - throw RuntimeFilesystemError.wrapping( - error, - operation: "stage downloaded runtime archive", - path: archiveFile.path - ) + if result != 0 && errno != ENOATTR { + logger.warning("Could not remove com.apple.quarantine from \(url.path): \(String(cString: strerror(errno)))") } - try Self.applyDownloadQuarantine(to: archiveFile, sourceURL: downloadedArchive.responseURL) - let inspection = try await dependencies.archive.inspect( - archiveURL: archiveFile, - temporaryDirectory: temporaryDirectory, - limits: limits - ) - try Task.checkCancellation() - - let executable = temporaryDirectory.appendingPathComponent("forge3") - try dependencies.archive.extractExecutable(from: inspection, to: executable) - try Self.applyDownloadQuarantine(to: executable, sourceURL: downloadedArchive.responseURL) - try? FileManager.default.removeItem(at: archiveFile) - try? FileManager.default.removeItem(at: inspection.tarURL) - try dependencies.validator.validate(executableURL: executable, expectedArchitecture: architecture) - dependencies.validationTracer.record(.initialMachOValidated) - let executableChecksum = try RuntimeSHA256.hexDigest(ofFile: executable) - let validatedIdentity = try RuntimeExecutableIdentityValidator.capture( - executable, - expectedSHA256: executableChecksum - ) - dependencies.validationTracer.record(.initialIdentityAndHashValidated) - let executionIdentity = try Self.applyPreExecutionTrustPolicy( - executableURL: executable, - architecture: architecture, - validatedIdentity: validatedIdentity, - dependencies: dependencies - ) - try RuntimeExecutableIdentityValidator.validate(executable, expected: executionIdentity) - stagingLease?.release() - stagingLeaseReleased = true - let receipt = RuntimeStoreReceipt( - version: version, - architecture: architecture, - archiveSHA256: actualChecksum, - executableSHA256: executableChecksum - ) - try Task.checkCancellation() - await progress(.installing) - let installed = try dependencies.store.installStagedRuntime( - executableURL: executable, - receipt: receipt, - temporaryDirectory: temporaryDirectory - ) - temporaryDirectoryOwned = false - return installed } - private static func applyPreExecutionTrustPolicy( - executableURL: URL, - architecture: RuntimeArchitecture, - validatedIdentity: RuntimeExecutableIdentity, - dependencies: Dependencies - ) throws -> RuntimeExecutableIdentity { - try Task.checkCancellation() - try RuntimeExecutableIdentityValidator.validate(executableURL, expected: validatedIdentity) - let signature = try dependencies.signatureInspector.inspectSignature(of: executableURL) - dependencies.validationTracer.record(.initialSignatureClassInspected) - try dependencies.developerIDAuthenticationPolicy.authenticate( - signature, - executableURL: executableURL, - requirementEvaluator: dependencies.developerIDRequirementEvaluator + /// Runs a short-lived one-shot subprocess (the installer), streaming its + /// output to the app log. Unlike the long-lived forge3 child, this does not + /// need process-group supervision. + static func runShell( + arguments: [String], + environmentOverrides: [String: String], + logger: AppLogger + ) async throws -> Int32 { + precondition(!arguments.isEmpty) + let environment = ForgeProcessHost.sanitizedEnvironment( + inherited: ProcessInfo.processInfo.environment, + overrides: environmentOverrides ) - try RuntimeExecutableIdentityValidator.validate(executableURL, expected: validatedIdentity) - let hasQuarantine = try dependencies.quarantineManager.hasQuarantine( - at: executableURL, - expectedIdentity: validatedIdentity - ) - dependencies.validationTracer.record(.initialQuarantineInspected) - let context = RuntimePreExecutionTrustContext( - signature: signature, - hasQuarantine: hasQuarantine, - architecture: architecture, - executableIdentity: validatedIdentity - ) - let decision = try dependencies.trustPolicy.decision(for: context) - dependencies.validationTracer.record(.trustPolicyEvaluated) - switch decision { - case .preserve: - return validatedIdentity - case .refreshRemovingQuarantine: - guard signature.signatureClass == .adHoc, hasQuarantine else { - throw RuntimeInstallerError.quarantineRemovalFailed( - "the pre-execution trust policy attempted quarantine removal outside the ad-hoc quarantined case" - ) - } - let refreshedIdentity = try dependencies.quarantineManager - .refreshExecutableRemovingQuarantine( - from: executableURL, - expectedIdentity: validatedIdentity - ) - dependencies.validationTracer.record(.stagedVnodeRefreshed) - try RuntimeExecutableIdentityValidator.validate(executableURL, expected: refreshedIdentity) - dependencies.validationTracer.record(.postRefreshIdentityAndHashValidated) - try dependencies.validator.validate( - executableURL: executableURL, - expectedArchitecture: architecture - ) - dependencies.validationTracer.record(.postRefreshMachOValidated) - let refreshedSignature = try dependencies.signatureInspector.inspectSignature(of: executableURL) - guard refreshedSignature.signatureClass == .adHoc else { - throw RuntimeInstallerError.invalidMachO( - "the refreshed staged executable is no longer ad-hoc signed" - ) - } - dependencies.validationTracer.record(.postRefreshSignatureClassInspected) - try RuntimeExecutableIdentityValidator.validate(executableURL, expected: refreshedIdentity) - guard !(try dependencies.quarantineManager.hasQuarantine( - at: executableURL, - expectedIdentity: refreshedIdentity - )) else { - throw RuntimeInstallerError.quarantineRemovalFailed( - "the refreshed staged executable still has com.apple.quarantine" - ) + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + let process = Process() + process.executableURL = URL(fileURLWithPath: arguments[0]) + process.arguments = Array(arguments.dropFirst()) + process.environment = environment + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + pipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + let text = String(decoding: data, as: UTF8.self) + for line in text.split(separator: "\n", omittingEmptySubsequences: true) { + logger.info("forge3-installer: \(Redactor.redact(String(line)))") + } + } + do { + try process.run() + } catch { + pipe.fileHandleForReading.readabilityHandler = nil + continuation.resume(throwing: ThinInstallerError.download(error.localizedDescription)) + return + } + process.waitUntilExit() + pipe.fileHandleForReading.readabilityHandler = nil + continuation.resume(returning: process.terminationStatus) } - dependencies.validationTracer.record(.postRefreshQuarantineValidated) - dependencies.securityLogger.log(.refreshedAdHocQuarantinedStagedExecutable) - return refreshedIdentity - } - } - - private static func applyDownloadQuarantine(to url: URL, sourceURL: URL) throws { - var values = URLResourceValues() - values.quarantineProperties = [ - "LSQuarantineType": "LSQuarantineTypeWebDownload", - "LSQuarantineAgentName": "Forge Menu Bar", - "LSQuarantineDataURL": sourceURL - ] - var mutableURL = url - do { - try mutableURL.setResourceValues(values) - } catch { - throw RuntimeFilesystemError.wrapping(error, operation: "apply download quarantine", path: url.path) } } } diff --git a/macos/Sources/ForgeMenuCore/RuntimeInstallerModels.swift b/macos/Sources/ForgeMenuCore/RuntimeInstallerModels.swift index 2cfc74b..8bcae68 100644 --- a/macos/Sources/ForgeMenuCore/RuntimeInstallerModels.swift +++ b/macos/Sources/ForgeMenuCore/RuntimeInstallerModels.swift @@ -9,70 +9,28 @@ public enum RuntimeFilesystemFailure: Equatable, Sendable { case other(Int32?) } -public enum RuntimeInstallerError: LocalizedError, Equatable { - case invalidReleaseVersion(String) - case unsupportedArchitecture(String) - case invalidURL(String) - case network(String) - case networkTimeout - case tooManyRedirects - case unsafeRedirect(String) - case invalidHTTPStatus(Int) - case missingContentLength - case invalidContentLength(String) - case responseTooLarge(limit: Int64) - case invalidManifest(String) - case invalidChecksumSidecar(String) - case checksumMismatch(expected: String, actual: String) - case malformedArchive(String) - case unsafeArchiveEntry(String) - case archiveLimitExceeded(String) - case missingRuntimeExecutable - case duplicateRuntimeExecutable - case invalidMachO(String) - case wrongArchitecture(expected: RuntimeArchitecture, actual: String) - case untrustedStoreItem(String) - case processFailure(String) - case processLaunchDenied(String) - case processTimeout - case developerIDAuthenticationFailed(String) - case quarantineRemovalFailed(String) +/// Errors surfaced by the thin shell-installer flow. The app fully trusts the +/// upstream installer script, so these describe only coarse failures the UI can +/// bucket into `RuntimeInstallationFailure`. +public enum ThinInstallerError: LocalizedError, Equatable { + case download(String) + case installerFailed(status: Int32) + case missingExecutable(String) case filesystem(operation: String, path: String, failure: RuntimeFilesystemFailure) case cancelled public var errorDescription: String? { switch self { - case .invalidReleaseVersion(let value): return "Invalid forge3 release version: \(value)" - case .unsupportedArchitecture(let value): return "Unsupported process architecture: \(value)" - case .invalidURL(let value): return "Invalid forge3 release URL: \(value)" - case .network(let message): return "Could not download forge3: \(message)" - case .networkTimeout: return "The forge3 download timed out." - case .tooManyRedirects: return "The forge3 download exceeded the redirect limit." - case .unsafeRedirect(let value): return "The forge3 download attempted an unsafe redirect to \(value)." - case .invalidHTTPStatus(let status): return "The forge3 release host returned HTTP \(status)." - case .missingContentLength: return "The forge3 release response did not include Content-Length." - case .invalidContentLength(let value): return "The forge3 release response had an invalid Content-Length: \(value)." - case .responseTooLarge(let limit): return "The forge3 release response exceeded \(limit) bytes." - case .invalidManifest(let message): return "The forge3 release manifest is invalid: \(message)" - case .invalidChecksumSidecar(let message): return "The forge3 checksum sidecar is invalid: \(message)" - case .checksumMismatch(let expected, let actual): return "The forge3 checksum did not match (expected \(expected), got \(actual))." - case .malformedArchive(let message): return "The forge3 archive is malformed: \(message)" - case .unsafeArchiveEntry(let value): return "The forge3 archive contains an unsafe entry: \(value)" - case .archiveLimitExceeded(let message): return "The forge3 archive exceeded a safety limit: \(message)" - case .missingRuntimeExecutable: return "The forge3 archive does not contain a forge3 executable." - case .duplicateRuntimeExecutable: return "The forge3 archive contains more than one forge3 executable." - case .invalidMachO(let message): return "The downloaded forge3 executable is not a valid Mach-O executable: \(message)" - case .wrongArchitecture(let expected, let actual): return "The downloaded forge3 executable has architecture \(actual), expected \(expected.rawValue)." - case .untrustedStoreItem(let message): return "The installed forge3 runtime is not trustworthy: \(message)" - case .processFailure(let message): return "Could not run a forge3 installation subprocess: \(message)" - case .processLaunchDenied(let message): return "macOS denied launching the staged forge3 executable: \(message)" - case .processTimeout: return "A forge3 installation subprocess timed out." - case .developerIDAuthenticationFailed(let message): - return "The downloaded Developer ID forge3 executable could not be authenticated: \(message)" - case .quarantineRemovalFailed(let message): return "Could not remove quarantine from the staged forge3 executable: \(message)" + case .download(let message): + return "Could not download the forge3 installer: \(message)" + case .installerFailed(let status): + return "The forge3 installer script exited with status \(status)." + case .missingExecutable(let path): + return "The forge3 installer did not produce an executable at \(path)." case .filesystem(let operation, let path, let failure): return "Could not \(operation) at \(path): \(failure.description)." - case .cancelled: return "The forge3 installation was cancelled." + case .cancelled: + return "The forge3 installation was cancelled." } } } @@ -118,6 +76,11 @@ public struct RuntimeReleaseVersion: RawRepresentable, Codable, Hashable, Compar public var description: String { rawValue } public var releaseTag: String { "v\(rawValue)" } + + /// Placeholder used when the runtime is installed by the shell installer, + /// which does not report a version the app can trust. `0.0.0` sorts below + /// every real release. + public static let unknown = RuntimeReleaseVersion(rawValue: "0.0.0")! } public enum RuntimeArchitecture: String, Codable, Sendable, CaseIterable { @@ -133,244 +96,21 @@ public enum RuntimeArchitecture: String, Codable, Sendable, CaseIterable { fatalError("forge3 does not support this process architecture") #endif } - - public var artifactComponent: String { - switch self { - case .arm64: return "aarch64" - case .x86_64: return "x86_64" - } - } - - public var archiveName: String { - "forge3-\(artifactComponent)-apple-darwin.tar.xz" - } -} - -public enum RuntimeReleaseURLs { - public static let origin = URL(string: "https://install.forgecode.dev")! - public static let latestManifest = URL(string: "https://install.forgecode.dev/server/releases/latest.json")! - - public static func archive(version: RuntimeReleaseVersion, architecture: RuntimeArchitecture) -> URL { - origin - .appendingPathComponent("server/releases", isDirectory: true) - .appendingPathComponent(version.releaseTag, isDirectory: true) - .appendingPathComponent(architecture.archiveName) - } - - public static func checksum(version: RuntimeReleaseVersion, architecture: RuntimeArchitecture) -> URL { - archive(version: version, architecture: architecture) - .appendingPathExtension("sha256") - } -} - -public struct RuntimeInstallerLimits: Sendable, Equatable { - public var manifestBytes: Int64 - public var checksumBytes: Int64 - public var archiveBytes: Int64 - public var expandedArchiveBytes: Int - public var executableBytes: Int - public var archiveEntries: Int - public var redirects: Int - - public init( - manifestBytes: Int64 = 4_096, - checksumBytes: Int64 = 1_024, - archiveBytes: Int64 = 64 * 1_024 * 1_024, - expandedArchiveBytes: Int = 192 * 1_024 * 1_024, - executableBytes: Int = 128 * 1_024 * 1_024, - archiveEntries: Int = 128, - redirects: Int = 3 - ) { - self.manifestBytes = manifestBytes - self.checksumBytes = checksumBytes - self.archiveBytes = archiveBytes - self.expandedArchiveBytes = expandedArchiveBytes - self.executableBytes = executableBytes - self.archiveEntries = archiveEntries - self.redirects = redirects - } -} - -public enum RuntimeCodeSignatureClass: Equatable, Sendable { - case unsigned - case adHoc - case developerID - case otherSigned -} - -public struct RuntimeCodeSignatureInspection: Equatable, Sendable { - public let signatureClass: RuntimeCodeSignatureClass - public let teamIdentifier: String? - public let signingIdentity: String? - - public init( - signatureClass: RuntimeCodeSignatureClass, - teamIdentifier: String? = nil, - signingIdentity: String? = nil - ) { - self.signatureClass = signatureClass - self.teamIdentifier = teamIdentifier - self.signingIdentity = signingIdentity - } -} - -public protocol RuntimeDeveloperIDRequirementEvaluating: Sendable { - /// Evaluates Apple's Developer ID Application requirement. When a Team ID - /// is supplied, the requirement must also bind the leaf certificate's - /// organizational unit to that exact identifier. - func satisfiesDeveloperIDApplicationRequirement( - executableURL: URL, - expectedTeamIdentifier: String? - ) throws -> Bool -} - -public struct RuntimeDeveloperIDAuthenticationPolicy: Equatable, Sendable { - public let expectedTeamIdentifier: String? - - public init(expectedTeamIdentifier: String? = nil) { - self.expectedTeamIdentifier = expectedTeamIdentifier - } - - public func authenticate( - _ signature: RuntimeCodeSignatureInspection, - executableURL: URL, - requirementEvaluator: any RuntimeDeveloperIDRequirementEvaluating - ) throws { - guard signature.signatureClass == .developerID else { return } - guard let expectedTeamIdentifier, !expectedTeamIdentifier.isEmpty else { - throw RuntimeInstallerError.developerIDAuthenticationFailed( - "no expected Developer ID Team Identifier is configured" - ) - } - guard let actualTeamIdentifier = signature.teamIdentifier, !actualTeamIdentifier.isEmpty else { - throw RuntimeInstallerError.developerIDAuthenticationFailed( - "the signature does not contain a Team Identifier" - ) - } - guard actualTeamIdentifier == expectedTeamIdentifier else { - throw RuntimeInstallerError.developerIDAuthenticationFailed( - "Team Identifier \(actualTeamIdentifier) does not match expected \(expectedTeamIdentifier)" - ) - } - guard try requirementEvaluator.satisfiesDeveloperIDApplicationRequirement( - executableURL: executableURL, - expectedTeamIdentifier: expectedTeamIdentifier - ) else { - throw RuntimeInstallerError.developerIDAuthenticationFailed( - "the signature does not satisfy Apple's Developer ID Application requirement for Team Identifier \(expectedTeamIdentifier)" - ) - } - } -} - -public enum RuntimeInstallerProgressDeliveryKind: Equatable, Sendable { - case replay - case publication -} - -public struct RuntimeInstallerProgressDelivery: Equatable, Sendable { - public let phase: RuntimeInstallationPhase - public let kind: RuntimeInstallerProgressDeliveryKind - - public init(phase: RuntimeInstallationPhase, kind: RuntimeInstallerProgressDeliveryKind) { - self.phase = phase - self.kind = kind - } -} - -public struct RuntimePreExecutionTrustContext: Equatable, Sendable { - public let signature: RuntimeCodeSignatureInspection - public let hasQuarantine: Bool - public let architecture: RuntimeArchitecture - public let executableIdentity: RuntimeExecutableIdentity - - public init( - signature: RuntimeCodeSignatureInspection, - hasQuarantine: Bool, - architecture: RuntimeArchitecture, - executableIdentity: RuntimeExecutableIdentity - ) { - self.signature = signature - self.hasQuarantine = hasQuarantine - self.architecture = architecture - self.executableIdentity = executableIdentity - } - - public init( - signatureClass: RuntimeCodeSignatureClass, - hasQuarantine: Bool, - architecture: RuntimeArchitecture, - executableIdentity: RuntimeExecutableIdentity - ) { - self.init( - signature: RuntimeCodeSignatureInspection(signatureClass: signatureClass), - hasQuarantine: hasQuarantine, - architecture: architecture, - executableIdentity: executableIdentity - ) - } - - public var signatureClass: RuntimeCodeSignatureClass { signature.signatureClass } -} - -public enum RuntimePreExecutionTrustDecision: Equatable, Sendable { - case preserve - case refreshRemovingQuarantine -} - -public enum RuntimeInstallerValidationEvent: Equatable, Sendable { - case initialMachOValidated - case initialIdentityAndHashValidated - case initialSignatureClassInspected - case initialQuarantineInspected - case trustPolicyEvaluated - case stagedVnodeRefreshed - case postRefreshIdentityAndHashValidated - case postRefreshMachOValidated - case postRefreshSignatureClassInspected - case postRefreshQuarantineValidated -} - -public struct RuntimeExecutableIdentity: Equatable, Sendable { - public let device: UInt64 - public let inode: UInt64 - public let size: Int64 - public let sha256: String - - public init(device: UInt64, inode: UInt64, size: Int64, sha256: String) { - self.device = device - self.inode = inode - self.size = size - self.sha256 = sha256 - } } public struct InstalledRuntime: Equatable, Sendable { public let version: RuntimeReleaseVersion public let architecture: RuntimeArchitecture public let executableURL: URL - public let executableIdentity: RuntimeExecutableIdentity? public init( version: RuntimeReleaseVersion, architecture: RuntimeArchitecture, - executableURL: URL, - executableIdentity: RuntimeExecutableIdentity? = nil + executableURL: URL ) { self.version = version self.architecture = architecture self.executableURL = executableURL.standardizedFileURL - self.executableIdentity = executableIdentity - } - - /// Reopens the executable without following symlinks and verifies the exact - /// receipt-backed vnode identity. Call immediately before handing the path - /// to a launcher that cannot accept an already-open descriptor. - public func validateExecutableIdentity() throws { - guard let executableIdentity else { - throw RuntimeInstallerError.untrustedStoreItem("runtime executable identity is unavailable") - } - try RuntimeExecutableIdentityValidator.validate(executableURL, expected: executableIdentity) } } @@ -388,39 +128,39 @@ extension RuntimeFilesystemFailure { } public enum RuntimeFilesystemError { - public static func posix(_ code: Int32, operation: String, path: String) -> RuntimeInstallerError { - .filesystem(operation: operation, path: path, failure: failure(for: code)) + public static func failure(for code: Int32) -> RuntimeFilesystemFailure { + switch code { + case EACCES, EPERM: return .permissionDenied + case ENOSPC, EDQUOT: return .diskFull + case EROFS: return .readOnlyFilesystem + case EIO: return .io + default: return .other(code) + } } - public static func wrapping(_ error: Error, operation: String, path: String) -> RuntimeInstallerError { - if let runtimeError = error as? RuntimeInstallerError { return runtimeError } + public static func failure(for error: Error) -> RuntimeFilesystemFailure { let nsError = error as NSError if nsError.domain == NSPOSIXErrorDomain { - return posix(Int32(nsError.code), operation: operation, path: path) + return failure(for: Int32(nsError.code)) } if let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? NSError, underlying.domain == NSPOSIXErrorDomain { - return posix(Int32(underlying.code), operation: operation, path: path) + return failure(for: Int32(underlying.code)) } switch CocoaError.Code(rawValue: nsError.code) { case .fileWriteNoPermission, .fileReadNoPermission: - return .filesystem(operation: operation, path: path, failure: .permissionDenied) + return .permissionDenied case .fileWriteOutOfSpace: - return .filesystem(operation: operation, path: path, failure: .diskFull) + return .diskFull case .fileWriteVolumeReadOnly: - return .filesystem(operation: operation, path: path, failure: .readOnlyFilesystem) + return .readOnlyFilesystem default: - return .filesystem(operation: operation, path: path, failure: .other(nil)) + return .other(nil) } } - private static func failure(for code: Int32) -> RuntimeFilesystemFailure { - switch code { - case EACCES, EPERM: return .permissionDenied - case ENOSPC, EDQUOT: return .diskFull - case EROFS: return .readOnlyFilesystem - case EIO: return .io - default: return .other(code) - } + public static func thin(_ error: Error, operation: String, path: String) -> ThinInstallerError { + if let thin = error as? ThinInstallerError { return thin } + return .filesystem(operation: operation, path: path, failure: failure(for: error)) } } diff --git a/macos/Sources/ForgeMenuCore/RuntimeLaunchSecurity.swift b/macos/Sources/ForgeMenuCore/RuntimeLaunchSecurity.swift deleted file mode 100644 index fdef3e8..0000000 --- a/macos/Sources/ForgeMenuCore/RuntimeLaunchSecurity.swift +++ /dev/null @@ -1,322 +0,0 @@ -import Darwin -import Foundation - -public struct RuntimePinnedLaunchHooks: Sendable { - public let beforeFinalIdentityValidation: @Sendable () throws -> Void - - public init( - beforeFinalIdentityValidation: @escaping @Sendable () throws -> Void = {} - ) { - self.beforeFinalIdentityValidation = beforeFinalIdentityValidation - } -} - -/// Pins the executable's parent directory and launches only its relative -/// basename. macOS 13 does not provide fexecve, so the final path identity -/// check and posix_spawn call are deliberately kept in this helper. -struct RuntimePinnedExecutable { - let directoryDescriptor: Int32 - let basename: String - let displayPath: String - let expectedUserID: uid_t - let expectedIdentity: RuntimeExecutableIdentity? - - init( - url: URL, - expectedUserID: uid_t = geteuid(), - expectedIdentity: RuntimeExecutableIdentity? = nil - ) throws { - let executable = url.standardizedFileURL - try Self.validatePathTraversal(to: executable, expectedUserID: expectedUserID) - let directory = executable.deletingLastPathComponent() - let basename = executable.lastPathComponent - guard !basename.isEmpty, - basename != ".", - basename != "..", - !basename.contains("/"), - directory.appendingPathComponent(basename).standardizedFileURL == executable - else { - throw RuntimeInstallerError.untrustedStoreItem("invalid runtime executable path") - } - - let descriptor = open(directory.path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) - guard descriptor >= 0 else { - throw RuntimeFilesystemError.posix( - errno, - operation: "pin runtime executable parent directory", - path: directory.path - ) - } - do { - var info = stat() - guard fstat(descriptor, &info) == 0, - (info.st_mode & S_IFMT) == S_IFDIR, - info.st_uid == expectedUserID, - info.st_mode & 0o022 == 0 - else { - throw RuntimeInstallerError.untrustedStoreItem( - "runtime executable parent directory is not private and user-owned" - ) - } - self.directoryDescriptor = descriptor - self.basename = basename - displayPath = executable.path - self.expectedUserID = expectedUserID - self.expectedIdentity = expectedIdentity - } catch { - Darwin.close(descriptor) - throw error - } - } - - func close() { - Darwin.close(directoryDescriptor) - } - - private static func validatePathTraversal(to executable: URL, expectedUserID: uid_t) throws { - let components = executable.pathComponents.dropFirst().dropLast() - var current = URL(fileURLWithPath: "/", isDirectory: true) - var enteredUserBoundary = false - for component in components { - current.appendPathComponent(component, isDirectory: true) - var info = stat() - guard lstat(current.path, &info) == 0 else { - throw RuntimeFilesystemError.posix( - errno, - operation: "inspect runtime executable path component", - path: current.path - ) - } - if info.st_uid == expectedUserID { enteredUserBoundary = true } - if (info.st_mode & S_IFMT) == S_IFLNK { - if enteredUserBoundary { - throw RuntimeInstallerError.untrustedStoreItem( - "runtime executable path contains a symbolic link at \(current.path)" - ) - } - continue - } - guard (info.st_mode & S_IFMT) == S_IFDIR else { - throw RuntimeInstallerError.untrustedStoreItem( - "runtime executable path component is not a directory at \(current.path)" - ) - } - if enteredUserBoundary, info.st_mode & 0o022 != 0 { - throw RuntimeInstallerError.untrustedStoreItem( - "runtime executable path component is not private at \(current.path)" - ) - } - } - } - - func addDirectoryActions(to actions: inout posix_spawn_file_actions_t?) throws { - try checkPOSIX( - posix_spawn_file_actions_addfchdir_np(&actions, directoryDescriptor), - operation: "pin child working directory" - ) - try checkPOSIX( - posix_spawn_file_actions_addclose(&actions, directoryDescriptor), - operation: "close child pinned-directory descriptor" - ) - } - - func spawn( - pid: inout pid_t, - actions: inout posix_spawn_file_actions_t?, - attributes: inout posix_spawnattr_t?, - argv: inout [UnsafeMutablePointer?], - envp: inout [UnsafeMutablePointer?], - hooks: RuntimePinnedLaunchHooks - ) throws -> Int32 { - try hooks.beforeFinalIdentityValidation() - try validateCurrentFile() - return posix_spawn(&pid, basename, &actions, &attributes, &argv, &envp) - } - - func validateCurrentFile() throws { - var info = stat() - let result = basename.withCString { - fstatat(directoryDescriptor, $0, &info, AT_SYMLINK_NOFOLLOW) - } - guard result == 0, - (info.st_mode & S_IFMT) == S_IFREG, - info.st_uid == expectedUserID, - info.st_nlink == 1, - info.st_mode & 0o022 == 0, - info.st_mode & 0o100 != 0 - else { - throw RuntimeInstallerError.untrustedStoreItem( - "runtime executable is unsafe at the launch boundary" - ) - } - if let expectedIdentity { - guard info.st_size == expectedIdentity.size, - UInt64(info.st_dev) == expectedIdentity.device, - UInt64(info.st_ino) == expectedIdentity.inode - else { - throw RuntimeInstallerError.untrustedStoreItem( - "runtime executable identity changed at the launch boundary" - ) - } - } - } - - private func checkPOSIX(_ result: Int32, operation: String) throws { - guard result == 0 else { - throw RuntimeInstallerError.processFailure( - "could not \(operation): \(String(cString: strerror(result)))" - ) - } - } -} - -public final class RuntimeStoreLease: @unchecked Sendable { - public enum Mode: Sendable { - case sharedExecution - case exclusiveMutation - } - - public final class Token: @unchecked Sendable { - public let descriptor: Int32 - private let lock = NSLock() - private var released = false - - fileprivate init(descriptor: Int32) { - self.descriptor = descriptor - } - - deinit { - release() - } - - public func release() { - lock.lock() - guard !released else { - lock.unlock() - return - } - released = true - lock.unlock() - _ = flock(descriptor, LOCK_UN) - Darwin.close(descriptor) - } - } - - public let rootURL: URL - public var lockURL: URL { rootURL.appendingPathComponent(".execution-lease") } - private static let creationLock = NSLock() - - public init(rootURL: URL) { - self.rootURL = rootURL.standardizedFileURL - } - - public func acquire(_ mode: Mode) throws -> Token { - try ensurePrivateRootExists() - let descriptor = open( - lockURL.path, - O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, - 0o600 - ) - guard descriptor >= 0 else { - throw RuntimeFilesystemError.posix( - errno, - operation: "open runtime store lease", - path: lockURL.path - ) - } - do { - var info = stat() - guard fstat(descriptor, &info) == 0, - (info.st_mode & S_IFMT) == S_IFREG, - info.st_uid == geteuid(), - info.st_nlink == 1 - else { - throw RuntimeInstallerError.untrustedStoreItem("runtime store lease is not a private regular file") - } - guard fchmod(descriptor, 0o600) == 0 else { - throw RuntimeFilesystemError.posix( - errno, - operation: "protect runtime store lease", - path: lockURL.path - ) - } - let operation = mode == .sharedExecution ? LOCK_SH : LOCK_EX - while flock(descriptor, operation) != 0 { - if errno == EINTR { continue } - throw RuntimeFilesystemError.posix( - errno, - operation: "acquire runtime store lease", - path: lockURL.path - ) - } - return Token(descriptor: descriptor) - } catch { - Darwin.close(descriptor) - throw error - } - } - - private func ensurePrivateRootExists() throws { - Self.creationLock.lock() - defer { Self.creationLock.unlock() } - let standardized = rootURL.standardizedFileURL - var current = URL(fileURLWithPath: "/", isDirectory: true) - var enteredUserBoundary = false - for component in standardized.pathComponents.dropFirst() { - current.appendPathComponent(component, isDirectory: true) - var info = stat() - if lstat(current.path, &info) == 0 { - if info.st_uid == geteuid() { enteredUserBoundary = true } - if (info.st_mode & S_IFMT) == S_IFLNK { - if enteredUserBoundary { - throw RuntimeInstallerError.untrustedStoreItem( - "runtime store path contains a symbolic link at \(current.path)" - ) - } - continue - } - guard (info.st_mode & S_IFMT) == S_IFDIR else { - throw RuntimeInstallerError.untrustedStoreItem( - "runtime store path component is not a directory at \(current.path)" - ) - } - if enteredUserBoundary, info.st_mode & 0o022 != 0 { - throw RuntimeInstallerError.untrustedStoreItem( - "runtime store path component is not private at \(current.path)" - ) - } - continue - } - guard errno == ENOENT else { - throw RuntimeFilesystemError.posix( - errno, - operation: "inspect runtime store lease path", - path: current.path - ) - } - guard mkdir(current.path, 0o700) == 0 || errno == EEXIST else { - throw RuntimeFilesystemError.posix( - errno, - operation: "create runtime store lease directory", - path: current.path - ) - } - enteredUserBoundary = true - } - var rootInfo = stat() - guard lstat(rootURL.path, &rootInfo) == 0, - (rootInfo.st_mode & S_IFMT) == S_IFDIR, - rootInfo.st_uid == geteuid(), - rootInfo.st_mode & 0o022 == 0 - else { - throw RuntimeInstallerError.untrustedStoreItem("runtime store root is not private and user-owned") - } - guard chmod(rootURL.path, 0o700) == 0 else { - throw RuntimeFilesystemError.posix( - errno, - operation: "protect runtime store root", - path: rootURL.path - ) - } - } -} diff --git a/macos/Sources/ForgeMenuCore/RuntimeNetwork.swift b/macos/Sources/ForgeMenuCore/RuntimeNetwork.swift deleted file mode 100644 index 7a37a8b..0000000 --- a/macos/Sources/ForgeMenuCore/RuntimeNetwork.swift +++ /dev/null @@ -1,237 +0,0 @@ -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -public typealias RuntimeDownloadProgressHandler = @Sendable (_ receivedBytes: Int64, _ expectedBytes: Int64) async -> Void - -public protocol RuntimeNetworkClient: Sendable { - func download( - _ request: RuntimeDownloadRequest, - progress: @escaping RuntimeDownloadProgressHandler - ) async throws -> RuntimeDownload -} - -public extension RuntimeNetworkClient { - func download(_ request: RuntimeDownloadRequest) async throws -> RuntimeDownload { - try await download(request, progress: { _, _ in }) - } -} - -public struct RuntimeDownloadRequest: Sendable, Equatable { - public let url: URL - public let maximumBytes: Int64 - public let requireContentLength: Bool - public let maximumRedirects: Int - - public init(url: URL, maximumBytes: Int64, requireContentLength: Bool = true, maximumRedirects: Int = 3) { - self.url = url - self.maximumBytes = maximumBytes - self.requireContentLength = requireContentLength - self.maximumRedirects = maximumRedirects - } -} - -public struct RuntimeDownload: Sendable, Equatable { - public let data: Data - public let responseURL: URL - - public init(data: Data, responseURL: URL) { - self.data = data - self.responseURL = responseURL - } -} - -/// Production downloader. Tests inject a URLSessionConfiguration whose protocol -/// classes provide deterministic transport while exercising this exact client. -public final class URLSessionRuntimeNetworkClient: NSObject, RuntimeNetworkClient, @unchecked Sendable { - private final class Delegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { - private let lock = NSLock() - private var redirectCount = 0 - private var redirectError: RuntimeInstallerError? - let maximumRedirects: Int - - init(maximumRedirects: Int) { - self.maximumRedirects = maximumRedirects - } - - func urlSession( - _ session: URLSession, - task: URLSessionTask, - willPerformHTTPRedirection response: HTTPURLResponse, - newRequest request: URLRequest, - completionHandler: @escaping (URLRequest?) -> Void - ) { - let error: RuntimeInstallerError? = lock.withLock { - redirectCount += 1 - if redirectCount > maximumRedirects { return .tooManyRedirects } - guard Self.isAllowed(request.url) else { - return .unsafeRedirect(request.url?.absoluteString ?? "") - } - return nil - } - if let error { - lock.withLock { redirectError = error } - completionHandler(nil) - } else { - completionHandler(request) - } - } - - func consumeRedirectError() -> RuntimeInstallerError? { - lock.withLock { - defer { redirectError = nil } - return redirectError - } - } - - static func isAllowed(_ url: URL?) -> Bool { - guard let url, - url.scheme?.lowercased() == "https", - url.host?.lowercased() == RuntimeReleaseURLs.origin.host, - url.user == nil, - url.password == nil, - url.port == nil, - url.fragment == nil - else { return false } - return true - } - } - - private let baseConfiguration: URLSessionConfiguration - private let requestTimeout: TimeInterval - private let resourceTimeout: TimeInterval - - public override convenience init() { - self.init(configuration: .ephemeral) - } - - public init( - configuration: URLSessionConfiguration, - requestTimeout: TimeInterval = 30, - resourceTimeout: TimeInterval = 120 - ) { - baseConfiguration = configuration - self.requestTimeout = requestTimeout - self.resourceTimeout = resourceTimeout - super.init() - } - - public func download( - _ request: RuntimeDownloadRequest, - progress: @escaping RuntimeDownloadProgressHandler - ) async throws -> RuntimeDownload { - guard request.maximumBytes > 0, - request.maximumRedirects >= 0, - Delegate.isAllowed(request.url) - else { throw RuntimeInstallerError.invalidURL(request.url.absoluteString) } - - let delegate = Delegate(maximumRedirects: request.maximumRedirects) - guard let configuration = baseConfiguration.copy() as? URLSessionConfiguration else { - throw RuntimeInstallerError.network("could not create an isolated URL session") - } - configuration.httpCookieStorage = nil - configuration.urlCache = nil - configuration.requestCachePolicy = .reloadIgnoringLocalCacheData - configuration.httpShouldSetCookies = false - configuration.timeoutIntervalForRequest = requestTimeout - configuration.timeoutIntervalForResource = resourceTimeout - let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) - defer { session.invalidateAndCancel() } - - var urlRequest = URLRequest(url: request.url) - urlRequest.httpMethod = "GET" - urlRequest.setValue("application/octet-stream", forHTTPHeaderField: "Accept") - urlRequest.setValue("identity", forHTTPHeaderField: "Accept-Encoding") - - do { - let (bytes, response) = try await session.bytes(for: urlRequest) - if let redirectError = delegate.consumeRedirectError() { throw redirectError } - guard let response = response as? HTTPURLResponse else { - throw RuntimeInstallerError.network("non-HTTP response") - } - guard response.statusCode == 200 else { - throw RuntimeInstallerError.invalidHTTPStatus(response.statusCode) - } - guard Delegate.isAllowed(response.url) else { - throw RuntimeInstallerError.unsafeRedirect(response.url?.absoluteString ?? "") - } - - let declaredLength = try Self.validatedContentLength( - response.value(forHTTPHeaderField: "Content-Length"), - required: request.requireContentLength - ) - if let declaredLength, declaredLength > request.maximumBytes { - throw RuntimeInstallerError.responseTooLarge(limit: request.maximumBytes) - } - - try Task.checkCancellation() - var data = Data() - if let declaredLength, declaredLength <= Int64(Int.max) { - data.reserveCapacity(Int(declaredLength)) - await progress(0, declaredLength) - try Task.checkCancellation() - } - var lastReportedBucket: Int64 = 0 - for try await byte in bytes { - try Task.checkCancellation() - guard Int64(data.count) < request.maximumBytes else { - throw RuntimeInstallerError.responseTooLarge(limit: request.maximumBytes) - } - data.append(byte) - if let declaredLength { - let received = Int64(data.count) - guard received <= declaredLength else { - throw RuntimeInstallerError.invalidContentLength( - "declared \(declaredLength), received more than declared" - ) - } - let bucket = min(100, received * 100 / max(1, declaredLength)) - if bucket > lastReportedBucket || received == declaredLength { - lastReportedBucket = bucket - await progress(received, declaredLength) - try Task.checkCancellation() - } - } - } - try Task.checkCancellation() - if let declaredLength, declaredLength != Int64(data.count) { - throw RuntimeInstallerError.invalidContentLength("declared \(declaredLength), received \(data.count)") - } - try Task.checkCancellation() - return RuntimeDownload(data: data, responseURL: response.url ?? request.url) - } catch is CancellationError { - throw RuntimeInstallerError.cancelled - } catch let error as RuntimeInstallerError { - throw error - } catch let error as URLError where error.code == .cancelled && Task.isCancelled { - throw RuntimeInstallerError.cancelled - } catch let error as URLError where error.code == .timedOut { - if let redirectError = delegate.consumeRedirectError() { throw redirectError } - throw RuntimeInstallerError.networkTimeout - } catch { - if let redirectError = delegate.consumeRedirectError() { throw redirectError } - throw RuntimeInstallerError.network(error.localizedDescription) - } - } - - private static func validatedContentLength(_ value: String?, required: Bool) throws -> Int64? { - guard let value else { - if required { throw RuntimeInstallerError.missingContentLength } - return nil - } - guard !value.isEmpty, - value.allSatisfy({ $0.isASCII && $0.isNumber }), - let parsed = Int64(value) - else { throw RuntimeInstallerError.invalidContentLength(value) } - return parsed - } -} - -private extension NSLock { - func withLock(_ body: () throws -> T) rethrows -> T { - lock() - defer { unlock() } - return try body() - } -} diff --git a/macos/Sources/ForgeMenuCore/RuntimeProcess.swift b/macos/Sources/ForgeMenuCore/RuntimeProcess.swift deleted file mode 100644 index ef4e643..0000000 --- a/macos/Sources/ForgeMenuCore/RuntimeProcess.swift +++ /dev/null @@ -1,570 +0,0 @@ -import Darwin -import Foundation - -public struct RuntimeProcessResult: Sendable, Equatable { - public let status: Int32 - public let stdout: Data - public let stderr: Data - public let standardOutputLimitExceeded: Bool - - public init( - status: Int32, - stdout: Data = Data(), - stderr: Data = Data(), - standardOutputLimitExceeded: Bool = false - ) { - self.status = status - self.stdout = stdout - self.stderr = stderr - self.standardOutputLimitExceeded = standardOutputLimitExceeded - } -} - -public protocol RuntimeProcessRunning: Sendable { - func run( - executable: URL, - arguments: [String], - standardOutput: URL?, - maximumStandardOutputBytes: Int, - timeout: TimeInterval, - terminationGracePeriod: TimeInterval - ) async throws -> RuntimeProcessResult -} - -public protocol RuntimeIdentityPinnedProcessRunning: RuntimeProcessRunning { - func run( - executable: URL, - arguments: [String], - standardOutput: URL?, - maximumStandardOutputBytes: Int, - timeout: TimeInterval, - terminationGracePeriod: TimeInterval, - expectedIdentity: RuntimeExecutableIdentity? - ) async throws -> RuntimeProcessResult -} - -public extension RuntimeProcessRunning { - func run( - executable: URL, - arguments: [String], - standardOutput: URL?, - maximumStandardOutputBytes: Int, - timeout: TimeInterval, - terminationGracePeriod: TimeInterval, - expectedIdentity: RuntimeExecutableIdentity? - ) async throws -> RuntimeProcessResult { - if let runner = self as? any RuntimeIdentityPinnedProcessRunning { - return try await runner.run( - executable: executable, - arguments: arguments, - standardOutput: standardOutput, - maximumStandardOutputBytes: maximumStandardOutputBytes, - timeout: timeout, - terminationGracePeriod: terminationGracePeriod, - expectedIdentity: expectedIdentity - ) - } - return try await run( - executable: executable, - arguments: arguments, - standardOutput: standardOutput, - maximumStandardOutputBytes: maximumStandardOutputBytes, - timeout: timeout, - terminationGracePeriod: terminationGracePeriod - ) - } - - func run( - executable: URL, - arguments: [String], - standardOutput: URL?, - maximumStandardOutputBytes: Int - ) async throws -> RuntimeProcessResult { - try await run( - executable: executable, - arguments: arguments, - standardOutput: standardOutput, - maximumStandardOutputBytes: maximumStandardOutputBytes, - timeout: 60, - terminationGracePeriod: 1 - ) - } -} - -public struct POSIXRuntimeProcessRunner: RuntimeIdentityPinnedProcessRunning { - private let launchHooks: RuntimePinnedLaunchHooks - - public init(launchHooks: RuntimePinnedLaunchHooks = RuntimePinnedLaunchHooks()) { - self.launchHooks = launchHooks - } - - public func run( - executable: URL, - arguments: [String], - standardOutput: URL? = nil, - maximumStandardOutputBytes: Int = 1_024 * 1_024, - timeout: TimeInterval = 60, - terminationGracePeriod: TimeInterval = 1 - ) async throws -> RuntimeProcessResult { - try await run( - executable: executable, - arguments: arguments, - standardOutput: standardOutput, - maximumStandardOutputBytes: maximumStandardOutputBytes, - timeout: timeout, - terminationGracePeriod: terminationGracePeriod, - expectedIdentity: nil - ) - } - - public func run( - executable: URL, - arguments: [String], - standardOutput: URL? = nil, - maximumStandardOutputBytes: Int = 1_024 * 1_024, - timeout: TimeInterval = 60, - terminationGracePeriod: TimeInterval = 1, - expectedIdentity: RuntimeExecutableIdentity? - ) async throws -> RuntimeProcessResult { - try Task.checkCancellation() - guard maximumStandardOutputBytes >= 0, - timeout.isFinite, timeout > 0, - terminationGracePeriod.isFinite, terminationGracePeriod >= 0 else { - throw RuntimeInstallerError.processFailure("invalid process limits") - } - - let selectedExecutable = executable.standardizedFileURL - let pinnedExecutable = try expectedIdentity.map { - try RuntimePinnedExecutable( - url: selectedExecutable, - expectedIdentity: $0 - ) - } - defer { pinnedExecutable?.close() } - var stdoutDescriptors = [Int32](repeating: 0, count: 2) - guard pipe(&stdoutDescriptors) == 0 else { - throw RuntimeInstallerError.processFailure("could not create stdout pipe: \(String(cString: strerror(errno)))") - } - var stderrDescriptors = [Int32](repeating: 0, count: 2) - guard pipe(&stderrDescriptors) == 0 else { - close(stdoutDescriptors[0]) - close(stdoutDescriptors[1]) - throw RuntimeInstallerError.processFailure("could not create stderr pipe: \(String(cString: strerror(errno)))") - } - let stdoutRead = stdoutDescriptors[0] - let stdoutWrite = stdoutDescriptors[1] - let stderrRead = stderrDescriptors[0] - let stderrWrite = stderrDescriptors[1] - - var actions: posix_spawn_file_actions_t? - guard posix_spawn_file_actions_init(&actions) == 0 else { - Self.closeDescriptors([stdoutRead, stdoutWrite, stderrRead, stderrWrite]) - throw RuntimeInstallerError.processFailure("could not initialize process file actions") - } - defer { posix_spawn_file_actions_destroy(&actions) } - do { - if let pinnedExecutable { - try pinnedExecutable.addDirectoryActions(to: &actions) - } - try Self.checkPOSIX( - posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0), - operation: "redirect stdin" - ) - try Self.checkPOSIX( - posix_spawn_file_actions_adddup2(&actions, stdoutWrite, STDOUT_FILENO), - operation: "redirect stdout" - ) - try Self.checkPOSIX( - posix_spawn_file_actions_adddup2(&actions, stderrWrite, STDERR_FILENO), - operation: "redirect stderr" - ) - for (descriptor, operation) in [ - (stdoutRead, "close child stdout input"), - (stdoutWrite, "close child stdout output"), - (stderrRead, "close child stderr input"), - (stderrWrite, "close child stderr output") - ] { - try Self.checkPOSIX( - posix_spawn_file_actions_addclose(&actions, descriptor), - operation: operation - ) - } - } catch { - Self.closeDescriptors([stdoutRead, stdoutWrite, stderrRead, stderrWrite]) - throw error - } - - var attributes: posix_spawnattr_t? - guard posix_spawnattr_init(&attributes) == 0 else { - Self.closeDescriptors([stdoutRead, stdoutWrite, stderrRead, stderrWrite]) - throw RuntimeInstallerError.processFailure("could not initialize process attributes") - } - defer { posix_spawnattr_destroy(&attributes) } - do { - try Self.checkPOSIX( - posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP)), - operation: "enable process-group creation" - ) - try Self.checkPOSIX( - posix_spawnattr_setpgroup(&attributes, 0), - operation: "configure process group" - ) - } catch { - Self.closeDescriptors([stdoutRead, stdoutWrite, stderrRead, stderrWrite]) - throw error - } - - let environment = ForgeProcessHost.sanitizedEnvironment( - inherited: ProcessInfo.processInfo.environment, - overrides: ["LANG": "C", "LC_ALL": "C"] - ) - let environmentStrings = environment.keys.sorted().compactMap { key in - environment[key].map { "\(key)=\($0)" } - } - let executableArgument = pinnedExecutable?.basename ?? selectedExecutable.path - var argv = ([executableArgument] + arguments).map { strdup($0) } + [nil] - var envp = environmentStrings.map { strdup($0) } + [nil] - defer { - for pointer in argv where pointer != nil { free(pointer) } - for pointer in envp where pointer != nil { free(pointer) } - } - - var pid: pid_t = 0 - let spawnResult: Int32 - do { - if let pinnedExecutable { - spawnResult = try pinnedExecutable.spawn( - pid: &pid, - actions: &actions, - attributes: &attributes, - argv: &argv, - envp: &envp, - hooks: launchHooks - ) - } else { - spawnResult = posix_spawn( - &pid, - selectedExecutable.path, - &actions, - &attributes, - &argv, - &envp - ) - } - } catch { - close(stdoutWrite) - close(stderrWrite) - close(stdoutRead) - close(stderrRead) - throw error - } - close(stdoutWrite) - close(stderrWrite) - guard spawnResult == 0 else { - close(stdoutRead) - close(stderrRead) - let message = String(cString: strerror(spawnResult)) - if spawnResult == EACCES || spawnResult == EPERM { - throw RuntimeInstallerError.processLaunchDenied(message) - } - throw RuntimeInstallerError.processFailure(message) - } - - let control = POSIXProcessControl(pid: pid, gracePeriod: terminationGracePeriod) - let operation = Task.detached(priority: .utility) { - try Self.collect( - pid: pid, - stdoutDescriptor: stdoutRead, - stderrDescriptor: stderrRead, - standardOutput: standardOutput, - maximumStandardOutputBytes: maximumStandardOutputBytes, - control: control - ) - } - - let outcome = await withTaskCancellationHandler { - await withTaskGroup(of: ProcessRaceOutcome.self) { group in - group.addTask { - do { return .result(.success(try await operation.value)) } - catch { return .result(.failure(error)) } - } - group.addTask { - do { try await Task.sleep(nanoseconds: Self.nanoseconds(timeout)) } - catch { return .cancelled } - control.requestTermination() - return .timedOut - } - let first = await group.next() ?? .cancelled - group.cancelAll() - return first - } - } onCancel: { - control.requestTermination() - } - - switch outcome { - case .result(.success(let result)): - if Task.isCancelled { throw RuntimeInstallerError.cancelled } - return result - case .result(.failure(let error)): - if error is CancellationError || Task.isCancelled { throw RuntimeInstallerError.cancelled } - throw error - case .timedOut: - control.requestTermination() - _ = try? await operation.value - throw RuntimeInstallerError.processTimeout - case .cancelled: - control.requestTermination() - _ = try? await operation.value - throw RuntimeInstallerError.cancelled - } - } - - private static func collect( - pid: pid_t, - stdoutDescriptor: Int32, - stderrDescriptor: Int32, - standardOutput: URL?, - maximumStandardOutputBytes: Int, - control: POSIXProcessControl - ) throws -> RuntimeProcessResult { - let outputHandle: FileHandle? - if let standardOutput { - let descriptor = open( - standardOutput.path, - O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, - 0o600 - ) - guard descriptor >= 0 else { - control.requestTermination() - close(stdoutDescriptor) - close(stderrDescriptor) - throw RuntimeFilesystemError.posix( - errno, - operation: "exclusively create process output file", - path: standardOutput.path - ) - } - outputHandle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) - } else { - outputHandle = nil - } - - let lock = NSLock() - var stdout = Data() - var stderr = Data() - var outputBytes = 0 - var outputLimitExceeded = false - let readers = DispatchGroup() - readers.enter() - DispatchQueue.global(qos: .utility).async { - defer { - try? outputHandle?.close() - close(stdoutDescriptor) - readers.leave() - } - var bytes = [UInt8](repeating: 0, count: 16 * 1_024) - while true { - let reaped = control.isReaped - var descriptor = pollfd(fd: stdoutDescriptor, events: Int16(POLLIN | POLLHUP), revents: 0) - let pollResult = poll(&descriptor, 1, reaped ? 0 : 50) - if pollResult == 0 { - if reaped { return } - continue - } - if pollResult < 0 { - if errno == EINTR { continue } - return - } - let received = read(stdoutDescriptor, &bytes, bytes.count) - if received > 0 { - let accepted = lock.withLock { () -> Int in - let remaining = max(0, maximumStandardOutputBytes - outputBytes) - let count = min(received, remaining) - outputBytes += count - if count < received { outputLimitExceeded = true } - return count - } - if accepted > 0 { - let chunk = Data(bytes.prefix(accepted)) - if let outputHandle { try? outputHandle.write(contentsOf: chunk) } - else { lock.withLock { stdout.append(chunk) } } - } - if accepted < received { control.requestTermination() } - } else if received == 0 { - return - } else if errno != EINTR { - return - } - } - } - readers.enter() - DispatchQueue.global(qos: .utility).async { - defer { - close(stderrDescriptor) - readers.leave() - } - var bytes = [UInt8](repeating: 0, count: 16 * 1_024) - while true { - let reaped = control.isReaped - var descriptor = pollfd(fd: stderrDescriptor, events: Int16(POLLIN | POLLHUP), revents: 0) - let pollResult = poll(&descriptor, 1, reaped ? 0 : 50) - if pollResult == 0 { - if reaped { return } - continue - } - if pollResult < 0 { - if errno == EINTR { continue } - return - } - let received = read(stderrDescriptor, &bytes, bytes.count) - if received > 0 { - lock.withLock { - let remaining = max(0, maximumStandardOutputBytes - stderr.count) - stderr.append(contentsOf: bytes.prefix(min(received, remaining))) - } - } else if received == 0 { - return - } else if errno != EINTR { - return - } - } - } - - var waitStatus: Int32 = 0 - var waitResult: pid_t - repeat { - waitResult = waitpid(pid, &waitStatus, 0) - } while waitResult == -1 && errno == EINTR - guard waitResult == pid else { - let message = String(cString: strerror(errno)) - control.requestTermination() - _ = kill(-pid, SIGKILL) - readers.wait() - throw RuntimeInstallerError.processFailure("could not reap process \(pid): \(message)") - } - - // Mark the PID unavailable for all future signaling before waiting - // for pipe readers. Readers poll this state, so descendants that - // escaped the process group cannot retain our operation indefinitely. - control.markReaped() - readers.wait() - return lock.withLock { - RuntimeProcessResult( - status: Self.exitStatus(waitStatus), - stdout: stdout, - stderr: stderr, - standardOutputLimitExceeded: outputLimitExceeded - ) - } - } - - private static func exitStatus(_ waitStatus: Int32) -> Int32 { - let signal = waitStatus & 0x7f - if signal == 0 { return (waitStatus >> 8) & 0xff } - if signal != 0x7f { return 128 + signal } - return waitStatus - } - - private static func nanoseconds(_ interval: TimeInterval) -> UInt64 { - let value = interval * 1_000_000_000 - return value >= Double(UInt64.max) ? UInt64.max : UInt64(value) - } - - private static func closeDescriptors(_ descriptors: [Int32]) { - descriptors.forEach { close($0) } - } - - private static func checkPOSIX(_ result: Int32, operation: String) throws { - guard result == 0 else { - throw RuntimeInstallerError.processFailure( - "could not \(operation): \(String(cString: strerror(result)))" - ) - } - } -} - -public struct FoundationRuntimeProcessRunner: RuntimeProcessRunning { - public init() {} - - public func run( - executable: URL, - arguments: [String], - standardOutput: URL? = nil, - maximumStandardOutputBytes: Int = 1_024 * 1_024, - timeout: TimeInterval = 60, - terminationGracePeriod: TimeInterval = 1 - ) async throws -> RuntimeProcessResult { - // Keep the compatibility type but use the descriptor-based runner. - // Foundation's blocking FileHandle readers cannot be safely bounded - // when escaped descendants retain inherited pipe descriptors. - try await POSIXRuntimeProcessRunner().run( - executable: executable, - arguments: arguments, - standardOutput: standardOutput, - maximumStandardOutputBytes: maximumStandardOutputBytes, - timeout: timeout, - terminationGracePeriod: terminationGracePeriod - ) - } -} - -private enum ProcessRaceOutcome: @unchecked Sendable { - case result(Result) - case timedOut - case cancelled -} - -final class POSIXProcessControl: @unchecked Sendable { - typealias SignalSender = @Sendable (_ processGroup: pid_t, _ signal: Int32) -> Void - typealias Scheduler = @Sendable (_ delay: TimeInterval, _ action: @escaping @Sendable () -> Void) -> Void - - private let lock = NSLock() - private let pid: pid_t - private let gracePeriod: TimeInterval - private var terminationRequested = false - private var reaped = false - private let sendSignal: SignalSender - private let schedule: Scheduler - - init( - pid: pid_t, - gracePeriod: TimeInterval, - sendSignal: @escaping SignalSender = { processGroup, signal in _ = kill(-processGroup, signal) }, - schedule: @escaping Scheduler = { delay, action in - DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay, execute: action) - } - ) { - self.pid = pid - self.gracePeriod = gracePeriod - self.sendSignal = sendSignal - self.schedule = schedule - } - - var isReaped: Bool { lock.withLock { reaped } } - - func requestTermination() { - let requested = lock.withLock { () -> Bool in - guard !terminationRequested, !reaped else { return false } - terminationRequested = true - sendSignal(pid, SIGTERM) - return true - } - guard requested else { return } - schedule(gracePeriod) { [self] in - lock.withLock { - if !reaped { sendSignal(pid, SIGKILL) } - } - } - } - - func markReaped() { lock.withLock { reaped = true } } -} - -private extension NSLock { - func withLock(_ body: () throws -> T) rethrows -> T { - lock() - defer { unlock() } - return try body() - } -} diff --git a/macos/Sources/ForgeMenuCore/RuntimeStore.swift b/macos/Sources/ForgeMenuCore/RuntimeStore.swift deleted file mode 100644 index d79aeed..0000000 --- a/macos/Sources/ForgeMenuCore/RuntimeStore.swift +++ /dev/null @@ -1,653 +0,0 @@ -import Darwin -import Foundation - -public struct RuntimeStoreReceipt: Codable, Equatable, Sendable { - public static let format = 1 - - public let formatVersion: Int - public let version: RuntimeReleaseVersion - public let architecture: RuntimeArchitecture - public let archiveSHA256: String - public let executableSHA256: String - - public init( - version: RuntimeReleaseVersion, - architecture: RuntimeArchitecture, - archiveSHA256: String, - executableSHA256: String - ) { - formatVersion = Self.format - self.version = version - self.architecture = architecture - self.archiveSHA256 = archiveSHA256 - self.executableSHA256 = executableSHA256 - } -} - -public protocol RuntimeStoreManaging: Sendable { - func cached(version: RuntimeReleaseVersion, architecture: RuntimeArchitecture) throws -> InstalledRuntime? - func current(architecture: RuntimeArchitecture) throws -> InstalledRuntime? - func makePrivateTemporaryDirectory() throws -> URL - func installStagedRuntime( - executableURL: URL, - receipt: RuntimeStoreReceipt, - temporaryDirectory: URL - ) throws -> InstalledRuntime -} - -public final class RuntimeStore: RuntimeStoreManaging, @unchecked Sendable { - private static let mutationLock = NSRecursiveLock() - public struct CommitHooks: Sendable { - public let afterDestinationMove: @Sendable () throws -> Void - public let beforeActivationRename: @Sendable () throws -> Void - - public init( - afterDestinationMove: @escaping @Sendable () throws -> Void = {}, - beforeActivationRename: @escaping @Sendable () throws -> Void = {} - ) { - self.afterDestinationMove = afterDestinationMove - self.beforeActivationRename = beforeActivationRename - } - } - - public let rootURL: URL - private let fileManager: FileManager - private let validator: any RuntimeExecutableValidating - private let expectedUserID: uid_t - private let commitHooks: CommitHooks - private let lease: RuntimeStoreLease - private let encoder: JSONEncoder - private let decoder: JSONDecoder - private let recoveryLock = NSLock() - private var completedColdRecovery = false - private var storeLockDepth = 0 - private var storeLeaseToken: RuntimeStoreLease.Token? - - public init( - rootURL: URL, - fileManager: FileManager = .default, - validator: any RuntimeExecutableValidating = MachORuntimeValidator(), - expectedUserID: uid_t = geteuid(), - commitHooks: CommitHooks = CommitHooks(), - lease: RuntimeStoreLease? = nil - ) { - self.rootURL = rootURL.standardizedFileURL - self.fileManager = fileManager - self.validator = validator - self.expectedUserID = expectedUserID - self.commitHooks = commitHooks - self.lease = lease ?? RuntimeStoreLease(rootURL: self.rootURL) - encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - decoder = JSONDecoder() - } - - public static func defaultRoot( - libraryDirectory: URL? = nil, - fileManager: FileManager = .default - ) -> URL { - let library = libraryDirectory ?? fileManager.urls(for: .libraryDirectory, in: .userDomainMask)[0] - return library.appendingPathComponent("Application Support/ForgeCode/runtime", isDirectory: true) - } - - public func cached(version: RuntimeReleaseVersion, architecture: RuntimeArchitecture) throws -> InstalledRuntime? { - try withStoreLock { - try ensureRoot() - let versionDirectory = versionsURL.appendingPathComponent(version.rawValue, isDirectory: true) - try ensureManagedDirectory(versionDirectory, recoverCorrupt: true) - let directory = versionDirectory.appendingPathComponent(architecture.rawValue, isDirectory: true) - return try validateOrRecover( - directory: directory, - expectedVersion: version, - expectedArchitecture: architecture - ) - } - } - - public func current(architecture: RuntimeArchitecture) throws -> InstalledRuntime? { - try withStoreLock { - try ensureRoot() - let pointer = currentPointerURL - guard try itemExistsNoFollow(pointer) else { - return try recoverCurrent(architecture: architecture) - } - do { - let data = try readRegularFileNoFollow(pointer, maximumBytes: 128, permissionsMustBePrivate: true) - guard var value = String(data: data, encoding: .utf8) else { - throw RuntimeInstallerError.untrustedStoreItem("invalid current pointer") - } - if value.hasSuffix("\n") { value.removeLast() } - guard !value.contains("\n"), !value.contains("\r"), let version = RuntimeReleaseVersion(rawValue: value) else { - throw RuntimeInstallerError.untrustedStoreItem("current pointer is not an exact release version") - } - guard let runtime = try cached(version: version, architecture: architecture) else { - try removeItemNoFollow(pointer) - return try recoverCurrent(architecture: architecture) - } - return runtime - } catch let error as RuntimeInstallerError { - if case .untrustedStoreItem = error { - try removeItemNoFollow(pointer) - return try recoverCurrent(architecture: architecture) - } - throw error - } - } - } - - public func makePrivateTemporaryDirectory() throws -> URL { - try withStoreLock { - try ensureRoot() - let template = temporaryURL.appendingPathComponent("install.XXXXXX").path - var bytes = Array(template.utf8CString) - guard mkdtemp(&bytes) != nil else { - throw RuntimeFilesystemError.posix(errno, operation: "create private runtime staging directory", path: template) - } - let path = String(cString: bytes) - guard chmod(path, 0o700) == 0 else { - try? fileManager.removeItem(atPath: path) - throw RuntimeFilesystemError.posix(errno, operation: "protect runtime staging directory", path: path) - } - return URL(fileURLWithPath: path, isDirectory: true) - } - } - - public func installStagedRuntime( - executableURL: URL, - receipt: RuntimeStoreReceipt, - temporaryDirectory: URL - ) throws -> InstalledRuntime { - try withStoreLock { - try ensureRoot() - try checkCancellation() - let standardizedTemporary = temporaryDirectory.standardizedFileURL - let standardizedExecutable = executableURL.standardizedFileURL - guard standardizedTemporary.deletingLastPathComponent() == temporaryURL.standardizedFileURL, - standardizedExecutable == standardizedTemporary.appendingPathComponent("forge3") - else { throw RuntimeInstallerError.untrustedStoreItem("staging path is outside the private runtime store") } - try validatePrivateDirectory(standardizedTemporary, label: "staging directory") - try validateRegularFileNoFollow(standardizedExecutable, label: "staged executable") - let executableHash = try RuntimeSHA256.hexDigest(ofFile: standardizedExecutable) - guard executableHash == receipt.executableSHA256 else { - throw RuntimeInstallerError.checksumMismatch(expected: receipt.executableSHA256, actual: executableHash) - } - try validator.validate(executableURL: standardizedExecutable, expectedArchitecture: receipt.architecture) - try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: standardizedExecutable.path) - - let receiptURL = standardizedTemporary.appendingPathComponent("receipt.json") - try encoder.encode(receipt).write(to: receiptURL, options: .atomic) - try validateRegularFileNoFollow(receiptURL, label: "staged receipt") - try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: receiptURL.path) - - let versionDirectory = versionsURL.appendingPathComponent(receipt.version.rawValue, isDirectory: true) - try ensureManagedDirectory(versionDirectory, recoverCorrupt: true) - let destination = versionDirectory.appendingPathComponent(receipt.architecture.rawValue, isDirectory: true) - - if try itemExistsNoFollow(destination) { - if let existing = try validateOrRecover( - directory: destination, - expectedVersion: receipt.version, - expectedArchitecture: receipt.architecture - ) { - try removeItemNoFollow(standardizedTemporary) - try activate(receipt.version) - return existing - } - } - - try checkCancellation() - var movedIntoStore = false - do { - try fileManager.moveItem(at: standardizedTemporary, to: destination) - movedIntoStore = true - try commitHooks.afterDestinationMove() - try checkCancellation() - try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: destination.path) - guard let installed = try validate( - directory: destination, - expectedVersion: receipt.version, - expectedArchitecture: receipt.architecture - ) else { - throw RuntimeInstallerError.untrustedStoreItem("staging did not produce a valid runtime") - } - try checkCancellation() - try activate(receipt.version) - return installed - } catch { - if movedIntoStore { try? removeRuntimeDirectoryNoFollow(destination) } - throw error - } - } - } - - private var versionsURL: URL { rootURL.appendingPathComponent("versions", isDirectory: true) } - private var temporaryURL: URL { rootURL.appendingPathComponent("tmp", isDirectory: true) } - private var currentPointerURL: URL { rootURL.appendingPathComponent("current") } - - private func ensureRoot() throws { - try validateManagedPathComponents(for: rootURL) - try validatePrivateCreationBoundary(for: rootURL) - try ensureManagedDirectory(rootURL, recoverCorrupt: false) - try ensureManagedDirectory(versionsURL, recoverCorrupt: false) - try ensureManagedDirectory(temporaryURL, recoverCorrupt: false) - try performColdRecoveryOnce() - } - - /// Validate the closest existing ancestor controlled by the current user. - /// System paths such as `/var -> /private/var` may legitimately contain a - /// root-owned symlink, but no user-controlled component at or below this - /// private boundary may be a symlink or unsafe directory. - private func validatePrivateCreationBoundary(for url: URL) throws { - var candidate = url.deletingLastPathComponent() - while candidate.path != "/", !(try itemExistsNoFollow(candidate)) { - candidate.deleteLastPathComponent() - } - guard candidate.path != "/" else { return } - try validatePrivateDirectory(candidate, label: "runtime store parent") - } - - private func ensureManagedDirectory(_ directory: URL, recoverCorrupt: Bool) throws { - if try itemExistsNoFollow(directory) { - do { - try validatePrivateDirectory(directory, label: "runtime store directory") - } catch { - guard recoverCorrupt else { throw error } - try removeItemNoFollow(directory) - try createPrivateDirectory(directory) - } - } else { - try createPrivateDirectory(directory) - } - try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) - } - - private func createPrivateDirectory(_ directory: URL) throws { - let parent = directory.deletingLastPathComponent() - if directory != rootURL, directory != parent, !(try itemExistsNoFollow(parent)) { - try createPrivateDirectory(parent) - } - do { - try fileManager.createDirectory( - at: directory, - withIntermediateDirectories: false, - attributes: [.posixPermissions: 0o700] - ) - } catch { - throw RuntimeFilesystemError.wrapping(error, operation: "create runtime store directory", path: directory.path) - } - } - - private func validatePrivateDirectory( - _ directory: URL, - label: String, - exactPermissions: mode_t? = nil - ) throws { - var info = stat() - guard lstat(directory.path, &info) == 0 else { - throw RuntimeFilesystemError.posix(errno, operation: "inspect runtime store directory", path: directory.path) - } - guard (info.st_mode & S_IFMT) == S_IFDIR, - info.st_uid == expectedUserID, - info.st_mode & 0o022 == 0, - exactPermissions.map({ info.st_mode & 0o777 == $0 }) ?? true - else { throw RuntimeInstallerError.untrustedStoreItem("insecure \(label) \(directory.path)") } - } - - private func validateRegularFileNoFollow( - _ url: URL, - label: String, - exactPermissions: mode_t? = nil - ) throws { - var info = stat() - guard lstat(url.path, &info) == 0, - (info.st_mode & S_IFMT) == S_IFREG, - info.st_uid == expectedUserID, - info.st_nlink == 1, - info.st_mode & 0o022 == 0, - exactPermissions.map({ info.st_mode & 0o777 == $0 }) ?? true - else { throw RuntimeInstallerError.untrustedStoreItem("unsafe \(label) \(url.path)") } - } - - private func validateOrRecover( - directory: URL, - expectedVersion: RuntimeReleaseVersion, - expectedArchitecture: RuntimeArchitecture - ) throws -> InstalledRuntime? { - guard try itemExistsNoFollow(directory) else { return nil } - do { - return try validate( - directory: directory, - expectedVersion: expectedVersion, - expectedArchitecture: expectedArchitecture - ) - } catch { - try removeRuntimeDirectoryNoFollow(directory) - return nil - } - } - - private func validate( - directory: URL, - expectedVersion: RuntimeReleaseVersion, - expectedArchitecture: RuntimeArchitecture - ) throws -> InstalledRuntime? { - guard try itemExistsNoFollow(directory) else { return nil } - try validatePrivateDirectory( - directory, - label: "runtime directory", - exactPermissions: 0o700 - ) - let contents = try fileManager.contentsOfDirectory(atPath: directory.path) - guard Set(contents) == Set(["forge3", "receipt.json"]) else { - throw RuntimeInstallerError.untrustedStoreItem("runtime directory contains unexpected files") - } - - let executable = directory.appendingPathComponent("forge3") - let receiptURL = directory.appendingPathComponent("receipt.json") - try validateManagedExecutable(executable) - try validateRegularFileNoFollow( - receiptURL, - label: "runtime receipt", - exactPermissions: 0o600 - ) - - let data = try readRegularFileNoFollow(receiptURL, maximumBytes: 4_096, permissionsMustBePrivate: true) - do { - _ = try decoder.decode(RuntimeStoreReceipt.self, from: data) - } catch { - throw RuntimeInstallerError.untrustedStoreItem("receipt is invalid") - } - return InstalledRuntime( - version: expectedVersion, - architecture: expectedArchitecture, - executableURL: executable - ) - } - - private func validateManagedExecutable(_ executable: URL) throws { - var info = stat() - guard lstat(executable.path, &info) == 0, - (info.st_mode & S_IFMT) == S_IFREG, - info.st_uid == expectedUserID, - info.st_nlink == 1, - info.st_mode & 0o022 == 0, - info.st_mode & 0o100 != 0 - else { - throw RuntimeInstallerError.untrustedStoreItem( - "unsafe runtime executable \(executable.path)" - ) - } - } - - private func activate(_ version: RuntimeReleaseVersion) throws { - try checkCancellation() - let temporaryPointer = rootURL.appendingPathComponent(".current.\(UUID().uuidString)") - defer { try? removeItemNoFollow(temporaryPointer) } - try Data("\(version.rawValue)\n".utf8).write(to: temporaryPointer, options: .withoutOverwriting) - try validateRegularFileNoFollow(temporaryPointer, label: "temporary current pointer") - try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporaryPointer.path) - try commitHooks.beforeActivationRename() - try checkCancellation() - - if try itemExistsNoFollow(currentPointerURL) { - var info = stat() - guard lstat(currentPointerURL.path, &info) == 0 else { - throw RuntimeFilesystemError.posix(errno, operation: "inspect current runtime pointer", path: currentPointerURL.path) - } - if (info.st_mode & S_IFMT) != S_IFREG { - try removeItemNoFollow(currentPointerURL) - } - } - let result = rename(temporaryPointer.path, currentPointerURL.path) - guard result == 0 else { - throw RuntimeFilesystemError.posix(errno, operation: "atomically activate forge3", path: currentPointerURL.path) - } - } - - private func readRegularFileNoFollow( - _ url: URL, - maximumBytes: Int, - permissionsMustBePrivate: Bool - ) throws -> Data { - let descriptor = open(url.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) - guard descriptor >= 0 else { - throw RuntimeInstallerError.untrustedStoreItem("could not safely open \(url.lastPathComponent)") - } - defer { close(descriptor) } - var info = stat() - guard fstat(descriptor, &info) == 0, - (info.st_mode & S_IFMT) == S_IFREG, - info.st_uid == geteuid(), - info.st_nlink == 1, - (!permissionsMustBePrivate || info.st_mode & 0o022 == 0), - info.st_size >= 0, - info.st_size <= maximumBytes - else { throw RuntimeInstallerError.untrustedStoreItem("invalid \(url.lastPathComponent)") } - let dataCount = Int(info.st_size) - var data = Data(count: dataCount) - var total = 0 - while total < dataCount { - let remaining = dataCount - total - let readCount = data.withUnsafeMutableBytes { buffer in - read(descriptor, buffer.baseAddress!.advanced(by: total), remaining) - } - guard readCount > 0 else { - throw RuntimeInstallerError.untrustedStoreItem("truncated \(url.lastPathComponent)") - } - total += readCount - } - return data - } - - private func itemExistsNoFollow(_ url: URL) throws -> Bool { - var info = stat() - if lstat(url.path, &info) == 0 { return true } - if errno == ENOENT { return false } - throw RuntimeFilesystemError.posix(errno, operation: "inspect runtime store item", path: url.path) - } - - private func removeItemNoFollow(_ url: URL) throws { - guard try itemExistsNoFollow(url) else { return } - try fileManager.removeItem(at: url) - } - - private func removeRuntimeDirectoryNoFollow(_ directory: URL) throws { - guard try itemExistsNoFollow(directory) else { return } - var info = stat() - guard lstat(directory.path, &info) == 0, - (info.st_mode & S_IFMT) == S_IFDIR, - info.st_uid == geteuid(), - info.st_mode & 0o022 == 0 - else { - try removeItemNoFollow(directory) - return - } - let directoryDescriptor = open( - directory.path, - O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW - ) - guard directoryDescriptor >= 0 else { - throw RuntimeFilesystemError.posix( - errno, - operation: "open managed runtime directory for removal", - path: directory.path - ) - } - defer { close(directoryDescriptor) } - for name in ["forge3", "receipt.json"] { - var childInfo = stat() - let result = name.withCString { - fstatat(directoryDescriptor, $0, &childInfo, AT_SYMLINK_NOFOLLOW) - } - if result != 0 { - if errno == ENOENT { continue } - throw RuntimeFilesystemError.posix( - errno, - operation: "inspect managed runtime item before removal", - path: directory.appendingPathComponent(name).path - ) - } - guard (childInfo.st_mode & S_IFMT) == S_IFREG, - childInfo.st_uid == geteuid(), - childInfo.st_nlink == 1, - childInfo.st_mode & 0o022 == 0 - else { - throw RuntimeInstallerError.untrustedStoreItem( - "refusing to unseal an unmanaged runtime item at \(directory.appendingPathComponent(name).path)" - ) - } - let childDescriptor = name.withCString { - openat(directoryDescriptor, $0, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) - } - guard childDescriptor >= 0 else { - throw RuntimeFilesystemError.posix( - errno, - operation: "open managed runtime item for removal", - path: directory.appendingPathComponent(name).path - ) - } - let chmodResult = fchmod(childDescriptor, name == "forge3" ? 0o700 : 0o600) - close(childDescriptor) - guard chmodResult == 0 else { - throw RuntimeFilesystemError.posix( - errno, - operation: "unseal managed runtime item for removal", - path: directory.appendingPathComponent(name).path - ) - } - } - guard fchmod(directoryDescriptor, 0o700) == 0 else { - throw RuntimeFilesystemError.posix( - errno, - operation: "unseal managed runtime directory for removal", - path: directory.path - ) - } - try removeItemNoFollow(directory) - } - - private func recoverCurrent(architecture: RuntimeArchitecture) throws -> InstalledRuntime? { - let names: [String] - do { - names = try fileManager.contentsOfDirectory(atPath: versionsURL.path) - } catch { - throw RuntimeFilesystemError.wrapping(error, operation: "enumerate installed runtimes", path: versionsURL.path) - } - let versions = names.compactMap(RuntimeReleaseVersion.init(rawValue:)).sorted(by: >) - for version in versions { - if let runtime = try cached(version: version, architecture: architecture) { - try activate(version) - return runtime - } - } - return nil - } - - private func performColdRecoveryOnce() throws { - recoveryLock.lock() - if completedColdRecovery { - recoveryLock.unlock() - return - } - completedColdRecovery = true - recoveryLock.unlock() - do { - try cleanSafeStaleArtifacts() - } catch { - recoveryLock.lock() - completedColdRecovery = false - recoveryLock.unlock() - throw error - } - } - - private func cleanSafeStaleArtifacts() throws { - let temporaryNames: [String] - do { - temporaryNames = try fileManager.contentsOfDirectory(atPath: temporaryURL.path) - } catch { - throw RuntimeFilesystemError.wrapping(error, operation: "enumerate runtime staging artifacts", path: temporaryURL.path) - } - for name in temporaryNames where name.hasPrefix("install.") { - let artifact = temporaryURL.appendingPathComponent(name) - if try isSafeStaleArtifact(artifact, expectedType: S_IFDIR) { - try removeItemNoFollow(artifact) - } - } - let rootNames: [String] - do { - rootNames = try fileManager.contentsOfDirectory(atPath: rootURL.path) - } catch { - throw RuntimeFilesystemError.wrapping(error, operation: "enumerate runtime store artifacts", path: rootURL.path) - } - for name in rootNames where name.hasPrefix(".current.") { - let artifact = rootURL.appendingPathComponent(name) - if try isSafeStaleArtifact(artifact, expectedType: S_IFREG) { - try removeItemNoFollow(artifact) - } - } - } - - private func isSafeStaleArtifact(_ url: URL, expectedType: mode_t) throws -> Bool { - var info = stat() - guard lstat(url.path, &info) == 0 else { - if errno == ENOENT { return false } - throw RuntimeFilesystemError.posix(errno, operation: "inspect stale runtime artifact", path: url.path) - } - guard (info.st_mode & S_IFMT) == expectedType, - info.st_uid == geteuid(), - info.st_mode & 0o022 == 0 - else { return false } - let staleBefore = Date().addingTimeInterval(-24 * 60 * 60).timeIntervalSince1970 - let modificationTime = TimeInterval(info.st_mtimespec.tv_sec) - + TimeInterval(info.st_mtimespec.tv_nsec) / 1_000_000_000 - return modificationTime <= staleBefore - } - - private func validateManagedPathComponents(for url: URL) throws { - var current = URL(fileURLWithPath: "/", isDirectory: true) - var enteredUserBoundary = false - for component in url.standardizedFileURL.pathComponents.dropFirst() { - current.appendPathComponent(component) - var info = stat() - if lstat(current.path, &info) != 0 { - if errno == ENOENT { break } - throw RuntimeFilesystemError.posix(errno, operation: "inspect runtime path component", path: current.path) - } - if info.st_uid == geteuid() { enteredUserBoundary = true } - if (info.st_mode & S_IFMT) == S_IFLNK, enteredUserBoundary { - throw RuntimeInstallerError.untrustedStoreItem("runtime store path contains a symbolic link at \(current.path)") - } - } - } - - private func withStoreLock(_ body: () throws -> T) throws -> T { - Self.mutationLock.lock() - defer { Self.mutationLock.unlock() } - do { - if storeLockDepth == 0 { - storeLeaseToken = try lease.acquire(.exclusiveMutation) - } - storeLockDepth += 1 - defer { - storeLockDepth -= 1 - if storeLockDepth == 0 { - storeLeaseToken?.release() - storeLeaseToken = nil - } - } - return try body() - } catch { - throw RuntimeFilesystemError.wrapping( - error, - operation: "manage forge3 runtime store", - path: rootURL.path - ) - } - } - - private func checkCancellation() throws { - if Task.isCancelled { throw RuntimeInstallerError.cancelled } - } - -} diff --git a/macos/Sources/ForgeMenuCore/ServiceSupervisor.swift b/macos/Sources/ForgeMenuCore/ServiceSupervisor.swift index 52f0135..1b85bbd 100644 --- a/macos/Sources/ForgeMenuCore/ServiceSupervisor.swift +++ b/macos/Sources/ForgeMenuCore/ServiceSupervisor.swift @@ -220,7 +220,7 @@ public actor ServiceSupervisor { ) } case .failure(let error): - if error is CancellationError || error as? RuntimeInstallerError == .cancelled { return } + if error is CancellationError || error as? ThinInstallerError == .cancelled { return } guard lifecycleIsCurrent(expectedGeneration) else { return } client = nil snapshot.endpoint = nil @@ -532,27 +532,12 @@ public actor ServiceSupervisor { private func installationFailure(_ error: Error) -> RuntimeInstallationFailure { switch error { - case RuntimeInstallerError.network, - RuntimeInstallerError.networkTimeout, - RuntimeInstallerError.invalidHTTPStatus, - RuntimeInstallerError.missingContentLength, - RuntimeInstallerError.invalidContentLength, - RuntimeInstallerError.responseTooLarge, - RuntimeInstallerError.tooManyRedirects, - RuntimeInstallerError.unsafeRedirect: + case ThinInstallerError.download: return .download - case RuntimeInstallerError.checksumMismatch, - RuntimeInstallerError.invalidChecksumSidecar, - RuntimeInstallerError.invalidManifest, - RuntimeInstallerError.malformedArchive, - RuntimeInstallerError.unsafeArchiveEntry, - RuntimeInstallerError.archiveLimitExceeded, - RuntimeInstallerError.missingRuntimeExecutable, - RuntimeInstallerError.duplicateRuntimeExecutable, - RuntimeInstallerError.invalidMachO, - RuntimeInstallerError.wrongArchitecture: + case ThinInstallerError.installerFailed, + ThinInstallerError.missingExecutable: return .verification - case RuntimeInstallerError.filesystem(_, _, let failure): + case ThinInstallerError.filesystem(_, _, let failure): return .filesystem(failure) default: return .other diff --git a/macos/Sources/ForgeRuntimeLeaseTestHelper/main.swift b/macos/Sources/ForgeRuntimeLeaseTestHelper/main.swift deleted file mode 100644 index c6b1ec7..0000000 --- a/macos/Sources/ForgeRuntimeLeaseTestHelper/main.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Darwin -import Foundation -import ForgeMenuCore - -@main -private enum ForgeRuntimeLeaseTestHelper { - static func main() { - guard CommandLine.arguments.count == 5, - let mode = Mode(rawValue: CommandLine.arguments[2]) - else { - FileHandle.standardError.write( - Data("usage: ForgeRuntimeLeaseTestHelper \n".utf8) - ) - exit(64) - } - - do { - let rootURL = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) - let lease = RuntimeStoreLease(rootURL: rootURL) - let token = try lease.acquire(mode.leaseMode) - let markerURL = URL(fileURLWithPath: CommandLine.arguments[3]) - try Data("locked".utf8).write(to: markerURL, options: .withoutOverwriting) - if CommandLine.arguments[4] == "hold" { - while true { pause() } - } - token.release() - } catch { - FileHandle.standardError.write(Data("\(error)\n".utf8)) - exit(1) - } - } - - private enum Mode: String { - case shared - case exclusive - - var leaseMode: RuntimeStoreLease.Mode { - switch self { - case .shared: return .sharedExecution - case .exclusive: return .exclusiveMutation - } - } - } -} diff --git a/macos/Sources/ForgeRuntimeSmokeHelper/main.swift b/macos/Sources/ForgeRuntimeSmokeHelper/main.swift deleted file mode 100644 index 61e6d93..0000000 --- a/macos/Sources/ForgeRuntimeSmokeHelper/main.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Foundation -import ForgeMenuCore - -private struct SmokeResult: Codable { - let succeeded: Bool - let version: String? - let architecture: String? - let executablePath: String? - let cachedRuntimeMatched: Bool? - let error: String? -} - -@main -private enum ForgeRuntimeSmokeHelper { - static func main() async { - guard CommandLine.arguments.count == 3 else { - FileHandle.standardError.write(Data("usage: ForgeRuntimeSmokeHelper \n".utf8)) - exit(64) - } - - let rootURL = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) - let resultURL = URL(fileURLWithPath: CommandLine.arguments[2]) - let result: SmokeResult - let exitStatus: Int32 - do { - let installer = RuntimeInstaller(rootURL: rootURL) - let runtime = try await installer.installLatest() - guard FileManager.default.isExecutableFile(atPath: runtime.executableURL.path) else { - throw RuntimeInstallerError.untrustedStoreItem("installed runtime is not executable") - } - try InstalledRuntimeIdentityValidator().validate(runtime) - - let restartedInstaller = RuntimeInstaller(rootURL: rootURL) - let cached = try await restartedInstaller.installedCurrentRuntime() - guard cached == runtime else { - throw RuntimeInstallerError.untrustedStoreItem("cold-restarted current runtime did not match the activated runtime") - } - if let cached { - try InstalledRuntimeIdentityValidator().validate(cached) - } - result = SmokeResult( - succeeded: true, - version: runtime.version.rawValue, - architecture: runtime.architecture.rawValue, - executablePath: runtime.executableURL.path, - cachedRuntimeMatched: true, - error: nil - ) - exitStatus = 0 - } catch { - result = SmokeResult( - succeeded: false, - version: nil, - architecture: nil, - executablePath: nil, - cachedRuntimeMatched: nil, - error: String(describing: error) - ) - exitStatus = 1 - } - - do { - let data = try JSONEncoder().encode(result) - try data.write(to: resultURL, options: .atomic) - } catch { - FileHandle.standardError.write(Data("could not write smoke result: \(error)\n".utf8)) - exit(74) - } - exit(exitStatus) - } -} From c0c72a343958cb5c9b251e6d1bbc85dee189fd78 Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:18:39 +0530 Subject: [PATCH 2/4] test(macos): replace runtime installer tests with thin installer suite --- .../LiveRuntimeSmokeTests.swift | 144 - .../ProcessIntegrationTests.swift | 287 +- .../RuntimeInstallerTests.swift | 2836 ----------------- .../ServiceSupervisorTests.swift | 220 +- .../ThinInstallerTests.swift | 173 + 5 files changed, 255 insertions(+), 3405 deletions(-) delete mode 100644 macos/Tests/ForgeMenuCoreTests/LiveRuntimeSmokeTests.swift delete mode 100644 macos/Tests/ForgeMenuCoreTests/RuntimeInstallerTests.swift create mode 100644 macos/Tests/ForgeMenuCoreTests/ThinInstallerTests.swift diff --git a/macos/Tests/ForgeMenuCoreTests/LiveRuntimeSmokeTests.swift b/macos/Tests/ForgeMenuCoreTests/LiveRuntimeSmokeTests.swift deleted file mode 100644 index 60bdc0f..0000000 --- a/macos/Tests/ForgeMenuCoreTests/LiveRuntimeSmokeTests.swift +++ /dev/null @@ -1,144 +0,0 @@ -import Foundation -import XCTest -@testable import ForgeMenuCore - -final class LiveRuntimeSmokeTests: XCTestCase { - private struct SmokeResult: Decodable { - let succeeded: Bool - let version: String? - let architecture: String? - let executablePath: String? - let cachedRuntimeMatched: Bool? - let error: String? - } - - func testPublicFirstInstallAndCacheSmoke() throws { - guard ProcessInfo.processInfo.environment["FORGE_LIVE_RUNTIME_SMOKE"] == "1" else { - throw XCTSkip("Set FORGE_LIVE_RUNTIME_SMOKE=1 to exercise the real public runtime endpoint.") - } - - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("forge-live-runtime-smoke.\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) - defer { try? FileManager.default.removeItem(at: directory) } - - let runtimeRoot = directory.appendingPathComponent("runtime", isDirectory: true) - let resultURL = directory.appendingPathComponent("result.json") - let helperURL = try locateSmokeHelper() - let helperAppURL = try makeSmokeHelperApp(in: directory, executableURL: helperURL) - - // LaunchServices establishes an application responsibility context, - // unlike a process spawned by xctest or submitted by xctest to launchd. - // This matches the menu-bar application's production launch context. - let launch = Process() - launch.executableURL = URL(fileURLWithPath: "/usr/bin/open") - launch.arguments = [ - "-n", helperAppURL.path, - "--args", runtimeRoot.path, resultURL.path - ] - let launchError = Pipe() - launch.standardOutput = FileHandle.nullDevice - launch.standardError = launchError - try launch.run() - // Read to EOF before reaping: a full pipe buffer would block the child - // while the parent blocks in waitUntilExit(). - let launchDiagnostic = String( - decoding: launchError.fileHandleForReading.readDataToEndOfFile(), - as: UTF8.self - ) - launch.waitUntilExit() - XCTAssertEqual(launch.terminationStatus, 0, launchDiagnostic) - guard launch.terminationStatus == 0 else { return } - - let deadline = Date().addingTimeInterval(120) - while !FileManager.default.fileExists(atPath: resultURL.path), Date() < deadline { - Thread.sleep(forTimeInterval: 0.1) - } - XCTAssertTrue( - FileManager.default.fileExists(atPath: resultURL.path), - "LaunchServices install/cache smoke helper did not produce a result within 120 seconds" - ) - guard FileManager.default.fileExists(atPath: resultURL.path) else { return } - - let result = try JSONDecoder().decode(SmokeResult.self, from: Data(contentsOf: resultURL)) - guard result.succeeded else { - XCTFail(result.error ?? "smoke helper failed without a diagnostic") - return - } - XCTAssertEqual(result.version, "0.1.191") - XCTAssertEqual(result.architecture, RuntimeArchitecture.native.rawValue) - XCTAssertEqual(result.cachedRuntimeMatched, true) - if let executablePath = result.executablePath { - XCTAssertTrue(FileManager.default.isExecutableFile(atPath: executablePath)) - } else { - XCTFail("smoke helper did not report the installed executable path") - } - } - - private func makeSmokeHelperApp(in directory: URL, executableURL: URL) throws -> URL { - let appURL = directory.appendingPathComponent("ForgeRuntimeSmokeHelper.app", isDirectory: true) - let contentsURL = appURL.appendingPathComponent("Contents", isDirectory: true) - let macOSURL = contentsURL.appendingPathComponent("MacOS", isDirectory: true) - try FileManager.default.createDirectory(at: macOSURL, withIntermediateDirectories: true) - let bundledExecutable = macOSURL.appendingPathComponent("ForgeRuntimeSmokeHelper") - try FileManager.default.copyItem(at: executableURL, to: bundledExecutable) - try FileManager.default.setAttributes( - [.posixPermissions: 0o700], - ofItemAtPath: bundledExecutable.path - ) - let identifier = "dev.forgecode.runtime-smoke.\(UUID().uuidString)" - let info: [String: Any] = [ - "CFBundleDevelopmentRegion": "en", - "CFBundleExecutable": "ForgeRuntimeSmokeHelper", - "CFBundleIdentifier": identifier, - "CFBundleInfoDictionaryVersion": "6.0", - "CFBundleName": "Forge Runtime Smoke Helper", - "CFBundlePackageType": "APPL", - "CFBundleShortVersionString": "1.0", - "CFBundleVersion": "1", - "LSBackgroundOnly": true, - "LSMinimumSystemVersion": "13.0" - ] - let plist = try PropertyListSerialization.data( - fromPropertyList: info, - format: .xml, - options: 0 - ) - try plist.write(to: contentsURL.appendingPathComponent("Info.plist"), options: .atomic) - - // Give LaunchServices a sealed application identity. A loose or - // unsealed executable does not become the responsible application for - // Gatekeeper's launch assessment of the downloaded runtime. - let sign = Process() - sign.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") - sign.arguments = ["--force", "--sign", "-", "--identifier", identifier, appURL.path] - let signError = Pipe() - sign.standardOutput = FileHandle.nullDevice - sign.standardError = signError - try sign.run() - // Read to EOF before reaping: a full pipe buffer would block the child - // while the parent blocks in waitUntilExit(). - let diagnostic = String( - decoding: signError.fileHandleForReading.readDataToEndOfFile(), - as: UTF8.self - ) - sign.waitUntilExit() - guard sign.terminationStatus == 0 else { - throw RuntimeInstallerError.processFailure("could not sign smoke helper app: \(diagnostic)") - } - return appURL - } - - private func locateSmokeHelper() throws -> URL { - let testsURL = Bundle(for: Self.self).bundleURL - let candidates = [ - testsURL.deletingLastPathComponent().appendingPathComponent("ForgeRuntimeSmokeHelper"), - URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - .appendingPathComponent(".build/debug/ForgeRuntimeSmokeHelper") - ] - return try XCTUnwrap( - candidates.first { FileManager.default.isExecutableFile(atPath: $0.path) }, - "Build ForgeRuntimeSmokeHelper before running the live smoke test. Checked: \(candidates.map(\.path))" - ) - } -} diff --git a/macos/Tests/ForgeMenuCoreTests/ProcessIntegrationTests.swift b/macos/Tests/ForgeMenuCoreTests/ProcessIntegrationTests.swift index 61a995c..c6636e9 100644 --- a/macos/Tests/ForgeMenuCoreTests/ProcessIntegrationTests.swift +++ b/macos/Tests/ForgeMenuCoreTests/ProcessIntegrationTests.swift @@ -3,119 +3,7 @@ import Foundation import XCTest @testable import ForgeMenuCore -private struct PermissiveRuntimeIdentityValidator: InstalledRuntimeIdentityValidating { - func validate(_ runtime: InstalledRuntime) throws {} -} - -private struct RejectingRuntimeIdentityValidator: InstalledRuntimeIdentityValidating { - func validate(_ runtime: InstalledRuntime) throws { - throw RuntimeInstallerError.untrustedStoreItem("identity changed") - } -} - final class ProcessIntegrationTests: XCTestCase { - func testProcessHostAcceptsSafeSelfReplacementAtAdjacentLaunchValidation() async throws { - let fixture = try ProcessFixture(script: "#!/bin/sh\nprintf 'original-must-not-run\\n'\nsleep 30\n") - defer { fixture.remove() } - let originalIdentity = try RuntimeExecutableIdentityValidator.capture(fixture.scriptURL) - let runtime = InstalledRuntime( - version: RuntimeReleaseVersion(rawValue: "1.2.3")!, - architecture: .native, - executableURL: fixture.scriptURL, - executableIdentity: originalIdentity - ) - let replacement = fixture.directory.appendingPathComponent("replacement") - try Data("#!/bin/sh\nprintf 'self-replaced-runtime\\n'\nsleep 30\n".utf8).write(to: replacement) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: replacement.path) - let host = ForgeProcessHost( - configuration: .init( - logURL: fixture.logURL, - launchHooks: RuntimePinnedLaunchHooks(beforeFinalIdentityValidation: { - try FileManager.default.removeItem(at: fixture.scriptURL) - try FileManager.default.moveItem(at: replacement, to: fixture.scriptURL) - }) - ), - runtimeIdentityValidator: PermissiveRuntimeIdentityValidator() - ) - - try await host.start(runtime: runtime, endpoint: LoopbackEndpoint(port: 55_444), generation: 1) - try await waitForFile(fixture.logURL, containing: "self-replaced-runtime") - XCTAssertNotEqual(try inode(fixture.scriptURL), originalIdentity.inode) - await host.stop(gracePeriod: 0.1) - let log = try String(contentsOf: fixture.logURL) - XCTAssertTrue(log.contains("self-replaced-runtime")) - XCTAssertFalse(log.contains("original-must-not-run")) - } - - func testProcessHostPinnedParentPreventsAncestorReplacementRedirect() async throws { - let parent = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - let originalDirectory = parent.appendingPathComponent("runtime") - let movedDirectory = parent.appendingPathComponent("runtime-pinned") - let replacementDirectory = parent.appendingPathComponent("runtime-replacement") - try FileManager.default.createDirectory(at: originalDirectory, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: replacementDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: parent) } - let originalExecutable = originalDirectory.appendingPathComponent("forge3") - let replacementExecutable = replacementDirectory.appendingPathComponent("forge3") - let logURL = parent.appendingPathComponent("ancestor.jsonl") - try Data("#!/bin/sh\nprintf 'pinned-original\\n'\nsleep 30\n".utf8).write(to: originalExecutable) - try Data("#!/bin/sh\nprintf 'redirected-replacement\\n'\nsleep 30\n".utf8).write(to: replacementExecutable) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: originalExecutable.path) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: replacementExecutable.path) - let identity = try RuntimeExecutableIdentityValidator.capture(originalExecutable) - let runtime = InstalledRuntime( - version: RuntimeReleaseVersion(rawValue: "1.2.3")!, - architecture: .native, - executableURL: originalExecutable, - executableIdentity: identity - ) - let host = ForgeProcessHost( - configuration: .init( - logURL: logURL, - launchHooks: RuntimePinnedLaunchHooks(beforeFinalIdentityValidation: { - try FileManager.default.moveItem(at: originalDirectory, to: movedDirectory) - try FileManager.default.moveItem(at: replacementDirectory, to: originalDirectory) - }) - ), - runtimeIdentityValidator: PermissiveRuntimeIdentityValidator() - ) - - try await host.start(runtime: runtime, endpoint: LoopbackEndpoint(port: 55_445), generation: 1) - try await waitForFile(logURL, containing: "pinned-original") - await host.stop(gracePeriod: 0.1) - let log = try String(contentsOf: logURL) - XCTAssertTrue(log.contains("pinned-original")) - XCTAssertFalse(log.contains("redirected-replacement")) - } - - func testProcessHostRetainsSharedStoreLeaseUntilServiceIsReaped() async throws { - let fixture = try ProcessFixture(script: "#!/bin/sh\nprintf 'lease-ready\\n'\nsleep 30\n") - defer { fixture.remove() } - let runtimeRoot = fixture.directory.appendingPathComponent("runtime-store") - let lease = RuntimeStoreLease(rootURL: runtimeRoot) - let host = ForgeProcessHost( - configuration: .init(logURL: fixture.logURL), - runtimeIdentityValidator: PermissiveRuntimeIdentityValidator(), - lease: lease - ) - - try await host.start(runtime: fixture.runtime, endpoint: LoopbackEndpoint(port: 55_446), generation: 1) - try await waitForFile(fixture.logURL, containing: "lease-ready") - let exclusiveTask = Task.detached { try lease.acquire(.exclusiveMutation) } - try await Task.sleep(nanoseconds: 100_000_000) - let probeDescriptor = open(lease.lockURL.path, O_RDWR | O_CLOEXEC | O_NOFOLLOW) - XCTAssertGreaterThanOrEqual(probeDescriptor, 0) - if probeDescriptor >= 0 { - XCTAssertEqual(flock(probeDescriptor, LOCK_EX | LOCK_NB), -1) - XCTAssertEqual(errno, EWOULDBLOCK) - close(probeDescriptor) - } - - await host.stop(gracePeriod: 0.1) - let exclusive = try await exclusiveTask.value - exclusive.release() - } - func testProcessHostUsesExactArgumentsEnvironmentAndRedactedLog() async throws { let fixture = try ProcessFixture(script: #""" #!/bin/sh @@ -127,8 +15,7 @@ final class ProcessIntegrationTests: XCTestCase { """#) defer { fixture.remove() } let host = ForgeProcessHost( - configuration: .init(logURL: fixture.logURL), - runtimeIdentityValidator: PermissiveRuntimeIdentityValidator() + configuration: .init(logURL: fixture.logURL) ) let endpoint = LoopbackEndpoint(port: 55_432) @@ -152,8 +39,7 @@ final class ProcessIntegrationTests: XCTestCase { defer { first.remove(); second.remove() } let logURL = first.directory.appendingPathComponent("selected-runtime.jsonl") let host = ForgeProcessHost( - configuration: .init(logURL: logURL), - runtimeIdentityValidator: PermissiveRuntimeIdentityValidator() + configuration: .init(logURL: logURL) ) try await host.start( @@ -192,8 +78,7 @@ final class ProcessIntegrationTests: XCTestCase { configuration: .init( logURL: fixture.logURL, maximumBufferedOutputBytes: 4_096 - ), - runtimeIdentityValidator: PermissiveRuntimeIdentityValidator() + ) ) try await host.start( @@ -213,8 +98,7 @@ final class ProcessIntegrationTests: XCTestCase { let fixture = try ProcessFixture(script: "#!/bin/sh\nprintf 'ready\\n'\nsleep 30\n") defer { fixture.remove() } let host = ForgeProcessHost( - configuration: .init(logURL: fixture.logURL), - runtimeIdentityValidator: PermissiveRuntimeIdentityValidator() + configuration: .init(logURL: fixture.logURL) ) try await host.start( runtime: fixture.runtime, @@ -236,97 +120,24 @@ final class ProcessIntegrationTests: XCTestCase { await host.stop(gracePeriod: 0.1) } - func testProcessHostValidatesRuntimeIdentityBeforeSpawn() async throws { - let fixture = try ProcessFixture(script: "#!/bin/sh\nprintf 'must-not-run\\n'\n") - defer { fixture.remove() } - let host = ForgeProcessHost( - configuration: .init(logURL: fixture.logURL), - runtimeIdentityValidator: RejectingRuntimeIdentityValidator() - ) - - do { - try await host.start( - runtime: fixture.runtime, - endpoint: LoopbackEndpoint(port: 55_439), - generation: 1 - ) - XCTFail("identity validation must reject the process") - } catch { - XCTAssertEqual(error as? RuntimeInstallerError, .untrustedStoreItem("identity changed")) - } - XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.logURL.path)) - } - - func testDefaultProcessValidatorAcceptsSafeCurrentFileWithoutReceiptIdentity() throws { - let fixture = try ProcessFixture(script: "#!/bin/sh\nprintf 'safe\\n'\n") - defer { fixture.remove() } - let validator = InstalledRuntimeIdentityValidator() - let staleIdentity = try RuntimeExecutableIdentityValidator.capture(fixture.scriptURL) - let runtime = InstalledRuntime( - version: RuntimeReleaseVersion(rawValue: "1.2.3")!, - architecture: .arm64, - executableURL: fixture.scriptURL, - executableIdentity: staleIdentity - ) - - let replacement = fixture.directory.appendingPathComponent("replacement") - try Data("#!/bin/sh\nprintf 'new-content-and-inode\\n'\n".utf8).write(to: replacement) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: replacement.path) - try FileManager.default.removeItem(at: fixture.scriptURL) - try FileManager.default.moveItem(at: replacement, to: fixture.scriptURL) - - XCTAssertNotEqual(try inode(fixture.scriptURL), staleIdentity.inode) - XCTAssertNoThrow(try validator.validate(runtime)) - XCTAssertNoThrow(try validator.validate(InstalledRuntime( - version: runtime.version, - architecture: runtime.architecture, - executableURL: runtime.executableURL - ))) - } - - func testDefaultProcessValidatorRejectsUnsafeCurrentFiles() throws { - let fixture = try ProcessFixture(script: "#!/bin/sh\nprintf 'unsafe\\n'\n") - defer { fixture.remove() } + func testProcessHostRejectsMissingExecutable() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let missing = directory.appendingPathComponent("forge3") let runtime = InstalledRuntime( version: RuntimeReleaseVersion(rawValue: "1.2.3")!, architecture: .native, - executableURL: fixture.scriptURL + executableURL: missing ) - let validator = InstalledRuntimeIdentityValidator() - - let symlink = fixture.directory.appendingPathComponent("forge3-symlink") - try FileManager.default.createSymbolicLink(at: symlink, withDestinationURL: fixture.scriptURL) - XCTAssertThrowsError(try validator.validate(InstalledRuntime( - version: runtime.version, - architecture: runtime.architecture, - executableURL: symlink - ))) - - let linkedDirectory = fixture.directory.appendingPathComponent("runtime-link") - let realDirectory = fixture.directory.appendingPathComponent("runtime-real") - try FileManager.default.createDirectory(at: realDirectory, withIntermediateDirectories: false) - let linkedExecutable = realDirectory.appendingPathComponent("forge3") - try Data("#!/bin/sh\nprintf 'linked\\n'\n".utf8).write(to: linkedExecutable) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: linkedExecutable.path) - try FileManager.default.createSymbolicLink(at: linkedDirectory, withDestinationURL: realDirectory) - XCTAssertThrowsError(try validator.validate(InstalledRuntime( - version: runtime.version, - architecture: runtime.architecture, - executableURL: linkedDirectory.appendingPathComponent("forge3") - ))) - - let hardlink = fixture.directory.appendingPathComponent("forge3-hardlink") - try FileManager.default.linkItem(at: fixture.scriptURL, to: hardlink) - XCTAssertThrowsError(try validator.validate(runtime)) - try FileManager.default.removeItem(at: hardlink) - - XCTAssertThrowsError( - try InstalledRuntimeIdentityValidator(expectedUserID: geteuid() &+ 1).validate(runtime) + let host = ForgeProcessHost( + configuration: .init(logURL: directory.appendingPathComponent("forge3.jsonl")) ) - - for mode in [0o720, 0o702, 0o600] { - try FileManager.default.setAttributes([.posixPermissions: mode], ofItemAtPath: fixture.scriptURL.path) - XCTAssertThrowsError(try validator.validate(runtime), "mode \(String(mode, radix: 8))") + do { + try await host.start(runtime: runtime, endpoint: LoopbackEndpoint(port: 55_439), generation: 1) + XCTFail("starting a missing executable must fail") + } catch { + XCTAssertEqual(error as? ForgeCoreError, .missingExecutable(missing.standardizedFileURL.path)) } } @@ -342,8 +153,7 @@ final class ProcessIntegrationTests: XCTestCase { """#) defer { fixture.remove() } let host = ForgeProcessHost( - configuration: .init(logURL: fixture.logURL), - runtimeIdentityValidator: PermissiveRuntimeIdentityValidator() + configuration: .init(logURL: fixture.logURL) ) try await host.start( runtime: fixture.runtime, @@ -360,8 +170,7 @@ final class ProcessIntegrationTests: XCTestCase { defer { first.remove(); second.remove() } let logURL = first.directory.appendingPathComponent("stale-timer.jsonl") let host = ForgeProcessHost( - configuration: .init(logURL: logURL), - runtimeIdentityValidator: PermissiveRuntimeIdentityValidator() + configuration: .init(logURL: logURL) ) try await host.start(runtime: first.runtime, endpoint: LoopbackEndpoint(port: 55_441), generation: 1) try await waitForFile(logURL, containing: "first-ready") @@ -390,8 +199,7 @@ final class ProcessIntegrationTests: XCTestCase { """#) defer { fixture.remove() } let host = ForgeProcessHost( - configuration: .init(logURL: fixture.logURL), - runtimeIdentityValidator: PermissiveRuntimeIdentityValidator() + configuration: .init(logURL: fixture.logURL) ) try await host.start( @@ -405,6 +213,50 @@ final class ProcessIntegrationTests: XCTestCase { XCTAssertLessThan(Date().timeIntervalSince(started), 1.5) } + func testProcessHostKillsWholeProcessGroupLeavingNoOrphans() async throws { + // The child spawns a grandchild in the same process group that ignores + // SIGTERM. Group-kill on stop() must reap the descendant too, and the + // stop must still return within the grace bound. + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let scriptURL = directory.appendingPathComponent("forge3") + let logURL = directory.appendingPathComponent("forge3.jsonl") + let markerURL = directory.appendingPathComponent("grandchild.marker") + let script = """ + #!/bin/sh + ( + trap '' TERM + printf 'gc\\n' > \(markerURL.path) + while :; do sleep 1; done + ) & + printf 'parent-ready\\n' + wait + """ + try Data(script.utf8).write(to: scriptURL) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) + defer { try? FileManager.default.removeItem(at: directory) } + let fixtureRuntime = InstalledRuntime( + version: RuntimeReleaseVersion(rawValue: "1.2.3")!, + architecture: .native, + executableURL: scriptURL + ) + + let host = ForgeProcessHost( + configuration: .init(logURL: logURL) + ) + try await host.start(runtime: fixtureRuntime, endpoint: LoopbackEndpoint(port: 55_450), generation: 1) + try await waitForFile(logURL, containing: "parent-ready") + // Wait for the grandchild to record its presence. + let deadline = Date().addingTimeInterval(10) + while !FileManager.default.fileExists(atPath: markerURL.path), Date() < deadline { + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTAssertTrue(FileManager.default.fileExists(atPath: markerURL.path)) + let started = Date() + await host.stop(gracePeriod: 0.2) + XCTAssertLessThan(Date().timeIntervalSince(started), 2) + } + func testRuntimePathsCanResolveLogsBeforeRuntimeExists() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } @@ -430,14 +282,6 @@ final class ProcessIntegrationTests: XCTestCase { } } -private func inode(_ url: URL) throws -> UInt64 { - let attributes = try FileManager.default.attributesOfItem(atPath: url.path) - guard let value = attributes[.systemFileNumber] as? NSNumber else { - throw RuntimeInstallerError.processFailure("could not read runtime inode") - } - return value.uint64Value -} - private struct ProcessFixture { let directory: URL let scriptURL: URL @@ -446,8 +290,7 @@ private struct ProcessFixture { InstalledRuntime( version: RuntimeReleaseVersion(rawValue: "1.2.3")!, architecture: .native, - executableURL: scriptURL, - executableIdentity: try? RuntimeExecutableIdentityValidator.capture(scriptURL) + executableURL: scriptURL ) } diff --git a/macos/Tests/ForgeMenuCoreTests/RuntimeInstallerTests.swift b/macos/Tests/ForgeMenuCoreTests/RuntimeInstallerTests.swift deleted file mode 100644 index 36b30bb..0000000 --- a/macos/Tests/ForgeMenuCoreTests/RuntimeInstallerTests.swift +++ /dev/null @@ -1,2836 +0,0 @@ -import Darwin -import Foundation -import XCTest -@testable import ForgeMenuCore - -final class RuntimeInstallerTests: XCTestCase { - override func tearDown() { - ScriptedURLProtocol.reset() - super.tearDown() - } - - func testURLSessionNetworkReportsValidatedMonotonicByteProgress() async throws { - let url = RuntimeReleaseURLs.origin.appendingPathComponent("progress") - ScriptedURLProtocol.install([ - url: .response(status: 200, headers: ["Content-Length": "6"], chunks: [Data("ab".utf8), Data("cdef".utf8)]) - ]) - let progress = LockedValues<(Int64, Int64)>() - let download = try await networkClient().download( - RuntimeDownloadRequest(url: url, maximumBytes: 16) - ) { received, expected in - progress.append((received, expected)) - } - - XCTAssertEqual(download.data, Data("abcdef".utf8)) - XCTAssertEqual(progress.values.first?.0, 0) - XCTAssertEqual(progress.values.last?.0, 6) - XCTAssertTrue(progress.values.allSatisfy { $0.1 == 6 }) - XCTAssertEqual(progress.values.map(\.0), progress.values.map(\.0).sorted()) - XCTAssertTrue(zip(progress.values, progress.values.dropFirst()).allSatisfy { $0.0.0 <= $0.1.0 }) - } - - func testURLSessionNetworkThrottlesProgressToABoundedCallbackCount() async throws { - let url = RuntimeReleaseURLs.origin.appendingPathComponent("bounded-progress") - let data = Data(repeating: 7, count: 10_000) - // Progress throttling buckets by percentage of the byte stream, so the - // transport chunking is irrelevant to the assertions; 100-byte chunks - // keep multi-chunk delivery while avoiding 10k delegate round-trips. - let chunks = stride(from: 0, to: data.count, by: 100).map { start in - data.subdata(in: start..() - _ = try await networkClient().download( - RuntimeDownloadRequest(url: url, maximumBytes: 20_000) - ) { received, expected in - progress.append((received, expected)) - } - - XCTAssertLessThanOrEqual(progress.values.count, 102) - XCTAssertEqual(progress.values.first?.0, 0) - XCTAssertEqual(progress.values.last?.0, Int64(data.count)) - } - - func testURLSessionNetworkRejectsHTTPAndContentLengthFailures() async throws { - let cases: [(String, ScriptedURLProtocol.Plan, RuntimeInstallerError)] = [ - ("http", .response(status: 503, headers: ["Content-Length": "0"], chunks: []), .invalidHTTPStatus(503)), - ("missing", .response(status: 200, headers: [:], chunks: [Data("a".utf8)]), .missingContentLength), - ("malformed", .response(status: 200, headers: ["Content-Length": "1x"], chunks: [Data("a".utf8)]), .invalidContentLength("1x")), - ("oversize", .response(status: 200, headers: ["Content-Length": "9"], chunks: []), .responseTooLarge(limit: 8)), - ("short", .response(status: 200, headers: ["Content-Length": "3"], chunks: [Data("ab".utf8)]), .invalidContentLength("declared 3, received 2")) - ] - for (path, plan, expected) in cases { - ScriptedURLProtocol.reset() - let url = RuntimeReleaseURLs.origin.appendingPathComponent(path) - ScriptedURLProtocol.install([url: plan]) - do { - _ = try await networkClient().download(RuntimeDownloadRequest(url: url, maximumBytes: 8)) - XCTFail("expected failure for \(path)") - } catch let error as RuntimeInstallerError { - XCTAssertEqual(error, expected, path) - } - } - } - - func testURLSessionNetworkRejectsUnsafeAndExcessiveRedirects() async throws { - let first = RuntimeReleaseURLs.origin.appendingPathComponent("redirect-one") - let second = RuntimeReleaseURLs.origin.appendingPathComponent("redirect-two") - let final = RuntimeReleaseURLs.origin.appendingPathComponent("redirect-final") - ScriptedURLProtocol.install([ - first: .redirect(second), - second: .redirect(final), - final: .response(status: 200, headers: ["Content-Length": "0"], chunks: []) - ]) - // The scripted transport has to both announce the redirect and end the - // load, so a refused redirect can surface either as the guard's own - // error or as the raw 302 that triggered it. Either way the download - // must not succeed, which is the property that matters here. - do { - _ = try await networkClient().download( - RuntimeDownloadRequest(url: first, maximumBytes: 8, maximumRedirects: 1) - ) - XCTFail("expected redirect limit") - } catch let error as RuntimeInstallerError { - XCTAssertTrue( - error == .tooManyRedirects || error == .invalidHTTPStatus(302), - "unexpected error: \(error)" - ) - } - - ScriptedURLProtocol.reset() - let unsafe = URL(string: "https://example.com/runtime")! - ScriptedURLProtocol.install([first: .redirect(unsafe)]) - do { - _ = try await networkClient().download(RuntimeDownloadRequest(url: first, maximumBytes: 8)) - XCTFail("expected unsafe redirect") - } catch let error as RuntimeInstallerError { - XCTAssertTrue( - error == .unsafeRedirect(unsafe.absoluteString) || error == .invalidHTTPStatus(302), - "unexpected error: \(error)" - ) - } - } - - func testURLSessionNetworkMapsTimeoutAndCancellationAndStopsTransport() async throws { - let timeoutURL = RuntimeReleaseURLs.origin.appendingPathComponent("timeout") - ScriptedURLProtocol.install([timeoutURL: .failure(URLError(.timedOut))]) - do { - _ = try await networkClient().download(RuntimeDownloadRequest(url: timeoutURL, maximumBytes: 8)) - XCTFail("expected timeout") - } catch let error as RuntimeInstallerError { - XCTAssertEqual(error, .networkTimeout) - } - - ScriptedURLProtocol.reset() - let cancelURL = RuntimeReleaseURLs.origin.appendingPathComponent("cancel") - ScriptedURLProtocol.install([cancelURL: .hold]) - let task = Task { - try await networkClient().download(RuntimeDownloadRequest(url: cancelURL, maximumBytes: 8)) - } - await ScriptedURLProtocol.waitUntilStarted(cancelURL) - task.cancel() - do { - _ = try await task.value - XCTFail("expected cancellation") - } catch let error as RuntimeInstallerError { - XCTAssertEqual(error, .cancelled) - } - let transportStopped = await ScriptedURLProtocol.waitUntilStopped(cancelURL) - XCTAssertTrue(transportStopped) - } - - func testReleaseVersionIsExactThreeComponentSemver() { - XCTAssertEqual(RuntimeReleaseVersion(rawValue: "0.1.190")?.rawValue, "0.1.190") - for value in ["v0.1.190", "0.1", "0.1.190.0", "01.1.190", "0.01.190", "0.1.0190", "0.1.190-rc.1", " 0.1.190", "+0.1.190", "0.a.190"] { - XCTAssertNil(RuntimeReleaseVersion(rawValue: value), value) - } - } - - func testReleaseURLsAreFixedVersionedInstallHostURLs() throws { - let version = try XCTUnwrap(RuntimeReleaseVersion(rawValue: "0.1.190")) - XCTAssertEqual( - RuntimeReleaseURLs.archive(version: version, architecture: .arm64).absoluteString, - "https://install.forgecode.dev/server/releases/v0.1.190/forge3-aarch64-apple-darwin.tar.xz" - ) - XCTAssertEqual( - RuntimeReleaseURLs.checksum(version: version, architecture: .x86_64).absoluteString, - "https://install.forgecode.dev/server/releases/v0.1.190/forge3-x86_64-apple-darwin.tar.xz.sha256" - ) - XCTAssertEqual(RuntimeArchitecture.native.rawValue, ProcessInfo.processInfo.machineArchitectureForTest) - } - - func testChecksumSidecarParsingIsStrict() throws { - let filename = RuntimeArchitecture.arm64.archiveName - let hash = String(repeating: "a", count: 64) - XCTAssertEqual(try RuntimeChecksumSidecar.parse(Data("\(hash) *\(filename)\n".utf8), expectedFilename: filename), hash) - XCTAssertEqual(try RuntimeChecksumSidecar.parse(Data("\(hash) *\(filename)\n\n".utf8), expectedFilename: filename), hash) - XCTAssertEqual(try RuntimeChecksumSidecar.parse(Data("\(hash) \(filename)\r\n".utf8), expectedFilename: filename), hash) - - for value in [ - "\(hash) *other.tar.xz\n", - "\(hash.uppercased()) *\(filename)\n", - "\(hash) *\(filename)\nextra\n", - " \(hash) *\(filename)\n", - "\(hash) *../\(filename)\n", - "\(hash) *\(filename) " - ] { - XCTAssertThrowsError(try RuntimeChecksumSidecar.parse(Data(value.utf8), expectedFilename: filename), value) - } - } - - func testSHA256KnownVectorAndFileDigest() throws { - let expected = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" - XCTAssertEqual(RuntimeSHA256.hexDigest(of: Data("abc".utf8)), expected) - let directory = try TemporaryDirectory() - defer { directory.remove() } - let file = directory.url.appendingPathComponent("input") - try Data("abc".utf8).write(to: file) - XCTAssertEqual(try RuntimeSHA256.hexDigest(ofFile: file), expected) - } - - func testMachOValidatorRequiresCoherentExecutableStructureAndArchitecture() throws { - let directory = try TemporaryDirectory() - defer { directory.remove() } - let validator = MachORuntimeValidator() - let arm = directory.url.appendingPathComponent("arm") - try macho(architecture: .arm64).write(to: arm) - XCTAssertNoThrow(try validator.validate(executableURL: arm, expectedArchitecture: .arm64)) - XCTAssertThrowsError(try validator.validate(executableURL: arm, expectedArchitecture: .x86_64)) { - XCTAssertEqual($0 as? RuntimeInstallerError, .wrongArchitecture(expected: .x86_64, actual: "arm64")) - } - - let headerOnly = directory.url.appendingPathComponent("header-only") - var syntheticHeader = macho(architecture: .arm64) - syntheticHeader.removeSubrange(32..() - - let installed = try await installer.installLatest { phase in phases.append(phase) } - - let requestedURLs = await network.requestedURLs - XCTAssertEqual(installed, cached) - XCTAssertEqual(requestedURLs, [], "current cached reuse must issue exactly zero RuntimeNetworkClient requests") - XCTAssertEqual(phases.values, [.resolving, .ready]) - } - - func testInstallerUsesCachedVersionWithZeroNetwork() async throws { - let version = try XCTUnwrap(RuntimeReleaseVersion(rawValue: "1.2.3")) - let cached = InstalledRuntime(version: version, architecture: .arm64, executableURL: URL(fileURLWithPath: "/cached/forge3")) - let store = MockStore(cached: cached) - let network = MockNetwork(responses: [:]) - let installer = RuntimeInstaller( - architecture: .arm64, - dependencies: .init(network: network, store: store, archive: MockArchive(), validator: StubValidator()) - ) - let installed = try await installer.install(version: version) - let requestedURLs = await network.requestedURLs - XCTAssertEqual(installed, cached) - XCTAssertEqual(requestedURLs, [], "versioned cached reuse must issue exactly zero RuntimeNetworkClient requests") - } - - func testInstallerReportsOnlyArchiveByteDerivedDownloadProgress() async throws { - let version = try XCTUnwrap(RuntimeReleaseVersion(rawValue: "1.2.3")) - let archiveData = Data("archive-progress".utf8) - let archiveURL = RuntimeReleaseURLs.archive(version: version, architecture: .arm64) - let checksumURL = RuntimeReleaseURLs.checksum(version: version, architecture: .arm64) - let sidecar = Data("\(RuntimeSHA256.hexDigest(of: archiveData)) *\(RuntimeArchitecture.arm64.archiveName)\n".utf8) - let manifest = Data("{\"version\":\"1.2.3\"}".utf8) - let network = MockNetwork(responses: [ - RuntimeReleaseURLs.latestManifest: manifest, - archiveURL: archiveData, - checksumURL: sidecar - ]) - let installer = RuntimeInstaller( - architecture: .arm64, - dependencies: .init( - network: network, - store: MockStore(), - archive: MockArchive(executable: macho(architecture: .arm64)), - validator: MachORuntimeValidator() - ) - ) - let phases = LockedValues() - _ = try await installer.installLatest { phases.append($0) } - - let downloads = phases.values.compactMap(\.boundedDownloadProgress) - XCTAssertEqual(downloads.first, 0) - XCTAssertEqual(downloads.last, 1) - XCTAssertFalse(downloads.contains(0.15), "progress must never be fabricated") - XCTAssertEqual(downloads, downloads.sorted()) - } - - func testInstallerDownloadsImmutableAssetsVerifiesChecksumAndInstalls() async throws { - let version = try XCTUnwrap(RuntimeReleaseVersion(rawValue: "1.2.3")) - let archiveData = Data("archive-fixture".utf8) - let archiveURL = RuntimeReleaseURLs.archive(version: version, architecture: .arm64) - let checksumURL = RuntimeReleaseURLs.checksum(version: version, architecture: .arm64) - let sidecar = Data("\(RuntimeSHA256.hexDigest(of: archiveData)) *\(RuntimeArchitecture.arm64.archiveName)\n".utf8) - let network = MockNetwork(responses: [archiveURL: archiveData, checksumURL: sidecar]) - let store = MockStore() - let archive = MockArchive(executable: macho(architecture: .arm64)) - let installer = RuntimeInstaller( - architecture: .arm64, - dependencies: .init( - network: network, - store: store, - archive: archive, - validator: MachORuntimeValidator() - ) - ) - let installed = try await installer.install(version: version) - XCTAssertEqual(installed.version, version) - let requestedURLs = await network.requestedURLs - XCTAssertEqual(Set(requestedURLs), Set([archiveURL, checksumURL])) - XCTAssertEqual(store.installCount, 1) - } - - func testInstallerAcceptsAdHocMachOWithoutVersionMarkersOrRunnableVersionCommandAndRejectsValidationFailures() async throws { - let directory = try TemporaryDirectory() - defer { directory.remove() } - let source = directory.url.appendingPathComponent("main.c") - let binary = directory.url.appendingPathComponent("forge3") - try """ - #include - int main(int argc, char **argv) { - return argc > 1 && strcmp(argv[1], "--version") == 0 ? 73 : 0; - } - """.write(to: source, atomically: true, encoding: .utf8) - try runTool("/usr/bin/clang", [source.path, "-o", binary.path]) - try runTool("/usr/bin/codesign", ["--force", "--sign", "-", binary.path]) - let executable = try Data(contentsOf: binary) - XCTAssertNil(executable.range(of: Data("forge3 1.2.3".utf8))) - - func makeInstaller( - archiveData: Data = Data("archive-fixture".utf8), - sidecarChecksum: String? = nil, - executableData: Data = executable - ) throws -> (RuntimeInstaller, MockStore) { - let version = try XCTUnwrap(RuntimeReleaseVersion(rawValue: "1.2.3")) - let archiveURL = RuntimeReleaseURLs.archive(version: version, architecture: .native) - let checksumURL = RuntimeReleaseURLs.checksum(version: version, architecture: .native) - let checksum = sidecarChecksum ?? RuntimeSHA256.hexDigest(of: archiveData) - let store = MockStore() - return ( - RuntimeInstaller( - architecture: .native, - dependencies: .init( - network: MockNetwork(responses: [ - archiveURL: archiveData, - checksumURL: Data("\(checksum) *\(RuntimeArchitecture.native.archiveName)\n".utf8) - ]), - store: store, - archive: MockArchive(executable: executableData), - validator: MachORuntimeValidator(), - signatureInspector: MachORuntimeValidator(), - quarantineManager: RecordingQuarantineManager(quarantined: false) - ) - ), - store - ) - } - - let (installer, store) = try makeInstaller() - let installed = try await installer.install(version: RuntimeReleaseVersion(rawValue: "1.2.3")!) - XCTAssertEqual(installed.version.rawValue, "1.2.3") - XCTAssertEqual(store.installCount, 1) - - let (checksumInstaller, checksumStore) = try makeInstaller( - sidecarChecksum: String(repeating: "0", count: 64) - ) - do { - _ = try await checksumInstaller.install(version: RuntimeReleaseVersion(rawValue: "1.2.3")!) - XCTFail("expected checksum failure") - } catch let error as RuntimeInstallerError { - guard case .checksumMismatch = error else { return XCTFail("unexpected \(error)") } - } - XCTAssertEqual(checksumStore.installCount, 0) - - let (machOInstaller, machOStore) = try makeInstaller(executableData: Data("not-mach-o".utf8)) - do { - _ = try await machOInstaller.install(version: RuntimeReleaseVersion(rawValue: "1.2.3")!) - XCTFail("expected Mach-O failure") - } catch let error as RuntimeInstallerError { - guard case .invalidMachO = error else { return XCTFail("unexpected \(error)") } - } - XCTAssertEqual(machOStore.installCount, 0) - - var corruptSignature = executable - let signatureRange = try XCTUnwrap(machOCodeSignatureRange(corruptSignature)) - corruptSignature[signatureRange.lowerBound + 16] ^= 0xff - let (signatureInstaller, signatureStore) = try makeInstaller(executableData: corruptSignature) - do { - _ = try await signatureInstaller.install(version: RuntimeReleaseVersion(rawValue: "1.2.3")!) - XCTFail("expected signature failure") - } catch let error as RuntimeInstallerError { - guard case .invalidMachO = error else { return XCTFail("unexpected \(error)") } - } - XCTAssertEqual(signatureStore.installCount, 0) - } - - func testInstallerRejectsMalformedManifestAndChecksumEndToEnd() async throws { - let malformedManifestInstaller = RuntimeInstaller( - architecture: .arm64, - dependencies: .init( - network: MockNetwork(responses: [RuntimeReleaseURLs.latestManifest: Data("{\"version\":\"01.2.3\"}".utf8)]), - store: MockStore(), - archive: MockArchive(), - validator: StubValidator() - ) - ) - do { - _ = try await malformedManifestInstaller.installLatest() - XCTFail("expected malformed manifest rejection") - } catch let error as RuntimeInstallerError { - guard case .invalidManifest = error else { return XCTFail("unexpected \(error)") } - } - - let version = RuntimeReleaseVersion(rawValue: "1.2.3")! - let archiveURL = RuntimeReleaseURLs.archive(version: version, architecture: .arm64) - let checksumURL = RuntimeReleaseURLs.checksum(version: version, architecture: .arm64) - let archive = MockArchive() - let malformedChecksumInstaller = RuntimeInstaller( - architecture: .arm64, - dependencies: .init( - network: MockNetwork(responses: [ - archiveURL: Data("archive".utf8), - checksumURL: Data("not-a-checksum\n".utf8) - ]), - store: MockStore(), - archive: archive, - validator: StubValidator() - ) - ) - do { - _ = try await malformedChecksumInstaller.install(version: version) - XCTFail("expected malformed checksum rejection") - } catch let error as RuntimeInstallerError { - guard case .invalidChecksumSidecar = error else { return XCTFail("unexpected \(error)") } - } - let inspectCount = await archive.inspectCount - XCTAssertEqual(inspectCount, 0) - } - - func testInstallerRejectsChecksumMismatchBeforeArchiveInspection() async throws { - let version = try XCTUnwrap(RuntimeReleaseVersion(rawValue: "1.2.3")) - let archiveURL = RuntimeReleaseURLs.archive(version: version, architecture: .arm64) - let checksumURL = RuntimeReleaseURLs.checksum(version: version, architecture: .arm64) - let network = MockNetwork(responses: [ - archiveURL: Data("bad".utf8), - checksumURL: Data("\(String(repeating: "0", count: 64)) *\(RuntimeArchitecture.arm64.archiveName)\n".utf8) - ]) - let archive = MockArchive() - let installer = RuntimeInstaller( - architecture: .arm64, - dependencies: .init(network: network, store: MockStore(), archive: archive, validator: StubValidator()) - ) - do { - _ = try await installer.install(version: version) - XCTFail("expected checksum mismatch") - } catch let error as RuntimeInstallerError { - guard case .checksumMismatch = error else { return XCTFail("unexpected \(error)") } - } - let inspectCount = await archive.inspectCount - XCTAssertEqual(inspectCount, 0) - } - - func testInstallerGloballySerializesDifferentVersionRequests() async throws { - let firstVersion = try XCTUnwrap(RuntimeReleaseVersion(rawValue: "1.2.3")) - let secondVersion = try XCTUnwrap(RuntimeReleaseVersion(rawValue: "1.2.4")) - let network = SerializedInstallNetwork() - let installer = RuntimeInstaller( - architecture: .arm64, - dependencies: .init( - network: network, - store: MockStore(), - archive: MockArchive(executable: macho(architecture: .arm64)), - validator: MachORuntimeValidator() - ) - ) - - let first = Task { try await installer.install(version: firstVersion) } - await network.waitUntilFirstRequestStarted() - let second = Task { try await installer.install(version: secondVersion) } - try await Task.sleep(nanoseconds: 30_000_000) - let requestedBeforeRelease = await network.requestedVersions - XCTAssertEqual(requestedBeforeRelease, [firstVersion]) - await network.releaseFirstVersion() - _ = try await first.value - _ = try await second.value - let maximumConcurrent = await network.maximumConcurrentVersionCount - XCTAssertEqual(maximumConcurrent, 1) - } - - func testInstallerCoalescesConcurrentSameVersionRequests() async throws { - let version = try XCTUnwrap(RuntimeReleaseVersion(rawValue: "1.2.3")) - let archiveData = Data("archive".utf8) - let archiveURL = RuntimeReleaseURLs.archive(version: version, architecture: .arm64) - let checksumURL = RuntimeReleaseURLs.checksum(version: version, architecture: .arm64) - let network = MockNetwork(responses: [ - archiveURL: archiveData, - checksumURL: Data("\(RuntimeSHA256.hexDigest(of: archiveData)) *\(RuntimeArchitecture.arm64.archiveName)\n".utf8) - ], delayNanoseconds: 50_000_000) - let store = MockStore() - let installer = RuntimeInstaller( - architecture: .arm64, - dependencies: .init( - network: network, - store: store, - archive: MockArchive(executable: macho(architecture: .arm64)), - validator: MachORuntimeValidator() - ) - ) - async let firstOperation = installer.install(version: version) - async let secondOperation = installer.install(version: version) - let first = try await firstOperation - let second = try await secondOperation - let requestCount = await network.requestCount - XCTAssertEqual(first, second) - XCTAssertEqual(requestCount, 2) - XCTAssertEqual(store.installCount, 1) - } - - func testCoalescedReplayCannotArriveAfterNewerPublicationOrCompletion() async throws { - let version = try XCTUnwrap(RuntimeReleaseVersion(rawValue: "1.2.3")) - let network = ReplayRaceNetwork(version: version) - let hook = BlockingReplayProgressHook() - let deliveries = LockedValues() - let installer = RuntimeInstaller( - architecture: .arm64, - dependencies: .init( - network: network, - store: MockStore(), - archive: MockArchive(executable: macho(architecture: .arm64)), - validator: MachORuntimeValidator(), - progressDeliveryHook: hook - ) - ) - - let owner = Task { try await installer.installLatest() } - await network.waitUntilManifestRequested() - let joiner = Task { - let runtime = try await installer.installLatest { phase in - deliveries.append("phase:\(phase.testName)") - } - deliveries.append("completed") - return runtime - } - await hook.waitUntilReplayBlocked() - - await network.releaseManifest() - try await Task.sleep(nanoseconds: 30_000_000) - XCTAssertEqual(deliveries.values, [], "completion must wait for the blocked replay stream") - - await hook.releaseReplay() - _ = try await owner.value - _ = try await joiner.value - - let values = deliveries.values - XCTAssertEqual(values.first, "phase:resolving") - XCTAssertEqual(values.last, "completed") - XCTAssertEqual(values.filter { $0 == "phase:resolving" }.count, 1) - let phaseOrder = values.dropLast().compactMap { RuntimeInstallationPhase.testOrder(named: $0) } - XCTAssertEqual(phaseOrder, phaseOrder.sorted()) - let blockedReplayCount = await hook.blockedReplayCount - XCTAssertEqual(blockedReplayCount, 1) - } - - func testCancellingOneCoalescedWaiterDoesNotCancelAnother() async throws { - let version = try XCTUnwrap(RuntimeReleaseVersion(rawValue: "1.2.3")) - let archiveData = Data("archive".utf8) - let archiveURL = RuntimeReleaseURLs.archive(version: version, architecture: .arm64) - let checksumURL = RuntimeReleaseURLs.checksum(version: version, architecture: .arm64) - let network = MockNetwork(responses: [ - archiveURL: archiveData, - checksumURL: Data("\(RuntimeSHA256.hexDigest(of: archiveData)) *\(RuntimeArchitecture.arm64.archiveName)\n".utf8) - ], delayNanoseconds: 100_000_000) - let store = MockStore() - let installer = RuntimeInstaller( - architecture: .arm64, - dependencies: .init( - network: network, - store: store, - archive: MockArchive(executable: macho(architecture: .arm64)), - validator: MachORuntimeValidator() - ) - ) - let cancelled = Task { try await installer.install(version: version) } - let survivor = Task { try await installer.install(version: version) } - try await Task.sleep(nanoseconds: 20_000_000) - cancelled.cancel() - do { - _ = try await cancelled.value - XCTFail("expected cancelled waiter") - } catch {} - let installed = try await survivor.value - let requestCount = await network.requestCount - XCTAssertEqual(installed.version, version) - XCTAssertEqual(requestCount, 2) - XCTAssertEqual(store.installCount, 1) - } - - func testCancelledSoleOperationRemainsRegisteredUntilTerminationBeforeReplacementStarts() async throws { - let firstVersion = try XCTUnwrap(RuntimeReleaseVersion(rawValue: "1.2.3")) - let secondVersion = try XCTUnwrap(RuntimeReleaseVersion(rawValue: "1.2.4")) - let network = CancellationHoldingNetwork(firstVersion: firstVersion) - let installer = RuntimeInstaller( - architecture: .arm64, - dependencies: .init(network: network, store: MockStore(), archive: MockArchive(), validator: StubValidator()) - ) - let first = Task { try await installer.install(version: firstVersion) } - await network.waitUntilFirstVersionStarted() - first.cancel() - do { - _ = try await first.value - XCTFail("expected prompt waiter cancellation") - } catch let error as RuntimeInstallerError { - XCTAssertEqual(error, .cancelled) - } - let replacement = Task { try await installer.install(version: secondVersion) } - try await Task.sleep(nanoseconds: 30_000_000) - let startedBeforeRelease = await network.secondVersionStarted - XCTAssertFalse(startedBeforeRelease) - await network.releaseCancelledFirstVersion() - _ = try? await replacement.value - let startedAfterRelease = await network.secondVersionStarted - XCTAssertTrue(startedAfterRelease) - } - - func testInstallerCancellationPreventsActivation() async throws { - let version = try XCTUnwrap(RuntimeReleaseVersion(rawValue: "1.2.3")) - let network = CancellingNetwork() - let store = MockStore() - let installer = RuntimeInstaller( - architecture: .arm64, - dependencies: .init(network: network, store: store, archive: MockArchive(), validator: StubValidator()) - ) - let task = Task { try await installer.install(version: version) } - await network.waitUntilRequested() - task.cancel() - await network.release() - do { - _ = try await task.value - XCTFail("expected cancellation") - } catch {} - XCTAssertEqual(store.installCount, 0) - } - - func testProductionTemporaryTrustPolicyPredicateIsExactlyAdHocPlusQuarantine() throws { - let identity = RuntimeExecutableIdentity( - device: 1, - inode: 2, - size: 3, - sha256: String(repeating: "a", count: 64) - ) - let policy = TemporaryAdHocRuntimeTrustPolicy() - for signatureClass in [ - RuntimeCodeSignatureClass.unsigned, - .adHoc, - .developerID, - .otherSigned - ] { - for hasQuarantine in [false, true] { - let decision = try policy.decision( - for: RuntimePreExecutionTrustContext( - signatureClass: signatureClass, - hasQuarantine: hasQuarantine, - architecture: .arm64, - executableIdentity: identity - ) - ) - XCTAssertEqual( - decision, - signatureClass == .adHoc && hasQuarantine - ? .refreshRemovingQuarantine - : .preserve - ) - } - } - } - - func testDeveloperIDClassificationRequiresAppleAnchorRequirementNotLookalikeSubject() throws { - let executableURL = URL(fileURLWithPath: "/tmp/forge3-lookalike") - - let anchoredEvaluator = RecordingDeveloperIDRequirementEvaluator(results: [.success(true)]) - let anchoredValidator = MachORuntimeValidator( - developerIDRequirementEvaluator: anchoredEvaluator - ) - XCTAssertEqual( - try anchoredValidator.classifySignature( - flags: 0, - hasCertificates: true, - executableURL: executableURL - ), - .developerID - ) - XCTAssertEqual(anchoredEvaluator.calls.map(\.expectedTeamIdentifier), [nil]) - - let lookalikeEvaluator = RecordingDeveloperIDRequirementEvaluator(results: [.success(false)]) - let lookalikeValidator = MachORuntimeValidator( - developerIDRequirementEvaluator: lookalikeEvaluator - ) - XCTAssertEqual( - try lookalikeValidator.classifySignature( - flags: 0, - hasCertificates: true, - executableURL: executableURL - ), - .otherSigned, - "a self-signed/lookalike certificate subject must not classify as Developer ID" - ) - XCTAssertEqual(lookalikeEvaluator.calls.map(\.expectedTeamIdentifier), [nil]) - } - - func testDeveloperIDClassificationFailsClosedWhenRequirementEvaluationFails() throws { - let expected = RuntimeInstallerError.developerIDAuthenticationFailed( - "injected requirement evaluation failure" - ) - let evaluator = RecordingDeveloperIDRequirementEvaluator(results: [.failure(expected)]) - let validator = MachORuntimeValidator(developerIDRequirementEvaluator: evaluator) - - XCTAssertThrowsError( - try validator.classifySignature( - flags: 0, - hasCertificates: true, - executableURL: URL(fileURLWithPath: "/tmp/forge3") - ) - ) { - XCTAssertEqual($0 as? RuntimeInstallerError, expected) - } - } - - func testDeveloperIDRequirementTextUsesAppleAnchorOIDsAndExactTeamID() throws { - let base = try SecurityRuntimeDeveloperIDRequirementEvaluator.requirementText( - expectedTeamIdentifier: nil - ) - XCTAssertTrue(base.contains("anchor apple generic")) - XCTAssertTrue(base.contains("certificate 1[field.1.2.840.113635.100.6.2.6] exists")) - XCTAssertTrue(base.contains("certificate leaf[field.1.2.840.113635.100.6.1.13] exists")) - XCTAssertFalse(base.contains("subject.OU")) - - let teamBound = try SecurityRuntimeDeveloperIDRequirementEvaluator.requirementText( - expectedTeamIdentifier: "TEAM123456" - ) - XCTAssertTrue(teamBound.contains("certificate leaf[subject.OU] = \"TEAM123456\"")) - XCTAssertThrowsError( - try SecurityRuntimeDeveloperIDRequirementEvaluator.requirementText( - expectedTeamIdentifier: "bad-team" - ) - ) - } - - func testDeveloperIDAuthenticationPolicyRequiresExactConfiguredTeamIdentifier() throws { - let matching = RuntimeCodeSignatureInspection( - signatureClass: .developerID, - teamIdentifier: "TEAM123456", - signingIdentity: "Developer ID Application: Forge Example (TEAM123456)" - ) - XCTAssertNoThrow( - try RuntimeDeveloperIDAuthenticationPolicy( - expectedTeamIdentifier: "TEAM123456" - ).authenticate( - matching, - executableURL: URL(fileURLWithPath: "/tmp/forge3"), - requirementEvaluator: RecordingDeveloperIDRequirementEvaluator(results: [.success(true)]) - ) - ) - - for (policy, signature, expectedError) in [ - ( - RuntimeDeveloperIDAuthenticationPolicy(), - matching, - RuntimeInstallerError.developerIDAuthenticationFailed( - "no expected Developer ID Team Identifier is configured" - ) - ), - ( - RuntimeDeveloperIDAuthenticationPolicy(expectedTeamIdentifier: "TEAM123456"), - RuntimeCodeSignatureInspection( - signatureClass: .developerID, - signingIdentity: "Developer ID Application: Forge Example" - ), - RuntimeInstallerError.developerIDAuthenticationFailed( - "the signature does not contain a Team Identifier" - ) - ), - ( - RuntimeDeveloperIDAuthenticationPolicy(expectedTeamIdentifier: "TEAM123456"), - RuntimeCodeSignatureInspection( - signatureClass: .developerID, - teamIdentifier: "OTHER98765", - signingIdentity: "Developer ID Application: Other (OTHER98765)" - ), - RuntimeInstallerError.developerIDAuthenticationFailed( - "Team Identifier OTHER98765 does not match expected TEAM123456" - ) - ) - ] { - XCTAssertThrowsError( - try policy.authenticate( - signature, - executableURL: URL(fileURLWithPath: "/tmp/forge3"), - requirementEvaluator: RecordingDeveloperIDRequirementEvaluator(results: [.success(true)]) - ) - ) { - XCTAssertEqual($0 as? RuntimeInstallerError, expectedError) - } - } - - for signatureClass in [ - RuntimeCodeSignatureClass.unsigned, - .adHoc, - .otherSigned - ] { - XCTAssertNoThrow( - try RuntimeDeveloperIDAuthenticationPolicy().authenticate( - RuntimeCodeSignatureInspection(signatureClass: signatureClass), - executableURL: URL(fileURLWithPath: "/tmp/forge3"), - requirementEvaluator: RecordingDeveloperIDRequirementEvaluator(results: [.success(true)]) - ) - ) - } - } - - func testDeveloperIDAuthenticationRequirementFailureIsABarrierBeforeTrustPolicy() async throws { - let expected = RuntimeInstallerError.developerIDAuthenticationFailed( - "injected Apple-anchor evaluation failure" - ) - let trustPolicy = RecordingTrustPolicy(decision: .preserve) - let quarantine = RecordingQuarantineManager() - let evaluator = RecordingDeveloperIDRequirementEvaluator(results: [.failure(expected)]) - let fixture = try installerFixture( - quarantineManager: quarantine, - securityLogger: RecordingSecurityLogger(), - signatureInspector: matchingDeveloperIDSignatureInspector(), - trustPolicy: trustPolicy, - developerIDAuthenticationPolicy: matchingDeveloperIDAuthenticationPolicy(), - developerIDRequirementEvaluator: evaluator - ) - - do { - _ = try await fixture.installer.install(version: fixture.version) - XCTFail("expected Developer ID requirement-evaluation failure") - } catch let error as RuntimeInstallerError { - XCTAssertEqual(error, expected) - } - XCTAssertEqual(evaluator.calls.map(\.expectedTeamIdentifier), ["TEAM123456"]) - XCTAssertEqual(trustPolicy.contexts, []) - XCTAssertEqual(quarantine.inspectedURLs, []) - XCTAssertEqual(fixture.store.installCount, 0) - } - - func testDeveloperIDTeamAuthenticationIsABarrierBeforeTrustPolicy() async throws { - let cases: [(String?, String?, RuntimeInstallerError?)] = [ - ("TEAM123456", "TEAM123456", nil), - (nil, "TEAM123456", .developerIDAuthenticationFailed( - "no expected Developer ID Team Identifier is configured" - )), - ("TEAM123456", nil, .developerIDAuthenticationFailed( - "the signature does not contain a Team Identifier" - )), - ("TEAM123456", "OTHER98765", .developerIDAuthenticationFailed( - "Team Identifier OTHER98765 does not match expected TEAM123456" - )) - ] - - for (expectedTeam, actualTeam, expectedError) in cases { - let trustPolicy = RecordingTrustPolicy(decision: .preserve) - let quarantine = RecordingQuarantineManager() - let signingIdentity = "Developer ID Application: Forge Example" - let fixture = try installerFixture( - quarantineManager: quarantine, - securityLogger: RecordingSecurityLogger(), - signatureInspector: RecordingSignatureInspector(inspections: [ - RuntimeCodeSignatureInspection( - signatureClass: .developerID, - teamIdentifier: actualTeam, - signingIdentity: signingIdentity - ) - ]), - trustPolicy: trustPolicy, - developerIDAuthenticationPolicy: RuntimeDeveloperIDAuthenticationPolicy( - expectedTeamIdentifier: expectedTeam - ) - ) - - if let expectedError { - do { - _ = try await fixture.installer.install(version: fixture.version) - XCTFail("expected Developer ID authentication failure") - } catch let error as RuntimeInstallerError { - XCTAssertEqual(error, expectedError) - } - XCTAssertEqual(trustPolicy.contexts, []) - XCTAssertEqual(quarantine.inspectedURLs, []) - XCTAssertEqual(fixture.store.installCount, 0) - } else { - _ = try await fixture.installer.install(version: fixture.version) - XCTAssertEqual(trustPolicy.contexts.count, 1) - XCTAssertEqual(trustPolicy.contexts.first?.signature.teamIdentifier, expectedTeam) - XCTAssertEqual(trustPolicy.contexts.first?.signature.signingIdentity, signingIdentity) - XCTAssertEqual(quarantine.refreshedURLs, []) - XCTAssertEqual(fixture.store.installCount, 1) - } - } - } - - func testPreExecutionTrustPolicyRunsOnlyAfterFullExecutableValidation() async throws { - let validator = RecordingValidator(error: .invalidMachO("rejected before policy")) - let signatureInspector = RecordingSignatureInspector([.adHoc]) - let trustPolicy = RecordingTrustPolicy(decision: .refreshRemovingQuarantine) - let quarantine = RecordingQuarantineManager() - let fixture = try installerFixture( - quarantineManager: quarantine, - securityLogger: RecordingSecurityLogger(), - validator: validator, - signatureInspector: signatureInspector, - trustPolicy: trustPolicy - ) - - do { - _ = try await fixture.installer.install(version: fixture.version) - XCTFail("expected Mach-O validation failure") - } catch let error as RuntimeInstallerError { - XCTAssertEqual(error, .invalidMachO("rejected before policy")) - } - XCTAssertEqual(validator.calls.count, 1) - XCTAssertEqual(signatureInspector.inspectedURLs, []) - XCTAssertEqual(trustPolicy.contexts, []) - XCTAssertEqual(quarantine.inspectedURLs, []) - XCTAssertEqual(quarantine.refreshedURLs, []) - XCTAssertEqual(fixture.store.installCount, 0) - } - - func testTemporaryTrustPolicyRefreshesOnlyAdHocQuarantinedArtifactBeforeInstall() async throws { - let validator = RecordingValidator() - let signatureInspector = RecordingSignatureInspector([.adHoc, .adHoc]) - let trustPolicy = RecordingTrustPolicy(decision: .refreshRemovingQuarantine) - let quarantine = RecordingQuarantineManager() - let securityLogger = RecordingSecurityLogger() - let fixture = try installerFixture( - quarantineManager: quarantine, - securityLogger: securityLogger, - validator: validator, - signatureInspector: signatureInspector, - trustPolicy: trustPolicy - ) - - let installed = try await fixture.installer.install(version: fixture.version) - - XCTAssertEqual(installed.version, fixture.version) - XCTAssertEqual(validator.calls.count, 2) - XCTAssertEqual(signatureInspector.inspectedURLs.count, 2) - XCTAssertEqual(trustPolicy.contexts.count, 1) - XCTAssertEqual(trustPolicy.contexts.first?.signatureClass, .adHoc) - XCTAssertNil(trustPolicy.contexts.first?.signature.teamIdentifier) - XCTAssertNil(trustPolicy.contexts.first?.signature.signingIdentity) - XCTAssertEqual(trustPolicy.contexts.first?.hasQuarantine, true) - XCTAssertEqual(quarantine.refreshedURLs.count, 1) - XCTAssertNotEqual( - quarantine.refreshIdentities.first?.inode, - try inode(quarantine.refreshedURLs[0]) - ) - XCTAssertEqual(securityLogger.events, [.refreshedAdHocQuarantinedStagedExecutable]) - XCTAssertEqual(fixture.store.installCount, 1) - } - - func testTemporaryTrustPolicyPreservesUnquarantinedAndDeveloperIDArtifacts() async throws { - for (signatureClass, quarantined) in [ - (RuntimeCodeSignatureClass.adHoc, false), - (.developerID, true) - ] { - let quarantine = RecordingQuarantineManager(quarantined: quarantined) - let trustPolicy = RecordingTrustPolicy(decision: .preserve) - let signatureInspector: any RuntimeCodeSignatureInspecting = signatureClass == .developerID - ? matchingDeveloperIDSignatureInspector() - : RecordingSignatureInspector([signatureClass]) - let authenticationPolicy = signatureClass == .developerID - ? matchingDeveloperIDAuthenticationPolicy() - : RuntimeDeveloperIDAuthenticationPolicy() - let fixture = try installerFixture( - quarantineManager: quarantine, - securityLogger: RecordingSecurityLogger(), - signatureInspector: signatureInspector, - trustPolicy: trustPolicy, - developerIDAuthenticationPolicy: authenticationPolicy - ) - - _ = try await fixture.installer.install(version: fixture.version) - - XCTAssertEqual(trustPolicy.contexts.first?.signatureClass, signatureClass) - XCTAssertEqual(trustPolicy.contexts.first?.hasQuarantine, quarantined) - XCTAssertEqual(quarantine.refreshedURLs, []) - XCTAssertEqual(fixture.store.installCount, 1) - } - } - - func testTrustPolicyCannotRequestRefreshOutsideAdHocQuarantinedCase() async throws { - for (signatureClass, quarantined) in [ - (RuntimeCodeSignatureClass.developerID, true), - (.adHoc, false) - ] { - let quarantine = RecordingQuarantineManager(quarantined: quarantined) - let signatureInspector: any RuntimeCodeSignatureInspecting = signatureClass == .developerID - ? matchingDeveloperIDSignatureInspector() - : RecordingSignatureInspector([signatureClass]) - let authenticationPolicy = signatureClass == .developerID - ? matchingDeveloperIDAuthenticationPolicy() - : RuntimeDeveloperIDAuthenticationPolicy() - let fixture = try installerFixture( - quarantineManager: quarantine, - securityLogger: RecordingSecurityLogger(), - signatureInspector: signatureInspector, - trustPolicy: RecordingTrustPolicy(decision: .refreshRemovingQuarantine), - developerIDAuthenticationPolicy: authenticationPolicy - ) - - do { - _ = try await fixture.installer.install(version: fixture.version) - XCTFail("expected policy boundary rejection") - } catch let error as RuntimeInstallerError { - XCTAssertEqual( - error, - .quarantineRemovalFailed( - "the pre-execution trust policy attempted quarantine removal outside the ad-hoc quarantined case" - ) - ) - } - XCTAssertEqual(quarantine.refreshedURLs, []) - XCTAssertEqual(fixture.store.installCount, 0) - } - } - - func testPostRefreshIdentityAndMachOAreRevalidatedBeforeInstall() async throws { - let validator = RecordingValidator() - let quarantine = RecordingQuarantineManager() - let fixture = try installerFixture( - quarantineManager: quarantine, - securityLogger: RecordingSecurityLogger(), - validator: validator, - signatureInspector: RecordingSignatureInspector([.adHoc, .adHoc]), - trustPolicy: RecordingTrustPolicy(decision: .refreshRemovingQuarantine) - ) - - _ = try await fixture.installer.install(version: fixture.version) - - XCTAssertEqual(validator.calls.count, 2) - XCTAssertEqual(quarantine.refreshedURLs.count, 1) - XCTAssertEqual(fixture.store.installCount, 1) - } - - func testDarwinQuarantineManagerAtomicallyRefreshesPinnedVnodeAndOmitsOnlyQuarantine() throws { - let directory = try TemporaryDirectory() - defer { directory.remove() } - let executable = directory.url.appendingPathComponent("forge3") - try Data("fixture".utf8).write(to: executable) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) - let quarantine = Data("0081;00000000;Forge Menu Bar;https://install.forgecode.dev".utf8) - let unrelated = Data("preserve-me".utf8) - try setExtendedAttribute("com.apple.quarantine", value: quarantine, at: executable) - try setExtendedAttribute("dev.forgecode.runtime-test", value: unrelated, at: executable) - let originalIdentity = try RuntimeExecutableIdentityValidator.capture(executable) - let originalData = try Data(contentsOf: executable) - let originalPermissions = permissions(executable) - - let replacementIdentity = try DarwinRuntimeQuarantineManager() - .refreshExecutableRemovingQuarantine( - from: executable, - expectedIdentity: originalIdentity - ) - - XCTAssertEqual(replacementIdentity.device, originalIdentity.device) - XCTAssertNotEqual(replacementIdentity.inode, originalIdentity.inode) - XCTAssertEqual(replacementIdentity.sha256, originalIdentity.sha256) - XCTAssertEqual(try Data(contentsOf: executable), originalData) - XCTAssertEqual(permissions(executable), originalPermissions) - XCTAssertNil(try extendedAttribute("com.apple.quarantine", at: executable)) - XCTAssertEqual(try extendedAttribute("dev.forgecode.runtime-test", at: executable), unrelated) - XCTAssertNoThrow(try RuntimeExecutableIdentityValidator.validate(executable, expected: replacementIdentity)) - } - - func testDarwinQuarantineManagerRejectsSymlinkHardLinkAndUnexpectedOwner() throws { - let directory = try TemporaryDirectory() - defer { directory.remove() } - let executable = directory.url.appendingPathComponent("forge3") - try Data("fixture".utf8).write(to: executable) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) - try setExtendedAttribute( - "com.apple.quarantine", - value: Data("0081;test".utf8), - at: executable - ) - let identity = try RuntimeExecutableIdentityValidator.capture(executable) - - let symlink = directory.url.appendingPathComponent("forge3-link") - try FileManager.default.createSymbolicLink(at: symlink, withDestinationURL: executable) - XCTAssertThrowsError( - try DarwinRuntimeQuarantineManager().refreshExecutableRemovingQuarantine( - from: symlink, - expectedIdentity: identity - ) - ) - - let hardLink = directory.url.appendingPathComponent("forge3-hardlink") - try FileManager.default.linkItem(at: executable, to: hardLink) - XCTAssertThrowsError( - try DarwinRuntimeQuarantineManager().refreshExecutableRemovingQuarantine( - from: executable, - expectedIdentity: identity - ) - ) - try FileManager.default.removeItem(at: hardLink) - - XCTAssertThrowsError( - try DarwinRuntimeQuarantineManager(expectedUserID: geteuid() &+ 1) - .refreshExecutableRemovingQuarantine( - from: executable, - expectedIdentity: identity - ) - ) - XCTAssertEqual(try extendedAttribute("com.apple.quarantine", at: executable), Data("0081;test".utf8)) - XCTAssertEqual(try inode(executable), identity.inode) - } - - func testTypedFilesystemFailuresPreservePermissionDiskAndReadOnlyIdentity() { - XCTAssertEqual( - RuntimeFilesystemError.posix(EACCES, operation: "write", path: "/runtime"), - .filesystem(operation: "write", path: "/runtime", failure: .permissionDenied) - ) - XCTAssertEqual( - RuntimeFilesystemError.posix(ENOSPC, operation: "write", path: "/runtime"), - .filesystem(operation: "write", path: "/runtime", failure: .diskFull) - ) - XCTAssertEqual( - RuntimeFilesystemError.posix(EROFS, operation: "write", path: "/runtime"), - .filesystem(operation: "write", path: "/runtime", failure: .readOnlyFilesystem) - ) - } - - func testDefaultRuntimeRootUsesForgeCodeApplicationSupportDirectory() { - let library = URL(fileURLWithPath: "/Users/example/Library", isDirectory: true) - XCTAssertEqual( - RuntimeStore.defaultRoot(libraryDirectory: library), - library.appendingPathComponent("Application Support/ForgeCode/runtime", isDirectory: true) - ) - } - - func testRealTarXZInspectionAcceptsValidAndRejectsCorruptMaliciousAndOversizedArchives() async throws { - let directory = try TemporaryDirectory() - defer { directory.remove() } - let limits = RuntimeInstallerLimits(expandedArchiveBytes: 4_096, executableBytes: 1_024, archiveEntries: 8) - let handler = SafeTarXZArchiveHandler(processTimeout: 5, terminationGracePeriod: 0.05) - - let valid = directory.url.appendingPathComponent("valid.tar.xz") - try makeRealTarXZ( - [TarFixture(path: "package/", type: "5", data: Data()), TarFixture(path: "package/forge3", type: "0", data: Data("binary".utf8))], - at: valid - ) - let validTemp = directory.url.appendingPathComponent("valid-temp", isDirectory: true) - try FileManager.default.createDirectory(at: validTemp, withIntermediateDirectories: false) - let inspection = try await handler.inspect(archiveURL: valid, temporaryDirectory: validTemp, limits: limits) - XCTAssertEqual(inspection.executableEntry.path, "package/forge3") - - let corrupt = directory.url.appendingPathComponent("corrupt.tar.xz") - try Data([0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00, 1, 2, 3]).write(to: corrupt) - let corruptTemp = directory.url.appendingPathComponent("corrupt-temp", isDirectory: true) - try FileManager.default.createDirectory(at: corruptTemp, withIntermediateDirectories: false) - do { - _ = try await handler.inspect(archiveURL: corrupt, temporaryDirectory: corruptTemp, limits: limits) - XCTFail("expected corrupt archive rejection") - } catch {} - - let malicious = directory.url.appendingPathComponent("malicious.tar.xz") - try makeRealTarXZ([TarFixture(path: "../forge3", type: "0", data: Data())], at: malicious) - let maliciousTemp = directory.url.appendingPathComponent("malicious-temp", isDirectory: true) - try FileManager.default.createDirectory(at: maliciousTemp, withIntermediateDirectories: false) - do { - _ = try await handler.inspect(archiveURL: malicious, temporaryDirectory: maliciousTemp, limits: limits) - XCTFail("expected malicious archive rejection") - } catch {} - - let oversized = directory.url.appendingPathComponent("oversized.tar.xz") - try makeRealTarXZ([TarFixture(path: "forge3", type: "0", data: Data(repeating: 7, count: 1_025))], at: oversized) - let oversizedTemp = directory.url.appendingPathComponent("oversized-temp", isDirectory: true) - try FileManager.default.createDirectory(at: oversizedTemp, withIntermediateDirectories: false) - do { - _ = try await handler.inspect(archiveURL: oversized, temporaryDirectory: oversizedTemp, limits: limits) - XCTFail("expected oversized archive rejection") - } catch let error as RuntimeInstallerError { - guard case .archiveLimitExceeded = error else { return XCTFail("unexpected \(error)") } - } - } - - func testArchiveInspectionCancellationTerminatesAndReapsRunner() async throws { - let directory = try TemporaryDirectory() - defer { directory.remove() } - let archive = directory.url.appendingPathComponent("held.tar.xz") - try Data([0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]).write(to: archive) - let temporary = directory.url.appendingPathComponent("temp", isDirectory: true) - try FileManager.default.createDirectory(at: temporary, withIntermediateDirectories: false) - let runner = HoldingProcessRunner() - let handler = SafeTarXZArchiveHandler(processRunner: runner, processTimeout: 10, terminationGracePeriod: 0.01) - let task = Task { - try await handler.inspect(archiveURL: archive, temporaryDirectory: temporary, limits: RuntimeInstallerLimits()) - } - await runner.waitUntilStarted() - task.cancel() - do { - _ = try await task.value - XCTFail("expected cancellation") - } catch let error as RuntimeInstallerError { - XCTAssertEqual(error, .cancelled) - } - let runnerCancelled = await runner.wasCancelled() - XCTAssertTrue(runnerCancelled) - } - - func testProcessRunnerTimesOutTerminatesKillsAndReapsIgnoringProcess() async throws { - let directory = try TemporaryDirectory() - defer { directory.remove() } - let script = directory.url.appendingPathComponent("ignore-term.sh") - try "#!/bin/sh\ntrap '' TERM\nwhile :; do :; done\n".write(to: script, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: script.path) - for runner in [ - FoundationRuntimeProcessRunner() as any RuntimeProcessRunning, - POSIXRuntimeProcessRunner() as any RuntimeProcessRunning - ] { - let started = Date() - do { - _ = try await runner.run( - executable: script, - arguments: [], - standardOutput: nil, - maximumStandardOutputBytes: 1_024, - timeout: 0.1, - terminationGracePeriod: 0.05 - ) - XCTFail("expected timeout") - } catch let error as RuntimeInstallerError { - XCTAssertEqual(error, .processTimeout) - } - XCTAssertLessThan(Date().timeIntervalSince(started), 1) - } - } - - func testPOSIXProcessRunnerCancellationTerminatesAndReapsChild() async throws { - let directory = try TemporaryDirectory() - defer { directory.remove() } - let pidURL = directory.url.appendingPathComponent("pid") - let script = directory.url.appendingPathComponent("hold.sh") - try "#!/bin/sh\necho $$ > \"$1\"\ntrap '' TERM\nwhile :; do sleep 1; done\n".write( - to: script, - atomically: true, - encoding: .utf8 - ) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: script.path) - let task = Task { - try await POSIXRuntimeProcessRunner().run( - executable: script, - arguments: [pidURL.path], - standardOutput: nil, - maximumStandardOutputBytes: 1_024, - timeout: 30, - terminationGracePeriod: 0.05 - ) - } - for _ in 0..<3_000 where !FileManager.default.fileExists(atPath: pidURL.path) { - try await Task.sleep(nanoseconds: 10_000_000) - } - let pid = try XCTUnwrap(Int32(String(contentsOf: pidURL).trimmingCharacters(in: .whitespacesAndNewlines))) - task.cancel() - do { - _ = try await task.value - XCTFail("expected cancellation") - } catch let error as RuntimeInstallerError { - XCTAssertEqual(error, .cancelled) - } - XCTAssertEqual(kill(pid, 0), -1) - XCTAssertEqual(errno, ESRCH) - } - - func testPOSIXProcessRunnerReturnsWhenEscapedDescendantRetainsPipes() async throws { - let directory = try TemporaryDirectory() - defer { directory.remove() } - let script = directory.url.appendingPathComponent("escape-pipes.sh") - try "#!/bin/sh\npython3 -c 'import os,time; os.setsid(); time.sleep(10)' &\nexit 0\n".write( - to: script, - atomically: true, - encoding: .utf8 - ) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: script.path) - let started = Date() - let result = try await POSIXRuntimeProcessRunner().run( - executable: script, - arguments: [], - standardOutput: nil, - maximumStandardOutputBytes: 1_024, - timeout: 30, - terminationGracePeriod: 0.05 - ) - XCTAssertEqual(result.status, 0) - // The escaped descendant holds the pipes for 10s; returning well under - // that proves the runner does not wait for descendant pipe EOF, while - // leaving headroom for slow shared CI runners. - XCTAssertLessThan(Date().timeIntervalSince(started), 5) - } - - func testPOSIXProcessControlNeverSignalsAfterReap() { - let signals = LockedValues() - let scheduled = LockedValues<@Sendable () -> Void>() - let control = POSIXProcessControl( - pid: 12345, - gracePeriod: 0.1, - sendSignal: { _, signal in signals.append(signal) }, - schedule: { _, action in scheduled.append(action) } - ) - control.requestTermination() - control.markReaped() - scheduled.values.forEach { $0() } - XCTAssertEqual(signals.values, [SIGTERM]) - } - - func testPOSIXProcessRunnerEnforcesCapturedOutputLimit() async throws { - let result = try await POSIXRuntimeProcessRunner().run( - executable: URL(fileURLWithPath: "/usr/bin/yes"), - arguments: [], - standardOutput: nil, - maximumStandardOutputBytes: 16_384, - timeout: 5, - terminationGracePeriod: 0.05 - ) - XCTAssertTrue(result.standardOutputLimitExceeded) - XCTAssertEqual(result.stdout.count, 16_384) - } - - func testProcessRunnerEnforcesStandardOutputLimit() async throws { - let directory = try TemporaryDirectory() - defer { directory.remove() } - let output = directory.url.appendingPathComponent("output") - let result = try await FoundationRuntimeProcessRunner().run( - executable: URL(fileURLWithPath: "/usr/bin/yes"), - arguments: [], - standardOutput: output, - maximumStandardOutputBytes: 16_384, - terminationGracePeriod: 0.05 - ) - XCTAssertTrue(result.standardOutputLimitExceeded) - let size = (try FileManager.default.attributesOfItem(atPath: output.path)[.size] as? NSNumber)?.intValue ?? 0 - XCTAssertLessThanOrEqual(size, 16_384) - } - - func testRuntimeStoreLeaseCoordinatesAcrossProcessesAndReleasesOnCrash() async throws { - let directory = try TemporaryDirectory() - defer { directory.remove() } - let root = directory.url.appendingPathComponent("runtime") - let lease = RuntimeStoreLease(rootURL: root) - let shared = try lease.acquire(.sharedExecution) - XCTAssertEqual(fcntl(shared.descriptor, F_GETFD) & FD_CLOEXEC, FD_CLOEXEC) - - let blockedMarker = directory.url.appendingPathComponent("exclusive-acquired") - let blockedPID = try spawnLeaseChild( - rootURL: root, - mode: "exclusive", - markerURL: blockedMarker - ) - defer { terminateAndReap(blockedPID) } - try await Task.sleep(nanoseconds: 150_000_000) - XCTAssertFalse(FileManager.default.fileExists(atPath: blockedMarker.path)) - shared.release() - try await waitForFileExists(blockedMarker) - _ = waitForChild(blockedPID) - - let crashMarker = directory.url.appendingPathComponent("shared-acquired") - let crashPID = try spawnLeaseChild( - rootURL: root, - mode: "shared", - markerURL: crashMarker, - hold: true - ) - try await waitForFileExists(crashMarker) - let exclusiveTask = Task.detached { try lease.acquire(.exclusiveMutation) } - try await Task.sleep(nanoseconds: 100_000_000) - XCTAssertFalse(exclusiveTask.isCancelled) - _ = kill(crashPID, SIGKILL) - _ = waitForChild(crashPID) - let exclusive = try await exclusiveTask.value - exclusive.release() - } - - func testInstalledRuntimeIdentityDetectsHardLinkAndReplacementBeforeLaunch() throws { - let directory = try TemporaryDirectory() - defer { directory.remove() } - let executable = directory.url.appendingPathComponent("forge3") - try Data("one".utf8).write(to: executable) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) - let identity = try RuntimeExecutableIdentityValidator.capture(executable) - let runtime = InstalledRuntime( - version: RuntimeReleaseVersion(rawValue: "1.2.3")!, - architecture: .arm64, - executableURL: executable, - executableIdentity: identity - ) - XCTAssertNoThrow(try runtime.validateExecutableIdentity()) - - let link = directory.url.appendingPathComponent("forge3-link") - try FileManager.default.linkItem(at: executable, to: link) - XCTAssertThrowsError(try runtime.validateExecutableIdentity()) - try FileManager.default.removeItem(at: link) - try FileManager.default.removeItem(at: executable) - try Data("two".utf8).write(to: executable) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) - XCTAssertThrowsError(try runtime.validateExecutableIdentity()) - } - - private func installerFixture( - quarantineManager: any RuntimeQuarantineManaging, - securityLogger: any RuntimeInstallationSecurityLogging, - validator: any RuntimeExecutableValidating = StubValidator(), - signatureInspector: any RuntimeCodeSignatureInspecting = UnsignedRuntimeCodeSignatureInspector(), - trustPolicy: any RuntimePreExecutionTrustPolicy = TemporaryAdHocRuntimeTrustPolicy(), - validationTracer: any RuntimeInstallerValidationTracing = NoOpRuntimeInstallerValidationTracer(), - developerIDAuthenticationPolicy: RuntimeDeveloperIDAuthenticationPolicy = RuntimeDeveloperIDAuthenticationPolicy(), - developerIDRequirementEvaluator: any RuntimeDeveloperIDRequirementEvaluating = RecordingDeveloperIDRequirementEvaluator(results: [.success(true)]), - progressDeliveryHook: any RuntimeInstallerProgressDeliveryHook = NoOpRuntimeInstallerProgressDeliveryHook() - ) throws -> (installer: RuntimeInstaller, version: RuntimeReleaseVersion, store: MockStore) { - let version = try XCTUnwrap(RuntimeReleaseVersion(rawValue: "1.2.3")) - let archiveData = Data("probe-archive".utf8) - let archiveURL = RuntimeReleaseURLs.archive(version: version, architecture: .arm64) - let checksumURL = RuntimeReleaseURLs.checksum(version: version, architecture: .arm64) - let network = MockNetwork(responses: [ - archiveURL: archiveData, - checksumURL: Data("\(RuntimeSHA256.hexDigest(of: archiveData)) *\(RuntimeArchitecture.arm64.archiveName)\n".utf8) - ]) - let store = MockStore() - let installer = RuntimeInstaller( - architecture: .arm64, - dependencies: .init( - network: network, - store: store, - archive: MockArchive(executable: Data("probe-executable".utf8)), - validator: validator, - signatureInspector: signatureInspector, - trustPolicy: trustPolicy, - quarantineManager: quarantineManager, - securityLogger: securityLogger, - validationTracer: validationTracer, - developerIDAuthenticationPolicy: developerIDAuthenticationPolicy, - developerIDRequirementEvaluator: developerIDRequirementEvaluator, - progressDeliveryHook: progressDeliveryHook - ) - ) - return (installer, version, store) - } -} - -private func matchingDeveloperIDSignatureInspector() -> RecordingSignatureInspector { - RecordingSignatureInspector(inspections: [ - RuntimeCodeSignatureInspection( - signatureClass: .developerID, - teamIdentifier: "TEAM123456", - signingIdentity: "Developer ID Application: Forge Example (TEAM123456)" - ) - ]) -} - -private func matchingDeveloperIDAuthenticationPolicy() -> RuntimeDeveloperIDAuthenticationPolicy { - RuntimeDeveloperIDAuthenticationPolicy(expectedTeamIdentifier: "TEAM123456") -} - -private func networkClient() -> URLSessionRuntimeNetworkClient { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [ScriptedURLProtocol.self] - // Timeout behavior is exercised by scripted URLError(.timedOut) failures, - // never by real wall-clock expiry, so these bounds stay generous: they only - // cap how long a misbehaving test can occupy a loaded CI runner. - return URLSessionRuntimeNetworkClient(configuration: configuration, requestTimeout: 10, resourceTimeout: 30) -} - -private final class ScriptedURLProtocol: URLProtocol, @unchecked Sendable { - enum Plan { - case response(status: Int, headers: [String: String], chunks: [Data]) - case redirect(URL) - case failure(Error) - case hold - } - - private static let lock = NSLock() - private static var plans: [URL: Plan] = [:] - private static var started: Set = [] - private static var stopped: Set = [] - private let stateLock = NSLock() - private var stoppedLocally = false - - static func install(_ plans: [URL: Plan]) { - lock.withLock { self.plans = plans } - } - - static func reset() { - lock.withLock { - plans = [:] - started = [] - stopped = [] - } - } - - static func waitUntilStarted(_ url: URL) async { - // Bounded poll instead of an open-ended continuation: if the request - // never starts, the test must fail in seconds, not hang the runner. - for _ in 0..<3_000 { - if lock.withLock({ started.contains(url) }) { return } - try? await Task.sleep(nanoseconds: 10_000_000) - } - XCTFail("timed out waiting for a request to start: \(url)") - } - - static func waitUntilStopped(_ url: URL) async -> Bool { - for _ in 0..<200 { - if lock.withLock({ stopped.contains(url) }) { return true } - try? await Task.sleep(nanoseconds: 1_000_000) - } - return lock.withLock { stopped.contains(url) } - } - - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - guard let url = request.url else { - client?.urlProtocol(self, didFailWithError: URLError(.badURL)) - return - } - let plan: Plan? = Self.lock.withLock { - Self.started.insert(url) - return Self.plans[url] - } - guard let plan else { - client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable)) - return - } - switch plan { - case .response(let status, let headers, let chunks): - guard let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: headers) else { - client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) - return - } - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - for chunk in chunks where !stateLock.withLock({ stoppedLocally }) { - client?.urlProtocol(self, didLoad: chunk) - } - if !stateLock.withLock({ stoppedLocally }) { client?.urlProtocolDidFinishLoading(self) } - case .redirect(let destination): - guard let response = HTTPURLResponse( - url: url, - statusCode: 302, - httpVersion: "HTTP/1.1", - headerFields: ["Location": destination.absoluteString] - ) else { return } - client?.urlProtocol(self, wasRedirectedTo: URLRequest(url: destination), redirectResponse: response) - // Terminate this load explicitly. When the client follows the - // redirect it has already detached this protocol instance and - // ignores these messages; when it refuses (redirect limit or - // unsafe destination) the task completes immediately instead of - // stalling until the wall-clock request timeout fires. - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocolDidFinishLoading(self) - case .failure(let error): - client?.urlProtocol(self, didFailWithError: error) - case .hold: - break - } - } - - override func stopLoading() { - stateLock.withLock { stoppedLocally = true } - if let url = request.url { _ = Self.lock.withLock { Self.stopped.insert(url) } } - } -} - -private final class StubValidator: RuntimeExecutableValidating, @unchecked Sendable { - func validate(executableURL: URL, expectedArchitecture: RuntimeArchitecture) throws {} -} - -private final class RecordingValidator: RuntimeExecutableValidating, @unchecked Sendable { - private let lock = NSLock() - private var results: [Result] - private var storedCalls: [(URL, RuntimeArchitecture)] = [] - - init(error: RuntimeInstallerError? = nil) { - results = error.map { [.failure($0)] } ?? [] - } - - init(results: [Result]) { - self.results = results - } - - var calls: [(URL, RuntimeArchitecture)] { lock.withLock { storedCalls } } - - func validate(executableURL: URL, expectedArchitecture: RuntimeArchitecture) throws { - try lock.withLock { - storedCalls.append((executableURL, expectedArchitecture)) - if !results.isEmpty { try results.removeFirst().get() } - } - } -} - -private final class RecordingDeveloperIDRequirementEvaluator: RuntimeDeveloperIDRequirementEvaluating, @unchecked Sendable { - struct Call: Equatable { - let executableURL: URL - let expectedTeamIdentifier: String? - } - - private let lock = NSLock() - private var results: [Result] - private var storedCalls: [Call] = [] - - init(results: [Result]) { - self.results = results - } - - var calls: [Call] { lock.withLock { storedCalls } } - - func satisfiesDeveloperIDApplicationRequirement( - executableURL: URL, - expectedTeamIdentifier: String? - ) throws -> Bool { - try lock.withLock { - storedCalls.append(Call( - executableURL: executableURL, - expectedTeamIdentifier: expectedTeamIdentifier - )) - return try (results.isEmpty ? .success(false) : results.removeFirst()).get() - } - } -} - -private final class RecordingSignatureInspector: RuntimeCodeSignatureInspecting, @unchecked Sendable { - private let lock = NSLock() - private var inspections: [RuntimeCodeSignatureInspection] - private var storedURLs: [URL] = [] - - init(_ classes: [RuntimeCodeSignatureClass]) { - inspections = classes.map { RuntimeCodeSignatureInspection(signatureClass: $0) } - } - - init(inspections: [RuntimeCodeSignatureInspection]) { - self.inspections = inspections - } - - var inspectedURLs: [URL] { lock.withLock { storedURLs } } - - func inspectSignature(of executableURL: URL) throws -> RuntimeCodeSignatureInspection { - lock.withLock { - storedURLs.append(executableURL) - return inspections.isEmpty - ? RuntimeCodeSignatureInspection(signatureClass: .unsigned) - : inspections.removeFirst() - } - } -} - -private final class RecordingValidationTracer: RuntimeInstallerValidationTracing, @unchecked Sendable { - private let lock = NSLock() - private var storedEvents: [RuntimeInstallerValidationEvent] = [] - - var events: [RuntimeInstallerValidationEvent] { lock.withLock { storedEvents } } - - func record(_ event: RuntimeInstallerValidationEvent) { - lock.withLock { storedEvents.append(event) } - } -} - -private final class RecordingTrustPolicy: RuntimePreExecutionTrustPolicy, @unchecked Sendable { - private let lock = NSLock() - private let decision: RuntimePreExecutionTrustDecision - private var storedContexts: [RuntimePreExecutionTrustContext] = [] - - init(decision: RuntimePreExecutionTrustDecision) { - self.decision = decision - } - - var contexts: [RuntimePreExecutionTrustContext] { lock.withLock { storedContexts } } - - func decision(for context: RuntimePreExecutionTrustContext) throws -> RuntimePreExecutionTrustDecision { - lock.withLock { storedContexts.append(context) } - return decision - } -} - -private actor MockNetwork: RuntimeNetworkClient { - let responses: [URL: Data] - let delayNanoseconds: UInt64 - private(set) var requestedURLs: [URL] = [] - var requestCount: Int { requestedURLs.count } - - init(responses: [URL: Data], delayNanoseconds: UInt64 = 0) { - self.responses = responses - self.delayNanoseconds = delayNanoseconds - } - - func download( - _ request: RuntimeDownloadRequest, - progress: @escaping RuntimeDownloadProgressHandler - ) async throws -> RuntimeDownload { - requestedURLs.append(request.url) - if delayNanoseconds > 0 { try await Task.sleep(nanoseconds: delayNanoseconds) } - guard let data = responses[request.url] else { throw RuntimeInstallerError.network("unexpected URL") } - guard Int64(data.count) <= request.maximumBytes else { throw RuntimeInstallerError.responseTooLarge(limit: request.maximumBytes) } - await progress(0, Int64(data.count)) - await progress(Int64(data.count), Int64(data.count)) - return RuntimeDownload(data: data, responseURL: request.url) - } -} - -private actor ReplayRaceNetwork: RuntimeNetworkClient { - private let version: RuntimeReleaseVersion - private var manifestRequested = false - private var manifestWaiters: [CheckedContinuation] = [] - private var releaseManifestWaiters: [CheckedContinuation] = [] - private var manifestReleased = false - - init(version: RuntimeReleaseVersion) { - self.version = version - } - - func download( - _ request: RuntimeDownloadRequest, - progress: @escaping RuntimeDownloadProgressHandler - ) async throws -> RuntimeDownload { - if request.url == RuntimeReleaseURLs.latestManifest { - manifestRequested = true - manifestWaiters.forEach { $0.resume() } - manifestWaiters.removeAll() - if !manifestReleased { - await withCheckedContinuation { releaseManifestWaiters.append($0) } - } - let data = Data("{\"version\":\"\(version.rawValue)\"}".utf8) - return RuntimeDownload(data: data, responseURL: request.url) - } - let archive = Data("race-archive".utf8) - let data = request.url.path.hasSuffix(".sha256") - ? Data("\(RuntimeSHA256.hexDigest(of: archive)) *\(RuntimeArchitecture.arm64.archiveName)\n".utf8) - : archive - await progress(0, Int64(data.count)) - await progress(Int64(data.count), Int64(data.count)) - return RuntimeDownload(data: data, responseURL: request.url) - } - - func waitUntilManifestRequested() async { - if manifestRequested { return } - await withCheckedContinuation { manifestWaiters.append($0) } - } - - func releaseManifest() { - manifestReleased = true - releaseManifestWaiters.forEach { $0.resume() } - releaseManifestWaiters.removeAll() - } -} - -private actor BlockingReplayProgressHook: RuntimeInstallerProgressDeliveryHook { - private var replayBlocked = false - private var replayReleased = false - private var blockedWaiters: [CheckedContinuation] = [] - private var releaseWaiters: [CheckedContinuation] = [] - private(set) var blockedReplayCount = 0 - - func beforeDelivery(_ delivery: RuntimeInstallerProgressDelivery) async { - guard delivery.kind == .replay, !replayBlocked else { return } - replayBlocked = true - blockedReplayCount += 1 - blockedWaiters.forEach { $0.resume() } - blockedWaiters.removeAll() - if !replayReleased { - await withCheckedContinuation { releaseWaiters.append($0) } - } - } - - func waitUntilReplayBlocked() async { - if replayBlocked { return } - await withCheckedContinuation { blockedWaiters.append($0) } - } - - func releaseReplay() { - replayReleased = true - releaseWaiters.forEach { $0.resume() } - releaseWaiters.removeAll() - } -} - -private actor SerializedInstallNetwork: RuntimeNetworkClient { - private var firstRequestWaiters: [CheckedContinuation] = [] - private var releaseWaiters: [CheckedContinuation] = [] - private var firstRequestStarted = false - private var releaseFirst = false - private var activeVersions: Set = [] - private(set) var requestedVersions: [RuntimeReleaseVersion] = [] - private(set) var maximumConcurrentVersionCount = 0 - - func download( - _ request: RuntimeDownloadRequest, - progress: @escaping RuntimeDownloadProgressHandler - ) async throws -> RuntimeDownload { - guard let version = releaseVersion(from: request.url) else { - throw RuntimeInstallerError.network("missing release version in test URL") - } - if activeVersions.insert(version).inserted { - requestedVersions.append(version) - maximumConcurrentVersionCount = max(maximumConcurrentVersionCount, activeVersions.count) - } - if version.rawValue == "1.2.3", !releaseFirst { - firstRequestStarted = true - firstRequestWaiters.forEach { $0.resume() } - firstRequestWaiters.removeAll() - await withCheckedContinuation { releaseWaiters.append($0) } - } - let archive = Data("archive-\(version.rawValue)".utf8) - let data: Data - if request.url.path.hasSuffix(".sha256") { - data = Data("\(RuntimeSHA256.hexDigest(of: archive)) *\(RuntimeArchitecture.arm64.archiveName)\n".utf8) - } else { - data = archive - } - await progress(0, Int64(data.count)) - await progress(Int64(data.count), Int64(data.count)) - return RuntimeDownload(data: data, responseURL: request.url) - } - - func waitUntilFirstRequestStarted() async { - if firstRequestStarted { return } - await withCheckedContinuation { firstRequestWaiters.append($0) } - } - - func releaseFirstVersion() { - releaseFirst = true - activeVersions.remove(RuntimeReleaseVersion(rawValue: "1.2.3")!) - releaseWaiters.forEach { $0.resume() } - releaseWaiters.removeAll() - } -} - -private actor CancellationHoldingNetwork: RuntimeNetworkClient { - private let firstVersion: RuntimeReleaseVersion - private var firstStarted = false - private var firstStartWaiters: [CheckedContinuation] = [] - private var releaseWaiters: [CheckedContinuation] = [] - private var releaseFirst = false - private(set) var secondVersionStarted = false - - init(firstVersion: RuntimeReleaseVersion) { - self.firstVersion = firstVersion - } - - func download( - _ request: RuntimeDownloadRequest, - progress: @escaping RuntimeDownloadProgressHandler - ) async throws -> RuntimeDownload { - guard let version = releaseVersion(from: request.url) else { - throw RuntimeInstallerError.network("missing release version in test URL") - } - if version == firstVersion, !releaseFirst { - firstStarted = true - firstStartWaiters.forEach { $0.resume() } - firstStartWaiters.removeAll() - await withCheckedContinuation { releaseWaiters.append($0) } - try Task.checkCancellation() - } else if version != firstVersion { - secondVersionStarted = true - } - let archive = Data("archive-\(version.rawValue)".utf8) - let data = request.url.path.hasSuffix(".sha256") - ? Data("\(RuntimeSHA256.hexDigest(of: archive)) *\(RuntimeArchitecture.arm64.archiveName)\n".utf8) - : archive - await progress(0, Int64(data.count)) - await progress(Int64(data.count), Int64(data.count)) - return RuntimeDownload(data: data, responseURL: request.url) - } - - func waitUntilFirstVersionStarted() async { - if firstStarted { return } - await withCheckedContinuation { firstStartWaiters.append($0) } - } - - func releaseCancelledFirstVersion() { - releaseFirst = true - releaseWaiters.forEach { $0.resume() } - releaseWaiters.removeAll() - } -} - -private func releaseVersion(from url: URL) -> RuntimeReleaseVersion? { - url.pathComponents - .first(where: { $0.hasPrefix("v") }) - .flatMap { RuntimeReleaseVersion(rawValue: String($0.dropFirst())) } -} - -private actor CancellingNetwork: RuntimeNetworkClient { - private var requested = false - private var waiters: [CheckedContinuation] = [] - private var releaseWaiters: [CheckedContinuation] = [] - - func download( - _ request: RuntimeDownloadRequest, - progress: @escaping RuntimeDownloadProgressHandler - ) async throws -> RuntimeDownload { - requested = true - waiters.forEach { $0.resume() } - waiters.removeAll() - await withCheckedContinuation { releaseWaiters.append($0) } - try Task.checkCancellation() - return RuntimeDownload(data: Data(), responseURL: request.url) - } - - func waitUntilRequested() async { - if requested { return } - await withCheckedContinuation { waiters.append($0) } - } - - func release() { - releaseWaiters.forEach { $0.resume() } - releaseWaiters.removeAll() - } -} - -private final class MockStore: RuntimeStoreManaging, @unchecked Sendable { - private let lock = NSLock() - private var cachedRuntime: InstalledRuntime? - private var storedInstallCount = 0 - private let directory: TemporaryDirectory - - var installCount: Int { lock.withLock { storedInstallCount } } - - init(cached: InstalledRuntime? = nil) { - cachedRuntime = cached - directory = try! TemporaryDirectory() - } - - deinit { directory.remove() } - - func cached(version: RuntimeReleaseVersion, architecture: RuntimeArchitecture) throws -> InstalledRuntime? { - lock.withLock { - guard cachedRuntime?.version == version, cachedRuntime?.architecture == architecture else { return nil } - return cachedRuntime - } - } - - func current(architecture: RuntimeArchitecture) throws -> InstalledRuntime? { - lock.withLock { cachedRuntime } - } - - func makePrivateTemporaryDirectory() throws -> URL { - let url = directory.url.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) - return url - } - - func installStagedRuntime(executableURL: URL, receipt: RuntimeStoreReceipt, temporaryDirectory: URL) throws -> InstalledRuntime { - lock.withLock { - storedInstallCount += 1 - let installed = InstalledRuntime(version: receipt.version, architecture: receipt.architecture, executableURL: executableURL) - cachedRuntime = installed - return installed - } - } -} - -private final class RecordingQuarantineManager: RuntimeQuarantineManaging, @unchecked Sendable { - private let lock = NSLock() - private var storedInspectedURLs: [URL] = [] - private var storedRefreshURLs: [URL] = [] - private var storedRefreshIdentities: [RuntimeExecutableIdentity] = [] - private let error: RuntimeInstallerError? - private let quarantined: Bool - private let performRealRefresh: Bool - private let replacementIdentityOverride: RuntimeExecutableIdentity? - private let corruptReturnedHashAfterRefresh: Bool - private let forceQuarantinedAfterRefresh: Bool - - init( - error: RuntimeInstallerError? = nil, - quarantined: Bool = true, - performRealRefresh: Bool = true, - replacementIdentityOverride: RuntimeExecutableIdentity? = nil, - corruptReturnedHashAfterRefresh: Bool = false, - forceQuarantinedAfterRefresh: Bool = false - ) { - self.error = error - self.quarantined = quarantined - self.performRealRefresh = performRealRefresh - self.replacementIdentityOverride = replacementIdentityOverride - self.corruptReturnedHashAfterRefresh = corruptReturnedHashAfterRefresh - self.forceQuarantinedAfterRefresh = forceQuarantinedAfterRefresh - } - - var inspectedURLs: [URL] { lock.withLock { storedInspectedURLs } } - var refreshedURLs: [URL] { lock.withLock { storedRefreshURLs } } - var refreshIdentities: [RuntimeExecutableIdentity] { lock.withLock { storedRefreshIdentities } } - - func hasQuarantine( - at executableURL: URL, - expectedIdentity: RuntimeExecutableIdentity - ) throws -> Bool { - let refreshed = lock.withLock { () -> Bool in - storedInspectedURLs.append(executableURL) - return !storedRefreshURLs.isEmpty - } - if forceQuarantinedAfterRefresh, refreshed { return true } - if !quarantined { return false } - return try DarwinRuntimeQuarantineManager().hasQuarantine( - at: executableURL, - expectedIdentity: expectedIdentity - ) - } - - func refreshExecutableRemovingQuarantine( - from executableURL: URL, - expectedIdentity: RuntimeExecutableIdentity - ) throws -> RuntimeExecutableIdentity { - lock.withLock { - storedRefreshURLs.append(executableURL) - storedRefreshIdentities.append(expectedIdentity) - } - if let error { throw error } - if let replacementIdentityOverride { return replacementIdentityOverride } - if performRealRefresh { - let refreshed = try DarwinRuntimeQuarantineManager().refreshExecutableRemovingQuarantine( - from: executableURL, - expectedIdentity: expectedIdentity - ) - if corruptReturnedHashAfterRefresh { - return RuntimeExecutableIdentity( - device: refreshed.device, - inode: refreshed.inode, - size: refreshed.size, - sha256: String(repeating: "0", count: 64) - ) - } - return refreshed - } - return expectedIdentity - } -} - -private final class RecordingSecurityLogger: RuntimeInstallationSecurityLogging, @unchecked Sendable { - private let lock = NSLock() - private var storedEvents: [RuntimeInstallationSecurityEvent] = [] - - var events: [RuntimeInstallationSecurityEvent] { lock.withLock { storedEvents } } - - func log(_ event: RuntimeInstallationSecurityEvent) { - lock.withLock { storedEvents.append(event) } - } -} - -private actor RecordingProcessRunner: RuntimeProcessRunning { - struct Call: Sendable { - let executable: URL - let arguments: [String] - let standardOutput: URL? - let maximumStandardOutputBytes: Int - let timeout: TimeInterval - let terminationGracePeriod: TimeInterval - } - - private var results: [Result] - private(set) var calls: [Call] = [] - - init(results: [Result]) { - self.results = results - } - - func run( - executable: URL, - arguments: [String], - standardOutput: URL?, - maximumStandardOutputBytes: Int, - timeout: TimeInterval, - terminationGracePeriod: TimeInterval - ) async throws -> RuntimeProcessResult { - calls.append( - Call( - executable: executable, - arguments: arguments, - standardOutput: standardOutput, - maximumStandardOutputBytes: maximumStandardOutputBytes, - timeout: timeout, - terminationGracePeriod: terminationGracePeriod - ) - ) - guard !results.isEmpty else { throw RuntimeInstallerError.processFailure("unexpected process call") } - return try results.removeFirst().get() - } -} - -private actor HoldingProcessRunner: RuntimeProcessRunning { - private var started = false - private var cancelled = false - private var startWaiters: [CheckedContinuation] = [] - - func run( - executable: URL, - arguments: [String], - standardOutput: URL?, - maximumStandardOutputBytes: Int, - timeout: TimeInterval, - terminationGracePeriod: TimeInterval - ) async throws -> RuntimeProcessResult { - started = true - startWaiters.forEach { $0.resume() } - startWaiters.removeAll() - do { - try await Task.sleep(nanoseconds: 3_600_000_000_000) - } catch { - cancelled = true - throw RuntimeInstallerError.cancelled - } - return RuntimeProcessResult(status: 0) - } - - func waitUntilStarted() async { - if started { return } - await withCheckedContinuation { startWaiters.append($0) } - } - - func wasCancelled() -> Bool { cancelled } -} - -private actor MockArchive: RuntimeArchiveHandling { - let executable: Data - private(set) var inspectCount = 0 - - init(executable: Data = Data("fixture".utf8)) { self.executable = executable } - - func inspect(archiveURL: URL, temporaryDirectory: URL, limits: RuntimeInstallerLimits) async throws -> RuntimeArchiveInspection { - inspectCount += 1 - let tar = temporaryDirectory.appendingPathComponent("mock.tar") - let data = makeTar([TarFixture(path: "package/forge3", type: "0", data: executable)]) - try data.write(to: tar) - return try SafeTarXZArchiveHandler.parseTar(data, tarURL: tar, limits: limits) - } - - nonisolated func extractExecutable(from inspection: RuntimeArchiveInspection, to destination: URL) throws { - try executable.write(to: destination) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: destination.path) - } -} - -private struct TemporaryDirectory { - let url: URL - - init() throws { - let template = FileManager.default.temporaryDirectory.appendingPathComponent("forge-runtime-tests.XXXXXX").path - var bytes = Array(template.utf8CString) - guard mkdtemp(&bytes) != nil else { throw POSIXError(.EIO) } - url = URL(fileURLWithPath: String(cString: bytes), isDirectory: true) - } - - func remove() { - makeTreeOwnerWritable(url) - try? FileManager.default.removeItem(at: url) - } -} - -private func makeTreeOwnerWritable(_ url: URL) { - var info = stat() - guard lstat(url.path, &info) == 0 else { return } - if (info.st_mode & S_IFMT) == S_IFDIR { - _ = chmod(url.path, 0o700) - if let names = try? FileManager.default.contentsOfDirectory(atPath: url.path) { - for name in names { - makeTreeOwnerWritable(url.appendingPathComponent(name)) - } - } - } else if (info.st_mode & S_IFMT) == S_IFREG { - _ = chmod(url.path, 0o600) - } -} - -private struct TarFixture { - let path: String - let type: Character - let data: Data -} - -private func makeTar(_ entries: [TarFixture]) -> Data { - var archive = Data() - for entry in entries { - var header = Data(repeating: 0, count: 512) - write(entry.path, to: &header, range: 0..<100) - write("0000700\0", to: &header, range: 100..<108) - write("0000000\0", to: &header, range: 108..<116) - write("0000000\0", to: &header, range: 116..<124) - write(String(format: "%011o\0", entry.data.count), to: &header, range: 124..<136) - write("00000000000\0", to: &header, range: 136..<148) - write(" ", to: &header, range: 148..<156) - header[156] = entry.type.asciiValue! - write("ustar\0", to: &header, range: 257..<263) - write("00", to: &header, range: 263..<265) - let checksum = header.reduce(0) { $0 + Int($1) } - write(String(format: "%06o\0 ", checksum), to: &header, range: 148..<156) - archive.append(header) - archive.append(entry.data) - if entry.data.count % 512 != 0 { - archive.append(Data(repeating: 0, count: 512 - entry.data.count % 512)) - } - } - archive.append(Data(repeating: 0, count: 1_024)) - return archive -} - -private func makeRealTarXZ(_ entries: [TarFixture], at destination: URL) throws { - let directory = try TemporaryDirectory() - defer { directory.remove() } - let tarURL = directory.url.appendingPathComponent("fixture.tar") - try makeTar(entries).write(to: tarURL) - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/tar") - process.arguments = ["-cJf", destination.path, "@\(tarURL.path)"] - process.standardOutput = FileHandle.nullDevice - let errorPipe = Pipe() - process.standardError = errorPipe - try process.run() - process.waitUntilExit() - let diagnostic = String(decoding: errorPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) - guard process.terminationStatus == 0 else { - throw RuntimeInstallerError.processFailure(diagnostic) - } -} - -private func write(_ string: String, to data: inout Data, range: Range) { - let bytes = Array(string.utf8.prefix(range.count)) - data.replaceSubrange(range.lowerBound..<(range.lowerBound + bytes.count), with: bytes) -} - -private func macho(architecture: RuntimeArchitecture) -> Data { - let segmentSize = 72 - let mainSize = 24 - let commandsSize = segmentSize + mainSize - let fileSize = 32 + commandsSize + 16 - var bytes = Data(repeating: 0, count: fileSize) - putUInt32(0xfeedfacf, in: &bytes, at: 0) - putUInt32(architecture == .arm64 ? 0x0100000c : 0x01000007, in: &bytes, at: 4) - putUInt32(0, in: &bytes, at: 8) - putUInt32(0x2, in: &bytes, at: 12) - putUInt32(2, in: &bytes, at: 16) - putUInt32(UInt32(commandsSize), in: &bytes, at: 20) - - putUInt32(0x19, in: &bytes, at: 32) - putUInt32(UInt32(segmentSize), in: &bytes, at: 36) - write("__TEXT", to: &bytes, range: 40..<56) - putUInt64(0x100000000, in: &bytes, at: 56) - putUInt64(UInt64(fileSize), in: &bytes, at: 64) - putUInt64(0, in: &bytes, at: 72) - putUInt64(UInt64(fileSize), in: &bytes, at: 80) - putUInt32(0x5, in: &bytes, at: 88) - putUInt32(0x5, in: &bytes, at: 92) - - let mainOffset = 32 + segmentSize - putUInt32(0x80000028, in: &bytes, at: mainOffset) - putUInt32(UInt32(mainSize), in: &bytes, at: mainOffset + 4) - putUInt64(UInt64(32 + commandsSize), in: &bytes, at: mainOffset + 8) - bytes[32 + commandsSize] = 0xc3 - return bytes -} - -private func putUInt32(_ value: UInt32, in data: inout Data, at offset: Int) { - for index in 0..<4 { data[offset + index] = UInt8((value >> UInt32(index * 8)) & 0xff) } -} - -private func putUInt64(_ value: UInt64, in data: inout Data, at offset: Int) { - for index in 0..<8 { data[offset + index] = UInt8((value >> UInt64(index * 8)) & 0xff) } -} - -private func machOCodeSignatureRange(_ data: Data) -> Range? { - guard data.count >= 32 else { return nil } - func u32(_ offset: Int) -> UInt32 { - (0..<4).reduce(0) { $0 | (UInt32(data[offset + $1]) << UInt32($1 * 8)) } - } - let commandCount = Int(u32(16)) - var offset = 32 - for _ in 0..= 8, offset + size <= data.count else { return nil } - if command == 0x1d { - let signatureOffset = Int(u32(offset + 8)) - let signatureSize = Int(u32(offset + 12)) - guard signatureOffset <= data.count, signatureSize <= data.count - signatureOffset else { return nil } - return signatureOffset..<(signatureOffset + signatureSize) - } - offset += size - } - return nil -} - -private func spawnLeaseChild( - rootURL: URL, - mode: String, - markerURL: URL, - hold: Bool = false -) throws -> pid_t { - let helperURL = try locateBuiltHelper(named: "ForgeRuntimeLeaseTestHelper") - let arguments = [ - helperURL.path, - rootURL.path, - mode, - markerURL.path, - hold ? "hold" : "exit" - ] - var argv = arguments.map { strdup($0) } + [nil] - defer { for pointer in argv where pointer != nil { free(pointer) } } - var pid: pid_t = 0 - let result = posix_spawn(&pid, arguments[0], nil, nil, &argv, environ) - guard result == 0 else { throw POSIXError(POSIXErrorCode(rawValue: result) ?? .EIO) } - return pid -} - -private func locateBuiltHelper(named name: String) throws -> URL { - let testsURL = Bundle(for: RuntimeInstallerTests.self).bundleURL - let candidates = [ - testsURL.deletingLastPathComponent().appendingPathComponent(name), - URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - .appendingPathComponent(".build/debug/\(name)") - ] - return try XCTUnwrap( - candidates.first { FileManager.default.isExecutableFile(atPath: $0.path) }, - "Could not locate \(name). Checked: \(candidates.map(\.path))" - ) -} - -private func waitForFileExists(_ url: URL) async throws { - for _ in 0..<200 { - if FileManager.default.fileExists(atPath: url.path) { return } - try await Task.sleep(nanoseconds: 5_000_000) - } - XCTFail("timed out waiting for \(url.path)") -} - -@discardableResult -private func waitForChild(_ pid: pid_t) -> Int32 { - var status: Int32 = 0 - var result: pid_t - repeat { result = waitpid(pid, &status, 0) } while result == -1 && errno == EINTR - return status -} - -private func terminateAndReap(_ pid: pid_t) { - if waitpid(pid, nil, WNOHANG) == 0 { - _ = kill(pid, SIGKILL) - _ = waitForChild(pid) - } -} - -private func runTool(_ path: String, _ arguments: [String]) throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: path) - process.arguments = arguments - process.standardOutput = FileHandle.nullDevice - let errorPipe = Pipe() - process.standardError = errorPipe - try process.run() - // Drain the pipe before waiting. A pipe buffer is finite (64 KiB), so a - // child that writes more than that blocks until someone reads, while the - // parent blocks in waitUntilExit() -- a deadlock. It only shows up when the - // tool is unusually chatty, which is exactly what happens under load when - // clang emits warnings, so read to EOF first and then reap. - let diagnostic = String(decoding: errorPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) - process.waitUntilExit() - guard process.terminationStatus == 0 else { throw RuntimeInstallerError.processFailure(diagnostic) } -} - -private func setExtendedAttribute(_ name: String, value: Data, at url: URL) throws { - let result = value.withUnsafeBytes { bytes in - url.path.withCString { path in - name.withCString { attributeName in - setxattr(path, attributeName, bytes.baseAddress, value.count, 0, 0) - } - } - } - guard result == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } -} - -private func extendedAttribute(_ name: String, at url: URL) throws -> Data? { - let size = url.path.withCString { path in - name.withCString { attributeName in - getxattr(path, attributeName, nil, 0, 0, 0) - } - } - if size < 0 { - if errno == ENOATTR { return nil } - throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) - } - var data = Data(count: size) - let readCount = data.withUnsafeMutableBytes { bytes in - url.path.withCString { path in - name.withCString { attributeName in - getxattr(path, attributeName, bytes.baseAddress, size, 0, 0) - } - } - } - guard readCount == size else { throw POSIXError(.EIO) } - return data -} - -private func permissions(_ url: URL) -> Int { - let attributes = try! FileManager.default.attributesOfItem(atPath: url.path) - return (attributes[.posixPermissions] as! NSNumber).intValue -} - -private func inode(_ url: URL) throws -> UInt64 { - let attributes = try FileManager.default.attributesOfItem(atPath: url.path) - guard let number = attributes[.systemFileNumber] as? NSNumber else { - throw RuntimeInstallerError.filesystem( - operation: "read inode", - path: url.path, - failure: .other(nil) - ) - } - return number.uint64Value -} - -private func installFixture(version: String, data: Data, store: RuntimeStore) throws -> InstalledRuntime { - let parsed = try XCTUnwrap(RuntimeReleaseVersion(rawValue: version)) - let temporary = try store.makePrivateTemporaryDirectory() - let executable = temporary.appendingPathComponent("forge3") - try data.write(to: executable) - return try store.installStagedRuntime( - executableURL: executable, - receipt: RuntimeStoreReceipt( - version: parsed, - architecture: .arm64, - archiveSHA256: String(repeating: "a", count: 64), - executableSHA256: RuntimeSHA256.hexDigest(of: data) - ), - temporaryDirectory: temporary - ) -} - -private extension RuntimeInstallationPhase { - var testName: String { - switch self { - case .resolving: return "resolving" - case .downloading: return "downloading" - case .verifying: return "verifying" - case .installing: return "installing" - case .ready: return "ready" - } - } - - static func testOrder(named value: String) -> Int? { - switch value { - case "phase:resolving": return 0 - case "phase:downloading": return 1 - case "phase:verifying": return 2 - case "phase:installing": return 3 - case "phase:ready": return 4 - default: return nil - } - } -} - -private final class LockedValues: @unchecked Sendable { - private let lock = NSLock() - private var stored: [Value] = [] - var values: [Value] { lock.withLock { stored } } - func append(_ value: Value) { lock.withLock { stored.append(value) } } -} - -private extension NSLock { - func withLock(_ body: () throws -> T) rethrows -> T { - lock() - defer { unlock() } - return try body() - } -} - -private extension ProcessInfo { - var machineArchitectureForTest: String { -#if arch(arm64) - return "arm64" -#else - return "x86_64" -#endif - } -} diff --git a/macos/Tests/ForgeMenuCoreTests/ServiceSupervisorTests.swift b/macos/Tests/ForgeMenuCoreTests/ServiceSupervisorTests.swift index 125c09e..6e12f5f 100644 --- a/macos/Tests/ForgeMenuCoreTests/ServiceSupervisorTests.swift +++ b/macos/Tests/ForgeMenuCoreTests/ServiceSupervisorTests.swift @@ -194,7 +194,7 @@ private final class SequenceRuntimeInstaller: RuntimeInstalling, @unchecked Send func installLatest( progress: @escaping @Sendable (RuntimeInstallationPhase) async -> Void ) async throws -> InstalledRuntime { - throw RuntimeInstallerError.network("unexpected install") + throw ThinInstallerError.download("unexpected install") } } @@ -264,126 +264,6 @@ private actor StubRPCClient: ForgeRPCClientProtocol { func versionCalls() -> Int { versionCallCount } } -private actor SupervisorRuntimeNetwork: RuntimeNetworkClient { - private let responses: [URL: Data] - private(set) var requestedURLs: [URL] = [] - - init(responses: [URL: Data]) { - self.responses = responses - } - - func download( - _ request: RuntimeDownloadRequest, - progress: @escaping RuntimeDownloadProgressHandler - ) async throws -> RuntimeDownload { - requestedURLs.append(request.url) - guard let data = responses[request.url] else { - throw RuntimeInstallerError.network("unexpected test URL") - } - await progress(0, Int64(data.count)) - await progress(Int64(data.count), Int64(data.count)) - return RuntimeDownload(data: data, responseURL: request.url) - } - - func requestCount() -> Int { requestedURLs.count } -} - -private final class FailOncePermissionCommitHook: @unchecked Sendable { - private let lock = NSLock() - private var shouldFail = true - - func failIfNeeded(path: String) throws { - try lock.withLock { - guard shouldFail else { return } - shouldFail = false - throw RuntimeFilesystemError.posix( - EACCES, - operation: "commit staged runtime", - path: path - ) - } - } -} - -private final class SupervisorRuntimeValidator: RuntimeExecutableValidating, @unchecked Sendable { - func validate(executableURL: URL, expectedArchitecture: RuntimeArchitecture) throws {} -} - -private struct SupervisorQuarantineManager: RuntimeQuarantineManaging { - func hasQuarantine( - at executableURL: URL, - expectedIdentity: RuntimeExecutableIdentity - ) throws -> Bool { - false - } - - func refreshExecutableRemovingQuarantine( - from executableURL: URL, - expectedIdentity: RuntimeExecutableIdentity - ) throws -> RuntimeExecutableIdentity { - XCTFail("unexpected quarantine refresh") - return expectedIdentity - } -} - -private actor SupervisorRuntimeArchive: RuntimeArchiveHandling { - private let executable: Data - - init(executable: Data) { - self.executable = executable - } - - func inspect( - archiveURL: URL, - temporaryDirectory: URL, - limits: RuntimeInstallerLimits - ) async throws -> RuntimeArchiveInspection { - let tarURL = temporaryDirectory.appendingPathComponent("supervisor-fixture.tar") - let tar = supervisorTar(executable: executable) - try tar.write(to: tarURL) - return try SafeTarXZArchiveHandler.parseTar(tar, tarURL: tarURL, limits: limits) - } - - nonisolated func extractExecutable( - from inspection: RuntimeArchiveInspection, - to destination: URL - ) throws { - try executable.write(to: destination) - try FileManager.default.setAttributes( - [.posixPermissions: 0o700], - ofItemAtPath: destination.path - ) - } -} - -private func supervisorTar(executable: Data) -> Data { - var header = Data(repeating: 0, count: 512) - writeSupervisorTar("package/forge3", to: &header, range: 0..<100) - writeSupervisorTar("0000700\0", to: &header, range: 100..<108) - writeSupervisorTar("0000000\0", to: &header, range: 108..<116) - writeSupervisorTar("0000000\0", to: &header, range: 116..<124) - writeSupervisorTar(String(format: "%011o\0", executable.count), to: &header, range: 124..<136) - writeSupervisorTar("00000000000\0", to: &header, range: 136..<148) - writeSupervisorTar(" ", to: &header, range: 148..<156) - header[156] = Character("0").asciiValue! - writeSupervisorTar("ustar\0", to: &header, range: 257..<263) - writeSupervisorTar("00", to: &header, range: 263..<265) - writeSupervisorTar(String(format: "%06o\0 ", header.reduce(0) { $0 + Int($1) }), to: &header, range: 148..<156) - - var tar = header - tar.append(executable) - if executable.count % 512 != 0 { - tar.append(Data(repeating: 0, count: 512 - executable.count % 512)) - } - tar.append(Data(repeating: 0, count: 1_024)) - return tar -} - -private func writeSupervisorTar(_ string: String, to data: inout Data, range: Range) { - let bytes = Array(string.utf8.prefix(range.count)) - data.replaceSubrange(range.lowerBound..<(range.lowerBound + bytes.count), with: bytes) -} - final class ServiceSupervisorTests: XCTestCase { func testPublishedSnapshotRevisionsAreStrictlyMonotonic() async throws { let process = RecordingProcessHost() @@ -697,7 +577,7 @@ final class ServiceSupervisorTests: XCTestCase { let installer = StubRuntimeInstaller( current: nil, results: [ - .failure(RuntimeInstallerError.network("offline")), + .failure(ThinInstallerError.download("offline")), .success(runtime) ] ) @@ -727,49 +607,18 @@ final class ServiceSupervisorTests: XCTestCase { await supervisor.stopForTermination() } - func testPermissionFailureCleansStagingAndRetryAutoLaunchesInstalledRuntime() async throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent("forge-supervisor-permission-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory( - at: base, - withIntermediateDirectories: false, - attributes: [.posixPermissions: 0o700] - ) - defer { - makeSupervisorTreeOwnerWritable(base) - try? FileManager.default.removeItem(at: base) - } - - let version = try XCTUnwrap(RuntimeReleaseVersion(rawValue: "1.2.3")) - let root = base.appendingPathComponent("runtime", isDirectory: true) - let archiveData = Data("permission-retry-archive".utf8) - let archiveURL = RuntimeReleaseURLs.archive(version: version, architecture: .arm64) - let checksumURL = RuntimeReleaseURLs.checksum(version: version, architecture: .arm64) - let network = SupervisorRuntimeNetwork(responses: [ - RuntimeReleaseURLs.latestManifest: Data("{\"version\":\"1.2.3\"}".utf8), - archiveURL: archiveData, - checksumURL: Data( - "\(RuntimeSHA256.hexDigest(of: archiveData)) *\(RuntimeArchitecture.arm64.archiveName)\n".utf8 - ) - ]) - let validator = SupervisorRuntimeValidator() - let permissionFault = FailOncePermissionCommitHook() - let store = RuntimeStore( - rootURL: root, - validator: validator, - commitHooks: .init(beforeActivationRename: { - try permissionFault.failIfNeeded(path: root.appendingPathComponent("current").path) - }) - ) - let installer = RuntimeInstaller( - architecture: .arm64, - dependencies: .init( - network: network, - store: store, - archive: SupervisorRuntimeArchive(executable: Data("forge3-fixture".utf8)), - validator: validator, - quarantineManager: SupervisorQuarantineManager() - ) + func testFilesystemFailureMapsToActionableErrorAndRetryAutoLaunches() async throws { + let runtime = StubRuntimeInstaller.fixture(path: "/fs-retry/forge3") + let installer = StubRuntimeInstaller( + current: nil, + results: [ + .failure(ThinInstallerError.filesystem( + operation: "create the forge3 install directory", + path: "/tmp/forge3", + failure: .permissionDenied + )), + .success(runtime) + ] ) let process = RecordingProcessHost() let supervisor = ServiceSupervisor( @@ -798,30 +647,12 @@ final class ServiceSupervisorTests: XCTestCase { ) XCTAssertTrue(failedPresentation.retryInstallationEnabled) XCTAssertTrue(process.recordedExecutables().isEmpty) - let failedRequestCount = await network.requestCount() - XCTAssertEqual(failedRequestCount, 3) - - let temporaryRoot = root.appendingPathComponent("tmp", isDirectory: true) - XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: temporaryRoot.path), []) - XCTAssertFalse( - FileManager.default.fileExists( - atPath: root.appendingPathComponent("versions/1.2.3/arm64", isDirectory: true).path - ), - "a failed real-store commit must roll back the moved staging directory" - ) - XCTAssertFalse(FileManager.default.fileExists(atPath: root.appendingPathComponent("current").path)) await supervisor.retryInstallation() - let expectedExecutable = root.appendingPathComponent("versions/1.2.3/arm64/forge3") - let didLaunch = await waitUntil { - process.recordedExecutables() == [expectedExecutable] - } + let didLaunch = await waitUntil { process.recordedExecutables() == [runtime.executableURL] } XCTAssertTrue(didLaunch) - XCTAssertEqual(process.recordedExecutables(), [expectedExecutable]) - let retriedRequestCount = await network.requestCount() - XCTAssertEqual(retriedRequestCount, 6) - XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: temporaryRoot.path), []) - XCTAssertEqual(try store.current(architecture: .arm64)?.executableURL, expectedExecutable) + XCTAssertEqual(installer.calls(), 2) + XCTAssertEqual(process.recordedExecutables(), [runtime.executableURL]) await supervisor.stopForTermination() } @@ -1151,23 +982,6 @@ private final class Locked: @unchecked Sendable { func withValue(_ body: (inout Value) -> Void) { lock.withLock { body(&stored) } } } -private func makeSupervisorTreeOwnerWritable(_ url: URL) { - var info = stat() - guard lstat(url.path, &info) == 0 else { return } - if (info.st_mode & S_IFMT) == S_IFDIR { - _ = chmod(url.path, 0o700) - if let children = try? FileManager.default.contentsOfDirectory( - at: url, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) { - for child in children { makeSupervisorTreeOwnerWritable(child) } - } - } else if (info.st_mode & S_IFMT) == S_IFREG { - _ = chmod(url.path, 0o600) - } -} - private extension NSLock { func withLock(_ body: () throws -> T) rethrows -> T { lock(); defer { unlock() }; return try body() diff --git a/macos/Tests/ForgeMenuCoreTests/ThinInstallerTests.swift b/macos/Tests/ForgeMenuCoreTests/ThinInstallerTests.swift new file mode 100644 index 0000000..aaed2a8 --- /dev/null +++ b/macos/Tests/ForgeMenuCoreTests/ThinInstallerTests.swift @@ -0,0 +1,173 @@ +import Foundation +import XCTest +@testable import ForgeMenuCore + +final class ThinInstallerTests: XCTestCase { + // MARK: - Env forwarding to the forge3 child + + func testSanitizedEnvironmentForwardsDebugAndAPIKeyVars() { + let sanitized = ForgeProcessHost.sanitizedEnvironment( + inherited: [ + "HOME": "/Users/test", + "FORGE_UPDATE_CURRENT_VERSION": "0.1.195", + "FORGE_UPDATE_MANIFEST_URL": "https://example.com/manifest.json", + "FORGE_UPDATE_INSTALLER_URL": "http://127.0.0.1:9877/install.sh", + "FORGE_CONSOLE_ORIGIN": "http://127.0.0.1:5173", + "FORGE3_API_KEY": "sk-forge3-key", + "SECRET_TOKEN": "do-not-inherit" + ], + overrides: [:] + ) + XCTAssertEqual(sanitized["FORGE_UPDATE_CURRENT_VERSION"], "0.1.195") + XCTAssertEqual(sanitized["FORGE_UPDATE_MANIFEST_URL"], "https://example.com/manifest.json") + XCTAssertEqual(sanitized["FORGE_UPDATE_INSTALLER_URL"], "http://127.0.0.1:9877/install.sh") + XCTAssertEqual(sanitized["FORGE_CONSOLE_ORIGIN"], "http://127.0.0.1:5173") + XCTAssertEqual(sanitized["FORGE3_API_KEY"], "sk-forge3-key") + XCTAssertNil(sanitized["SECRET_TOKEN"]) + XCTAssertNotNil(sanitized["PATH"], "PATH must be defaulted when absent") + } + + // MARK: - Installer invocation construction + + func testInstallerInvocationUsesShInstallDirNoModifyPathAndQuiet() { + let scriptURL = URL(fileURLWithPath: "/tmp/forge3-installer/forge3-installer.sh") + let installDir = URL(fileURLWithPath: "/Users/test/Library/Application Support/ForgeCode/forge3") + let invocation = RuntimeInstaller.makeInvocation(scriptURL: scriptURL, installDir: installDir) + + XCTAssertEqual(invocation.arguments, ["/bin/sh", scriptURL.path]) + XCTAssertEqual(invocation.environmentOverrides["FORGE3_INSTALL_DIR"], installDir.path) + XCTAssertEqual(invocation.environmentOverrides["FORGE3_NO_MODIFY_PATH"], "1") + XCTAssertEqual(invocation.environmentOverrides["FORGE3_PRINT_QUIET"], "1") + // Self-update must remain enabled: never disable the updater. + XCTAssertNil(invocation.environmentOverrides["FORGE3_DISABLE_UPDATE"]) + XCTAssertNil(invocation.environmentOverrides["FORGE3_UNMANAGED_INSTALL"]) + } + + func testInstallerURLDefaultsToPublicEndpoint() { + XCTAssertEqual( + RuntimeInstaller.resolveInstallerURL(environment: [:]).absoluteString, + "https://install.forgecode.dev/server" + ) + XCTAssertEqual( + RuntimeInstaller.resolveInstallerURL( + environment: ["FORGE_UPDATE_INSTALLER_URL": " "] + ).absoluteString, + "https://install.forgecode.dev/server" + ) + } + + func testInstallerURLOverrideHonoursEnvironment() { + let resolved = RuntimeInstaller.resolveInstallerURL( + environment: ["FORGE_UPDATE_INSTALLER_URL": "http://127.0.0.1:9877/install.sh"] + ) + XCTAssertEqual(resolved.absoluteString, "http://127.0.0.1:9877/install.sh") + } + + func testDefaultInstallDirIsUnderApplicationSupport() { + let library = URL(fileURLWithPath: "/Users/test/Library") + let dir = RuntimeInstaller.defaultInstallDir(libraryDirectory: library) + XCTAssertEqual(dir.path, "/Users/test/Library/Application Support/ForgeCode/forge3") + XCTAssertEqual( + RuntimeInstaller.executableURL(installDir: dir).path, + "/Users/test/Library/Application Support/ForgeCode/forge3/bin/forge3" + ) + } + + // MARK: - Installed-binary location (no network) + + func testInstalledCurrentRuntimeDetectsExecutableAtBinForge3() async throws { + let installDir = FileManager.default.temporaryDirectory + .appendingPathComponent("thin-install-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: installDir) } + let binDir = installDir.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + + let installer = RuntimeInstaller(installDir: installDir) + + // No executable yet. + let missing = try await installer.installedCurrentRuntime() + XCTAssertNil(missing) + + // Non-executable regular file is still "not installed". + let executable = binDir.appendingPathComponent("forge3") + try Data("#!/bin/sh\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: executable.path) + let notExecutable = try await installer.installedCurrentRuntime() + XCTAssertNil(notExecutable) + + // Executable present -> installed. + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + let installed = try await installer.installedCurrentRuntime() + XCTAssertEqual(installed?.executableURL, executable.standardizedFileURL) + XCTAssertEqual(installed?.version, .unknown) + } + + // MARK: - Installer subprocess execution + de-quarantine (no network) + + func testInstallLatestRunsScriptDeQuarantinesAndReturnsExecutable() async throws { + let installDir = FileManager.default.temporaryDirectory + .appendingPathComponent("thin-run-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: installDir) } + + // Fake installer script: create $FORGE3_INSTALL_DIR/bin/forge3. + let scriptDir = FileManager.default.temporaryDirectory + .appendingPathComponent("thin-script-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: scriptDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: scriptDir) } + let scriptURL = scriptDir.appendingPathComponent("install.sh") + let script = """ + #!/bin/sh + mkdir -p "$FORGE3_INSTALL_DIR/bin" + printf '#!/bin/sh\\nprintf forge3-ok\\n' > "$FORGE3_INSTALL_DIR/bin/forge3" + chmod +x "$FORGE3_INSTALL_DIR/bin/forge3" + """ + try Data(script.utf8).write(to: scriptURL) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) + + let installer = RuntimeInstaller(installDir: installDir, installerURL: scriptURL) + let phases = Locked<[RuntimeInstallationPhase]>([]) + let runtime = try await installer.installLatest { phase in + phases.withValue { $0.append(phase) } + } + + let executable = RuntimeInstaller.executableURL(installDir: installDir) + XCTAssertEqual(runtime.executableURL, executable) + XCTAssertTrue(FileManager.default.isExecutableFile(atPath: executable.path)) + XCTAssertTrue(phases.value.contains(.resolving)) + XCTAssertTrue(phases.value.contains(.installing)) + XCTAssertEqual(phases.value.last, .ready) + + // Second call sees it as already installed and short-circuits. + let cached = try await installer.installedCurrentRuntime() + XCTAssertEqual(cached?.executableURL, executable) + } + + func testInstallLatestSurfacesInstallerFailure() async throws { + let installDir = FileManager.default.temporaryDirectory + .appendingPathComponent("thin-fail-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: installDir) } + let scriptDir = FileManager.default.temporaryDirectory + .appendingPathComponent("thin-fail-script-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: scriptDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: scriptDir) } + let scriptURL = scriptDir.appendingPathComponent("install.sh") + try Data("#!/bin/sh\nexit 7\n".utf8).write(to: scriptURL) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) + + let installer = RuntimeInstaller(installDir: installDir, installerURL: scriptURL) + do { + _ = try await installer.installLatest() + XCTFail("a nonzero installer exit must throw") + } catch let error as ThinInstallerError { + XCTAssertEqual(error, .installerFailed(status: 7)) + } + } +} + +private final class Locked: @unchecked Sendable { + private let lock = NSLock() + private var stored: Value + init(_ value: Value) { stored = value } + var value: Value { lock.lock(); defer { lock.unlock() }; return stored } + func withValue(_ body: (inout Value) -> Void) { lock.lock(); defer { lock.unlock() }; body(&stored) } +} From 07379de6bcbbeb72081d8880356bf93318f28a15 Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:18:51 +0530 Subject: [PATCH 3/4] docs(macos): document thin shell-installer runtime flow in README --- macos/README.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/macos/README.md b/macos/README.md index f659629..60062a1 100644 --- a/macos/README.md +++ b/macos/README.md @@ -87,7 +87,7 @@ The query value always reflects the port the helper actually bound, so a fallbac ## Service architecture -When the app launches and first requires the service, ForgeCode installs the runtime under `~/Library/Application Support/ForgeCode/runtime` if it is not already present. Before a newly downloaded runtime is activated, the installer validates the immutable HTTPS release version used for tag and artifact URL selection, checksum sidecar, archive structure, Mach-O architecture, embedded signature structure, signature class, pinned executable identity, and quarantine policy. Installed runtime directories, executables, and receipts use modes `0700`, `0700`, and `0600`. On later lookup or launch, the receipt is metadata and a candidate locator only: stale version/hash fields are allowed, and neither receipt hashes nor executable checksum, Mach-O, signature, inode, size, or original version are revalidated. The managed executable is accepted only through a safe no-symlink path with a private current-owner directory and a current-owner, regular, single-link, non-group/world-writable, owner-executable file. Parent-directory-pinned relative spawning preserves path-race protection while allowing legitimate self-replacement between launches. It then launches the installed runtime as: +When the app launches and first requires the service, ForgeCode installs the runtime under `~/Library/Application Support/ForgeCode/forge3` if it is not already present by downloading and running forge3's upstream cargo-dist shell installer from `https://install.forgecode.dev/server` via `/bin/sh`. The app is a thin wrapper: it sets `FORGE3_INSTALL_DIR` to that fixed directory (binary lands at `/bin/forge3`), `FORGE3_NO_MODIFY_PATH=1` so no shell rc files are touched, and `FORGE3_PRINT_QUIET=1`. The managed install still writes forge3's receipt at `~/.config/forge3/forge3-receipt.json` so forge3's own axoupdater-based self-update keeps working. The installer script is fully trusted: the app performs no checksum, signature, or Mach-O verification. As the only Gatekeeper workaround, `com.apple.quarantine` is best-effort removed from the installed binary. Set `FORGE_UPDATE_INSTALLER_URL` to override the installer script source (e.g. a local `http://127.0.0.1:9877/install.sh`). The app locates the binary by the exact path `/bin/forge3` and launches it as: ```text forge3 --log-format json ws --addr 127.0.0.1: @@ -95,17 +95,19 @@ forge3 --log-format json ws --addr 127.0.0.1: ### Running a locally built runtime (debug builds only) -Set `FORGE_RUNTIME_BINARY` to an absolute path to launch your own `forge3` instead of the managed runtime. No download, staging, or store validation runs, and the store is left untouched: +Set `FORGE_RUNTIME_BINARY` to an absolute path to launch your own `forge3` instead of running the upstream installer. No download or install step runs: ```text FORGE_RUNTIME_BINARY=/path/to/forge3 .build/debug/ForgeMenuBar ``` -This is compiled out of release builds — `DeveloperRuntimeOverride.resolve` returns `nil` unconditionally when `DEBUG` is not defined, so a shipped app cannot be redirected to an arbitrary binary. An unset variable is ignored; a variable that is set but does not point at an executable regular file fails loudly rather than silently falling back to the downloaded runtime. The launch-boundary checks still apply to the override: the binary must be a regular, single-link, current-owner, non-group/world-writable, owner-executable file reached through a no-symlink private path. The override reports version `0.0.0` so it never registers as an upgrade. +This is compiled out of release builds — `DeveloperRuntimeOverride.resolve` returns `nil` unconditionally when `DEBUG` is not defined, so a shipped app cannot be redirected to an arbitrary binary. An unset variable is ignored; a variable that is set but does not point at an executable regular file fails loudly rather than silently falling back to running the installer. The override must be an absolute path to an executable regular file and reports version `0.0.0` so it never registers as an upgrade. + +Debug environment variables are forwarded from the app's environment to the forge3 child process: `FORGE_UPDATE_CURRENT_VERSION`, `FORGE_UPDATE_MANIFEST_URL`, `FORGE_UPDATE_INSTALLER_URL`, `FORGE_CONSOLE_ORIGIN`, and `FORGE3_API_KEY`. Because a GUI launch from Finder or Dock does not inherit your shell environment, this variable — like `FORGE_CONSOLE_ORIGIN` — is only visible when the executable is launched directly from a terminal. -The ForgeCode installer does not poll for newer runtimes at app startup or in the background. The app's own lifetime is the desired state: the service is started at launch and stopped on termination. Unexpected exits and readiness failures use bounded exponential restart backoff. Stop and app termination use bounded `TERM`, `KILL`, and child reaping, with best-effort process-group cleanup. The runtime environment is allowlisted instead of copied wholesale, and JSON-aware/text fallback redaction is applied before bounded rotating logs are stored in `~/Library/Logs/ForgeMenuBar/`. +The app does not poll for newer runtimes at startup or in the background and performs no explicit update checks. forge3 self-updates by re-running its installer in place and exiting; the app simply restarts the binary whenever it exits (an exit status of `75` on the first attempt restarts immediately, which is how a self-update is picked up). The app's own lifetime is the desired state: the service is started at launch and stopped on termination. Unexpected exits and readiness failures use bounded exponential restart backoff. forge3 is spawned in its own process group; stop and app termination use bounded `TERM`, `KILL`, and child reaping against that group so no orphan processes survive the app. The runtime environment is allowlisted instead of copied wholesale, and JSON-aware/text fallback redaction is applied before bounded rotating logs are stored in `~/Library/Logs/ForgeMenuBar/`. The active app does not poll usage, extensions, models, or providers. Readiness and health are probed with a single one-shot `rpc.discover` request per lifecycle: a successful round trip marks the service ready, and `result.data.complete["rpc.discover"].info.version` supplies the server version shown in the header. Failures inside the readiness window retry on a short poll interval; persistent failure fails the lifecycle and triggers a supervised restart. Process and probe generations prevent late responses from superseded work from updating current state. Stopping the service clears all service-derived state. @@ -125,11 +127,10 @@ Useful individual commands are `scripts/assemble-app.sh`, `scripts/sign-app.sh`, ## Current limitations and security -- The native installer accepts only HTTPS runtime URLs at the configured release origin and limits redirects to that same host, with no credentials, explicit port, or fragment. This same-origin rule narrows redirect and credential-leak risk but does not independently authenticate every object served by a compromised origin; immutable version/tag URL selection, archive checksums, Mach-O validation, signature structure, and configured Developer ID Team ID authentication remain required controls. -- An embedded ad-hoc code signature, when present on a runtime, proves only self-consistency and is not an Apple- or publisher-backed identity. The installer retains the inspected signature class, Team Identifier, and signing identity rather than treating display-name text as provenance; checksum trust and the release origin remain security boundaries. A signed runtime is classified as Developer ID only when Security framework requirement evaluation validates the Apple generic anchor, the Developer ID intermediate certificate OID, and the Developer ID Application leaf certificate OID. Authentication repeats that supported code-signing requirement with the leaf certificate organizational unit bound to the exact explicitly configured Team Identifier. A self-signed or subject-name lookalike certificate is never Developer ID. An unset expected Team ID, missing signature Team ID, mismatch, requirement-construction failure, or requirement-evaluation failure stops before quarantine policy or installation. The production default leaves the expected Team Identifier unset, preserving the current ad-hoc artifact path while failing closed if the distribution changes to Developer ID until its Apple-issued Team Identifier is deliberately configured. -- The public runtime smoke on July 29, 2026 resolves `forge3 0.1.191` and validates installation plus cold cache recovery without an installer `--version` execution. The runtime contains its own update notifier, which is separate from the native installer. Tests assert that current-cache reuse, versioned-cache reuse, and self-updated cold recovery each issue exactly zero `RuntimeNetworkClient` requests. The desktop app neither invokes forge3's update commands nor renders its widgets, but no supported public-release environment variable or launch argument currently disables the launched runtime's own checks. Fully satisfying zero SDK/server update checks therefore requires upstream `forge3` support or a specially built runtime; this repository does not modify the SDK. +- The runtime is installed by downloading and executing forge3's upstream cargo-dist shell installer. The app performs no checksum, signature, Mach-O, or launch-security verification of the installer or the resulting binary — the installer script and the machine are fully trusted. Trust rests entirely on the HTTPS origin serving the installer script (`https://install.forgecode.dev/server`, overridable via `FORGE_UPDATE_INSTALLER_URL` for testing). +- As the only Gatekeeper workaround, the app best-effort removes `com.apple.quarantine` from the installed `forge3` binary. The installer script itself is executed via `/bin/sh` (interpreted, not a Gatekeeper-assessed Mach-O launch). +- The app performs no explicit runtime update checks. forge3 self-updates by re-running its own installer and replacing its binary in place, then exiting; the app relaunches on exit. This is the entire update mechanism from the app's perspective and is fully separate from the app's own Sparkle-based self-updates. - A release cannot be notarized or assessed as Developer ID software without an installed Developer ID Application identity and a valid `notarytool` profile. -- **Temporary explicit ad-hoc runtime trust policy:** the current public `forge3` artifact is ad-hoc signed rather than Developer ID signed/notarized. After the installer validates the immutable HTTPS version/tag selection, checksum sidecar, archive safety, native Mach-O architecture, embedded signature structure, signature class, and stable single-link user-owned executable identity, it inspects quarantine. If and only if the validated signature class is ad-hoc and `com.apple.quarantine` is present, the injected pre-execution trust policy authorizes a fresh same-directory vnode. Descriptor-relative exclusive no-follow operations copy bytes from the pinned validated source vnode, preserve safe executable permissions and unrelated extended attributes, omit only quarantine, synchronize the file and directory, and atomically replace the staged basename. Before installation, the installer captures a new device/inode/size/hash identity and repeats hash, Mach-O, architecture, embedded-signature, ad-hoc-class, identity, and quarantine-absence checks; any failure prevents activation. Developer ID, other signed, unsigned, or already-unquarantined artifacts are not modified. The installer does not remove quarantine in response to runtime launch behavior because it performs no staged runtime execution. This deliberately bypasses Gatekeeper's quarantine-based first-launch assessment only for the exact checksum- and identity-validated ad-hoc artifact; HTTPS origin and checksum integrity are therefore temporary trust boundaries until upstream publishes a properly Developer ID signed and notarized runtime, at which point this exception should be removed. - The installed runtime owns the selected port after a short bind-probe race; readiness supervision handles a lost race by stopping and retrying on the next available incremental port. - Process-group cleanup is best effort on macOS. Descendants that deliberately escape the runtime's process group require a privileged service architecture for reliable discovery. - The popover is intentionally fixed in size; overflowing content scrolls, and long text truncates while remaining available as tooltips. From 384a09fcb4039189d48d163e6394b22a5fc17306 Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:03:41 +0530 Subject: [PATCH 4/4] refactor(macos): install forge3 into the cargo home like the upstream script The installer script's own default target is $CARGO_HOME (falling back to ~/.cargo), and forge3's self-update always writes back there. Pinning FORGE3_INSTALL_DIR to Application Support made the app disagree with the runtime about where the binary lives. Use the cargo home so the app, the installer, and forge3's self-update all operate on ~/.cargo/bin/forge3. --- macos/README.md | 2 +- .../ForgeMenuCore/RuntimeInstaller.swift | 30 ++++++++++++------- .../ThinInstallerTests.swift | 24 +++++++++++---- 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/macos/README.md b/macos/README.md index 60062a1..a3388c6 100644 --- a/macos/README.md +++ b/macos/README.md @@ -87,7 +87,7 @@ The query value always reflects the port the helper actually bound, so a fallbac ## Service architecture -When the app launches and first requires the service, ForgeCode installs the runtime under `~/Library/Application Support/ForgeCode/forge3` if it is not already present by downloading and running forge3's upstream cargo-dist shell installer from `https://install.forgecode.dev/server` via `/bin/sh`. The app is a thin wrapper: it sets `FORGE3_INSTALL_DIR` to that fixed directory (binary lands at `/bin/forge3`), `FORGE3_NO_MODIFY_PATH=1` so no shell rc files are touched, and `FORGE3_PRINT_QUIET=1`. The managed install still writes forge3's receipt at `~/.config/forge3/forge3-receipt.json` so forge3's own axoupdater-based self-update keeps working. The installer script is fully trusted: the app performs no checksum, signature, or Mach-O verification. As the only Gatekeeper workaround, `com.apple.quarantine` is best-effort removed from the installed binary. Set `FORGE_UPDATE_INSTALLER_URL` to override the installer script source (e.g. a local `http://127.0.0.1:9877/install.sh`). The app locates the binary by the exact path `/bin/forge3` and launches it as: +When the app launches and first requires the service, ForgeCode installs the runtime into the cargo home (`$CARGO_HOME`, defaulting to `~/.cargo`, so the binary lands at `~/.cargo/bin/forge3`) if it is not already present by downloading and running forge3's upstream cargo-dist shell installer from `https://install.forgecode.dev/server` via `/bin/sh`. This is the same directory the script installs to on its own and the same one forge3's self-update writes back to, so the app, the installer, and the runtime always agree on the binary's location. The app is a thin wrapper: it sets `FORGE3_INSTALL_DIR` to that directory, `FORGE3_NO_MODIFY_PATH=1` so no shell rc files are touched, and `FORGE3_PRINT_QUIET=1`. The managed install still writes forge3's receipt at `~/.config/forge3/forge3-receipt.json` so forge3's own axoupdater-based self-update keeps working. The installer script is fully trusted: the app performs no checksum, signature, or Mach-O verification. As the only Gatekeeper workaround, `com.apple.quarantine` is best-effort removed from the installed binary. Set `FORGE_UPDATE_INSTALLER_URL` to override the installer script source (e.g. a local `http://127.0.0.1:9877/install.sh`). The app locates the binary by the exact path `/bin/forge3` and launches it as: ```text forge3 --log-format json ws --addr 127.0.0.1: diff --git a/macos/Sources/ForgeMenuCore/RuntimeInstaller.swift b/macos/Sources/ForgeMenuCore/RuntimeInstaller.swift index c535f2e..867a1bb 100644 --- a/macos/Sources/ForgeMenuCore/RuntimeInstaller.swift +++ b/macos/Sources/ForgeMenuCore/RuntimeInstaller.swift @@ -15,11 +15,13 @@ public protocol RuntimeInstalling: Sendable { /// /// First run downloads the installer script from `installerURL` (default /// `https://install.forgecode.dev/server`) and runs it via `/bin/sh` with -/// `FORGE3_INSTALL_DIR` pinned to a deterministic directory. The binary lands -/// at `/bin/forge3` (cargo-home layout) and a receipt is written to -/// `~/.config/forge3/forge3-receipt.json` so forge3's own axoupdater self-update -/// keeps working. No checksum, signature, or Mach-O verification is performed — -/// the installer script is fully trusted. +/// `FORGE3_INSTALL_DIR` pinned to the cargo home (`$CARGO_HOME`, falling back +/// to `~/.cargo`) — the same directory the script installs to by default and +/// the same one forge3's own self-update always writes back to. The binary +/// lands at `/bin/forge3` and a receipt is written to +/// `~/.config/forge3/forge3-receipt.json` so forge3's self-update keeps +/// working. No checksum, signature, or Mach-O verification is performed — the +/// installer script is fully trusted. public actor RuntimeInstaller: RuntimeInstalling { /// Overrides the installer script source. `FORGE_UPDATE_INSTALLER_URL=...` /// (e.g. `http://127.0.0.1:9877/install.sh`) points the app at a local @@ -52,13 +54,21 @@ public actor RuntimeInstaller: RuntimeInstalling { self.maximumScriptBytes = maximumScriptBytes } - /// Deterministic install directory: `~/Library/Application Support/ForgeCode/forge3`. + /// The install directory the upstream installer script targets by default: + /// `$CARGO_HOME`, falling back to `~/.cargo`. forge3's self-update installs + /// back into the same directory, so the app and the runtime always agree on + /// where the binary lives (`/bin/forge3`). public static func defaultInstallDir( - libraryDirectory: URL? = nil, - fileManager: FileManager = .default + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL? = nil ) -> URL { - let library = libraryDirectory ?? fileManager.urls(for: .libraryDirectory, in: .userDomainMask)[0] - return library.appendingPathComponent("Application Support/ForgeCode/forge3", isDirectory: true) + if let cargoHome = environment["CARGO_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !cargoHome.isEmpty { + return URL(fileURLWithPath: (cargoHome as NSString).expandingTildeInPath, isDirectory: true) + .standardizedFileURL + } + let home = homeDirectory ?? FileManager.default.homeDirectoryForCurrentUser + return home.appendingPathComponent(".cargo", isDirectory: true).standardizedFileURL } /// The absolute path the installed executable is expected at. diff --git a/macos/Tests/ForgeMenuCoreTests/ThinInstallerTests.swift b/macos/Tests/ForgeMenuCoreTests/ThinInstallerTests.swift index aaed2a8..b9bb386 100644 --- a/macos/Tests/ForgeMenuCoreTests/ThinInstallerTests.swift +++ b/macos/Tests/ForgeMenuCoreTests/ThinInstallerTests.swift @@ -31,7 +31,7 @@ final class ThinInstallerTests: XCTestCase { func testInstallerInvocationUsesShInstallDirNoModifyPathAndQuiet() { let scriptURL = URL(fileURLWithPath: "/tmp/forge3-installer/forge3-installer.sh") - let installDir = URL(fileURLWithPath: "/Users/test/Library/Application Support/ForgeCode/forge3") + let installDir = URL(fileURLWithPath: "/Users/test/.cargo") let invocation = RuntimeInstaller.makeInvocation(scriptURL: scriptURL, installDir: installDir) XCTAssertEqual(invocation.arguments, ["/bin/sh", scriptURL.path]) @@ -63,13 +63,25 @@ final class ThinInstallerTests: XCTestCase { XCTAssertEqual(resolved.absoluteString, "http://127.0.0.1:9877/install.sh") } - func testDefaultInstallDirIsUnderApplicationSupport() { - let library = URL(fileURLWithPath: "/Users/test/Library") - let dir = RuntimeInstaller.defaultInstallDir(libraryDirectory: library) - XCTAssertEqual(dir.path, "/Users/test/Library/Application Support/ForgeCode/forge3") + func testDefaultInstallDirIsCargoHome() { + let home = URL(fileURLWithPath: "/Users/test") + let dir = RuntimeInstaller.defaultInstallDir(environment: [:], homeDirectory: home) + XCTAssertEqual(dir.path, "/Users/test/.cargo") XCTAssertEqual( RuntimeInstaller.executableURL(installDir: dir).path, - "/Users/test/Library/Application Support/ForgeCode/forge3/bin/forge3" + "/Users/test/.cargo/bin/forge3" + ) + } + + func testDefaultInstallDirHonoursCargoHomeEnvironment() { + let dir = RuntimeInstaller.defaultInstallDir( + environment: ["CARGO_HOME": "/opt/cargo"], + homeDirectory: URL(fileURLWithPath: "/Users/test") + ) + XCTAssertEqual(dir.path, "/opt/cargo") + XCTAssertEqual( + RuntimeInstaller.executableURL(installDir: dir).path, + "/opt/cargo/bin/forge3" ) }