Skip to main content

lib/stevedore/store/local.ex

defmodule Stevedore.Store.Local do
  @moduledoc """
  A filesystem-backed `Stevedore.Store`.

  Blobs are laid out as `<root>/blobs/<algorithm>/<hex>`, matching the OCI image-layout blob
  convention. Writes are **atomic** (temp file + `File.rename/2`) and **digest-verified** (the
  bytes must hash to the digest before the blob is committed). The on-disk path is derived only
  from a validated `Stevedore.Digest`, never from caller-supplied strings, so there is no
  path-traversal surface.

  The store `config` is the root directory, given as a path string or `[root: path]`.

  Spec: [OCI image-layout](https://github.com/opencontainers/image-spec/blob/main/image-layout.md).
  """

  @behaviour Stevedore.Store

  alias Stevedore.Digest

  @impl true
  @spec put(Stevedore.Store.config(), Digest.t(), iodata()) :: :ok | {:error, term()}
  def put(config, %Digest{} = digest, data) do
    case Digest.verify(data, digest) do
      :ok -> write_atomic(path(config, digest), data)
      {:error, _} = error -> error
    end
  end

  @impl true
  @spec get(Stevedore.Store.config(), Digest.t()) :: {:ok, binary()} | {:error, :not_found}
  def get(config, %Digest{} = digest) do
    case File.read(path(config, digest)) do
      {:ok, data} ->
        {:ok, data}

      {:error, :enoent} ->
        {:error, :not_found}

      {:error, reason} ->
        raise File.Error, reason: reason, action: "read blob", path: path(config, digest)
    end
  end

  @impl true
  @spec delete(Stevedore.Store.config(), Digest.t()) :: :ok | {:error, term()}
  def delete(config, %Digest{} = digest) do
    case File.rm(path(config, digest)) do
      :ok -> :ok
      {:error, :enoent} -> :ok
      {:error, reason} -> {:error, reason}
    end
  end

  @impl true
  @spec exists?(Stevedore.Store.config(), Digest.t()) :: boolean()
  def exists?(config, %Digest{} = digest), do: File.exists?(path(config, digest))

  @impl true
  @spec list(Stevedore.Store.config(), keyword()) :: {:ok, [Digest.t()]}
  def list(config, _opts \\ []) do
    digests =
      config
      |> blobs_dir()
      |> Path.join("*/*")
      |> Path.wildcard()
      |> Enum.flat_map(&digest_from_path/1)

    {:ok, digests}
  end

  @impl true
  @spec local_path(Stevedore.Store.config(), Digest.t()) :: {:ok, Path.t()}
  def local_path(config, %Digest{} = digest), do: {:ok, path(config, digest)}

  @spec write_atomic(Path.t(), iodata()) :: :ok | {:error, term()}
  defp write_atomic(final, data) do
    dir = Path.dirname(final)
    tmp = Path.join(dir, ".tmp-#{System.unique_integer([:positive])}")

    with :ok <- File.mkdir_p(dir),
         :ok <- File.write(tmp, data),
         :ok <- File.rename(tmp, final) do
      :ok
    else
      {:error, _} = error ->
        _ = File.rm(tmp)
        error
    end
  end

  @spec path(Stevedore.Store.config(), Digest.t()) :: Path.t()
  defp path(config, digest), do: Path.join(blobs_dir(config), Digest.to_path(digest))

  @spec blobs_dir(Stevedore.Store.config()) :: Path.t()
  defp blobs_dir(config), do: Path.join(root(config), "blobs")

  @spec root(Stevedore.Store.config()) :: binary()
  defp root(config) when is_binary(config), do: config
  defp root(config) when is_list(config), do: Keyword.fetch!(config, :root)

  # Reconstruct a digest from a "<root>/blobs/<algo>/<hex>" path; skip unknown algorithms
  # (never String.to_atom an on-disk name).
  @spec digest_from_path(Path.t()) :: [Digest.t()]
  defp digest_from_path(path) do
    algo = path |> Path.dirname() |> Path.basename()
    hex = Path.basename(path)

    case algo do
      "sha256" -> [%Digest{algorithm: :sha256, hex: hex}]
      "sha512" -> [%Digest{algorithm: :sha512, hex: hex}]
      _ -> []
    end
  end
end