Use registry-1.docker.io for Docker Hub's docker.io host (#1332)

* Use registry-1.docker.io for Docker Hub's docker.io host

docker.io doesn't serve the registry API: https://docker.io/v2/ redirects
to https://www.docker.com/, which URLSession follows, getting back an HTML
page with HTTP 200. As a result, pushing fails with
UnexpectedHTTPStatusCode("pushing blob (POST)", 200, ...), pulling fails
to parse the manifest and "tart login docker.io" accepts any credentials,
because ping() never gets an authentication challenge.

Send the API requests for docker.io to registry-1.docker.io instead, while
still using docker.io for the pushed image names and for the credentials
lookup, so that credentials saved with "tart login docker.io" keep working.

Fixes #1275

* Match docker.io case insensitively

* Recognize Docker Hub with an explicit port

* Parse the registry host once and keep it normalized

Using the host exactly as specified for naming and credentials lookup
changed the behavior for other registries too. For example,
"127.0.0.1:05000" used to find credentials stored for "127.0.0.1:5000",
but didn't anymore.

Parse the URL once instead, take the normalized host and port from it
like before, and only replace the URL's host with registry-1.docker.io
for Docker Hub.
This commit is contained in:
om singhal
2026-09-24 14:00:30 -07:00
committed by GitHub
parent 4e58a2a0b9
commit 65aea029ab
2 changed files with 126 additions and 19 deletions
+38 -19
View File
@@ -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 {
+88
View File
@@ -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 {
}
}