diff --git a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md index 8a667e17..4f4137a6 100644 --- a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md +++ b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md @@ -405,7 +405,7 @@ written to this file as commands that can be run after the installation. Perform swiftly initialization into your user account. ``` -swiftly init [--no-modify-profile] [--overwrite] [--platform=] [--skip-install] [--quiet-shell-followup] [--assume-yes] [--verbose] [--version] [--help] +swiftly init [--no-modify-profile] [--overwrite] [--platform=] [--skip-install] [--quiet-shell-followup] [--sudo-install-packages] [--assume-yes] [--verbose] [--version] [--help] ``` **--no-modify-profile:** @@ -433,6 +433,11 @@ swiftly init [--no-modify-profile] [--overwrite] [--platform=] [--skip *Quiet shell follow up commands* +**--sudo-install-packages:** + +*Run sudo if there are post-installation packages to install (Linux only)* + + **--assume-yes:** *Disable confirmation prompts by assuming 'yes'* diff --git a/Sources/Swiftly/Init.swift b/Sources/Swiftly/Init.swift index bc071334..ecd7050d 100644 --- a/Sources/Swiftly/Init.swift +++ b/Sources/Swiftly/Init.swift @@ -20,21 +20,25 @@ internal struct Init: SwiftlyCommand { var skipInstall: Bool = false @Flag(help: "Quiet shell follow up commands") var quietShellFollowup: Bool = false + @Flag(help: "Run sudo if there are post-installation packages to install (Linux only)") + var sudoInstallPackages: Bool = false @OptionGroup var root: GlobalOptions + internal static var allowedInstallCommands: Regex<(Substring, Substring, Substring)> { try! Regex("^(apt-get|yum) -y install( [A-Za-z0-9:\\-\\+]+)+$") } + private enum CodingKeys: String, CodingKey { - case noModifyProfile, overwrite, platform, skipInstall, root, quietShellFollowup + case noModifyProfile, overwrite, platform, skipInstall, root, quietShellFollowup, sudoInstallPackages } public mutating func validate() throws {} internal mutating func run() async throws { - try await Self.execute(assumeYes: self.root.assumeYes, noModifyProfile: self.noModifyProfile, overwrite: self.overwrite, platform: self.platform, verbose: self.root.verbose, skipInstall: self.skipInstall, quietShellFollowup: self.quietShellFollowup) + try await Self.execute(assumeYes: self.root.assumeYes, noModifyProfile: self.noModifyProfile, overwrite: self.overwrite, platform: self.platform, verbose: self.root.verbose, skipInstall: self.skipInstall, quietShellFollowup: self.quietShellFollowup, sudoInstallPackages: self.sudoInstallPackages) } /// Initialize the installation of swiftly. - internal static func execute(assumeYes: Bool, noModifyProfile: Bool, overwrite: Bool, platform: String?, verbose: Bool, skipInstall: Bool, quietShellFollowup: Bool) async throws { + internal static func execute(assumeYes: Bool, noModifyProfile: Bool, overwrite: Bool, platform: String?, verbose: Bool, skipInstall: Bool, quietShellFollowup: Bool, sudoInstallPackages: Bool) async throws { try Swiftly.currentPlatform.verifySwiftlySystemPrerequisites() var config = try? Config.load() @@ -290,6 +294,12 @@ internal struct Init: SwiftlyCommand { } if let postInstall { +#if !os(Linux) + if sudoInstallPackages { + SwiftlyCore.print("Sudo installing missing packages has no effect on non-Linux platforms.") + } +#endif + SwiftlyCore.print(""" There are some dependencies that should be installed before using this toolchain. You can run the following script as the system administrator (e.g. root) to prepare @@ -298,6 +308,42 @@ internal struct Init: SwiftlyCommand { \(postInstall) """) + + if sudoInstallPackages { + // 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: postInstall) != nil else { + fatalError("post installation command \(postInstall) 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 it right away (Ctrl-C aborts): "] + postInstall.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 { + SwiftlyCore.print("sudo could not be run to install the packages") + } + } catch { + SwiftlyCore.print("sudo could not be run to install the packages") + } + } } } } diff --git a/Sources/Swiftly/Proxy.swift b/Sources/Swiftly/Proxy.swift index d0640ee4..ce14ce74 100644 --- a/Sources/Swiftly/Proxy.swift +++ b/Sources/Swiftly/Proxy.swift @@ -24,8 +24,8 @@ public enum Proxy { if CommandLine.arguments.count == 1 { // User ran swiftly with no extra arguments in an uninstalled environment, so we jump directly into - // an simple init. - try await Init.execute(assumeYes: false, noModifyProfile: false, overwrite: false, platform: nil, verbose: false, skipInstall: false, quietShellFollowup: false) + // a simple init. + try await Init.execute(assumeYes: false, noModifyProfile: false, overwrite: false, platform: nil, verbose: false, skipInstall: false, quietShellFollowup: false, sudoInstallPackages: false) return } else if CommandLine.arguments.count >= 2 && CommandLine.arguments[1] == "init" { // Let the user run the init command with their arguments, if any. diff --git a/Tests/SwiftlyTests/InitTests.swift b/Tests/SwiftlyTests/InitTests.swift index c4169d99..bfde22c4 100644 --- a/Tests/SwiftlyTests/InitTests.swift +++ b/Tests/SwiftlyTests/InitTests.swift @@ -125,4 +125,15 @@ final class InitTests: SwiftlyTests { XCTAssertTrue(Swiftly.currentPlatform.swiftlyToolchainsDir.appendingPathComponent("foo.txt").fileExists()) } } + + func testAllowedInstalledCommands() async throws { + XCTAssertTrue(try Init.allowedInstallCommands.wholeMatch(in: "apt-get -y install python3 libsqlite3") != nil) + XCTAssertTrue(try Init.allowedInstallCommands.wholeMatch(in: "yum -y install python3 libsqlite3") != nil) + XCTAssertTrue(try Init.allowedInstallCommands.wholeMatch(in: "yum -y install python3 libsqlite3-dev") != nil) + XCTAssertTrue(try Init.allowedInstallCommands.wholeMatch(in: "yum -y install libstdc++-dev:i386") != nil) + + XCTAssertTrue(try Init.allowedInstallCommands.wholeMatch(in: "SOME_ENV_VAR=abcde yum -y install libstdc++-dev:i386") == nil) + XCTAssertTrue(try Init.allowedInstallCommands.wholeMatch(in: "apt-get -y install libstdc++-dev:i386; rm -rf /") == nil) + XCTAssertTrue(try Init.allowedInstallCommands.wholeMatch(in: "apt-get -y install libstdc++-dev:i386\nrm -rf /") == nil) + } }