diff --git a/Package.swift b/Package.swift index df498005..0fcaa6cc 100644 --- a/Package.swift +++ b/Package.swift @@ -194,6 +194,7 @@ let package = Package( "Containerization", "ContainerizationIO", .product(name: "NIO", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), .product(name: "Crypto", package: "swift-crypto"), ] ), diff --git a/Sources/ContainerizationOCI/Client/RegistryClient.swift b/Sources/ContainerizationOCI/Client/RegistryClient.swift index 14f3142c..dc443f98 100644 --- a/Sources/ContainerizationOCI/Client/RegistryClient.swift +++ b/Sources/ContainerizationOCI/Client/RegistryClient.swift @@ -141,6 +141,32 @@ public final class RegistryClient: ContentClient { base.host ?? "" } + /// Builds the base `HTTPClientRequest` for a registry call, applying the headers + /// that are constant across authentication and retry attempts. + /// + /// A `User-Agent` identifying the client is always set so that registries can + /// attribute and, where required, gate requests. The HTTP/1.1 specification only + /// recommends this header, so some servers (and proxies) reject or mishandle + /// requests that omit it. Callers may override it by passing their own + /// `User-Agent` entry in `headers`. + internal func buildRequest( + url: String, + method: HTTPMethod, + headers: [(String, String)]? + ) -> HTTPClientRequest { + var request = HTTPClientRequest(url: url) + request.method = method + request.headers.add(name: "User-Agent", value: clientID) + headers?.forEach { (k, v) in + if k.lowercased() == "user-agent" { + request.headers.replaceOrAdd(name: k, value: v) + } else { + request.headers.add(name: k, value: v) + } + } + return request + } + internal func request( components: URLComponents, method: HTTPMethod = .GET, @@ -152,8 +178,7 @@ public final class RegistryClient: ContentClient { throw ContainerizationError(.invalidArgument, message: "invalid url \(components.path)") } - var request = HTTPClientRequest(url: path) - request.method = method + var request = buildRequest(url: path, method: method, headers: headers) var currentToken: TokenResponse? let token: String? = try await { @@ -167,8 +192,6 @@ public final class RegistryClient: ContentClient { request.headers.add(name: "Authorization", value: "\(token)") } - // Add any arbitrary headers - headers?.forEach { (k, v) in request.headers.add(name: k, value: v) } var retryCount = 0 var response: HTTPClientResponse? while true { diff --git a/Tests/ContainerizationOCITests/RegistryRequestHeaderTests.swift b/Tests/ContainerizationOCITests/RegistryRequestHeaderTests.swift new file mode 100644 index 00000000..4cf0867a --- /dev/null +++ b/Tests/ContainerizationOCITests/RegistryRequestHeaderTests.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025-2026 Apple Inc. and the Containerization project authors. +// +// 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 NIOHTTP1 +import Testing + +@testable import ContainerizationOCI + +struct RegistryRequestHeaderTests { + @Test func defaultUserAgentIsSet() throws { + let client = RegistryClient(host: "registry.example.com") + let request = client.buildRequest(url: "https://registry.example.com/v2/", method: .GET, headers: nil) + #expect(request.headers["User-Agent"] == ["containerization-registry-client"]) + } + + @Test func customClientIDBecomesUserAgent() throws { + let client = RegistryClient(host: "registry.example.com", clientID: "my-tool/1.2.3") + let request = client.buildRequest(url: "https://registry.example.com/v2/", method: .GET, headers: nil) + #expect(request.headers["User-Agent"] == ["my-tool/1.2.3"]) + } + + @Test func callerSuppliedUserAgentOverridesDefault() throws { + let client = RegistryClient(host: "registry.example.com") + let request = client.buildRequest( + url: "https://registry.example.com/v2/", + method: .GET, + headers: [("User-Agent", "override/9.9")] + ) + // The default must not be duplicated when the caller provides one. + #expect(request.headers["User-Agent"] == ["override/9.9"]) + } + + @Test func userAgentCoexistsWithOtherHeaders() throws { + let client = RegistryClient(host: "registry.example.com") + let request = client.buildRequest( + url: "https://registry.example.com/v2/", + method: .PUT, + headers: [("Content-Type", "application/octet-stream")] + ) + #expect(request.headers["User-Agent"] == ["containerization-registry-client"]) + #expect(request.headers["Content-Type"] == ["application/octet-stream"]) + } +}