diff --git a/Sources/tart/OCI/Registry.swift b/Sources/tart/OCI/Registry.swift index 25d13cb..083ea52 100644 --- a/Sources/tart/OCI/Registry.swift +++ b/Sources/tart/OCI/Registry.swift @@ -111,27 +111,24 @@ struct TokenResponse: Decodable, Authentication { } class Registry { - private let baseURL: URL + let baseURL: URL let namespace: String let credentialsProviders: [CredentialsProvider] let authenticationKeeper = AuthenticationKeeper() - var host: String? { - guard let host = baseURL.host else { return nil } - - if let port = baseURL.port { - return "\(host):\(port)" - } - - return host - } + // Host with an optional port (e.g. "127.0.0.1:5000"), which is used for naming + // and credentials lookup. For Docker Hub it stays "docker.io", while baseURL + // points to registry-1.docker.io. + let host: String? init(baseURL: URL, namespace: String, + host: String? = nil, credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()] ) throws { self.baseURL = baseURL self.namespace = namespace + self.host = host ?? Registry.hostWithPort(of: baseURL) self.credentialsProviders = credentialsProviders } @@ -142,9 +139,9 @@ class Registry { credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()] ) throws { let proto = insecure ? "http" : "https" - let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")! + var baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")! - guard let baseURL = baseURLComponents.url else { + guard var baseURL = baseURLComponents.url else { var hint = "" if host.hasPrefix("http://") || host.hasPrefix("https://") { @@ -154,7 +151,33 @@ class Registry { throw RuntimeError.ImproperlyFormattedHost(host, hint) } - try self.init(baseURL: baseURL, namespace: namespace, credentialsProviders: credentialsProviders) + // Naming and credentials lookup use the host and port of the original URL, + // so it's "docker.io" for Docker Hub and "127.0.0.1:5000" for "127.0.0.1:05000" + let normalizedHost = Registry.hostWithPort(of: baseURL) + + // Docker Hub serves its registry API from registry-1.docker.io, while docker.io, + // the host used in image names, redirects to Docker's website. URLSession follows + // these redirects, so we'd get an HTML page with HTTP 200 instead of an API + // response, which breaks pushing, pulling and "tart login" credentials validation. + // + // Host names are case insensitive, and only the host is replaced, + // so an explicit port like in "Docker.IO:443" is kept. + if baseURLComponents.host?.lowercased() == "docker.io" { + baseURLComponents.host = "registry-1.docker.io" + baseURL = baseURLComponents.url! + } + + try self.init(baseURL: baseURL, namespace: namespace, host: normalizedHost, credentialsProviders: credentialsProviders) + } + + private static func hostWithPort(of url: URL) -> String? { + guard let host = url.host else { return nil } + + if let port = url.port { + return "\(host):\(port)" + } + + return host } func ping() async throws { @@ -421,12 +444,8 @@ class Registry { await authenticationKeeper.set(try TokenResponse.parse(fromData: data)) } - private func lookupCredentials() throws -> (String, String)? { - var host = baseURL.host! - - if let port = baseURL.port { - host += ":\(port)" - } + func lookupCredentials() throws -> (String, String)? { + let host = self.host! for provider in credentialsProviders { do { diff --git a/Tests/TartTests/RegistryHostTests.swift b/Tests/TartTests/RegistryHostTests.swift new file mode 100644 index 0000000..3d71e3b --- /dev/null +++ b/Tests/TartTests/RegistryHostTests.swift @@ -0,0 +1,88 @@ +import XCTest +@testable import tart + +final class RegistryHostTests: XCTestCase { + func testDockerHub() throws { + let credentialsProvider = RecordingCredentialsProvider() + let registry = try Registry(host: "docker.io", namespace: "org/repo", + credentialsProviders: [credentialsProvider]) + + // docker.io redirects to Docker's website, so the API + // requests should go to registry-1.docker.io instead + XCTAssertEqual(registry.baseURL, URL(string: "https://registry-1.docker.io/v2/")) + + // ...while naming and credentials lookup should still use the host specified by the user + XCTAssertEqual(registry.host, "docker.io") + XCTAssertNil(try registry.lookupCredentials()) + XCTAssertEqual(credentialsProvider.requestedHosts, ["docker.io"]) + } + + func testDockerHubIsMatchedCaseInsensitively() throws { + let credentialsProvider = RecordingCredentialsProvider() + let registry = try Registry(host: "Docker.IO", namespace: "org/repo", + credentialsProviders: [credentialsProvider]) + + XCTAssertEqual(registry.baseURL, URL(string: "https://registry-1.docker.io/v2/")) + XCTAssertEqual(registry.host, "Docker.IO") + XCTAssertNil(try registry.lookupCredentials()) + XCTAssertEqual(credentialsProvider.requestedHosts, ["Docker.IO"]) + } + + func testDockerHubWithExplicitPort() throws { + for host in ["docker.io:443", "DOCKER.IO:443"] { + let registry = try Registry(host: host, namespace: "org/repo") + + XCTAssertEqual(registry.baseURL, URL(string: "https://registry-1.docker.io:443/v2/")) + XCTAssertEqual(registry.host, host) + } + } + + func testOtherHostsAreUnchanged() throws { + for host in ["ghcr.io", "index.docker.io", "registry-1.docker.io", "registry.hub.docker.com", "127.0.0.1:8080"] { + let registry = try Registry(host: host, namespace: "org/repo") + + XCTAssertEqual(registry.baseURL, URL(string: "https://\(host)/v2/")) + XCTAssertEqual(registry.host, host) + } + + let registry = try Registry(host: "127.0.0.1:5000", namespace: "org/repo", insecure: true) + XCTAssertEqual(registry.baseURL, URL(string: "http://127.0.0.1:5000/v2/")) + XCTAssertEqual(registry.host, "127.0.0.1:5000") + } + + func testHostPortIsNormalized() throws { + // Credentials stored for "127.0.0.1:5000" should still be found + // when the port is written with a leading zero + let credentialsProvider = RecordingCredentialsProvider(credentials: ["127.0.0.1:5000": ("user", "password")]) + let registry = try Registry(host: "127.0.0.1:05000", namespace: "org/repo", insecure: true, + credentialsProviders: [credentialsProvider]) + + XCTAssertEqual(registry.baseURL, URL(string: "http://127.0.0.1:05000/v2/")) + XCTAssertEqual(registry.host, "127.0.0.1:5000") + + let (user, password) = try XCTUnwrap(registry.lookupCredentials()) + XCTAssertEqual(user, "user") + XCTAssertEqual(password, "password") + XCTAssertEqual(credentialsProvider.requestedHosts, ["127.0.0.1:5000"]) + } +} + +fileprivate class RecordingCredentialsProvider: CredentialsProvider { + let userFriendlyName = "recording credentials provider" + + let credentials: [String: (String, String)] + var requestedHosts: [String] = [] + + init(credentials: [String: (String, String)] = [:]) { + self.credentials = credentials + } + + func retrieve(host: String) throws -> (String, String)? { + requestedHosts.append(host) + + return credentials[host] + } + + func store(host: String, user: String, password: String) throws { + } +}