defmodule GitGud.Packages.Formats.Rpm.Metadata do
@moduledoc """
The rpm-md XML documents, and the summary of a package they are built
from.
`summarize/1` runs once, at upload, and reduces a parsed
`Rpm.Header` to the fields the three index documents need. That
summary is what gets stored on the `PackageFile`, so serving the
index never touches the `.rpm` blobs — the same trade the apt
repository makes by storing the control stanza.
The rest of this module renders:
* `primary.xml` — what dnf reads to resolve a transaction: names,
versions, dependencies, and the subset of files that anything
depends on by path.
* `filelists.xml` — every file in every package. Fetched only when
a dependency is on a path `primary.xml` filtered out.
* `other.xml` — changelogs. Emitted empty: RPM stores them in the
header and nothing here reads them yet, but dnf expects the
document to exist and a repository without it looks corrupt
rather than sparse.
## Why the file list is split across two documents
It is the whole reason rpm-md has three files instead of one.
`filelists.xml` for a full distribution is enormous, and almost every
dependency on a path is on something under `bin/` or `/etc`. So
createrepo puts those few in `primary.xml` and the rest in a document
most clients never fetch. The filter here is the same one createrepo
uses, because a client that can't find `/usr/bin/foo` in `primary`
and doesn't know to look further would resolve differently against
our repository than against a stock one.
"""
import Bitwise
alias GitGud.Packages.Formats.Rpm.Header
# rpmsenseFlags, the comparison bits of them.
@sense_less 0x02
@sense_greater 0x04
@sense_equal 0x08
# A dependency rpm itself satisfies — `rpmlib(FileDigests)` and
# friends. They appear in every package built this decade and mean
# nothing to a repository, and createrepo drops them, so a client
# comparing our index with a mirror's sees the same list.
@sense_rpmlib 0x1000000
# Flags that make a requirement an install-time prerequisite.
@sense_prereq 0x6E0
# S_IFDIR and S_IFLNK, for the `type` attribute on a file entry.
@s_ifmt 0xF000
@s_ifdir 0x4000
@doc """
Reduce a parsed header to the map stored on the `PackageFile`.
Keys are strings because this round-trips through JSON on its way to
and from the database, and a map that changes shape depending on
whether it was just parsed or just loaded is a bug waiting to be
written.
"""
@spec summarize(Header.t()) :: map()
def summarize(header) do
%{
"name" => Header.get(header, :name),
"epoch" => Header.get(header, :epoch),
"version" => Header.get(header, :version),
"release" => Header.get(header, :release),
"arch" => Header.get(header, :arch),
"summary" => Header.get(header, :summary),
"description" => Header.get(header, :description),
"license" => Header.get(header, :license),
"vendor" => Header.get(header, :vendor),
"group" => Header.get(header, :group),
"buildhost" => Header.get(header, :buildhost),
"sourcerpm" => Header.get(header, :sourcerpm),
"url" => Header.get(header, :url),
"packager" => Header.get(header, :packager),
"buildtime" => Header.get(header, :buildtime),
"installed_size" => Header.get(header, :size),
"archive_size" => Header.get(header, :archivesize),
"header_start" => header.header_start,
"header_end" => header.header_end,
"provides" => deps(header, :providename, :provideflags, :provideversion),
"requires" => header |> deps(:requirename, :requireflags, :requireversion) |> drop_rpmlib(),
"conflicts" => deps(header, :conflictname, :conflictflags, :conflictversion),
"obsoletes" => deps(header, :obsoletename, :obsoleteflags, :obsoleteversion),
"files" => files(header)
}
end
defp deps(header, name_tag, flags_tag, version_tag) do
names = Header.list(header, name_tag)
flags = Header.list(header, flags_tag)
versions = Header.list(header, version_tag)
names
|> Enum.with_index()
|> Enum.map(fn {name, index} ->
%{
"name" => name,
"flags" => Enum.at(flags, index, 0),
"version" => Enum.at(versions, index, "")
}
end)
|> Enum.uniq()
end
defp drop_rpmlib(deps),
do: Enum.reject(deps, &((&1["flags"] &&& @sense_rpmlib) != 0))
defp files(header) do
modes = Header.list(header, :filemodes)
header
|> Header.files()
|> Enum.with_index()
|> Enum.map(fn {path, index} ->
mode = Enum.at(modes, index, 0)
%{"path" => path, "dir" => (mode &&& @s_ifmt) == @s_ifdir}
end)
end
@doc """
Whether a file belongs in `primary.xml` as well as `filelists.xml`.
createrepo's filter, unchanged: anything under a `bin/` directory,
anything in `/etc`, and sendmail. Diverging from it would make our
repository resolve differently from a stock one for the same
packages, which is a worse failure than a large index.
"""
@spec primary_file?(String.t()) :: boolean()
def primary_file?(path) do
String.contains?(path, "bin/") or String.starts_with?(path, "/etc/") or
path == "/usr/lib/sendmail"
end
# ── the documents ───────────────────────────────────────────────────
@doc """
`primary.xml`, over `packages` — each a `%{meta:, file:, href:}`.
"""
def primary(packages) do
[
~s(<?xml version="1.0" encoding="UTF-8"?>\n),
~s(<metadata xmlns="http://linux.duke.edu/metadata/common" ),
~s(xmlns:rpm="http://linux.duke.edu/metadata/rpm" packages="#{length(packages)}">\n),
Enum.map(packages, &primary_package/1),
"</metadata>\n"
]
end
defp primary_package(%{meta: meta, file: file, href: href}) do
[
~s(<package type="rpm">\n),
element("name", meta["name"]),
element("arch", meta["arch"]),
version_element(meta),
~s( <checksum type="sha256" pkgid="YES">#{file.sha256}</checksum>\n),
element("summary", meta["summary"]),
element("description", meta["description"]),
element("packager", meta["packager"]),
element("url", meta["url"]),
~s( <time file="#{file_time(file)}" build="#{meta["buildtime"] || 0}"/>\n),
~s( <size package="#{file.size}" installed="#{meta["installed_size"] || 0}" ),
~s(archive="#{meta["archive_size"] || 0}"/>\n),
~s( <location href="#{esc(href)}"/>\n),
" <format>\n",
rpm_element("license", meta["license"]),
rpm_element("vendor", meta["vendor"]),
rpm_element("group", meta["group"]),
rpm_element("buildhost", meta["buildhost"]),
rpm_element("sourcerpm", meta["sourcerpm"]),
~s( <rpm:header-range start="#{meta["header_start"]}" end="#{meta["header_end"]}"/>\n),
entries("provides", meta["provides"]),
entries("requires", meta["requires"]),
entries("conflicts", meta["conflicts"]),
entries("obsoletes", meta["obsoletes"]),
meta["files"]
|> List.wrap()
|> Enum.filter(&primary_file?(&1["path"]))
|> Enum.map(&file_element(&1, " ")),
" </format>\n</package>\n"
]
end
@doc "`filelists.xml`, over the same package list."
def filelists(packages) do
[
~s(<?xml version="1.0" encoding="UTF-8"?>\n),
~s(<filelists xmlns="http://linux.duke.edu/metadata/filelists" ),
~s(packages="#{length(packages)}">\n),
Enum.map(packages, fn %{meta: meta, file: file} ->
[
package_open("package", meta, file),
version_element(meta),
meta["files"] |> List.wrap() |> Enum.map(&file_element(&1, " ")),
"</package>\n"
]
end),
"</filelists>\n"
]
end
@doc """
`other.xml` — the package identities with no changelog under them.
An empty `<package>` is what createrepo emits for a package whose
changelog it was told to skip, so this is a sparse document rather
than a malformed one.
"""
def other(packages) do
[
~s(<?xml version="1.0" encoding="UTF-8"?>\n),
~s(<otherdata xmlns="http://linux.duke.edu/metadata/other" ),
~s(packages="#{length(packages)}">\n),
Enum.map(packages, fn %{meta: meta, file: file} ->
[package_open("package", meta, file), version_element(meta), "</package>\n"]
end),
"</otherdata>\n"
]
end
# ── XML bits ────────────────────────────────────────────────────────
defp package_open(tag, meta, file) do
~s(<#{tag} pkgid="#{file.sha256}" name="#{esc(meta["name"])}" ) <>
~s(arch="#{esc(meta["arch"])}">\n)
end
# Epoch is "0" rather than absent when a package doesn't declare one:
# RPM's comparison treats a missing epoch as zero, and leaving the
# attribute out makes some clients treat it as unset instead.
defp version_element(meta) do
~s( <version epoch="#{meta["epoch"] || 0}" ver="#{esc(meta["version"])}" ) <>
~s(rel="#{esc(meta["release"])}"/>\n)
end
defp file_element(%{"path" => path, "dir" => true}, indent),
do: [indent, ~s(<file type="dir">), esc(path), "</file>\n"]
defp file_element(%{"path" => path}, indent),
do: [indent, "<file>", esc(path), "</file>\n"]
defp entries(_kind, nil), do: []
defp entries(_kind, []), do: []
defp entries(kind, deps) do
[
" <rpm:#{kind}>\n",
Enum.map(deps, &entry/1),
" </rpm:#{kind}>\n"
]
end
defp entry(%{"name" => name, "flags" => flags, "version" => version}) do
[
~s( <rpm:entry name="),
esc(name),
"\"",
flag_attributes(flags, version),
pre_attribute(flags),
"/>\n"
]
end
# A dependency with no comparison bits is unversioned, and carrying a
# version on it anyway is how `Requires: foo` turns into
# `Requires: foo = 1.0` in a client's view.
defp flag_attributes(flags, version) do
case comparison(flags) do
nil ->
[]
operator ->
{epoch, ver, rel} = split_evr(version)
[
~s( flags="#{operator}" epoch="#{epoch}" ver="#{esc(ver)}"),
if(rel in [nil, ""], do: [], else: ~s( rel="#{esc(rel)}"))
]
end
end
defp comparison(flags) do
case flags &&& (@sense_less ||| @sense_greater ||| @sense_equal) do
@sense_equal -> "EQ"
@sense_less -> "LT"
@sense_greater -> "GT"
0x0A -> "LE"
0x0C -> "GE"
_ -> nil
end
end
defp pre_attribute(flags) when (flags &&& @sense_prereq) != 0, do: ~s( pre="1")
defp pre_attribute(_flags), do: []
# RPM stores a dependency's version as the `[epoch:]version[-release]`
# string a spec file wrote, and rpm-md wants the three parts apart.
defp split_evr(version) do
{epoch, rest} =
case String.split(version || "", ":", parts: 2) do
[epoch, rest] -> {epoch, rest}
[rest] -> {"0", rest}
end
case String.split(rest, "-", parts: 2) do
[ver, rel] -> {epoch, ver, rel}
[ver] -> {epoch, ver, nil}
end
end
defp element(_name, value) when value in [nil, ""], do: []
defp element(name, value), do: [" <", name, ">", esc(value), "</", name, ">\n"]
defp rpm_element(_name, value) when value in [nil, ""], do: []
defp rpm_element(name, value),
do: [" <rpm:", name, ">", esc(value), "</rpm:", name, ">\n"]
defp file_time(%{inserted_at: %DateTime{} = at}), do: DateTime.to_unix(at)
defp file_time(%{inserted_at: %NaiveDateTime{} = at}),
do: at |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix()
defp file_time(_), do: 0
defp esc(value) do
value
|> to_string()
|> String.replace("&", "&")
|> String.replace("<", "<")
|> String.replace(">", ">")
|> String.replace("\"", """)
# RPM descriptions are free text and occasionally carry control
# bytes an XML 1.0 parser rejects outright. Dropping them is the
# only option that still produces a parseable document.
|> String.replace(~r/[\x00-\x08\x0b\x0c\x0e-\x1f]/, "")
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
12.0 KiB · text
History
b868907