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
53 changes: 47 additions & 6 deletions AppUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -372,12 +372,53 @@ public final class PreparedUpdate {
}
}

/// An unsuccessful HTTPS response. The response may contain sensitive URLs or headers;
/// use the localized error text for presentation rather than logging the raw response.
public struct AppUpdaterHTTPError: LocalizedError, Sendable {
public let response: HTTPURLResponse

public var errorDescription: String? {
let host = response.url?.host ?? "The update server"
if isRateLimited { return "\(host) is rate limiting update requests." }
return "\(host) returned an HTTP error."
}

public var failureReason: String? {
let status = response.statusCode
return "HTTP \(status) (\(HTTPURLResponse.localizedString(forStatusCode: status)))."
}

public var recoverySuggestion: String? {
if isRateLimited || (500..<600).contains(response.statusCode) {
return "Try again later."
}
return nil
}

private var isRateLimited: Bool {
response.statusCode == 429 || (
response.statusCode == 403 &&
response.value(forHTTPHeaderField: "X-RateLimit-Remaining") == "0"
)
}
}

public struct AppUpdaterNetworkError: LocalizedError, Equatable, Sendable {
public let host: String
public let code: URLError.Code
/// Original transport error, including its diagnostic userInfo. May contain sensitive URLs.
public let underlyingError: URLError?

init(host: String, code: URLError.Code, underlyingError: URLError? = nil) {
self.host = host
self.code = code
self.underlyingError = underlyingError
}

public var errorDescription: String? {
switch code {
case .timedOut:
"The connection to \(host) timed out."
case .cannotFindHost:
"Could not find \(host)."
case .networkConnectionLost:
Expand Down Expand Up @@ -551,15 +592,13 @@ enum NetworkTransfer {

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
}
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
code: urlError.code,
underlyingError: urlError
)
}

Expand All @@ -569,11 +608,13 @@ enum NetworkTransfer {
permitsCompression: Bool = true
) throws {
guard response.url?.scheme?.lowercased() == "https",
let response = response as? HTTPURLResponse,
200..<300 ~= response.statusCode
let response = response as? HTTPURLResponse
else {
throw AppUpdaterError.invalidHTTPResponse
}
guard 200..<300 ~= response.statusCode else {
throw AppUpdaterHTTPError(response: response)
}
if let allowedContentTypes {
guard let contentType = response.value(forHTTPHeaderField: "Content-Type")?
.split(separator: ";", maxSplits: 1).first?
Expand Down
1 change: 1 addition & 0 deletions Attestation/TUF.swift
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ actor TUFClient {

private static func isNetworkFailure(_ error: Error) -> Bool {
if error is CancellationError { return false }
if error is AppUpdaterHTTPError { return true }
if let error = error as? AppUpdaterNetworkError { return error.code != .cancelled }
if let error = error as? URLError { return error.code != .cancelled }
guard let error = error as? AppUpdaterError else { return false }
Expand Down
87 changes: 82 additions & 5 deletions Tests/AppUpdaterTests/AppUpdaterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,74 @@ final class AppUpdaterTests: XCTestCase {
)
XCTFail("fetch should throw")
} catch {
XCTAssertEqual(error as? AppUpdaterError, .invalidHTTPResponse)
let httpError = try XCTUnwrap(error as? AppUpdaterHTTPError)
XCTAssertEqual(httpError.response.statusCode, 500)
XCTAssertEqual(httpError.response.url?.host, "api.github.com")
XCTAssertEqual(httpError.failureReason, "HTTP 500 (internal server error).")
}
}

func testHTTPFailuresPreserveResponseWithoutLeakingSignedURL() async throws {
let url = URL(string: "https://example.com/update?token=secret")!
for (status, headers, rateLimited) in [
(403, ["X-RateLimit-Remaining": "0", "X-RateLimit-Reset": "1790341622"], true),
(403, [:], false),
(429, ["Retry-After": "60"], true),
(503, ["Retry-After": "120"], false),
] {
let session = URLSession.stubbed(
statusCode: status, body: "secret response body",
responseURL: url, headers: headers
)
do {
_ = try await NetworkTransfer.data(
for: URLRequest(url: url), with: session, maximumBytes: 100
)
XCTFail("transfer should throw")
} catch let error as AppUpdaterHTTPError {
XCTAssertEqual(error.response.statusCode, status)
XCTAssertEqual(error.response.url, url)
for (name, value) in headers {
XCTAssertEqual(error.response.value(forHTTPHeaderField: name), value)
}
XCTAssertEqual(error.localizedDescription.contains("rate limiting"), rateLimited)
XCTAssertEqual(error.recoverySuggestion, status == 403 && !rateLimited ? nil : "Try again later.")
let alertText = [error.localizedDescription, error.failureReason ?? "", error.recoverySuggestion ?? ""].joined()
XCTAssertFalse(alertText.contains("secret"))
XCTAssertFalse(alertText.contains("token="))
}
}
}

func testDownloadHTTPFailureDoesNotPromoteFile() async throws {
let root = try temporaryDirectory()
defer { try? FileManager.default.removeItem(at: root) }
let destination = root.appendingPathComponent("update.dmg")
do {
try await NetworkTransfer.download(
URL(string: "https://example.com/update.dmg")!,
with: .stubbed(statusCode: 503, body: "unavailable"),
to: destination, maximumBytes: 100, timeout: 10
)
XCTFail("download should throw")
} catch let error as AppUpdaterHTTPError {
XCTAssertEqual(error.response.statusCode, 503)
}
XCTAssertFalse(FileManager.default.fileExists(atPath: destination.path))
}

func testNetworkTimeoutPreservesUnderlyingError() async throws {
let original = URLError(.timedOut, userInfo: ["diagnostic": "retained"])
do {
_ = try await NetworkTransfer.data(
for: URLRequest(url: URL(string: "https://example.com/releases")!),
with: .stubbed(error: original), maximumBytes: 100
)
XCTFail("transfer should throw")
} catch let error as AppUpdaterNetworkError {
XCTAssertEqual(error.code, .timedOut)
XCTAssertEqual(error.underlyingError?.userInfo["diagnostic"] as? String, "retained")
XCTAssertEqual(error.localizedDescription, "The connection to example.com timed out.")
}
}

Expand Down Expand Up @@ -169,6 +236,11 @@ final class AppUpdaterTests: XCTestCase {
} catch let error as AppUpdaterNetworkError {
XCTAssertEqual(error.host, "release-assets.githubusercontent.com")
XCTAssertEqual(error.code, .cannotConnectToHost)
XCTAssertEqual(error.underlyingError?.code, .cannotConnectToHost)
XCTAssertEqual(
(error.underlyingError as NSError?)?.userInfo[NSURLErrorFailingURLErrorKey] as? URL,
failingURL
)
XCTAssertEqual(
error.localizedDescription,
"Could not connect to release-assets.githubusercontent.com."
Expand Down Expand Up @@ -1498,6 +1570,7 @@ private final class URLProtocolStub: URLProtocol, @unchecked Sendable {
private nonisolated(unsafe) static var error: Error?
private nonisolated(unsafe) static var recordedRequests: [URLRequest] = []
private nonisolated(unsafe) static var responseURL: URL?
private nonisolated(unsafe) static var headers: [String: String] = [:]

static var requests: [URLRequest] {
recordedRequests
Expand All @@ -1507,8 +1580,10 @@ private final class URLProtocolStub: URLProtocol, @unchecked Sendable {
statusCode: Int,
body: String,
responseURL: URL?,
error: Error? = nil
error: Error? = nil,
headers: [String: String] = [:]
) {
self.headers = headers
self.body = Data(body.utf8)
self.error = error
recordedRequests = []
Expand Down Expand Up @@ -1536,7 +1611,7 @@ private final class URLProtocolStub: URLProtocol, @unchecked Sendable {
url: Self.responseURL ?? request.url!,
statusCode: Self.statusCode,
httpVersion: nil,
headerFields: nil
headerFields: Self.headers
)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Self.body)
Expand All @@ -1562,12 +1637,14 @@ private extension URLSession {
static func stubbed(
statusCode: Int,
body: String,
responseURL: URL? = nil
responseURL: URL? = nil,
headers: [String: String] = [:]
) -> URLSession {
URLProtocolStub.configure(
statusCode: statusCode,
body: body,
responseURL: responseURL
responseURL: responseURL,
headers: headers
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [URLProtocolStub.self]
Expand Down
16 changes: 16 additions & 0 deletions Tests/AppUpdaterTests/TUFTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,22 @@ final class TUFTests: XCTestCase {
XCTAssertFalse(root.certificateAuthorities.isEmpty)
}

func testHTTPFailureUsesEmbeddedFallback() async throws {
let date = validDate
let client = TUFClient(
fetch: { url, _ in
throw AppUpdaterHTTPError(response: HTTPURLResponse(
url: url, statusCode: 503, httpVersion: nil, headerFields: nil
)!)
},
now: { date },
bootstrapRoot: try fixture("15.root.json"),
fallbackTarget: try fixture("trusted-root.json")
)
let root = try await client.trustedRoot()
XCTAssertFalse(root.certificateAuthorities.isEmpty)
}

func testMappedCancellationDoesNotUseEmbeddedFallback() async throws {
let date = validDate
let client = TUFClient(
Expand Down
Loading