defmodule GitGud.Packages.Formats.Hex do
@moduledoc """
A Hex registry v2 repository, per git repository.
mix hex.repo add <name> https://<host>/pkg/hex/<owner>/<repo> \\
--public-key=<(curl -s https://<host>/pkg/hex/<owner>/<repo>/public_key)
# then, in deps:
{:internal_thing, "~> 1.0", repo: "<name>"}
Paths below the repository root:
/names every package, signed
/versions every version of every package, signed
/packages/<NAME> one package's releases, signed
/tarballs/<NAME>-<VERSION>.tar the published tarball
/public_key the key that verifies the above
/publish POST a tarball here
## Building the registry rather than rebuilding it
The documented way to self-host is `mix hex.registry build`, which
regenerates the whole directory from a folder of tarballs. That makes
publishing a rebuild, and a rebuild is O(everything) — so this
encodes the protobufs on request instead, straight out of Postgres.
Publishing becomes an insert.
## The signature
Every resource is a `Signed{payload, signature}`, and the signature
is an RSA signature over the **SHA-512 digest of the payload**, both
unencoded. Then the encoded `Signed` is gzipped — and gzipped as
*content*, not as a transfer encoding: `mix` gunzips the body itself,
so a `Content-Encoding: gzip` header would have the HTTP client
decompress it first and leave `mix` with something it can't read.
## Repository name
`mix` refuses a payload whose `repository` field doesn't match the
name the repo was added under, so the name isn't cosmetic. It is
derived here (`<owner>_<repo>`), and the packages page prints the
exact `mix hex.repo add` line to paste.
"""
@behaviour GitGud.Packages.Format
alias GitGud.Packages
alias GitGud.Packages.Formats.Hex.Pb
alias GitGud.Packages.Formats.Hex.Tarball
alias GitGud.Packages.Package
alias GitGud.Packages.PackageFile
alias GitGud.Repositories.Repository
alias GitGud.Repositories.Storage
@format "hex"
@signed "application/octet-stream"
@doc """
The name `mix hex.repo add` must be given for this repository.
Underscore-joined rather than hyphen-joined because repository names
routinely contain hyphens, and `acme-web-utils` shouldn't be
ambiguous about where the owner stops.
"""
def repo_name(%Repository{} = repo) do
repo = GitGud.Repo.preload(repo, [:owner, :organization])
"#{Storage.repo_handle(repo)}_#{repo.name}"
end
@impl true
def content_type(_filename), do: "application/octet-stream"
# ── reads ───────────────────────────────────────────────────────────
@impl true
def index(repo, ctx)
def index(repo, %{path: ["public_key"]}) do
with {:ok, %{public_pem: pem}} <- Packages.signing_key(repo, @format) do
{:ok, "text/plain; charset=utf-8", pem}
end
end
def index(repo, %{path: ["names"]}) do
packages =
repo
|> Packages.list_packages(@format)
|> Enum.map(&%Pb.Names.Package{name: &1.name})
sign(repo, Pb.Names, %Pb.Names{repository: repo_name(repo), packages: packages})
end
def index(repo, %{path: ["versions"]}) do
packages =
repo
|> Packages.list_packages(@format)
|> Enum.map(fn package ->
releases = sorted_versions(package)
%Pb.Versions.Package{
name: package.name,
versions: Enum.map(releases, & &1.version),
# Indexes into `versions`, not version strings — the spec
# keeps the list positional.
retired: retired_indexes(releases)
}
end)
sign(repo, Pb.Versions, %Pb.Versions{repository: repo_name(repo), packages: packages})
end
def index(repo, %{path: ["packages", name]}) do
case Packages.get_package(repo, @format, name) do
nil ->
{:error, :not_found}
%Package{} = package ->
releases = package |> sorted_versions() |> Enum.map(&release(&1))
sign(repo, Pb.Package, %Pb.Package{
repository: repo_name(repo),
name: package.name,
releases: releases
})
end
end
def index(repo, %{path: ["tarballs", filename]}) do
case find_tarball(repo, filename) do
%PackageFile{} = file -> {:blob, file}
nil -> {:error, :not_found}
end
end
def index(_repo, _ctx), do: {:error, :not_found}
defp find_tarball(repo, filename) do
repo
|> Packages.list_packages(@format)
|> Enum.find_value(fn package -> Packages.get_package_file(package, filename) end)
end
# Hex resolves against this list directly, so ordering is the
# registry's job. Unparseable versions sort last by string rather
# than crashing the whole index.
defp sorted_versions(%Package{} = package) do
package
|> Packages.list_versions()
|> Enum.sort(fn a, b ->
case {Version.parse(a.version), Version.parse(b.version)} do
{{:ok, va}, {:ok, vb}} -> Version.compare(va, vb) != :gt
{{:ok, _}, :error} -> true
{:error, {:ok, _}} -> false
_ -> a.version <= b.version
end
end)
end
defp retired_indexes(releases) do
releases
|> Enum.with_index()
|> Enum.filter(fn {release, _index} -> release.yanked end)
|> Enum.map(fn {_release, index} -> index end)
end
defp release(version) do
metadata = version.metadata || %{}
%Pb.Release{
version: version.version,
inner_checksum: decode_checksum(metadata["inner_checksum"]),
outer_checksum: decode_checksum(metadata["outer_checksum"]),
dependencies: Enum.map(metadata["dependencies"] || [], &dependency/1),
retired: retirement(version)
}
end
# Checksums travel as hex in the database and as raw bytes on the
# wire.
defp decode_checksum(nil), do: <<>>
defp decode_checksum(hex) when is_binary(hex) do
case Base.decode16(hex, case: :mixed) do
{:ok, raw} -> raw
:error -> <<>>
end
end
defp dependency(dep) when is_map(dep) do
%Pb.Dependency{
package: dep["package"] || dep["name"] || "",
requirement: dep["requirement"] || "",
optional: dep["optional"] || false,
app: dep["app"],
repository: dep["repository"]
}
end
# Retirement is Hex's yank: the release stays resolvable and `mix`
# warns. We only ever record RETIRED_OTHER — the finer reasons are a
# publisher's claim, and nothing in our publish path establishes one.
defp retirement(%{yanked: true} = version),
do: %Pb.RetirementStatus{reason: :RETIRED_OTHER, message: version.yanked_reason}
defp retirement(_version), do: nil
# ── signing ─────────────────────────────────────────────────────────
defp sign(repo, module, message) do
with {:ok, %{private_pem: pem}} <- Packages.signing_key(repo, @format) do
payload = module.encode(message)
signed =
Pb.Signed.encode(%Pb.Signed{
payload: payload,
signature: rsa_sign(payload, pem)
})
{:ok, @signed, :zlib.gzip(signed)}
end
end
# "An RSA signature over the SHA-512 digest of the payload, both
# unencoded" — which is exactly what :public_key.sign/3 computes,
# digest included.
defp rsa_sign(payload, private_pem) do
[entry] = :public_key.pem_decode(private_pem)
:public_key.sign(payload, :sha512, :public_key.pem_entry_decode(entry))
end
@doc """
Verify a signed resource against a public key PEM. Not used to serve
anything — it exists so the tests can check what a client will check,
rather than checking that we called our own signer.
"""
def verify(signed_gzip, public_pem) do
%Pb.Signed{payload: payload, signature: signature} =
signed_gzip |> :zlib.gunzip() |> Pb.Signed.decode()
[entry] = :public_key.pem_decode(public_pem)
{payload,
:public_key.verify(payload, :sha512, signature, :public_key.pem_entry_decode(entry))}
end
# ── publishing ──────────────────────────────────────────────────────
@impl true
def store(repo, ctx, body)
def store(repo, %{method: "POST", path: path} = ctx, body)
when is_binary(body) and (path == ["publish"] or path == []) do
with {:ok, meta} <- Tarball.parse(body) do
filename = "#{meta.name}-#{meta.version}.tar"
attrs = %{
filename: filename,
body: body,
content_type: content_type(filename),
metadata: %{"inner_checksum" => meta.inner_checksum},
version_metadata: %{
"inner_checksum" => meta.inner_checksum,
"outer_checksum" => Base.encode16(:crypto.hash(:sha256, body), case: :lower),
"dependencies" => meta.dependencies,
"app" => meta.app,
"description" => meta.description
},
uploader: uploader(ctx)
}
case Packages.publish_file(repo, @format, meta.name, meta.version, attrs) do
{:ok, _package, _version, _file} ->
{:ok, "application/json",
Jason.encode_to_iodata!(%{"name" => meta.name, "version" => meta.version})}
{:error, :file_exists} ->
{:error, :file_exists}
{:error, reason} ->
{:error, {400, inspect(reason)}}
end
end
end
def store(_repo, %{method: "POST"}, _body),
do: {:error, {400, "POST the raw Hex tarball to /publish"}}
def store(_repo, %{method: method}, _body),
do: {:error, {405, "#{method} is not supported by the Hex registry"}}
defp uploader(%{principal: {:user, user, _scopes}}), 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.
9.7 KiB · text
History
95e9713