diff --git a/Sources/ContainerizationOCI/Client/RegistryClient+Token.swift b/Sources/ContainerizationOCI/Client/RegistryClient+Token.swift index acd5ff1b..43061f9e 100644 --- a/Sources/ContainerizationOCI/Client/RegistryClient+Token.swift +++ b/Sources/ContainerizationOCI/Client/RegistryClient+Token.swift @@ -108,7 +108,7 @@ struct TokenResponse: Codable, Hashable { } } -struct AuthenticateChallenge { +struct AuthenticateChallenge: Equatable { let type: String let realm: String? let service: String? @@ -158,7 +158,7 @@ extension RegistryClient { } internal func createTokenRequest(parsing authenticateHeaders: [String]) throws -> TokenRequest { - let parsedHeaders = parseWWWAuthenticateHeaders(headers: authenticateHeaders) + let parsedHeaders = Self.parseWWWAuthenticateHeaders(headers: authenticateHeaders) let bearerChallenge = parsedHeaders.first { $0.type == "Bearer" } guard let bearerChallenge else { throw ContainerizationError(.invalidArgument, message: "Missing Bearer challenge in \(TokenRequest.authenticateHeaderName) header") @@ -174,11 +174,11 @@ extension RegistryClient { return tokenRequest } - internal func parseWWWAuthenticateHeaders(headers: [String]) -> [AuthenticateChallenge] { + internal static func parseWWWAuthenticateHeaders(headers: [String]) -> [AuthenticateChallenge] { var parsed: [String: [String: String]] = [:] for challenge in headers { let trimmedChallenge = challenge.trimmingCharacters(in: .whitespacesAndNewlines) - let parts = trimmedChallenge.split(separator: " ", maxSplits: 2) + let parts = trimmedChallenge.split(separator: " ", maxSplits: 1) guard parts.count == 2 else { continue } diff --git a/Sources/ContainerizationOCI/Client/RegistryClient.swift b/Sources/ContainerizationOCI/Client/RegistryClient.swift index 59dc436e..e9386468 100644 --- a/Sources/ContainerizationOCI/Client/RegistryClient.swift +++ b/Sources/ContainerizationOCI/Client/RegistryClient.swift @@ -179,7 +179,7 @@ public final class RegistryClient: ContentClient { // The server did not tell us how to authenticate our requests, // Or we do not support scheme the server is requesting for. // Throw the 401/403 to the caller, and let them decide how to proceed. - throw RegistryClient.Error.invalidStatus(url: path, _response.status) + throw RegistryClient.Error.invalidStatus(url: path, _response.status, reason: String(describing: error)) } if let ct = currentToken, ct.isValid(scope: tokenRequest.scope) { break @@ -245,19 +245,8 @@ public final class RegistryClient: ContentClient { components: URLComponents, headers: [(String, String)]? = nil ) async throws -> Data { - try await request(components: components, method: .GET, headers: headers) { response in - guard response.status == .ok else { - let url = components.url?.absoluteString ?? "unknown" - let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString - throw Error.invalidStatus(url: url, response.status, reason: reason) - } - - var body = try await response.body.collect(upTo: self.bufferSize) - guard let bytes = body.readBytes(length: body.readableBytes) else { - throw ContainerizationError(.internalError, message: "Cannot read bytes from HTTP response") - } - return Data(bytes) - } + let bytes: ByteBuffer = try await requestBuffer(components: components, headers: headers) + return Data(buffer: bytes) } internal func requestBuffer( diff --git a/Tests/ContainerizationOCITests/AuthChallengeTests.swift b/Tests/ContainerizationOCITests/AuthChallengeTests.swift new file mode 100644 index 00000000..edbf7d6f --- /dev/null +++ b/Tests/ContainerizationOCITests/AuthChallengeTests.swift @@ -0,0 +1,58 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@testable import ContainerizationOCI + +struct AuthChallengeTests { + internal struct TestCase: Sendable { + let input: String + let expected: AuthenticateChallenge + } + + private static let testCases: [TestCase] = [ + .init( + input: """ + Bearer realm="https://domain.io/token",service="domain.io",scope="repository:user/image:pull" + """, + expected: .init(type: "Bearer", realm: "https://domain.io/token", service: "domain.io", scope: "repository:user/image:pull", error: nil)), + .init( + input: """ + Bearer realm="https://foo-bar-registry.com/auth",service="Awesome Registry" + """, + expected: .init(type: "Bearer", realm: "https://foo-bar-registry.com/auth", service: "Awesome Registry", scope: nil, error: nil)), + .init( + input: """ + Bearer realm="users.example.com", scope="create delete" + """, + expected: .init(type: "Bearer", realm: "users.example.com", service: nil, scope: "create delete", error: nil)), + .init( + input: """ + Bearer realm="https://auth.server.io/token",service="registry.server.io" + """, + expected: .init(type: "Bearer", realm: "https://auth.server.io/token", service: "registry.server.io", scope: nil, error: nil)), + + ] + + @Test(arguments: testCases) + func parseAuthHeader(testCase: TestCase) throws { + let challenges = RegistryClient.parseWWWAuthenticateHeaders(headers: [testCase.input]) + #expect(challenges.count == 1) + #expect(challenges[0] == testCase.expected) + } +} diff --git a/Tests/ContainerizationOCITests/RegistryClientTests.swift b/Tests/ContainerizationOCITests/RegistryClientTests.swift index 401ce701..c0567157 100644 --- a/Tests/ContainerizationOCITests/RegistryClientTests.swift +++ b/Tests/ContainerizationOCITests/RegistryClientTests.swift @@ -72,8 +72,14 @@ struct OCIClientTests: ~Copyable { #expect(response.getToken() != nil) } - @Test func ping() async throws { - let client = RegistryClient(host: "registry-1.docker.io") + @Test(arguments: [ + "registry-1.docker.io", + "public.ecr.aws", + "registry.k8s.io", + "mcr.microsoft.com", + ]) + func ping(host: String) async throws { + let client = RegistryClient(host: host) try await client.ping() }