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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 44 additions & 5 deletions AppUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,38 @@ public final class PreparedUpdate {
}
}

public struct AppUpdaterNetworkError: LocalizedError, Equatable, Sendable {
public let host: String
public let code: URLError.Code

public var errorDescription: String? {
switch code {
case .cannotFindHost:
"Could not find \(host)."
case .networkConnectionLost:
"The connection to \(host) was lost."
case .notConnectedToInternet:
"This Mac is not connected to the internet."
case .secureConnectionFailed,
.serverCertificateHasBadDate,
.serverCertificateUntrusted,
.serverCertificateHasUnknownRoot,
.serverCertificateNotYetValid:
"Could not establish a secure connection to \(host)."
default:
"Could not connect to \(host)."
}
}

public var failureReason: String? {
"Network error \(code.rawValue)."
}

public var recoverySuggestion: String? {
"Check your internet connection and try again."
}
}

public enum AppUpdaterError: LocalizedError, Equatable {
case bundleExecutableURL
case attestationVerificationFailed
Expand Down Expand Up @@ -481,7 +513,7 @@ enum NetworkTransfer {
if let error = delegate.error { throw error }
return data
} catch {
throw delegate.error ?? mapped(error)
throw delegate.error ?? mapped(error, fallbackURL: request.url)
}
}

Expand Down Expand Up @@ -513,15 +545,22 @@ enum NetworkTransfer {
ofItemAtPath: destination.path
)
} catch {
throw delegate.error ?? mapped(error)
throw delegate.error ?? mapped(error, fallbackURL: url)
}
}

private static func mapped(_ error: Error) -> Error {
if (error as? URLError)?.code == .timedOut {
private static func mapped(_ error: Error, fallbackURL: URL?) -> Error {
guard let urlError = error as? URLError else { return error }
if urlError.code == .timedOut {
return AppUpdaterError.operationTimedOut
}
return error
if urlError.code == .cancelled { return error }

let failingURL = (error as NSError).userInfo[NSURLErrorFailingURLErrorKey] as? URL
return AppUpdaterNetworkError(
host: failingURL?.host ?? fallbackURL?.host ?? "the update server",
code: urlError.code
)
}

private static func validate(
Expand Down
64 changes: 63 additions & 1 deletion Tests/AppUpdaterTests/AppUpdaterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,45 @@ final class AppUpdaterTests: XCTestCase {
}
}

func testNetworkTransferDescribesConnectionFailureWithoutLeakingURL() async throws {
let failingURL = URL(
string: "https://release-assets.githubusercontent.com/update.dmg?token=secret"
)!
let session = URLSession.stubbed(
error: URLError(
.cannotConnectToHost,
userInfo: [NSURLErrorFailingURLErrorKey: failingURL]
)
)
let request = URLRequest(
url: URL(string: "https://github.com/update.dmg")!
)

do {
_ = try await NetworkTransfer.data(
for: request,
with: session,
maximumBytes: 100
)
XCTFail("transfer should throw")
} catch let error as AppUpdaterNetworkError {
XCTAssertEqual(error.host, "release-assets.githubusercontent.com")
XCTAssertEqual(error.code, .cannotConnectToHost)
XCTAssertEqual(
error.localizedDescription,
"Could not connect to release-assets.githubusercontent.com."
)
XCTAssertEqual(error.failureReason, "Network error -1004.")
XCTAssertEqual(
error.recoverySuggestion,
"Check your internet connection and try again."
)
XCTAssertFalse(error.localizedDescription.contains("secret"))
} catch {
XCTFail("unexpected error: \(error)")
}
}

func testNetworkTransferRejectsHTTPSDowngradeRedirect() async throws {
let delegate = NetworkTransfer.TransferDelegate(maximumBytes: 100)
let session = URLSession.shared
Expand Down Expand Up @@ -1456,15 +1495,22 @@ private final class RequestRecorder: @unchecked Sendable {

private final class URLProtocolStub: URLProtocol, @unchecked Sendable {
private nonisolated(unsafe) static var body = Data()
private nonisolated(unsafe) static var error: Error?
private nonisolated(unsafe) static var recordedRequests: [URLRequest] = []
private nonisolated(unsafe) static var responseURL: URL?

static var requests: [URLRequest] {
recordedRequests
}

static func configure(statusCode: Int, body: String, responseURL: URL?) {
static func configure(
statusCode: Int,
body: String,
responseURL: URL?,
error: Error? = nil
) {
self.body = Data(body.utf8)
self.error = error
recordedRequests = []
self.statusCode = statusCode
self.responseURL = responseURL
Expand All @@ -1482,6 +1528,10 @@ private final class URLProtocolStub: URLProtocol, @unchecked Sendable {

override func startLoading() {
Self.recordedRequests.append(request)
if let error = Self.error {
client?.urlProtocol(self, didFailWithError: error)
return
}
let response = HTTPURLResponse(
url: Self.responseURL ?? request.url!,
statusCode: Self.statusCode,
Expand All @@ -1497,6 +1547,18 @@ private final class URLProtocolStub: URLProtocol, @unchecked Sendable {
}

private extension URLSession {
static func stubbed(error: Error) -> URLSession {
URLProtocolStub.configure(
statusCode: 0,
body: "",
responseURL: nil,
error: error
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [URLProtocolStub.self]
return URLSession(configuration: configuration)
}

static func stubbed(
statusCode: Int,
body: String,
Expand Down
Loading