10.4 KiB · text History fec4951
defmodule GitGud.Packages.Formats.Raw do
  @moduledoc """
  Static binaries with a manifest, for consumers that have no package
  manager at all.

      GET  /pkg/raw/<owner>/<repo>/manifest.json       what exists
      GET  /pkg/raw/<owner>/<repo>/latest/<file>       the newest bytes
      GET  /pkg/raw/<owner>/<repo>/<version>/<file>    one version's bytes
      PUT  /pkg/raw/<owner>/<repo>/<version>/<file>    publish

  The other five formats each serve an ecosystem: something on the far
  end knows how to read a sparse index, a `Release` file, a `repomd`.
  This one exists for the far end that knows nothing — a microcontroller
  doing OTA, a shell script, an installer — where the whole protocol a
  client can be expected to implement is "fetch a small JSON document,
  then fetch a URL out of it".

  So the manifest is the format. It is one document listing every
  version and every file, each with the absolute URL to fetch and the
  SHA-256 to check it against, and a `latest` pointer at the top:

      {"name": "lambent-flex-c6",
       "latest": "0.1.0+ci20260916.g1a2b3c4",
       "versions": {
         "0.1.0+ci20260916.g1a2b3c4": {
           "published_at": "2026-09-16T17:00:00Z",
           "yanked": false,
           "files": [{"name": "lambent-flex-c6.bin",
                      "size": 1312768,
                      "sha256": "9f86d0…",
                      "content_type": "application/octet-stream",
                      "url": "https://…/0.1.0+ci20260916.g1a2b3c4/lambent-flex-c6.bin"}]}}}

  ## One package per repository

  Every other format takes the package name from the artifact it is
  handed — a wheel, a `.deb`, an APK all carry their own. Raw bytes
  carry nothing, so the package is named after the repository and a
  repository that ships several binaries ships them as several *files*
  of one version. That keeps the URL free of a name nobody would have
  a source for, and keeps `manifest.json` a single document rather than
  an index of indexes.

  ## `latest` is the last thing published, not the highest version

  There is no version grammar here to sort by: `2026-09-16`, `v1.2.3`
  and `0.1.0+ci…g1a2b3c4` are all plausible and none of them compares
  to the others. So `latest` is whatever was published most recently,
  which is both well-defined and the thing CI actually means. Rolling a
  fleet back is then republishing the older bytes under a new version,
  rather than hoping a comparison function agrees with you.

  Consequently `latest` is a reserved word in the version slot: a
  version literally named `latest` is unreachable at its own URL.

  ## No signing key

  Every other signature-bearing format here pins a key because its
  client fetches indexes it cannot otherwise trust. A raw consumer
  fetches the manifest and the bytes from the same origin over TLS, and
  checks the SHA-256 the manifest gave it — which catches a truncated
  transfer and a mismatched publish. Anything stronger than that is
  secure boot's job, on the device, not a detached signature this
  registry could offer.
  """

  @behaviour GitGud.Packages.Format

  alias GitGud.Packages
  alias GitGud.Packages.Package
  alias GitGud.Packages.PackageFile
  alias GitGud.Packages.PackageVersion
  alias GitGud.Repositories.Repository

  @format "raw"

  # The version slot that means "resolve it for me".
  @latest "latest"

  @content_types %{
    ".json" => "application/json",
    ".txt" => "text/plain; charset=utf-8",
    ".md" => "text/markdown; charset=utf-8",
    ".sha256" => "text/plain; charset=utf-8",
    ".csv" => "text/csv; charset=utf-8",
    ".gz" => "application/gzip",
    ".zip" => "application/zip",
    ".tar" => "application/x-tar"
  }

  @doc """
  Media type for a stored artifact.

  Deliberately shallow: the point of this registry is bytes, and a
  client that cared about the type would be using a format that has
  one. Enough of a table to keep a browser from offering to download a
  checksum file, and `application/octet-stream` for everything else.
  """
  @impl true
  def content_type(filename) when is_binary(filename) do
    ext = filename |> Path.extname() |> String.downcase()
    Map.get(@content_types, ext, "application/octet-stream")
  end

  def content_type(_filename), do: "application/octet-stream"

  # ── reads ───────────────────────────────────────────────────────────

  @impl true
  def index(repo, ctx)

  # The manifest, at the registry root and under its own name. Both,
  # because a person poking at the URL in a browser lands on the root
  # and a device has a full URL written into its firmware — and a
  # device's URL should say what it fetches.
  def index(repo, %{path: path} = ctx) when path in [[], ["manifest.json"]] do
    {:ok, "application/json", manifest(repo, ctx),
     [
       # A manifest that a cache pins is a fleet that never updates.
       # Revalidation is cheap; the bytes it points at are immutable
       # and cache as aggressively as anything wants to.
       {"cache-control", "no-cache"}
     ]}
  end

  def index(repo, %{path: [@latest, filename]}) do
    with {:ok, package} <- fetch_package(repo),
         %PackageVersion{} = version <- newest(package) do
      serve(version, filename)
    else
      _ -> {:error, :not_found}
    end
  end

  def index(repo, %{path: [version, filename]}) do
    with {:ok, package} <- fetch_package(repo),
         %PackageVersion{} = version <- Packages.get_version(package, version) do
      serve(version, filename)
    else
      _ -> {:error, :not_found}
    end
  end

  def index(_repo, _ctx), do: {:error, :not_found}

  defp serve(%PackageVersion{} = version, filename) do
    case Enum.find(Packages.list_files(version), &(&1.filename == filename)) do
      %PackageFile{} = file -> {:blob, file}
      nil -> {:error, :not_found}
    end
  end

  # ── writes ──────────────────────────────────────────────────────────

  @impl true
  def store(repo, ctx, body)

  # PUT, because the URL names the thing being created and publishing
  # the same bytes twice is a no-op — `publish_file/5` is idempotent on
  # (version, filename) and refuses a filename whose bytes changed.
  # POST is accepted at the same path for callers whose HTTP client
  # makes PUT awkward.
  def store(repo, %{method: method, path: [version, filename]}, body)
      when method in ["PUT", "POST"] and is_binary(body) do
    cond do
      version == @latest ->
        {:error, {400, "'#{@latest}' is reserved; publish under a real version"}}

      body == "" ->
        {:error, {400, "empty body"}}

      true ->
        publish(repo, version, filename, body)
    end
  end

  # A multipart body arrives as a params map (Plug.Parsers decoded it
  # before the controller saw it). Nothing publishing raw bytes has a
  # reason to send one, and guessing which part is the artifact is how
  # the wrong bytes get published.
  def store(_repo, %{path: [_version, _filename]}, _body), do: not_a_form()

  def store(_repo, %{path: path}, _body) when is_list(path),
    do: {:error, {400, "publish to /<version>/<filename>"}}

  def store(_repo, _ctx, _body), do: not_a_form()

  defp not_a_form, do: {:error, {400, "send the file as the request body, not as a form"}}

  defp publish(%Repository{} = repo, version, filename, body) do
    attrs = %{
      filename: filename,
      body: body,
      content_type: content_type(filename)
    }

    case Packages.publish_file(repo, @format, repo.name, version, attrs) do
      {:ok, _package, _version, file} ->
        {:ok, "application/json",
         Jason.encode_to_iodata!(%{
           "name" => repo.name,
           "version" => version,
           "filename" => file.filename,
           "size" => file.size,
           "sha256" => file.sha256
         })}

      {:error, :file_exists} ->
        {:error, :file_exists}

      {:error, reason} ->
        {:error, reason}
    end
  end

  # ── the manifest ────────────────────────────────────────────────────

  defp manifest(%Repository{} = repo, ctx) do
    versions =
      case fetch_package(repo) do
        {:ok, package} -> Packages.list_versions(package)
        :error -> []
      end

    Jason.encode_to_iodata!(%{
      "name" => repo.name,
      # `null` rather than an omitted key: a client that has to
      # distinguish "nothing published yet" from "I fetched the wrong
      # URL" can, and one that doesn't care reads the same falsy value
      # either way.
      "latest" => latest_version(versions),
      "versions" =>
        Map.new(versions, fn version ->
          {version.version, describe(version, ctx)}
        end)
    })
  end

  defp describe(%PackageVersion{} = version, ctx) do
    %{
      "published_at" => version.pushed_at && DateTime.to_iso8601(version.pushed_at),
      "yanked" => version.yanked,
      "files" =>
        Enum.map(Packages.list_files(version), fn file ->
          %{
            "name" => file.filename,
            "size" => file.size,
            "sha256" => file.sha256,
            "content_type" => file.content_type,
            # Absolute, so a device stores one URL (the manifest's) and
            # follows what it is given rather than assembling paths.
            "url" => "#{ctx.base_url}/#{encode(version.version)}/#{encode(file.filename)}"
          }
        end)
    }
  end

  # `list_versions/1` is newest-published first, so this is the head —
  # skipping anything yanked, since a yanked version is exactly one an
  # updater should not roll onto.
  defp newest(%Package{} = package), do: package |> Packages.list_versions() |> newest()

  defp newest(versions) when is_list(versions), do: Enum.find(versions, &(not &1.yanked))

  defp latest_version(versions) do
    case newest(versions) do
      %PackageVersion{version: version} -> version
      nil -> nil
    end
  end

  defp fetch_package(%Repository{} = repo) do
    case Packages.get_package(repo, @format, repo.name) do
      %Package{} = package -> {:ok, package}
      nil -> :error
    end
  end

  # Version strings carry `+` (build metadata) and filenames carry all
  # sorts of things; both are single path segments, so encode them as
  # such rather than trusting them to be URL-clean.
  defp encode(segment), do: URI.encode(segment, &URI.char_unreserved?/1)
end