Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions Sources/LinuxPlatform/Linux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,5 +141,76 @@ public struct Linux: Platform {
FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())")
}

public func verifySignature(httpClient: SwiftlyHTTPClient, archiveDownloadURL: URL, archive: URL) async throws {
guard (try? self.runProgram("gpg", "--version", quiet: true)) != nil else {
SwiftlyCore.print("gpg not installed, skipping signature verification.")
return
}

let foundKeys = (try? self.runProgram(
"gpg",
"--list-keys",
"[email protected]",
"[email protected]",
quiet: true
)) != nil
guard foundKeys else {
SwiftlyCore.print("Swift PGP keys not imported, skipping signature verification.")
SwiftlyCore.print("To enable verification, import the keys from https://swift.org/keys/all-keys.asc")
return
}

SwiftlyCore.print("Refreshing Swift PGP keys...")
do {
try self.runProgram(
"gpg",
"--quiet",
"--keyserver",
"hkp://keyserver.ubuntu.com",
"--refresh-keys",
"Swift"
)
} catch {
throw Error(message: "Failed to refresh PGP keys: \(error)")
}

SwiftlyCore.print("Downloading toolchain signature...")
let sigFile = self.getTempFilePath()
FileManager.default.createFile(atPath: sigFile.path, contents: nil)
defer {
try? FileManager.default.removeItem(at: sigFile)
}

try await httpClient.downloadFile(
url: archiveDownloadURL.appendingPathExtension("sig"),
to: sigFile
)

SwiftlyCore.print("Verifying toolchain signature...")
do {
try self.runProgram("gpg", "--verify", sigFile.path, archive.path)
} catch {
throw Error(message: "Toolchain signature verification failed: \(error)")
}
}

private func runProgram(_ args: String..., quiet: Bool = false) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = args

if quiet {
process.standardOutput = nil
process.standardError = nil
}

try process.run()
process.waitUntilExit()

guard process.terminationStatus == 0 else {
throw Error(message: "\(args.first!) exited with non-zero status: \(process.terminationStatus)")
}
}

public static let currentPlatform: any Platform = Linux()
}
27 changes: 23 additions & 4 deletions Sources/Swiftly/Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,25 +56,35 @@ struct Install: SwiftlyCommand {
))
var token: String?

@Flag(help: "Skip PGP verification of the installed toolchain's signature.")
var noVerify = false

public var httpClient = SwiftlyHTTPClient()

private enum CodingKeys: String, CodingKey {
case version, token, use
case version, token, use, noVerify
}

mutating func run() async throws {
let selector = try ToolchainSelector(parsing: self.version)
self.httpClient.githubToken = self.token
let toolchainVersion = try await self.resolve(selector: selector)
var config = try Config.load()
try await Self.execute(version: toolchainVersion, &config, self.httpClient, useInstalledToolchain: self.use)
try await Self.execute(
version: toolchainVersion,
&config,
self.httpClient,
useInstalledToolchain: self.use,
verifySignature: !self.noVerify
)
}

internal static func execute(
version: ToolchainVersion,
_ config: inout Config,
_ httpClient: SwiftlyHTTPClient,
useInstalledToolchain: Bool
useInstalledToolchain: Bool,
verifySignature: Bool
) async throws {
guard !config.installedToolchains.contains(version) else {
SwiftlyCore.print("\(version) is already installed, exiting.")
Expand Down Expand Up @@ -167,9 +177,16 @@ struct Install: SwiftlyCommand {
animation.complete(success: false)
throw error
}

animation.complete(success: true)

if verifySignature {
try await Swiftly.currentPlatform.verifySignature(
httpClient: httpClient,
archiveDownloadURL: url,
archive: tmpFile
)
}

try Swiftly.currentPlatform.install(from: tmpFile, version: version)

config.installedToolchains.insert(version)
Expand All @@ -184,6 +201,8 @@ struct Install: SwiftlyCommand {
SwiftlyCore.print("\(version) installed successfully!")
}

func validateSignature(archive _: Foundation.URL, signature _: Foundation.URL) async throws {}

/// Utilize the GitHub API along with the provided selector to select a toolchain for install.
/// TODO: update this to use an official swift.org API
func resolve(selector: ToolchainSelector) async throws -> ToolchainVersion {
Expand Down
8 changes: 6 additions & 2 deletions Sources/Swiftly/Update.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,13 @@ struct Update: SwiftlyCommand {
)
var assumeYes: Bool = false

@Flag(help: "Skip PGP verification of the installed toolchain's signature.")
var noVerify = false

public var httpClient = SwiftlyHTTPClient()

private enum CodingKeys: String, CodingKey {
case toolchain, assumeYes
case toolchain, assumeYes, noVerify
}

public mutating func run() async throws {
Expand Down Expand Up @@ -104,7 +107,8 @@ struct Update: SwiftlyCommand {
version: newToolchain,
&config,
self.httpClient,
useInstalledToolchain: config.inUse == parameters.oldToolchain
useInstalledToolchain: config.inUse == parameters.oldToolchain,
verifySignature: !self.noVerify
)

try await Uninstall.execute(parameters.oldToolchain, &config)
Expand Down
8 changes: 6 additions & 2 deletions Sources/SwiftlyCore/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,11 @@ public struct SwiftlyHTTPClient {
public let url: String
}

public func downloadFile(url: URL, to destination: URL, reportProgress: @escaping (DownloadProgress) -> Void) async throws {
public func downloadFile(
url: URL,
to destination: URL,
reportProgress: ((DownloadProgress) -> Void)? = nil
) async throws {
let fileHandle = try FileHandle(forWritingTo: destination)
defer {
try? fileHandle.close()
Expand Down Expand Up @@ -168,7 +172,7 @@ public struct SwiftlyHTTPClient {
}

let now = Date()
if lastUpdate.distance(to: now) > 0.25 || receivedBytes == expectedBytes {
if let reportProgress, lastUpdate.distance(to: now) > 0.25 || receivedBytes == expectedBytes {
lastUpdate = now
reportProgress(SwiftlyHTTPClient.DownloadProgress(
receivedBytes: receivedBytes,
Expand Down
5 changes: 5 additions & 0 deletions Sources/SwiftlyCore/Platform.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ public protocol Platform {
/// Get a path pointing to a unique, temporary file.
/// This does not need to actually create the file.
func getTempFilePath() -> URL

/// Downloads the signature file associated with the archive and verifies it matches the downloaded archive.
/// Throws an error if the signature does not match.
/// On Linux, signature verification will be skipped if gpg is not installed.
func verifySignature(httpClient: SwiftlyHTTPClient, archiveDownloadURL: URL, archive: URL) async throws
}

extension Platform {
Expand Down
2 changes: 1 addition & 1 deletion Tests/SwiftlyTests/SwiftlyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ class SwiftlyTests: XCTestCase {
///
/// When executed, the mocked executables will simply print the toolchain version and return.
func installMockedToolchain(selector: String, args: [String] = [], executables: [String]? = nil) async throws {
var install = try self.parseCommand(Install.self, ["install", "\(selector)"] + args)
var install = try self.parseCommand(Install.self, ["install", "\(selector)", "--no-verify"] + args)
install.httpClient = SwiftlyHTTPClient(executor: MockToolchainDownloader(executables: executables))
try await install.run()
}
Expand Down
22 changes: 13 additions & 9 deletions Tests/SwiftlyTests/UpdateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ final class UpdateTests: SwiftlyTests {

let beforeUpdateConfig = try Config.load()

var update = try self.parseCommand(Update.self, ["update", "latest"])
var update = try self.parseCommand(Update.self, ["update", "latest", "--no-verify"])
update.httpClient = self.mockHttpClient
try await update.run()

Expand All @@ -28,7 +28,7 @@ final class UpdateTests: SwiftlyTests {
/// Verify that attempting to update when no toolchains are installed has no effect.
func testUpdateLatestWithNoToolchains() async throws {
try await self.withTestHome {
var update = try self.parseCommand(Update.self, ["update", "latest"])
var update = try self.parseCommand(Update.self, ["update", "latest", "--no-verify"])
update.httpClient = self.mockHttpClient
try await update.run()

Expand All @@ -43,7 +43,7 @@ final class UpdateTests: SwiftlyTests {
func testUpdateLatestToLatest() async throws {
try await self.withTestHome {
try await self.installMockedToolchain(selector: .stable(major: 5, minor: 0, patch: 0))
var update = try self.parseCommand(Update.self, ["update", "-y", "latest"])
var update = try self.parseCommand(Update.self, ["update", "-y", "latest", "--no-verify"])
update.httpClient = self.mockHttpClient
try await update.run()

Expand All @@ -63,7 +63,7 @@ final class UpdateTests: SwiftlyTests {
func testUpdateToLatestMinor() async throws {
try await self.withTestHome {
try await self.installMockedToolchain(selector: .stable(major: 5, minor: 0, patch: 0))
var update = try self.parseCommand(Update.self, ["update", "-y", "5"])
var update = try self.parseCommand(Update.self, ["update", "-y", "5", "--no-verify"])
update.httpClient = self.mockHttpClient
try await update.run()

Expand All @@ -85,7 +85,7 @@ final class UpdateTests: SwiftlyTests {
try await self.withTestHome {
try await self.installMockedToolchain(selector: "5.0.0")

var update = try self.parseCommand(Update.self, ["update", "-y", "5.0.0"])
var update = try self.parseCommand(Update.self, ["update", "-y", "5.0.0", "--no-verify"])
update.httpClient = self.mockHttpClient
try await update.run()

Expand All @@ -109,7 +109,7 @@ final class UpdateTests: SwiftlyTests {
try await self.withTestHome {
try await self.installMockedToolchain(selector: "5.0.0")

var update = try self.parseCommand(Update.self, ["update", "-y"])
var update = try self.parseCommand(Update.self, ["update", "-y", "--no-verify"])
update.httpClient = self.mockHttpClient
try await update.run()

Expand Down Expand Up @@ -141,7 +141,9 @@ final class UpdateTests: SwiftlyTests {
let date = "2023-09-19"
try await self.installMockedToolchain(selector: .snapshot(branch: branch, date: date))

var update = try self.parseCommand(Update.self, ["update", "-y", "\(branch.name)-snapshot"])
var update = try self.parseCommand(
Update.self, ["update", "-y", "\(branch.name)-snapshot", "--no-verify"]
)
update.httpClient = self.mockHttpClient
try await update.run()

Expand All @@ -165,7 +167,7 @@ final class UpdateTests: SwiftlyTests {
try await self.installMockedToolchain(selector: "5.0.1")
try await self.installMockedToolchain(selector: "5.0.0")

var update = try self.parseCommand(Update.self, ["update", "-y", "5.0"])
var update = try self.parseCommand(Update.self, ["update", "-y", "5.0", "--no-verify"])
update.httpClient = self.mockHttpClient
try await update.run()

Expand Down Expand Up @@ -194,7 +196,9 @@ final class UpdateTests: SwiftlyTests {
try await self.installMockedToolchain(selector: .snapshot(branch: branch, date: "2023-09-19"))
try await self.installMockedToolchain(selector: .snapshot(branch: branch, date: "2023-09-16"))

var update = try self.parseCommand(Update.self, ["update", "-y", "\(branch.name)-snapshot"])
var update = try self.parseCommand(
Update.self, ["update", "-y", "\(branch.name)-snapshot", "--no-verify"]
)
update.httpClient = self.mockHttpClient
try await update.run()

Expand Down
2 changes: 1 addition & 1 deletion docker/install-test-amazonlinux2.dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
ARG base_image=amazonlinux:2
FROM $base_image

RUN yum install -y curl util-linux
RUN yum install -y curl util-linux gpg
RUN echo 'export PATH="$HOME/.local/bin:$PATH"' >> $HOME/.profile
2 changes: 1 addition & 1 deletion docker/install-test-ubi9.dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
ARG base_image=redhat/ubi9:latest
FROM $base_image

RUN yum install --allowerasing -y curl gcc-c++
RUN yum install --allowerasing -y curl gcc-c++ gpg
RUN echo 'export PATH="$HOME/.local/bin:$PATH"' >> $HOME/.profile
2 changes: 1 addition & 1 deletion docker/install-test.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ ENV LANG en_US.UTF-8
ENV LANGUAGE en_US.UTF-8

# dependencies
RUN apt-get update --fix-missing && apt-get install -y curl
RUN apt-get update --fix-missing && apt-get install -y curl gpg
RUN echo 'export PATH="$HOME/.local/bin:$PATH"' >> $HOME/.profile
3 changes: 2 additions & 1 deletion docker/test-amazonlinux2.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ RUN yum install -y \
curl \
gcc \
gcc-c++ \
make
make \
gpg
COPY ./scripts/install-libarchive.sh /
RUN /install-libarchive.sh

Expand Down
3 changes: 2 additions & 1 deletion docker/test-ubi9.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ RUN yum install -y --allowerasing \
curl \
gcc \
gcc-c++ \
make
make \
gpg
COPY ./scripts/install-libarchive.sh /
RUN /install-libarchive.sh

Expand Down
4 changes: 3 additions & 1 deletion docker/test.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ ENV LANG en_US.UTF-8
ENV LANGUAGE en_US.UTF-8

# dependencies
RUN apt-get update --fix-missing && apt-get install -y curl build-essential
RUN apt-get update --fix-missing && apt-get install -y curl build-essential gpg
COPY ./scripts/install-libarchive.sh /
RUN /install-libarchive.sh

RUN curl -L https://swift.org/keys/all-keys.asc | gpg --import

# tools
RUN mkdir -p $HOME/.tools
RUN echo 'export PATH="$HOME/.tools:$PATH"' >> $HOME/.profile
Loading