In the Closure of Madness: Spec-based Package Management with Closure-Sanity Validation
Introduction
Linux distributions split their software into packages, which together with the kernel compose a full operating system. The usual way to assemble the packages into a system is the distribution's package manager, which requires superuser privileges and a host system of the same distribution family, meaning a Debian filesystem can only be built from a Debian system. The package formats themselves impose no such requirements, since they are public specifications, and a program that follows them can build the same filesystem on any Linux system as a regular user.
In this article, we present a methodology for root filesystem initialization without superuser permissions or an existing package manager, implemented as a tool called FlatRoot. We start by exploring the packaging formats of four popular distributions, those being Debian, Fedora, Arch Linux, and Alpine. We then present the resolver, which walks the dependency graph to build the closure of package dependencies. Following, we present the post-extraction steps, which compose a sane root filesystem. Lastly, we validate the resolver against the package managers of each distribution, the validation process checks the packages of the expected closure against the real output of the package managers.
Background
In this section, we introduce the basics of package management across Linux distributions. We start with the concepts common to the different implementations and the process that a package manager does to create valid root filesystems (known as rootfs). Lastly, we describe the distinct archive formats each package manager uses to manage root filesystems.
A package is an archive that the package manager downloads from the distribution's repositories and installs onto the system. Across all four families in this article, it is built from the same parts:
- The archive: the files that the package installs on the root filesystem, stored in the expected target directory layout.
- Metadata: the package name, version, and its relationships to other packages, such as dependencies and conflicts.
- Maintainer scripts: executable hooks that run at installation time to finish configuration (Debian Policy 6, alpm-install-scriptlet, apk's install hooks per apk-v2(5)).
A package manager orchestrates these parts into a working system, and it starts from the metadata. A request rarely stops at the package it names: bash needs the C library libc6 and the terminal library libtinfo6, and each of those needs packages of its own. The package manager follows every dependency, then the dependencies of those dependencies, until no new package appears. The resulting set is the coind as the dependency closure of the request: it contains the requested packages, and every dependency satisfied recursively. The package manager downloads and extracts the archives of every package in it, followed by the post installation scripts.
Each package manager has distinct archive formats with their own disposition of data. Debian packages its software as .deb files, a format it shares with Ubuntu and the many derivatives of both, with the tooling split in two layers:
dpkginstalls individual.debfiles onto the systemaptsits above it to fetch packages from repositories and resolve their dependencies.
The format is specified in the Debian Policy Manual, which defines the metadata and the rules around it, and dpkg's manual pages, which enforces the spec of the distributed archive.
The RPM Package Manager and its .rpm package format uses the same two-layer split: rpm installs individual files onto the system, and dnf, or zypper on openSUSE, fetches packages from repositories and resolves their dependencies, per rpm's reference documentation. Arch Linux and its derivatives package their software as .pkg.tar.zst files, handled by pacman, a single layer covering both aforementioned roles with ALPM (Arch Linux Package Management) library, with makepkg producing the archives, per the ALPM specifications. Alpine Linux packages its software as .apk files, handled by apk, a single layer from index fetch to extraction, per the apk-tools documentation, with v2 as the current stable version; apk-tools 3 defines a successor v3 format based on a different container (apk-v3(5)), which this article does not cover.
Archives
A package travels as a single file, the archive, that bundles the metadata, the maintainer scripts, and the payload of files to install. This section explores how each format builds its archive. The trees are interactive, click a highlighted entry to read more details.
Debian
Per deb(5), a .deb file is an ar(5) archive1 containing exactly three members in fixed order:
├──
├──
│ ├──
│ ├──
│ └──
└──
├── usr/bin/bash
└── usr/share/man/man1/bash.1.gz
A .deb file is an ar archive holding three members, in fixed order:
debian-binary: the format markercontrol.tar.xz: the metadata and maintainer scriptsdata.tar.xz: the files the package installs
debian-binary is the format marker, a single line, the bytes 2.0\n, that names the archive format version, so a reader that encounters an unknown major version stops instead of misreading the file.
control.tar.xz holds everything except the files to install, and it may be stored uncompressed or as gzip, xz, or zstd.
The control file is the package's metadata record, carrying the Package, Version, Architecture, and dependency fields (deb-control(5)).
The control file
Package: bash
Source: bash (5.2.15-2)
Version: 5.2.15-2+b13
Architecture: amd64
Essential: yes
Maintainer: Matthias Klose <doko@debian.org>
Installed-Size: 7164
Pre-Depends: libc6 (>= 2.36), libtinfo6 (>= 6)
Depends: base-files (>= 2.1.12), debianutils (>= 5.6-0.1)
Recommends: bash-completion (>= 20060301-0)
Suggests: bash-doc
Conflicts: bash-completion (<< 20060301-0)
Replaces: bash-completion (<< 20060301-0), bash-doc (<= 2.05-1)
Section: shells
Priority: required
Multi-Arch: foreign
Description: GNU Bourne Again SHell
Bash is an sh-compatible command language interpreter that executes
commands read from the standard input or from a file.
...
See the full file here.
The files the package installs need their integrity verified, and that is the purpose of md5sums: it lists the MD5 checksum of every one of them, so dpkg can detect a file that has been modified or corrupted (deb-md5sums(5)).
The md5sums file
2a76f5df90ba1375b6e6d825c4a28b2d bin/bash
12c7981c8fed81743552e47dd4b1483e usr/bin/bashbug
1beed4021b9f91e24b98b98e326b6970 usr/bin/clear_console
...
da7755d44d7ebecdc872cf866dbd45e8 usr/share/doc/bash/NEWS.gz
See the full file here.
The maintainer scripts, the four preinst, postinst, prerm, and postrm, run before and after the corresponding steps of unpacking and removal (Debian Policy 6.1).
The postinst script
#! /bin/bash
set -e
# This should never happen.
if [ ! -e /bin/sh ]; then
ln -s bash /bin/sh
fi
update-alternatives --install \
/usr/share/man/man7/builtins.7.gz \
builtins.7.gz \
/usr/share/man/man7/bash-builtins.7.gz \
10 \
|| true
...
bash ships postinst, postrm, and prerm but no preinst. See the full files here (postinst), here (postrm), and here (prerm).
data.tar.xz holds the files the package installs, laid out as they will appear on the system. It accepts the control member's compressions, plus bzip2 and lzma.
Walkthrough: .deb extraction in FlatRoot
The .deb archive carries everything a package installs. FlatRoot unpacks it straight into the rootfs, and this walkthrough follows the extraction of one archive, from its ar container to the installed files. The extraction triggers during an install: the install command extracts every downloaded archive in dependency order, each archive is dispatched to its format's backend, and for Debian the backend is extract.
The first step is to open the archive: an ar reader walks the members one at a time, and each member's name identifies which of the three it is.
The files of data.tar are extracted straight into the rootfs, since the archive stores them in their final layout. The maintainer scripts of control.tar cannot run yet, they expect a working system underneath, so they are unpacked into a per-package directory, saved for the post-extraction pass.
A .deb without a data.tar member fails the extraction. deb(5) requires the member in every package, even one that installs no files (a meta-package carries a data.tar with an empty file list), so a missing member always means a malformed archive.
The saved maintainer scripts run once every archive is extracted and the rootfs is assembled. The post-extraction pass replays each package's postinst: the script is copied into the rootfs and invoked as dpkg would invoke it, with the configure argument.
RPM Package Manager
Per the RPM v4 format specification, an .rpm file is a binary container with four sections in sequence:
├──
├──
├──
└──
├── /usr/bin/bash
└── /usr/share/man/man1/bash.1.gz
In order, the sections are the lead, the signature header, the metadata header, and the payload.
The lead is 96 bytes that begin with the magic ED AB EE DB, which serves to recognize the file as an RPM, and the rest of the lead carries legacy information that was migrated to the metadata header (lead format).
The lead
0000000 ed ab ee db 03 00 00 00 00 01 62 61 73 68 2d 35 >..........bash-5<
0000016 2e 32 2e 33 32 2d 31 2e 66 63 34 31 00 00 00 00 >.2.32-1.fc41....<
0000032 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 >................<
0000048 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 >................<
0000064 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00 05 >................<
0000080 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 >................<
The signature header holds checksums of the metadata header, so rpm can tell when the header was modified (format spec). A signed package adds a digital signature over the header, which is how rpm knows the package came from its signer.3
The signature header
0000096 8e ad e8 01 00 00 00 00 00 00 00 0a 00 00 60 d8 >..............`.<
0000112 00 00 00 3e 00 00 00 07 00 00 60 c8 00 00 00 10 >...>......`.....<
0000128 00 00 01 0c 00 00 00 07 00 00 00 00 00 00 02 36 >...............6<
0000144 00 00 01 0d 00 00 00 06 00 00 02 36 00 00 00 01 >...........6....<
...
0000256 00 00 03 f0 00 00 00 07 00 00 50 a8 00 00 10 20 >..........P.... <
The metadata header holds the package's name, version, dependencies, and maintainer scripts.
The metadata header
Name : bash
Version : 5.2.32
Release : 1.fc41
Architecture: x86_64
Size : 8546628
License : GPL-3.0-or-later
Source RPM : bash-5.2.32-1.fc41.src.rpm
...
Summary : The GNU Bourne Again shell
Description :
The GNU Bourne Again shell (Bash) is a shell or command language
interpreter that is compatible with the Bourne shell (sh).
...
See the full metadata here.
The maintainer scripts
postinstall scriptlet (using <lua>):
nl = '\n'
sh = '/bin/sh'..nl
bash = '/bin/bash'..nl
f = io.open('/etc/shells', 'a+')
...
postuninstall scriptlet (using <lua>):
-- Run it only if we are uninstalling
if arg[2] == 0
then
...
end
Unlike Debian, bash's RPM scripts are written in Lua and only run at install and removal. See the full scriptlets here.
The payload is a cpio(5) archive2 holding only the files, compressed with gzip by default, with bzip2, xz, lzma, and zstd also allowed per rpm-payloadflags(7).
Arch Linux
Per alpm-package(7), a package is a tar archive, conventionally zstd-compressed (.pkg.tar.zst), with the metadata as dotfiles at the archive root, next to the files themselves:
├──
├──
├──
├──
└── usr/bin/bash
In order, the files are .PKGINFO, .BUILDINFO, and .MTREE, which are required, plus the optional .INSTALL.
.PKGINFO is the package record, carrying the name, version, architecture, and the dependencies, provides, and conflicts that a resolver needs (PKGINFO(5)).
The .PKGINFO file
# Generated by makepkg 7.1.0
pkgname = bash
pkgbase = bash
pkgver = 5.3.15-1
pkgdesc = The GNU Bourne Again shell
url = https://www.gnu.org/software/bash/bash.html
arch = x86_64
license = GPL-3.0-or-later
provides = sh
backup = etc/bash.bashrc
backup = etc/bash.bash_logout
...
depend = readline
depend = glibc
depend = ncurses
optdepend = bash-completion: for tab completion
See the full file here.
.BUILDINFO records the environment the package was built in, the build tool, its options, and the packages installed at build time, so the package can be reproduced bit for bit (BUILDINFO(5)).
The .BUILDINFO file
format = 2
pkgname = bash
pkgver = 5.3.15-1
pkgarch = x86_64
packager = Tobias Powalowski <tpowa@archlinux.org>
builddate = 1781065932
buildtool = devtools
buildtoolver = 1:1.5.0-1-any
buildenv = !distcc
buildenv = color
...
options = strip
options = docs
See the full file here.
.MTREE lists every file with its type, ownership, mode, timestamps, and checksums, which is how pacman verifies the installed files against the package without keeping the archive around (ALPM-MTREE(5)).
The .MTREE file
#mtree
/set type=file uid=0 gid=0 mode=644
./.PKGINFO time=1781065932.0 size=632 sha256digest=215e529...7d4b55
./.INSTALL time=1781065932.0 size=454 sha256digest=a041703...4548d
./etc/bash.bashrc time=1781065932.0 size=733 sha256digest=563e03e...721a
...
./usr/bin/bash time=1781065932.0 mode=755 size=1195144 sha256digest=575e03a...f20a5
See the full file here.
.INSTALL is the maintainer script, whose functions run at the install, upgrade, and removal steps (alpm-install-scriptlet(5)).
The .INSTALL script
post_upgrade() {
grep -qe '^/bin/bash$' etc/shells || echo '/bin/bash' >> etc/shells
grep -qe '^/usr/bin/bash$' etc/shells || echo '/usr/bin/bash' >> etc/shells
...
}
See the full file here.
Alpine Linux
Per apk-v2(5), an .apk file is "three concatenated gzip streams, which together form a single tar archive":
├──
│ └── .SIGN.RSA.alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub
├──
│ ├──
│ ├──
│ └──
└──
├── usr/lib/libgdk_pixbuf-2.0.so.0.4200.12
└── usr/lib/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader-ani.so
An .apk file is three concatenated gzip streams that together form a single tar archive, in this order: the signature stream, the control stream, and the data stream.
Holds the package's detached signatures3, one per signing key, each named .SIGN.<algorithm>.<keyid>, where <algorithm> is the signature scheme, RSA in this package, and <keyid> is the name of the signing key's file under /etc/apk/keys (apk-v2(5)).
The control stream is the metadata section of the archive. It holds plain-text files at the archive root, ahead of the data section, so a package manager can read the package record and its scripts before unpacking the files the package installs.
.PKGINFO is the package's metadata record, written in a key-value format with = separating key from value and # starting a comment (apk-v2(5)). It carries the name, version, architecture, and dependencies that a resolver needs, and its datahash line holds the SHA-256 checksum of the data section, which is how apk can verify the data against the package's signature (apk-package(5)).
The .PKGINFO file
# Generated by abuild 3.14.0-r0
pkgname = gdk-pixbuf
pkgver = 2.42.12-r1
pkgdesc = GTK+ image loading library
url = https://wiki.gnome.org/Projects/GdkPixbuf
arch = x86_64
license = LGPL-2.1-or-later
triggers = /usr/lib/gdk-pixbuf-2.0/*/loaders
# automatically detected:
provides = so:libgdk_pixbuf-2.0.so.0=0.4200.12
...
datahash = cca1e7a63a0f3a07a915400ba09ff895d91010f338602ac570a109f56b7c6902
See the full file here.
.pre-deinstall is the script apk runs before removing the package, one of the script types a package can ship, each invoked with the affected version as its argument (apk-package(5)).
The .pre-deinstall hook
See the full file here.
.trigger is the package's trigger script: apk runs it with the matched directories as arguments whenever a transaction changes one of the directory globs the package lists in the triggers field of its .PKGINFO (apk-package(5)).
Holds interleaved data: a pax extended header record that stores the file's SHA1 checksum under the key APK-TOOLS.checksum.SHA1, followed by the file itself, for every file the package installs.
Indices
In this section, we explore mechanisms of package distribution, the catalog that maps every package to its archive and metadata. Each family publishes its index in its own format, and this section walks through each.
Debian
Debian's index4 is the Packages file. It is a regular file, plain text: the index is a sequence of records, one per package, each a block of Key: Value lines and separated from the next record by a blank line. Inspecting it is reading text like any other file, xzcat Packages.xz | less pages through the file, and xzcat Packages.xz | grep '^Package:' lists the package names alone. The file is sorted alphabetically:
Package: 0ad
Source: 0ad (0.27.0-2)
Version: 0.27.0-2+b1
Installed-Size: 54427
Maintainer: Debian Games Team <pkg-games-devel@lists.alioth.debian.org>
Architecture: amd64
Depends: 0ad-data (>= 0.27.0), 0ad-data (<= 0.27.0-2), 0ad-data-common (>= 0.27.0), 0ad-data-common (<= 0.27.0-2), libboost-filesystem1.83.0 (>= 1.83.0), libc6 (>= 2.39), ...
Pre-Depends: dpkg (>= 1.15.6~)
Description: Real-time strategy game of ancient warfare
...
Filename: pool/main/0/0ad/0ad_0.27.0-2+b1_amd64.deb
Size: 15478892
SHA256: 704dae7df79a35ed1bd3a0a8d5d1b9ce7a4ecdef74ddff72114d9523d63710eb
Package: 0ad-data
Its records follow the syntax defined in Debian Policy 5.1: Key: Value fields, one record per package, with continuation lines beginning with a space or tab.5 The record for the bash package:
Every combination of release, component, and architecture has its own copy, served compressed at a fixed path such as dists/bookworm/main/binary-amd64/Packages.xz, and the package archives it points to live under the pool/ directory.
A mirror is a server that keeps a copy of the repository and serves it to clients in place of the central host, so a client downloads from a nearby server, which is faster and spares the central hosts the load. Every mirror serves the same layout. The project's own entry point is deb.debian.org, a host that serves the nearest mirror, so a client can name it in sources.list without picking one. The worldwide mirror list groups every official mirror by country, and the complete list lists them all. The mirror status page tracks how current and reachable each listed mirror is.
Walkthrough: the Debian index in FlatRoot
The Packages file describes everything Debian publishes. FlatRoot processes it into a local database of packages, and this walkthrough follows the processing from download to storage. The processing triggers when a command first needs the database: the install command opens its per-architecture context, the context populates the database on first use, and the population is dispatched to the format's backend, which for Debian is index_fetch.
The first step is to fetch the remote file: its fixed path is assembled from the suite, the component, and the architecture. Packages.gz is downloaded from that path through one of the repository's mirrors. The file is compressed, so FlatRoot decompresses it into the plain text of the index and hands the text to catalogue_parse.
The fetched index is plain text, while the resolver works on individual packages and their fields, so the packages must be extracted from the text. This is the job of catalogue_parse, which iterates the text line by line. A record is a group of Key: Value lines, ended by a blank line, and each record describes one package. From every record, FlatRoot keeps the fields the resolver uses and ignores the rest.
The kept fields assemble a Package, one entry of the package database. Before the entry is saved, the Filename field is completed: its value, pool/main/0/0ad/0ad_0.27.0-2+b1_amd64.deb for 0ad, is only the suffix of the archive's URL, and the missing prefix is the mirror that served the index, for example https://deb.debian.org/debian. FlatRoot saves the two joined, https://deb.debian.org/debian|pool/main/0/0ad/0ad_0.27.0-2+b1_amd64.deb, and the installation process uses this value to download the archive from its full URL.
RPM Package Manager
For Fedora, the regular file repodata/repomd.xml defines the index: it is a regular file, XML text, the table of contents of the directory. The client fetches it first and reads from it the paths and checksums of the remaining metadata files (Pulp's repository-format reference). Inspecting it is reading text, curl -s <url> | less pages through the file, and each <data> element names one catalog and its checksummed path inside repodata/. The file opens with the primary catalog:
<repomd xmlns="http://linux.duke.edu/metadata/repo">
<revision>1761190640</revision>
<data type="primary">
<checksum type="sha256">fffa3e9f...</checksum>
<location href="repodata/fffa3e9f...-primary.xml.zst"/>
<timestamp>1761190628</timestamp>
<size>16042760</size>
</data>
<data type="filelists">
...
</data>
...
</repomd>
Among those files, primary.xml is the package catalog, a zstd-compressed XML with one entry per package holding its metadata and dependency data, inspected with zstd -dc primary.xml.zst | less. The entry for the bash package:
<package type="rpm">
<name>bash</name>
<arch>x86_64</arch>
<version epoch="0" ver="5.3.0" rel="2.fc43"/>
...
<location href="Packages/b/bash-5.3.0-2.fc43.x86_64.rpm"/>
<format>
...
<rpm:requires>
<rpm:entry name="filesystem" flags="GE" epoch="0" ver="3"/>
<rpm:entry name="libtinfo.so.6()(64bit)"/>
<rpm:entry name="rtld(GNU_HASH)"/>
<rpm:entry name="libc.so.6(GLIBC_2.38)(64bit)"/>
</rpm:requires>
<file>/usr/bin/bash</file>
...
</format>
</package>
An RPM dependency can name a file instead of a package: Requires: /usr/bin/sh means whichever package contains that file. Resolving it needs a mapping from files back to their owning packages. The complete mapping is filelists.xml, a second metadata file listing every file of every package, and it is large enough that clients avoid downloading it when possible. As the middle ground, primary.xml embeds a small subset of each package's files, enough to answer the common file dependencies on its own. The subset is a hardcoded rule of createrepo_c, the generator of the repository metadata. A file appears in the subset only when its path is under /etc/ or contains the substring bin/ (plus the one-off /usr/lib/sendmail). The format, however, does not restrict which paths a dependency may name. Requires: /usr/bin/sh is answerable from the catalog alone, while Requires: /usr/share/dict/words is not, and the resolver must fall back to downloading and searching filelists.xml.file
Fedora serves its repositories from dl.fedoraproject.org. The Fedora wiki's Mirroring page documents MirrorManager, "the Fedora Mirror Management system", as keeping track of all the mirrors, and its public page, the mirror list, shows every one of them.
Arch Linux
pacman's index4 is the sync database, a tar archive named after the repository it describes, such as core.db and extra.db, refreshed from the mirror when pacman synchronizes. It is a regular file but not plain text: a tar archive holding one directory per package, each with a desc record. Inspecting it is reading the archive, tar -tf core.db lists the package directories, and tar -xOf core.db bash-5.3.15-1/desc prints one record. The first directories in the file:
acl-2.4.0-1/
acl-2.4.0-1/desc
amd-ucode-20260810-2/
amd-ucode-20260810-2/desc
archlinux-keyring-20260727-1/
archlinux-keyring-20260727-1/desc
...
Per alpm-repo-db(7), each directory holds a desc file (alpm-repo-desc(5)) whose format is an all-caps %SECTION% header line followed by its values. The desc entry for the bash package:
%NAME%
bash
%VERSION%
5.3.15-1
%DESC%
The GNU Bourne Again shell
...
%PROVIDES%
sh
%DEPENDS%
readline
libreadline.so=8-64
glibc
ncurses
Arch's entry point is geo.mirror.pkgbuild.com, one of the tier-1 mirrors the project's DevOps team manages. The Arch wiki's Mirrors page names the official sources: the mirror list ships in the pacman-mirrorlist package, the mirrorlist generator offers a more current one, and the mirror status page tracks freshness.
Alpine Linux
Alpine's index4 is APKINDEX.tar.gz, one per repository and architecture. It is a regular file, a gzip-compressed tar whose single member APKINDEX is the plain-text index; inspecting it is curl -s <url> | tar -xzO APKINDEX | less. The file opens with the record of the first package, 7zip:
C:Q1Hsn+PTucNsaniz4VLmlaFNKp4nM=
P:7zip
V:24.08-r0
...
D:so:libc.musl-x86_64.so.1 so:libgcc_s.so.1 so:libstdc++.so.6
p:7zip-virtual p7zip=24.08-r0 cmd:7z=24.08-r0 cmd:7zz=24.08-r0
Its records use single-letter fields, each documented in apk-package(5): C: the checksum of the package metadata, P: name, V: version, D: dependencies, p: provides, i: install-if. The entry for the gdk-pixbuf package:
C:Q1piu+yXjbT6J1HCYt1gW/UecNujQ=
P:gdk-pixbuf
V:2.42.12-r1
...
D:shared-mime-info /bin/sh so:libc.musl-x86_64.so.1 so:libglib-2.0.so.0 ...
p:so:libgdk_pixbuf-2.0.so.0=0.4200.12 cmd:gdk-pixbuf-query-loaders=2.42.12-r1 ...
The C: checksum's prefix encodes both representation and algorithm. Per apk-tools' digest parser, the first character selects the encoding (Q for base64, X for hex) and the second the algorithm (1 for SHA-1, 2 for SHA-256). The Q1 prefix seen throughout Alpine's indices is base64-encoded SHA-1, while every other family publishes hex SHA-2 digests.
Alpine's entry point is dl-cdn.alpinelinux.org, the first entry of the mirror list the Alpine wiki's Mirrors page points to; this page also tracks the status of every listed mirror.
Dependency Languages
Packages never stand alone: each one needs, provides, and conflicts with others, and a resolver must read those statements to build a correct closure. This section explores how each of the four families declares them, in its own dependency language, and it covers the grammar of each language and the meaning of each construct.
Debian
In the Indices section we have introduced the index for Debian, a remote archive that contains the list of packages available to retrieve. This file defines the relationships between all packages in specific fields, and they use a syntax defined as follows using the EBNF-style notation:
depends = dependency , dependency , ... every one must hold
dependency = alternative | alternative | ... any one may hold
alternative = package-name [ "(" op version ")" ]
op = "<<" | "<=" | "=" | ">=" | ">>"
The grammar applies to the relationship fields, and the Fields tab names each field with its meaning. Alternatives covers a dependency that any one of several named packages may satisfy, Virtual packages covers a dependency that any of several packages may satisfy by offering the same shared name, and Essential packages covers the packages a system requires without any declaration naming them.
The operators are intuitive in what meaning they convey:
<<: only versions older than the written one pass.<=reads "less than or equal": the written version and older pass.=: only the written version passes.>=: the written version and newer pass.>>: only versions newer than the written one pass.
The same syntax serves every relationship field, with one restriction: alternatives may appear only in the fields that declare dependencies, not in Breaks or Conflicts. The fields, defined in Debian Policy 7, each give the declaration its meaning:
DependsandPre-Dependsname packages required for this one to work.Pre-Dependsis the stricter form, demanding its packages be fully installed before this one is even unpacked.RecommendsandSuggestsname packages that add to this one without being required, in decreasing order of strength.BreaksandConflictsname packages that cannot be present together with this one, withConflictsas the stronger form.
A simple form of declaring a dependency is: Depends: libc6, which is satisfied only by libc6. Some packages can do their job with any one of several interchangeable packages and the syntax lets a dependency express that. A dependency may list several package names separated by pipes, exactly as the grammar's line dependency = alternative | alternative | ... shows, and the pipe reads as "or": the dependency is satisfied when any one of the listed packages is installed. Each listed package is called an alternative.
A package's record can carry a Provides field that lists extra names the package is able to satisfy.
Package: mawk
Version: 1.3.4.20250131-1
Architecture: amd64
Provides: awk
Depends: libc6 (>= 2.38)
Priority: required
Filename: pool/main/m/mawk/mawk_1.3.4.20250131-1_amd64.deb
Each name is a group that several packages may offer, standing for a role rather than for one concrete program, such as awk or mail-transport-agent.
Package: gawk
Version: 1:5.2.1-2+b1
Provides: awk
...
Package: mawk
Version: 1.3.4.20250131-1
Provides: awk
...
Package: original-awk
Version: 2025-01-16-1
Provides: awk
...
Such a group is called a virtual package. A dependency can name it, and the resolver then satisfies the dependency by installing any one of the packages that offer the name, those packages are called its providers.
Essential packages are required without ever being declared. A package marked Essential: yes must be present and functional on every installed system, and other packages are instructed not to declare dependencies on it, since its presence is guaranteed regardless.
Walkthrough: Debian dependencies in FlatRoot
A record's relationship fields reach FlatRoot as text, and the resolver needs a data structure to work with. This walkthrough follows the parsing of that text into FlatRoot's dependency structures. The parsing happens during the index processing walked through in the Indices section: there, catalogue_parse extracts each record from the index text, block_flush closes a completed record, and its call to build assembles the record's fields into a Package, handing each relationship field to depends_parse.
The parsed form must preserve what the grammar distinguishes: which packages are all required, and which are interchangeable. FlatRoot keeps the distinction in two structures: a Dependency is one comma-separated group, holding its alternatives, any one of which satisfies it, and a DepSpec is one alternative, the package name with its version constraint when one is declared.
A relationship field mixes both separators, commas between groups and pipes inside them, and the grammar orders them: commas bind the loosest, so they split first. depends_parse follows that order: the field splits at the commas into groups, each group splits at the pipes into its alternatives, and each alternative splits at the opening parenthesis into the package name and the version constraint. Each group becomes one Dependency, holding one DepSpec per alternative.
A dependency on a virtual package is only resolvable when the database knows the name's providers, and the Provides field is where a package declares them. The field is a plain list with no alternatives, so provides_parse only splits it at the commas, keeping each name with its pinned version when one is declared. mawk's Provides: awk records it as a provider of awk.
RPM Package Manager
In the Indices section we have introduced primary.xml, the catalog that holds one entry per package. An entry states its relationships in lists, and every item of a list is one <rpm:entry> tag, defined as follows using the EBNF-style notation:
entry = name [ flags epoch ver [ rel ] ] one item of a list
name = capability | "(" expression ")"
capability = package-name | file-path | soname
expression = capability , "and" , capability , ... every operand must hold
| capability , "or" , capability , ... any operand may hold
| capability , "with" , capability both, met by one package
| capability , "without" , capability the first, not the second
| capability , "if" , capability [ "else" , capability ]
| capability , "unless" , capability [ "else" , capability ]
flags = "LT" | "LE" | "EQ" | "GE" | "GT"
Each operator of the parenthesized form, check boolean dependencies manual for the full reference.
The grammar applies to the lists of an entry, and the Recipes tab names each list with the spec file line it is generated from. Capabilities covers the names packages offer and require and the matching between them, Virtual packages covers a dependency that any of several packages may satisfy by offering the same shared name, and Weak dependencies covers the dependencies an installation may leave unsatisfied.
The flag names, written by createrepo_c, are the five usual comparisons:
LT: only versions older than the written one pass.LE: the written version and older pass.EQ: only the written version passes.GE: the written version and newer pass.GT: only versions newer than the written one pass.
The version each one compares against is written as [epoch:]version[-release], split into the epoch, ver and rel attributes of the same tag (rpm dependencies manual). For example, <rpm:entry name="filesystem" flags="GE" epoch="0" ver="3"/> asks for the filesystem package at version 3 or newer.
A dependency names a string, and rpm calls that string a capability (rpm-spec(5)). Packages offer capabilities and require them, and a requirement is met when the strings match. The entry for bash in CentOS Stream 9 BaseOS requires filesystem at version 3 or newer and libtinfo.so.6()(64bit), and offers /bin/sh and bash:
<rpm:provides>
<rpm:entry name="/bin/sh"/>
<rpm:entry name="bash" flags="EQ" epoch="0" ver="5.1.8" rel="2.el9"/>
...
</rpm:provides>
<rpm:requires>
<rpm:entry name="filesystem" flags="GE" epoch="0" ver="3"/>
<rpm:entry name="libtinfo.so.6()(64bit)"/>
...
</rpm:requires>
To install bash, rpm takes each name in its requires list and looks for a package whose provides list holds the same name. The name filesystem leads to the filesystem package, which offers it at version 3.16:
<rpm:provides>
<rpm:entry name="filesystem" flags="EQ" epoch="0" ver="3.16" rel="2.el9"/>
<rpm:entry name="filesystem(x86-64)" flags="EQ" epoch="0" ver="3.16" rel="2.el9"/>
<rpm:entry name="filesystem-afs" flags="EQ" epoch="0" ver="3.16" rel="2.el9"/>
</rpm:provides>
A dependency can also be conditional using (boolean dependencies):
The index is generated from spec files, the recipes maintainers write according to rpm-spec(5). A relationship is one line of a recipe, such as Conflicts: pulseaudio, and it becomes the list of the same name in the package's entry, <rpm:conflicts> (createrepo_c). The pipewire recipe of CentOS Stream 9 declares its PulseAudio implementation as follows:
%package pulseaudio
Summary: PipeWire PulseAudio implementation
License: MIT
Recommends: %{name}%{?_isa} = %{version}-%{release}
Requires: %{name}-libs%{?_isa} = %{version}-%{release}
Conflicts: pulseaudio
Supplements: %{name} = %{version}-%{release}
Obsoletes: pulseaudio < 14.2-3
Provides: pulseaudio-daemon
...
Names written %{...} are macros that the build expands, %{name} here to pipewire, and omitted lines are marked .... The meaning of the tags is as follows:
Requiresnames a capability that has to be present for the package to work, and fills<rpm:requires>.Providesnames a capability the package offers beyond its own name, and fills<rpm:provides>.Conflictsnames a capability that must not be installed alongside the package, and fills<rpm:conflicts>.Obsoletesnames a package this one takes the place of on an update, and fills<rpm:obsoletes>.RecommendsandSuggestsname capabilities to install alongside this package, which a resolver may leave unsatisfied, and fill<rpm:recommends>and<rpm:suggests>.SupplementsandEnhancesask the other way around, for this package to be installed alongside the capability they name, and fill<rpm:supplements>and<rpm:enhances>.
The strength of the last four, and the difference between asking and being asked for, is read from an index entry in the Weak dependencies tab.
A dependency can name a virtual package, a capability that several packages offer next to their own names (rpm dependencies manual). The requirement is met by any one of the packages that offer it, so a web application depends on webserver and runs with whichever server the system carries. The entry for nginx in Fedora 43 offers the shared name next to its own:
<rpm:provides>
<rpm:entry name="nginx" flags="EQ" epoch="2" ver="1.28.0" rel="3.fc43"/>
<rpm:entry name="webserver"/>
...
</rpm:provides>
The entries for httpd, lighttpd and caddy offer webserver as well, and the index holds no package of that name. Learn more in (rpm dependencies manual).
A weak dependency names a package to install alongside this one, and the installation goes through even when the named package cannot be installed (rpm dependencies manual). It is declared in the lists <rpm:recommends>, <rpm:suggests>, <rpm:supplements> and <rpm:enhances>. The entry for firewalld in the Fedora 43 Everything repository declares:
<rpm:recommends>
<rpm:entry name="ebtables"/>
<rpm:entry name="ipset"/>
<rpm:entry name="iptables"/>
</rpm:recommends>
<rpm:suggests>
<rpm:entry name="iptables-nft"/>
</rpm:suggests>
Installing firewalld also installs the names under <rpm:recommends> whenever they can be installed, while the names under <rpm:suggests> are only offered to the user as options. <rpm:supplements> and <rpm:enhances> state the same relationships in the opposite direction: the package carrying the list is the one to add when the named package is installed.
Arch Linux
In the Indices section we have introduced the sync database, whose desc records group their values under %SECTION% headers. The values of the relationship sections are package relations, and they use a syntax defined as follows using the EBNF-style notation (alpm-package-relation(7)):
relation = name [ operator version ] written together, without spaces
optdepend = relation [ ":" description ]
name = package-name | soname
operator = "<" | "<=" | "=" | ">=" | ">"
The grammar applies to the relationship sections, and the Sections tab names each section with its meaning. Sonames covers a dependency on a shared library rather than on the package that ships it, Optional dependencies covers a dependency that adds functionality and is left to the user, and Virtual packages covers a dependency that any of several packages may satisfy by providing the same shared name.
The operators are the five usual comparisons (alpm-comparison(7)):
<: only versions older than the written one pass.<=: the written version and older pass.=: only the written version passes.>=: the written version and newer pass.>: only versions newer than the written one pass.
For example, the glibc>=2.27 of the gcc-libs entry requires glibc at version 2.27 or newer. A comparison whose version omits the packaging release "matches any release of a package version", so =1.0.0 accepts 1.0.0-1 and 1.0.0-2 alike.
A record separates its relations by kind, each kind under a section of its own (alpm-repo-desc(5)). The record for dbus in core declares:
The meaning of the sections is as follows:
%DEPENDS%holds the run-time dependencies, the relations that must be satisfied for the package to work.%PROVIDES%holds the names the package provides beyond its own.%CONFLICTS%holds the names that must not be installed alongside the package.%REPLACES%holds the names the package "replaces upon installation".%OPTDEPENDS%holds the optional dependencies of the Optional dependencies tab.%MAKEDEPENDS%and%CHECKDEPENDS%hold what building the package and running its tests need, rather than installing it.
A relation can name a shared library instead of a package, written as the library's soname with its interface version and word size, so libreadline.so=8-64 is the 64-bit libreadline.so.8 (alpm-sonamev1(7)). The record for bash in core depends on the library next to the package that carries it:
and the record for readline provides it:
Both lines are generated, since a package build tool (e.g. makepkg) automatically derives soname information for ELF files in the build environment: the provision comes from the libraries readline ships, and the dependency from the libraries bash links against.
An optional dependency names a package that "provides optional functionality for a package but is otherwise not required" (alpm-package-relation(7)), and carries the reason to install it after a colon (PKGBUILD(5)). The record for bash in core names its completion scripts:
The shell works without the completions, so the relation requires nothing, and a user who wants them installs the named package.
A relation can name a virtual component, a name that exists because packages provide it rather than because a package is called it (alpm-repo-desc(5)). The record for systemd in core depends on the virtual component dbus-units, and the records for dbus-broker-units and dbus-daemon-units provide it:
%NAME%
dbus-broker-units
%CONFLICTS%
dbus-daemon-units
%PROVIDES%
dbus-units
%NAME%
dbus-daemon-units
%CONFLICTS%
dbus-broker-units
%PROVIDES%
dbus-units
Either provider satisfies the dependency of systemd, and the choice between them belongs to the resolver. The mutual conflict keeps a single provider installed at a time.
Alpine Linux
In the Indices section we have introduced the APKINDEX record and its single-letter fields. The dependencies in its D: field are constraints, and they use a syntax defined as follows using the EBNF-style notation (apk-world(5)):
dependency = [ "!" ] name [ "@" tag ] [ operator version ]
name = package-name | file-path | namespaced-name
operator = "=" | "<" | ">" | ">=" | "~" | ">~" | "<~" | "><"
The ! prefix "changes the dependency constraint to a conflict and ensures that any package matching the specification is not installed", and the @tag suffix allows the package to come from a repository tagged with that name. The record for gettext-dev uses the conflict form, carrying !musl-libintl in its D: field.
The grammar applies to the dependency fields, and the Fields tab names each field with its meaning. Namespaced provides covers the names a build tool generates for the libraries and commands a package ships, Virtual packages covers a dependency that any of several packages may satisfy by providing the same shared name, and Install-if covers a package that installs itself when the packages it names are present.
The operators are the four plain comparisons and the prefix matches (apk-world(5)):
=: only the written version passes.<: only versions older than the written one pass.>: only versions newer than the written one pass.>=: the written version and newer pass.~reads "prefix match": every version that starts with the written one passes.>~: versions newer than or matching the prefix pass.<~: versions older than or matching the prefix pass.><: matches an identity hash instead of a version, generated when a package file is added directly.
For example, the python3~3.12 of the pssh record accepts any version of python3 starting with 3.12, the way busybox~1.6 in the man page accepts 1.6, 1.6.0_pre1, and 1.6.9_p1.
A record states each kind of relationship in a field of its own (apk-package(5)). The record for gdk-pixbuf in main declares:
P:gdk-pixbuf
V:2.42.12-r1
D:shared-mime-info /bin/sh so:libc.musl-x86_64.so.1 so:libglib-2.0.so.0 ...
p:so:libgdk_pixbuf-2.0.so.0=0.4200.12 cmd:gdk-pixbuf-query-loaders=2.42.12-r1 ...
The meaning of the fields is as follows:
D:holds the dependencies: "installing this package will require APK to first satisfy the list of all its dependencies".p:holds the names the package provides "in addition to its primary name and version".r:holds the packages whose files this one may overwrite, where a file owned by more than one package is otherwise an error.i:holds the condition under which the package installs itself, read in the Install-if tab.k:holds the package's priority among the providers of a shared name, read in the Virtual packages tab.
A provided name can carry a namespace prefix, which the format reserves for names a build tool generates (apk-package(5)). abuild, Alpine's package build tool, writes so: for the shared libraries a package ships, cmd: for its commands, and pc: for its pkg-config modules (APKBUILD(5)). The p: field of gdk-pixbuf offers its library and its commands:
A dependency names them the same way: the record for pssh carries cmd:ssh in its D: field, asking for the command and staying indifferent to the package that delivers it.
A provided name written without a version is a name several packages may hold at once, which apk considers "a virtual package name" (apk-package(5)). A dependency on it leaves the choice of provider open: either the user names a concrete provider, or the providers carry the provider-priority field k:, which "enables this automatic selection" and determines "which of the packages to install in case multiple packages provide the same non-versioned package name". The records of postgresql16 and postgresql17 in main both provide postgresql and both carry a priority:
P:postgresql16
V:16.15-r0
k:16
p:postgresql cmd:pg_ctl cmd:postgres ...
P:postgresql17
V:17.11-r0
k:17
p:postgresql cmd:pg_ctl cmd:postgres ...
A dependency on postgresql accepts either record, and k: decides between them.
A package can install itself instead of being asked for: "APK will automatically select and install the package if all of the install-if dependencies are satisfied" (apk-package(5)). The record for gdk-pixbuf-doc in main names the docs meta package and its own main package in the i: field:
The documentation package installs itself exactly when docs and gdk-pixbuf at that version are present, and gdk-pixbuf declares nothing about it. The field must name at least two dependencies, and one of them must carry the = operator.
Versioning
Versions give an order to the packages of a family, and a resolver must reproduce that order when it compares two versions. This section explores how each of the four families encodes and compares versions, covering the syntax of each format and the algorithm behind the comparison.
Debian
Per Debian Policy 5.6.12, a version is built from three components:
version = [ epoch ":" ] upstream-version [ "-" debian-revision ]
epoch = digit , digit , ... an unsigned integer
upstream-version = alphanumeric , { alphanumeric | "." | "+" | "-" | "~" }
debian-revision = alphanumeric , { alphanumeric | "." | "+" | "~" }
- epoch: an integer that overrides every other comparison,
0when absent. It exists so a package can leave behind a mistaken or incompatible numbering scheme. - upstream-version: the version of the packaged software itself, as published by its authors. It must start with a digit.
- debian-revision: counts the Debian packaging changes on top of the same upstream release.
The version string is split at its last hyphen, so hyphens may appear inside the upstream version only when a revision exists to end the string. The record's 5.2.15-2+b10 reads as upstream version 5.2.15 with packaging revision 2+b10 and no epoch.
This algorithm, defined in deb-version(7), is based on the three components:
- Compare the epochs as integers. The higher one wins outright:
1:2.0beats0:9.9, because the epoch decides before anything after it is read. - If the epochs are equal, compare the upstream versions. If those are equal too, compare the revisions:
5.2.15-2and5.2.15-3tie on the upstream version, and the revisions2and3decide. - Upstream version and revision are text, compared by the character walk.
This is the character walk behind the third step, an algorithm of its own that works on runs. A run is a maximal stretch of consecutive characters of one kind, digits or non-digits: 5.2.15 breaks into the runs 5, ., 2, ., and 15. The grammar bounds what a run can contain, so a digit run is always a number, and a non-digit run is made of letters and the punctuation . + - ~.
The walk, in pseudocode:
\begin{algorithm}
\caption{Compare two version strings by the character walk}
\begin{algorithmic}
\INPUT strings $u$ and $v$, with lengths $|u|$ and $|v|$
\OUTPUT $-1$, $0$, or $1$: $u$ is older, equal, or newer than $v$
\STATE $\mathrm{rank}(c)$: $-1$ for the tilde, $0$ for the end of a run, ASCII(c) otherwise
\STATE $i \gets 1$, $j \gets 1$
\WHILE{$i \le |u|$ or $j \le |v|$}
\COMMENT{one pass per pair of runs}
\WHILE{$i \le |u|$ and $u[i]$ is not a digit and $j \le |v|$ and $v[j]$ is not a digit}
\COMMENT{walk the non-digit runs in lockstep}
\IF{$\mathrm{rank}(u[i]) < \mathrm{rank}(v[j])$}
\COMMENT{the first differing character decides}
\RETURN $-1$
\ELSIF{$\mathrm{rank}(u[i]) > \mathrm{rank}(v[j])$}
\RETURN $1$
\ENDIF
\STATE $i \gets i + 1$, $j \gets j + 1$
\ENDWHILE
\IF{$i \le |u|$ and $u[i]$ is not a digit}
\COMMENT{the first string's non-digit run is longer}
\RETURN $-1$ if $\mathrm{rank}(u[i]) < 0$, else $1$
\ENDIF
\IF{$j \le |v|$ and $v[j]$ is not a digit}
\COMMENT{the second string's non-digit run is longer}
\RETURN $1$ if $\mathrm{rank}(v[j]) < 0$, else $-1$
\ENDIF
\WHILE{$i \le |u|$ and $u[i]$ is the digit 0}
\COMMENT{skip the leading zeros of the first digit run}
\STATE $i \gets i + 1$
\ENDWHILE
\WHILE{$j \le |v|$ and $v[j]$ is the digit 0}
\COMMENT{skip the leading zeros of the second digit run}
\STATE $j \gets j + 1$
\ENDWHILE
\STATE $d \gets 0$
\WHILE{$i \le |u|$ and $u[i]$ is a digit and $j \le |v|$ and $v[j]$ is a digit}
\COMMENT{compare the digit runs as numbers}
\IF{$d = 0$ and $u[i] \ne v[j]$}
\COMMENT{remember the first differing digit}
\STATE $d \gets -1$ if $u[i] < v[j]$, else $1$
\ENDIF
\STATE $i \gets i + 1$, $j \gets j + 1$
\ENDWHILE
\IF{$i \le |u|$ and $u[i]$ is a digit}
\COMMENT{the longer digit run is the larger number}
\RETURN $1$
\ENDIF
\IF{$j \le |v|$ and $v[j]$ is a digit}
\COMMENT{the longer digit run is the larger number}
\RETURN $-1$
\ENDIF
\IF{$d \ne 0$}
\COMMENT{equal length: the first differing digit decides}
\RETURN $d$
\ENDIF
\ENDWHILE
\RETURN $0$
\end{algorithmic}
\end{algorithm}
The tilde is the pre-release mechanism, the reason 1.0~beta is older than 1.0 (deb-version(7)).
The pieces assemble into full comparisons. In 5.2.15 against 5.3.1, the leading non-digit runs are empty, the 5 runs tie, the . runs tie, and the digit runs 2 against 3 decide. In 1.1 against 1+b1, the 1 runs tie, and the non-digit runs . against + decide by their ASCII order: 1+b1 is older.
The edge cases all fall out of the same walk. The tilde is the pre-release mechanism: it ranks below the end of a run, so 1.0~beta is older than 1.0.
Non-digit runs that differ are decided by ASCII order: . (46) ranks above + (43), so 1+b1 is older than 1.1.
Leading zeros are skipped before a digit run is compared, so 1.0 and 1.00 are equal.
And when one digit run outlasts the other, the longer run is the larger number: 1.10 is newer than 1.1.
The comparator is directly executable via dpkg --compare-versions:
Walkthrough: Debian versions in FlatRoot
The resolver decides between alternatives and checks every constraint by comparing versions, so FlatRoot implements the ordering the specification defines, presented by the Algorithm tabs of the Versioning section. This walkthrough follows how FlatRoot compares two Debian versions. The comparison has two entry points. The resolver checks a candidate against a dependency constraint in candidate_satisfies, reaching the comparator through satisfies. The package database orders the releases of a name with the collation registered in collation_register, reaching it through compare. Both parse their arguments with DebianVersion::parse.
A version arrives as one flat string, while the ordering works on its three components, so the string is split first. parse cuts the epoch at the first colon and defaults it to 0 when the colon is absent. The revision is cut at the last hyphen, the splitting rule of the Syntax tab. Cutting at the last hyphen keeps any earlier hyphen inside the upstream version.
With both sides parsed, the components decide in the order of the Algorithm tab: cmp compares the epochs as integers, moves to the upstream versions only on a tie, and to the revisions only on a second tie. The two text components are compared by fragment_compare.
fragment_compare is the character walk of the pseudocode: the non-digit runs drain in lockstep with each character pair weighed by char_order, and the digit runs that follow are read as whole numbers by number_extract, so 9 loses to 10 on value instead of winning on the first character.
The whole ordering of characters lives in one weight. The end of a part weighs 0 and the tilde -1, which is the entire pre-release rule: 1.0~beta runs out of characters below where 1.0 ends. Letters weigh their plain value and every other symbol weighs above the letters, the rule of Debian Policy 5.6.12 that all letters sort earlier than all non-letters.
RPM Package Manager
Per rpm-version(7), a version is a label of up to three components, known as the EVR:
evr = [ epoch ":" ] version [ "-" release ]
epoch = digit , digit , ... a non-negative integer
version = alphanumeric , { alphanumeric | "." | "_" | "+" | "~" | "^" }
release = same format as version
- epoch: the component that decides before any other, implicitly
0when omitted. As in Debian, it exists to work around versioning anomalies, and the man page calls it a last resort. - version: the packaged software's own version. The dash cannot appear inside it, since the dash separates the components.
- release: the packaging revision within one software version, ideally a plain integer reset to
1at each new version.
The bash entry's epoch="0" ver="5.2.26" rel="3.fc41" carries the three components already split. Comparison runs left to right across them and stops at the first difference, so a higher epoch wins outright: 5:3.0-1 is newer than 6.0-1. Equal epochs move the decision to the versions, and equal versions to the releases: 1.0-5 is newer than 1.0-1.
Within a component, comparison works on segments rather than characters. Consecutive digits form a numeric segment and consecutive letters an alphabetic one, while the separators only delimit and are never themselves compared: abc123 holds the segments abc and 123 and equals abc.123, and 1.0 equals 1+0. The segments then compare pairwise under four rules, each from the man page:
- Numeric segments compare as integers, with leading zeros ignored:
abc123also equalsabc.000123. - A numeric segment is always newer than an alphabetic one:
1.0is newer than1.xyz. - When one side runs out of segments, the side with more is newer:
0.0is newer than0, and1.xyzis newer than1. - The operator characters bend the order. A tilde makes its segment sort older, the pre-release mechanism:
2.0~beta1is older than2.0and newer than1.0. A caret makes it sort newer while staying before the next version:2.0^150825is newer than2.0but older than2.0.1.
The segment rules leave two things open, how the text is cut into segments and the order the rules apply in, and both are fixed by rpmvercmp, the comparator inside rpm. The walk cuts and compares one segment pair per pass:
\begin{algorithm}
\caption{Compare two components by rpm's segment walk}
\begin{algorithmic}
\INPUT strings $u$ and $v$
\OUTPUT $-1$, $0$, or $1$: $u$ is older, equal, or newer than $v$
\WHILE{$u$ or $v$ has characters left}
\STATE drop the leading separators of $u$ and of $v$
\COMMENT{every character that is not alphanumeric, a tilde, or a caret}
\IF{$u$ or $v$ now starts with a tilde}
\COMMENT{the tilde sorts before everything, the end of the component included}
\IF{only one side starts with a tilde}
\RETURN $-1$ if it is $u$, else $1$
\ENDIF
\STATE drop the tilde from both sides
\CONTINUE
\ENDIF
\IF{$u$ or $v$ now starts with a caret}
\COMMENT{the caret sorts after the end of the component, before everything else}
\IF{one side has ended}
\RETURN $1$ if it is $v$, else $-1$
\ENDIF
\IF{only one side starts with a caret}
\RETURN $-1$ if it is $u$, else $1$
\ENDIF
\STATE drop the caret from both sides
\CONTINUE
\ENDIF
\IF{$u$ or $v$ has ended}
\BREAK
\ENDIF
\STATE $s, t \gets$ the leading segments of $u$ and $v$, cut by the kind of $u$'s first character
\COMMENT{a digit run when it is a digit, a letter run otherwise}
\IF{$t$ is empty}
\RETURN $1$ if $s$ is numeric, else $-1$
\COMMENT{the sides disagree on the kind: the numeric side is newer}
\ENDIF
\IF{$s$ and $t$ are numeric}
\STATE drop the leading zeros of both
\IF{their lengths differ}
\RETURN $1$ if $s$ is longer, else $-1$
\COMMENT{the longer number is the larger}
\ENDIF
\ENDIF
\IF{$s \ne t$}
\RETURN their byte order
\ENDIF
\ENDWHILE
\IF{both sides have ended}
\RETURN $0$
\ENDIF
\RETURN $-1$ if $u$ has ended, else $1$
\COMMENT{the side with characters left is newer}
\end{algorithmic}
\end{algorithm}
The walk settles the caret example end to end: in 2.0^150825 against 2.0.1, the segments 2 and 0 tie, then the caret meets the digit 1, and since the caret sorts before every character, 2.0^150825 is older.
Arch Linux
Per vercmp(8), a version has the same three components as RPM's label:
- epoch: overrules any comparison unless equal on both sides:
2:1.0-1is newer than1:3.6-1, and1:001is newer than4.34. - pkgver: the packaged software's own version.
- pkgrel: the packaging revision, with one deliberate departure from RPM: it "is only compared if it is available on both versions". Comparing
1.5-1with1.5yields equality, while1.5-1against1.5-2compares the releases, so a dependency constraint without a release matches every packaging revision of its version.
The component comparison is the segment walk of the RPM Package Manager block, adopted from rpm's rpmvercmp per the attribution in libalpm's source. The man page condenses the behavior into two sorted chains:
Alphanumeric: 1.0a < 1.0b < 1.0beta < 1.0p < 1.0pre < 1.0rc < 1.0 < 1.0.a < 1.0.1
Numeric: 1 < 1.0 < 1.1 < 1.1.1 < 1.2 < 2.0 < 3.0.0
The alphanumeric chain shows the segment rules at work. A letter segment attached to the numbers sorts older than the bare version, so every 1.0x form is a pre-release of 1.0, ordered among themselves by the segment text (a before b before beta). Behind a separator the same letter sorts newer instead: 1.0.a is newer than 1.0, and the numeric segment of 1.0.1 beats the alphabetic a.
The adopted walk is not a verbatim copy, and the departures are what give pacman its own ordering (version.c): the tilde and the caret have no rule of their own and count as ordinary separators, a difference in the number of separators decides before the segments do, and a component that ends while the other continues with letters wins, the rule behind 1.0rc sorting before 1.0 in the chains.
\begin{algorithm}
\caption{Compare two components by pacman's segment walk}
\begin{algorithmic}
\INPUT strings $u$ and $v$
\OUTPUT $-1$, $0$, or $1$: $u$ is older, equal, or newer than $v$
\WHILE{neither $u$ nor $v$ has ended}
\STATE drop the leading separators of $u$ and of $v$
\COMMENT{every character that is not alphanumeric, the tilde and the caret included}
\IF{$u$ or $v$ has ended}
\BREAK
\ENDIF
\IF{the two sides dropped different numbers of separators}
\RETURN $-1$ if $u$ dropped fewer, else $1$
\ENDIF
\STATE $s, t \gets$ the leading segments of $u$ and $v$, cut as in rpm's walk
\IF{$t$ is empty}
\RETURN $1$ if $s$ is numeric, else $-1$
\COMMENT{the sides disagree on the kind: the numeric side is newer}
\ENDIF
\IF{$s$ and $t$ are numeric}
\STATE drop the leading zeros of both
\IF{their lengths differ}
\RETURN $1$ if $s$ is longer, else $-1$
\ENDIF
\ENDIF
\IF{$s \ne t$}
\RETURN their byte order
\ENDIF
\ENDWHILE
\IF{both sides have ended}
\RETURN $0$
\ENDIF
\IF{$u$ has ended}
\RETURN $1$ if $v$ continues with a letter, else $-1$
\COMMENT{a leftover of letters reads as a pre-release and loses}
\ENDIF
\RETURN $-1$ if $u$ continues with a letter, else $1$
\end{algorithmic}
\end{algorithm}
The closing rule is the chains' pre-release ordering at work: 1.0rc against 1.0 ties through 1 and 0, then 1.0 ends while 1.0rc continues with letters, and the leftover loses. A numeric leftover still wins, which keeps 1.0.1 newer than 1.0.
Alpine Linux
apk-package(5) gives the version grammar directly:
- numbers: sequences of digits, forming the dotted core of the version.
- letter: a single lowercase letter, allowed only after the last numeric component.
- suffixes: words from the fixed set
alpha,beta,pre,rc,cvs,svn,git,hg, andp, each optionally followed by a number. - hash: a commit hash of lowercase hexadecimal, recording where the package was built from.
- -r number: the package build revision. The entry's
2.42.12-r1reads as the numeric core2.42.12at build revision1.
Comparison works on the grammar's pieces, each kind ordered by a rule of its own:
- The numbers of the dotted core compare as integers. A part with a leading zero instead compares as text, a rule inherited from Gentoo's scheme, so
1.05is older than1.1. - The letter compares by its character:
1.0bis newer than1.0a. - The suffix words rank in the total order
alpha<beta<pre<rc< no suffix <cvs<svn<git<hg<p. Words ranked before "no suffix" mark pre-releases and sort older than the bare version, and words ranked after mark snapshots and patches and sort newer:1.0_rc1is older than1.0, and1.0_p1is newer. - The suffix numbers and the build revisions compare as integers.
The rules of the pieces still need an order to apply in, and version.c fixes it: apk reads each number of the dotted core, the letter, each suffix with its number, the hash, and the build revision as one piece, called a token, and compares two versions token against token:
\begin{algorithm}
\caption{Compare two versions by apk's token walk}
\begin{algorithmic}
\INPUT strings $u$ and $v$
\OUTPUT $-1$, $0$, or $1$: $u$ is older, equal, or newer than $v$
\STATE read the first token of each side, the number before the first dot
\WHILE{the two tokens are of the same kind and neither side has ended}
\IF{the token values differ}
\RETURN their order
\COMMENT{numbers as integers, a dotted part with a leading zero as text, letters by character, suffix words by their rank}
\ENDIF
\STATE read the next token of each side
\ENDWHILE
\IF{the two tokens are of the same kind}
\RETURN $0$
\COMMENT{both sides ended together}
\ENDIF
\IF{$u$'s token is a pre-release suffix}
\RETURN $-1$
\ENDIF
\IF{$v$'s token is a pre-release suffix}
\RETURN $1$
\ENDIF
\RETURN $1$ if $u$'s token comes earlier in the grammar, else $-1$
\COMMENT{in particular, the side that ended is older}
\end{algorithmic}
\end{algorithm}
The pre-release check applies the suffix order at the point where the sides diverge: 1.0_rc1 against 1.0 ties on 1 and 0, then 1.0 ends, and since rc ranks before the bare version, the suffix side is older. Every other divergence favors the side that continues, so 1.0_p1 and 1.0.1 are both newer than 1.0.
Resolving the Closure
In this section we explain our approach to resolve the closure, the algorithm is a breadth-first walk over the dependency graph, followed by a fixed-point pass that differs based on the target distribution.
Closure Divergences
Two resolvers can return two different closures for the same request, both valid. The divergences trace back to constructs of the dependency languages.
Virtual packages
A dependency can name a virtual package, a role that several packages offer. On Debian bookworm, the essential package base-files depends on awk, and awk is provided by gawk, mawk, and original-awk. The specification defines who may fill the role and stays silent on who does, so the pick belongs to the resolver, and each pick yields a different graph, every one of them correct:
Walkthrough: virtual packages in FlatRoot
When dealing with virtual packages, the problem is which to select and if it satisfies the constraints to be considered a valid selection. Furthermore, this selection must be deterministic, i.e., the same conditions select the same packages and yield the same package closure. When the walk meets a virtual name, name_resolve resolves it through provider_first, which delegates the pick to one query over the package database.
Several providers may fill the role, so the query needs an order with no ties to make the pick reproducible. first orders the providers by the priority their distribution assigns, the required level ahead of optional, and breaks ties by name. On bookworm, mawk is priority required while gawk and original-awk are optional, so the awk of the divergence example resolves to mawk on every run.
Alternatives
A package may declare its dependencies as several possible choices, called alternatives, as seen in the Alternatives tab of the Dependency Languages section. On bookworm, bsd-mailx declares Depends: default-mta | mail-transport-agent, and any one of the listed alternatives satisfies the group. The order states the packager's preference, and the specification demands nothing beyond one satisfied alternative, so this pick too belongs to the resolver.
Walkthrough: alternatives in FlatRoot
Between the alternatives, only one must be selected. FlatRoot selects by the order the alternatives are written in the dependency line, the preference of the package's author: in default-mta | mail-transport-agent, default-mta is tried first. walk queues each admitted package's dependencies through deps_walk.
A dependency line mixes both constructs of the grammar: the commas separate the groups, every one of which must hold, and the pipes separate the alternatives inside a single group, any one of which satisfies it. deps_walk walks every group of the line and queues exactly one package per group, the pick of dep_resolve: the alternatives are tried left to right in their written order, and the first candidate whose version satisfies the constraint wins. Depends: a | b, c, d | e defines three comma-separated groups, where the first and last have two alternatives, so the walk queues three packages, one per group.
File Dependencies
File paths
An RPM dependency can name a file instead of a package: on Fedora 41, gawk requires /usr/bin/sh. The requirement is on whichever package installs that path, so resolving it needs the mapping from files back to their owning packages, published separately from the package index. Several packages may install the same path, so the pick between owners is one more source of divergence.
Walkthrough: file dependencies in FlatRoot
A file path is the last resort of name resolution: a name that is neither a package nor a virtual name may still be a path some package owns. walk reaches this case when it gathers the candidates for a name in candidates_get.
candidates_get tries the three name spaces in preference order: the package itself, then every provider of the virtual name, and last the file path. path_owner_get answers the path case from the path index, the file-to-owner mapping ingested during index processing, and a source that publishes no such mapping resolves no path dependencies.
Version Constraints
Constraint satisfaction
A dependency may accept only some versions of its target: on bookworm, libcrypt1 requires libc6 (>= 2.36). The Versioning section showed the comparison algorithms of each family, and they steer the selection of alternatives and providers: a candidate whose version does not satisfy the constraint is passed over, and the next candidate, then the next alternative, is considered. When the whole list is exhausted, an optional dependency is dropped, while a hard dependency falls back to its first alternative.
Consider that a package bundles several libraries, each with its own version, while other packages depend on those libraries at specific versions. For this case, virtual packages support a mechanism to include a version in the declaration of the Provides: entry: on bookworm, node-acorn declares Provides: node-acorn-bigint (= 1.0.0), node-acorn-node (= 2.0.1), the versions of the libraries it bundles. That way, a constraint on a bundled library is checked against the declared version: node-detective requires node-acorn-node (>= 1.3.0) and node-syntax-error requires node-acorn-node (>= 1.2.0), both pass against 2.0.1, and both packages pull node-acorn.
Walkthrough: version constraints in FlatRoot
Selecting an alternative or a provider is complete only when the candidate's version satisfies the dependency's constraint. The check runs during the selection itself, inside dep_resolve: for each candidate gathered by candidates_get, it calls candidate_satisfies, and the first candidate to pass is the pick.
On bookworm, node-detective requires the acorn-node library at version 1.3.0 or newer: Depends: node-acorn-node (>= 1.3.0). The acorn-node library ships inside another package, node-acorn, and each release of node-acorn bundles a different acorn-node version. To satisfy the constraint, the resolver needs the version of the bundled acorn-node, which is declared in the Provides: version field:
Package: node-acorn
Version: 8.8.1+ds+~cs25.17.7-2
Provides: node-acorn-bigint (= 1.0.0), node-acorn-node (= 2.0.1), ...
As a candidate for node-acorn, its version is 8.8.1+ds+~cs25.17.7-2. As a candidate for node-acorn-node, its version is 2.0.1. The constraint is checked against the version of the name it was written for: 2.0.1 >= 1.3.0, so the check passes. candidate_satisfies takes the version provides_version_get declares for the requested name, else the candidate's own, and compares it with the family's satisfies, the comparators of the Versioning walkthroughs.
Undeclared Requirements
Essential packages and the base
A sane root filesystem contains packages no dependency reaches. Debian marks a set of essential packages, required on every system and deliberately never declared as dependencies, since their presence is guaranteed: bookworm marks 23, bash, coreutils, and dash among them. Every family also assumes a base already present when its package manager runs, the filesystem layout, the C library, a shell. Both must enter the closure by seeding, since no edge of the graph leads to them.
Walkthrough: the seeds' sources in FlatRoot
The seeds are gathered by seed_set, and its two lists come from two different sources.
FlatRoot carries two shared lists, one for the RHEL descendants and one for the pacman descendants. openSUSE and Alpine define their own list in their builders, and the Debian family carries an empty one, since the essential set covers its baseline. The base a working system assumes comes from FlatRoot itself. The constants RHEL and PACMAN in the snippet are fixed lists of package names hardcoded in the source, and from_table copies the chosen list into the distribution's description. Every closure includes the baseline of its distribution, independently of the packages the user asked to install.
The essential packages, in contrast, are recorded: the index processing of the Indices section stores each record's Essential: yes mark, and one query returns the whole set at resolve time.
Conditional Dependencies
Boolean dependencies
RPM lets a dependency hold only under a condition on the installed set: on Fedora 41, SDL2 declares (libdecor-0.so.0()(64bit) if libwayland-client), requiring the libdecor library only when libwayland-client is present. The condition reads the final package set, and satisfying it changes that set, so the rule is undecidable while the graph is still being walked: it is evaluated against a complete closure with the fixed-point iteration.
Walkthrough: boolean dependencies in FlatRoot
A conditional dependency is a dependency included only when its condition holds in the closure, so the resolver must evaluate the condition against the closure to decide the inclusion. The expression arrives as text inside the dependency line, and rich_dep_fixpoint needs it as a structure it can evaluate. The work splits in two halves: the parse during package indexing, and the evaluation of one expression during closure resolution.
An expression nests, (A if (B and C)) is legal, so an expression can contain expressions, and the parser must recurse. parse reads the parenthesized text one operator per level, building a tree with one node per operator. The tree is stored in the package database during package indexing, and closure resolution reads it back.
During closure resolution, evaluating the tree answers which packages the expression requires given the current closure, one rule per operator as the snippet shows. Every package an evaluation adds changes the closure, and the changed closure can flip the condition of another expression, so each addition demands a further evaluation round: the rounds repeat until one adds nothing, the fixed point.
Install-if triggers
Alpine allows the opposite: instead of a package in the closure conditionally requiring another, the resolver conditionally includes a package when its triggers are satisfied. On Alpine 3.20, 7zip-doc carries the triggers 7zip and docs, so it is installed once both are in the closure. This rule is also evaluated against the complete closure with the same fixed-point iteration.
Walkthrough: install-if triggers in FlatRoot
Including a triggered package requires two decisions: whether every trigger it names is met by the closure, and whether the package's own dependencies can be satisfied inside it. The install_if_fixpoint function repeats the two decisions until a sweep adds no package.
The trigger_satisfied function decides whether one trigger is met through a presence test: the test passes when the closure contains the named package, or when it contains a provider of that name, resolved with the same provider logic as every other name.
An included package brings its own hard dependencies, and including it while one of them cannot be satisfied would leave the closure with an unmet hard dependency. apk skips such a candidate instead of failing the resolution, and FlatRoot matches: the transitive_resolve_optional function adds the candidate together with its full closure as one unit, and any unsatisfiable part rolls the walker back to the exact state before the attempt.
Graph Shape
Cycles
Dependencies form a graph rather than a tree, and the graph carries real cycles: on bookworm, libgcc-s1 depends on libc6 (>= 2.35), and libc6 depends on libgcc-s1. A walk that revisits names never terminates, so every name must be accounted for exactly once.
Walkthrough: cycles in FlatRoot
Termination rests on one invariant, a package is visited at most once during the walk, and the invariant lives in Closure, the walker's state.
Closure is the walker's memory: visited holds every name already accounted for, and walk skips any name found in it, so libc6 and libgcc-s1 queue each other once.
Install order
The closure's order matters as much as its content, since extraction consumes it package by package. A package that shapes the directory layout must be unpacked before the packages that write into that layout: usrmerge plants the /bin to /usr/bin symlink, and a package extracted into /bin before the symlink exists creates a real directory that breaks the merged layout. The order that satisfies this is dependency-first, every package ahead of the packages that need it.
Walkthrough: install order in FlatRoot
The order is not recomputed at extraction time: the output list of resolve already carries it, and extraction consumes the list as given.
The archives_extract function unpacks the archives through one pass over the resolver's list, in its order: a package like usrmerge creates directory symlinks, and a package extracted into /bin before the symlink exists would create a real directory instead. The order itself is the walk's reach order, anything needed by something else ahead of the thing that needs it.
Negative and Optional Relations
Conflicts
Some relations subtract instead of add: on bookworm, postfix conflicts with mail-transport-agent, the very virtual name every mail server provides, so at most one of them may be installed. A conflict constrains the closure without contributing a package to it.
Walkthrough: conflicts in FlatRoot
A conflict must be surfaced without vetoing the install, since the user may have asked for both sides. walk registers each admitted package's conflicts as it admits them.
conflicts_register records each admitted package's Conflicts and Breaks entries, keeping only the ones whose version constraint matches the version the index carries. The map feeds the check at the top of the walk's loop: a later pick landing on a registered name is reported as a warning, and the resolve continues.
Optional dependencies
Debian's Recommends and Suggests and Arch Linux's optional dependencies mark packages that improve the target without being required, so following them is a choice, and installers choose differently: apt follows Recommends by default (apt.conf(5)), while pacman lists optional dependencies and installs none. Two correct closures for the same target can then differ by every package an optional edge brings in.
Walkthrough: optional dependencies in FlatRoot
Following optional edges is the installer's choice, so the choice must reach the resolver as input. The install command carries it as its --with option, and the resolve reads it from its environment.
ResolutionEnv is everything one resolve reads, and its two flags are the choice made explicit: include_recommends and include_suggests widen the walk to the optional kinds, both defaulting to off, so the closure FlatRoot builds is the narrow one unless the user asks otherwise. The widening happens where the walk queues dependencies, gated on exactly these flags.
The Graph Walk
The closure's defining question is which packages an installation must contain for the requested ones to work. The resolver answers it with a walk, a breadth-first traversal of the dependency graph. Following declared dependencies gives only part of the answer, since a working system also carries packages nothing declares. Every distribution assumes a base, the filesystem layout, the C library, a shell, and the core utilities, and marks a set of essential packages, required on every installed system and for that reason never declared as dependencies. The walk therefore starts from the seeds: the requested packages, joined with the base and the essential packages. A queue holds the names to visit and a set holds the names already accounted for, so a cycle cannot loop. Each name leaving the queue is resolved to the package that satisfies it: a real package, else the first provider of a virtual name, else the owner of a file path. Every dependency group of that package must hold, so the walk pushes one alternative per group into the queue, the first whose version satisfies the constraint under the family's comparator. A name is accounted for exactly once, so the walk terminates when no new package appears. The names leave the queue in dependency-first order, and that ordered list is the closure, ready for extraction to unpack in sequence.
\begin{algorithm}
\caption{Resolve the dependency closure}
\begin{algorithmic}
\INPUT the requested packages (e.g. bash)
\OUTPUT the closure in dependency-first order
\STATE S $\gets$ the seeds: requested $\cup$ essential $\cup$ base
\STATE Q $\gets$ a queue holding every seed
\STATE visited $\gets$ the empty set
\STATE order $\gets$ an empty list
\WHILE{Q is not empty}
\STATE name $\gets$ pop the front of Q
\IF{name is in visited}
\STATE continue
\ENDIF
\STATE pkg $\gets$ the package satisfying name
\COMMENT{a real name, a provider of a virtual name, or a path owner}
\STATE visited $\gets$ visited $\cup$ $\{name\}$
\FOR{each dependency group of pkg}
\STATE dep $\gets$ the first alternative whose version satisfies the constraint
\STATE push dep to Q
\ENDFOR
\STATE append name to order
\ENDWHILE
\RETURN order
\end{algorithmic}
\end{algorithm}
Walkthrough: the walk in FlatRoot
FlatRoot runs the walk from the install command down to the ordered closure. The install command resolves the requested patterns into package names and hands them to resolved_set, which builds the seeds and starts the walker through DepWalker::resolve.
The walk needs its starting names before anything else. seed_set gathers them in priority order: the distribution's base packages, kept only when the index carries them, then the essential packages the index reports, then the requested packages, each name entering once.
walk realizes the loop of the pseudocode, and it begins with the loop's state: the queue is seeded with the requested names, and a map gathers the conflicts met along the way.
Each pass of the loop takes one name from the queue. A name already accounted for, or excluded by the user, is skipped. Every other name is resolved to a real package through name_resolve, and the package is marked as accounted for, the guarantee that a cycle cannot loop.
A pick can clash with a package already selected. The clash is reported as a warning rather than an error, since the user may have asked for both sides. The package's own Conflicts and Breaks are then registered through conflicts_register, so a later pick that clashes with this one warns too.
Every dependency group of the package must hold, so the hard groups are queued through deps_walk, one chosen alternative each. The recommends and suggests kinds are optional, queued only when the install asked for them. The package then joins the order, the closure being assembled.
A name leaving the queue is not yet a package, since it may name a real package, a virtual name, or a file path. name_resolve tries the three in that order: the real package wins when the index carries the name, else the first provider of the virtual name under a fixed ordering, so the same provider wins every run, else the owner of the file path.
Each dependency group admits several alternatives, and the walk must pick exactly one. dep_resolve tries the alternatives in the packager's order and, inside each, every candidate able to satisfy it, and the first candidate whose version passes candidate_satisfies wins. A hard group with no fit still returns its first alternative, so the shortfall surfaces at that name instead of disappearing. An optional group with no fit returns nothing.
Fixed Point Iteration
Two constructs from the Dependency Languages section cannot be decided during the walk, because their conditions reference the final closure. RPM's boolean dependencies require (A if B) to add A only when B is already in the set, and Alpine's install-if triggers volunteer a package when every package the trigger names is present. Both are evaluated against the current closure, after the walk, and both can change it, so the evaluation repeats until a pass adds nothing, the fixed point.
\begin{algorithm}
\caption{Apply the conditional rules to a fixed point}
\begin{algorithmic}
\INPUT the closure from the walk
\REPEAT
\STATE added $\gets$ false
\FOR{each conditional rule of a package in the closure}
\IF{the rule's condition holds in the current closure}
\STATE add the rule's payload with its own dependencies
\STATE added $\gets$ true
\ENDIF
\ENDFOR
\UNTIL{added is false}
\RETURN the closure
\end{algorithmic}
\end{algorithm}
The two rule sets are the same loop with different conditions. The rich-dependency pass re-evaluates each package's conditional expressions against the current set, adding the payload and its own closure when the condition holds. The install-if pass scans the candidates that carry triggers and admits each one whose triggers are all present, directly or through a provider, skipping a candidate whose own dependencies cannot be satisfied. One admission can satisfy another rule's condition, which is why the loop runs to a fixed point rather than once.
Walkthrough: the fixed point in FlatRoot
The conditional passes run after the walk returns, walk_internal runs the rich-dependency pass and then the install-if pass.
The conditional rules judge their conditions against the closure, so the closure must exist before they run. walk_internal orders the resolve accordingly: the plain dependencies resolve first, then the rich-dependency fixpoint, then the install-if fixpoint.
One admission can satisfy the next condition, so a single pass is not enough. rich_dep_fixpoint loops whole passes: each pass re-evaluates the conditionals of every package already in the closure through rich_deps_apply, and the loop ends at the pass that changes nothing, the fixed point.
An install-if candidate enters the closure through its triggers alone, so the resolver examines the candidates carrying triggers rather than the members of the closure. The install_if_fixpoint function checks every candidate and collects the ones whose triggers are all met, each trigger satisfied by the named package or by one of its providers through trigger_satisfied. An admitted candidate brings its own hard dependencies, and a candidate whose dependencies cannot be satisfied is skipped with a warning and never reconsidered. The examination repeats until it admits nothing, since one admission can meet another candidate's triggers.
Post Extraction Steps
In this section, we present the steps that run after extraction to turn the unpacked tree into a working system. Unpacking leaves every file in place, yet programs also depend on artifacts no package ships: caches derived from the installed files, and configuration that only exists once created on the system itself. We cover the three phases in order, the dynamic-linker cache, the maintainer scripts, and the application caches, closing with where each family runs the caching programs from.
The dynamic-linker cache
An executable finds its shared libraries through the run-time linker, which reads the ordered list written to /etc/ld.so.cache (ldconfig(8)). The cache is built by ldconfig, which scans the installed libraries, creates the soname links, and writes the list. The cache must reflect every library on the tree, so it is built after the last library is unpacked, and because ldconfig may write root-owned files, it "should normally be run by the superuser" (ldconfig(8)), one more place the Background's superuser requirement enters the final steps.
Walkthrough: the dynamic-linker cache in FlatRoot
FlatRoot builds the cache with the superuser requirement removed: ldconfig runs as the unprivileged user, inside namespaces where that user appears as the superuser and the new tree appears as /. This walkthrough follows the cache build from the install command, which runs the post-install phases after the last archive is unpacked.
The phases need the sandbox before anything runs. run first verifies that unprivileged user namespaces are available, and refuses with the enabling instruction otherwise, since every phase depends on them. One sandbox is then created, presenting the rootfs at /, and the ldconfig phase hands it to Ldconfig::run.
The cache must list the libraries of the new tree, not the host's, so the ldconfig that runs is the rootfs's own. Ldconfig::run locates the binary inside the rootfs, and a rootfs without one, the musl families, is skipped rather than failed. The found binary runs through the sandbox, and a failed run is reported rather than aborting the install.
ldconfig writes root-owned files under /etc, exactly what an unprivileged user cannot do on a real system. The sandbox removes the obstacle with namespaces: child_run enters a fresh user namespace and mount namespace, waits for the parent to write the UID and GID mappings that turn the unprivileged user into the superuser inside, mounts the rootfs as /, and only then executes the command. To ldconfig, the run is a superuser rebuilding /etc/ld.so.cache on a real root.
The maintainer scripts
Installing a package takes more than placing its files, since some installation steps are commands to run on the target system: bash appends itself to the list of login shells in /etc/shells, and mawk points the shared awk name at itself through update-alternatives. A package ships these steps as its maintainer scripts, executable hooks the package manager runs at install time (Debian Policy 6.1). The scripts expect the finished tree underneath, which is why they run as a post-extraction step: the postinst of bash calls update-alternatives, a program unpacked onto the tree by another package.
Walkthrough: the maintainer scripts in FlatRoot
FlatRoot replays each package's saved install scripts with neither the superuser nor a booted system available. This walkthrough follows the replay from the install command down to one running postinst.
The install command starts the post-install once every archive is extracted, and only when the run changed the rootfs. The distribution was named by the --from argument of the command, and the source identity built from that argument answers post_install, the script behavior to run.
Each family names and invokes its script files differently, while one shared loop runs them all, so every distribution maps to one of three behaviors: Debian's, Arch's, and Alpine's. The mapping also shows the RPM families reusing the Debian behavior, which covers their shell scriptlets; a scriptlet written in Lua, the other language RPM allows, is skipped, since the runner interprets only shell. This walkthrough follows the Debian behavior, which replays each postinst the way dpkg would run it.
The chosen behavior lands in the same post-install run the dynamic-linker walkthrough entered. The scripts call commands the bare tree cannot provide, so the phase installs their no-ops before any script runs: stubs_install is the precondition, and scripts_run starts the replay after it.
A postinst written for a live system calls the service manager, the user database, and the configuration prompts, none of which exist in a directory tree. stubs_install shadows these commands with no-ops that simply succeed. Some commands are required to really do their work, at least the part that shapes the tree, so FlatRoot implements a minimal working version of each: the update-alternatives stub creates the requested symlinks, the add-shell stub appends to /etc/shells, and the dpkg stub answers the version comparisons the scripts branch on.
The add-shell stub for example, appends each shell to /etc/shells, skipping the ones already listed. This is the command bash's postinst executes when it registers bash as a login shell.
With the stubs in place, scripts_run starts the execution, handing it the DebianFlavour: for each package, a Debian install script is a file named postinst with commands to run, and the flavour tells the loop where that file is, the command that runs it, and its environment.
run replays the saved scripts one package at a time. For each saved package, stage copies its postinst into the rootfs and builds the invocation dpkg would use, env supplies the environment the script expects, and the sandbox runs the result with the rootfs as /. A failing script is reported and the replay continues, so one package's breakage never blocks the rest.
The stubs only take effect if the scripts find them first, and the environment arranges it: the PATH that env builds starts at /.flatroot/bin, the stub directory, ahead of the system paths. The remaining variables answer what the scripts branch on: dpkg's own maintainer-script variables, and DEBIAN_FRONTEND=noninteractive, so no script stops at a prompt.
The application caches
An application that needs a font, an icon, or a file-type association cannot afford to scan the whole tree at every start, so caching mechanisms are used: fc-cache builds the font information caches fontconfig reads at startup (fc-cache(1)), update-mime-database builds the Shared MIME-Info cache (update-mime-database(1)), glib-compile-schemas compiles the GSettings schema files into gschemas.compiled (glib-compile-schemas(1)), gtk4-update-icon-cache writes the icon-theme cache (gtk4-update-icon-cache(1)), and update-ca-certificates regenerates the CA trust store from the configured certificate list (update-ca-certificates(8)). Each of these programs reads the files a package installs and writes a compiled cache elsewhere on the tree.
Something must run these programs after extraction places the packages' files, and each family assigns that job to a different place. Debian writes the calls inside the maintainer scripts, an intended use: update-mime-database documents its -n option as existing "for package pre- and post-installation scripts" (update-mime-database(1)). Arch uses separate hook files: a hook names the paths it watches and the command to run, and pacman runs every hook whose watched paths an install changed, after the extraction (alpm-hooks(5)); a hook watching the schema directory runs glib-compile-schemas, and one watching the icon directory runs gtk4-update-icon-cache. Alpine uses trigger scripts, run when an install changes a directory the package watches (apk-package(5)). RPM writes the calls in the scriptlets its packages carry.
Walkthrough: the application caches in FlatRoot
Where the families run the caching programs from their scripts, hooks, and triggers, FlatRoot runs them from one table of its own, as the third phase of the same post-install run the previous two walkthroughs entered. This walkthrough follows the phase from the plan to one rebuilt cache.
The Hooks phase closes the post-install run: it hands the rootfs and the sandbox to CacheHooks::run.
A fresh rootfs may carry any subset of these programs and the files they summarize, so the rebuild is best-effort by design. run walks a fixed table of every cache FlatRoot knows, in an order where a cache that others read rebuilds first, and one cache's failure never stops the rest.
Each entry of the table is one cache described as data: the candidate names of the program that writes it, the directory to search when that program lives off the PATH, and the command that runs it. The slice shows programs the section introduced: update-mime-database over /usr/share/mime, glib-compile-schemas over the schema directory, and gtk-update-icon-cache, which runs only when the hicolor theme index is present.
A cache applies only when the rootfs carries both the program that writes it and the files it summarizes. resolve_and_run locates the program among its candidate names, the multilib -64 and -32 variants included, builds the command, and runs it in the sandbox with the rootfs as /. A missing program or missing input skips the cache, the normal case for a minimal rootfs, and a failed rebuild is reported while the remaining caches still run.
Validation
In this section, we validate the resolution methodology against the package managers themselves. We first state the problem that rules out a plain one-to-one comparison, and define closure sanity, the property the comparison tests instead. Then we present the validation procedure: the experiment applied to every family with its comparison rules, followed by the methodology of each family.
The packaging specifications of the four families define a space of correct solutions rather than a single one, as presented in the Closure Divergences of the Resolving the Closure section: the pick of a provider, the pick of an alternative, and the following of optional dependencies are all choices the specifications leave open. The package manager's installed set is one point in that space, and the resolver's closure is another: awk is satisfiable by mawk and by gawk, so the two can pick differently while both closures stay correct. A plain diff between the two sets reports every such legal difference as an error, failing correct output. The property to test is closure sanity instead, i.e., if the closure is inside the valid solution space. A closure is sane under two conditions. Internally, every hard dependency of every member is satisfied inside the closure itself. Externally, every difference from the package manager's set lies at one of the open choices, and the differing pick satisfies the dependency that the package manager's own pick satisfied.
The Methodology
The experiment produces the two closures that the definition of closure sanity compares. Every family is validated by the same procedure:
- Start the distro's official container image.
- Inside it, the distribution's package manager installs a target package. The resulting package set is the reference closure.
- The resolver proposed in this article resolves the same target against the same repositories. Its output is the computed closure.
- Still inside the container, the package manager's metadata is queried recursively, package by package, into the solution space: a graph holding every dependency of every package and every package able to satisfy each, all virtual packages and alternatives included.
- The solution space is walked from the same seeds, following the resolver's picks at each open choice. The walk's output is the expected closure, everything the computed closure must contain.
- Compare: a package the walk reaches and the computed closure lacks is a missing candidate, and a package only the computed closure holds is a phantom candidate. Each candidate passes through the excusals, the rules that recognize a difference as one of the open choices.
- The target passes with zero missing and zero phantom packages.
The missing check and the phantom check split the definition of closure sanity between them. The missing check tests that every hard dependency of every member is satisfied inside the computed closure: a package the walk reaches and the computed closure lacks is an unmet hard dependency. The phantom check tests every package that only the computed closure holds: the package is legitimate when it entered through a choice between alternatives, such as a different provider of the same virtual name.
The Comparison
FlatRoot resolves curl into the computed closure, and apt installs the same curl in Docker into the reference closure. The test then runs apt-cache depends in the container for every package it encounters, recursively, and the answers assemble the solution space. A walk runs over the solution space with the choices of FlatRoot as its reference, starting from curl plus the essential packages. A dependency that a single package can satisfy is followed always. A dependency that several packages can satisfy is followed into the package FlatRoot picked. When FlatRoot picked none of those packages, the walk falls back to the pick of the reference closure, and the missing check then reports that package. When apt also picked none of them, the dependency is skipped, because apt itself left it unsatisfied. Every visited package is collected into the expected closure, the list the computed closure must contain. The missing check judges each package in the expected closure that the computed closure lacks: a candidate absent from the reference closure as well was reached through a branch apt and FlatRoot both skipped, and it is set aside; a candidate whose need the computed closure filled with an alternative, a different provider of the same virtual name, is excused; every other candidate is missing. The phantom check judges each package in the computed closure that the reference closure lacks: a package present in the expected closure is excused; a package that provides a name a member of the reference closure also provides is excused; a package whose own name a member of the reference closure provides is excused, two picks of the same name; every other package is a phantom. The target passes with zero missing and zero phantom packages:
\begin{algorithm}
\caption{Validate the computed closure of one target}
\begin{algorithmic}
\INPUT a distribution release and a target package $t$
\OUTPUT pass or fail
\STATE $C \gets$ the closure the resolver proposed in this article computes for $t$
\COMMENT{the computed closure}
\STATE $R \gets$ the packages the package manager installs for $t$ in the container
\COMMENT{the reference closure}
\STATE $G \gets$ every dependency and every package able to satisfy each, queried recursively in the container
\COMMENT{the solution space, virtual packages and alternatives included}
\STATE $W \gets \emptyset$, $Q \gets$ a queue holding $t$ and the essential packages
\WHILE{$Q$ is not empty}
\STATE $p \gets$ pop the front of $Q$
\IF{$p \in W$}
\STATE continue
\ENDIF
\STATE $W \gets W \cup \{p\}$
\FOR{each dependency of $p$ in $G$}
\IF{a single package can satisfy the dependency}
\STATE push that package to $Q$
\ELSIF{$C$ holds a package able to satisfy it}
\STATE push the package $C$ picked to $Q$
\COMMENT{the walk retraces the resolution of C}
\ELSIF{$R$ holds a package able to satisfy it}
\STATE push the package $R$ picked to $Q$
\COMMENT{C left the dependency unsatisfied; the fallback surfaces the package in the missing check}
\ELSE
\STATE skip the dependency
\COMMENT{R itself left it unsatisfied}
\ENDIF
\ENDFOR
\ENDWHILE
\STATE failures $\gets \emptyset$
\FOR{each $p \in W$ with $p \notin C$}
\COMMENT{W is the expected closure: everything C must contain}
\IF{$p \notin R$}
\STATE set $p$ aside
\COMMENT{the walk reached p through a branch R and C both skipped}
\ELSIF{a member of $C$ provides the name of $p$, or shares a provided name with $p$}
\STATE excuse $p$
\COMMENT{C filled the need with a different provider}
\ELSE
\STATE failures $\gets$ failures $\cup \{p\}$
\COMMENT{p is missing}
\ENDIF
\ENDFOR
\FOR{each $p \in C$ with $p \notin R$}
\IF{$p \in W$}
\STATE excuse $p$
\COMMENT{the picks of C require p}
\ELSIF{$p$ provides a name a member of $R$ also provides}
\STATE excuse $p$
\COMMENT{the two closures hold different providers of the shared name}
\ELSIF{a member of $R$ provides the name of $p$}
\STATE excuse $p$
\COMMENT{two picks of the same name: C holds the package bearing it, R a provider of it}
\ELSIF{on Alpine, $p$ carries install-if triggers and every trigger is in $C$}
\STATE excuse $p$
\COMMENT{apk itself would include p through its install-if rule}
\ELSE
\STATE failures $\gets$ failures $\cup \{p\}$
\COMMENT{p is a phantom}
\ENDIF
\ENDFOR
\RETURN pass when failures $= \emptyset$
\end{algorithmic}
\end{algorithm}
Walkthrough: the validation in FlatRoot
The validation procedure runs in FlatRoot's test suite, once per release and target. resolver_debian.rs declares each pair as one test, debian:bookworm with curl among them, and each test hands its pair to validate_deb, the Debian family's entry.
The judgments need the algorithm's four sets assembled first, and the validate_deb function gathers them in order. docker_apt_graph starts the container and returns the solution space, the reference closure, and the essential packages. flatroot_resolved_list returns the computed closure. The seeds join the essential packages with the target, bfs_reachable walks the solution space into the expected closure, and validate runs the two checks.
The reference closure comes from a real install, so the first container script runs the package manager itself: apt-get install pulls the target, dpkg -l lists every package now on the system, and apt-cache dumpavail lists the packages marked Essential: yes. The installed list is the reference closure, and the essential list seeds the walk the way it seeds the resolver.
The solution space must hold every branch, while apt-cache depends answers one package at a time, so the discovery runs in rounds. Each round queries one batch of packages, and parse_apt_batch reads the answers into dependency groups, providers, and the names discovered inside them. Every discovered name joins the next round's batch, and the rounds end when a round discovers nothing new.
The computed closure must come out of FlatRoot's real install path, so flatroot_resolved_list runs the flatroot binary: --from names the same release, install resolves and extracts into a temporary directory, and --postinstall=none skips the script replay, since the checks read package names alone. The install writes the manifest .flatroot/packages, and the computed closure is its Package: lines.
The bfs_reachable function retraces FlatRoot's resolution over the solution space through the member it queues for each dependency group. A group with a single member queues that member. A group of alternatives queues the member the computed closure holds, else the member the reference closure holds, so a package FlatRoot skipped still enters the expected closure and meets the missing check. A group with members in neither closure is dropped, since apt itself left it unsatisfied.
The missing check reads as three filters over the expected closure. A candidate outside the computed closure passes the first, and the reference closure holding it passes the second, confirming the genuine need. The third asks is_provided_by_flatroot for a member of the computed closure that provides the candidate's name or shares a provided name with it, the excusal of the algorithm, and a candidate past all three filters is missing.
The phantom check mirrors the filters from the computed closure's side. A member outside the reference closure and outside the expected closure reaches is_legitimate_alternative, which looks for the provider ties of the algorithm's excusals, and a member with none of them is a phantom.
The verdict is the pair of asserts: either list holding a survivor fails the test and prints the names, and the target passes with both lists empty.
Appendix
Repositories and package indices
A repository is a plain HTTP server that hosts package archives together with an index, a catalog file listing every available package with its metadata and the path of its archive on the same server. The package manager downloads the index first and works from it alone: dependency resolution and version selection happen locally, and the server is contacted again only to fetch the archives that the resolution selected. Since the whole model runs on static files, any web server or mirror can host a repository. The package formats differ in the index's encoding and location alone: Debian's Packages file sits in the release's dists/ tree (Debian repository format), RPM's XML files sit in a repodata/ directory (repository metadata), pacman's database sits at the repository root (alpm-repo-db(7)), and Alpine's APKINDEX.tar.gz sits per architecture directory (apk-package(5)). The live mirrors expose these layouts as plain directory listings, as seen on Debian bookworm, Fedora 43, Arch core, and Alpine v3.21.
Debian records and RFC 822
RFC 822 is the 1982 standard for the header lines of Internet email, the Key: Value rows at the top of every message. Debian adopted this syntax for its package records, so a record reads like an email, down to the leading-space continuation lines. The Debian Policy Manual makes the compatibility official, citing RFC 5322, the modern revision of RFC 822.
The ar archive format
ar(5) is a Unix archive format that predates tar: it appears in the First Edition Unix manual of 1971, and GNU ar still uses it today to pack compiled object files into static libraries such as libc.a. The format simply concatenates its contents. The archive starts with the magic bytes !<arch>, and each member follows as a fixed 60-byte header with the member's raw content after it. The header is written entirely in printable ASCII, spending 16 bytes on the file name, 12 on the modification time, 6 each on the owner and group, 8 on the permission mode, 10 on the size, and 2 on a closing marker. There is no compression and no index. Debian reuses this container as the outer layer of every .deb file, and the compression lives inside the members instead.
The cpio archive format
cpio(5) is another archive format from early Unix and a close sibling of ar. It is a stream of members, each a small header followed by the file's raw content, without compression or an index of its own. RPM uses the SVR4 variant of the format. Its headers write every numeric field as 8-digit ASCII hexadecimal, which caps individual files at 4 GiB, and the end of the archive is marked by a member called TRAILER!!!.
Checksums and digital signatures
A checksum, also called a hash, is a short value computed from the bytes of a file by a fixed rule. The same file always produces the same checksum, and any change to the file changes it, so comparing checksums reveals whether two files are identical. The rule is chosen so that finding two different files with the same checksum is impractical, which is what makes the comparison trustworthy (GNU Privacy Handbook). A checksum alone does not stop tampering, since an attacker who can replace a file can recompute its checksum too. A digital signature adds that guarantee. The signer keeps a private key and publishes the matching public key. Signing reduces the content to a checksum and encrypts that checksum with the private key, producing the signature, and anyone with the public key can undo the encryption and compare the recovered checksum against one computed from the content itself. Matching checksums mean the content is exactly what the signer signed and has not been altered since, so the signature verifies both who signed it and that it is intact (OpenPGP, signatures over data). A detached signature is stored as its own file or stream next to the signed content rather than inside it, which lets the same content be re-signed by a different key without rewriting it (detached signatures).
The EBNF notation
EBNF, the Extended Backus-Naur Form standardized as ISO/IEC 14977, is a notation for writing grammars. Each rule names a construct and defines what it may contain: sequences list their parts in order, | separates acceptable choices, square brackets surround optional parts, and quoted text stands for characters that appear verbatim. The RFC series uses its own variant of the idea, ABNF.