diff --git a/AppUpdater.swift b/AppUpdater.swift index 0b7f02d..d403136 100644 --- a/AppUpdater.swift +++ b/AppUpdater.swift @@ -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: @@ -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 ) } @@ -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? diff --git a/Attestation/TUF.swift b/Attestation/TUF.swift index d71c64b..44fec2e 100644 --- a/Attestation/TUF.swift +++ b/Attestation/TUF.swift @@ -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 } diff --git a/Tests/AppUpdaterTests/AppUpdaterTests.swift b/Tests/AppUpdaterTests/AppUpdaterTests.swift index 8c9551f..2b40a27 100644 --- a/Tests/AppUpdaterTests/AppUpdaterTests.swift +++ b/Tests/AppUpdaterTests/AppUpdaterTests.swift @@ -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.") } } @@ -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." @@ -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 @@ -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 = [] @@ -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) @@ -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] diff --git a/Tests/AppUpdaterTests/TUFTests.swift b/Tests/AppUpdaterTests/TUFTests.swift index 7986ddb..a7b5589 100644 --- a/Tests/AppUpdaterTests/TUFTests.swift +++ b/Tests/AppUpdaterTests/TUFTests.swift @@ -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(