defmodule GitGud.Packages.Formats.Rpm do
@moduledoc """
An rpm-md repository, per git repository *and* per namespace.
# everything acme publishes — one repo file, one key
[acme]
name=acme
baseurl=https://<host>/pkg/rpm/acme/_all
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://<host>/pkg/_keys/rpm/acme
# or one repository on its own
baseurl=https://<host>/pkg/rpm/acme/tools
The shape is the apt repository's: static documents over HTTP with
one detached OpenPGP signature over the index, generated per request
from what the caller may read. `_all` aggregates the namespace for
the same reason it does there — an administrator thinks in
publishers, not repositories.
Paths below either root:
/repodata/repomd.xml the index of indices, and what dnf fetches first
/repodata/repomd.xml.asc detached signature over it — repo_gpgcheck=1
/repodata/repomd.xml.key the public key, for gpgkey=
/repodata/primary.xml.gz names, versions, dependencies
/repodata/filelists.xml.gz every file in every package
/repodata/other.xml.gz changelogs (empty)
/Packages/<repo>/<p>/<file>.rpm
/upload POST an .rpm (per repo)
## Why the whole index is regenerated per request
Because the caller's visibility is part of the answer. A token that
can read one private repository and a stranger who can read none must
get different — and separately correct — indices from the same URL,
and a cached document has no caller.
The cost is bounded by what is stored at upload: `Rpm.Metadata`
summarises each package's header once, so building `primary.xml`
reads database rows, never `.rpm` blobs.
## repomd.xml must agree with itself
`repomd.xml` carries a checksum, a size and a timestamp for each of
the three documents, and dnf rejects a repository where they don't
match what it downloads. So nothing in the generated documents may
depend on the clock: the repository's `revision` and every
`timestamp` is the newest publish time in it, not `now`, and gzip is
used in the mode that writes a zero mtime. The same mistake sank the
first F-Droid index, which pinned a hash it then regenerated.
## Signing
`repomd.xml.asc` is a detached OpenPGP signature, which is exactly
what `Release.gpg` is — same NIF, same per-namespace key, and a
namespace that already publishes debs signs its RPMs with the key
its users have imported.
Signing individual packages is a different job and deliberately not
done here: `gpgcheck=1` verifies a signature baked into the `.rpm` at
build time, by whoever built it. The forge signs what the forge
produces, which is the index.
"""
@behaviour GitGud.Packages.Format
alias GitGud.Packages
alias GitGud.Packages.Apt.Native
alias GitGud.Packages.Formats.Listing
alias GitGud.Packages.Formats.Rpm.Header
alias GitGud.Packages.Formats.Rpm.Metadata
alias GitGud.Packages.PackageFile
alias GitGud.Repositories.Repository
alias GitGud.Repositories.Storage
@format "rpm"
# The three documents, in the order createrepo writes them into
# repomd.xml. dnf doesn't care about the order; a human diffing our
# repomd.xml against a mirror's does.
@documents [:primary, :filelists, :other]
@impl true
def content_type(_filename), do: "application/x-rpm"
# ── reads ───────────────────────────────────────────────────────────
@impl true
def index(repo, ctx), do: serve([repo], repo_label(repo), repo, ctx)
@doc """
The namespace-wide repository at `_all`, built from every repository
in the namespace the caller may read and signed with the namespace's
key — the same one that signs each repository on its own, so one
`gpgkey=` covers both ways of consuming it.
"""
@impl true
def namespace_index(namespace, repos, ctx),
do: serve(repos, Storage.owner_handle(namespace), namespace, ctx)
defp serve(repos, label, signing_scope, ctx)
defp serve(_repos, _label, scope, %{path: ["repodata", "repomd.xml.key"]}) do
with {:ok, %{public_pem: armored}} <- Packages.signing_key(scope, @format) do
{:ok, "application/pgp-keys", armored}
end
end
defp serve(repos, label, _scope, %{path: ["repodata", "repomd.xml"]}),
do: {:ok, "application/xml", repomd(repos, label)}
defp serve(repos, label, scope, %{path: ["repodata", "repomd.xml.asc"]}) do
with {:ok, %{private_pem: secret}} <- Packages.signing_key(scope, @format),
{:ok, signature} <-
Native.sign_detached(secret, IO.iodata_to_binary(repomd(repos, label))) do
{:ok, "application/pgp-signature", signature}
else
{:error, reason} -> {:error, {500, "could not sign repomd.xml: #{inspect(reason)}"}}
end
end
defp serve(repos, _label, _scope, %{path: ["repodata", name]}) do
case document_for(name) do
{kind, :gzip} -> {:ok, "application/x-gzip", gzip(render(repos, kind))}
{kind, :plain} -> {:ok, "application/xml", render(repos, kind)}
:none -> {:error, :not_found}
end
end
defp serve(repos, label, _scope, %{path: ["Packages" | _] = path} = ctx) do
# Matched on the whole path rather than the filename: two
# repositories in one namespace may both publish
# `koco-0.1.2-1.x86_64.rpm`, and serving the wrong one would be a
# silent substitution rather than an error.
wanted = Enum.join(path, "/")
repos
|> rpms()
|> Enum.find(&(&1.href == wanted))
|> case do
%{file: %PackageFile{} = file} -> {:blob, file}
nil -> listing(repos, label, ctx)
end
end
defp serve(repos, label, _scope, ctx), do: listing(repos, label, ctx)
defp document_for("primary.xml.gz"), do: {:primary, :gzip}
defp document_for("filelists.xml.gz"), do: {:filelists, :gzip}
defp document_for("other.xml.gz"), do: {:other, :gzip}
defp document_for("primary.xml"), do: {:primary, :plain}
defp document_for("filelists.xml"), do: {:filelists, :plain}
defp document_for("other.xml"), do: {:other, :plain}
defp document_for(_), do: :none
defp render(repos, kind), do: render_packages(rpms(repos), kind)
defp render_packages(packages, :primary), do: Metadata.primary(packages)
defp render_packages(packages, :filelists), do: Metadata.filelists(packages)
defp render_packages(packages, :other), do: Metadata.other(packages)
# `:zlib.gzip/1` writes a zero mtime, which is the only reason this
# can be called twice — once to checksum into repomd.xml, once to
# serve — and produce the same bytes both times.
defp gzip(iodata), do: :zlib.gzip(IO.iodata_to_binary(iodata))
# ── repomd.xml ──────────────────────────────────────────────────────
defp repomd(repos, label) do
packages = rpms(repos)
revision = revision(packages)
[
~s(<?xml version="1.0" encoding="UTF-8"?>\n),
~s(<repomd xmlns="http://linux.duke.edu/metadata/repo" ),
~s(xmlns:rpm="http://linux.duke.edu/metadata/rpm">\n),
" <!-- ",
esc(label),
" -->\n",
" <revision>",
Integer.to_string(revision),
"</revision>\n",
Enum.map(@documents, &repomd_data(&1, packages, revision)),
"</repomd>\n"
]
end
defp repomd_data(kind, packages, revision) do
plain = IO.iodata_to_binary(render_packages(packages, kind))
compressed = :zlib.gzip(plain)
[
~s( <data type="#{kind}">\n),
~s( <checksum type="sha256">#{digest(compressed)}</checksum>\n),
~s( <open-checksum type="sha256">#{digest(plain)}</open-checksum>\n),
~s( <location href="repodata/#{kind}.xml.gz"/>\n),
~s( <timestamp>#{revision}</timestamp>\n),
~s( <size>#{byte_size(compressed)}</size>\n),
~s( <open-size>#{byte_size(plain)}</open-size>\n),
" </data>\n"
]
end
defp digest(bytes), do: :sha256 |> :crypto.hash(bytes) |> Base.encode16(case: :lower)
# The newest publish in the repository, or zero for an empty one.
# Never `now`: dnf caches on the revision, so a clock-derived one
# invalidates every client's metadata on every fetch, and — worse —
# the checksums pinned beside it would describe a document generated
# a moment earlier.
defp revision([]), do: 0
defp revision(packages) do
packages
|> Enum.map(&unix(&1.file.inserted_at))
|> Enum.max(fn -> 0 end)
end
defp unix(%DateTime{} = at), do: DateTime.to_unix(at)
defp unix(%NaiveDateTime{} = at),
do: at |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix()
defp unix(_), do: 0
# ── the packages ────────────────────────────────────────────────────
# Every .rpm across `repos`, with the summary stored at upload and
# the path it is served under. One query per package rather than a
# join is an N+1, but a namespace's package list is small and this
# keeps the substrate's API unchanged.
defp rpms(repos) do
Enum.flat_map(repos, fn %Repository{} = repo ->
repo
|> Packages.list_packages(@format)
|> Enum.flat_map(&Packages.list_package_files/1)
|> Enum.map(fn %PackageFile{} = file ->
meta = (file.metadata || %{})["rpm"] || %{}
%{file: file, meta: meta, repo_name: repo.name, href: href(repo.name, meta, file)}
end)
end)
|> Enum.sort_by(& &1.href)
end
@doc """
Where a package is served, relative to the repository root.
Qualified by the source repository, which a stock rpm-md tree is not.
It has to be: a namespace repository aggregates several, and two of
them publishing `koco-0.1.2-1.x86_64.rpm` would otherwise be one path
with two sets of bytes. `location href` is opaque to dnf, and
qualifying it in *both* views means the per-repository and namespace
indices name the same file identically — a client that switches
between them re-downloads nothing.
"""
def href(repo_name, meta, %PackageFile{} = file) do
initial = meta["name"] |> to_string() |> String.first() |> Kernel.||("_")
Path.join(["Packages", repo_name, String.downcase(initial), file.filename])
end
defp listing(repos, label, ctx) do
packages = rpms(repos)
paths =
Enum.map(
~w(repomd.xml repomd.xml.asc repomd.xml.key primary.xml.gz filelists.xml.gz other.xml.gz),
&%{path: "repodata/#{&1}", size: nil, at: nil}
) ++
Enum.map(
packages,
&%{path: &1.href, size: &1.file.size, at: &1.file.inserted_at}
)
Listing.serve(paths, label, "RPM repository · dnf reads this tree; you can browse it.", ctx)
end
defp repo_label(%Repository{} = repo) do
repo = GitGud.Repo.preload(repo, [:owner, :organization])
"#{Storage.repo_handle(repo)}/#{repo.name}"
end
defp esc(value) do
value |> to_string() |> String.replace("&", "&") |> String.replace("<", "<")
end
# ── uploads ─────────────────────────────────────────────────────────
@impl true
def store(repo, ctx, body)
def store(repo, %{method: "POST", path: path} = ctx, body)
when is_binary(body) and (path == ["upload"] or path == []) do
with {:ok, header} <- Header.parse(body) do
meta = Metadata.summarize(header)
name = meta["name"]
version = evr(meta)
attrs = %{
filename: Header.filename(header),
body: body,
content_type: content_type(name),
# Namespaced under "rpm" so the summary can grow without
# colliding with anything the substrate later puts here.
metadata: %{"rpm" => meta},
version_metadata: %{"rpm" => Map.drop(meta, ["files"])},
uploader: uploader(ctx)
}
case Packages.publish_file(repo, @format, name, version, attrs) do
{:ok, _package, _version, _file} ->
{:ok, "application/json",
Jason.encode_to_iodata!(%{
"package" => name,
"version" => version,
"arch" => meta["arch"],
"filename" => attrs.filename
})}
{: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 .rpm to /upload"}}
def store(_repo, %{method: method}, _body),
do: {:error, {405, "#{method} is not supported by the RPM repository"}}
# The version a package is filed under. `version-release`, with the
# epoch only when there is one — which is how RPM itself prints an
# EVR, and means the common case reads as the upstream version rather
# than as `0:1.2.3-1`.
defp evr(%{"epoch" => epoch} = meta) when is_integer(epoch) and epoch > 0,
do: "#{epoch}:#{meta["version"]}-#{meta["release"]}"
defp evr(meta), do: "#{meta["version"]}-#{meta["release"]}"
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.
13.0 KiB · text
History
ff5c190