Skip to main content

lib/engine/auth.ex

# 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