Documentation
¶
Overview ¶
Package rpm reads RPM packages: their metadata, and their cpio payload as a filesystem.
It is pure Go, CGO-free, and depends on no rpm library. Two entry points:
func Metadata(r io.ReaderAt, size int64) (*Package, error) func OpenReader(r io.ReaderAt, size int64) (filesystem.Filesystem, error)
Metadata reads the lead and the two header structures and stops; its cost is a few kilobytes whatever the package weighs. OpenReader additionally decompresses the payload and returns it as a github.com/go-filesystems/interface Filesystem. Open is OpenReader with the concrete *FS, for a caller that wants Package() or the optional interfaces by name.
The filesystem is READ-ONLY: every mutating method returns ErrReadOnly. The optional filesystem.Opener is implemented, so a caller can read a byte range of one payload file without materialising it; see FS.OpenFile for why that is honest here and would not be for a driver that streamed.
The format, and the one rule that catches everyone ¶
A .rpm is: a 96-byte lead, a signature header structure, the header proper, then the payload. Everything is big-endian. Each header structure is a 3-byte magic, a version, four reserved bytes, an entry count, a data size, that many 16-byte index entries, and that many bytes of data store.
⛔ THE SIGNATURE HEADER'S DATA STORE IS PADDED TO AN 8-BYTE BOUNDARY AND THE HEADER PROPER'S IS NOT. Getting this wrong lands 1 to 7 bytes inside the second magic and the error reads as a corrupt package. It is asymmetric, it is easy to apply in the wrong place, and -- measured here -- it is invisible to a substantial minority of real packages, whose signature data happens to end 8-aligned already. See nextHeaderOffset and README.md's ablation table.
The cpio reader comes from go-filesystems/cpio ¶
The payload is a cpio archive and github.com/go-filesystems/cpio parses it. This package used to carry a second copy of that parser -- newc only, about 220 lines -- with a notice here saying so and asking for exactly this consolidation. The cost it named was real: a defect fixed in one copy stayed present in the other.
What stays here is the mapping onto this package's entry, the index in fs.go, and posixMode, which is the direction the shared parser does not go. The FILESYSTEM is deliberately not shared: a payload is one compressed stream, so it is in memory and entries are sections of it, while unarchive indexes into a file it keeps open.
It calls cpio.RecordsExact rather than cpio.Records. A payload's length is known exactly, so a record that does not parse, a magic that is not one, and a missing trailer are all damage -- not the block padding an initramfs legitimately carries. Records tolerates all three, and using it here turned three of this package's refusals into silent successes, which is how that distinction was found.
Two things the consolidation settled, both of them disagreements between the two readers that no test on either side could see:
- the shared parser checks EVERY numeric header field. This package did; the other ignored the errors on inode and mtime, so a header with a non-hex inode parsed to inode 0 and looked ordinary.
- the inode is carried, because this package reports it in Stat. It is NOT portable across cpio variants: odc's field and the old binary variant's are too narrow to hold a real one, and cpio(1) renumbers them 1..n.
⚠⚠ WHERE THE WITNESSES COME FROM, AND WHAT THEY CANNOT SHOW ¶
No reference implementation exists on the machine this was written on: no rpm, no rpm2cpio, no rpmbuild. So the controls are of two kinds, and they are not of equal weight.
REAL PACKAGES (testdata/rootfiles-el{5,7,9}-*.rpm). Three builds of the same source package, by rpmbuild, spanning 2007 to 2024 and the gzip, xz and zstd payload compressors. Their BYTES are rpmbuild's, which is what makes them worth having: the lead, both headers, the 8-byte padding, the index's byte order, the tag numbering and newc's 4-byte padding are all witnessed against output this package had no hand in. The EXPECTED VALUES were read out with an independent 40-line Python script written from the specification -- a weaker control than rpm itself, because two readers written from one specification can share a misreading of it. They are Public Domain and redistributable; see testdata/README.md.
CRAFTED FIXTURES (build_test.go). A package this reader's own tests assemble. ⚠ A fixture of the writer's own making CANNOT FAIL THE WAY A REAL PACKAGE CAN: if the builder and the parser misunderstand the format the same way, the test agrees with the bug. The 8-byte padding is exactly that shape -- omit it in both and the round trip is perfect while every real package since 2000 fails.
So the crafted fixtures are used only for what the real ones cannot provide, and each is a strictly weaker witness than a real package would be:
- a bzip2 payload, and an uncompressed one. rpm has supported both for twenty years and neither appears in any mainstream distribution, so no small public-domain package carries them. Go has no bzip2 WRITER either, so the bzip2 archive was compressed by CPython's bz2 module from a cpio that a third implementation wrote, and committed as a blob; the test cross-checks that blob against build_test.go's builder so the two cannot drift apart unnoticed.
- a package with NO PAYLOADCOMPRESSOR TAG, which rpm defines as gzip. Every package older than rpm 4.0 (2000) is in this class and none was obtainable small enough and clearly redistributable; the default is therefore reasoned from rpm's source, not measured. THIS IS THE WEAKEST CLAIM IN THE PACKAGE.
- fifos, character and block devices, and sockets in a payload, which exercise both directions of the st_mode translation. No rootfiles package has one.
- every one of the ten index types in one header, and each corruption in turn.
Reading the tests ¶
witness_test.go asserts BYTES per payload entry, never counts: a count is satisfied by the right number of wrong entries, and a mis-rounded newc pad produces exactly that. ablation_test.go removes one rule at a time and asserts the suite then fails; two of its six ablations have something to report, and README.md tabulates all of them.
Index ¶
- Constants
- Variables
- func OpenReader(r io.ReaderAt, size int64) (filesystem.Filesystem, error)
- type Entry
- type FS
- func (f *FS) Close() error
- func (f *FS) DeleteDir(string) error
- func (f *FS) DeleteFile(string) error
- func (f *FS) ListDir(p string) ([]filesystem.DirEntry, error)
- func (f *FS) MkDir(string, os.FileMode) error
- func (f *FS) OpenFile(p string) (filesystem.File, error)
- func (f *FS) Package() *Package
- func (f *FS) ReadFile(p string) ([]byte, error)
- func (f *FS) ReadLink(p string) (string, error)
- func (f *FS) Rename(string, string) error
- func (f *FS) Stat(p string) (filesystem.Stat, error)
- func (f *FS) WriteFile(string, []byte, os.FileMode) error
- type Header
- func (h *Header) Bytes(tag uint32) []byte
- func (h *Header) Has(tag uint32) bool
- func (h *Header) Int(tag uint32) (int64, bool)
- func (h *Header) Ints(tag uint32) []int64
- func (h *Header) Lookup(tag uint32) (Entry, bool)
- func (h *Header) String(tag uint32) string
- func (h *Header) Strings(tag uint32) []string
- type Lead
- type Package
- type Type
Constants ¶
const ( CompressorNone = "none" CompressorGzip = "gzip" CompressorBzip2 = "bzip2" CompressorXz = "xz" CompressorZstd = "zstd" )
The five values rpm writes into PAYLOADCOMPRESSOR.
const ( // DefaultMaxPayload is what Open allows a payload to inflate to: 1 GiB, the // installed size of a very large package. OpenLimit takes another. DefaultMaxPayload = 1 << 30 // MaxDecoderWindow bounds the memory a compressor may ask for up front: a zstd // window or an xz LZMA2 dictionary. 128 MiB is libzstd's default limit. MaxDecoderWindow = 128 << 20 // MaxEntries bounds the entries of the payload, real and synthesised parent // directories together. A real package has hundreds of thousands at most. MaxEntries = 1 << 22 // MaxPathLength bounds one entry's path: Linux's PATH_MAX (limits.h), which is // also what the kernel's initramfs unpacker refuses beyond (init/initramfs.c). MaxPathLength = 4096 )
The ceilings. An RPM is untrusted input and a payload is the one thing in it that is compressed, so what it inflates to is the file's choice unless these say otherwise. Each is a number the format does not fix; the reference each was taken from is named where it is used.
const ( TagName = 1000 TagVersion = 1001 TagRelease = 1002 TagEpoch = 1003 TagSummary = 1004 TagDescription = 1005 TagBuildTime = 1006 TagSize = 1009 TagVendor = 1011 TagLicense = 1014 TagPackager = 1015 TagGroup = 1016 TagURL = 1020 TagOS = 1021 TagArch = 1022 // TagFileSizes and its neighbours describe the payload's files in // PARALLEL ARRAYS -- one entry per file, in payload order. They are not // read here: the cpio payload carries the same facts and carries them // with the bytes, so reading them from the header would be a second // source that can disagree with the first. TagFileSizes = 1028 TagFileModes = 1030 TagFileNames = 1027 // retired in rpm 4; see TagBaseNames TagSourceRPM = 1044 TagArchiveSize = 1046 TagBaseNames = 1117 TagDirNames = 1118 // TagPayloadFormat is very nearly always "cpio". rpm 5 could write // "drpm"; nothing this reader opens does. TagPayloadFormat = 1124 // TagPayloadCompressor names the stream wrapped around the cpio: // "gzip", "bzip2", "xz", "zstd" or "none". // // ⛔ ITS ABSENCE IS NOT AN ERROR AND NOT "none". A package built before // rpm 4.0 carries no such tag and its payload is gzip: that is what // rpmlib's own default is, and treating a missing tag as an // uncompressed payload turns every pre-2000 package into a corrupt one. TagPayloadCompressor = 1125 TagPayloadFlags = 1126 )
The tags this reader names. RPM defines several hundred; these are the ones something here reads, plus the few a caller most often wants next.
The numbers are rpm's own (rpmtag.h). They are part of the on-disk format and cannot be renumbered, which is why they are written as literals rather than as an iota block: a tag's value is data, not a position in a list.
const ( SigTagSize = 1000 // size of the header + payload, in bytes SigTagMD5 = 1004 SigTagGPG = 1005 SigTagPayloadSize = 1007 SigTagSHA1 = 269 SigTagSHA256 = 273 )
Signature-header tags. They share the numeric space with the main header's but mean different things in it, which is the reason the two headers are kept as separate values here instead of being merged into one tag map.
const PayloadFormatCpio = "cpio"
PayloadFormatCpio is the only PAYLOADFORMAT this reader opens.
Variables ¶
var ErrCorrupt = errors.New("rpm: corrupt package")
ErrCorrupt is returned when the file IS an RPM -- the lead magic matched -- and something inside it does not hold together: a header magic that is not there, a count or size that runs past the end of the file, an index entry whose offset points outside its own data store.
⛔ It is deliberately NOT wrapped in fs.ErrNotExist. A caller that maps a driver's errors onto HTTP or NFS status must be able to answer 500 for a damaged package and 404 for a name that is not in it; collapsing the two makes a corrupt package look like a routine miss and nothing anywhere reports the fault. See the error-contract note in github.com/go-filesystems/interface.
var ErrNotFound = fmt.Errorf("rpm: path not found in payload: %w", iofs.ErrNotExist)
ErrNotFound is the driver's single "that path is not in this package" sentinel, and it satisfies the interface module's error contract: errors.Is(err, fs.ErrNotExist) is true for it and for anything wrapping it.
Every site that raises it means a name that the payload does not carry. A payload that cannot be parsed raises ErrCorrupt instead, so the two do not share a sentinel and a broken package never reads as a 404 -- which is the distinction the interface module asks each driver to check before wrapping.
var ErrNotRPM = errors.New("rpm: not an RPM package (bad lead magic)")
ErrNotRPM is returned when the 96-byte lead does not begin with RPM's magic. It is the one error that means "this is not an RPM at all", which is what a format-probing caller such as go-filesystems/detect needs to tell apart from "this is an RPM and something in it is wrong".
var ErrNotRegular = fmt.Errorf("rpm: not a regular file: %w", iofs.ErrInvalid)
ErrNotRegular is returned by OpenFile and ReadFile for a path that is in the payload and is not a regular file -- a directory, a symlink, a device node. It satisfies fs.ErrInvalid rather than fs.ErrNotExist: the name resolved, so answering 404 would be wrong.
var ErrReadOnly = errors.New("rpm: package is read-only")
ErrReadOnly is returned by every mutating method. An RPM package is a distribution artefact: its payload is a single compressed stream and its headers carry digests over the bytes that follow them, so there is no in-place write that would leave a valid package behind.
var ErrTooLarge = errors.New("rpm: over the size ceiling")
ErrTooLarge is returned when a package asks for more memory than the ceilings allow: a payload that inflates past the limit given to OpenLimit (a compression bomb is a few hundred kilobytes of file for gigabytes of payload), a compressor window or dictionary over MaxDecoderWindow, more entries than MaxEntries, or a path longer than MaxPathLength. The package may be a well-formed one that this reader declines to hold; it is not ErrCorrupt.
var ErrUnsupported = errors.New("rpm: unsupported")
ErrUnsupported is returned for a package this reader understands well enough to identify and not well enough to open: a payload format that is not cpio, or a compressor that is not one of the five named in PAYLOADCOMPRESSOR.
Functions ¶
func OpenReader ¶
func OpenReader(r io.ReaderAt, size int64) (filesystem.Filesystem, error)
OpenReader is Open under the name every driver in go-filesystems answers to.
github.com/go-filesystems/detect registers drivers as func(io.ReaderAt, int64) (filesystem.Filesystem, error); Open is the right shape but returns the concrete *FS, which is a different type and does not fit. A caller writes detect.Register(detect.RPM, rpm.OpenReader) and is done.
Types ¶
type Entry ¶
Entry is one 16-byte index entry: which tag, of what type, where in the header's data store, and how many of them.
type FS ¶
type FS struct {
// contains filtered or unexported fields
}
FS is an opened RPM package seen as a filesystem: the cpio payload, decompressed, indexed by path.
It satisfies filesystem.Filesystem and filesystem.Opener, and every mutating method returns ErrReadOnly.
func Open ¶
Open reads an RPM's metadata AND decompresses its payload, returning the cpio contents as a filesystem.
⛔ The whole payload is decompressed into memory here, because an RPM payload is one compressed stream and no byte in it is reachable without decoding every byte before it. Budget for the INSTALLED size of the package. A caller that only wants the name and version should call Metadata, which reads the headers alone.
func OpenLimit ¶ added in v0.4.0
OpenLimit is Open with the payload allowed to inflate to maxPayload bytes rather than DefaultMaxPayload; maxPayload <= 0 means the default. A payload that inflates past it is refused with ErrTooLarge, having held at most maxPayload+1 bytes (the buffer may touch twice that while it grows).
The memory a successful Open holds is the payload plus an index of its entries, about 400 bytes per entry (MaxEntries caps them), so budget a small multiple of maxPayload.
func (*FS) Close ¶
Close releases nothing: the payload is a byte slice and the io.ReaderAt it was read from belongs to the caller. It is here because filesystem.Filesystem asks for it, and it returns nil rather than pretending to a flush it does not do.
func (*FS) DeleteFile ¶
func (*FS) ListDir ¶
func (f *FS) ListDir(p string) ([]filesystem.DirEntry, error)
ListDir lists one directory. The root is "", "." or "/".
func (*FS) OpenFile ¶
func (f *FS) OpenFile(p string) (filesystem.File, error)
OpenFile satisfies filesystem.Opener.
It is implemented, rather than left out, because the payload is already resident: decompressPayload had to materialise the whole cpio to reach any of it, so a byte range costs a copy. The interface module's own guidance is that a driver which cannot answer a range without decoding everything before it should NOT implement Opener -- the decoding here happens once, at open, and is accounted for there rather than hidden inside each read.
func (*FS) Package ¶
Package returns the metadata of the package this filesystem came from, so a caller that opened the payload does not have to parse the headers twice.
func (*FS) ReadLink ¶
ReadLink returns a symlink's target, as the payload stored it. The target is not resolved: a relative target stays relative.
func (*FS) Stat ¶
func (f *FS) Stat(p string) (filesystem.Stat, error)
Stat reports mode, size and inode for a path.
type Header ¶
type Header struct {
Version uint8
// Entries are in the order the file lists them, which is rpm's own tag
// order and is therefore sorted ascending in every package measured.
// Nothing here relies on that.
Entries []Entry
// Offset is where this header's magic sits in the file, and End is the
// first byte after its data store -- BEFORE any alignment padding. The
// padding is the caller's business because only the signature header has
// it; see nextHeaderOffset.
Offset, End int64
// contains filtered or unexported fields
}
Header is one of an RPM's two header structures: the signature, or the header proper. Both have exactly this shape, which is why one parser reads both.
The data store is held whole. A header is at most a few kilobytes -- the largest in this repository's witnesses is 4.8 KiB -- so copying it once buys accessors that cannot fail and need no io.ReaderAt of their own.
func (*Header) Bytes ¶
Bytes returns the raw value stored under a BIN or CHAR tag -- a digest, a signature, a blob. The slice aliases the header's own copy of the data store and must not be written to.
func (*Header) Int ¶
Int returns the first integer stored under tag, and whether there was one.
⛔ Like String it reads the first value and builds no slice: Ints widens every value to 8 bytes, so an INT8 entry of 256 Mi values would be 2 GiB to answer "what is the epoch".
func (*Header) Ints ¶
Ints returns every integer stored under tag, widened to int64. CHAR and INT8 count as integers here; BIN does not, because a BIN entry is a digest or a blob and reading it as numbers is never what a caller meant.
func (*Header) String ¶
String returns the first string stored under tag, or "" if the tag is absent or holds something that is not a string.
"" for an absent tag is the right default HERE and would not be everywhere: every string tag this package reads is one whose absence and whose emptiness mean the same thing to a caller (no summary, no license, no compressor named). The one tag where that is false -- PAYLOADCOMPRESSOR, whose absence means gzip and not "no compression" -- is read through Has first. See payloadCompressor.
⛔ It reads the FIRST string and builds nothing else. It used to call Strings and take element 0, which allocates a slice of Count strings: 16 bytes each, for a header whose store is 256 MiB of NULs, is 4 GiB to return one empty string -- and Metadata calls this for fifteen tags. Measured on the old code: a 16 MiB package whose NAME was a STRING_ARRAY of 16 Mi empty strings made Metadata allocate 268 MB.
type Lead ¶
type Lead struct {
Major uint8 // 3 in every package rpm has written since 1997
Minor uint8
// Type is 0 for a binary package and 1 for a source package.
Type uint16
// Archnum is a number from rpm's own table, not a Linux machine type.
Archnum uint16
// Name is the 66-byte NUL-padded "name-version-release" field, trimmed.
// It is truncated, not wrapped, for a package whose NVR is longer.
Name string
Osnum uint16
SigType uint16 // 5 for the header-structure signature used since rpm 2.1
}
Lead is the 96-byte prologue of every RPM package.
⛔ IT IS LEGACY AND ITS FIELDS ARE NOT TO BE TRUSTED. rpm stopped maintaining the lead in 2000 and keeps writing it only so that file(1) and older tools still recognise a package. The measured witnesses in this repository show why reading anything but the magic out of it is a mistake: `Archnum` is 255 in both the el5 and el9 noarch packages and 1 in the el7 one, for three packages whose ARCH tag says "noarch" in all three. A caller that decided the architecture from the lead would get a different answer per build era for the same package.
So this type exists to be VALIDATED (the magic) and EXPOSED (for a tool that wants to show what the lead claims), and Metadata takes every fact it reports, architecture included, from the main header instead.
type Package ¶
type Package struct {
Name string
Version string
Release string
Arch string
Summary string
// Epoch is absent from most packages. rpm treats an absent epoch as 0 for
// comparison but an ABSENT epoch and an epoch of 0 are distinguishable in
// the header, so the flag says which this was rather than collapsing them.
Epoch int64
HasEpoch bool
License string
Description string
Group string
OS string
Vendor string
Packager string
URL string
SourceRPM string
// PayloadFormat is PAYLOADFORMAT, almost always "cpio".
PayloadFormat string
// PayloadCompressor is PAYLOADCOMPRESSOR, or "gzip" for a package that
// carries no such tag -- see payloadCompressor for why the default is
// gzip and not "none".
PayloadCompressor string
// PayloadOffset is the first byte of the compressed payload: the byte
// after the main header's data store, with NO padding. PayloadSize is
// what is left of the file from there.
PayloadOffset int64
PayloadSize int64
// Lead, Signature and Header are the raw structures, for a caller that
// wants a tag this type does not name.
Lead *Lead
Signature *Header
Header *Header
}
Package is what an RPM says about itself.
Every field here is taken from the MAIN HEADER and none from the lead, which is legacy and disagrees with the header about the architecture in two of this repository's three witnesses. Lead is kept so a caller can see what the lead claims, clearly labelled as its own.
