diff --git a/Package.swift b/Package.swift index 9b129f26..5415c7fa 100644 --- a/Package.swift +++ b/Package.swift @@ -52,11 +52,28 @@ let package = Package( .target( name: "SwiftlyCore", dependencies: [ + "SwiftlyDownloadAPI", + "SwiftlyWebsiteAPI", .product(name: "AsyncHTTPClient", package: "async-http-client"), .product(name: "NIOFoundationCompat", package: "swift-nio"), .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), .product(name: "OpenAPIAsyncHTTPClient", package: "swift-openapi-async-http-client"), ], + ), + .target( + name: "SwiftlyDownloadAPI", + dependencies: [ + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + ], + plugins: [ + .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator"), + ] + ), + .target( + name: "SwiftlyWebsiteAPI", + dependencies: [ + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + ], plugins: [ .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator"), ] diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index 124a8b23..b2796ff3 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -42,7 +42,8 @@ public struct Linux: Platform { "tar.gz" } - private static let skipVerificationMessage: String = "To skip signature verification, specify the --no-verify flag." + private static let skipVerificationMessage: String = + "To skip signature verification, specify the --no-verify flag." public func verifySwiftlySystemPrerequisites() throws { // Check if the root CA certificates are installed on this system for NIOSSL to use. @@ -67,10 +68,15 @@ public struct Linux: Platform { } } - public func verifySystemPrerequisitesForInstall(_ ctx: SwiftlyCoreContext, platformName: String, version _: ToolchainVersion, requireSignatureValidation: Bool) async throws -> String? { + public func verifySystemPrerequisitesForInstall( + _ ctx: SwiftlyCoreContext, platformName: String, version _: ToolchainVersion, + requireSignatureValidation: Bool + ) async throws -> 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] = switch platformName { + let packages: [String] = + switch platformName + { case "ubuntu1804": [ "libatomic1", @@ -221,7 +227,9 @@ public struct Linux: Platform { [] } - let manager: String? = switch platformName { + let manager: String? = + switch platformName + { case "ubuntu1804": "apt-get" case "ubuntu2004": @@ -259,18 +267,19 @@ public struct Linux: Platform { } let tmpFile = self.getTempFilePath() - let _ = FileManager.default.createFile(atPath: tmpFile.path, contents: nil, attributes: [.posixPermissions: 0o600]) + let _ = FileManager.default.createFile( + atPath: tmpFile.path, contents: nil, attributes: [.posixPermissions: 0o600] + ) defer { try? FileManager.default.removeItem(at: tmpFile) } - guard let url = URL(string: "https://www.swift.org/keys/all-keys.asc") else { - throw SwiftlyError(message: "malformed URL to the swift gpg keys") - } - - try await ctx.httpClient.downloadFile(url: url, to: tmpFile) + try await ctx.httpClient.getGpgKeys().download(to: tmpFile) if let mockedHomeDir = ctx.mockedHomeDir { - try self.runProgram("gpg", "--import", tmpFile.path, quiet: true, env: ["GNUPGHOME": mockedHomeDir.appendingPathComponent(".gnupg").path]) + try self.runProgram( + "gpg", "--import", tmpFile.path, quiet: true, + env: ["GNUPGHOME": mockedHomeDir.appendingPathComponent(".gnupg").path] + ) } else { try self.runProgram("gpg", "--import", tmpFile.path, quiet: true) } @@ -323,13 +332,17 @@ public struct Linux: Platform { } } - public func install(_ ctx: SwiftlyCoreContext, from tmpFile: URL, version: ToolchainVersion, verbose: Bool) throws { + public func install( + _ ctx: SwiftlyCoreContext, from tmpFile: URL, version: ToolchainVersion, verbose: Bool + ) throws { guard tmpFile.fileExists() else { throw SwiftlyError(message: "\(tmpFile) doesn't exist") } if !self.swiftlyToolchainsDir(ctx).fileExists() { - try FileManager.default.createDirectory(at: self.swiftlyToolchainsDir(ctx), withIntermediateDirectories: false) + try FileManager.default.createDirectory( + at: self.swiftlyToolchainsDir(ctx), withIntermediateDirectories: false + ) } ctx.print("Extracting toolchain...") @@ -375,7 +388,9 @@ public struct Linux: Platform { try self.runProgram(tmpDir.appendingPathComponent("swiftly").path, "init") } - public func uninstall(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, verbose _: Bool) throws { + public func uninstall(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, verbose _: Bool) + throws + { let toolchainDir = self.swiftlyToolchainsDir(ctx).appendingPathComponent(toolchain.name) try FileManager.default.removeItem(at: toolchainDir) } @@ -390,7 +405,9 @@ public struct Linux: Platform { FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") } - public func verifySignature(_ ctx: SwiftlyCoreContext, archiveDownloadURL: URL, archive: URL, verbose: Bool) async throws { + public func verifyToolchainSignature( + _ ctx: SwiftlyCoreContext, toolchainFile: ToolchainFile, archive: URL, verbose: Bool + ) async throws { if verbose { ctx.print("Downloading toolchain signature...") } @@ -401,15 +418,15 @@ public struct Linux: Platform { try? FileManager.default.removeItem(at: sigFile) } - try await ctx.httpClient.downloadFile( - url: archiveDownloadURL.appendingPathExtension("sig"), - to: sigFile - ) + try await ctx.httpClient.getSwiftToolchainFileSignature(toolchainFile).download(to: sigFile) ctx.print("Verifying toolchain signature...") do { if let mockedHomeDir = ctx.mockedHomeDir { - try self.runProgram("gpg", "--verify", sigFile.path, archive.path, quiet: false, env: ["GNUPGHOME": mockedHomeDir.appendingPathComponent(".gnupg").path]) + try self.runProgram( + "gpg", "--verify", sigFile.path, archive.path, quiet: false, + env: ["GNUPGHOME": mockedHomeDir.appendingPathComponent(".gnupg").path] + ) } else { try self.runProgram("gpg", "--verify", sigFile.path, archive.path, quiet: !verbose) } @@ -418,23 +435,65 @@ public struct Linux: Platform { } } - private func manualSelectPlatform(_ ctx: SwiftlyCoreContext, _ platformPretty: String?) async -> PlatformDefinition { + public func verifySwiftlySignature( + _ ctx: SwiftlyCoreContext, archiveDownloadURL: URL, archive: URL, verbose: Bool + ) async throws { + if verbose { + ctx.print("Downloading swiftly signature...") + } + + let sigFile = self.getTempFilePath() + let _ = FileManager.default.createFile(atPath: sigFile.path, contents: nil) + defer { + try? FileManager.default.removeItem(at: sigFile) + } + + try await ctx.httpClient.getSwiftlyReleaseSignature( + url: archiveDownloadURL.appendingPathExtension("sig") + ).download(to: sigFile) + + ctx.print("Verifying swiftly signature...") + do { + if let mockedHomeDir = ctx.mockedHomeDir { + try self.runProgram( + "gpg", "--verify", sigFile.path, archive.path, quiet: false, + env: ["GNUPGHOME": mockedHomeDir.appendingPathComponent(".gnupg").path] + ) + } else { + try self.runProgram("gpg", "--verify", sigFile.path, archive.path, quiet: !verbose) + } + } catch { + throw SwiftlyError(message: "Signature verification failed: \(error).") + } + } + + private func manualSelectPlatform(_ ctx: SwiftlyCoreContext, _ platformPretty: String?) async + -> PlatformDefinition + { if let platformPretty { - print("\(platformPretty) is not an officially supported platform, but the toolchains for another platform may still work on it.") + print( + "\(platformPretty) is not an officially supported platform, but the toolchains for another platform may still work on it." + ) } else { - print("This platform could not be detected, but a toolchain for one of the supported platforms may work on it.") + print( + "This platform could not be detected, but a toolchain for one of the supported platforms may work on it." + ) } - let selections = self.linuxPlatforms.enumerated().map { "\($0 + 1)) \($1.namePretty)" }.joined(separator: "\n") + let selections = self.linuxPlatforms.enumerated().map { "\($0 + 1)) \($1.namePretty)" }.joined( + separator: "\n") - print(""" - Please select the platform to use for toolchain downloads: + print( + """ + Please select the platform to use for toolchain downloads: - 0) Cancel - \(selections) - """) + 0) Cancel + \(selections) + """) - let choice = ctx.readLine(prompt: "Pick one of the available selections [0-\(self.linuxPlatforms.count)] ") ?? "0" + let choice = + ctx.readLine(prompt: "Pick one of the available selections [0-\(self.linuxPlatforms.count)] ") + ?? "0" guard let choiceNum = Int(choice) else { fatalError("Installation canceled") @@ -447,11 +506,15 @@ public struct Linux: Platform { return self.linuxPlatforms[choiceNum - 1] } - public func detectPlatform(_ ctx: SwiftlyCoreContext, disableConfirmation: Bool, platform: String?) async throws -> PlatformDefinition { + public func detectPlatform( + _ ctx: SwiftlyCoreContext, disableConfirmation: Bool, platform: String? + ) async throws -> PlatformDefinition { // We've been given a hint to use if let platform { guard let pd = linuxPlatforms.first(where: { $0.nameFull == platform }) else { - fatalError("Unrecognized platform \(platform). Recognized values: \(self.linuxPlatforms.map(\.nameFull).joined(separator: ", ")).") + fatalError( + "Unrecognized platform \(platform). Recognized values: \(self.linuxPlatforms.map(\.nameFull).joined(separator: ", "))." + ) } return pd @@ -489,9 +552,13 @@ public struct Linux: Platform { } else if info.hasPrefix("ID_LIKE=") { idlike = String(info.dropFirst("ID_LIKE=".count)).replacingOccurrences(of: "\"", with: "") } else if info.hasPrefix("VERSION_ID=") { - versionID = String(info.dropFirst("VERSION_ID=".count)).replacingOccurrences(of: "\"", with: "").replacingOccurrences(of: ".", with: "") + versionID = String(info.dropFirst("VERSION_ID=".count)).replacingOccurrences( + of: "\"", with: "" + ).replacingOccurrences(of: ".", with: "") } else if info.hasPrefix("PRETTY_NAME=") { - platformPretty = String(info.dropFirst("PRETTY_NAME=".count)).replacingOccurrences(of: "\"", with: "") + platformPretty = String(info.dropFirst("PRETTY_NAME=".count)).replacingOccurrences( + of: "\"", with: "" + ) } } @@ -529,7 +596,9 @@ public struct Linux: Platform { } return .rhel9 - } else if let pd = [PlatformDefinition.ubuntu1804, .ubuntu2004, .ubuntu2204, .ubuntu2404, .debian12, .fedora39].first(where: { $0.name == id + versionID }) { + } else if let pd = [ + PlatformDefinition.ubuntu1804, .ubuntu2004, .ubuntu2204, .ubuntu2404, .debian12, .fedora39, + ].first(where: { $0.name == id + versionID }) { return pd } @@ -559,7 +628,8 @@ public struct Linux: Platform { return "/bin/bash" } - public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL { + public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL + { self.swiftlyToolchainsDir(ctx).appendingPathComponent("\(toolchain.name)") } diff --git a/Sources/MacOSPlatform/MacOS.swift b/Sources/MacOSPlatform/MacOS.swift index c299439c..02407df4 100644 --- a/Sources/MacOSPlatform/MacOS.swift +++ b/Sources/MacOSPlatform/MacOS.swift @@ -28,7 +28,9 @@ public struct MacOS: Platform { public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> URL { ctx.mockedHomeDir.map { $0.appendingPathComponent("Toolchains", isDirectory: true) } // The toolchains are always installed here by the installer. We bypass the installer in the case of test mocks - ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Developer/Toolchains", isDirectory: true) + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent( + "Library/Developer/Toolchains", isDirectory: true + ) } public var toolchainFileExtension: String { @@ -39,31 +41,45 @@ public struct MacOS: Platform { // All system prerequisites are there for swiftly on macOS } - public func verifySystemPrerequisitesForInstall(_: SwiftlyCoreContext, platformName _: String, version _: ToolchainVersion, requireSignatureValidation _: Bool) async throws -> String? { + public func verifySystemPrerequisitesForInstall( + _: SwiftlyCoreContext, platformName _: String, version _: ToolchainVersion, + requireSignatureValidation _: Bool + ) async throws -> String? { // All system prerequisites should be there for macOS nil } - public func install(_ ctx: SwiftlyCoreContext, from tmpFile: URL, version: ToolchainVersion, verbose: Bool) throws { + public func install( + _ ctx: SwiftlyCoreContext, from tmpFile: URL, version: ToolchainVersion, verbose: Bool + ) throws { guard tmpFile.fileExists() else { throw SwiftlyError(message: "\(tmpFile) doesn't exist") } if !self.swiftlyToolchainsDir(ctx).fileExists() { - try FileManager.default.createDirectory(at: self.swiftlyToolchainsDir(ctx), withIntermediateDirectories: false) + try FileManager.default.createDirectory( + at: self.swiftlyToolchainsDir(ctx), withIntermediateDirectories: false + ) } if ctx.mockedHomeDir == nil { ctx.print("Installing package in user home directory...") - try runProgram("installer", "-verbose", "-pkg", tmpFile.path, "-target", "CurrentUserHomeDirectory", quiet: !verbose) + try runProgram( + "installer", "-verbose", "-pkg", tmpFile.path, "-target", "CurrentUserHomeDirectory", + quiet: !verbose + ) } else { // In the case of a mock for testing purposes we won't use the installer, perferring a manual process because // the installer will not install to an arbitrary path, only a volume or user home directory. ctx.print("Expanding pkg...") let tmpDir = self.getTempFilePath() - let toolchainDir = self.swiftlyToolchainsDir(ctx).appendingPathComponent("\(version.identifier).xctoolchain", isDirectory: true) + let toolchainDir = self.swiftlyToolchainsDir(ctx).appendingPathComponent( + "\(version.identifier).xctoolchain", isDirectory: true + ) if !toolchainDir.fileExists() { - try FileManager.default.createDirectory(at: toolchainDir, withIntermediateDirectories: false) + try FileManager.default.createDirectory( + at: toolchainDir, withIntermediateDirectories: false + ) } try runProgram("pkgutil", "--verbose", "--expand", tmpFile.path, tmpDir.path, quiet: !verbose) // There's a slight difference in the location of the special Payload file between official swift packages @@ -95,7 +111,9 @@ public struct MacOS: Platform { homeDir = ctx.mockedHomeDir ?? FileManager.default.homeDirectoryForCurrentUser let installDir = homeDir.appendingPathComponent(".swiftly") - try FileManager.default.createDirectory(atPath: installDir.path, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + atPath: installDir.path, withIntermediateDirectories: true + ) // In the case of a mock for testing purposes we won't use the installer, perferring a manual process because // the installer will not install to an arbitrary path, only a volume or user home directory. @@ -116,10 +134,14 @@ public struct MacOS: Platform { try self.runProgram(homeDir.appendingPathComponent(".swiftly/bin/swiftly").path, "init") } - public func uninstall(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, verbose: Bool) throws { + public func uninstall(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, verbose: Bool) + throws + { ctx.print("Uninstalling package in user home directory...") - let toolchainDir = self.swiftlyToolchainsDir(ctx).appendingPathComponent("\(toolchain.identifier).xctoolchain", isDirectory: true) + let toolchainDir = self.swiftlyToolchainsDir(ctx).appendingPathComponent( + "\(toolchain.identifier).xctoolchain", isDirectory: true + ) let decoder = PropertyListDecoder() let infoPlist = toolchainDir.appendingPathComponent("Info.plist") @@ -134,7 +156,9 @@ public struct MacOS: Platform { try FileManager.default.removeItem(at: toolchainDir) let homedir = ProcessInfo.processInfo.environment["HOME"]! - try? runProgram("pkgutil", "--volume", homedir, "--forget", pkgInfo.CFBundleIdentifier, quiet: !verbose) + try? runProgram( + "pkgutil", "--volume", homedir, "--forget", pkgInfo.CFBundleIdentifier, quiet: !verbose + ) } public func getExecutableName() -> String { @@ -145,18 +169,31 @@ public struct MacOS: Platform { FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID()).pkg") } - public func verifySignature(_: SwiftlyCoreContext, archiveDownloadURL _: URL, archive _: URL, verbose _: Bool) async throws { + public func verifyToolchainSignature( + _: SwiftlyCoreContext, toolchainFile _: ToolchainFile, archive _: URL, verbose _: Bool + ) async throws { // No signature verification is required on macOS since the pkg files have their own signing // mechanism and the swift.org downloadables are trusted by stock macOS installations. } - public func detectPlatform(_: SwiftlyCoreContext, disableConfirmation _: Bool, platform _: String?) async -> PlatformDefinition { + public func verifySwiftlySignature( + _: SwiftlyCoreContext, archiveDownloadURL _: URL, archive _: URL, verbose _: Bool + ) async throws { + // No signature verification is required on macOS since the pkg files have their own signing + // mechanism and the swift.org downloadables are trusted by stock macOS installations. + } + + public func detectPlatform( + _: SwiftlyCoreContext, disableConfirmation _: Bool, platform _: String? + ) async -> PlatformDefinition { // No special detection required on macOS platform .macOS } public func getShell() async throws -> String { - if let directoryInfo = try await runProgramOutput("dscl", ".", "-read", FileManager.default.homeDirectoryForCurrentUser.path) { + if let directoryInfo = try await runProgramOutput( + "dscl", ".", "-read", FileManager.default.homeDirectoryForCurrentUser.path + ) { for line in directoryInfo.components(separatedBy: "\n") { if line.hasPrefix("UserShell: ") { if case let comps = line.components(separatedBy: ": "), comps.count == 2 { @@ -170,7 +207,8 @@ public struct MacOS: Platform { return "/bin/zsh" } - public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL { + public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL + { self.swiftlyToolchainsDir(ctx).appendingPathComponent("\(toolchain.identifier).xctoolchain") } diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index 86e2003b..98fee2c2 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -1,69 +1,73 @@ import _StringProcessing import ArgumentParser import Foundation +import SwiftlyCore import TSCBasic import TSCUtility -import SwiftlyCore - struct Install: SwiftlyCommand { public static var configuration = CommandConfiguration( abstract: "Install a new toolchain." ) - @Argument(help: ArgumentHelp( - "The version of the toolchain to install.", - discussion: """ + @Argument( + help: ArgumentHelp( + "The version of the toolchain to install.", + discussion: """ - The string "latest" can be provided to install the most recent stable version release: + The string "latest" can be provided to install the most recent stable version release: - $ swiftly install latest + $ swiftly install latest - A specific toolchain can be installed by providing a full toolchain name, for example \ - a stable release version with patch (e.g. a.b.c): + A specific toolchain can be installed by providing a full toolchain name, for example \ + a stable release version with patch (e.g. a.b.c): - $ swiftly install 5.4.2 + $ swiftly install 5.4.2 - Or a snapshot with date: + Or a snapshot with date: - $ swiftly install 5.7-snapshot-2022-06-20 - $ swiftly install main-snapshot-2022-06-20 + $ swiftly install 5.7-snapshot-2022-06-20 + $ swiftly install main-snapshot-2022-06-20 - The latest patch release of a specific minor version can be installed by omitting the \ - patch version: + The latest patch release of a specific minor version can be installed by omitting the \ + patch version: - $ swiftly install 5.6 + $ swiftly install 5.6 - Likewise, the latest snapshot associated with a given development branch can be \ - installed by omitting the date: + Likewise, the latest snapshot associated with a given development branch can be \ + installed by omitting the date: - $ swiftly install 5.7-snapshot - $ swiftly install main-snapshot + $ swiftly install 5.7-snapshot + $ swiftly install main-snapshot - Install whatever toolchain is currently selected, such as the the one in the .swift-version file: + Install whatever toolchain is currently selected, such as the the one in the .swift-version file: - $ swiftly install + $ swiftly install - NOTE: Swiftly downloads toolchains to a temporary file that it later cleans during its installation process. If these files are too big for your system temporary directory, set another location by setting the `TMPDIR` environment variable. + NOTE: Swiftly downloads toolchains to a temporary file that it later cleans during its installation process. If these files are too big for your system temporary directory, set another location by setting the `TMPDIR` environment variable. - $ TMPDIR=/large/file/tmp/storage swiftly install latest - """ - )) + $ TMPDIR=/large/file/tmp/storage swiftly install latest + """ + )) var version: String? @Flag(name: .shortAndLong, help: "Mark the newly installed toolchain as in-use.") var use: Bool = false - @Flag(inversion: .prefixedNo, help: "Verify the toolchain's PGP signature before proceeding with installation.") + @Flag( + inversion: .prefixedNo, + help: "Verify the toolchain's PGP signature before proceeding with installation." + ) var verify = true - @Option(help: ArgumentHelp( - "A file path to a location for a post installation script", - discussion: """ - If the toolchain that is installed has extra post installation steps, they will be - written to this file as commands that can be run after the installation. - """ - )) + @Option( + help: ArgumentHelp( + "A file path to a location for a post installation script", + discussion: """ + If the toolchain that is installed has extra post installation steps, they will be + written to this file as commands that can be run after the installation. + """ + )) var postInstallFile: String? @OptionGroup var root: GlobalOptions @@ -97,7 +101,10 @@ struct Install: SwiftlyCommand { throw SwiftlyError(message: "Internal error selecting toolchain to install.") } } else { - throw SwiftlyError(message: "Swiftly couldn't determine the toolchain version to install. Please set a version like this and try again: `swiftly install latest`") + throw SwiftlyError( + message: + "Swiftly couldn't determine the toolchain version to install. Please set a version like this and try again: `swiftly install latest`" + ) } } @@ -112,7 +119,9 @@ struct Install: SwiftlyCommand { assumeYes: self.root.assumeYes ) - let shell = if let s = ProcessInfo.processInfo.environment["SHELL"] { + let shell = + if let s = ProcessInfo.processInfo.environment["SHELL"] + { s } else { try await Swiftly.currentPlatform.getShell() @@ -120,30 +129,34 @@ struct Install: SwiftlyCommand { // Fish doesn't cache its path, so this instruction is not necessary. if pathChanged && !shell.hasSuffix("fish") { - ctx.print(""" - NOTE: Swiftly has updated some elements in your path and your shell may not yet be - aware of the changes. You can update your shell's environment by running + ctx.print( + """ + NOTE: Swiftly has updated some elements in your path and your shell may not yet be + aware of the changes. You can update your shell's environment by running - hash -r + hash -r - or restarting your shell. + or restarting your shell. - """) + """) } if let postInstallScript { guard let postInstallFile = self.postInstallFile else { - throw SwiftlyError(message: """ + throw SwiftlyError( + message: """ - 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 - your system: + 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 + your system: - \(postInstallScript) - """) + \(postInstallScript) + """) } - try Data(postInstallScript.utf8).write(to: URL(fileURLWithPath: postInstallFile), options: .atomic) + try Data(postInstallScript.utf8).write( + to: URL(fileURLWithPath: postInstallFile), options: .atomic + ) } } @@ -164,7 +177,10 @@ struct Install: SwiftlyCommand { // Ensure the system is set up correctly before downloading it. Problems that prevent installation // will throw, while problems that prevent use of the toolchain will be written out as a post install // script for the user to run afterwards. - let postInstallScript = try await Swiftly.currentPlatform.verifySystemPrerequisitesForInstall(ctx, platformName: config.platform.name, version: version, requireSignatureValidation: verifySignature) + let postInstallScript = try await Swiftly.currentPlatform.verifySystemPrerequisitesForInstall( + ctx, platformName: config.platform.name, version: version, + requireSignatureValidation: verifySignature + ) ctx.print("Installing \(version)") @@ -174,8 +190,6 @@ struct Install: SwiftlyCommand { try? FileManager.default.removeItem(at: tmpFile) } - var url = "https://download.swift.org/" - var platformString = config.platform.name var platformFullString = config.platform.nameFull @@ -184,6 +198,7 @@ struct Install: SwiftlyCommand { platformFullString += "-aarch64" #endif + let category: String switch version { case let .stable(stableVersion): // Building URL path that looks like: @@ -193,24 +208,16 @@ struct Install: SwiftlyCommand { versionString += ".\(stableVersion.patch)" } - url += "swift-\(versionString)-release/" + category = "swift-\(versionString)-release" case let .snapshot(release): switch release.branch { case let .release(major, minor): - url += "swift-\(major).\(minor)-branch/" + category = "swift-\(major).\(minor)-branch" case .main: - url += "development/" + category = "development" } } - url += "\(platformString)/" - url += "\(version.identifier)/" - url += "\(version.identifier)-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)" - - guard let url = URL(string: url) else { - throw SwiftlyError(message: "Invalid toolchain URL: \(url)") - } - let animation = PercentProgressAnimation( stream: stdoutStream, header: "Downloading \(version)" @@ -218,14 +225,20 @@ struct Install: SwiftlyCommand { var lastUpdate = Date() + let toolchainFile = ToolchainFile( + category: category, platform: platformString, version: version.identifier, + file: + "\(version.identifier)-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)" + ) + do { - try await ctx.httpClient.downloadFile( - url: url, + try await ctx.httpClient.getSwiftToolchainFile(toolchainFile).download( to: tmpFile, reportProgress: { progress in let now = Date() - guard lastUpdate.distance(to: now) > 0.25 || progress.receivedBytes == progress.totalBytes else { + guard lastUpdate.distance(to: now) > 0.25 || progress.receivedBytes == progress.totalBytes + else { return } @@ -237,11 +250,12 @@ struct Install: SwiftlyCommand { animation.update( step: progress.receivedBytes, total: progress.totalBytes!, - text: "Downloaded \(String(format: "%.1f", downloadedMiB)) MiB of \(String(format: "%.1f", totalMiB)) MiB" + text: + "Downloaded \(String(format: "%.1f", downloadedMiB)) MiB of \(String(format: "%.1f", totalMiB)) MiB" ) } ) - } catch let notFound as SwiftlyHTTPClient.DownloadNotFoundError { + } catch let notFound as DownloadNotFoundError { throw SwiftlyError(message: "\(version) does not exist at URL \(notFound.url), exiting") } catch { animation.complete(success: false) @@ -250,9 +264,9 @@ struct Install: SwiftlyCommand { animation.complete(success: true) if verifySignature { - try await Swiftly.currentPlatform.verifySignature( + try await Swiftly.currentPlatform.verifyToolchainSignature( ctx, - archiveDownloadURL: url, + toolchainFile: toolchainFile, archive: tmpFile, verbose: verbose ) @@ -266,18 +280,22 @@ struct Install: SwiftlyCommand { if let proxyTo = try? Swiftly.currentPlatform.findSwiftlyBin(ctx) { // Ensure swiftly doesn't overwrite any existing executables without getting confirmation first. let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(ctx) - let swiftlyBinDirContents = (try? FileManager.default.contentsOfDirectory(atPath: swiftlyBinDir.path)) ?? [String]() + let swiftlyBinDirContents = + (try? FileManager.default.contentsOfDirectory(atPath: swiftlyBinDir.path)) ?? [String]() let toolchainBinDir = Swiftly.currentPlatform.findToolchainBinDir(ctx, version) - let toolchainBinDirContents = try FileManager.default.contentsOfDirectory(atPath: toolchainBinDir.path) + let toolchainBinDirContents = try FileManager.default.contentsOfDirectory( + atPath: toolchainBinDir.path) let existingProxies = swiftlyBinDirContents.filter { bin in do { - let linkTarget = try FileManager.default.destinationOfSymbolicLink(atPath: swiftlyBinDir.appendingPathComponent(bin).path) + let linkTarget = try FileManager.default.destinationOfSymbolicLink( + atPath: swiftlyBinDir.appendingPathComponent(bin).path) return linkTarget == proxyTo } catch { return false } } - let overwrite = Set(toolchainBinDirContents).subtracting(existingProxies).intersection(swiftlyBinDirContents) + let overwrite = Set(toolchainBinDirContents).subtracting(existingProxies).intersection( + swiftlyBinDirContents) if !overwrite.isEmpty && !assumeYes { ctx.print("The following existing executables will be overwritten:") @@ -294,7 +312,8 @@ struct Install: SwiftlyCommand { ctx.print("Setting up toolchain proxies...") } - let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents).union(overwrite) + let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents).union( + overwrite) for p in proxiesToCreate { let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx).appendingPathComponent(p) @@ -335,12 +354,18 @@ struct Install: SwiftlyCommand { } /// Utilize the swift.org API along with the provided selector to select a toolchain for install. - public static func resolve(_ ctx: SwiftlyCoreContext, config: Config, selector: ToolchainSelector) async throws -> ToolchainVersion { + public static func resolve(_ ctx: SwiftlyCoreContext, config: Config, selector: ToolchainSelector) + async throws -> ToolchainVersion + { switch selector { case .latest: ctx.print("Fetching the latest stable Swift release...") - guard let release = try await ctx.httpClient.getReleaseToolchains(platform: config.platform, limit: 1).first else { + guard + let release = try await ctx.httpClient.getReleaseToolchains( + platform: config.platform, limit: 1 + ).first + else { throw SwiftlyError(message: "couldn't get latest releases") } return .stable(release) @@ -348,7 +373,8 @@ struct Install: SwiftlyCommand { case let .stable(major, minor, patch): guard let minor else { throw SwiftlyError( - message: "Need to provide at least major and minor versions when installing a release toolchain." + message: + "Need to provide at least major and minor versions when installing a release toolchain." ) } @@ -359,7 +385,9 @@ struct Install: SwiftlyCommand { ctx.print("Fetching the latest stable Swift \(major).\(minor) release...") // If a patch was not provided, perform a lookup to get the latest patch release // of the provided major/minor version pair. - let toolchain = try await ctx.httpClient.getReleaseToolchains(platform: config.platform, limit: 1) { release in + let toolchain = try await ctx.httpClient.getReleaseToolchains( + platform: config.platform, limit: 1 + ) { release in release.major == major && release.minor == minor }.first @@ -380,11 +408,16 @@ struct Install: SwiftlyCommand { // for the given branch. let snapshots: [ToolchainVersion.Snapshot] do { - snapshots = try await ctx.httpClient.getSnapshotToolchains(platform: config.platform, branch: branch, limit: 1) { snapshot in + snapshots = try await ctx.httpClient.getSnapshotToolchains( + platform: config.platform, branch: branch, limit: 1 + ) { snapshot in snapshot.branch == branch } } catch let branchNotFoundErr as SwiftlyHTTPClient.SnapshotBranchNotFoundError { - throw SwiftlyError(message: "You have requested to install a snapshot toolchain from branch \(branchNotFoundErr.branch). It cannot be found on swift.org. Note that snapshots are only available from the current `main` release and the latest x.y (major.minor) release. Try again with a different branch.") + throw SwiftlyError( + message: + "You have requested to install a snapshot toolchain from branch \(branchNotFoundErr.branch). It cannot be found on swift.org. Note that snapshots are only available from the current `main` release and the latest x.y (major.minor) release. Try again with a different branch." + ) } catch { throw error } diff --git a/Sources/Swiftly/SelfUpdate.swift b/Sources/Swiftly/SelfUpdate.swift index e2d796c9..e0a4f8c3 100644 --- a/Sources/Swiftly/SelfUpdate.swift +++ b/Sources/Swiftly/SelfUpdate.swift @@ -1,10 +1,9 @@ import ArgumentParser import Foundation +import SwiftlyCore import TSCBasic import TSCUtility -import SwiftlyCore - struct SelfUpdate: SwiftlyCommand { public static var configuration = CommandConfiguration( abstract: "Update the version of swiftly itself." @@ -25,13 +24,18 @@ struct SelfUpdate: SwiftlyCommand { let swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir(ctx).appendingPathComponent("swiftly") guard FileManager.default.fileExists(atPath: swiftlyBin.path) else { - throw SwiftlyError(message: "Self update doesn't work when swiftly has been installed externally. Please keep it updated from the source where you installed it in the first place.") + throw SwiftlyError( + message: + "Self update doesn't work when swiftly has been installed externally. Please keep it updated from the source where you installed it in the first place." + ) } let _ = try await Self.execute(ctx, verbose: self.root.verbose) } - public static func execute(_ ctx: SwiftlyCoreContext, verbose: Bool) async throws -> SwiftlyVersion { + public static func execute(_ ctx: SwiftlyCoreContext, verbose: Bool) async throws + -> SwiftlyVersion + { ctx.print("Checking for swiftly updates...") let swiftlyRelease = try await ctx.httpClient.getCurrentSwiftlyRelease() @@ -61,7 +65,10 @@ struct SelfUpdate: SwiftlyCommand { } guard let downloadURL else { - throw SwiftlyError(message: "The newest release of swiftly is incompatible with your current OS and/or processor architecture.") + throw SwiftlyError( + message: + "The newest release of swiftly is incompatible with your current OS and/or processor architecture." + ) } let version = try swiftlyRelease.swiftlyVersion @@ -79,8 +86,7 @@ struct SelfUpdate: SwiftlyCommand { header: "Downloading swiftly \(version)" ) do { - try await ctx.httpClient.downloadFile( - url: downloadURL, + try await ctx.httpClient.getSwiftlyRelease(url: downloadURL).download( to: tmpFile, reportProgress: { progress in let downloadedMiB = Double(progress.receivedBytes) / (1024.0 * 1024.0) @@ -89,7 +95,8 @@ struct SelfUpdate: SwiftlyCommand { animation.update( step: progress.receivedBytes, total: progress.totalBytes!, - text: "Downloaded \(String(format: "%.1f", downloadedMiB)) MiB of \(String(format: "%.1f", totalMiB)) MiB" + text: + "Downloaded \(String(format: "%.1f", downloadedMiB)) MiB of \(String(format: "%.1f", totalMiB)) MiB" ) } ) @@ -99,7 +106,9 @@ struct SelfUpdate: SwiftlyCommand { } animation.complete(success: true) - try await Swiftly.currentPlatform.verifySignature(ctx, archiveDownloadURL: downloadURL, archive: tmpFile, verbose: verbose) + try await Swiftly.currentPlatform.verifySwiftlySignature( + ctx, archiveDownloadURL: downloadURL, archive: tmpFile, verbose: verbose + ) try Swiftly.currentPlatform.extractSwiftlyAndInstall(ctx, from: tmpFile) ctx.print("Successfully updated swiftly to \(version) (was \(SwiftlyCore.version))") diff --git a/Sources/SwiftlyCore/HTTPClient.swift b/Sources/SwiftlyCore/HTTPClient.swift index eee76dba..71ff5036 100644 --- a/Sources/SwiftlyCore/HTTPClient.swift +++ b/Sources/SwiftlyCore/HTTPClient.swift @@ -7,8 +7,10 @@ import NIOFoundationCompat import NIOHTTP1 import OpenAPIAsyncHTTPClient import OpenAPIRuntime +import SwiftlyDownloadAPI +import SwiftlyWebsiteAPI -extension Components.Schemas.SwiftlyRelease { +extension SwiftlyWebsiteAPI.Components.Schemas.SwiftlyRelease { public var swiftlyVersion: SwiftlyVersion { get throws { guard let releaseVersion = try? SwiftlyVersion(parsing: self.version) else { @@ -20,7 +22,7 @@ extension Components.Schemas.SwiftlyRelease { } } -extension Components.Schemas.SwiftlyReleasePlatformArtifacts { +extension SwiftlyWebsiteAPI.Components.Schemas.SwiftlyReleasePlatformArtifacts { public var isDarwin: Bool { self.platform.value1 == .darwin } @@ -50,17 +52,38 @@ extension Components.Schemas.SwiftlyReleasePlatformArtifacts { } } -extension Components.Schemas.SwiftlyPlatformIdentifier { - public init(_ knownSwiftlyPlatformIdentifier: Components.Schemas.KnownSwiftlyPlatformIdentifier) { +extension SwiftlyWebsiteAPI.Components.Schemas.SwiftlyPlatformIdentifier { + public init(_ knownSwiftlyPlatformIdentifier: SwiftlyWebsiteAPI.Components.Schemas.KnownSwiftlyPlatformIdentifier) { self.init(value1: knownSwiftlyPlatformIdentifier) } } +public struct ToolchainFile: Sendable { + public var category: String + public var platform: String + public var version: String + public var file: String + + public init(category: String, platform: String, version: String, file: String) { + self.category = category + self.platform = platform + self.version = version + self.file = file + } +} + public protocol HTTPRequestExecutor { - func execute(_ request: HTTPClientRequest, timeout: TimeAmount) async throws -> HTTPClientResponse - func getCurrentSwiftlyRelease() async throws -> Components.Schemas.SwiftlyRelease - func getReleaseToolchains() async throws -> [Components.Schemas.Release] - func getSnapshotToolchains(branch: Components.Schemas.SourceBranch, platform: Components.Schemas.PlatformIdentifier) async throws -> Components.Schemas.DevToolchains + func getCurrentSwiftlyRelease() async throws -> SwiftlyWebsiteAPI.Components.Schemas.SwiftlyRelease + func getReleaseToolchains() async throws -> [SwiftlyWebsiteAPI.Components.Schemas.Release] + func getSnapshotToolchains( + branch: SwiftlyWebsiteAPI.Components.Schemas.SourceBranch, platform: SwiftlyWebsiteAPI.Components.Schemas.PlatformIdentifier + ) async throws -> SwiftlyWebsiteAPI.Components.Schemas.DevToolchains + func getGpgKeys() async throws -> OpenAPIRuntime.HTTPBody + func getSwiftlyRelease(url: URL) async throws -> OpenAPIRuntime.HTTPBody + func getSwiftlyReleaseSignature(url: URL) async throws -> OpenAPIRuntime.HTTPBody + func getSwiftToolchainFile(_ toolchainFile: ToolchainFile) async throws -> OpenAPIRuntime.HTTPBody + func getSwiftToolchainFileSignature(_ toolchainFile: ToolchainFile) async throws + -> OpenAPIRuntime.HTTPBody } struct SwiftlyUserAgentMiddleware: ClientMiddleware { @@ -107,7 +130,9 @@ public class HTTPRequestExecutorImpl: HTTPRequestExecutor { } if proxy != nil { - self.httpClient = HTTPClient(eventLoopGroupProvider: .singleton, configuration: HTTPClient.Configuration(proxy: proxy)) + self.httpClient = HTTPClient( + eventLoopGroupProvider: .singleton, configuration: HTTPClient.Configuration(proxy: proxy) + ) } else { self.httpClient = HTTPClient.shared } @@ -119,47 +144,129 @@ public class HTTPRequestExecutorImpl: HTTPRequestExecutor { } } - public func execute(_ request: HTTPClientRequest, timeout: TimeAmount) async throws -> HTTPClientResponse { - try await self.httpClient.execute(request, timeout: timeout) + private func websiteClient() throws -> SwiftlyWebsiteAPI.Client { + let swiftlyUserAgent = SwiftlyUserAgentMiddleware() + let transport: ClientTransport + + let config = AsyncHTTPClientTransport.Configuration( + client: self.httpClient, timeout: .seconds(30) + ) + transport = AsyncHTTPClientTransport(configuration: config) + + return Client( + serverURL: try SwiftlyWebsiteAPI.Servers.productionURL(), + transport: transport, + middlewares: [swiftlyUserAgent] + ) } - private func client() throws -> Client { + private func downloadClient(baseURL: URL) throws -> SwiftlyDownloadAPI.Client { let swiftlyUserAgent = SwiftlyUserAgentMiddleware() let transport: ClientTransport - let config = AsyncHTTPClientTransport.Configuration(client: self.httpClient, timeout: .seconds(30)) + let config = AsyncHTTPClientTransport.Configuration( + client: self.httpClient, timeout: .seconds(30) + ) transport = AsyncHTTPClientTransport(configuration: config) - return Client( - serverURL: try Servers.Server1.url(), + return SwiftlyDownloadAPI.Client( + serverURL: baseURL, transport: transport, middlewares: [swiftlyUserAgent] ) } - public func getCurrentSwiftlyRelease() async throws -> Components.Schemas.SwiftlyRelease { - let response = try await self.client().getCurrentSwiftlyRelease() + public func getCurrentSwiftlyRelease() async throws -> SwiftlyWebsiteAPI.Components.Schemas.SwiftlyRelease { + let response = try await self.websiteClient().getCurrentSwiftlyRelease() return try response.ok.body.json } - public func getReleaseToolchains() async throws -> [Components.Schemas.Release] { - let response = try await self.client().listReleases() + public func getReleaseToolchains() async throws -> [SwiftlyWebsiteAPI.Components.Schemas.Release] { + let response = try await self.websiteClient().listReleases() return try response.ok.body.json } - public func getSnapshotToolchains(branch: Components.Schemas.SourceBranch, platform: Components.Schemas.PlatformIdentifier) async throws -> Components.Schemas.DevToolchains { - let response = try await self.client().listDevToolchains(.init(path: .init(branch: branch, platform: platform))) + public func getSnapshotToolchains( + branch: SwiftlyWebsiteAPI.Components.Schemas.SourceBranch, platform: SwiftlyWebsiteAPI.Components.Schemas.PlatformIdentifier + ) async throws -> SwiftlyWebsiteAPI.Components.Schemas.DevToolchains { + let response = try await self.websiteClient().listDevToolchains( + .init(path: .init(branch: branch, platform: platform))) return try response.ok.body.json } -} -private func makeRequest(url: String) -> HTTPClientRequest { - var request = HTTPClientRequest(url: url) - request.headers.add(name: "User-Agent", value: "swiftly/\(SwiftlyCore.version)") - return request + public func getGpgKeys() async throws -> OpenAPIRuntime.HTTPBody { + let response = try await downloadClient(baseURL: SwiftlyDownloadAPI.Servers.productionURL()).swiftGpgKeys( + .init()) + + return try response.ok.body.plainText + } + + public func getSwiftlyRelease(url: URL) async throws -> OpenAPIRuntime.HTTPBody { + guard try url.host(percentEncoded: false) == Servers.productionDownloadURL().host(percentEncoded: false), + let match = try #/\/swiftly\/(?.+)\/(?.+)/#.wholeMatch( + in: url.path(percentEncoded: false)) + else { + throw SwiftlyError(message: "Unexpected Swiftly download URL format: \(url.path(percentEncoded: false))") + } + + let response = try await downloadClient(baseURL: SwiftlyDownloadAPI.Servers.productionDownloadURL()) + .downloadSwiftlyRelease( + .init(path: .init(platform: String(match.output.platform), file: String(match.output.file))) + ) + + return try response.ok.body.binary + } + + public func getSwiftlyReleaseSignature(url: URL) async throws -> OpenAPIRuntime.HTTPBody { + guard try url.host(percentEncoded: false) == Servers.productionDownloadURL().host(percentEncoded: false), + let match = try #/\/swiftly\/(?.+)\/(?.+).sig/#.wholeMatch( + in: url.path(percentEncoded: false)) + else { + throw SwiftlyError(message: "Unexpected Swiftly signature URL format: \(url.path(percentEncoded: false))") + } + + let response = try await downloadClient(baseURL: SwiftlyDownloadAPI.Servers.productionDownloadURL()) + .getSwiftlyReleaseSignature( + .init(path: .init(platform: String(match.output.platform), file: String(match.output.file))) + ) + + return try response.ok.body.binary + } + + public func getSwiftToolchainFile(_ toolchainFile: ToolchainFile) async throws + -> OpenAPIRuntime.HTTPBody + { + let response = try await downloadClient(baseURL: SwiftlyDownloadAPI.Servers.productionDownloadURL()) + .downloadSwiftToolchain( + .init( + path: .init( + category: String(toolchainFile.category), platform: String(toolchainFile.platform), + version: String(toolchainFile.version), file: String(toolchainFile.file) + ))) + if response == .notFound { + throw try DownloadNotFoundError( + url: Servers.productionDownloadURL().appendingPathComponent(toolchainFile.category).appendingPathComponent(toolchainFile.platform).appendingPathComponent(toolchainFile.version).appendingPathComponent(toolchainFile.file)) + } + + return try response.ok.body.binary + } + + public func getSwiftToolchainFileSignature(_ toolchainFile: ToolchainFile) async throws + -> OpenAPIRuntime.HTTPBody + { + let response = try await downloadClient(baseURL: SwiftlyDownloadAPI.Servers.productionDownloadURL()) + .getSwiftToolchainSignature( + .init( + path: .init( + category: String(toolchainFile.category), platform: String(toolchainFile.platform), + version: String(toolchainFile.version), file: String(toolchainFile.file) + ))) + + return try response.ok.body.binary + } } -extension Components.Schemas.Release { +extension SwiftlyWebsiteAPI.Components.Schemas.Release { var stableName: String { let components = self.name.components(separatedBy: ".") if components.count == 2 { @@ -170,8 +277,8 @@ extension Components.Schemas.Release { } } -extension Components.Schemas.Architecture { - public init(_ knownArchitecture: Components.Schemas.KnownArchitecture) { +extension SwiftlyWebsiteAPI.Components.Schemas.Architecture { + public init(_ knownArchitecture: SwiftlyWebsiteAPI.Components.Schemas.KnownArchitecture) { self.init(value1: knownArchitecture, value2: knownArchitecture.rawValue) } @@ -180,8 +287,8 @@ extension Components.Schemas.Architecture { } } -extension Components.Schemas.PlatformIdentifier { - public init(_ knownPlatformIdentifier: Components.Schemas.KnownPlatformIdentifier) { +extension SwiftlyWebsiteAPI.Components.Schemas.PlatformIdentifier { + public init(_ knownPlatformIdentifier: SwiftlyWebsiteAPI.Components.Schemas.KnownPlatformIdentifier) { self.init(value1: knownPlatformIdentifier) } @@ -190,8 +297,8 @@ extension Components.Schemas.PlatformIdentifier { } } -extension Components.Schemas.SourceBranch { - public init(_ knownSourceBranch: Components.Schemas.KnownSourceBranch) { +extension SwiftlyWebsiteAPI.Components.Schemas.SourceBranch { + public init(_ knownSourceBranch: SwiftlyWebsiteAPI.Components.Schemas.KnownSourceBranch) { self.init(value1: knownSourceBranch) } @@ -200,12 +307,14 @@ extension Components.Schemas.SourceBranch { } } -extension Components.Schemas.Architecture { - static let x8664: Components.Schemas.Architecture = .init(Components.Schemas.KnownArchitecture.x8664) - static let aarch64: Components.Schemas.Architecture = .init(Components.Schemas.KnownArchitecture.aarch64) +extension SwiftlyWebsiteAPI.Components.Schemas.Architecture { + static let x8664: SwiftlyWebsiteAPI.Components.Schemas.Architecture = .init( + SwiftlyWebsiteAPI.Components.Schemas.KnownArchitecture.x8664) + static let aarch64: SwiftlyWebsiteAPI.Components.Schemas.Architecture = .init( + SwiftlyWebsiteAPI.Components.Schemas.KnownArchitecture.aarch64) } -extension Components.Schemas.Platform { +extension SwiftlyWebsiteAPI.Components.Schemas.Platform { /// platformDef is a mapping from the 'name' field of the swift.org platform object /// to swiftly's PlatformDefinition, if possible. var platformDef: PlatformDefinition? { @@ -255,7 +364,7 @@ extension Components.Schemas.Platform { } } -extension Components.Schemas.DevToolchainForArch { +extension SwiftlyWebsiteAPI.Components.Schemas.DevToolchainForArch { private static let snapshotRegex: Regex<(Substring, Substring?, Substring?, Substring)> = try! Regex("swift(?:-(\\d+)\\.(\\d+))?-DEVELOPMENT-SNAPSHOT-(\\d{4}-\\d{2}-\\d{2})") @@ -267,7 +376,8 @@ extension Components.Schemas.DevToolchainForArch { let branch: ToolchainVersion.Snapshot.Branch if let majorString = match.output.1, let minorString = match.output.2 { guard let major = Int(majorString), let minor = Int(minorString) else { - throw SwiftlyError(message: "malformatted release branch: \"\(majorString).\(minorString)\"") + throw SwiftlyError( + message: "malformatted release branch: \"\(majorString).\(minorString)\"") } branch = .release(major: major, minor: minor) } else { @@ -278,6 +388,19 @@ extension Components.Schemas.DevToolchainForArch { } } +public struct DownloadProgress { + public let receivedBytes: Int + public let totalBytes: Int? +} + +public struct DownloadNotFoundError: LocalizedError { + public let url: URL + + public init(url: URL) { + self.url = url + } +} + /// HTTPClient wrapper used for interfacing with various REST APIs and downloading things. public struct SwiftlyHTTPClient { public let httpRequestExecutor: HTTPRequestExecutor @@ -286,12 +409,8 @@ public struct SwiftlyHTTPClient { self.httpRequestExecutor = httpRequestExecutor } - public struct JSONNotFoundError: LocalizedError { - public var url: String - } - /// Return the current Swiftly release using the swift.org API. - public func getCurrentSwiftlyRelease() async throws -> Components.Schemas.SwiftlyRelease { + public func getCurrentSwiftlyRelease() async throws -> SwiftlyWebsiteAPI.Components.Schemas.SwiftlyRelease { try await self.httpRequestExecutor.getCurrentSwiftlyRelease() } @@ -299,7 +418,7 @@ public struct SwiftlyHTTPClient { /// limit (default unlimited). public func getReleaseToolchains( platform: PlatformDefinition, - arch a: Components.Schemas.Architecture? = nil, + arch a: SwiftlyWebsiteAPI.Components.Schemas.Architecture? = nil, limit: Int? = nil, filter: ((ToolchainVersion.StableRelease) -> Bool)? = nil ) async throws -> [ToolchainVersion.StableRelease] { @@ -307,10 +426,13 @@ public struct SwiftlyHTTPClient { let releases = try await self.httpRequestExecutor.getReleaseToolchains() - var swiftOrgFiltered: [ToolchainVersion.StableRelease] = try releases.compactMap { swiftOrgRelease in + var swiftOrgFiltered: [ToolchainVersion.StableRelease] = try releases.compactMap { + swiftOrgRelease in if platform.name != PlatformDefinition.macOS.name { // If the platform isn't xcode then verify that there is an offering for this platform name and arch - guard let swiftOrgPlatform = swiftOrgRelease.platforms.first(where: { $0.matches(platform) }) else { + guard + let swiftOrgPlatform = swiftOrgRelease.platforms.first(where: { $0.matches(platform) }) + else { return nil } @@ -322,7 +444,8 @@ public struct SwiftlyHTTPClient { guard let version = try? ToolchainVersion(parsing: swiftOrgRelease.stableName), case let .stable(release) = version else { - throw SwiftlyError(message: "error parsing swift.org release version: \(swiftOrgRelease.stableName)") + throw SwiftlyError( + message: "error parsing swift.org release version: \(swiftOrgRelease.stableName)") } if let filter { @@ -356,9 +479,12 @@ public struct SwiftlyHTTPClient { limit: Int? = nil, filter: ((ToolchainVersion.Snapshot) -> Bool)? = nil ) async throws -> [ToolchainVersion.Snapshot] { - let platformId: Components.Schemas.PlatformIdentifier = switch platform.name { + let platformId: SwiftlyWebsiteAPI.Components.Schemas.PlatformIdentifier = + switch platform.name + { // These are new platforms that aren't yet in the list of known platforms in the OpenAPI schema - case PlatformDefinition.ubuntu2404.name, PlatformDefinition.debian12.name, PlatformDefinition.fedora39.name: + case PlatformDefinition.ubuntu2404.name, PlatformDefinition.debian12.name, + PlatformDefinition.fedora39.name: .init(platform.name) case PlatformDefinition.ubuntu2204.name: @@ -372,41 +498,49 @@ public struct SwiftlyHTTPClient { case PlatformDefinition.macOS.name: .init(.macos) default: - throw SwiftlyError(message: "No snapshot toolchains available for platform \(platform.name)") + throw SwiftlyError( + message: "No snapshot toolchains available for platform \(platform.name)") } - let sourceBranch: Components.Schemas.SourceBranch = switch branch { + let sourceBranch: SwiftlyWebsiteAPI.Components.Schemas.SourceBranch = + switch branch + { case .main: .init(.main) case let .release(major, minor): .init("\(major).\(minor)") } - let devToolchains = try await self.httpRequestExecutor.getSnapshotToolchains(branch: sourceBranch, platform: platformId) + let devToolchains = try await self.httpRequestExecutor.getSnapshotToolchains( + branch: sourceBranch, platform: platformId + ) let arch = a ?? cpuArch.value2 // These are the available snapshots for the branch, platform, and architecture - let swiftOrgSnapshots = if platform.name == PlatformDefinition.macOS.name { - devToolchains.universal ?? [Components.Schemas.DevToolchainForArch]() + let swiftOrgSnapshots = + if platform.name == PlatformDefinition.macOS.name + { + devToolchains.universal ?? [SwiftlyWebsiteAPI.Components.Schemas.DevToolchainForArch]() } else if arch == "aarch64" { - devToolchains.aarch64 ?? [Components.Schemas.DevToolchainForArch]() + devToolchains.aarch64 ?? [SwiftlyWebsiteAPI.Components.Schemas.DevToolchainForArch]() } else if arch == "x86_64" { - devToolchains.x8664 ?? [Components.Schemas.DevToolchainForArch]() + devToolchains.x8664 ?? [SwiftlyWebsiteAPI.Components.Schemas.DevToolchainForArch]() } else { - [Components.Schemas.DevToolchainForArch]() + [SwiftlyWebsiteAPI.Components.Schemas.DevToolchainForArch]() } // Convert these into toolchain snapshot versions that match the filter - var matchingSnapshots = try swiftOrgSnapshots.map { try $0.parseSnapshot() }.compactMap { $0 }.filter { toolchainVersion in - if let filter { - guard filter(toolchainVersion) else { - return false + var matchingSnapshots = try swiftOrgSnapshots.map { try $0.parseSnapshot() }.compactMap { $0 } + .filter { toolchainVersion in + if let filter { + guard filter(toolchainVersion) else { + return false + } } - } - return true - } + return true + } matchingSnapshots.sort(by: >) @@ -417,54 +551,63 @@ public struct SwiftlyHTTPClient { } } - public struct DownloadProgress { - public let receivedBytes: Int - public let totalBytes: Int? + public func getGpgKeys() async throws -> OpenAPIRuntime.HTTPBody { + try await self.httpRequestExecutor.getGpgKeys() + } + + public func getSwiftlyRelease(url: URL) async throws -> OpenAPIRuntime.HTTPBody { + try await self.httpRequestExecutor.getSwiftlyRelease(url: url) + } + + public func getSwiftlyReleaseSignature(url: URL) async throws -> OpenAPIRuntime.HTTPBody { + try await self.httpRequestExecutor.getSwiftlyReleaseSignature(url: url) + } + + public func getSwiftToolchainFile(_ toolchainFile: ToolchainFile) async throws + -> OpenAPIRuntime.HTTPBody + { + try await self.httpRequestExecutor.getSwiftToolchainFile(toolchainFile) } - public struct DownloadNotFoundError: LocalizedError { - public let url: String + public func getSwiftToolchainFileSignature(_ toolchainFile: ToolchainFile) async throws + -> OpenAPIRuntime.HTTPBody + { + try await self.httpRequestExecutor.getSwiftToolchainFileSignature(toolchainFile) } +} - public func downloadFile( - url: URL, - to destination: URL, - reportProgress: ((DownloadProgress) -> Void)? = nil - ) async throws { +extension OpenAPIRuntime.HTTPBody { + public func download(to destination: URL, reportProgress: ((DownloadProgress) -> Void)? = nil) + async throws + { let fileHandle = try FileHandle(forWritingTo: destination) defer { try? fileHandle.close() } - let request = makeRequest(url: url.absoluteString) - let response = try await self.httpRequestExecutor.execute(request, timeout: .seconds(60)) - - switch response.status { - case .ok: - break - case .notFound: - throw SwiftlyHTTPClient.DownloadNotFoundError(url: url.path) - default: - throw SwiftlyError(message: "Received \(response.status) when trying to download \(url)") + let expectedBytes: Int? + switch self.length { + case .unknown: + expectedBytes = nil + case let .known(count): + expectedBytes = Int(count) } - // if defined, the content-length headers announces the size of the body - let expectedBytes = response.headers.first(name: "content-length").flatMap(Int.init) - var lastUpdate = Date() var receivedBytes = 0 - for try await buffer in response.body { - receivedBytes += buffer.readableBytes + for try await buffer in self { + receivedBytes += buffer.count - try fileHandle.write(contentsOf: buffer.readableBytesView) + try fileHandle.write(contentsOf: buffer) let now = Date() if let reportProgress, lastUpdate.distance(to: now) > 0.25 || receivedBytes == expectedBytes { lastUpdate = now - reportProgress(SwiftlyHTTPClient.DownloadProgress( - receivedBytes: receivedBytes, - totalBytes: expectedBytes - )) + reportProgress( + DownloadProgress( + receivedBytes: receivedBytes, + totalBytes: expectedBytes + )) } } diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index f28171e9..0d28f3fe 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -23,19 +23,34 @@ public struct PlatformDefinition: Codable, Equatable { public static let macOS = PlatformDefinition(name: "xcode", nameFull: "osx", namePretty: "macOS") - public static let ubuntu2404 = PlatformDefinition(name: "ubuntu2404", nameFull: "ubuntu24.04", namePretty: "Ubuntu 24.04") - public static let ubuntu2204 = PlatformDefinition(name: "ubuntu2204", nameFull: "ubuntu22.04", namePretty: "Ubuntu 22.04") - public static let ubuntu2004 = PlatformDefinition(name: "ubuntu2004", nameFull: "ubuntu20.04", namePretty: "Ubuntu 20.04") - public static let ubuntu1804 = PlatformDefinition(name: "ubuntu1804", nameFull: "ubuntu18.04", namePretty: "Ubuntu 18.04") + public static let ubuntu2404 = PlatformDefinition( + name: "ubuntu2404", nameFull: "ubuntu24.04", namePretty: "Ubuntu 24.04" + ) + public static let ubuntu2204 = PlatformDefinition( + name: "ubuntu2204", nameFull: "ubuntu22.04", namePretty: "Ubuntu 22.04" + ) + public static let ubuntu2004 = PlatformDefinition( + name: "ubuntu2004", nameFull: "ubuntu20.04", namePretty: "Ubuntu 20.04" + ) + public static let ubuntu1804 = PlatformDefinition( + name: "ubuntu1804", nameFull: "ubuntu18.04", namePretty: "Ubuntu 18.04" + ) public static let rhel9 = PlatformDefinition(name: "ubi9", nameFull: "ubi9", namePretty: "RHEL 9") - public static let fedora39 = PlatformDefinition(name: "fedora39", nameFull: "fedora39", namePretty: "Fedora Linux 39") - public static let amazonlinux2 = PlatformDefinition(name: "amazonlinux2", nameFull: "amazonlinux2", namePretty: "Amazon Linux 2") - public static let debian12 = PlatformDefinition(name: "debian12", nameFull: "debian12", namePretty: "Debian GNU/Linux 12") + public static let fedora39 = PlatformDefinition( + name: "fedora39", nameFull: "fedora39", namePretty: "Fedora Linux 39" + ) + public static let amazonlinux2 = PlatformDefinition( + name: "amazonlinux2", nameFull: "amazonlinux2", namePretty: "Amazon Linux 2" + ) + public static let debian12 = PlatformDefinition( + name: "debian12", nameFull: "debian12", namePretty: "Debian GNU/Linux 12" + ) } public struct RunProgramError: Swift.Error { public let exitCode: Int32 public let program: String + public let arguments: [String] } public protocol Platform { @@ -60,7 +75,8 @@ public protocol Platform { /// Installs a toolchain from a file on disk pointed to by the given URL. /// After this completes, a user can “use” the toolchain. - func install(_ ctx: SwiftlyCoreContext, from: URL, version: ToolchainVersion, verbose: Bool) throws + func install(_ ctx: SwiftlyCoreContext, from: URL, version: ToolchainVersion, verbose: Bool) + throws /// Extract swiftly from the provided downloaded archive and install /// ourselves from that. @@ -90,14 +106,27 @@ public protocol Platform { /// can run to install these dependencies, possibly with super user permissions. /// /// Throws if system does not meet the requirements to perform the install. - func verifySystemPrerequisitesForInstall(_ ctx: SwiftlyCoreContext, platformName: String, version: ToolchainVersion, requireSignatureValidation: Bool) async throws -> String? + func verifySystemPrerequisitesForInstall( + _ ctx: SwiftlyCoreContext, platformName: String, version: ToolchainVersion, + requireSignatureValidation: Bool + ) async throws -> 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. - func verifySignature(_ ctx: SwiftlyCoreContext, archiveDownloadURL: URL, archive: URL, verbose: Bool) async throws + func verifyToolchainSignature( + _ ctx: SwiftlyCoreContext, toolchainFile: ToolchainFile, archive: URL, verbose: Bool + ) + async throws + + /// Downloads the signature file associated with the archive and verifies it matches the downloaded archive. + /// Throws an error if the signature does not match. + func verifySwiftlySignature( + _ ctx: SwiftlyCoreContext, archiveDownloadURL: URL, archive: URL, verbose: Bool + ) async throws /// Detect the platform definition for this platform. - func detectPlatform(_ ctx: SwiftlyCoreContext, disableConfirmation: Bool, platform: String?) async throws -> PlatformDefinition + func detectPlatform(_ ctx: SwiftlyCoreContext, disableConfirmation: Bool, platform: String?) + async throws -> PlatformDefinition /// Get the user's current login shell func getShell() async throws -> String @@ -135,10 +164,16 @@ extension Platform { } #if os(macOS) || os(Linux) - func proxyEnv(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) throws -> [String: String] { + func proxyEnv(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) throws -> [ + String: + String + ] { let tcPath = self.findToolchainLocation(ctx, toolchain).appendingPathComponent("usr/bin") guard tcPath.fileExists() else { - throw SwiftlyError(message: "Toolchain \(toolchain) could not be located. You can try `swiftly uninstall \(toolchain)` to uninstall it and then `swiftly install \(toolchain)` to install it again.") + throw SwiftlyError( + message: + "Toolchain \(toolchain) could not be located. You can try `swiftly uninstall \(toolchain)` to uninstall it and then `swiftly install \(toolchain)` to install it again." + ) } var newEnv = ProcessInfo.processInfo.environment @@ -157,7 +192,10 @@ extension Platform { /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with /// the exit code and program information. /// - public func proxy(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, _ command: String, _ arguments: [String], _ env: [String: String] = [:]) async throws { + public func proxy( + _ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, _ command: String, + _ arguments: [String], _ env: [String: String] = [:] + ) async throws { var newEnv = try self.proxyEnv(ctx, toolchain) for (key, value) in env { newEnv[key] = value @@ -170,7 +208,10 @@ extension Platform { /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with /// the exit code and program information. /// - public func proxyOutput(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, _ command: String, _ arguments: [String]) async throws -> String? { + public func proxyOutput( + _ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, _ command: String, + _ arguments: [String] + ) async throws -> String? { try await self.runProgramOutput(command, arguments, env: self.proxyEnv(ctx, toolchain)) } @@ -179,7 +220,9 @@ extension Platform { /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with /// the exit code and program information. /// - public func runProgram(_ args: String..., quiet: Bool = false, env: [String: String]? = nil) throws { + public func runProgram(_ args: String..., quiet: Bool = false, env: [String: String]? = nil) + throws + { try self.runProgram([String](args), quiet: quiet, env: env) } @@ -188,7 +231,9 @@ extension Platform { /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with /// the exit code and program information. /// - public func runProgram(_ args: [String], quiet: Bool = false, env: [String: String]? = nil) throws { + public func runProgram(_ args: [String], quiet: Bool = false, env: [String: String]? = nil) + throws + { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = args @@ -209,14 +254,16 @@ extension Platform { tcsetpgrp(STDOUT_FILENO, process.processIdentifier) } - defer { if pgid != -1 { - tcsetpgrp(STDOUT_FILENO, pgid) - }} + defer { + if pgid != -1 { + tcsetpgrp(STDOUT_FILENO, pgid) + } + } process.waitUntilExit() guard process.terminationStatus == 0 else { - throw RunProgramError(exitCode: process.terminationStatus, program: args.first!) + throw RunProgramError(exitCode: process.terminationStatus, program: args.first!, arguments: Array(args.dropFirst())) } } @@ -225,7 +272,9 @@ extension Platform { /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with /// the exit code and program information. /// - public func runProgramOutput(_ program: String, _ args: String..., env: [String: String]? = nil) async throws -> String? { + public func runProgramOutput(_ program: String, _ args: String..., env: [String: String]? = nil) + async throws -> String? + { try await self.runProgramOutput(program, [String](args), env: env) } @@ -234,7 +283,9 @@ extension Platform { /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with /// the exit code and program information. /// - public func runProgramOutput(_ program: String, _ args: [String], env: [String: String]? = nil) async throws -> String? { + public func runProgramOutput(_ program: String, _ args: [String], env: [String: String]? = nil) + async throws -> String? + { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = [program] + args @@ -254,16 +305,18 @@ extension Platform { if pgid != -1 { tcsetpgrp(STDOUT_FILENO, process.processIdentifier) } - defer { if pgid != -1 { - tcsetpgrp(STDOUT_FILENO, pgid) - }} + defer { + if pgid != -1 { + tcsetpgrp(STDOUT_FILENO, pgid) + } + } let outData = try outPipe.fileHandleForReading.readToEnd() process.waitUntilExit() guard process.terminationStatus == 0 else { - throw RunProgramError(exitCode: process.terminationStatus, program: args.first!) + throw RunProgramError(exitCode: process.terminationStatus, program: args.first!, arguments: Array(args.dropFirst())) } if let outData { @@ -277,14 +330,18 @@ extension Platform { public func installSwiftlyBin(_ ctx: SwiftlyCoreContext) throws { // First, let's find out where we are. let cmd = CommandLine.arguments[0] - let cmdAbsolute = if cmd.hasPrefix("/") { + let cmdAbsolute = + if cmd.hasPrefix("/") + { cmd } else { - ([FileManager.default.currentDirectoryPath] + (ProcessInfo.processInfo.environment["PATH"]?.components(separatedBy: ":") ?? [])).map { - $0 + "/" + cmd - }.filter { - FileManager.default.fileExists(atPath: $0) - }.first + ([FileManager.default.currentDirectoryPath] + + (ProcessInfo.processInfo.environment["PATH"]?.components(separatedBy: ":") ?? [])).map + { + $0 + "/" + cmd + }.filter { + FileManager.default.fileExists(atPath: $0) + }.first } // We couldn't find ourselves in the usual places. Assume that no installation is necessary @@ -295,8 +352,10 @@ extension Platform { // Proceed to installation only if we're in the user home directory, or a non-system location. let userHome = FileManager.default.homeDirectoryForCurrentUser - guard cmdAbsolute.hasPrefix(userHome.path + "/") || - (!cmdAbsolute.hasPrefix("/usr/") && !cmdAbsolute.hasPrefix("/opt/") && !cmdAbsolute.hasPrefix("/bin/")) + guard + cmdAbsolute.hasPrefix(userHome.path + "/") + || (!cmdAbsolute.hasPrefix("/usr/") && !cmdAbsolute.hasPrefix("/opt/") + && !cmdAbsolute.hasPrefix("/bin/")) else { return } @@ -307,7 +366,11 @@ extension Platform { } // We're already running from where we would be installing ourselves. - guard case let swiftlyHomeBin = self.swiftlyBinDir(ctx).appendingPathComponent("swiftly", isDirectory: false).path, cmdAbsolute != swiftlyHomeBin else { + guard + case let swiftlyHomeBin = self.swiftlyBinDir(ctx).appendingPathComponent( + "swiftly", isDirectory: false + ).path, cmdAbsolute != swiftlyHomeBin + else { return } @@ -321,24 +384,32 @@ extension Platform { try FileManager.default.moveItem(atPath: cmdAbsolute, toPath: swiftlyHomeBin) } catch { try FileManager.default.copyItem(atPath: cmdAbsolute, toPath: swiftlyHomeBin) - ctx.print("Swiftly has been copied into the installation directory. You can remove '\(cmdAbsolute)'. It is no longer needed.") + ctx.print( + "Swiftly has been copied into the installation directory. You can remove '\(cmdAbsolute)'. It is no longer needed." + ) } } // Find the location where swiftly should be executed. public func findSwiftlyBin(_ ctx: SwiftlyCoreContext) throws -> String? { - let swiftlyHomeBin = self.swiftlyBinDir(ctx).appendingPathComponent("swiftly", isDirectory: false).path + let swiftlyHomeBin = self.swiftlyBinDir(ctx).appendingPathComponent( + "swiftly", isDirectory: false + ).path // First, let's find out where we are. let cmd = CommandLine.arguments[0] - let cmdAbsolute = if cmd.hasPrefix("/") { + let cmdAbsolute = + if cmd.hasPrefix("/") + { cmd } else { - ([FileManager.default.currentDirectoryPath] + (ProcessInfo.processInfo.environment["PATH"]?.components(separatedBy: ":") ?? [])).map { - $0 + "/" + cmd - }.filter { - FileManager.default.fileExists(atPath: $0) - }.first + ([FileManager.default.currentDirectoryPath] + + (ProcessInfo.processInfo.environment["PATH"]?.components(separatedBy: ":") ?? [])).map + { + $0 + "/" + cmd + }.filter { + FileManager.default.fileExists(atPath: $0) + }.first } // We couldn't find ourselves in the usual places, so if we're not going to be installing @@ -349,7 +420,11 @@ extension Platform { // If we are system managed then we know where swiftly should be. let userHome = FileManager.default.homeDirectoryForCurrentUser - if let cmdAbsolute, !cmdAbsolute.hasPrefix(userHome.path + "/") && (cmdAbsolute.hasPrefix("/usr/") || cmdAbsolute.hasPrefix("/opt/") || cmdAbsolute.hasPrefix("/bin/")) { + if let cmdAbsolute, + !cmdAbsolute.hasPrefix(userHome.path + "/") + && (cmdAbsolute.hasPrefix("/usr/") || cmdAbsolute.hasPrefix("/opt/") + || cmdAbsolute.hasPrefix("/bin/")) + { return cmdAbsolute } @@ -361,7 +436,8 @@ extension Platform { return FileManager.default.fileExists(atPath: swiftlyHomeBin) ? swiftlyHomeBin : nil } - public func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL { + public func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL + { self.findToolchainLocation(ctx, toolchain).appendingPathComponent("usr/bin") } diff --git a/Sources/SwiftlyCore/SwiftlyCore.swift b/Sources/SwiftlyCore/SwiftlyCore.swift index f81c29a7..ebfd6668 100644 --- a/Sources/SwiftlyCore/SwiftlyCore.swift +++ b/Sources/SwiftlyCore/SwiftlyCore.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftlyWebsiteAPI public let version = SwiftlyVersion(major: 1, minor: 1, patch: 0, suffix: "dev") @@ -91,9 +92,9 @@ public struct SwiftlyCoreContext { } #if arch(x86_64) -public let cpuArch = Components.Schemas.Architecture.x8664 +public let cpuArch = SwiftlyWebsiteAPI.Components.Schemas.Architecture.x8664 #elseif arch(arm64) -public let cpuArch = Components.Schemas.Architecture.aarch64 +public let cpuArch = SwiftlyWebsiteAPI.Components.Schemas.Architecture.aarch64 #else #error("Unsupported processor architecture") #endif diff --git a/Sources/SwiftlyDownloadAPI/Servers+Extensions.swift b/Sources/SwiftlyDownloadAPI/Servers+Extensions.swift new file mode 100644 index 00000000..d0ec4056 --- /dev/null +++ b/Sources/SwiftlyDownloadAPI/Servers+Extensions.swift @@ -0,0 +1,11 @@ +import Foundation + +extension Servers { + public static func productionURL() throws -> URL { + try Server1.url() + } + + public static func productionDownloadURL() throws -> URL { + try Server2.url() + } +} diff --git a/Sources/SwiftlyDownloadAPI/openapi-generator-config.yaml b/Sources/SwiftlyDownloadAPI/openapi-generator-config.yaml new file mode 100644 index 00000000..ae995919 --- /dev/null +++ b/Sources/SwiftlyDownloadAPI/openapi-generator-config.yaml @@ -0,0 +1,5 @@ +generate: + - types + - client +namingStrategy: idiomatic +accessModifier: public diff --git a/Sources/SwiftlyDownloadAPI/openapi.yaml b/Sources/SwiftlyDownloadAPI/openapi.yaml new file mode 100644 index 00000000..31a63c38 --- /dev/null +++ b/Sources/SwiftlyDownloadAPI/openapi.yaml @@ -0,0 +1,154 @@ +openapi: '3.0.3' +info: + title: swift.org Download Endpoints + description: Endpoints for retrieving Swift toolchain and Swiftly releases. + version: 1.1.0 +servers: + - url: https://www.swift.org + description: The production deployment. + - url: https://download.swift.org + description: The production deployment (for file download operations). +tags: + - name: Download + description: File download operations for Swift toolchains, Swiftly releases, and their signatures. + - name: Security + description: Signing keys for published swift.org releases. +paths: + /keys/all-keys.asc: + get: + operationId: swiftGpgKeys + summary: Download Swift GPG keys. + tags: + - Security + responses: + 200: + description: A successful response. + content: + text/plain: + schema: + type: string + format: binary + /swiftly/{platform}/{file}: + parameters: + - name: platform + in: path + required: true + schema: + type: string + - name: file + in: path + required: true + schema: + type: string + get: + operationId: downloadSwiftlyRelease + summary: Download a Swiftly release. + tags: + - Download + responses: + 200: + description: A successful response. + content: + application/octet-stream: + schema: + type: string + format: binary + 404: + description: Not found. + /swiftly/{platform}/{file}.sig: + parameters: + - name: platform + in: path + required: true + schema: + type: string + - name: file + in: path + required: true + schema: + type: string + get: + operationId: getSwiftlyReleaseSignature + summary: Get a Swiftly release signature. + tags: + - Download + responses: + 200: + description: A successful response. + content: + application/octet-stream: + schema: + type: string + format: binary + /{category}/{platform}/{version}/{file}: + parameters: + - name: category + in: path + required: true + schema: + type: string + - name: platform + in: path + required: true + schema: + type: string + - name: version + in: path + required: true + schema: + type: string + - name: file + in: path + required: true + schema: + type: string + get: + operationId: downloadSwiftToolchain + summary: Download a Swift toolchain. + tags: + - Download + responses: + 200: + description: A successful response. + content: + application/octet-stream: + schema: + type: string + format: binary + 404: + description: Not found. + /{category}/{platform}/{version}/{file}.sig: + parameters: + - name: category + in: path + required: true + schema: + type: string + - name: platform + in: path + required: true + schema: + type: string + - name: version + in: path + required: true + schema: + type: string + - name: file + in: path + required: true + schema: + type: string + get: + operationId: getSwiftToolchainSignature + summary: Get a Swift toolchain signature. + tags: + - Download + responses: + 200: + description: A successful response. + content: + application/octet-stream: + schema: + type: string + format: binary diff --git a/Sources/SwiftlyWebsiteAPI/Servers+Extensions.swift b/Sources/SwiftlyWebsiteAPI/Servers+Extensions.swift new file mode 100644 index 00000000..2af41028 --- /dev/null +++ b/Sources/SwiftlyWebsiteAPI/Servers+Extensions.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Servers { + public static func productionURL() throws -> URL { + try Server1.url() + } +} diff --git a/Sources/SwiftlyCore/openapi-generator-config.yaml b/Sources/SwiftlyWebsiteAPI/openapi-generator-config.yaml similarity index 100% rename from Sources/SwiftlyCore/openapi-generator-config.yaml rename to Sources/SwiftlyWebsiteAPI/openapi-generator-config.yaml diff --git a/Sources/SwiftlyCore/openapi.yaml b/Sources/SwiftlyWebsiteAPI/openapi.yaml similarity index 100% rename from Sources/SwiftlyCore/openapi.yaml rename to Sources/SwiftlyWebsiteAPI/openapi.yaml diff --git a/Tests/SwiftlyTests/HTTPClientTests.swift b/Tests/SwiftlyTests/HTTPClientTests.swift index 08bf15fe..1feec811 100644 --- a/Tests/SwiftlyTests/HTTPClientTests.swift +++ b/Tests/SwiftlyTests/HTTPClientTests.swift @@ -2,38 +2,76 @@ import AsyncHTTPClient import Foundation @testable import Swiftly @testable import SwiftlyCore +import SwiftlyWebsiteAPI import Testing @Suite(.serialized) struct HTTPClientTests { @Test func getSwiftOrgGPGKeys() async throws { - let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) + try await withTemporaryFile { tmpFile in + let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) + + try await retry { + try await httpClient.getGpgKeys().download(to: tmpFile) + } - let tmpFile = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") - _ = FileManager.default.createFile(atPath: tmpFile.path, contents: nil) - defer { - try? FileManager.default.removeItem(at: tmpFile) + try await withGpg { runGpg in + try runGpg(["--import", tmpFile.path]) + } } + } - let gpgKeysUrl = URL(string: "https://www.swift.org/keys/all-keys.asc")! + @Test func getSwiftToolchain() async throws { + try await withTemporaryFile { tmpFile in + try await withTemporaryFile { tmpFileSignature in + let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) - do { - try await httpClient.downloadFile(url: gpgKeysUrl, to: tmpFile) - } catch { - // Retry once to improve CI resiliency - try await httpClient.downloadFile(url: gpgKeysUrl, to: tmpFile) - } + let toolchainFile = ToolchainFile(category: "swift-6.0-release", platform: "ubuntu2404", version: "swift-6.0-RELEASE", file: "swift-6.0-RELEASE-ubuntu24.04.tar.gz") -#if os(Linux) - // With linux, we can ask gpg to try an import to see if the file is valid - // in a sandbox home directory to avoid contaminating the system - let gpgHome = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") - try FileManager.default.createDirectory(atPath: gpgHome.path, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: gpgHome) + try await retry { + try await httpClient.getSwiftToolchainFile(toolchainFile).download(to: tmpFile) + } + + try await retry { + try await httpClient.getSwiftToolchainFileSignature(toolchainFile).download(to: tmpFileSignature) + } + + try await withGpg { runGpg in + try await withTemporaryFile { keysFile in + try await httpClient.getGpgKeys().download(to: keysFile) + try runGpg(["--import", keysFile.path]) + } + + try runGpg(["--verify", tmpFileSignature.path, tmpFile.path]) + } + } } + } - try Swiftly.currentPlatform.runProgram("gpg", "--import", tmpFile.path, quiet: false, env: ["GNUPGHOME": gpgHome.path]) -#endif + @Test func getSwiftlyRelease() async throws { + try await withTemporaryFile { tmpFile in + try await withTemporaryFile { tmpFileSignature in + let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) + + let swiftlyURL = try #require(URL(string: "https://download.swift.org/swiftly/linux/swiftly-x86_64.tar.gz")) + + try await retry { + try await httpClient.getSwiftlyRelease(url: swiftlyURL).download(to: tmpFile) + } + + try await retry { + try await httpClient.getSwiftlyReleaseSignature(url: swiftlyURL.appendingPathExtension("sig")).download(to: tmpFileSignature) + } + + try await withGpg { runGpg in + try await withTemporaryFile { keysFile in + try await httpClient.getGpgKeys().download(to: keysFile) + try runGpg(["--import", keysFile.path]) + } + + try runGpg(["--verify", tmpFileSignature.path, tmpFile.path]) + } + } + } } @Test func getSwiftlyReleaseMetadataFromSwiftOrg() async throws { @@ -50,8 +88,8 @@ import Testing @Test( arguments: [PlatformDefinition.macOS, .ubuntu2404, .ubuntu2204, .rhel9, .fedora39, .amazonlinux2, .debian12], - [Components.Schemas.Architecture.x8664, .aarch64] - ) func getToolchainMetdataFromSwiftOrg(_ platform: PlatformDefinition, _ arch: Components.Schemas.Architecture) async throws { + [SwiftlyWebsiteAPI.Components.Schemas.Architecture.x8664, .aarch64] + ) func getToolchainMetdataFromSwiftOrg(_ platform: PlatformDefinition, _ arch: SwiftlyWebsiteAPI.Components.Schemas.Architecture) async throws { let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) let branches: [ToolchainVersion.Snapshot.Branch] = [ @@ -74,3 +112,39 @@ import Testing } } } + +private func withTemporaryFile(_ body: (URL) async throws -> T) async rethrows -> T { + let tmpFile = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") + _ = FileManager.default.createFile(atPath: tmpFile.path, contents: nil) + defer { + try? FileManager.default.removeItem(at: tmpFile) + } + return try await body(tmpFile) +} + +private func withGpg(_ body: (([String]) throws -> Void) async throws -> Void) async throws { +#if os(Linux) + // With linux, we can ask gpg to try an import to see if the file is valid + // in a sandbox home directory to avoid contaminating the system + let gpgHome = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") + try FileManager.default.createDirectory(atPath: gpgHome.path, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: gpgHome) + } + + func runGpg(arguments: [String]) throws { + try Swiftly.currentPlatform.runProgram(["gpg"] + arguments, quiet: false, env: ["GNUPGHOME": gpgHome.path]) + } + + try await body(runGpg) +#endif +} + +private func retry(_ body: () async throws -> Void) async throws { + do { + try await body() + } catch { + // Retry once to improve CI resiliency + try await body() + } +} diff --git a/Tests/SwiftlyTests/SwiftlyTests.swift b/Tests/SwiftlyTests/SwiftlyTests.swift index 5c0078e4..c26d6c2c 100644 --- a/Tests/SwiftlyTests/SwiftlyTests.swift +++ b/Tests/SwiftlyTests/SwiftlyTests.swift @@ -1,10 +1,10 @@ import _StringProcessing import ArgumentParser -import AsyncHTTPClient import Foundation -import NIO +import OpenAPIRuntime @testable import Swiftly @testable import SwiftlyCore +import SwiftlyWebsiteAPI import Testing #if os(macOS) @@ -33,10 +33,15 @@ struct InputProviderFail: InputProvider { } struct HTTPRequestExecutorFail: HTTPRequestExecutor { - func execute(_: HTTPClientRequest, timeout _: TimeAmount) async throws -> HTTPClientResponse { fatalError(unmockedMsg) } - func getCurrentSwiftlyRelease() async throws -> Components.Schemas.SwiftlyRelease { fatalError(unmockedMsg) } + func getCurrentSwiftlyRelease() async throws -> SwiftlyWebsiteAPI.Components.Schemas.SwiftlyRelease { fatalError(unmockedMsg) } func getReleaseToolchains() async throws -> [Components.Schemas.Release] { fatalError(unmockedMsg) } - func getSnapshotToolchains(branch _: Components.Schemas.SourceBranch, platform _: Components.Schemas.PlatformIdentifier) async throws -> Components.Schemas.DevToolchains { fatalError(unmockedMsg) } + func getSnapshotToolchains(branch _: SwiftlyWebsiteAPI.Components.Schemas.SourceBranch, platform _: SwiftlyWebsiteAPI.Components.Schemas.PlatformIdentifier) async throws -> SwiftlyWebsiteAPI.Components.Schemas.DevToolchains { fatalError(unmockedMsg) } + func getGpgKeys() async throws -> OpenAPIRuntime.HTTPBody { fatalError(unmockedMsg) } + func getSwiftlyRelease(url _: URL) async throws -> OpenAPIRuntime.HTTPBody { fatalError(unmockedMsg) } + func getSwiftlyReleaseSignature(url _: URL) async throws -> OpenAPIRuntime.HTTPBody { fatalError(unmockedMsg) } + func getSwiftToolchainFile(_: ToolchainFile) async throws -> OpenAPIRuntime.HTTPBody { fatalError(unmockedMsg) } + func getSwiftToolchainFileSignature(_: ToolchainFile) async throws + -> OpenAPIRuntime.HTTPBody { fatalError(unmockedMsg) } } // Convenience extensions to common Swiftly and SwiftlyCore types to set the correct context @@ -556,8 +561,8 @@ public class MockToolchainDownloader: HTTPRequestExecutor { self.snapshotToolchains = snapshotToolchains } - public func getCurrentSwiftlyRelease() async throws -> Components.Schemas.SwiftlyRelease { - let release = Components.Schemas.SwiftlyRelease( + public func getCurrentSwiftlyRelease() async throws -> SwiftlyWebsiteAPI.Components.Schemas.SwiftlyRelease { + let release = SwiftlyWebsiteAPI.Components.Schemas.SwiftlyRelease( version: self.latestSwiftlyVersion.description, platforms: [ .init(platform: .init(.darwin), arm64: "https://download.swift.org/swiftly-darwin.pkg", x8664: "https://download.swift.org/swiftly-darwin.pkg"), @@ -597,7 +602,7 @@ public class MockToolchainDownloader: HTTPRequestExecutor { } return self.releaseToolchains.map { releaseToolchain in - Components.Schemas.Release( + SwiftlyWebsiteAPI.Components.Schemas.Release( name: String(describing: releaseToolchain), date: "", platforms: platformName != "Xcode" ? [.init( @@ -612,7 +617,7 @@ public class MockToolchainDownloader: HTTPRequestExecutor { } } - public func getSnapshotToolchains(branch: Components.Schemas.SourceBranch, platform _: Components.Schemas.PlatformIdentifier) async throws -> Components.Schemas.DevToolchains { + public func getSnapshotToolchains(branch: SwiftlyWebsiteAPI.Components.Schemas.SourceBranch, platform _: SwiftlyWebsiteAPI.Components.Schemas.PlatformIdentifier) async throws -> SwiftlyWebsiteAPI.Components.Schemas.DevToolchains { let currentPlatform = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil) let releasesForBranch = self.snapshotToolchains.filter { snapshotVersion in @@ -625,8 +630,8 @@ public class MockToolchainDownloader: HTTPRequestExecutor { } let devToolchainsForArch = releasesForBranch.map { branchSnapshot in - Components.Schemas.DevToolchainForArch( - name: Components.Schemas.DevToolchainKind?.none, + SwiftlyWebsiteAPI.Components.Schemas.DevToolchainForArch( + name: SwiftlyWebsiteAPI.Components.Schemas.DevToolchainKind?.none, date: "", dir: branch.value1 == .main || branch.value2 == "main" ? "swift-DEVELOPMENT-SNAPSHOT-\(branchSnapshot.date)" : @@ -638,35 +643,21 @@ public class MockToolchainDownloader: HTTPRequestExecutor { } if currentPlatform == PlatformDefinition.macOS { - return Components.Schemas.DevToolchains(universal: devToolchainsForArch) - } else if cpuArch == Components.Schemas.Architecture.x8664 { - return Components.Schemas.DevToolchains(x8664: devToolchainsForArch) - } else if cpuArch == Components.Schemas.Architecture.aarch64 { - return Components.Schemas.DevToolchains(aarch64: devToolchainsForArch) + return SwiftlyWebsiteAPI.Components.Schemas.DevToolchains(universal: devToolchainsForArch) + } else if cpuArch == SwiftlyWebsiteAPI.Components.Schemas.Architecture.x8664 { + return SwiftlyWebsiteAPI.Components.Schemas.DevToolchains(x8664: devToolchainsForArch) + } else if cpuArch == SwiftlyWebsiteAPI.Components.Schemas.Architecture.aarch64 { + return SwiftlyWebsiteAPI.Components.Schemas.DevToolchains(aarch64: devToolchainsForArch) } else { - return Components.Schemas.DevToolchains() + return SwiftlyWebsiteAPI.Components.Schemas.DevToolchains() } } - public func execute(_ request: HTTPClientRequest, timeout _: TimeAmount) async throws -> HTTPClientResponse { - guard let url = URL(string: request.url) else { - throw SwiftlyTestError(message: "invalid request URL: \(request.url)") - } - - if url.host == "download.swift.org" && url.path.hasPrefix("/swiftly-") { - // Download a swiftly bundle - return try self.makeSwiftlyDownloadResponse(from: url) - } else if url.host == "download.swift.org" && (url.path.hasPrefix("/swift-") || url.path.hasPrefix("/development")) { - // Download a toolchain - return try self.makeToolchainDownloadResponse(from: url) - } else if url.host == "www.swift.org" && url.path == "/keys/all-keys.asc" { - return try self.makeGPGKeysResponse(from: url) - } else { - throw SwiftlyTestError(message: "unmocked URL: \(request)") - } + private func makeToolchainDownloadURL(_ toolchainFile: ToolchainFile, isSignature: Bool = false) throws -> URL { + URL(string: "https://download.swift.org/\(toolchainFile.category)/\(toolchainFile.platform)/\(toolchainFile.version)/\(toolchainFile.file)\(isSignature ? ".sig" : "")")! } - private func makeToolchainDownloadResponse(from url: URL) throws -> HTTPClientResponse { + private func makeToolchainDownloadResponse(from url: URL) throws -> OpenAPIRuntime.HTTPBody { let toolchain: ToolchainVersion if let match = try Self.releaseURLRegex.firstMatch(in: url.path) { var version = "\(match.output.1).\(match.output.2)." @@ -687,17 +678,30 @@ public class MockToolchainDownloader: HTTPRequestExecutor { } let mockedToolchain = try self.makeMockedToolchain(toolchain: toolchain, name: url.lastPathComponent) - return HTTPClientResponse(body: .bytes(ByteBuffer(data: mockedToolchain))) + return HTTPBody(mockedToolchain) + } + + public func getSwiftToolchainFile(_ toolchainFile: ToolchainFile) async throws -> OpenAPIRuntime.HTTPBody { + try self.makeToolchainDownloadResponse(from: self.makeToolchainDownloadURL(toolchainFile)) + } + + public func getSwiftToolchainFileSignature(_ toolchainFile: ToolchainFile) async throws + -> OpenAPIRuntime.HTTPBody + { + try self.makeToolchainDownloadResponse(from: self.makeToolchainDownloadURL(toolchainFile, isSignature: true)) + } + + public func getSwiftlyRelease(url: URL) async throws -> OpenAPIRuntime.HTTPBody { + try HTTPBody(Array(self.makeMockedSwiftly(from: url))) } - private func makeSwiftlyDownloadResponse(from url: URL) throws -> HTTPClientResponse { - let mockedSwiftly = try self.makeMockedSwiftly(from: url) - return HTTPClientResponse(body: .bytes(ByteBuffer(data: mockedSwiftly))) + public func getSwiftlyReleaseSignature(url: URL) async throws -> OpenAPIRuntime.HTTPBody { + try HTTPBody(Array(self.makeMockedSwiftly(from: url))) } - private func makeGPGKeysResponse(from _: URL) throws -> HTTPClientResponse { + public func getGpgKeys() async throws -> OpenAPIRuntime.HTTPBody { // Give GPG the test's private signature here as trusted - HTTPClientResponse(body: .bytes(ByteBuffer(data: Data(PackageResources.mock_signing_key_private_pgp)))) + HTTPBody(Array(Data(PackageResources.mock_signing_key_private_pgp))) } #if os(Linux)