defmodule GitGud.Packages.Formats.Rpm.Header do
@moduledoc """
Reads the metadata headers out of an `.rpm`.
An RPM file is four things back to back:
lead 96 bytes, a fixed struct kept only for `file`
signature header digests and signatures over what follows
(padding) to an 8-byte boundary
main header everything the repository index needs
payload a compressed cpio archive
Both headers use the same structure: an 8-byte magic and version, a
count of index entries, the byte size of the data store, then the
entries — `{tag, type, offset, count}`, four big-endian words each —
followed by the store the offsets point into.
That is the whole format. It is parsed here rather than shelled out
to `rpm` for the same reason `Deb.Control` parses `ar` itself: the
runtime image has no rpm tooling in it, and adding some would be a
deployment change wearing a dependency's clothes.
## What is deliberately not read
The payload, for the index. `primary.xml` and `filelists.xml` are built entirely
from header tags — even the file list, which RPM stores as
`DIRNAMES` / `DIRINDEXES` / `BASENAMES` in the header precisely so a
repository can be indexed without decompressing anything. So a 200 MB
package is parsed by reading its first few hundred kilobytes.
The signature header, beyond skipping it. Verifying a package's own
OpenPGP signature is a client-side concern — `rpm --checksig` against
a key the administrator imported — and the repository's job is to
sign `repomd.xml`, which it does.
"""
@lead_magic <<0xED, 0xAB, 0xEE, 0xDB>>
@header_magic <<0x8E, 0xAD, 0xE8, 0x01>>
@lead_size 96
# Tag numbers, from rpm's `rpmtag.h`. Only the ones an index needs.
# The signature header numbers its tags in the same low range as the
# main header and means entirely different things by them — 1007 is
# the payload size here and the build host there. So the two get
# separate tables; one shared table reads a size as a hostname.
@signature_tags %{
271 => :longarchivesize,
1007 => :archivesize
}
@tags %{
1000 => :name,
1001 => :version,
1002 => :release,
1003 => :epoch,
1004 => :summary,
1005 => :description,
1006 => :buildtime,
1007 => :buildhost,
1009 => :size,
1011 => :vendor,
1014 => :license,
1015 => :packager,
1016 => :group,
1020 => :url,
1021 => :os,
1022 => :arch,
1030 => :filemodes,
1044 => :sourcerpm,
1046 => :archivesize,
1047 => :providename,
1048 => :requireflags,
1049 => :requirename,
1050 => :requireversion,
1053 => :conflictflags,
1054 => :conflictname,
1055 => :conflictversion,
1090 => :obsoletename,
1112 => :provideflags,
1113 => :provideversion,
1114 => :obsoleteflags,
1115 => :obsoleteversion,
1116 => :dirindexes,
1117 => :basenames,
1118 => :dirnames,
1125 => :payloadcompressor
}
@type t :: %{
tags: %{atom() => term()},
header_start: non_neg_integer(),
header_end: non_neg_integer()
}
@doc """
Parse `body`. Returns `{:ok, header}` or `{:error, {status, message}}`
shaped for the registry controller.
`header_start` and `header_end` bracket the main header in the file.
They go into `primary.xml` as `<rpm:header-range/>`, which is what
lets dnf fetch a package's metadata with a range request instead of
downloading it — the one piece of the index that cannot be derived
from the tags themselves.
"""
@spec parse(binary()) :: {:ok, t()} | {:error, {integer(), String.t()}}
def parse(<<@lead_magic, _lead::binary-size(@lead_size - 4), rest::binary>> = body) do
with {:ok, signature, after_signature} <- read_header(rest, @lead_size, @signature_tags),
# The main header starts on an 8-byte boundary. Only the
# signature header is padded this way; the offset is from the
# start of the file, not from the header.
start = align8(after_signature),
{:ok, tags, header_end} <- read_header(binary_part_from(body, start), start, @tags) do
# `ARCHIVESIZE` is in the main header only on packages built
# before rpm moved it; everything current carries the 64-bit
# `LONGARCHIVESIZE` in the signature header instead. Both spell
# the same number, so they collapse into one tag here rather than
# making every caller check two.
tags =
case {tags[:archivesize], signature[:longarchivesize] || signature[:archivesize]} do
{nil, nil} -> tags
{nil, size} -> Map.put(tags, :archivesize, size)
_ -> tags
end
{:ok, %{tags: tags, header_start: start, header_end: header_end}}
end
end
def parse(_),
do: {:error, {422, "not an RPM — the file does not start with the RPM lead magic"}}
@doc """
A tag's value, or `nil`. Scalars come back as an integer or a string,
arrays as a list.
"""
@spec get(t(), atom()) :: term()
def get(%{tags: tags}, tag), do: Map.get(tags, tag)
@doc """
A tag's value as a list, whether RPM stored it as one or not. Several
tags are singular in some packages and arrays in others, and every
caller here wants the list.
"""
@spec list(t(), atom()) :: [term()]
def list(header, tag) do
case get(header, tag) do
nil -> []
value when is_list(value) -> value
value -> [value]
end
end
@doc """
The package's full file list, reassembled from the three tags RPM
splits it across.
`BASENAMES` holds every filename, `DIRNAMES` the distinct directories
and `DIRINDEXES` the index into `DIRNAMES` for each basename. The
split is what keeps the header small — a package with a thousand
files under `/usr/share/doc/foo/` stores that prefix once.
"""
@spec files(t()) :: [String.t()]
def files(header) do
dirs = list(header, :dirnames)
indexes = list(header, :dirindexes)
bases = list(header, :basenames)
bases
|> Enum.zip(indexes)
|> Enum.map(fn {base, index} -> "#{Enum.at(dirs, index, "")}#{base}" end)
end
@doc """
The package's `name-version-release.arch.rpm` filename — what
`rpmbuild` writes and what every mirror serves it as.
"""
@spec filename(t()) :: String.t()
def filename(header) do
"#{get(header, :name)}-#{get(header, :version)}-#{get(header, :release)}.#{get(header, :arch)}.rpm"
end
@doc """
The payload — everything after the main header — as
`{:ok, compression, blob}`, or `:error` for a compressor nothing here
can undo (`lzma`, `bzip2`). Left compressed, like `Deb.Control.data/1`.
Only the desktop entry's icon wants it; see the note above about why
the index never does.
"""
@spec payload(binary(), t()) :: {:ok, atom(), binary()} | :error
def payload(body, %{header_end: at} = header) when byte_size(body) > at do
blob = binary_part(body, at, byte_size(body) - at)
# rpm's default when the tag is absent, which only very old
# packages are.
case get(header, :payloadcompressor) || "gzip" do
"gzip" -> {:ok, :gzip, blob}
"xz" -> {:ok, :xz, blob}
"zstd" -> {:ok, :zstd, blob}
_ -> :error
end
end
def payload(_body, _header), do: :error
# ── the header structure ────────────────────────────────────────────
# Returns `{:ok, tags, offset_of_end_in_file}`. `base` is where this
# header starts in the file, which is the only reason it is threaded
# through: the caller needs file offsets, not header-relative ones.
defp read_header(
<<@header_magic, _reserved::binary-size(4), count::32, store_size::32, rest::binary>>,
base,
tag_map
) do
index_size = count * 16
case rest do
<<index::binary-size(^index_size), store::binary-size(^store_size), _::binary>> ->
{:ok, entries(index, store, tag_map), base + 16 + index_size + store_size}
_ ->
{:error, {422, "RPM header is truncated"}}
end
end
defp read_header(_, _, _), do: {:error, {422, "RPM header has the wrong magic"}}
defp entries(index, store, tag_map) do
for <<tag::32, type::32, offset::32, count::32 <- index>>,
name = Map.get(tag_map, tag),
name != nil,
into: %{} do
{name, value(type, offset, count, store)}
end
end
# ── the data store ──────────────────────────────────────────────────
# Types, from `rpmTagType_e`. CHAR and INT8 are byte-wide and appear
# only in tags this module ignores, but decoding them costs a clause
# and misreading one as a string would be silent.
defp value(1, offset, count, store), do: ints(store, offset, count, 1)
defp value(2, offset, count, store), do: ints(store, offset, count, 1)
defp value(3, offset, count, store), do: ints(store, offset, count, 2)
defp value(4, offset, count, store), do: ints(store, offset, count, 4)
defp value(5, offset, count, store), do: ints(store, offset, count, 8)
# A STRING is always count 1; the count field lies for some producers,
# so it is ignored rather than trusted.
defp value(6, offset, _count, store), do: cstring(store, offset)
defp value(7, offset, count, store) do
case store do
<<_::binary-size(^offset), bytes::binary-size(^count), _::binary>> -> bytes
_ -> nil
end
end
defp value(8, offset, count, store), do: cstrings(store, offset, count)
# An I18NSTRING is an array with one entry per locale in the header's
# `HEADERI18NTABLE`, the first of which is always C. Taking the first
# is what rpm does with no locale set, and a repository index has no
# locale to set.
defp value(9, offset, _count, store), do: cstring(store, offset)
defp value(_type, _offset, _count, _store), do: nil
defp ints(store, offset, 1, width), do: List.first(ints(store, offset, 1, width, []))
defp ints(store, offset, count, width), do: ints(store, offset, count, width, [])
defp ints(_store, _offset, 0, _width, acc), do: Enum.reverse(acc)
defp ints(store, offset, count, width, acc) do
bits = width * 8
case store do
<<_::binary-size(^offset), value::size(^bits), _::binary>> ->
ints(store, offset + width, count - 1, width, [value | acc])
_ ->
Enum.reverse(acc)
end
end
defp cstring(store, offset) do
case store do
<<_::binary-size(^offset), rest::binary>> ->
case :binary.match(rest, <<0>>) do
{at, _} -> binary_part(rest, 0, at)
:nomatch -> rest
end
_ ->
nil
end
end
defp cstrings(store, offset, count), do: cstrings(store, offset, count, [])
defp cstrings(_store, _offset, 0, acc), do: Enum.reverse(acc)
defp cstrings(store, offset, count, acc) do
case cstring(store, offset) do
nil -> Enum.reverse(acc)
string -> cstrings(store, offset + byte_size(string) + 1, count - 1, [string | acc])
end
end
# ── odds and ends ───────────────────────────────────────────────────
defp align8(offset), do: offset + rem(8 - rem(offset, 8), 8)
defp binary_part_from(body, offset) when byte_size(body) > offset,
do: binary_part(body, offset, byte_size(body) - offset)
defp binary_part_from(_body, _offset), do: <<>>
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
11.3 KiB · text
History
b868907