Skip to main content

lib/stevedore/transport/static.ex

defmodule Stevedore.Transport.Static do
  @moduledoc """
  The `static:` transport — a Stevedore-native registry-v2 directory tree.

  Writes a layout a dumb web server can serve as a read-only registry:
  `v2/<name>/manifests/<tag|digest>` and `v2/<name>/blobs/<digest>`. Each manifest is written
  both by tag and by digest, with a `.mediatype` sidecar recording its `Content-Type` (which a
  static server can't infer); `Stevedore.Deploy` (Step 7) turns these into server headers.

  `:name` is the repository (e.g. `library/alpine`); `Stevedore.copy/3` fills it from the source
  when not set.

  Spec: [Docker Registry HTTP API v2](https://distribution.github.io/distribution/spec/api/).
  """

  @behaviour Stevedore.Transport

  alias Stevedore.{Digest, Manifest, Transport}

  @enforce_keys [:path]
  defstruct [:path, :name]

  @type t :: %__MODULE__{path: Path.t(), name: String.t() | nil}

  @impl true
  @spec get_manifest(t(), Transport.ref()) :: {:ok, Transport.fetched()} | {:error, term()}
  def get_manifest(%__MODULE__{} = t, ref) do
    target = target(ref)

    with {:ok, raw} <- read(manifest_path(t, target)),
         {:ok, manifest} <- Manifest.parse(raw, read_mediatype(t, target)) do
      {:ok,
       %{
         media_type: manifest.media_type,
         digest: Digest.compute(raw),
         raw: raw,
         json: manifest.json
       }}
    end
  end

  @impl true
  @spec put_manifest(t(), Transport.ref(), binary(), String.t()) :: {:ok, Digest.t()}
  def put_manifest(%__MODULE__{} = t, ref, raw, media_type) do
    digest = Digest.compute(raw)
    File.mkdir_p!(manifests_dir(t))

    # Always addressable by digest; also by tag when one is given.
    write_manifest(t, Digest.to_string(digest), raw, media_type)
    if is_binary(ref), do: write_manifest(t, ref, raw, media_type)

    {:ok, digest}
  end

  @impl true
  @spec get_blob(t(), Digest.t()) :: {:ok, binary()} | {:error, :not_found}
  def get_blob(%__MODULE__{} = t, %Digest{} = digest), do: read(blob_path(t, digest))

  @impl true
  @spec put_blob(t(), Digest.t(), iodata()) :: :ok | {:error, term()}
  def put_blob(%__MODULE__{} = t, %Digest{} = digest, data) do
    case Digest.verify(data, digest) do
      :ok ->
        File.mkdir_p!(blobs_dir(t))
        File.write!(blob_path(t, digest), data)

      {:error, _} = error ->
        error
    end
  end

  @impl true
  @spec has_blob?(t(), Digest.t()) :: boolean()
  def has_blob?(%__MODULE__{} = t, %Digest{} = digest), do: File.exists?(blob_path(t, digest))

  @impl true
  @spec list_tags(t()) :: {:ok, [String.t()]}
  def list_tags(%__MODULE__{} = t) do
    tags =
      manifests_dir(t)
      |> ls()
      |> Enum.reject(&(String.contains?(&1, ":") or String.ends_with?(&1, ".mediatype")))

    {:ok, tags}
  end

  @impl true
  @spec delete(t(), Transport.ref()) :: :ok
  def delete(%__MODULE__{} = t, ref) do
    target = target(ref)
    _ = File.rm(manifest_path(t, target))
    _ = File.rm(manifest_path(t, target) <> ".mediatype")
    :ok
  end

  @doc "Deletes a blob by digest (idempotent)."
  @spec delete_blob(t(), Digest.t()) :: :ok
  def delete_blob(%__MODULE__{} = t, %Digest{} = digest) do
    _ = File.rm(blob_path(t, digest))
    :ok
  end

  @doc "Lists every stored manifest reference (tags and digests), excluding sidecar files."
  @spec list_manifest_refs(t()) :: [String.t()]
  def list_manifest_refs(%__MODULE__{} = t) do
    t |> manifests_dir() |> ls() |> Enum.reject(&String.ends_with?(&1, ".mediatype"))
  end

  @spec write_manifest(t(), String.t(), binary(), String.t()) :: :ok
  defp write_manifest(t, target, raw, media_type) do
    File.write!(manifest_path(t, target), raw)
    File.write!(manifest_path(t, target) <> ".mediatype", media_type)
  end

  @spec read_mediatype(t(), String.t()) :: String.t() | nil
  defp read_mediatype(t, target) do
    case File.read(manifest_path(t, target) <> ".mediatype") do
      {:ok, mt} -> mt
      _ -> nil
    end
  end

  @spec name(t()) :: String.t()
  defp name(%__MODULE__{name: name}) when is_binary(name), do: name

  defp name(%__MODULE__{name: nil}) do
    raise ArgumentError, "Stevedore.Transport.Static requires a :name (repository)"
  end

  @spec repo_dir(t()) :: Path.t()
  defp repo_dir(%__MODULE__{} = t), do: Path.join([t.path, "v2", name(t)])

  @spec manifests_dir(t()) :: Path.t()
  defp manifests_dir(%__MODULE__{} = t), do: Path.join(repo_dir(t), "manifests")

  @spec blobs_dir(t()) :: Path.t()
  defp blobs_dir(%__MODULE__{} = t), do: Path.join(repo_dir(t), "blobs")

  @spec manifest_path(t(), String.t()) :: Path.t()
  defp manifest_path(%__MODULE__{} = t, target), do: Path.join(manifests_dir(t), target)

  @spec blob_path(t(), Digest.t()) :: Path.t()
  defp blob_path(%__MODULE__{} = t, digest), do: Path.join(blobs_dir(t), Digest.to_string(digest))

  @spec target(Transport.ref()) :: String.t()
  defp target(%Digest{} = digest), do: Digest.to_string(digest)
  defp target(ref) when is_binary(ref), do: ref

  @spec read(Path.t()) :: {:ok, binary()} | {:error, :not_found}
  defp read(path) do
    case File.read(path) do
      {:ok, data} -> {:ok, data}
      {:error, :enoent} -> {:error, :not_found}
      {:error, reason} -> {:error, reason}
    end
  end

  @spec ls(Path.t()) :: [String.t()]
  defp ls(dir) do
    case File.ls(dir) do
      {:ok, entries} -> entries
      _ -> []
    end
  end
end