From 0e043dd0672db371e87323c4983bc4a388fc6585 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Mon, 12 May 2025 16:50:31 -0400 Subject: [PATCH 1/3] Add support for sudo installation missing packages after init Currently when there is a post installation command during init the user is given the command that they must run at the end. In order to streamline the process further an additional flag called '--sudo-install-packages' is added that will invoke sudo on behalf of the user to perform that command as root. Add the new flag to the init subcommand. Create a regex that restricts the allowable commands to a narrow set of patterns as a measure of protection. Invoke the sudo process directly from the expected directory on supported Linux systems, which is /usr/bin/sudo --- Sources/Swiftly/Init.swift | 52 ++++++++++++++++++++++++++++-- Sources/Swiftly/Proxy.swift | 4 +-- Tests/SwiftlyTests/InitTests.swift | 11 +++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/Sources/Swiftly/Init.swift b/Sources/Swiftly/Init.swift index bc071334..65a3c559 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() + + guard p.terminationStatus == 0 else { + throw SwiftlyError(message: "sudo could not be run to install the packages") + } + } catch { + throw SwiftlyError(message: "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) + } } From 4342264152d2d9e888dde29c03daec623f1ff664 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Mon, 12 May 2025 17:15:29 -0400 Subject: [PATCH 2/3] Update the documentation --- Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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'* From ed376b38c09d6f0ba28efa0c3666cdfa2e364d44 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Mon, 12 May 2025 18:21:32 -0400 Subject: [PATCH 3/3] Make the sudo workflow always exit 0 to prevent breaking scripted installations --- Sources/Swiftly/Init.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Swiftly/Init.swift b/Sources/Swiftly/Init.swift index 65a3c559..ecd7050d 100644 --- a/Sources/Swiftly/Init.swift +++ b/Sources/Swiftly/Init.swift @@ -337,11 +337,11 @@ internal struct Init: SwiftlyCommand { p.waitUntilExit() - guard p.terminationStatus == 0 else { - throw SwiftlyError(message: "sudo could not be run to install the packages") + if p.terminationStatus == 0 { + SwiftlyCore.print("sudo could not be run to install the packages") } } catch { - throw SwiftlyError(message: "sudo could not be run to install the packages") + SwiftlyCore.print("sudo could not be run to install the packages") } } }