From 6d69b701f4d5fcd81b5487d57fbce5f308bbe79a Mon Sep 17 00:00:00 2001 From: justiceadams Date: Wed, 16 Jul 2025 14:11:43 -0700 Subject: [PATCH 1/6] refactor platform functions to get packages independently --- Sources/LinuxPlatform/Linux.swift | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index 386e69db..1bd2ac50 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. " From 59eebd94dd3601e7e48b4c3595e1a0f46ab5f030 Mon Sep 17 00:00:00 2001 From: justiceadams Date: Wed, 16 Jul 2025 16:09:25 -0700 Subject: [PATCH 2/6] move refactored signatures into Platform protocol --- Sources/LinuxPlatform/Linux.swift | 4 ++-- Sources/SwiftlyCore/Platform.swift | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index 1bd2ac50..5708302d 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -231,7 +231,7 @@ public struct Linux: Platform { return packages } - public func getSystemPackageManager(platformName: String) -> [String] { + public func getSystemPackageManager(platformName: String) -> String? { let manager: String? = switch platformName { @@ -262,7 +262,7 @@ public struct Linux: Platform { _ ctx: SwiftlyCoreContext, platformName: String, version _: ToolchainVersion, requireSignatureValidation: Bool ) async throws -> String? { - let packages: [String] = getSystemPrerequisites(platformName: platformName)) + let packages: [String] = getSystemPrerequisites(platformName: platformName) let manager: String? = getSystemPackageManager(platformName: platformName) diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index be388960..3dc80656 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -107,6 +107,18 @@ 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 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. From f604ff7d04050e4a7bd20169365ee69b709738c6 Mon Sep 17 00:00:00 2001 From: justiceadams Date: Thu, 17 Jul 2025 14:30:48 -0700 Subject: [PATCH 3/6] add output schema for dependency list --- Sources/Swiftly/OutputSchema.swift | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Sources/Swiftly/OutputSchema.swift b/Sources/Swiftly/OutputSchema.swift index 6c971ed2..34832645 100644 --- a/Sources/Swiftly/OutputSchema.swift +++ b/Sources/Swiftly/OutputSchema.swift @@ -364,3 +364,36 @@ 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 { + 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") + } +} From 2618b9c7d223945dcc3f03509218f4722fb96161 Mon Sep 17 00:00:00 2001 From: justiceadams Date: Thu, 17 Jul 2025 14:31:40 -0700 Subject: [PATCH 4/6] hoist isSystemPackageInstalled to the platform protocal --- Sources/SwiftlyCore/Platform.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index 3dc80656..f303e48a 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -114,6 +114,9 @@ public protocol Platform: Sendable { /// 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 From be3bc1204a5d27e9f9b0bed79fc9e0cdb96ff5b5 Mon Sep 17 00:00:00 2001 From: justiceadams Date: Mon, 21 Jul 2025 15:01:18 -0700 Subject: [PATCH 5/6] Add command entrypoint --- Sources/MacOSPlatform/MacOS.swift | 12 ++++ Sources/Swiftly/ListDependencies.swift | 98 ++++++++++++++++++++++++++ Sources/Swiftly/OutputSchema.swift | 4 ++ Sources/Swiftly/Swiftly.swift | 1 + 4 files changed, 115 insertions(+) create mode 100644 Sources/Swiftly/ListDependencies.swift 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..f45729b1 --- /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 + + 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)' + """ + // ToDo: make dynamic via an arg + let promptForConfirmation = true + + if promptForConfirmation { + 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 34832645..e86e4cc4 100644 --- a/Sources/Swiftly/OutputSchema.swift +++ b/Sources/Swiftly/OutputSchema.swift @@ -377,6 +377,10 @@ struct ToolchainDependencyInfo: OutputData { 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("----------------------------") 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, From c910b4fb0cfda55351b832dce6d15ed15a1759b8 Mon Sep 17 00:00:00 2001 From: justiceadams Date: Mon, 21 Jul 2025 15:08:03 -0700 Subject: [PATCH 6/6] add --install-system-deps (-i) option --- Sources/Swiftly/ListDependencies.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/Swiftly/ListDependencies.swift b/Sources/Swiftly/ListDependencies.swift index f45729b1..74a4f8c1 100644 --- a/Sources/Swiftly/ListDependencies.swift +++ b/Sources/Swiftly/ListDependencies.swift @@ -10,6 +10,9 @@ struct ListDependencies: SwiftlyCommand { @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 { @@ -28,7 +31,7 @@ struct ListDependencies: SwiftlyCommand { // 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] = [] @@ -43,7 +46,7 @@ struct ListDependencies: SwiftlyCommand { try await ctx.output( ToolchainDependencyInfo(installedDependencies: installedDeps, missingDependencies: missingDeps) ) - + if !missingDeps.isEmpty, let packageManager { let installCmd = "\(packageManager) -y install \(missingDeps.joined(separator: " "))" @@ -53,12 +56,9 @@ struct ListDependencies: SwiftlyCommand { '--install-system-deps'/'-i' option): '\(installCmd)' """ - // ToDo: make dynamic via an arg - let promptForConfirmation = true - - if promptForConfirmation { + if !installSystemDeps { await ctx.message(msg) - + guard await ctx.promptForConfirmation(defaultBehavior: true) else { throw SwiftlyError(message: "System dependency installation has been cancelled") }