16.4 KiB · text History 173e2a6
defmodule GitGud.Packages.Formats.Cargo do
  @moduledoc """
  A Cargo registry — sparse HTTP index plus the publish API.

      # .cargo/config.toml
      [registries.forge]
      index = "sparse+https://<host>/pkg/cargo/<owner>/<repo>/"

      cargo publish --registry forge

  The git-index form is deliberately skipped. It is tempting here of
  all places — this *is* a git server — but sparse has been the default
  since Rust 1.68, it is a plain tree of files over HTTP, and it avoids
  a whole second write path that would have to keep a repository in
  sync with the database.

  Paths below the repository root:

      /config.json                             index configuration
      /1/{name}, /2/{name}, /3/{c}/{name},
      /{c1c2}/{c3c4}/{name}                    the index entries
      /api/v1/crates/{n}/{v}/download          the .crate bytes
      /api/v1/crates/new                       PUT to publish
      /api/v1/crates/{n}/{v}/yank              DELETE to yank
      /api/v1/crates/{n}/{v}/unyank            PUT to restore
      /api/v1/crates/{n}/owners                GET/PUT/DELETE
      /api/v1/crates?q=&per_page=              search

  ## The prefix layout

  Index files are sharded by the lowercased crate name's length: one
  and two-character names sit under `1/` and `2/`, three-character ones
  under `3/{first}/`, and everything else under `{first two}/{next
  two}/` — so `cargo` lives at `ca/rg/cargo`. Requests are checked
  against the shard the name actually belongs to, so a crate can't be
  served from a path it doesn't hash to.

  ## Caching is not a nicety here

  The index is read constantly — every dependency of every build, on
  every resolve — so `ETag`/`If-None-Match` is implemented and returns
  a real `304`. Bodies are small; the round trips are the cost.

  ## Publish framing

  `PUT /api/v1/crates/new` sends four things concatenated: a
  little-endian u32 length, that many bytes of JSON metadata, another
  u32, and the `.crate` tarball. Cargo labels this
  `Content-Type: application/json`, which it is not — hence the
  package upload parser running ahead of the endpoint's JSON parser, so
  nothing tries to decode the tarball as JSON.
  """

  @behaviour GitGud.Packages.Format

  alias GitGud.Packages
  alias GitGud.Packages.Package
  alias GitGud.Packages.PackageFile
  alias GitGud.Packages.PackageVersion

  @format "cargo"
  @json "application/json"
  # Cargo's index parser is newline-delimited JSON, not a JSON array.
  @index_type "text/plain; charset=utf-8"

  @impl true
  def content_type(_filename), do: "application/x-tar"

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

  @impl true
  def index(repo, ctx)

  def index(repo, %{path: ["config.json"], base_url: base} = ctx) do
    config = %{
      "dl" => base <> "/api/v1/crates",
      "api" => base,
      # Only when it is actually true: setting it on a public registry
      # makes cargo demand a token for every fetch, including from
      # builds that have no business holding one.
      "auth-required" => repo.visibility != "public"
    }

    cached(ctx, @json, Jason.encode_to_iodata!(config))
  end

  def index(repo, %{path: ["api", "v1", "crates", name, version, "download"]}) do
    with %Package{} = package <- Packages.get_package(repo, @format, name),
         %PackageFile{} = file <-
           Packages.get_package_file(package, crate_filename(name, version)) do
      {:blob, file}
    else
      _ -> {:error, :not_found}
    end
  end

  def index(repo, %{path: ["api", "v1", "crates"], conn: conn} = ctx) do
    params = Plug.Conn.fetch_query_params(conn).query_params
    query = String.downcase(params["q"] || "")
    per_page = to_integer(params["per_page"], 10)

    crates =
      repo
      |> Packages.list_packages(@format)
      |> Enum.filter(&String.contains?(&1.normalized_name, query))
      |> Enum.take(per_page)
      |> Enum.map(fn package ->
        %{
          "name" => package.name,
          "max_version" => package.latest_version,
          "description" => description(package)
        }
      end)

    body = %{"crates" => crates, "meta" => %{"total" => length(crates)}}
    cached(ctx, @json, Jason.encode_to_iodata!(body))
  end

  def index(repo, %{path: ["api", "v1", "crates", name, "owners"]}) do
    repo = GitGud.Repo.preload(repo, :owner)

    owners = [
      %{
        "id" => repo.owner_id,
        "login" => owner_login(repo),
        "name" => owner_login(repo)
      }
    ]

    case Packages.get_package(repo, @format, name) do
      nil -> {:error, :not_found}
      %Package{} -> {:ok, @json, Jason.encode_to_iodata!(%{"users" => owners})}
    end
  end

  # Everything else is an index path, and the shard it arrived under
  # has to be the shard its name belongs to.
  def index(repo, %{path: path} = ctx) when path != [] do
    name = List.last(path)

    if index_path(name) == path do
      case Packages.get_package(repo, @format, name) do
        nil -> {:error, :not_found}
        %Package{} = package -> cached(ctx, @index_type, index_body(package))
      end
    else
      {:error, :not_found}
    end
  end

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

  @doc """
  The sparse-index path for a crate name, as segments.

      iex> index_path("a")      #=> ["1", "a"]
      iex> index_path("ab")     #=> ["2", "ab"]
      iex> index_path("abc")    #=> ["3", "a", "abc"]
      iex> index_path("cargo")  #=> ["ca", "rg", "cargo"]
  """
  def index_path(name) when is_binary(name) do
    lower = String.downcase(name)

    case String.length(lower) do
      0 -> []
      1 -> ["1", lower]
      2 -> ["2", lower]
      3 -> ["3", String.slice(lower, 0, 1), lower]
      _ -> [String.slice(lower, 0, 2), String.slice(lower, 2, 2), lower]
    end
  end

  # ── the index document ──────────────────────────────────────────────

  # One JSON object per version, newline-separated, oldest first.
  # Only `yanked` may ever change after a line is written; everything
  # else is frozen at publish, because cargo caches these by version.
  defp index_body(%Package{} = package) do
    package
    |> Packages.list_versions()
    |> Enum.sort_by(& &1.pushed_at, DateTime)
    |> Enum.map(&[Jason.encode_to_iodata!(index_entry(package, &1)), "\n"])
  end

  defp index_entry(%Package{} = package, %PackageVersion{} = version) do
    metadata = version.metadata || %{}
    {features, features2} = split_features(metadata["features"] || %{})

    entry = %{
      "name" => package.name,
      "vers" => version.version,
      "deps" => metadata["deps"] || [],
      "cksum" => metadata["cksum"],
      "features" => features,
      "yanked" => version.yanked,
      "links" => metadata["links"],
      # `v: 2` is what tells cargo to look at `features2` at all. Emit
      # it only when there is something there to look at, so older
      # cargo keeps reading crates that don't need the new syntax.
      "v" => if(features2 == %{}, do: 1, else: 2)
    }

    entry
    |> maybe_put("features2", if(features2 == %{}, do: nil, else: features2))
    |> maybe_put("rust_version", metadata["rust_version"])
  end

  @doc """
  Split a feature table into the pre-1.60 half and the half that needs
  `features2`.

  A feature belongs in `features2` if any of its values uses the
  `dep:name` or `pkg?/feat` syntax. Older cargo ignores `features2`
  entirely, which is the point: it sees a crate whose remaining
  features it can still understand rather than a parse error.
  """
  def split_features(features) when is_map(features) do
    Enum.reduce(features, {%{}, %{}}, fn {name, values}, {old, new} ->
      values = List.wrap(values)

      if Enum.any?(values, &new_feature_syntax?/1),
        do: {old, Map.put(new, name, values)},
        else: {Map.put(old, name, values), new}
    end)
  end

  def split_features(_), do: {%{}, %{}}

  defp new_feature_syntax?(value) when is_binary(value),
    do: String.starts_with?(value, "dep:") or String.contains?(value, "?/")

  defp new_feature_syntax?(_), do: false

  defp description(%Package{} = package) do
    case Packages.list_versions(package) do
      [%PackageVersion{metadata: %{"description" => description}} | _] -> description
      _ -> nil
    end
  end

  defp owner_login(repo) do
    case repo.owner do
      %{handle: handle} when is_binary(handle) and handle != "" -> handle
      %{email: email} when is_binary(email) -> email |> String.split("@") |> List.first()
      _ -> "owner"
    end
  end

  # ── conditional requests ────────────────────────────────────────────

  # The index is refetched constantly, so a 304 is the common case
  # rather than an optimisation. The body's own hash is the validator:
  # it changes exactly when the answer changes, which `updated_at`
  # timestamps across several rows do not.
  defp cached(ctx, content_type, body) do
    etag =
      ~s("#{:crypto.hash(:sha256, body) |> Base.encode16(case: :lower) |> binary_part(0, 32)}")

    if etag in if_none_match(ctx.conn) do
      :not_modified
    else
      {:ok, content_type, body, [{"etag", etag}, {"cache-control", "public, max-age=0"}]}
    end
  end

  defp if_none_match(conn) do
    conn
    |> Plug.Conn.get_req_header("if-none-match")
    |> Enum.flat_map(&String.split(&1, ","))
    |> Enum.map(&String.trim/1)
    # A revalidation that survived a proxy may come back weak.
    |> Enum.map(&String.replace_prefix(&1, "W/", ""))
  end

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

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

  def store(repo, %{method: "PUT", path: ["api", "v1", "crates", "new"]} = ctx, body)
      when is_binary(body) do
    with {:ok, metadata, crate} <- unframe(body),
         {:ok, name} <- fetch(metadata, "name"),
         {:ok, version} <- fetch(metadata, "vers") do
      attrs = %{
        filename: crate_filename(name, version),
        body: crate,
        content_type: content_type(name),
        version_metadata: version_metadata(metadata, crate),
        uploader: uploader(ctx)
      }

      case Packages.publish_file(repo, @format, name, version, attrs) do
        {:ok, _package, _version, _file} ->
          # Cargo prints whatever comes back under `warnings.other`,
          # and requires all three keys to be present.
          {:ok, @json,
           Jason.encode_to_iodata!(%{
             "warnings" => %{"invalid_categories" => [], "invalid_badges" => [], "other" => []}
           })}

        {:error, :file_exists} ->
          {:error, {409, "#{name} #{version} is already published"}}

        {:error, reason} ->
          {:error, {400, inspect(reason)}}
      end
    end
  end

  def store(
        repo,
        %{method: "DELETE", path: ["api", "v1", "crates", name, version, "yank"]},
        _body
      ),
      do: set_yanked(repo, name, version, true)

  def store(
        repo,
        %{method: "PUT", path: ["api", "v1", "crates", name, version, "unyank"]},
        _body
      ),
      do: set_yanked(repo, name, version, false)

  # Ownership lives in the forge's own permission model — a repository's
  # owner and organization members. Accepting a change here would make
  # cargo's view of who may publish diverge from the forge's, so the
  # endpoint answers truthfully instead of pretending.
  def store(_repo, %{method: method, path: ["api", "v1", "crates", _name, "owners"]}, _body)
      when method in ["PUT", "DELETE"] do
    {:error,
     {403, "crate ownership follows the repository's collaborators; change it in the forge"}}
  end

  def store(_repo, %{method: method, path: path}, _body),
    do: {:error, {404, "no #{method} endpoint at /#{Enum.join(path, "/")}"}}

  defp set_yanked(repo, name, version, yanked?) do
    with %Package{} = package <- Packages.get_package(repo, @format, name),
         %PackageVersion{} = row <- Packages.get_version(package, version),
         {:ok, _} <- Packages.set_yanked(row, yanked?) do
      {:ok, @json, Jason.encode_to_iodata!(%{"ok" => true})}
    else
      nil -> {:error, {404, "#{name} #{version} is not published here"}}
      {:error, reason} -> {:error, {400, inspect(reason)}}
    end
  end

  @doc """
  Split the publish body into its metadata and its `.crate`.

      u32 little-endian   metadata length
      <n bytes>           JSON metadata
      u32 little-endian   .crate length
      <n bytes>           the tarball
  """
  def unframe(body) do
    case body do
      <<meta_len::little-unsigned-32, rest::binary>> when byte_size(rest) >= meta_len ->
        <<meta_json::binary-size(^meta_len), rest::binary>> = rest

        case rest do
          <<crate_len::little-unsigned-32, crate::binary>> when byte_size(crate) >= crate_len ->
            <<crate::binary-size(^crate_len), _trailing::binary>> = crate
            decode_metadata(meta_json, crate)

          _ ->
            {:error, {400, "the publish body's crate length prefix is truncated"}}
        end

      _ ->
        {:error, {400, "the publish body's metadata length prefix is truncated"}}
    end
  end

  defp decode_metadata(json, crate) do
    case Jason.decode(json) do
      {:ok, metadata} when is_map(metadata) -> {:ok, metadata, crate}
      _ -> {:error, {400, "the publish body's metadata is not a JSON object"}}
    end
  end

  defp version_metadata(metadata, crate) do
    %{
      "cksum" => Base.encode16(:crypto.hash(:sha256, crate), case: :lower),
      "deps" => Enum.map(List.wrap(metadata["deps"]), &index_dependency/1),
      "features" => metadata["features"] || %{},
      "links" => metadata["links"],
      "rust_version" => rust_version(metadata["rust_version"]),
      "description" => metadata["description"],
      "documentation" => metadata["documentation"],
      "homepage" => metadata["homepage"],
      "repository" => metadata["repository"],
      "license" => metadata["license"],
      "keywords" => metadata["keywords"] || [],
      "categories" => metadata["categories"] || []
    }
  end

  # The publish payload and the index disagree on how to name a
  # dependency, and the translation is the whole of it: publish sends
  # the real crate in `name` with any rename in `explicit_name_in_toml`;
  # the index wants the *renamed* name in `name` and the real crate in
  # `package`. Getting this backwards resolves the wrong crate.
  defp index_dependency(dep) when is_map(dep) do
    real = dep["name"]
    renamed = blank_to_nil(dep["explicit_name_in_toml"])

    %{
      "name" => renamed || real,
      "package" => if(renamed, do: real),
      "req" => dep["version_req"] || dep["req"],
      "features" => dep["features"] || [],
      "optional" => dep["optional"] || false,
      "default_features" => Map.get(dep, "default_features", true),
      "target" => dep["target"],
      "kind" => dep["kind"] || "normal",
      "registry" => dep["registry"]
    }
  end

  defp index_dependency(_), do: %{}

  # `rust_version` in the index is a bare version, never a requirement —
  # `1.60`, not `=1.60`. Cargo compares it directly.
  defp rust_version(nil), do: nil

  defp rust_version(value) when is_binary(value) do
    case String.trim_leading(value, "=") |> String.trim() do
      "" -> nil
      trimmed -> String.replace_leading(trimmed, "^", "")
    end
  end

  defp rust_version(_), do: nil

  # ── helpers ─────────────────────────────────────────────────────────

  defp crate_filename(name, version), do: "#{name}-#{version}.crate"

  defp fetch(metadata, key) do
    case metadata[key] do
      value when is_binary(value) and value != "" -> {:ok, value}
      _ -> {:error, {400, "the publish metadata has no `#{key}`"}}
    end
  end

  defp maybe_put(map, _key, nil), do: map
  defp maybe_put(map, key, value), do: Map.put(map, key, value)

  defp blank_to_nil(value) when is_binary(value) do
    case String.trim(value) do
      "" -> nil
      trimmed -> trimmed
    end
  end

  defp blank_to_nil(_), do: nil

  defp to_integer(value, default) when is_binary(value) do
    case Integer.parse(value) do
      {n, _} when n > 0 -> min(n, 100)
      _ -> default
    end
  end

  defp to_integer(_, default), do: default

  defp uploader(%{principal: {:user, user}}), do: user
  defp uploader(_ctx), do: nil
end