Skip to content

Commit 820cb34

Browse files
committed
Convert file downloading to go through the OpenAPI abstraction
This is a little awkward due to the OpenAPI spec not supporting variable length path components in an endpoint declaration (that's coming in v4 of the spec), but it does the job to get all of our HTTP traffic flowing through the same abstraction layer.
1 parent 46fb64f commit 820cb34

File tree

7 files changed

+292
-76
lines changed

7 files changed

+292
-76
lines changed

Sources/LinuxPlatform/Linux.swift

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -264,11 +264,7 @@ public struct Linux: Platform {
264264
try? FileManager.default.removeItem(at: tmpFile)
265265
}
266266

267-
guard let url = URL(string: "https://www.swift.org/keys/all-keys.asc") else {
268-
throw SwiftlyError(message: "malformed URL to the swift gpg keys")
269-
}
270-
271-
try await ctx.httpClient.downloadFile(url: url, to: tmpFile)
267+
try await ctx.httpClient.getGpgKeys().download(to: tmpFile)
272268
if let mockedHomeDir = ctx.mockedHomeDir {
273269
try self.runProgram("gpg", "--import", tmpFile.path, quiet: true, env: ["GNUPGHOME": mockedHomeDir.appendingPathComponent(".gnupg").path])
274270
} else {
@@ -390,7 +386,7 @@ public struct Linux: Platform {
390386
FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())")
391387
}
392388

393-
public func verifySignature(_ ctx: SwiftlyCoreContext, archiveDownloadURL: URL, archive: URL, verbose: Bool) async throws {
389+
public func verifyToolchainSignature(_ ctx: SwiftlyCoreContext, toolchainFile: ToolchainFile, archive: URL, verbose: Bool) async throws {
394390
if verbose {
395391
ctx.print("Downloading toolchain signature...")
396392
}
@@ -401,10 +397,7 @@ public struct Linux: Platform {
401397
try? FileManager.default.removeItem(at: sigFile)
402398
}
403399

404-
try await ctx.httpClient.downloadFile(
405-
url: archiveDownloadURL.appendingPathExtension("sig"),
406-
to: sigFile
407-
)
400+
try await ctx.httpClient.getSwiftToolchainFileSignature(toolchainFile).download(to: sigFile)
408401

409402
ctx.print("Verifying toolchain signature...")
410403
do {
@@ -418,6 +411,31 @@ public struct Linux: Platform {
418411
}
419412
}
420413

414+
public func verifySwiftlySignature(_ ctx: SwiftlyCoreContext, archiveDownloadURL: URL, archive: URL, verbose: Bool) async throws {
415+
if verbose {
416+
ctx.print("Downloading swiftly signature...")
417+
}
418+
419+
let sigFile = self.getTempFilePath()
420+
let _ = FileManager.default.createFile(atPath: sigFile.path, contents: nil)
421+
defer {
422+
try? FileManager.default.removeItem(at: sigFile)
423+
}
424+
425+
try await ctx.httpClient.getSwiftlyReleaseSignature(url: archiveDownloadURL.appendingPathExtension("sig")).download(to: sigFile)
426+
427+
ctx.print("Verifying swiftly signature...")
428+
do {
429+
if let mockedHomeDir = ctx.mockedHomeDir {
430+
try self.runProgram("gpg", "--verify", sigFile.path, archive.path, quiet: false, env: ["GNUPGHOME": mockedHomeDir.appendingPathComponent(".gnupg").path])
431+
} else {
432+
try self.runProgram("gpg", "--verify", sigFile.path, archive.path, quiet: !verbose)
433+
}
434+
} catch {
435+
throw SwiftlyError(message: "Signature verification failed: \(error).")
436+
}
437+
}
438+
421439
private func manualSelectPlatform(_ ctx: SwiftlyCoreContext, _ platformPretty: String?) async -> PlatformDefinition {
422440
if let platformPretty {
423441
print("\(platformPretty) is not an officially supported platform, but the toolchains for another platform may still work on it.")

Sources/MacOSPlatform/MacOS.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,12 @@ public struct MacOS: Platform {
145145
FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID()).pkg")
146146
}
147147

148-
public func verifySignature(_: SwiftlyCoreContext, archiveDownloadURL _: URL, archive _: URL, verbose _: Bool) async throws {
148+
public func verifyToolchainSignature(_: SwiftlyCoreContext, toolchainFile _: ToolchainFile, archive _: URL, verbose _: Bool) async throws {
149+
// No signature verification is required on macOS since the pkg files have their own signing
150+
// mechanism and the swift.org downloadables are trusted by stock macOS installations.
151+
}
152+
153+
public func verifySwiftlySignature(_: SwiftlyCoreContext, archiveDownloadURL _: URL, archive _: URL, verbose _: Bool) async throws {
149154
// No signature verification is required on macOS since the pkg files have their own signing
150155
// mechanism and the swift.org downloadables are trusted by stock macOS installations.
151156
}

Sources/Swiftly/Install.swift

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,6 @@ struct Install: SwiftlyCommand {
174174
try? FileManager.default.removeItem(at: tmpFile)
175175
}
176176

177-
var url = "https://download.swift.org/"
178-
179177
var platformString = config.platform.name
180178
var platformFullString = config.platform.nameFull
181179

@@ -184,6 +182,7 @@ struct Install: SwiftlyCommand {
184182
platformFullString += "-aarch64"
185183
#endif
186184

185+
let category: String
187186
switch version {
188187
case let .stable(stableVersion):
189188
// Building URL path that looks like:
@@ -193,34 +192,27 @@ struct Install: SwiftlyCommand {
193192
versionString += ".\(stableVersion.patch)"
194193
}
195194

196-
url += "swift-\(versionString)-release/"
195+
category = "swift-\(versionString)-release"
197196
case let .snapshot(release):
198197
switch release.branch {
199198
case let .release(major, minor):
200-
url += "swift-\(major).\(minor)-branch/"
199+
category = "swift-\(major).\(minor)-branch"
201200
case .main:
202-
url += "development/"
201+
category = "development"
203202
}
204203
}
205204

206-
url += "\(platformString)/"
207-
url += "\(version.identifier)/"
208-
url += "\(version.identifier)-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)"
209-
210-
guard let url = URL(string: url) else {
211-
throw SwiftlyError(message: "Invalid toolchain URL: \(url)")
212-
}
213-
214205
let animation = PercentProgressAnimation(
215206
stream: stdoutStream,
216207
header: "Downloading \(version)"
217208
)
218209

219210
var lastUpdate = Date()
220211

212+
let toolchainFile = ToolchainFile(category: category, platform: platformString, version: version.identifier, file: "\(version.identifier)-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)")
213+
221214
do {
222-
try await ctx.httpClient.downloadFile(
223-
url: url,
215+
try await ctx.httpClient.getSwiftToolchainFile(toolchainFile).download(
224216
to: tmpFile,
225217
reportProgress: { progress in
226218
let now = Date()
@@ -241,7 +233,7 @@ struct Install: SwiftlyCommand {
241233
)
242234
}
243235
)
244-
} catch let notFound as SwiftlyHTTPClient.DownloadNotFoundError {
236+
} catch let notFound as DownloadNotFoundError {
245237
throw SwiftlyError(message: "\(version) does not exist at URL \(notFound.url), exiting")
246238
} catch {
247239
animation.complete(success: false)
@@ -250,9 +242,9 @@ struct Install: SwiftlyCommand {
250242
animation.complete(success: true)
251243

252244
if verifySignature {
253-
try await Swiftly.currentPlatform.verifySignature(
245+
try await Swiftly.currentPlatform.verifyToolchainSignature(
254246
ctx,
255-
archiveDownloadURL: url,
247+
toolchainFile: toolchainFile,
256248
archive: tmpFile,
257249
verbose: verbose
258250
)

Sources/Swiftly/SelfUpdate.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,7 @@ struct SelfUpdate: SwiftlyCommand {
7979
header: "Downloading swiftly \(version)"
8080
)
8181
do {
82-
try await ctx.httpClient.downloadFile(
83-
url: downloadURL,
82+
try await ctx.httpClient.getSwiftlyRelease(url: downloadURL).download(
8483
to: tmpFile,
8584
reportProgress: { progress in
8685
let downloadedMiB = Double(progress.receivedBytes) / (1024.0 * 1024.0)
@@ -99,7 +98,7 @@ struct SelfUpdate: SwiftlyCommand {
9998
}
10099
animation.complete(success: true)
101100

102-
try await Swiftly.currentPlatform.verifySignature(ctx, archiveDownloadURL: downloadURL, archive: tmpFile, verbose: verbose)
101+
try await Swiftly.currentPlatform.verifySwiftlySignature(ctx, archiveDownloadURL: downloadURL, archive: tmpFile, verbose: verbose)
103102
try Swiftly.currentPlatform.extractSwiftlyAndInstall(ctx, from: tmpFile)
104103

105104
ctx.print("Successfully updated swiftly to \(version) (was \(SwiftlyCore.version))")

Sources/SwiftlyCore/HTTPClient.swift

Lines changed: 103 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,29 @@ extension Components.Schemas.SwiftlyPlatformIdentifier {
5656
}
5757
}
5858

59+
public struct ToolchainFile: Sendable {
60+
public var category: String
61+
public var platform: String
62+
public var version: String
63+
public var file: String
64+
65+
public init(category: String, platform: String, version: String, file: String) {
66+
self.category = category
67+
self.platform = platform
68+
self.version = version
69+
self.file = file
70+
}
71+
}
72+
5973
public protocol HTTPRequestExecutor {
60-
func execute(_ request: HTTPClientRequest, timeout: TimeAmount) async throws -> HTTPClientResponse
6174
func getCurrentSwiftlyRelease() async throws -> Components.Schemas.SwiftlyRelease
6275
func getReleaseToolchains() async throws -> [Components.Schemas.Release]
6376
func getSnapshotToolchains(branch: Components.Schemas.SourceBranch, platform: Components.Schemas.PlatformIdentifier) async throws -> Components.Schemas.DevToolchains
77+
func getGpgKeys() async throws -> OpenAPIRuntime.HTTPBody
78+
func getSwiftlyRelease(url: URL) async throws -> OpenAPIRuntime.HTTPBody
79+
func getSwiftlyReleaseSignature(url: URL) async throws -> OpenAPIRuntime.HTTPBody
80+
func getSwiftToolchainFile(_ toolchainFile: ToolchainFile) async throws -> OpenAPIRuntime.HTTPBody
81+
func getSwiftToolchainFileSignature(_ toolchainFile: ToolchainFile) async throws -> OpenAPIRuntime.HTTPBody
6482
}
6583

6684
struct SwiftlyUserAgentMiddleware: ClientMiddleware {
@@ -119,19 +137,15 @@ public class HTTPRequestExecutorImpl: HTTPRequestExecutor {
119137
}
120138
}
121139

122-
public func execute(_ request: HTTPClientRequest, timeout: TimeAmount) async throws -> HTTPClientResponse {
123-
try await self.httpClient.execute(request, timeout: timeout)
124-
}
125-
126-
private func client() throws -> Client {
140+
private func client(baseURL: URL? = nil) throws -> Client {
127141
let swiftlyUserAgent = SwiftlyUserAgentMiddleware()
128142
let transport: ClientTransport
129143

130144
let config = AsyncHTTPClientTransport.Configuration(client: self.httpClient, timeout: .seconds(30))
131145
transport = AsyncHTTPClientTransport(configuration: config)
132146

133147
return Client(
134-
serverURL: try Servers.Server1.url(),
148+
serverURL: try baseURL ?? Servers.Server1.url(),
135149
transport: transport,
136150
middlewares: [swiftlyUserAgent]
137151
)
@@ -151,12 +165,47 @@ public class HTTPRequestExecutorImpl: HTTPRequestExecutor {
151165
let response = try await self.client().listDevToolchains(.init(path: .init(branch: branch, platform: platform)))
152166
return try response.ok.body.json
153167
}
154-
}
155168

156-
private func makeRequest(url: String) -> HTTPClientRequest {
157-
var request = HTTPClientRequest(url: url)
158-
request.headers.add(name: "User-Agent", value: "swiftly/\(SwiftlyCore.version)")
159-
return request
169+
public func getGpgKeys() async throws -> OpenAPIRuntime.HTTPBody {
170+
let response = try await client(baseURL: URL(string: "https://www.swift.org/")!).swiftGpgKeys(.init())
171+
172+
return try response.ok.body.binary
173+
}
174+
175+
public func getSwiftlyRelease(url: URL) async throws -> OpenAPIRuntime.HTTPBody {
176+
guard url.host(percentEncoded: false) == "download.swift.org", let match = try #/\/swiftly\/(?<platform>.+)\/(?<file>.+)/#.wholeMatch(in: url.path(percentEncoded: false)) else {
177+
throw SwiftlyError(message: "Unexpected URL format: \(url.path(percentEncoded: false))")
178+
}
179+
180+
let response = try await client(baseURL: URL(string: "https://download.swift.org/")!).downloadSwiftlyRelease(.init(path: .init(platform: String(match.output.platform), file: String(match.output.file))))
181+
182+
return try response.ok.body.binary
183+
}
184+
185+
public func getSwiftlyReleaseSignature(url: URL) async throws -> OpenAPIRuntime.HTTPBody {
186+
guard url.host(percentEncoded: false) == "download.swift.org", let match = try #/\/swiftly\/(?<platform>.+)\/(?<file>.+).sig/#.wholeMatch(in: url.path(percentEncoded: false)) else {
187+
throw SwiftlyError(message: "Unexpected URL format: \(url.path(percentEncoded: false))")
188+
}
189+
190+
let response = try await client(baseURL: URL(string: "https://download.swift.org/")!).getSwiftlyReleaseSignature(.init(path: .init(platform: String(match.output.platform), file: String(match.output.file))))
191+
192+
return try response.ok.body.binary
193+
}
194+
195+
public func getSwiftToolchainFile(_ toolchainFile: ToolchainFile) async throws -> OpenAPIRuntime.HTTPBody {
196+
let response = try await client(baseURL: URL(string: "https://download.swift.org/")!).downloadSwiftToolchain(.init(path: .init(category: String(toolchainFile.category), platform: String(toolchainFile.platform), version: String(toolchainFile.version), file: String(toolchainFile.file))))
197+
if response == .notFound {
198+
throw DownloadNotFoundError(url: URL(string: "https://download.swift.org/\(toolchainFile.category)/\(toolchainFile.platform)/\(toolchainFile.version)/\(toolchainFile.file)")!)
199+
}
200+
201+
return try response.ok.body.binary
202+
}
203+
204+
public func getSwiftToolchainFileSignature(_ toolchainFile: ToolchainFile) async throws -> OpenAPIRuntime.HTTPBody {
205+
let response = try await client(baseURL: URL(string: "https://download.swift.org/")!).getSwiftToolchainSignature(.init(path: .init(category: String(toolchainFile.category), platform: String(toolchainFile.platform), version: String(toolchainFile.version), file: String(toolchainFile.file))))
206+
207+
return try response.ok.body.binary
208+
}
160209
}
161210

162211
extension Components.Schemas.Release {
@@ -278,6 +327,19 @@ extension Components.Schemas.DevToolchainForArch {
278327
}
279328
}
280329

330+
public struct DownloadProgress {
331+
public let receivedBytes: Int
332+
public let totalBytes: Int?
333+
}
334+
335+
public struct DownloadNotFoundError: LocalizedError {
336+
public let url: URL
337+
338+
public init(url: URL) {
339+
self.url = url
340+
}
341+
}
342+
281343
/// HTTPClient wrapper used for interfacing with various REST APIs and downloading things.
282344
public struct SwiftlyHTTPClient {
283345
public let httpRequestExecutor: HTTPRequestExecutor
@@ -286,10 +348,6 @@ public struct SwiftlyHTTPClient {
286348
self.httpRequestExecutor = httpRequestExecutor
287349
}
288350

289-
public struct JSONNotFoundError: LocalizedError {
290-
public var url: String
291-
}
292-
293351
/// Return the current Swiftly release using the swift.org API.
294352
public func getCurrentSwiftlyRelease() async throws -> Components.Schemas.SwiftlyRelease {
295353
try await self.httpRequestExecutor.getCurrentSwiftlyRelease()
@@ -417,51 +475,53 @@ public struct SwiftlyHTTPClient {
417475
}
418476
}
419477

420-
public struct DownloadProgress {
421-
public let receivedBytes: Int
422-
public let totalBytes: Int?
478+
public func getGpgKeys() async throws -> OpenAPIRuntime.HTTPBody {
479+
try await httpRequestExecutor.getGpgKeys()
423480
}
424481

425-
public struct DownloadNotFoundError: LocalizedError {
426-
public let url: String
482+
public func getSwiftlyRelease(url: URL) async throws -> OpenAPIRuntime.HTTPBody {
483+
try await httpRequestExecutor.getSwiftlyRelease(url: url)
427484
}
428485

429-
public func downloadFile(
430-
url: URL,
431-
to destination: URL,
432-
reportProgress: ((DownloadProgress) -> Void)? = nil
433-
) async throws {
486+
public func getSwiftlyReleaseSignature(url: URL) async throws -> OpenAPIRuntime.HTTPBody {
487+
try await httpRequestExecutor.getSwiftlyReleaseSignature(url: url)
488+
}
489+
490+
public func getSwiftToolchainFile(_ toolchainFile: ToolchainFile) async throws -> OpenAPIRuntime.HTTPBody {
491+
try await httpRequestExecutor.getSwiftToolchainFile(toolchainFile)
492+
}
493+
494+
public func getSwiftToolchainFileSignature(_ toolchainFile: ToolchainFile) async throws -> OpenAPIRuntime.HTTPBody {
495+
try await httpRequestExecutor.getSwiftToolchainFileSignature(toolchainFile)
496+
}
497+
}
498+
499+
extension OpenAPIRuntime.HTTPBody {
500+
public func download(to destination: URL, reportProgress: ((DownloadProgress) -> Void)? = nil) async throws {
434501
let fileHandle = try FileHandle(forWritingTo: destination)
435502
defer {
436503
try? fileHandle.close()
437504
}
438505

439-
let request = makeRequest(url: url.absoluteString)
440-
let response = try await self.httpRequestExecutor.execute(request, timeout: .seconds(60))
441-
442-
switch response.status {
443-
case .ok:
444-
break
445-
case .notFound:
446-
throw SwiftlyHTTPClient.DownloadNotFoundError(url: url.path)
447-
default:
448-
throw SwiftlyError(message: "Received \(response.status) when trying to download \(url)")
506+
let expectedBytes: Int?
507+
switch self.length {
508+
case .unknown:
509+
expectedBytes = nil
510+
case let .known(count):
511+
expectedBytes = Int(count)
449512
}
450513

451-
// if defined, the content-length headers announces the size of the body
452-
let expectedBytes = response.headers.first(name: "content-length").flatMap(Int.init)
453-
454514
var lastUpdate = Date()
455515
var receivedBytes = 0
456-
for try await buffer in response.body {
457-
receivedBytes += buffer.readableBytes
516+
for try await buffer in self {
517+
receivedBytes += buffer.count
458518

459-
try fileHandle.write(contentsOf: buffer.readableBytesView)
519+
try fileHandle.write(contentsOf: buffer)
460520

461521
let now = Date()
462522
if let reportProgress, lastUpdate.distance(to: now) > 0.25 || receivedBytes == expectedBytes {
463523
lastUpdate = now
464-
reportProgress(SwiftlyHTTPClient.DownloadProgress(
524+
reportProgress(DownloadProgress(
465525
receivedBytes: receivedBytes,
466526
totalBytes: expectedBytes
467527
))

0 commit comments

Comments
 (0)