Skip to main content

lib/stevedore/auth/cache.ex

defmodule Stevedore.Auth.Cache do
  @moduledoc """
  An opt-in, in-process cache of registry bearer tokens, keyed by `{registry, scope}`.

  By default `Stevedore.Registry` re-runs the `401 → token` handshake on every request. Start a
  cache and pass it as the `:token_cache` option to reuse a token across the manifest + blob
  fetches of a pull: the first request earns the token, the rest send it **preemptively** —
  skipping both the `401` and the token-endpoint round-trip. A stale or rejected token still
  falls back to a fresh handshake, so the cache never changes results, only request count.

  Tokens are cached for `:ttl` milliseconds (default 60s, comfortably inside a typical registry
  token lifetime); the `401` fallback covers any token that expires sooner. Starting a cache is
  the consumer's choice — nothing here runs unless you start it, preserving Stevedore's
  weightless-by-default invariant.

  ## Example

      {:ok, cache} = Stevedore.Auth.Cache.start_link([])
      Stevedore.copy("docker://alpine:3.20", "oci:./alpine:3.20", token_cache: cache)
  """

  use Agent

  @default_ttl 60_000

  @typedoc "A cache entry's key: the registry host and the auth scope the token is valid for."
  @type key :: {registry :: String.t(), scope :: String.t()}

  @doc """
  Starts a token cache.

  Options: `:name` (register the process under a name) and `:ttl` (token lifetime in
  milliseconds, default `#{@default_ttl}`). Other options are passed to `Agent.start_link/2`.
  """
  @spec start_link(keyword()) :: Agent.on_start()
  def start_link(opts \\ []) do
    {ttl, agent_opts} = Keyword.pop(opts, :ttl, @default_ttl)
    Agent.start_link(fn -> %{ttl: ttl, entries: %{}} end, agent_opts)
  end

  @doc "Returns `{:ok, token}` for `key`, or `:miss` when it is absent or expired."
  @spec get(Agent.agent(), key()) :: {:ok, String.t()} | :miss
  def get(server, key) do
    Agent.get(server, fn %{entries: entries} ->
      case Map.get(entries, key) do
        {token, expires_at} when is_binary(token) ->
          if monotonic_ms() < expires_at, do: {:ok, token}, else: :miss

        _ ->
          :miss
      end
    end)
  end

  @doc """
  Caches `token` under `key`.

  `ttl` is the lifetime in milliseconds, or `:default` to use the cache's configured `:ttl`.
  """
  @spec put(Agent.agent(), key(), String.t(), non_neg_integer() | :default) :: :ok
  def put(server, key, token, ttl \\ :default) when is_binary(token) do
    Agent.update(server, fn %{ttl: default_ttl, entries: entries} = state ->
      ms = if ttl == :default, do: default_ttl, else: ttl
      %{state | entries: Map.put(entries, key, {token, monotonic_ms() + ms})}
    end)
  end

  @doc "Drops all cached tokens."
  @spec clear(Agent.agent()) :: :ok
  def clear(server), do: Agent.update(server, &%{&1 | entries: %{}})

  # Monotonic time can't jump backward, so TTL comparisons are immune to wall-clock changes.
  defp monotonic_ms, do: System.monotonic_time(:millisecond)
end