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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
16.4 KiB · text
History
173e2a6