diff --git a/Sources/Basics/Archiver/Archiver.swift b/Sources/Basics/Archiver/Archiver.swift index e9d416ef21d..1af017e3837 100644 --- a/Sources/Basics/Archiver/Archiver.swift +++ b/Sources/Basics/Archiver/Archiver.swift @@ -11,6 +11,7 @@ //===----------------------------------------------------------------------===// import _Concurrency +import struct Foundation.URL /// The `Archiver` protocol abstracts away the different operations surrounding archives. public protocol Archiver: Sendable { @@ -95,4 +96,8 @@ extension Archiver { self.validate(path: path, completion: { continuation.resume(with: $0) }) } } + + package func isFileSupported(_ lastPathComponent: String) -> Bool { + self.supportedExtensions.contains(where: { lastPathComponent.hasSuffix($0) }) + } } diff --git a/Sources/Commands/PackageCommands/ComputeChecksum.swift b/Sources/Commands/PackageCommands/ComputeChecksum.swift index 9a0b92e78e0..7ea69dc2335 100644 --- a/Sources/Commands/PackageCommands/ComputeChecksum.swift +++ b/Sources/Commands/PackageCommands/ComputeChecksum.swift @@ -28,17 +28,10 @@ struct ComputeChecksum: SwiftCommand { var path: AbsolutePath func run(_ swiftCommandState: SwiftCommandState) throws { - let binaryArtifactsManager = try Workspace.BinaryArtifactsManager( - fileSystem: swiftCommandState.fileSystem, - authorizationProvider: swiftCommandState.getAuthorizationProvider(), - hostToolchain: swiftCommandState.getHostToolchain(), - checksumAlgorithm: SHA256(), - cachePath: .none, - customHTTPClient: .none, - customArchiver: .none, - delegate: .none + let checksum = try Workspace.BinaryArtifactsManager.checksum( + forBinaryArtifactAt: self.path, + fileSystem: swiftCommandState.fileSystem ) - let checksum = try binaryArtifactsManager.checksum(forBinaryArtifactAt: path) print(checksum) } } diff --git a/Sources/PackageModel/SwiftSDKs/SwiftSDK.swift b/Sources/PackageModel/SwiftSDKs/SwiftSDK.swift index ec3b0e47a81..5f48f3dd995 100644 --- a/Sources/PackageModel/SwiftSDKs/SwiftSDK.swift +++ b/Sources/PackageModel/SwiftSDKs/SwiftSDK.swift @@ -26,6 +26,12 @@ public enum SwiftSDKError: Swift.Error { /// A passed argument is neither a valid file system path nor a URL. case invalidPathOrURL(String) + /// Bundles installed from remote URLs require a checksum to be provided. + case checksumNotProvided(URL) + + /// Computed archive checksum does not match the provided checksum. + case checksumInvalid(computed: String, provided: String) + /// Couldn't find the Xcode installation. case invalidInstallation(String) @@ -65,6 +71,17 @@ public enum SwiftSDKError: Swift.Error { extension SwiftSDKError: CustomStringConvertible { public var description: String { switch self { + case let .checksumInvalid(computed, provided): + return """ + Computed archive checksum `\(computed)` does not match the provided checksum `\(provided)`. + """ + + case .checksumNotProvided(let url): + return """ + Bundles installed from remote URLs (`\(url)`) require their checksum passed via `--checksum` option. + The distributor of the bundle must compute it with the `swift package compute-checksum` \ + command and provide it with their Swift SDK installation instructions. + """ case .invalidBundleArchive(let archivePath): return """ Swift SDK archive at `\(archivePath)` does not contain at least one directory with the \ diff --git a/Sources/PackageModel/SwiftSDKs/SwiftSDKBundleStore.swift b/Sources/PackageModel/SwiftSDKs/SwiftSDKBundleStore.swift index 366d2ec2fab..aa08f740571 100644 --- a/Sources/PackageModel/SwiftSDKs/SwiftSDKBundleStore.swift +++ b/Sources/PackageModel/SwiftSDKs/SwiftSDKBundleStore.swift @@ -22,6 +22,8 @@ public final class SwiftSDKBundleStore { public enum Output: Equatable, CustomStringConvertible { case downloadStarted(URL) case downloadFinishedSuccessfully(URL) + case verifyingChecksum + case checksumValid case unpackingArchive(bundlePathOrURL: String) case installationSuccessful(bundlePathOrURL: String, bundleName: String) @@ -31,6 +33,10 @@ public final class SwiftSDKBundleStore { return "Downloading a Swift SDK bundle archive from `\(url)`..." case let .downloadFinishedSuccessfully(url): return "Swift SDK bundle archive successfully downloaded from `\(url)`." + case .verifyingChecksum: + return "Verifying if checksum of the downloaded archive is valid..." + case .checksumValid: + return "Downloaded archive has a valid checksum." case let .installationSuccessful(bundlePathOrURL, bundleName): return "Swift SDK bundle at `\(bundlePathOrURL)` successfully installed as \(bundleName)." case let .unpackingArchive(bundlePathOrURL): @@ -145,8 +151,10 @@ public final class SwiftSDKBundleStore { /// - archiver: Archiver instance to use for extracting bundle archives. public func install( bundlePathOrURL: String, + checksum: String? = nil, _ archiver: any Archiver, - _ httpClient: HTTPClient = .init() + _ httpClient: HTTPClient = .init(), + hasher: ((_ archivePath: AbsolutePath) throws -> String)? = nil ) async throws { let bundleName = try await withTemporaryDirectory(fileSystem: self.fileSystem, removeTreeOnDeinit: true) { temporaryDirectory in let bundlePath: AbsolutePath @@ -156,9 +164,13 @@ public final class SwiftSDKBundleStore { let scheme = bundleURL.scheme, scheme == "http" || scheme == "https" { + guard let checksum, let hasher else { + throw SwiftSDKError.checksumNotProvided(bundleURL) + } + let bundleName: String let fileNameComponent = bundleURL.lastPathComponent - if archiver.supportedExtensions.contains(where: { fileNameComponent.hasSuffix($0) }) { + if archiver.isFileSupported(fileNameComponent) { bundleName = fileNameComponent } else { // Assume that the bundle is a tarball if it doesn't have a recognized extension. @@ -193,9 +205,16 @@ public final class SwiftSDKBundleStore { ) self.downloadProgressAnimation?.complete(success: true) - bundlePath = downloadedBundlePath - self.outputHandler(.downloadFinishedSuccessfully(bundleURL)) + + self.outputHandler(.verifyingChecksum) + let computedChecksum = try hasher(downloadedBundlePath) + guard computedChecksum == checksum else { + throw SwiftSDKError.checksumInvalid(computed: computedChecksum, provided: checksum) + } + self.outputHandler(.checksumValid) + + bundlePath = downloadedBundlePath } else if let cwd: AbsolutePath = self.fileSystem.currentWorkingDirectory, let originalBundlePath = try? AbsolutePath(validating: bundlePathOrURL, relativeTo: cwd) diff --git a/Sources/SwiftSDKCommand/Configuration/DeprecatedSwiftSDKConfigurationCommand.swift b/Sources/SwiftSDKCommand/Configuration/DeprecatedSwiftSDKConfigurationCommand.swift index cdbebc5fce9..696a25983b6 100644 --- a/Sources/SwiftSDKCommand/Configuration/DeprecatedSwiftSDKConfigurationCommand.swift +++ b/Sources/SwiftSDKCommand/Configuration/DeprecatedSwiftSDKConfigurationCommand.swift @@ -14,8 +14,8 @@ import ArgumentParser import Basics import PackageModel -public struct DeprecatedSwiftSDKConfigurationCommand: ParsableCommand { - public static let configuration = CommandConfiguration( +package struct DeprecatedSwiftSDKConfigurationCommand: ParsableCommand { + package static let configuration = CommandConfiguration( commandName: "configuration", abstract: """ Deprecated: use `swift sdk configure` instead. @@ -29,5 +29,5 @@ public struct DeprecatedSwiftSDKConfigurationCommand: ParsableCommand { ] ) - public init() {} + package init() {} } diff --git a/Sources/SwiftSDKCommand/InstallSwiftSDK.swift b/Sources/SwiftSDKCommand/InstallSwiftSDK.swift index 96674b5ae32..6b56a23446d 100644 --- a/Sources/SwiftSDKCommand/InstallSwiftSDK.swift +++ b/Sources/SwiftSDKCommand/InstallSwiftSDK.swift @@ -17,10 +17,11 @@ import CoreCommands import Foundation import PackageModel +import class Workspace.Workspace import var TSCBasic.stdoutStream -public struct InstallSwiftSDK: SwiftSDKSubcommand { - public static let configuration = CommandConfiguration( +struct InstallSwiftSDK: SwiftSDKSubcommand { + static let configuration = CommandConfiguration( commandName: "install", abstract: """ Installs a given Swift SDK bundle to a location discoverable by SwiftPM. If the artifact bundle \ @@ -34,8 +35,8 @@ public struct InstallSwiftSDK: SwiftSDKSubcommand { @Argument(help: "A local filesystem path or a URL of a Swift SDK bundle to install.") var bundlePathOrURL: String - - public init() {} + @Option(help: "The checksum of the bundle generated with `swift package compute-checksum`.") + var checksum: String? = nil func run( hostTriple: Triple, @@ -54,10 +55,18 @@ public struct InstallSwiftSDK: SwiftSDKSubcommand { .percent(stream: stdoutStream, verbose: false, header: "Downloading") .throttled(interval: .milliseconds(300)) ) + try await store.install( bundlePathOrURL: bundlePathOrURL, + checksum: self.checksum, UniversalArchiver(self.fileSystem, cancellator), - HTTPClient() + HTTPClient(), + hasher: { + try Workspace.BinaryArtifactsManager.checksum( + forBinaryArtifactAt: $0, + fileSystem: self.fileSystem + ) + } ) } } diff --git a/Sources/SwiftSDKCommand/ListSwiftSDKs.swift b/Sources/SwiftSDKCommand/ListSwiftSDKs.swift index 23e221788fb..062b43b9e75 100644 --- a/Sources/SwiftSDKCommand/ListSwiftSDKs.swift +++ b/Sources/SwiftSDKCommand/ListSwiftSDKs.swift @@ -16,8 +16,8 @@ import CoreCommands import PackageModel import SPMBuildCore -public struct ListSwiftSDKs: SwiftSDKSubcommand { - public static let configuration = CommandConfiguration( +package struct ListSwiftSDKs: SwiftSDKSubcommand { + package static let configuration = CommandConfiguration( commandName: "list", abstract: """ @@ -28,7 +28,7 @@ public struct ListSwiftSDKs: SwiftSDKSubcommand { @OptionGroup() var locations: LocationOptions - public init() {} + package init() {} func run( hostTriple: Triple, diff --git a/Sources/SwiftSDKCommand/RemoveSwiftSDK.swift b/Sources/SwiftSDKCommand/RemoveSwiftSDK.swift index 2c0a735c257..78bc3248a0d 100644 --- a/Sources/SwiftSDKCommand/RemoveSwiftSDK.swift +++ b/Sources/SwiftSDKCommand/RemoveSwiftSDK.swift @@ -15,8 +15,8 @@ import Basics import CoreCommands import PackageModel -public struct RemoveSwiftSDK: SwiftSDKSubcommand { - public static let configuration = CommandConfiguration( +package struct RemoveSwiftSDK: SwiftSDKSubcommand { + package static let configuration = CommandConfiguration( commandName: "remove", abstract: """ Removes a previously installed Swift SDK bundle from the filesystem. diff --git a/Sources/SwiftSDKCommand/SwiftSDKCommand.swift b/Sources/SwiftSDKCommand/SwiftSDKCommand.swift index 8fa5a863bd7..e167cc548ac 100644 --- a/Sources/SwiftSDKCommand/SwiftSDKCommand.swift +++ b/Sources/SwiftSDKCommand/SwiftSDKCommand.swift @@ -12,9 +12,9 @@ import ArgumentParser import Basics - -public struct SwiftSDKCommand: AsyncParsableCommand { - public static let configuration = CommandConfiguration( + +package struct SwiftSDKCommand: AsyncParsableCommand { + package static let configuration = CommandConfiguration( commandName: "sdk", _superCommandName: "swift", abstract: "Perform operations on Swift SDKs.", @@ -29,5 +29,5 @@ public struct SwiftSDKCommand: AsyncParsableCommand { helpNames: [.short, .long, .customLong("help", withSingleDash: true)] ) - public init() {} + package init() {} } diff --git a/Sources/Workspace/Workspace+BinaryArtifacts.swift b/Sources/Workspace/Workspace+BinaryArtifacts.swift index d487cbfea49..13e51ac457e 100644 --- a/Sources/Workspace/Workspace+BinaryArtifacts.swift +++ b/Sources/Workspace/Workspace+BinaryArtifacts.swift @@ -18,7 +18,7 @@ import SPMBuildCore import struct TSCBasic.ByteString import protocol TSCBasic.HashAlgorithm - +import struct TSCBasic.SHA256 import enum TSCUtility.Diagnostics extension Workspace { @@ -537,20 +537,35 @@ extension Workspace { return result.get() } - public func checksum(forBinaryArtifactAt path: AbsolutePath) throws -> String { + package static func checksum( + forBinaryArtifactAt path: AbsolutePath, + hashAlgorithm: HashAlgorithm = SHA256(), + archiver: (any Archiver)? = nil, + fileSystem: any FileSystem + ) throws -> String { + let archiver = archiver ?? UniversalArchiver(fileSystem) // Validate the path has a supported extension. - guard let pathExtension = path.extension, self.archiver.supportedExtensions.contains(pathExtension) else { - let supportedExtensionList = self.archiver.supportedExtensions.joined(separator: ", ") + guard let lastPathComponent = path.components.last, archiver.isFileSupported(lastPathComponent) else { + let supportedExtensionList = archiver.supportedExtensions.joined(separator: ", ") throw StringError("unexpected file type; supported extensions are: \(supportedExtensionList)") } // Ensure that the path with the accepted extension is a file. - guard self.fileSystem.isFile(path) else { + guard fileSystem.isFile(path) else { throw StringError("file not found at path: \(path.pathString)") } - let contents = try self.fileSystem.readFileContents(path) - return self.checksumAlgorithm.hash(contents).hexadecimalRepresentation + let contents = try fileSystem.readFileContents(path) + return hashAlgorithm.hash(contents).hexadecimalRepresentation + } + + public func checksum(forBinaryArtifactAt path: AbsolutePath) throws -> String { + try Self.checksum( + forBinaryArtifactAt: path, + hashAlgorithm: self.checksumAlgorithm, + archiver: self.archiver, + fileSystem: self.fileSystem + ) } public func cancel(deadline: DispatchTime) throws { diff --git a/Tests/PackageModelTests/SwiftSDKBundleTests.swift b/Tests/PackageModelTests/SwiftSDKBundleTests.swift index 58b9e96e462..21c391e0fa8 100644 --- a/Tests/PackageModelTests/SwiftSDKBundleTests.swift +++ b/Tests/PackageModelTests/SwiftSDKBundleTests.swift @@ -19,6 +19,7 @@ import XCTest import struct TSCBasic.ByteString import protocol TSCBasic.FileSystem import class TSCBasic.InMemoryFileSystem +import class Workspace.Workspace private let testArtifactID = "test-artifact" @@ -146,13 +147,13 @@ final class SwiftSDKBundleTests: XCTestCase { let cancellator = Cancellator(observabilityScope: observabilityScope) let archiver = UniversalArchiver(localFileSystem, cancellator) - let fixtureAndURLs: [(url: String, fixture: String)] = [ - ("https://localhost/archive?test=foo", "test-sdk.artifactbundle.tar.gz"), - ("https://localhost/archive.tar.gz", "test-sdk.artifactbundle.tar.gz"), - ("https://localhost/archive.zip", "test-sdk.artifactbundle.zip"), + let fixtureAndURLs: [(url: String, fixture: String, checksum: String)] = [ + ("https://localhost/archive?test=foo", "test-sdk.artifactbundle.tar.gz", "724b5abf125287517dbc5be9add055d4755dfca679e163b249ea1045f5800c6e"), + ("https://localhost/archive.tar.gz", "test-sdk.artifactbundle.tar.gz", "724b5abf125287517dbc5be9add055d4755dfca679e163b249ea1045f5800c6e"), + ("https://localhost/archive.zip", "test-sdk.artifactbundle.zip", "74f6df5aa91c582c12e3a6670ff95973e463dd3266aabbc52ad13c3cd27e2793"), ] - for (bundleURLString, fixture) in fixtureAndURLs { + for (bundleURLString, fixture, checksum) in fixtureAndURLs { let httpClient = HTTPClient { request, _ in guard case let .download(_, downloadPath) = request.kind else { XCTFail("Unexpected HTTPClient.Request.Kind") @@ -173,12 +174,16 @@ final class SwiftSDKBundleTests: XCTestCase { output.append($0) } ) - try await store.install(bundlePathOrURL: bundleURLString, archiver, httpClient) + try await store.install(bundlePathOrURL: bundleURLString, checksum: checksum, archiver, httpClient) { + try Workspace.BinaryArtifactsManager.checksum(forBinaryArtifactAt: $0, fileSystem: localFileSystem) + } let bundleURL = URL(string: bundleURLString)! XCTAssertEqual(output, [ .downloadStarted(bundleURL), .downloadFinishedSuccessfully(bundleURL), + .verifyingChecksum, + .checksumValid, .unpackingArchive(bundlePathOrURL: bundleURLString), .installationSuccessful( bundlePathOrURL: bundleURLString,