# SPDX-License-Identifier: MIT defmodule TestcontainerEx.Engine.Auth do @moduledoc """ Resolves Docker registry credentials from the user's Docker config file (typically `~/.docker/config.json`) and returns a ready-to-send `X-Registry-Auth` header value. Scope: * Only the `auths` map in `config.json` is supported. * Credential helpers (`credsStore`, `credHelpers`) are intentionally out of scope — if encountered, a debug log is emitted and `nil` is returned so the caller can fall back to anonymous access. The `DOCKER_CONFIG` environment variable is honoured: when set, the config file is read from `$DOCKER_CONFIG/config.json`; otherwise the default path `~/.docker/config.json` is used. The header value is a URL-safe base64 encoding (without padding) of a JSON document describing the credentials, as specified by the Docker Engine API. Third-party registries (quay.io, ghcr.io, gcr.io, registry.gitlab.com, etc.) are fully supported. The registry key is extracted from the image reference and looked up directly in the `auths` map. """ require Logger @docker_hub_key "https://index.docker.io/v1/" # Known third-party registry hostnames for candidate key generation @known_registries ~w( quay.io ghcr.io gcr.io registry.gitlab.com us.gcr.io eu.gcr.io asia.gcr.io pkg.dev docker.elastic.co mcr.microsoft.com public.ecr.aws nvcr.io registry.k8s.io ) @doc """ Resolves registry credentials for the given `image` and returns the ready-to-send `X-Registry-Auth` header value, or `nil` if no matching credentials can be found. `config_path` may be `nil`, in which case the default lookup logic is used (respecting the `DOCKER_CONFIG` environment variable). """ @spec resolve(String.t(), String.t() | nil) :: String.t() | nil def resolve(image, config_path \\ nil) when is_binary(image) do case read_config(config_path) do {:ok, config} -> registry = registry_for_image(image) resolve_from_config(config, registry) :error -> nil end end @doc """ Returns the registry key that should be used for looking up credentials for the given `image` (Docker config convention). Unnamespaced or explicitly `docker.io`-hosted images resolve to `https://index.docker.io/v1/`; everything else resolves to the registry host component of the image reference. ## Examples iex> TestcontainerEx.Engine.Auth.registry_for_image("nginx") "https://index.docker.io/v1/" iex> TestcontainerEx.Engine.Auth.registry_for_image("library/nginx") "https://index.docker.io/v1/" iex> TestcontainerEx.Engine.Auth.registry_for_image("docker.io/library/nginx") "https://index.docker.io/v1/" iex> TestcontainerEx.Engine.Auth.registry_for_image("quay.io/org/image") "quay.io" iex> TestcontainerEx.Engine.Auth.registry_for_image("ghcr.io/org/image") "ghcr.io" iex> TestcontainerEx.Engine.Auth.registry_for_image("gcr.io/project/image") "gcr.io" iex> TestcontainerEx.Engine.Auth.registry_for_image("registry.gitlab.com/org/image") "registry.gitlab.com" iex> TestcontainerEx.Engine.Auth.registry_for_image("myregistry:5000/image") "myregistry:5000" """ @spec registry_for_image(String.t()) :: String.t() def registry_for_image(image) when is_binary(image) do case String.split(image, "/", parts: 2) do [_single] -> @docker_hub_key [maybe_host, _rest] -> registry_from_host_component(maybe_host) end end defp registry_from_host_component(component) do cond do not host?(component) -> @docker_hub_key component in ["docker.io", "index.docker.io"] -> @docker_hub_key true -> component end end # A registry host contains a "." or ":" or is exactly "localhost". defp host?(component) do component == "localhost" or String.contains?(component, ".") or String.contains?(component, ":") end defp read_config(nil), do: read_config(default_config_path()) defp read_config(path) when is_binary(path) do with {:ok, contents} <- File.read(path), {:ok, decoded} <- Jason.decode(contents) do {:ok, decoded} else {:error, reason} -> Logger.debug( "TestcontainerEx.Engine.Auth: could not read Docker config at #{path}: #{inspect(reason)}" ) :error end end defp default_config_path do case System.get_env("DOCKER_CONFIG") do nil -> Path.join(System.user_home() || "", ".docker/config.json") "" -> Path.join(System.user_home() || "", ".docker/config.json") dir -> Path.join(dir, "config.json") end end defp resolve_from_config(config, registry) do auths = Map.get(config, "auths", %{}) case find_auth_entry(auths, registry) do {matched_key, %{"auth" => encoded}} when is_binary(encoded) and encoded != "" -> build_header(encoded, matched_key) {_matched_key, _entry} -> maybe_warn_cred_helper(config, registry) nil :not_found -> maybe_warn_cred_helper(config, registry) nil end end defp find_auth_entry(auths, registry) when is_map(auths) do candidates = candidate_keys(registry) Enum.find_value(candidates, :not_found, fn key -> case Map.get(auths, key) do nil -> nil entry when is_map(entry) -> {key, entry} _ -> nil end end) end defp find_auth_entry(_auths, _registry), do: :not_found # Docker config.json keys are sometimes stored with scheme/path prefixes and # sometimes as plain hostnames. We try the most specific form first and fall # back to the bare host. @doc """ Returns the list of candidate keys to try when looking up credentials for a given registry in the Docker config.json `auths` map. Docker config files may store registry keys in various formats (with or without scheme, with or without trailing slash). This function generates all plausible variants so that lookups succeed regardless of how the key was originally stored. ## Examples iex> TestcontainerEx.Engine.Auth.candidate_keys("quay.io") ["quay.io", "https://quay.io", "http://quay.io", "https://quay.io/", "http://quay.io/"] iex> TestcontainerEx.Engine.Auth.candidate_keys("https://index.docker.io/v1/") ["https://index.docker.io/v1/", "index.docker.io", "docker.io", "https://index.docker.io/v1", "https://index.docker.io", "https://docker.io"] """ @spec candidate_keys(String.t()) :: [String.t()] def candidate_keys(@docker_hub_key) do [ @docker_hub_key, "index.docker.io", "docker.io", "https://index.docker.io/v1", "https://index.docker.io", "https://docker.io" ] end def candidate_keys(registry) do # For known registries, try the registry name with various prefixes # For ECR-style registries (dkr.ecr.*.amazonaws.com), also try the full hostname base_candidates = [ registry, "https://" <> registry, "http://" <> registry, "https://" <> registry <> "/", "http://" <> registry <> "/" ] # For registries with port (e.g., myregistry:5000), also try without scheme base_candidates = if String.contains?(registry, ":") do [registry | base_candidates] else base_candidates end # For known registries, also try the registry name as-is first base_candidates = if registry in @known_registries do [registry | base_candidates] else base_candidates end Enum.uniq(base_candidates) end defp build_header(encoded, server_address) do with {:ok, decoded} <- Base.decode64(encoded), [username, password] <- String.split(decoded, ":", parts: 2) do payload = %{ "username" => username, "password" => password, "serveraddress" => normalize_server_address(server_address) } payload |> Jason.encode!() |> Base.url_encode64(padding: false) else _ -> Logger.debug( "TestcontainerEx.Engine.Auth: could not decode auth entry for #{server_address}" ) nil end end # Docker config.json keys are often stored as URLs (e.g. # "https://index.docker.io/v1/") but the Docker Engine API's # `serveraddress` field expects a bare domain/IP (optionally with port) — # podman in particular rejects a full URL with HTTP 400. @doc false @spec normalize_server_address(String.t()) :: String.t() def normalize_server_address(address) when is_binary(address) do address |> strip_scheme() |> strip_trailing_path() |> canonicalize_docker_hub() end defp strip_scheme(address) do case String.split(address, "://", parts: 2) do [_scheme, rest] -> rest [single] -> single end end defp strip_trailing_path(address) do address |> String.split("/", parts: 2) |> List.first() end defp canonicalize_docker_hub("index.docker.io"), do: "docker.io" defp canonicalize_docker_hub(host), do: host defp maybe_warn_cred_helper(config, registry) do cond do Map.has_key?(config, "credsStore") -> Logger.debug( "TestcontainerEx.Engine.Auth: credsStore present in Docker config; " <> "credential helpers are not supported, returning nil for #{registry}" ) is_map(Map.get(config, "credHelpers")) and Map.has_key?(config["credHelpers"], registry) -> Logger.debug( "TestcontainerEx.Engine.Auth: credHelpers entry for #{registry} present; " <> "credential helpers are not supported, returning nil" ) true -> :ok end end end