diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index 386e69db..5708302d 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -72,10 +72,7 @@ public struct Linux: Platform { } } - public func verifySystemPrerequisitesForInstall( - _ ctx: SwiftlyCoreContext, platformName: String, version _: ToolchainVersion, - requireSignatureValidation: Bool - ) async throws -> String? { + public func getSystemPrerequisites(platformName: String) -> [String] { // TODO: these are hard-coded until we have a place to query for these based on the toolchain version // These lists were copied from the dockerfile sources here: https://github.com/apple/swift-docker/tree/ea035798755cce4ec41e0c6dbdd320904cef0421/5.10 let packages: [String] = @@ -231,6 +228,10 @@ public struct Linux: Platform { [] } + return packages + } + + public func getSystemPackageManager(platformName: String) -> String? { let manager: String? = switch platformName { @@ -254,6 +255,17 @@ public struct Linux: Platform { nil } + return manager + } + + public func verifySystemPrerequisitesForInstall( + _ ctx: SwiftlyCoreContext, platformName: String, version _: ToolchainVersion, + requireSignatureValidation: Bool + ) async throws -> String? { + let packages: [String] = getSystemPrerequisites(platformName: platformName) + + let manager: String? = getSystemPackageManager(platformName: platformName) + if requireSignatureValidation { guard (try? self.runProgram("gpg", "--version", quiet: true)) != nil else { var msg = "gpg is not installed. " diff --git a/Sources/MacOSPlatform/MacOS.swift b/Sources/MacOSPlatform/MacOS.swift index fc2ee67e..679883bf 100644 --- a/Sources/MacOSPlatform/MacOS.swift +++ b/Sources/MacOSPlatform/MacOS.swift @@ -42,6 +42,18 @@ public struct MacOS: Platform { "pkg" } + public func getSystemPrerequisites(platformName: String) -> [String] { + return [] + } + + public func isSystemPackageInstalled(_ manager: String?, _ package: String) async -> Bool { + return false + } + + public func getSystemPackageManager(platformName: String) -> String? { + return nil + } + public func verifySwiftlySystemPrerequisites() throws { // All system prerequisites are there for swiftly on macOS } diff --git a/Sources/Swiftly/ListDependencies.swift b/Sources/Swiftly/ListDependencies.swift new file mode 100644 index 00000000..74a4f8c1 --- /dev/null +++ b/Sources/Swiftly/ListDependencies.swift @@ -0,0 +1,98 @@ +import ArgumentParser +import Foundation +import SwiftlyCore + +struct ListDependencies: SwiftlyCommand { + public static let configuration = CommandConfiguration( + abstract: "List toolchain dependencies required for the given platform." + ) + + @Option(name: .long, help: "Output format (text, json)") + var format: SwiftlyCore.OutputFormat = .text + + @Flag(name: .shortAndLong, help: "Automatically install missing system dependencies with elevated permissions") + var installSystemDeps: Bool = false + + internal static var allowedInstallCommands: Regex<(Substring, Substring, Substring)> { try! Regex("^(apt-get|yum) -y install( [A-Za-z0-9:\\-\\+]+)+$") } + + mutating func run() async throws { + try await self.run(Swiftly.createDefaultContext(format: self.format)) + } + + mutating func run(_ ctx: SwiftlyCoreContext) async throws { + let versionUpdateReminder = try await validateSwiftly(ctx) + defer { + versionUpdateReminder() + } + try await validateLinked(ctx) + + var config = try await Config.load(ctx) + + // Get the dependencies which must be required for this platform + let dependencies = try await Swiftly.currentPlatform.getSystemPrerequisites(platformName: config.platform.name) + let packageManager = try await Swiftly.currentPlatform.getSystemPackageManager(platformName: config.platform.name) + + // Determine which dependencies are missing and which are installed + var installedDeps: [String] = [] + var missingDeps: [String] = [] + for dependency in dependencies { + if await Swiftly.currentPlatform.isSystemPackageInstalled(packageManager, dependency) { + installedDeps.append(dependency) + } else { + missingDeps.append(dependency) + } + } + + try await ctx.output( + ToolchainDependencyInfo(installedDependencies: installedDeps, missingDependencies: missingDeps) + ) + + if !missingDeps.isEmpty, let packageManager { + let installCmd = "\(packageManager) -y install \(missingDeps.joined(separator: " "))" + + let msg = """ + + For your convenience, would you like swiftly to attempt to use elevated permissions to run the following command in order to install the missing toolchain dependencies (This prompt can be suppressed with the + '--install-system-deps'/'-i' option): + '\(installCmd)' + """ + if !installSystemDeps { + await ctx.message(msg) + + guard await ctx.promptForConfirmation(defaultBehavior: true) else { + throw SwiftlyError(message: "System dependency installation has been cancelled") + } + } else { + await ctx.message("Swiftly will run the following command with elevated permissions: \(installCmd)") + } + + // This is very security sensitive code here and that's why there's special process handling + // and an allow-list of what we will attempt to run as root. Also, the sudo binary is run directly + // with a fully-qualified path without any checking in order to avoid TOCTOU. + guard try Self.allowedInstallCommands.wholeMatch(in: installCmd) != nil else { + fatalError("Command \(installCmd) does not match allowed patterns for sudo") + } + + let p = Process() + p.executableURL = URL(fileURLWithPath: "/usr/bin/sudo") + p.arguments = ["-k"] + ["-p", "Enter your sudo password to run the dependency install command right away (Ctrl-C aborts): "] + installCmd.split(separator: " ").map { String($0) } + do { + try p.run() + // Attach this process to our process group so that Ctrl-C and other signals work + let pgid = tcgetpgrp(STDOUT_FILENO) + if pgid != -1 { + tcsetpgrp(STDOUT_FILENO, p.processIdentifier) + } + defer { if pgid != -1 { + tcsetpgrp(STDOUT_FILENO, pgid) + }} + p.waitUntilExit() + if p.terminationStatus != 0 { + throw SwiftlyError(message: "") + } + } catch { + throw SwiftlyError(message: "Error: sudo could not be run to install the packages. You will need to run the dependency install command manually.") + } + } + } +} diff --git a/Sources/Swiftly/OutputSchema.swift b/Sources/Swiftly/OutputSchema.swift index 6c971ed2..e86e4cc4 100644 --- a/Sources/Swiftly/OutputSchema.swift +++ b/Sources/Swiftly/OutputSchema.swift @@ -364,3 +364,40 @@ struct InstallInfo: OutputData { try container.encode(self.alreadyInstalled, forKey: .alreadyInstalled) } } + +struct ToolchainDependencyInfo: OutputData { + let installedDependencies: [String] + let missingDependencies: [String] + + private enum CodingKeys: String, CodingKey { + case installedDependencies + case missingDependencies + } + + var description: String { + var lines: [String] = [] + + if installedDependencies.isEmpty && missingDependencies.isEmpty { + lines.append("There are no toolchain dependencies for this platform") + } + + if !installedDependencies.isEmpty { + lines.append("Already installed toolchain dependencies") + lines.append("----------------------------") + for dependency in installedDependencies { + lines.append("• \(dependency)") + } + } + + if !missingDependencies.isEmpty { + lines.append("\n") + lines.append("Missing toolchain dependencies") + lines.append("----------------------------") + for dependency in missingDependencies { + lines.append("• \(dependency)") + } + } + + return lines.joined(separator: "\n") + } +} diff --git a/Sources/Swiftly/Swiftly.swift b/Sources/Swiftly/Swiftly.swift index 6660b9ab..355c6e18 100644 --- a/Sources/Swiftly/Swiftly.swift +++ b/Sources/Swiftly/Swiftly.swift @@ -39,6 +39,7 @@ public struct Swiftly: SwiftlyCommand { subcommands: [ Install.self, ListAvailable.self, + ListDependencies.self, Use.self, Uninstall.self, List.self, diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index be388960..f303e48a 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -107,6 +107,21 @@ public protocol Platform: Sendable { _ ctx: SwiftlyCoreContext, platformName: String, version: ToolchainVersion, requireSignatureValidation: Bool ) async throws -> String? + + /// Returns the list of system requirements needed to install a swift toolchain on the provided platform. + /// + /// `platformName` is the platform name of the system + /// + func getSystemPrerequisites(platformName: String) -> [String] + + /// Returns true if a given system is installed on the system. + func isSystemPackageInstalled(_ manager: String?, _ package: String) async -> Bool + + /// Returns the package manger if it exists for the given platform + /// + /// `platformName` is the platform name of the system + /// + func getSystemPackageManager(platformName: String) -> String? /// Downloads the signature file associated with the archive and verifies it matches the downloaded archive. /// Throws an error if the signature does not match.