Skip to content

Commit 0e043dd

Browse files
committed
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
1 parent c14ee6e commit 0e043dd

File tree

3 files changed

+62
-5
lines changed

3 files changed

+62
-5
lines changed

Sources/Swiftly/Init.swift

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,25 @@ internal struct Init: SwiftlyCommand {
2020
var skipInstall: Bool = false
2121
@Flag(help: "Quiet shell follow up commands")
2222
var quietShellFollowup: Bool = false
23+
@Flag(help: "Run sudo if there are post-installation packages to install (Linux only)")
24+
var sudoInstallPackages: Bool = false
2325

2426
@OptionGroup var root: GlobalOptions
2527

28+
internal static var allowedInstallCommands: Regex<(Substring, Substring, Substring)> { try! Regex("^(apt-get|yum) -y install( [A-Za-z0-9:\\-\\+]+)+$") }
29+
2630
private enum CodingKeys: String, CodingKey {
27-
case noModifyProfile, overwrite, platform, skipInstall, root, quietShellFollowup
31+
case noModifyProfile, overwrite, platform, skipInstall, root, quietShellFollowup, sudoInstallPackages
2832
}
2933

3034
public mutating func validate() throws {}
3135

3236
internal mutating func run() async throws {
33-
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)
37+
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)
3438
}
3539

3640
/// Initialize the installation of swiftly.
37-
internal static func execute(assumeYes: Bool, noModifyProfile: Bool, overwrite: Bool, platform: String?, verbose: Bool, skipInstall: Bool, quietShellFollowup: Bool) async throws {
41+
internal static func execute(assumeYes: Bool, noModifyProfile: Bool, overwrite: Bool, platform: String?, verbose: Bool, skipInstall: Bool, quietShellFollowup: Bool, sudoInstallPackages: Bool) async throws {
3842
try Swiftly.currentPlatform.verifySwiftlySystemPrerequisites()
3943

4044
var config = try? Config.load()
@@ -290,6 +294,12 @@ internal struct Init: SwiftlyCommand {
290294
}
291295

292296
if let postInstall {
297+
#if !os(Linux)
298+
if sudoInstallPackages {
299+
SwiftlyCore.print("Sudo installing missing packages has no effect on non-Linux platforms.")
300+
}
301+
#endif
302+
293303
SwiftlyCore.print("""
294304
There are some dependencies that should be installed before using this toolchain.
295305
You can run the following script as the system administrator (e.g. root) to prepare
@@ -298,6 +308,42 @@ internal struct Init: SwiftlyCommand {
298308
\(postInstall)
299309
300310
""")
311+
312+
if sudoInstallPackages {
313+
// This is very security sensitive code here and that's why there's special process handling
314+
// and an allow-list of what we will attempt to run as root. Also, the sudo binary is run directly
315+
// with a fully-qualified path without any checking in order to avoid TOCTOU.
316+
317+
guard try Self.allowedInstallCommands.wholeMatch(in: postInstall) != nil else {
318+
fatalError("post installation command \(postInstall) does not match allowed patterns for sudo")
319+
}
320+
321+
let p = Process()
322+
p.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
323+
p.arguments = ["-k"] + ["-p", "Enter your sudo password to run it right away (Ctrl-C aborts): "] + postInstall.split(separator: " ").map { String($0) }
324+
325+
do {
326+
try p.run()
327+
328+
// Attach this process to our process group so that Ctrl-C and other signals work
329+
let pgid = tcgetpgrp(STDOUT_FILENO)
330+
if pgid != -1 {
331+
tcsetpgrp(STDOUT_FILENO, p.processIdentifier)
332+
}
333+
334+
defer { if pgid != -1 {
335+
tcsetpgrp(STDOUT_FILENO, pgid)
336+
}}
337+
338+
p.waitUntilExit()
339+
340+
guard p.terminationStatus == 0 else {
341+
throw SwiftlyError(message: "sudo could not be run to install the packages")
342+
}
343+
} catch {
344+
throw SwiftlyError(message: "sudo could not be run to install the packages")
345+
}
346+
}
301347
}
302348
}
303349
}

Sources/Swiftly/Proxy.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ public enum Proxy {
2424

2525
if CommandLine.arguments.count == 1 {
2626
// User ran swiftly with no extra arguments in an uninstalled environment, so we jump directly into
27-
// an simple init.
28-
try await Init.execute(assumeYes: false, noModifyProfile: false, overwrite: false, platform: nil, verbose: false, skipInstall: false, quietShellFollowup: false)
27+
// a simple init.
28+
try await Init.execute(assumeYes: false, noModifyProfile: false, overwrite: false, platform: nil, verbose: false, skipInstall: false, quietShellFollowup: false, sudoInstallPackages: false)
2929
return
3030
} else if CommandLine.arguments.count >= 2 && CommandLine.arguments[1] == "init" {
3131
// Let the user run the init command with their arguments, if any.

Tests/SwiftlyTests/InitTests.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,15 @@ final class InitTests: SwiftlyTests {
125125
XCTAssertTrue(Swiftly.currentPlatform.swiftlyToolchainsDir.appendingPathComponent("foo.txt").fileExists())
126126
}
127127
}
128+
129+
func testAllowedInstalledCommands() async throws {
130+
XCTAssertTrue(try Init.allowedInstallCommands.wholeMatch(in: "apt-get -y install python3 libsqlite3") != nil)
131+
XCTAssertTrue(try Init.allowedInstallCommands.wholeMatch(in: "yum -y install python3 libsqlite3") != nil)
132+
XCTAssertTrue(try Init.allowedInstallCommands.wholeMatch(in: "yum -y install python3 libsqlite3-dev") != nil)
133+
XCTAssertTrue(try Init.allowedInstallCommands.wholeMatch(in: "yum -y install libstdc++-dev:i386") != nil)
134+
135+
XCTAssertTrue(try Init.allowedInstallCommands.wholeMatch(in: "SOME_ENV_VAR=abcde yum -y install libstdc++-dev:i386") == nil)
136+
XCTAssertTrue(try Init.allowedInstallCommands.wholeMatch(in: "apt-get -y install libstdc++-dev:i386; rm -rf /") == nil)
137+
XCTAssertTrue(try Init.allowedInstallCommands.wholeMatch(in: "apt-get -y install libstdc++-dev:i386\nrm -rf /") == nil)
138+
}
128139
}

0 commit comments

Comments
 (0)