Introduction
ferroday-cage is a pure-Rust library for running a command inside an unprivileged Linux sandbox. It creates a set of Linux namespaces, presents a root filesystem the caller provides, and executes the command in a clean, reproducible environment — established directly against the kernel by an ordinary unprivileged user. A default build is self-contained: pure Rust throughout, resting on the kernel alone.
The library is the product. A program links it, configures a sandbox with a
typed builder, launches a command, and consumes a typed result. A companion
command-line tool, fcage, is a thin consumer of the same public API and
offers the same sandbox at a shell prompt: it is built with every feature
enabled, so any profile the library accepts loads there and every capability of
the launch builder has a flag. Two seams stay in the library alone, being the
ones no argument could carry — handing the command an open descriptor as its
standard input, and supplying an identity-map delegate as code. The
feature guide states what the tool
covers of the other builders.
A bare dependency gives the sandbox itself. Provisioning, hardening, profiles, and the network stack are opt-in, each named by the chapter that covers it and listed together in Cargo features.
Isolation model
The sandbox is built from unprivileged user namespaces: the calling user is
mapped to root inside the sandbox, and the isolation is established directly
against the kernel. The command sees the provided root filesystem at /
with a standard mount profile — a fresh /proc, a minimal /dev, a tmpfs
/tmp, and the caller’s bind mounts — assembled over it, and runs as PID 2
under a minimal reaping init in isolated PID, mount, UTS, IPC, cgroup, and
(by default) network namespaces, with a clean, reproducible environment,
scrubbed signal state, and a descriptor table holding only its standard
streams. It runs one user namespace deeper than the rest of the sandbox: the
command enters a nested one before it execs, which is what locks the flags of
every mount the setup established, so a read-only bind is a boundary and the
root is nosuid. The surface grows additively from there. How the sandbox is
built describes the mechanism and The
process model the process tree, lifetimes, and signal
behavior.
Output is collected whole or streamed live to a caller-supplied observer, a
running sandbox is driven through a handle — wait, deadline, terminate, kill —
and, with the serde feature, the sandbox specification round-trips through
profile files. See Streaming output and
Profiles; Embedding in a runtime covers driving
launches from a thread pool or an async runtime, and Building an
orchestrator works a profile-driven consumer end
to end.
The isolated network is loopback-only by default; a caller gives it outbound
connectivity by attaching a userspace network stack at a seam, while the
sandbox keeps its private namespace and the host network policy stays outside
it. The library ships its own native stack — self-contained, unprivileged
outbound IPv4 and IPv6 — and the seam also accepts an external one such as
pasta or slirp4netns. See Userspace networking.
The root filesystem is the caller’s to supply, and the library can also produce
one — provisioning from a tarball, or bootstrapping a Debian, an
Alpine or postmarketOS, or a Gentoo userland from the
distribution’s own archive. See Provisioning a rootfs; An
ebuild development sandbox provisions a Gentoo stage3 and
builds a package in it, and Building a Debian
package bootstraps a Debian suite and builds a
.deb from source. A bootstrapped root need not be for the host’s own
architecture: A foreign-architecture test run builds
and runs arm64 binaries on an x86_64 host through the qemu-user binfmt
handler.
The default profile is a rootless convenience for code you trust: a controlled filesystem view, isolated namespaces, and a clean environment. Untrusted code calls for the hardening layer, which adds Landlock filesystem rules, seccomp syscall filters, and capability drops on request — the default profile alone is not a hardening boundary against hostile code. See Hardening, and for hosts that cannot create user namespaces at all, the restriction fallback.
Requirements
- Rust 1.91 or later. See Stability and versioning for how that floor moves.
- Linux 5.6 or later, with unprivileged user namespaces enabled. See Host requirements. The restriction fallback is the exception: it needs only Landlock or seccomp.
Status
ferroday-cage is under active development. The API settles over the 0.x series; Stability and versioning states what each interface promises, and this guide grows as the library does.
Quick start
A sandbox needs a root filesystem. Any directory laid out like a Linux root works; the Alpine minirootfs is a convenient, small one:
mkdir alpine-root
curl -fsSL https://dl-cdn.alpinelinux.org/alpine/v3.24/releases/x86_64/alpine-minirootfs-3.24.1-x86_64.tar.gz \
| tar -xz -C alpine-root
Using the library
Add the dependency. The crate has no default features, so the bare line gives the sandbox alone — which is all this chapter needs:
cargo add ferroday-cage
Later chapters cover capabilities that are opt-in: provisioning a rootfs, hardening, profiles, the network stack. Each names its feature, and Cargo features lists them in one place. A typical consumer wants a few:
cargo add ferroday-cage --features serde,tarball,hardening
Configure a sandbox with the builder, validate it with build, and launch it
with run:
use ferroday_cage::Cage;
fn main() -> ferroday_cage::Result<()> {
let status = Cage::builder()
.rootfs("alpine-root")
.command("/bin/sh")
.args(["-c", "echo hello from the cage"])
.build()?
.run()?;
assert!(status.success());
Ok(())
}
build performs all validation: a missing rootfs or a malformed command is a
typed configuration error before anything is launched. run blocks until the
command terminates and returns its ExitStatus whether or not the exit code
was zero — a failing command is data, not an error. Err is reserved for the
library itself failing, and a failure during sandbox setup names the exact
step and OS error.
The command runs with inherited standard streams, / as its working
directory, and a clean environment: PATH and HOME, plus whatever the
builder’s env calls add. The command path is interpreted inside the
sandbox and must be absolute; it is not searched for on PATH unless
path_lookup is enabled, which resolves a command with no slash against the
sandbox’s PATH like a shell. By default
the sandbox mounts a fresh /proc, a minimal /dev, and a tmpfs /tmp
over the rootfs, runs the command as PID 2 in its own PID namespace under a
minimal init, and isolates the network to a loopback interface — see How
the sandbox is built for the full profile and
the builder methods that adjust it:
use ferroday_cage::{Cage, Network};
fn main() -> ferroday_cage::Result<()> {
let status = Cage::builder()
.rootfs("alpine-root")
.bind("/home/user/project", "/build")
.bind_ro("/home/user/cache", "/cache")
.network(Network::Host)
.hostname("builder")
.current_dir("/build")
.env("CACHE_DIR", "/cache")
.command("/bin/sh")
.args(["-c", "echo building in $PWD on $(hostname)"])
.build()?
.run()?;
assert!(status.success());
Ok(())
}
bind_ro is a boundary and not merely a view: the command cannot remount it
read-write, cannot unmount it, and a write to it answers EROFS. Nothing else
has to be configured for that to hold — see A read-only bind is a
boundary for the
mechanism.
Using the command line
The fcage binary offers the same sandbox at a shell prompt:
cargo install ferroday-cage-cli
$ fcage --rootfs alpine-root -- /bin/sh -c 'echo hello && /bin/busybox uname -m'
hello
x86_64
$ fcage --rootfs alpine-root /bin/busybox id
uid=0(root) gid=0(root) groups=0(root)
$ fcage --rootfs alpine-root --ro-bind ~/project /project --chdir /project \
--share-net -- /bin/sh -c 'ls && cat /etc/resolv.conf'
fcage exits with the command’s own exit code, or 128 plus the signal number
when the command is terminated by a signal, or 124 when a --timeout expires.
The first two come from ExitStatus::shell_code, so a consumer that re-exports
a sandboxed command’s outcome reports the same codes without restating the
convention; Error::shell_code is its counterpart for a launch that failed
(127 for a command that does not exist, 126 for one that cannot be executed,
125 otherwise). A sandbox can also be described in a TOML file and loaded with
--profile — see Profiles. See fcage --help for the full
interface.
Going further
- Stream the command’s output to your own code: Streaming output.
- Wait with deadlines, stop gracefully, kill outright, and tie the sandbox’s lifetime to your process: The process model.
- Launch sandboxes from a thread pool or an async runtime, and cancel them from one: Embedding in a runtime.
- Pick the cargo features your consumer needs: Cargo features.
Cargo features
The crate has no default features. A bare dependency gives the sandbox itself — namespaces, mounts, the root swap, identity maps, the process model, output streaming, and resource limits — and it is self-contained: pure Rust throughout, resting on the kernel alone.
Everything beyond that is opt-in, so a consumer that wants a sandbox and nothing else pays for a sandbox and nothing else.
# The sandbox alone.
cargo add ferroday-cage
# A typical consumer: profiles, a tarball-provisioned rootfs, and hardening.
cargo add ferroday-cage --features serde,tarball,hardening
The features
| Feature | What it adds | What it pulls in |
|---|---|---|
serde | The profile format: CageBuilder serializes and deserializes as the sandbox specification, and RestrictedProfile loads one from an untrusted source | serde |
tarball | The Tarball provisioner (gzip, xz, and zstd, detected by content) and export_tar, its ownership-preserving counterpart | flate2, lzma-rust2, ruzstd |
debian | The Debian provisioner: bootstrap a suite and architecture from the archive, with signature verification and dependency resolution. Implies tarball | pgp, sha2, and tarball’s |
alpine | The Alpine provisioner: resolve and install a package set from an apk repository, with signature verification. Serves postmarketOS’s repositories too. Implies tarball | rsa, sha1, sha2, and tarball’s |
gentoo | The Gentoo provisioner: resolve a stage3 variant to the build the archive currently publishes and extract it, then install prebuilt packages into it from the archive’s binary-package host, all verified against a vendored keyring. Implies tarball | pgp, sha2, md-5, and tarball’s |
hardening | Landlock filesystem and network rules, seccomp syscall filters, capability drops, and the restriction fallback for hosts without user namespaces | seccompiler |
netstack | A native userspace TCP/IP stack giving the isolated network outbound IPv4 and IPv6, terminated in-process | smoltcp |
subid | The subordinate-id delegate: uid/gid range maps established through the shadow suite’s newuidmap/newgidmap | nothing (see below) |
Every dependency above is pure Rust with C backends and libc off, so enabling any combination keeps the build self-contained.
subid is the one feature that changes that property in a different way: it
adds no dependency, but it is the only feature under which the library executes
an external binary. A build without it invokes none. The
DirectMapper delegate, which needs no helper, is in the
featureless build and is tried first regardless.
Choosing a set
- Running a command in a prepared root. No features. Provision the root however you like — the library only needs a directory.
- Running untrusted code.
hardening, for the Landlock, seccomp, and capability controls, and the restriction fallback if the target hosts might disable user namespaces. - Loading sandbox configuration from files.
serde. - Producing the root filesystem too.
tarballfor an archive,debianfor a bootstrapped Debian userland,alpinefor an Alpine or postmarketOS one,gentoofor one bootstrapped from a signed stage3. Analpine-only build is the lightest of the three userlands: it needs no OpenPGP implementation, whichdebianandgentooeach pull in. - Giving the sandbox network access.
netstackfor the bundled stack. Without it, the seam an external stack attaches to is still there — it is part of the featureless build. - Running a userland that needs more than one identity.
subid, plus the shadow helpers and a subordinate allocation on the host.
In the command-line tool
fcage is built with every feature enabled, so any profile the library accepts
loads there. It is the quickest way to try a configuration before committing to
a feature set in a consumer’s Cargo.toml.
What it spells, surface by surface:
| Surface | In fcage |
|---|---|
The launch builder, and the restriction fallback behind --restrict | Every setting, but the two no argument can carry: a command’s standard input as an open descriptor, and an identity-map delegate supplied as code. The output pair has no flag either, a shell already spelling both of its dispositions. |
| The native network stack | Every setting, as --netstack-*. |
| The provisioners’ bootstrap surface | Every setting, plan and pin documents included, so a reproducible install is expressible at a prompt. A custom transport is code and has none. |
| Layered provisioning | Nothing. A provisioner given a base layer produces an increment in a disposable overlay upper rather than a published rootfs, and fcage publishes rootfs directories. |
| Read-only archive inspection | Gentoo’s, which prints stage3 variants, binhost packages, an installed set, and the vendored keyring’s horizon. Debian and Alpine have no equivalent mode. |
The record behind that table is tools/parity.toml, which names every public
builder setting against the flag that reaches it or the reason it has none; a
check in CI holds it to both surfaces.
Documentation
docs.rs builds with all features, so the published API documentation shows the
whole surface. Items only some builds provide are gated in the source, which
means a local cargo doc reflects exactly the features you enabled — including
a no-feature build, where the feature-gated sections and their links are absent
rather than dangling.
Host requirements
ferroday-cage requires Linux 5.6 or later with unprivileged user namespaces
enabled. The sandbox is built entirely from unprivileged facilities — there
is no setuid helper and no daemon — so the kernel must permit an ordinary
user to create user namespaces. The 5.6 floor comes from openat2, which
the sandbox uses to confine mount-target resolution to the rootfs and the
tarball provisioner uses to confine extraction to its destination.
Two opt-in capabilities have host requirements of their own. A uid/gid range
map requested by an unprivileged caller needs the shadow
suite’s newuidmap and newgidmap helpers and a subordinate-id allocation,
and host::range_map_blocker
reports what a host is missing. An overlay-rooted cage
needs a kernel that permits an unprivileged overlay mount and an upper layer on a
filesystem that records user.* extended attributes, and
host::overlay_blocker
reports what a host is missing. Neither is used unless requested; a plain,
single-identity cage needs nothing beyond this page.
Most desktop and server distributions permit this by default, but several common configurations disable it:
| Configuration | Effect |
|---|---|
kernel.unprivileged_userns_clone = 0 | Denies creation outright. An out-of-tree sysctl found on Debian-derived kernels. |
user.max_user_namespaces = 0 | Denies creation outright by setting the namespace limit to zero. |
user.max_user_namespaces = 1 | Admits the sandbox’s own user namespace and refuses the nested one its command enters. Every launch holds two; see The nested user namespace. |
kernel.apparmor_restrict_unprivileged_userns = 1 | Restricts creation to programs granted the capability by an AppArmor profile. The default on Ubuntu 23.10 and later. |
| Container seccomp profiles | Container runtimes commonly deny unshare with EPERM in their default seccomp profiles, so sandboxes nested inside such containers fail unless the profile permits it. |
How a blocked host surfaces
A launch on a host that denies user namespaces fails with
Error::UsernsUnavailable. The library probes the configurations above and
names the one responsible, together with the remedy, rather than surfacing a
raw EPERM.
The probe is also available directly as host::userns_blocker, which returns
the first blocking configuration it identifies. Test suites use it to skip
sandbox tests explicitly on hosts that cannot run them. The probe is
best-effort: a clear result does not guarantee creation succeeds, since a
seccomp filter or LSM policy can deny it invisibly.
The namespace budget is the clearest case of that. A ceiling of 1 is
reportable, because it cannot admit the two namespaces a launch holds; a
ceiling of four with three namespaces already live cannot be, because only the
launch can know how many are in use. That one surfaces at launch as
Error::NestedUsernsBudgetExhausted, which names the sysctl.
A host that cannot be unblocked is not entirely without options: with the
hardening feature, the restriction fallback
confines a command with Landlock and seccomp alone, which need no user
namespace.
Where the rootfs lives
The rootfs must sit under a directory the calling user controls — not a
world-writable one such as a shared /tmp, and not one whose path passes
through a directory another local user can write to.
Inside the sandbox, containment is kernel-enforced: mount targets resolve with
RESOLVE_IN_ROOT and extraction with RESOLVE_BENEATH, so nothing the sandbox
or an archive contains can reach past the root. What those guarantees rest on is
the host path naming that root. The launch resolves it twice — once to mount it,
and again inside the new mount namespace to anchor the mount targets and the
pivot — because a descriptor opened before unshare cannot anchor a mount in
the namespace that follows. A local user able to replace a component of that
path between the two resolutions could point the second one elsewhere.
The same requirement applies to
provision::ensure,
which places its lock file and staging directory beside the destination, and to
an overlay-rooted cage’s upper layer, whose sibling work
directory and preflight probe live beside it.
Overlay-rooted cages
CageBuilder::overlay_rootfs
roots a cage on an overlay of a read-only lower and a writable upper, so the
sandbox runs against a base whose changes it can discard. The overlay is mounted
unprivileged, from inside the cage’s user namespace, which two host properties
gate:
- An unprivileged overlay mount. Mounting
overlayinside a user namespace requires Linux 5.11 or later. Below it the mount is refused. user.*extended attributes on the upper’s filesystem. An unprivileged overlay records its metadata — whiteouts and opaque markers — inuser.*xattrs, so the filesystem holding the upper layer must support them. An on-disk filesystem such as ext4, xfs, or btrfs does throughout; tmpfs gained support only in Linux 6.6, so an upper on a tmpfs needs that kernel while an on-disk upper does not.
The build refuses an overlay-rooted cage a host cannot establish, naming the
missing property rather than failing at launch. The probe is also available
directly as
host::overlay_blocker,
which tests a given filesystem and returns the first blocker it identifies; test
suites use it to skip overlay tests on hosts that cannot run them. It is
best-effort — it forks a short-lived child that attempts a throwaway mount — and
a clear result does not guarantee a later mount succeeds.
The base rootfs (the lower) should not itself sit on an overlay: a nested overlay is restricted on older kernels. This matters only when the base is cached on a filesystem that is already an overlay, as inside some container runtimes; a base on an ordinary filesystem is unaffected.
An unreproduced report: a parallel first touch
A consumer has twice seen a heavily parallel build on an overlay root fail with
ENOENT on a file that is present in the upper layer, and succeed on immediate
retry. It is recorded here because an unreproduced report described accurately
is worth more than an absent one, and because the shape it implicates is one
another consumer could share.
The transient itself is established, without any instrument. The same build
stage’s ./configure had, minutes earlier and against the same layer,
successfully compiled probes that included both of the files that later went
missing. The file was momentarily unfindable rather than absent.
What distinguishes the failing case from that consumer’s clean one is not emulation or load but a fresh mount per command. Their build root builds one cage per command, so a stage’s serial configure warms one overlay superblock’s dentry cache and that superblock is then destroyed; the parallel build that follows starts against a cold one. Their control does its serial configure and its parallel build inside a single mount, and so never performs a first-touch lookup under parallelism. Every reported failure has been an upper-side path, in a directory both layers carry — one that overlayfs must therefore assemble from both — while lower-side paths, opened orders of magnitude more often by the same processes in the same command, have never failed.
The overlay-race example in the repository is the harness: it mounts, releases
a storm of parallel first-touch lookups, unmounts, and repeats, probing an
upper-only file, an upper-only subdirectory, and a lower-only file in the same
storm so a run that misses all three evenly can be told from one that reproduces
the reported asymmetry. --warm adds a single serial lookup in the same mount
before the storm, which is the direct test of the mount-per-command
discriminator.
It has not fired on the kernels tried here. Until it does, the practical advice is the one that costs nothing: where a pipeline runs several commands over one overlay upper, running them in one cage rather than one cage each removes the condition entirely, and is what the consumer’s own unaffected path already does.
Architecture
Namespaces, pivot_root, and the identity maps are architecture-independent;
ferroday-cage runs on any architecture the Rust Linux targets support. The
binaries inside the rootfs must be ones the host CPU executes — its own
architecture, or one it runs natively, such as i386 on an amd64 host — or a
matching qemu-user binfmt handler must be registered for them.
A full Debian bootstrap for an architecture the host does not run natively runs
that architecture’s dpkg and maintainer scripts, so the qemu-user handler must
be registered with the fix-binary (F) flag — the form that preloads the
interpreter so nothing is copied into the rootfs. The bootstrap checks whether
the host runs the target natively, requires a suitable handler only when it does
not, and reports an actionable error when one is missing; extract-only mode lays
out the files without running any foreign binary and needs no handler.
How the sandbox is built
A launch is a fork followed by a fixed setup sequence, ending in execve.
Everything the setup needs is prepared and validated in the caller
beforehand — the command line marshaled, the rootfs path resolved and
checked — so the post-fork windows perform no allocation and no fallible
preparation of their own. All syscalls are issued directly against the
kernel through rustix, and the default build drives the whole sequence
itself; the one opt-in exception — the subid feature’s range-map
delegation to the shadow suite’s helpers — runs in the caller, outside the
sandbox.
The setup runs across a short chain of forked stages — the launch stage, the sandbox init, and the command process — because a process that unshares a PID namespace does not enter it: only its next child process becomes the namespace’s PID 1. The process model describes the resulting process tree and its lifetimes; this chapter follows the setup sequence itself.
The setup sequence
- Reset the signal state. Every signal disposition returns to its default and the signal mask empties, immediately after the first fork. The caller’s ignored dispositions would otherwise survive into the command, and an inherited signal handler must never run inside these tightly constrained processes.
- Unshare. One
unsharecall creates user, mount, PID, UTS, IPC, and cgroup namespaces, plus a network namespace unless the host network is shared. The user namespace is owned by the process with full capabilities over it, and every other namespace is owned by that user namespace — the ownership is what lets an unprivileged process perform the mount, hostname, and network steps below. - Sweep the descriptors. The launch closes every descriptor it inherited except the launch channels and the standard streams. The sandbox processes are forked from the swept table, so the command starts with only its streams and the supervisor pins no caller resource for the sandbox’s lifetime.
- Identity map. The launch stage establishes the map of its freshly
created user namespace, per the configured
identity map. The default single-identity map is
written in process:
denyto/proc/self/setgroups, then a one-line gid map, then a one-line uid map, assigning the calling user’s effective ids to root — denyingsetgroupsfirst is what the kernel requires before an unprivileged process may write its own gid map. A range map cannot be self-written, so the stage instead signals ready on an internal gate and blocks while the caller’s delegate writes the map against its pid, leavingsetgroupsatallow; the gate closes before anything else is forked. Either way the map is complete before the next step, so every process the caller can observe lives in a fully mapped namespace. - Publish the supervisor. The launch stage forks the sandbox init,
opens a pidfd for it, and passes it back to the caller — the anchor for
kill, immune to pid reuse. The remaining steps run in the init, inside all the new namespaces. - Hostname. When a hostname is configured,
sethostnamesets it in the new UTS namespace; otherwise the sandbox keeps the host’s hostname, and either way a change inside the sandbox never reaches the host. - Loopback. In an isolated network namespace the only interface is loopback, which starts down; the init brings it up with the interface-flags ioctls on a throwaway datagram socket. To give the isolated namespace outbound connectivity, a caller attaches a userspace network stack at the seam described in Userspace networking.
- Privatize propagation. Mount propagation for the inherited tree is
made recursively private, so nothing done here propagates back to the
host, and because
pivot_rootrefuses to operate under shared mounts. - Bind the rootfs. The rootfs is bind-mounted onto itself, recursively.
pivot_rootrequires the new root to be a mount point; the self-bind makes it one without imposing any staging directory. The canonical path validated at build time is resolved here, inside the new namespace — the kernel requires bind sources and targets to be mounts attached in the caller’s own mount namespace, so a descriptor opened before the unshare cannot anchor this step. - The mount profile. The init executes the frozen mount operations in
order: the fresh
/proc, the minimal/dev, the/tmptmpfs, the caller’s own mounts, and theresolv.confbind, as configured. Bind sources are host paths and resolve normally — the host tree is still mounted. Targets resolve against the new root withopenat2andRESOLVE_IN_ROOT, so a symlink inside the rootfs — even an absolute one — resolves within the rootfs and can never address the host; the mount syscalls then reach the resolved target through its/proc/self/fdpath. Missing mount targets are created; a directory stays afterwards, a file does not (see What a launch leaves in the root). A read-only bind is made read-only after binding withmount_setattrandAT_RECURSIVE, so the bind and every mount beneath it become read-only in one step; on a kernel before 5.12, which lacksmount_setattr, only the top mount is made read-only (carrying along the flags the user namespace holds locked) and any submounts of the source keep their own flags. The restriction becomes a boundary at step 12, where the command enters a nested user namespace and the kernel locks it — see A read-only bind is a boundary. - Pivot. The init enters the new mount and calls
pivot_root(".", "."), stacking the old root on the same mount point, then lazily detaches the old root and changes directory to the new/. When the step completes, the host filesystem is no longer reachable from the sandbox’s view. - Fork and exec. After changing to the configured working directory,
the init forks the command process — PID 2 — which wires its standard
streams per the configuration, applies the configured resource
limits, enters a nested user namespace
(below), applies any hardening, and calls
execve, resolved inside the new root. The hardening layer applies in two halves around the switch to the configured run-as identity, when one is set: securebits, no-new-privileges, and Landlock before it, the capability drop and the seccomp filter after. The command receives the composed environment: the deterministic base (PATHandHOME) plus the caller’s variables, nothing from the host. The init remains as the namespace’s supervisor.
The nested user namespace
The command does not execute in the user namespace the sandbox was built in. Between the resource limits and the hardening layer it enters a second one, of its own, with a fresh mount namespace alongside it.
The reason is that the kernel locks a mount’s flags exactly when it copies a mount tree into a new user namespace. A sandbox built the other way round — create the namespace, then build the mounts inside it — carries no locks at all, and root of the namespace that owns a mount may lift any restriction on it. The nested entry makes the kernel perform the copy, and every mount the command inherits comes across locked.
The nested namespace’s map is the sandbox’s own map reflected onto its inside
ids: for each extent inside outside count of the sandbox’s map, a line
inside inside count. The command therefore sees exactly the ids it saw
before, under the same names. It is established the same way the sandbox’s own
is — written in process under the single-identity map, and written by a
delegate under a range map, the delegate here being the
sandbox init rather than the caller, since only a process inside the sandbox’s
namespace is root of it. It is reached through a procfs, by path, which is why a
sandbox that mounts none is refused at build time — except where the delegate is
outside the sandbox and reads the host’s own, which Identity
maps sets out.
The entry sits where it does because the kernel re-derives the whole credential for a new user namespace: securebits return to their defaults, the capability bounding set is refilled, and the inheritable and ambient sets are cleared. Any hardening applied before it would be silently undone. The launch gate stays above it, so a caller attaches its network stack against the namespaces it already knows, and the resource limits stay above it because they are unaffected either way.
What the command gives up
Past the entry the command is root of a namespace that owns nothing but its own mount namespace. It therefore holds no capability over the namespaces the sandbox created, which the sandbox’s user namespace still owns:
- It cannot clear a locked flag on any mount it inherited, cannot change atime behaviour on one — the kernel locks atime on every copied mount, whatever it was — and cannot unmount one to reveal what is beneath. It can still mount whatever it likes in its own namespace; those mounts are its own, unlocked, and private to it.
- It cannot bind a TCP port below 1024 (
CAP_NET_BIND_SERVICE), open a raw socket, or send ICMP — sopingdoes not work inside a sandbox. Ordinary sockets are unaffected: connecting out, listening above 1024, and everything the userspace network stack carries all behave as before. - It cannot change the sandbox’s hostname, and cannot reconfigure the network interfaces the setup brought up.
The host requirement
Every launch now holds two user namespaces rather than one, and a namespace is
charged against user.max_user_namespaces at every level up to the initial
namespace. host::userns_blocker reports a ceiling of
1 as a blocker for that reason. The check is necessary and not sufficient — a
host whose ceiling is four with three namespaces already live fails the same way
— so the launch itself reports the exhausted budget as
Error::NestedUsernsBudgetExhausted, naming the sysctl.
The mount profile
The default profile assembles three mounts over the rootfs, each of which can be disabled:
/procis a fresh procfs instance, mounted from inside the new PID namespace, so it presents the sandbox’s processes and nothing else. Withpid_namespace(false)it is instead the host’s procfs, bind-mounted with its submounts: without a new PID namespace a fresh instance would present the same processes anyway, and the kernel refuses to mount one in a user namespace when the host/proccarries overmounts, as common host configurations do. Either way,/proc/netand/proc/sys/netresolve through the reading process’s own network namespace, so they reflect the sandbox’s network, not the host’s./devis a tmpfs holding the host’snull,zero,full,random,urandom, andttydevices — bind-mounted read-write, sincemknodfor character devices is denied in an unprivileged user namespace — together with thestdin,stdout,stderr,fd, andptmxsymlinks, a freshdevptsinstance on/dev/pts, and a tmpfs on/dev/shm. The/devtmpfs isnosuidbut, by design, notnodev: it has to carry those device nodes, whichnodevwould render inoperable. This is deliberate and safe in the sandbox’s user namespace — the bound nodes are the host’s own, not fresh nodes the sandbox could use to reach an arbitrary device, and access to them is governed by the namespace.ttyis the exception to “the host’s own”: it is the character device5:0, whose open returns the opening process’s controlling terminal rather than the inode the bind carries, so what the sandbox reaches through it follows from the sandbox’s session. See The dispositions and the caller’s terminal, and A terminal of the sandbox’s own for the posture where it resolves to a terminal the caller does not share./tmpis a fresh tmpfs.
The caller’s own mounts apply after the managed profile, in configuration
order, with one exception: the resolv.conf bind is applied last of all.
Binds and raw mounts (see Profiles for the escape hatch) share
one sequence rather than being applied kind by kind, so a mount that
covers a path an earlier one established can be ordered deliberately: a raw
tmpfs followed by the binds that populate it yields a directory the sandbox
owns outright, which is how the managed /dev is itself assembled.
Last, the host’s resolv.conf — resolved to its real file — is bound
read-only onto /etc/resolv.conf when the host network is shared. Unless the
caller mounted that target themselves: a mount of their own there replaces this
one rather than sitting under it, so applying it last does not take back the
rule that among the caller’s mounts the order is theirs. Every kind of mount
counts, a raw mount as much as a bind.
A read-only bind is a boundary
A read-only bind establishes both what the sandbox sees and what the command
can do. The command cannot remount it read-write, and a write to it answers
EROFS.
The kernel locks a mount’s flags when it copies a mount tree into a new user
namespace, and the nested user namespace the
command enters before it execs is that copy. The lock is unconditional: it needs
no hardening, no capability drop, and no non-root identity, and it holds against
a command that is root of its own namespace with CAP_SYS_ADMIN and an
unfiltered mount syscall.
The same lock covers every restriction the sandbox’s mounts carry — nosuid on
the root, nodev and noexec where the profile sets them — and it holds for
unmounting too: the command cannot detach an inherited mount to reveal what is
beneath it. What the command remains free to do is mount things of its own; those
mounts are its own, unlocked, and private to its mount namespace.
The root is nosuid
The root mount carries nosuid, so a set-user-ID or set-group-ID binary in the
rootfs confers nothing on the command that executes it, and a file capability
recorded on one is ignored. This matters because a provisioned rootfs applies
the tree’s permission bits verbatim: a Debian or Alpine root ships set-user-ID
binaries owned by in-namespace uid 0, which a non-root run-as
identity would otherwise be
able to execute its way back through. The flag is locked by the nested entry
like every other, so the command cannot clear it.
The scope is the root and whatever the root bind carried with it. A caller’s own
bind and bind_ro mounts keep inheriting their source’s flags: a caller who
binds a host tree in order to run what is in it is entitled to a different
answer than the rootfs gets.
What a launch leaves in the root
A mount needs its target to exist, so the sandbox creates any that is missing. What it creates divides in two.
A missing directory is a mount point, and it stays. It is created at the
mode of the directory it stands for — 1777 for /tmp and /dev/shm,
0755 otherwise — so the tree is left holding the directory it would have
shipped, rather than one the mount’s own mode happened to hide.
A missing file target is content the root never had — an empty
/etc/resolv.conf, an empty file where a binary is bound — present only for
its mount to cover. It is created at 0644 exactly, independent of the
launching process’s umask, and does not survive the sandbox: the paths are
recorded at build time, and dropping the handle removes each one, whether the
sandbox ran to completion or the launch never proceeded. Only an empty regular
file is removed, so anything that gained content or changed type in the
meantime belongs to whoever put it there. The rule covers the managed
resolv.conf bind and the caller’s own file binds alike.
A target the root already ships is never touched, whatever kind of entry it
is. A symbolic link is one such entry, and it is left as it is rather than
created through: the mount resolves it as the root intends. A root that ships
a link to a path it does not have — the shape /etc/resolv.conf takes on a
host running a resolver daemon — therefore has no target to bind onto, and the
launch reports a failed mount naming that bind rather than creating a file
somewhere the root did not ask for.
Two targets are outside the rule, because neither is a file in the root: one
created inside an earlier mount — under the /tmp tmpfs, or under a bind of
the caller’s own — belongs to that mount rather than to the root, and for an
overlay root the created file lands in the upper, where the merged view’s
writes go, and is removed from there.
Failure reporting
Every stage shares a close-on-exec report pipe created before the first
fork. If any step fails, the failing stage writes a single 12-byte record —
the step, the errno, and the index of the item it concerned, all
little-endian — and exits; the write is atomic at that size, and the caller
surfaces it as a typed error naming the failed step and its subject: for a
mount step the mount it was assembling, for a resource limit the limit, and
for the exec step the command path. When execve succeeds, close-on-exec
closes the last write end: the caller reads end-of-file and knows the
command is running.
Reading an ENOENT from the exec step
sandbox setup failed while executing the command (/usr/bin/build): \
No such file or directory (os error 2)
execve answers ENOENT in two distinct situations, and the kernel does
not distinguish them:
- The command is not there. The usual case, and the error names the path so a typo is visible in the message reporting it.
- The command’s ELF interpreter is not there. A dynamically linked
binary names its loader —
/lib/ld-musl-x86_64.so.1,/lib64/ld-linux-x86-64.so.2— and the kernel reports a missing loader asENOENTagainst the binary, not against the loader. So a command that plainly exists inside the rootfs can still fail this way: the rootfs is missing the loader, or the binary was built against a different libc than the rootfs provides, or a merged-usr symlink the loader path resolves through is absent.
The second reading is the one to reach for when the named path is present.
ls -l the path inside the sandbox to confirm it is there, then check the
interpreter the binary asks for — readelf -l on the host names it in the
INTERP segment — and confirm that path resolves inside the rootfs.
spawn blocks until the report pipe settles, so a returned handle always
means a running command, and a setup failure is always a typed error at the
point of launch. The command’s exit status travels separately, over a
status pipe written by the supervisor, with full fidelity — an exit code
and a fatal signal are never conflated.
The scope of the isolation
The sandbox presents the rootfs as / with the mount profile assembled
over it, and maps the calling user to root inside. The rootfs itself is
writable by the command wherever the calling user could write it, and mount
point directories created during setup persist there — see What a launch
leaves in the root. The mount, PID, UTS, IPC,
cgroup, and — by default — network spaces are isolated.
The default profile is a rootless convenience for code you trust: a controlled filesystem view, isolated namespaces, and a clean environment. Hostile code calls for the hardening layer; the default profile alone is not a boundary against it.
What the sandbox does not hide
Isolation is about what the command can reach, not about what it can learn.
The command reads its own mount table — /proc/self/mountinfo, and the
/proc/mounts symlink to it — and every line carries the host path the mount
came from:
263 191 252:1 /home/alice/build/alpine-rootfs / rw,nosuid,noatime - ext4 ...
408 263 252:1 /tmp/build-inputs /ro ro,noatime - ext4 ...
So a sandboxed command can read the calling user’s name, the layout of the
build that started it, and the host path behind each bind. This is what every
bind-mount-based sandbox exposes — Docker and bubblewrap alike — and it follows
from binding host paths at all; closing it would take a mount over mountinfo
in a /proc of the sandbox’s own, which is a larger mechanism than the
disclosure warrants.
It is called out because the rest of the crate leans the other way. A
Terminal refuses to read the host’s window size, base_env
exists so that no host variable reaches the command unasked, and TERM is the
caller’s to state. None of that extends to the mount table: a command that wants
to know where it is running can find out. Where that matters — a build whose
output must not embed a path, code whose behaviour should not vary with one —
bind the inputs at stable in-sandbox paths and treat the mount table as one more
thing the command may read.
The process model
A sandbox is a small tree of processes. Understanding it explains what runs where, what happens when the command misbehaves, and what the library guarantees about lifetimes and signals.
The process tree
caller
└── launch stage resets signal state, unshares the namespaces,
(exits immediately) sweeps descriptors, establishes the identity
map, publishes the supervisor
└── init (PID 1) sets the sandbox up, then supervises
└── command (PID 2) execs the configured command
A launch forks a short-lived stage that creates the namespaces and hands a pidfd for the sandbox’s supervisor back to the caller before exiting. With the default PID namespace, the supervisor is a minimal init running as PID 1 inside the namespace; the command runs as PID 2 beneath it.
Throughout this guide, and in the library’s own errors, the supervisor is that process: the one the returned handle anchors on, kills, and collects the command’s outcome from. The launch stage is the separate, short-lived process that built the namespaces.
The init:
- adopts and collects processes orphaned inside the sandbox;
- forwards termination requests to the command;
- reports the command’s exit status to the caller when it terminates, and then exits itself.
The init’s exit destroys the PID namespace, and the kernel then kills every
process remaining in it. The sandbox’s lifetime is therefore the command’s
lifetime: a process the command leaves behind cannot outlive the launch,
and cannot keep run from returning.
With pid_namespace(false), there is no init: the command process is itself
the supervisor, and the launch stage remains outside the sandbox as its parent
until the outcome arrives. The command shares the host’s PID space, and its
descendants are ordinary host processes that can outlive it.
What the command starts with
Regardless of what state the calling process is in, the command starts with:
- default signal dispositions and an empty signal mask. Ignored
dispositions survive
execve, so without this reset a command would inherit, for example, the ignoredSIGPIPEevery Rust program sets — and pipelines inside the sandbox would misbehave. - a descriptor table holding only its standard streams. The launch closes every inherited descriptor other than the streams before the command is forked, so no caller descriptor leaks into the sandbox and no caller resource is pinned by it. This is stronger than the usual close-on-exec convention, which depends on every descriptor in the process having been opened carefully.
- the composed environment, standard streams per the configuration, and the working directory, as described in How the sandbox is built.
The handle
spawn starts the command and returns a Running handle. The launch is
complete when spawn returns: setup errors surface there, and the handle
means the command is executing.
wait()blocks until the command terminates and returns its exit status.wait_deadline(instant)/wait_timeout(duration)additionally return when the deadline passes, reporting that the command still runs. Nothing is killed by a deadline; escalation belongs to the caller.terminate()requests a graceful stop: the supervisor deliversSIGTERMto the command process, which may trap it, clean up, or ignore it.kill()ends the sandbox:SIGKILLto the supervisor through a pidfd — immune to pid reuse — which, as namespace init, takes the whole namespace with it. Nothing inside survives.kill_handle()returns a detached handle that can terminate or kill from another thread while one blocks in a wait.
The bounded-stop recipe composes them:
use std::time::Duration;
fn main() -> ferroday_cage::Result<()> {
let cage = ferroday_cage::Cage::builder().rootfs("/r").command("/c").build()?;
let mut running = cage.spawn()?;
let status = match running.wait_timeout(Duration::from_secs(60))? {
Some(status) => status,
None => {
running.terminate()?;
match running.wait_timeout(Duration::from_secs(10))? {
Some(status) => status,
None => {
running.kill()?;
running.wait()?
}
}
}
};
Ok(())
}
Waits are fused — once the outcome is known, every further wait returns it — and killing or terminating an already-exited sandbox is not an error. Dropping the handle without waiting does not kill the sandbox and does not collect its outcome; prefer waiting, including after a kill, which also drains any output produced before it.
Signals and the caller
The library installs no signal handlers — how the embedding application responds to its own signals is application policy. The defined behavior:
- The command shares the caller’s process group and session. A
terminal
^CdeliversSIGINTto the foreground process group, which includes the command; an interactive command exits with the foreground job, exactly as it would outside the sandbox. - A signal delivered to the caller alone does not affect the sandbox.
An application that wants its
SIGTERMto stop the sandbox callsterminate/killfrom its handler path, per the recipe above. stop_with_caller(true)ties the sandbox’s lifetime to the caller’s: when the calling process exits — cleanly, by signal, or by crash — or the last handle to the launch is dropped, the supervisor stops the sandbox. The tie is a descriptor held by the handle, not a signal arrangement, so it works from any thread and holds even forSIGKILL. It is off by default: an abandoned sandbox otherwise runs to completion, supervised, and is torn down when the command exits.
Reaping and zombies
Inside the sandbox, orphaned processes reparent to the init and are collected on its next wake-up: when the command exits, when control traffic arrives, or on the init’s reaping tick, which fires once per second while the sandbox runs. A zombie therefore lingers for at most about a second, regardless of how long the command runs; teardown at command exit clears everything. Without a PID namespace, orphaned processes reparent to the host’s init as usual.
On the caller’s side, a launch never leaks a zombie when it is waited: the
short-lived launch stage is collected during spawn (or by wait, in the
supervisor-outside mode), and the in-namespace init is not the caller’s
child process at all.
Identity maps
Every sandbox runs inside a user namespace, and the namespace’s identity map
decides which uids and gids exist there and which host ids they are. The map
is part of who the sandbox is: it governs what chown can name, which
users a build can drop to, and what ownership the sandbox’s writes leave on
the host.
The default map is the single-identity map: root inside is the calling
user outside, and no other id exists. It costs nothing and works everywhere
the sandbox itself works. Its limits follow directly from having one id:
reads of host files owned by anyone else present the overflow id (nobody),
and changing a file’s ownership to any other id fails with EINVAL, because
the namespace cannot represent the id at all. Most commands never notice.
Packaging tools do — a Debian maintainer script’s chown root:mail, or
portage chowning its state to the portage user, fails outright.
A range map removes the limit by giving the sandbox more ids:
use ferroday_cage::{Cage, IdentityMap};
fn main() -> ferroday_cage::Result<()> {
let cage = Cage::builder()
.rootfs("/srv/rootfs/gentoo")
.command("/usr/bin/emerge")
.identity_map(IdentityMap::Subordinate)
.build()?;
Ok(())
}
Inside a subordinate-mapped sandbox, root is still the calling user, and
inside ids from 1 onward are the user’s subordinate allocation — 65536 ids
on a typically configured host. chown succeeds, setgroups works, and
users other than root genuinely exist.
What the kernel permits
The map shapes the design because the kernel constrains who may write it:
- An unprivileged process writing its own map may write exactly one line, of exactly one id, naming its own effective id — the single-identity map, and nothing else.
- Any richer map must be written from outside the new namespace, by a
process holding
CAP_SETUIDandCAP_SETGIDover its parent. This is true even of a process that was privileged before it unshared: capabilities extend to a namespace’s descendants, never to its ancestors. - The map files are write-once. There is no establish-then-extend path, so whichever party applies the map applies all of it, once.
The launch accounts for this internally. The single-identity map is written by the launch stage itself, in process. For a range map, the launch stage unshares and then blocks at an internal gate while the caller’s side writes the map against its pid, and only then proceeds — so by the time a launch is observable, its namespace is always fully mapped.
The nested map
The command runs one user namespace deeper than the sandbox: it enters a nested one before it hardens, which is what locks the flags of the mounts the sandbox established (see The nested user namespace). That namespace needs a map of its own, and the library composes it rather than asking for one.
A nested namespace maps against its parent’s id space, not the host’s, so the
map that leaves the command’s view unchanged is the parent map reflected onto
its own inside ids: for each extent inside outside count, a line
inside inside count. Every id the sandbox could represent is still
representable, under the same name, and id inside reports what it reported
before.
The same kernel rules decide who writes it. Under the single-identity map the
nested map is 0 0 1 — one line, of one id, naming the writer’s own effective
id — so the command writes it itself. Under a range map it cannot, and neither
can the host caller, which owns the sandbox’s user namespace but is not inside
it: the writer has to be root of the namespace the nested one is a child of,
which is the sandbox init (or, without a PID namespace, the launch stage). That
process creates a gate immediately before it forks the command, writes the map
when the command signals that it has unshared, and releases it. No delegate of
the caller’s is involved, and the IdMapper seam below is not consulted.
The map files are reached by path, so establishing the nested map generally
requires a procfs, and a profile that mounts none is refused at build with
ConfigError::NestedUsernsNeedsProcfs. The managed profile mounts one by
default, so this arises only for a profile that opts out of the managed mounts
and supplies no procfs of its own.
The requirement follows the route the map takes, not the fact of nesting. A
self-written map — the single-identity tier — needs one, and any procfs serves,
since a process reaches its own map files through self. A delegated map inside
a PID namespace needs one too, and that one must be a fresh instance rather
than a bind of the host’s: the delegate names the command by a pid of the
sandbox’s own PID namespace, which a bound procfs from outside does not index.
A delegated map with pid_namespace(false) needs none — the delegate is then
the launch stage, outside the sandbox, which opens the host’s /proc before the
pivot — so a range map composes with managed_mounts(false).
Delegates
Who writes a range map is a public seam: the IdMapper trait. An
implementation answers two questions — resolve, called at build, reports
the ranges it can provide, so an unsatisfiable map is a configuration error
rather than a launch failure; apply, called during each launch, writes the
map against the gated launch stage. The library bundles two delegates and
consults them in order when none is configured:
DirectMapperwrites the map itself — pure Rust, in process. It works when the caller already holdsCAP_SETUIDandCAP_SETGIDover its own user namespace: running as root, or running inside another range-mapped namespace. The second case matters more than it may appear — a cage nested inside a range-mapped cage, or inside a rootless container, establishes its range map this way, entirely on its own.SubidMapper, behind thesubidfeature, executes the shadow suite’s privilegednewuidmapandnewgidmaphelpers — the standard subordinate-id mechanism every rootless container runtime uses, and the only route to a range map for an ordinary unprivileged caller on an ordinary host. This is the one place the crate executes an external binary, which is why it is a non-default feature: it keeps the default build self-contained.
A caller with site-specific machinery — a privileged broker of its own, a
different helper — supplies its own implementation through
CageBuilder::id_mapper, and the bundled delegates step aside.
The fallback is between delegates, never between tiers. A range request
no delegate can satisfy fails at build, naming each delegate’s refusal and
the host condition responsible; it is never quietly downgraded to the
single-identity map. An identity posture that silently becomes something
weaker is worse than one that fails — a caller who wants the single-identity
map asks for it by name.
The subordinate allocation
IdentityMap::Subordinate requests root plus the caller’s whole subordinate
allocation, as the delegate reports it. The composed map is the caller’s own
id at inside-id 0, then inside ids from 1 onward covering each allocated
range in order.
The allocation is the administrator’s delegation, granted per user in
/etc/subuid and /etc/subgid (usermod --add-subuids manages it) — or by
a site’s directory service, since subordinate ids are an NSS database like
passwd. The library therefore does not simply parse the files: it queries
getsubids, which consults the same sources the helpers do, and reads the
files directly only on hosts whose nsswitch.conf names no other subid
source — the same rule the shadow suite itself follows, so the fallback can
never miss a directory-provided allocation.
Explicit ranges are the alternative when the exact shape matters:
use ferroday_cage::{Cage, IdRange, IdentityMap};
fn main() -> ferroday_cage::Result<()> {
let cage = Cage::builder()
.rootfs("/srv/rootfs")
.command("/bin/sh")
.identity_map(IdentityMap::ranges(
vec![
IdRange { inside: 0, outside: 1000, count: 1 },
IdRange { inside: 1, outside: 100000, count: 999 },
],
vec![
IdRange { inside: 0, outside: 1000, count: 1 },
IdRange { inside: 1, outside: 100000, count: 999 },
],
))
.build()?;
Ok(())
}
Both lists must map inside-id 0 — the sandbox is built as root inside — and
stay within the kernel’s limits, which build checks: no overlaps, no zero
or wrapping extents, at most 340 extents per map.
Host requirements
The single-identity map has no requirements beyond the sandbox’s own. The delegated tier needs the shadow suite’s subordinate-id helpers, whose package differs by distribution:
| Host | Package |
|---|---|
| Debian, Devuan, Ubuntu | uidmap |
| Alpine | shadow-subids |
| Gentoo | sys-apps/shadow |
| Void | shadow |
The helpers are not always setuid: several distributions grant them file
capabilities instead, which works equally well, and
host::range_map_blocker probes for a working helper rather than a mode
bit. The probe names whichever condition blocks a range map — a missing
helper (commonly a container runtime installed without its recommended
packages), a helper stripped of its privilege, a file-capability helper too
old to map uid 0 (shadow before 4.11.1), a symlinked subordinate file, or an
absent allocation — with the remedy in the message. Subordinate on a build
without the subid feature is refused the same way, at build for code and
at load time for a profile.
Ownership on the host
Range-mapped ids are real, and that reaches the host: a file the sandbox
chowns to inside-id 250 is owned by the 250th subordinate id outside —
100249, say. That is the point, and it has one operational consequence.
Deleting a file needs write permission on its containing directory, so a
directory the sandbox chowned to a non-root id — portage’s state
directories, a service’s /var/lib tree, a user’s home — refuses the plain
caller’s rm -rf, which is how rootless container storage has always
behaved. provision::remove handles it: plain removal first, and where
ownership refuses it, the same map is re-entered and the tree deleted from
inside, where the ids are the caller’s own. At a shell prompt, fcage removes
the same tree:
fcage --remove-rootfs ./rootfs
The flag takes the map the tree was provisioned under and defaults to the
subordinate one, so --identity-map is needed only for a tree written under
another. Without either, the manual form every rootless runtime documents is
unshare --map-auto rm -rf ./rootfs.
Running as a non-root identity
A range map makes non-root identities exist; run_as makes the command one
of them:
use ferroday_cage::{Cage, Identity, IdentityMap};
fn main() -> ferroday_cage::Result<()> {
let cage = Cage::builder()
.rootfs("/srv/rootfs")
.command("/usr/bin/id")
.identity_map(IdentityMap::Subordinate)
.run_as(Identity::new(250, 250).groups([250, 100]))
.build()?;
Ok(())
}
The switch sits within the hardening layer, after securebits, no-new-privileges,
and Landlock and before the capability drop and the seccomp filter, so the
command holds the identity from its first instruction and its descendants
inherit it. Every id must be contained in the identity map —
build rejects one that is not — and supplementary groups additionally
require a range gid map, because establishing the single-identity map denies
setgroups permanently.
A run-as identity also sets no-new-privileges, in every build of the library
and whatever else the sandbox requests. The cleared capability set is only half
of the boundary: a rootfs ships set-user-ID binaries and file capabilities owned
by in-namespace uid 0 — the provisioners apply a tree’s permission bits
verbatim, so /usr/bin/sudo is a working /usr/bin/sudo — and without the flag
the non-root command executes them and is root inside again. The flag is
inherited across execve and by every process the command starts.
Capabilities interact with the switch in three ways:
- No keep request. The kernel clears the command’s capabilities across the transition to a non-zero uid. This is the boundary a non-root identity exists to draw, and the default.
- A kept capability set. The launch locks
SECBIT_NO_SETUID_FIXUPbefore the switch, so the kept set survives it: the command runs as the non-root identity holding exactly the kept capabilities, in its permitted, effective, and ambient sets. The securebit is locked, so the command cannot restore the default fixup behavior. - A kept set-id capability. Retaining
CAP_SETUID,CAP_SETGID, orCAP_SETPCAPalongside a non-root identity would let the command return to the mapped uid 0 — an identity that is not a boundary at all — and is rejected atbuildby name.
Profiles
With the serde feature the identity map and run-as identity are part of
the profile format:
rootfs = "/srv/rootfs/gentoo"
command = "/usr/bin/emerge"
identity-map = "subordinate"
[run-as]
uid = 250
gid = 250
groups = [250]
Explicit ranges spell out their extents:
[identity-map.ranges]
uid = [
{ inside = 0, outside = 1000, count = 1 },
{ inside = 1, outside = 100000, count = 65536 },
]
gid = [
{ inside = 0, outside = 1000, count = 1 },
{ inside = 1, outside = 100000, count = 65536 },
]
A profile requesting subordinate on a build without the subid feature is
refused when it loads: a profile’s identity posture is never silently
downgraded by a library that cannot establish it. The delegate itself is
code, not configuration, and is never part of a profile.
Streaming output
By default the command inherits the caller’s standard streams: output goes
wherever the caller’s goes, and nothing is captured. For programmatic
consumption, a launch can instead capture the command’s standard output and
standard error — whole, with output, or live, with an observer.
Collecting the whole output
output runs the command and returns its exit status together with
everything it wrote, the shape std::process::Command::output has:
use ferroday_cage::Cage;
fn main() -> ferroday_cage::Result<()> {
let result = Cage::builder()
.rootfs("/srv/rootfs/alpine")
.command("/bin/sh")
.args(["-c", "echo out; echo err >&2"])
.build()?
.output()?;
assert!(result.status.success());
assert_eq!(result.stdout, b"out\n");
assert_eq!(result.stderr, b"err\n");
Ok(())
}
stdout and stderr are raw bytes: not lines, and not guaranteed to be
UTF-8. status is the same ExitStatus every other launch method returns,
so a non-zero exit is data here too.
What output keeps is unbounded, which is the right default for a command
whose output the caller controls and the wrong one for a command whose output
it does not. A command that writes without stopping is held entirely in
memory. For model-generated code, an untrusted build, or anything else that
might not stop, stream through an observer that caps what it retains — the
next section shows one — and pair it with a deadline from the process
model.
The observer
An observer implements the Observer trait: a callback per stream, each
with an empty default body, receiving raw byte chunks as they are read.
Chunks are not lines and not necessarily UTF-8; their boundaries carry no
meaning. An observer that wants lines assembles them itself.
use ferroday_cage::{Cage, Observer};
#[derive(Default)]
struct Log {
errors: Vec<u8>,
}
impl Observer for Log {
fn stdout(&mut self, chunk: &[u8]) {
print!("{}", String::from_utf8_lossy(chunk));
}
fn stderr(&mut self, chunk: &[u8]) {
self.errors.extend_from_slice(chunk);
}
}
fn main() -> ferroday_cage::Result<()> {
let cage = Cage::builder()
.rootfs("/srv/rootfs/alpine")
.command("/bin/sh")
.args(["-c", "echo out; echo err >&2"])
.build()?;
let mut log = Log::default();
let status = cage.run_with(&mut log)?;
assert!(status.success());
assert_eq!(log.errors, b"err\n");
Ok(())
}
run_with is the blocking convenience; spawn_with returns the running
handle with the observer attached for the handle’s lifetime.
Capping what you keep
An observer decides for itself how much to retain, which is what makes it the right tool for a command whose output volume the caller does not control:
use ferroday_cage::{Cage, Observer};
/// Keeps at most `LIMIT` bytes of each stream and counts the rest.
struct Capped {
stdout: Vec<u8>,
dropped: usize,
}
const LIMIT: usize = 1 << 20;
impl Observer for Capped {
fn stdout(&mut self, chunk: &[u8]) {
let room = LIMIT.saturating_sub(self.stdout.len());
let (kept, spilled) = chunk.split_at(chunk.len().min(room));
self.stdout.extend_from_slice(kept);
self.dropped += spilled.len();
}
}
fn main() -> ferroday_cage::Result<()> {
let mut capped = Capped { stdout: Vec::new(), dropped: 0 };
let cage = Cage::builder()
.rootfs("/srv/rootfs/alpine")
.command("/usr/bin/analyze")
.build()?;
cage.run_with(&mut capped)?;
if capped.dropped > 0 {
eprintln!("dropped {} bytes past the cap", capped.dropped);
}
Ok(())
}
The command still runs to completion — the pump keeps draining the pipes, so
nothing blocks — but the caller’s memory use is bounded by LIMIT regardless
of what the command writes. Bounding the time as well is
wait_deadline’s job, and bounding what the command can
consume while it runs is rlimit’s.
Launch progress
Alongside the command’s output, an observer receives the library’s own
launch milestones through progress, a third callback with an empty default
body. Each launch reports a Progress value at three points, in order:
Launching— the sandbox’s channels are created and its launch stage is forked.Supervised— the namespaces exist and the supervisor is published. For a direct launch the command is about to run; for a held launch (the userspace-networking seam) this is the gate, where a network stack attaches before the command runs.Executing— the command has been executed and is now running.
Milestones are the library’s own progress, not the command’s output, so an
observer can drive a launch log or a progress display without parsing the
streams. They are delivered on the calling thread during the launch itself,
before any captured output flows, and Progress is #[non_exhaustive] so
finer milestones can arrive without breaking an existing observer.
A closure taking a Progress is an observer, for a caller that wants the
milestones and nothing else:
#![allow(unused)]
fn main() {
use ferroday_cage::{Cage, Progress};
fn report(cage: Cage) -> ferroday_cage::Result<()> {
cage.run_with(&mut |event: Progress| eprintln!("{event:?}"))?;
Ok(())
}
}
A closure cannot receive the command’s output, because it has no way to say which stream a chunk came from; an observer that wants the output implements the trait.
How delivery works
The library is synchronous and spawns no threads. Captured output is pumped
inside the blocking calls — run_with itself, or the handle’s wait,
wait_deadline, and wait_timeout — with a single poll over the capture
pipes, on the calling thread. That has three practical consequences:
- The observer runs on the caller’s thread, so it can borrow freely from the caller’s state; no synchronization is imposed.
- Output flows while a wait is in progress. Between
spawn_withand the first wait, output accumulates in the pipes’ kernel buffers; a command that outruns them blocks writing until the caller pumps, the ordinary pipe discipline. - A deadline wait delivers everything produced before the deadline, even
when it returns with the command still running — and the wait after a
killdrains what the command wrote before the kill.
Capture covers the whole sandbox: the command’s child processes inherit its streams, so their output arrives interleaved with the command’s own, as it would in a terminal.
That inheritance also sets capture’s endpoint. A wait completes once the
command’s outcome is known and both captured streams have reached
end-of-file. Under the default PID namespace, the teardown at command exit
closes every remaining copy of the streams promptly. With
pid_namespace(false) there is no teardown: a descendant the command
leaves behind holds the streams open, and an unbounded wait — run_with,
or the handle’s wait — blocks until the last holder closes them. A
caller combining pid_namespace(false) with capture should prefer
wait_deadline or wait_timeout when the command may leave such
descendants behind.
Stream dispositions
Each of the three standard streams has a disposition of its own, set with
stdin, stdout, and stderr and spelled by one enum:
Stdio::Inherit(default, all three) — the stream is left as the launch inherited it. On standard input the command reads the caller’s; on the output pair it writes wherever the caller’s own output goes.Stdio::Null—/dev/null: immediate end-of-file on standard input, for a batch run that must not compete with the caller for input, and a discard on the output pair.Stdio::from_fd— a descriptor the caller supplies.
Inherit states no destination. It is the absence of a choice rather than a
choice of the caller’s descriptors, which is what lets a capturing launch supply
its own pipes for the output pair. Null and from_fd do state one, so a
capturing launch of a sandbox that names either is refused rather than silently
overriding it — the caller meant one of the two, and the library will not guess
which.
use ferroday_cage::{Cage, Stdio};
fn main() -> ferroday_cage::Result<()> {
let cage = Cage::builder()
.rootfs("/srv/rootfs/alpine")
.stdin(Stdio::Null)
.stderr(Stdio::Null)
.command("/usr/bin/build")
.build()?;
Ok(())
}
The dispositions and the caller’s terminal
They also decide what the sandbox reaches of the caller’s terminal. A controlling terminal is reached through the session, not through the filesystem, so no namespace and no swapped root affects it; an inherited descriptor is reached through neither. The rule the library follows is that a sandboxed command reaches the caller’s terminal exactly through the standard streams the caller handed it — and closing it therefore takes all three.
Standard input decides the session. Stdio::Inherit hands the command the
caller’s standard input, so it stays in the caller’s session, where the terminal
is reachable as file descriptor 0 and equally as /dev/tty, the same terminal
by another name. Stdio::Null and Stdio::from_fd do not, so the command runs
in a session of its own with no controlling terminal at all: /dev/tty fails
with ENXIO, and TIOCSTI — the ioctl that pushes characters into a terminal’s
input queue for the caller’s shell to run afterwards — is refused on every
terminal descriptor, since the kernel grants it only for the process’s own
controlling terminal.
The session does not close the output pair. Run from a shell with no
redirection, file descriptors 1 and 2 are dups of one read-write open of the
caller’s terminal, and a session of the sandbox’s own does not take them away. A
command holding them can read what is typed at it, leave the caller’s terminal
without echo with tcsetattr, and resize it with TIOCSWINSZ, which delivers
SIGWINCH to the caller’s foreground process group. stdout and stderr are
what close that, and a capturing launch closes it too by supplying pipes of its
own.
Job control has two senses, and the dispositions decide where each happens.
Caller-session job control is the caller’s shell managing the sandbox as one
of its own jobs: an interrupt typed at the caller’s terminal reaches it, ^Z
suspends it, fg resumes it. Sandbox-session job control is a shell inside
the sandbox running jobs of its own, which takes tcsetpgrp on a terminal whose
session that shell shares.
Under Stdio::Inherit both work, and both work by reaching into the caller’s
session — the second by the sandbox taking the caller’s terminal’s foreground
process group, a shell inside the sandbox competing with the caller’s own shell
for one terminal. Under Stdio::Null or Stdio::from_fd neither does: the
sandbox is outside the caller’s session, and the session it has instead owns no
terminal to run jobs against. A caller that wants to stop such a sandbox on an
interrupt calls Running::terminate or Running::kill, or ties the sandbox
to its own life with
stop_with_caller.
/dev/tty is bound into the sandbox either way, and dropping the /dev mount
would not change any of this: /dev/tty is the character device 5:0, whose
open returns whatever the opening process’s controlling terminal is, so it
conveys no terminal of its own — and an inherited terminal descriptor is still
an inherited terminal whether or not /dev/tty has a name.
Feeding the command input
Stdio::from_fd takes anything readable — the read end of a pipe the caller
writes, an open file, a pseudoterminal replica — and duplicates it onto the
command’s file descriptor 0. It is how a command is handed input that wants to
be a stream rather than a file:
use std::io::Write;
use ferroday_cage::{Cage, Stdio};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let (reader, mut writer) = std::io::pipe()?;
let cage = Cage::builder()
.rootfs("/srv/rootfs/alpine")
.stdin(Stdio::from_fd(reader.into()))
.command("/usr/bin/python3")
.build()?;
// Write the input and close the write end, so the command sees
// end-of-file rather than waiting for more.
writer.write_all(b"print(1 + 1)\n")?;
drop(writer);
let result = cage.output()?;
assert_eq!(result.stdout, b"2\n");
Ok(())
}
Two things to know about the shape:
- Write, then close, then wait. A pipe holds 64 KiB by default. Writing more than that from the same thread that later waits deadlocks: the write blocks for a reader, and nothing is draining the command’s output meanwhile. For input that fits, write it and close the write end before waiting, as above. For input that does not, drive the write from another thread while the main thread pumps.
- The descriptor is shared, not consumed. A
Cageis a frozen plan that launches any number of times, so every launch wires the same descriptor and the second launch reads whatever the first left. A cage feeding distinct input per launch is built per launch.
The same constructor serves the output pair, where anything writable does: a log file each launch appends to, or a pipe a caller drains itself rather than through an observer.
use ferroday_cage::{Cage, Stdio};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let log = std::fs::File::create("/var/log/build.log")?;
let cage = Cage::builder()
.rootfs("/srv/rootfs/alpine")
.stdout(Stdio::from_fd(log.into()))
.stderr(Stdio::Null)
.command("/usr/bin/make")
.build()?;
Ok(())
}
There is no conversational form on top of this — no way to write a statement, read the reply, and write another to a process that is already running. A caller can build one with two pipes and a thread.
Do not reach for a caller-opened pseudoterminal here. Passing its replica as the
standard-input descriptor puts the sandbox in a session of its own, and nothing
makes the replica that session’s controlling terminal, so a shell inside
reports cannot set terminal process group and runs without job control.
A terminal of the sandbox’s own is the answer to that: the
library allocates the pseudoterminal, wires it onto all three streams, and makes
it the sandbox’s controlling terminal.
A terminal of the sandbox’s own
A sandboxed command can be given a pseudoterminal allocated for it: isatty is
true on all three of its standard streams, a full-screen program behaves,
/dev/tty resolves, and a shell inside the sandbox runs jobs of its own —
against a terminal the caller does not share.
That last part is the point. Streaming output describes the two postures available without it, and both are compromises. Inheriting standard input makes an interactive command work, but everything that works there works by reaching into the caller’s session: the sandbox can read what is typed at the caller’s terminal, change its settings, and take its foreground process group. Not inheriting it closes that, and nothing that wants a terminal works. A terminal of the sandbox’s own gives both.
Starting one
A terminal is a launch mode rather than builder state, so it is passed to the launch and hands back a live resource:
use ferroday_cage::{Cage, Terminal};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cage = Cage::builder()
.rootfs("/srv/rootfs/alpine")
.command("/bin/sh")
.env("TERM", "xterm-256color")
.build()?;
let (mut running, mut pty) = cage.spawn_terminal(&Terminal::new())?;
std::io::copy(&mut pty, &mut std::io::stdout())?;
let status = running.wait()?;
assert!(status.success());
Ok(())
}
Terminal states the window size and nothing else: Terminal::new() is 80
columns by 24 rows, a stated constant rather than anything read from the host,
and Terminal::new().size(rows, cols) says otherwise. A zero dimension is left
at its default: zero is what a terminal reports when its size is unknown, not
a terminal with no rows, and a caller passing along what its own terminal
reported should not have to filter that out. The replica is wired onto
the command’s file descriptors 0, 1 and 2 — a terminal is one stream — and the
launch owns its session, so the pseudoterminal is the sandbox’s controlling
terminal whatever the standard-input disposition would otherwise have decided.
Pty is the caller’s end. It reads what the sandbox wrote to its terminal and
writes what the sandbox reads as typed input.
Three things are the caller’s to supply, because the library reads nothing from the host:
TERMis an environment variable rather than terminal configuration, so set it with.env("TERM", ...). Without one,vimfails on first use.- The size, if the caller has a better answer than 80x24 — and afterwards, because a terminal’s size changes over the life of a session and no launch-time constant can track it. See Resizing.
- The caller’s own terminal, which an interactive session must put into raw
mode and restore afterwards.
ferroday_cage::relaydoes both; what stays the caller’s is saying so. See The relay.
Draining, and the one order that deadlocks
The primary’s buffer is a few kilobytes. A command that writes more than that
blocks, so a caller that waits without reading deadlocks — which is why run
and output are not terminal entry points at all. When an unbounded wait()
starts, the Pty is in one of three states:
- Drained to end-of-file — safe. End-of-file means every replica is closed and the command is already gone.
- Dropped — safe. After the hangup the command’s writes fail with
EIOrather than block, so a caller that has dropped the primary owes the wait no drain. - Held and unread — the deadlocking case.
This is the same obligation Stdio::from_fd already carries for a pipe, with
the same answer: where the caller owns both ends, the feed and the drain are the
caller’s, and a wait that must be bounded is bounded.
A caller that sends no input drains to end-of-file and then waits. That terminates on its own — the example above is the whole pattern, and its second line is the part worth understanding.
An interactive caller needs both directions at once, and
the relay is the answer: one poll loop that cannot be stalled by
either direction, with the signal handling and the raw-mode discipline an
interactive session also owes. Everything below is for a caller composing
something else out of a Pty — a recorder, a test harness, a driver that is not
a terminal session at all.
Both directions at once needs concurrency: writes to the primary block too once its input buffer fills, so a large paste into a command that is not reading stalls a loop that writes from the thread it also reads on. Two threads is the right minimum — a reader beside the main thread in the wait:
use std::io::Read as _;
use std::sync::Arc;
use ferroday_cage::{Cage, Terminal};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cage = Cage::builder()
.rootfs("/srv/rootfs/alpine")
.command("/bin/sh")
.build()?;
let (mut running, pty) = cage.spawn_terminal(&Terminal::new())?;
let pty = Arc::new(pty);
let reader = {
let pty = Arc::clone(&pty);
std::thread::spawn(move || {
// `Read` is implemented for `&Pty`, so the thread borrows through
// the `Arc` rather than taking the terminal away from the main
// thread — which is what lets the size still be set from here.
let mut out = std::io::stdout();
std::io::copy(&mut &*pty, &mut out)
})
};
// ... write to `&*pty`, resize it on SIGWINCH ...
let status = running.wait()?;
reader.join().expect("the reader thread completes")?;
println!("exited with {status:?}");
Ok(())
}
Read and Write are implemented for &Pty as well as for Pty, and resize
takes &self, which is what makes that shape possible at all. Wrapping the
Pty in a mutex instead is worse: the reader holds the lock while blocked in
read, so a resize lands only when the sandbox next produces output.
Resizing
Pty::resize sets the window size and delivers SIGWINCH to the sandbox’s
foreground process group, which is how a full-screen program learns to redraw.
An interactive caller calls it on every SIGWINCH of its own, with the size its
own terminal reports.
Pty::size reads the size back.
Closing input
There is no way to close the write side of a pseudoterminal. End-of-file is sent
as the VEOF character — ^D in the baseline below — and that is a line
discipline behavior rather than a property of the descriptor: it holds only
while the replica is in canonical mode. A program that has put its terminal in
raw mode, an editor or a shell reading a line itself, receives VEOF as the
literal byte 0x04 and does whatever it does with it, and even in canonical
mode VEOF on a partial line flushes that line rather than signalling
end-of-file, so a strict end-of-input takes it twice.
A caller that needs end-of-input to be unambiguous drops the Pty, which
hangs the terminal up and cannot be mistaken for data, or does not use a
terminal at all — Stdio::from_fd on a pipe closes for real.
Dropping the primary is also how a session is ended deliberately: the kernel
sends SIGHUP to the sandbox’s foreground process group. Dropping it early ends
the session early, which is why it normally outlives the wait.
The termios baseline
The line discipline is stated by the library rather than inherited from
anything, so it is a promise rather than whatever the kernel happened to
default to. It is field for field the kernel’s own tty_std_termios:
| Group | Flags |
|---|---|
| Input | ICRNL, IXON |
| Output | OPOST, ONLCR |
| Control | B38400, CS8, CREAD, HUPCL |
| Local | ISIG, ICANON, ECHO, ECHOE, ECHOK, ECHOCTL, ECHOKE, IEXTEN |
| Characters | VINTR ^C, VQUIT ^\, VERASE DEL, VKILL ^U, VEOF ^D, VSTART ^Q, VSTOP ^S, VSUSP ^Z, VREPRINT ^R, VDISCARD ^O, VWERASE ^W, VLNEXT ^V |
| Reads | VMIN 1, VTIME 0; VEOL and VEOL2 unset |
It governs line-mode behavior only. A full-screen application calls tcsetattr
on the replica itself and takes the terminal from there.
One consequence is visible immediately: OPOST | ONLCR means a \n the command
writes arrives at the primary as \r\n. A terminal rewrites what the sandbox
wrote, so no reproducibility-sensitive path should ever use one. None can:
a terminal arrives only from spawn_terminal, which captures nothing and takes
no observer, so no capturing pipeline — output, an export, provisioning — can
compose one.
What does not work
Path resolution for the terminal fails; descriptor operations work. The
pseudoterminal is allocated on the host rather than in the sandbox’s own
devpts instance, which is what keeps the feature small. The cost is that the
replica’s device node lives in the host’s devpts while the sandbox’s
/dev/pts is a private instance, so ttyname fails inside the sandbox.
Affected: tty(1), who, and GPG_TTY, which gpg populates from tty and
pinentry then opens.
Not affected: isatty, tcgetattr, tcsetattr, the line discipline, the
window size, job control, and /dev/tty — which is the character device 5:0,
a per-process redirect to the opener’s own controlling terminal, so what it
yields inside a terminal launch is the sandbox’s own terminal.
The private devpts stays mounted, so nested allocation — tmux, script,
sshd, Python’s pty — works normally and still sees no host pseudoterminal
but its own. The sandbox holds exactly one descriptor to one host
pseudoterminal: its own, whose only reader is the caller. TIOCSTI on it
injects into its own input queue.
A terminal launch reports no launch milestones. The observer is the progress
sink as well as the capture sink, and the terminal entry points take no
observer, so Progress::Launching, Supervised, and Executing go
unwitnessed. For an interactive session the loss is small: a human is watching
the terminal itself.
Refusals
A terminal is all three standard streams, so a sandbox that directs one of them somewhere else has asked for two different things. The launch refuses it:
use ferroday_cage::{Cage, ConfigError, Error, Stdio, Terminal};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cage = Cage::builder()
.rootfs("/srv/rootfs/alpine")
.command("/bin/sh")
.stdout(Stdio::Null)
.build()?;
let refused = cage.spawn_terminal(&Terminal::new());
assert!(matches!(
refused.err(),
Some(Error::Config(ConfigError::StreamAttachmentConflict { .. })),
));
Ok(())
}
Stdio::Inherit — the default on all three — states no destination, so it is
not a contradiction: the launch is free to supply one. Only Stdio::Null and
Stdio::from_fd name a destination of their own.
A profile cannot describe a terminal, and resolved_inputs() does not report
one. Both follow from the same boundary: the record reports the plan — what
every launch of the cage shares — and a launch-time attachment supersedes it at
the one call site that made it. A terminal describes how the caller attaches
rather than what the sandbox is.
With the network seam
Cage::spawn_pending_terminal is the composition an interactive session
usually wants — a shell with outbound networking:
use ferroday_cage::{Cage, Terminal};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cage = Cage::builder().rootfs("/r").command("/bin/sh").build()?;
let (pending, mut pty) = cage.spawn_pending_terminal(&Terminal::new())?;
// ... attach a stack to /proc/<pending.netns_pid()>/ns/net ...
let mut running = pending.proceed()?;
let _ = (&mut pty, &mut running);
Ok(())
}
The primary comes back beside the Pending rather than out of proceed,
because the pseudoterminal is allocated before the fork and the gate sits after
its controlling terminal is established. Nothing writes to the terminal while
the launch is held, so the caller attaches its stack and proceeds with the
primary already in hand.
The restriction fallback
Restriction::spawn_terminal gives the restriction
fallback the same interactive story: a host-allocated
pseudoterminal needs no devpts instance, so nothing about it depends on the
namespaces a restriction does not have.
At a shell prompt
fcage --terminal is the whole of this from the command line:
fcage --rootfs /srv/rootfs/alpine --terminal -- /bin/sh
It allocates the terminal, copies this terminal’s current size into it, and
relays the two. TERM passes through as it does for every fcage run, so
--setenv TERM ... overrides it. It composes with --restrict and with
--netstack; --terminal with --stdin null is refused at parse time, naming
both flags. From a script, where fcage’s own input is not a terminal, it
degrades to a plain byte relay around a sandbox that still has a real terminal
of its own, at 80x24.
The relay
ferroday_cage::relay is the caller’s side of an interactive session: one poll
loop over the sandbox’s terminal, the caller’s own terminal, and the signals a
session has to handle, with raw mode and its restoration on every exit the
process can observe.
use std::os::fd::AsFd as _;
use std::time::Duration;
use ferroday_cage::relay::{Escalation, Relay, Signals, install_panic_restore};
use ferroday_cage::{Cage, Terminal};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// First of all: blocking the relayed signals is process-wide, and a thread
// inherits the signal mask of the thread that created it. A failed install
// leaves the mask as it found it, so a program that reports this and carries
// on still answers a SIGINT.
let signals = Signals::install()?;
// Restores the terminal before a backtrace prints, which a guard cannot.
install_panic_restore();
let cage = Cage::builder()
.rootfs("/srv/rootfs/alpine")
.command("/bin/sh")
.env("TERM", "xterm-256color")
.build()?;
let (mut running, pty) = cage.spawn_terminal(&Terminal::new().size(30, 100))?;
let stdin = std::io::stdin();
let stdout = std::io::stdout();
let outcome = Relay::new(stdin.as_fd(), stdout.as_fd())
.raw(true)
.escalation(Escalation::new(
Some(Duration::from_secs(3600)),
Duration::from_secs(5),
))
.run(&mut running, &pty, &signals)?;
match outcome {
Some(status) => println!("the session ended with {status:?}"),
None => println!("the session ran out of time"),
}
Ok(())
}
What stays yours
The relay does nothing process-global as a side effect and reads nothing from the environment, so four decisions belong to the program driving the session:
-
Installing
Signals, early and on the main thread, and keeping the handle. Blocking the relayed set is process-wide, so it is an explicit call rather than somethingrundoes on the way past — and it must precede every thread the run may start, because a thread inherits the mask of the thread that created it. The library starts one thread of its own — the native network stack’s pump — so a program composing a relay with a stack installs the signals before it attaches one.Nothing restores the mask, deliberately: a relay that unblocked on the way out would leave a window in which the signals it exists to handle were fatal again, and a program that relays one session usually relays another. What follows from that is the handle’s lifetime. Dropping it closes the descriptor and unblocks nothing, so the eight signals stay blocked with nowhere to be read from and the process then ignores
SIGINT,SIGTERM,SIGHUPandSIGQUITentirely — a shell’s^Cand a supervisor’sSIGTERMboth do nothing, and onlySIGKILLis left. Forfcagethe handle lives to process exit; a longer-lived program holds it for the program’s life, which is also the one-per-process shape the type asks for.That permanence belongs to a successful install. A call that blocks the set and then cannot open the descriptor — descriptor exhaustion, a seccomp policy refusing
signalfd4— restores the exact mask it found before it reports the failure, so a program that logs it and carries on is not left unable to answerSIGINTby the call that told it the relay was unavailable. The prior mask is restored rather than the relayed set unblocked, so a signal the program had blocked for its own reasons stays blocked. -
Raw mode.
Relay::rawis off unless asked. Whether the caller’s own standard input is a terminal is the program’s reading to make;fcageaskstcgetattrand passes the answer. -
The panic hook.
install_panic_restore()is one call, or a program with a hook of its own callsrestore_terminal()from it. -
The sandbox’s terminal.
Terminalstates a size; a program that wants its own window’s size reads it and passes it, because the library reads nothing of the host. See Resizing.
What the loop does
One thread, one poll: the primary, the signal descriptor, and the caller’s
input — in that order, because the input is the one channel that retires and a
retired channel must leave the polled set rather than be given an empty events
mask. poll reports POLLHUP, POLLERR, and POLLNVAL whatever the requested
events are, so a muted slot on a hung-up pipe is ready on every pass and the
loop stops blocking; dropping the slot is what keeps producer | fcage --terminal ... from burning a core after the producer exits.
The primary is set non-blocking so a large paste cannot stall the loop; bytes
that do not fit yet wait in a pending buffer behind POLLOUT, and the input is
left unwatched while that buffer is occupied, so it cannot grow without bound.
End of input sends VEOF to the primary — read from the replica’s current
settings rather than assumed, since a program inside is free to have changed it
— and retires that channel. An invalid input (fcage --terminal ... 0<&-) takes
the same path, since it says the same thing. End-of-file on the primary ends the
loop. An Escalation is the poll deadline, consulted on every wakeup rather
than only on the one poll timed out on, so a sandbox printing without pause
cannot postpone its own expiry.
Signals are read as data rather than handled: no library code runs in signal
context, so there are no handlers, no async-signal-safety reasoning, and no
shared flags. SIGWINCH resizes the sandbox’s terminal;
SIGINT/SIGTERM/SIGHUP/SIGQUIT from outside terminate the sandbox and a
second one kills it, while a typed ^C is a byte the sandbox’s own line
discipline acts on; SIGTSTP restores and stops; SIGCONT re-enters raw mode
and re-reads the size. The module’s own documentation carries the whole table,
including why SIGTTOU is blocked and SIGTTIN deliberately is not.
The two directions are not symmetric: the caller-bound one is a plain blocking
write, outside the poll. Making it symmetric would need O_NONBLOCK on the
output descriptor, and that flag lives on the open file description rather than
on the descriptor — setting it would set it for the caller’s shell and for every
other process in the pipeline the program was started from. What is given up is
that a stopped downstream reader stalls the loop, including the signal channel;
ssh has the same shape for the same reason.
What restoration promises
Raw mode is entered last — everything fallible happens first, so a failure
before the loop leaves the caller’s terminal untouched — and undone by three
things that between them cover every exit the process can observe: a guard in
run’s own frame, dropped on normal exit, on error, and on a panic unwinding
through it; the optional panic hook, which restores before the default hook
prints, because with OPOST off a backtrace prints stair-stepped and a drop
runs after the hook; and the stop path, which restores before the process stops
and re-enters raw mode when it continues.
SIGKILL and SIGSTOP are uncatchable and out of scope, with the same honesty
the leaked-handle case gets: the remedy is stty sane, and no process can
promise otherwise. Restoration means termios. Screen contents — an alternate
screen left active, a hidden cursor — were written by the sandboxed program
through the relay verbatim and belong to it, exactly as ssh treats them.
restore_terminal() reaches the first terminal a raw session was entered on,
which is what a panic hook with no argument can name. Sequential sessions on the
same terminal restore correctly, and so does every session’s own exit, because
run restores from its own guard. A program relaying raw sessions on different
terminals over its lifetime is the case the hook does not cover.
The worked consumer
fcage --terminal is this, configured: it reads whether its standard input is a
terminal and what size it reports, installs the hook when it is one, and hands
the relay descriptors 0 and 1. Everything else it does with a session is a
library call. crates/ferroday-cage-cli/src/terminal.rs is the
whole of what is left.
Embedding in a runtime
The library is synchronous and spawns no threads: a launch blocks the thread that made it, and captured output is pumped inside that blocking call. A service that wants to launch sandboxes from a thread pool or an async runtime therefore drives the library from a blocking context and connects it to the rest of the program itself. This chapter is that connection.
One cage, many launches
A Cage is a frozen plan holding no live resources. It is Clone, it is
Send + Sync, and every launch method takes &self, so one cage behind an
Arc serves an entire pool:
use std::sync::Arc;
use ferroday_cage::Cage;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cage = Arc::new(
Cage::builder()
.rootfs("/srv/rootfs/alpine")
.command("/bin/sh")
.args(["-c", "echo from a worker"])
.build()?,
);
std::thread::scope(|scope| {
for _ in 0..8 {
let cage = Arc::clone(&cage);
scope.spawn(move || {
let result = cage.output().expect("the sandbox launches");
assert!(result.status.success());
});
}
});
Ok(())
}
Nothing is shared between those launches but the plan itself: each gets its own namespaces, its own supervisor, and its own channels.
Setup cost is small enough that per-command overhead is rarely the thing worth
optimizing. On an ordinary developer machine against a small Alpine root, 20
sequential launches of a trivial command took 22.3 ms — about 1.1 ms per
launch — and 8 concurrent launches from 8 threads against one shared
Arc<Cage> completed in 3.2 ms in total. Treat those as an order of magnitude
rather than a promise: they move with the mount profile, the rootfs, and the
host. The point is the order of magnitude.
Launching from an async runtime
A launch blocks, so it belongs on a blocking thread. In tokio that is
spawn_blocking:
let cage = Arc::clone(&cage);
let status = tokio::task::spawn_blocking(move || cage.run()).await??;
That much is unremarkable. The part that is not obvious is cancellation.
Cancelling from async
Running and Pending are deliberately neither Send nor Sync: they
borrow an observer that carries no thread bounds, and the library delivers
output on the thread that waits. So a handle cannot cross an await point, and
the handle is exactly what holds the kill.
KillHandle is the piece that does cross threads — it is Send + Sync — but
it is obtained from the Running that is now inside the blocking task. The
pattern that works is to ship the kill handle out of the task over a channel as
soon as the sandbox is up, and keep it on the async side:
use std::sync::Arc;
use std::time::Duration;
use ferroday_cage::{Cage, KillHandle};
async fn run_bounded(cage: Arc<Cage>) -> Result<(), Box<dyn std::error::Error>> {
let (tx, rx) = tokio::sync::oneshot::channel::<KillHandle>();
let task = tokio::task::spawn_blocking(move || {
let mut running = cage.spawn()?;
// Hand the killer to the runtime before blocking on the wait.
let _ = tx.send(running.kill_handle()?);
running.wait()
});
let killer = rx.await?;
tokio::time::sleep(Duration::from_millis(300)).await;
killer.kill()?; // cancel from the async context
let status = task.await??; // terminated by SIGKILL
assert_eq!(status.signal(), Some(9));
Ok(())
}
The ordering matters: the kill handle is sent before the wait begins, so the
async side holds it for the whole life of the sandbox. kill_handle can be
called again later from the blocking side if a second one is wanted; each owns
its own duplicated channel ends.
For a graceful stop, KillHandle::terminate sends SIGTERM instead, and the
escalation recipe in the process model applies unchanged —
either driven from the blocking side with wait_deadline, or from the async
side with a tokio::time::timeout around the handle’s terminate and kill.
Two alternatives are worth knowing:
- A deadline inside the task. If the bound is purely a timeout, there is no
need to involve the runtime at all:
wait_deadlineinside the blocking task is simpler and needs no channel. stop_with_caller. Tying the sandbox’s lifetime to the calling process covers the case where the service exits rather than the individual task; it is not a per-task cancellation mechanism.
Getting output back
An observer runs on the thread that waits, which is inside the blocking task, so an observer that forwards to the async side is the natural shape:
use ferroday_cage::Observer;
struct Forward(tokio::sync::mpsc::Sender<Vec<u8>>);
impl Observer for Forward {
fn stdout(&mut self, chunk: &[u8]) {
// The blocking task is not in the runtime, so the blocking send is
// the correct one here.
let _ = self.0.blocking_send(chunk.to_vec());
}
}
blocking_send rather than send: the task is on a blocking thread, not
inside the reactor. A bounded channel then gives back-pressure for free — when
the consumer falls behind, the observer blocks, the pump stops draining, and
the command blocks writing, which is the ordinary pipe discipline rather than
unbounded memory growth. For the whole-output case, output inside the
blocking task is simpler, with the caution about unbounded capture from
Streaming output.
What crosses a thread boundary
The rule the library follows is that a seam it stores carries thread bounds and a seam it merely borrows for one call does not:
| Type | Send | Sync | Why |
|---|---|---|---|
Cage, CageBuilder | yes | yes | Frozen configuration, no live resources |
KillHandle | yes | yes | Owns duplicated channel ends; exists to cross threads |
Error, ConfigError | yes | yes | A worker returns one to a coordinator |
Running, Pending | no | no | Borrow a &mut dyn Observer, called on the waiting thread |
Provision, ProvisionRequest | no | no | Borrow a &mut dyn ProvisionObserver, likewise |
A consumer that needs one of the borrowed handles on the far side of a thread
boundary moves the inputs across and constructs it there — which is what the
Arc<Cage> fan-out above does.
Recording what a build ran under
What a command produces depends on the filesystem it sees, the environment it carries, the identity it holds, whether it can reach a network, and which syscalls will succeed. A runtime that publishes artifacts usually needs to state those in the artifact’s provenance, and the library version alone does not: the mount profile and the base environment are both outside the compatibility promise and may change in a patch release.
resolved_inputs reports them as data. It is a projection of the frozen
launch plan, computed on demand, so calling it costs nothing until asked and
adds nothing to a launch:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cage = ferroday_cage::Cage::builder()
.rootfs("/srv/rootfs/alpine")
.command("/usr/bin/make")
// Prevention: the library contributes no mount and no variable of its
// own, in this release or any later one.
.managed_mounts(false)
.base_env(false)
.env("PATH", "/usr/bin:/bin")
.env("SOURCE_DATE_EPOCH", "1700000000")
.bind_ro("/srv/src", "/usr/src")
.build()?;
// Verification: what the sandbox will actually apply, ready to stamp beside
// the consumer's own version pin.
let provenance = toml::to_string(&cage.resolved_inputs())?;
let _ = provenance;
Ok(())
}
The two are complements. The opt-outs keep the inputs from drifting under a
library upgrade; the record states what they were, which is what catches the
case where a declaration and the sandbox disagree. Note what the record
contains that no other accessor can report: with the managed profile in force
it lists the six /dev device nodes and the five /dev symlinks
individually, where get_mount_dev() answers only whether /dev is
assembled.
What the record carries
root is what the command’s filesystem is, and it is a field of its own
rather than an entry in mounts: a mount is something laid over the root, and
the root is what it is laid over. A plain rootfs records its canonicalized path;
an overlay records its whole lower stack, its upper, and its work directory,
because that stack is what the root was made of. Recording only the mounts would
leave two builds — one on a rootfs, one on an overlay over the same base —
looking identical.
identity, network, rlimits, and hardening are the posture the launch
puts the command under, and each changes what a build produces:
identity— whether the build sees uid 0 or a mapped id. A Debian build’sRules-Requires-Roothandling turns on exactly this, and a file’s recorded ownership follows from it.network— the namespace the sandbox creates. A stack attached to a pending launch is not visible here, being established after the cage is built.rlimits— a build that adapts its parallelism toRLIMIT_NOFILE, or fails a link underRLIMIT_AS, produces different output.hardening— the subtlest, and the one most worth recording: a seccomp policy changes which syscalls succeed, and a configure test that probes a syscall reads the refusal as an absent feature. The Landlock grants are recorded in full, by path and by port; the seccomp filter is recorded as its instruction count, since the program itself is thousands of instructions and means nothing to a reader.
hardening is present whether or not the hardening feature is compiled in,
reporting unavailable when it is not. A record that simply left the key out
could not be told apart from one written before the key existed, and a
provenance record has to be readable without knowing which build wrote it.
unavailable and an applied posture with no controls are different facts: the
second is a build that could have hardened and did not.
The value’s shape is stable API; its contents follow the defaults and may change, which is precisely why recording it is worth doing.
Profiles
With the library’s serde feature, the sandbox specification — the builder
itself — serializes and deserializes. A profile is a sandbox described in a
file: a versionable, reviewable artifact that any consumer can load, in any
serde format. The fcage binary loads TOML profiles with --profile; a
program does the same in three lines:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let text = std::fs::read_to_string("build.toml")?;
let builder: ferroday_cage::CageBuilder = toml::from_str(&text)?;
let status = builder.build()?.run()?;
Ok(())
}
The library itself carries no TOML dependency; it implements the serde traits and leaves the format to the consumer.
The format
Keys are kebab-case and correspond one-to-one with the builder’s methods. Unknown keys are rejected — a typo in a profile is an error, not a silently ignored setting. Every key is optional; an omitted key keeps the library default.
rootfs = "/srv/rootfs/alpine"
command = "/usr/bin/make"
args = ["-j4", "all"]
network = "host" # "isolated" (default) | "host" | "none"
hostname = "builder"
workdir = "/build"
stdin = "null" # "inherit" (default) | "null"
stdout = "inherit" # "inherit" (default) | "null"; "null" discards
stderr = "null" # the same, per stream
pid-namespace = true # default true
mount-proc = true # default true; see below before turning it off
mount-dev = true # default true
mount-tmp = true # default true
resolv-conf = true # default true; binds the host resolv.conf with
# network = "host", and governs the one fcage
# composes for --netstack
managed-mounts = true # default true; false establishes none of the four
# above, nor any managed mount a later release adds
base-env = true # default true; false gives the command exactly the
# [env] table, with no PATH or HOME underneath
stop-with-caller = false # default false
path-lookup = false # default false; true resolves a command with no
# slash against the sandbox's PATH
identity-map = "subordinate" # "single" (default) | "subordinate" | ranges
[run-as] # a non-root identity for the command
uid = 250
gid = 250
groups = [250] # default empty
[env]
CARGO_HOME = "/cache/cargo"
RUST_LOG = "debug"
[[mount]]
kind = "bind"
source = "/home/user/project"
target = "/build"
[[mount]]
kind = "bind"
source = "/home/user/cache"
target = "/cache"
read-only = true # default false
[[mount]]
kind = "raw"
target = "/sys"
fstype = "sysfs"
flags = 14 # raw MS_* bits: MS_NOSUID | MS_NODEV | MS_NOEXEC
An [overlay] table roots the sandbox on an overlay instead of a plain
rootfs; the two are alternatives, and a profile naming both is refused at
build time rather than silently taking one:
command = "/usr/bin/make"
[overlay]
lower = ["/srv/base", "/srv/patches"] # ordered, base first; at least one
upper = "/srv/scratch/run-1" # required; created if absent
work = "/srv/scratch/run-1-work" # optional; defaults beside the upper
The first lower is the base the overlay mounts over and the rest stack on it,
base first — the reverse of the kernel’s own option order, so the array reads
the way the layers were built. upper is where the sandbox’s writes land, and
discarding it reverts them. Note that a RestrictedProfile refuses an overlay
root outright, for the reason below.
[env] is a table of environment variables, added on top of the
deterministic base (PATH and HOME) — or the whole environment, where
base-env = false.
managed-mounts = false and base-env = false are opt-outs rather than
toggles: each reduces the library’s contribution to nothing, including
anything it may contribute in a later release. Setting managed-mounts = false alongside an explicit mount-dev = true is a contradiction and is
refused at build time.
A profile that suppresses the managed /proc — with managed-mounts = false
or mount-proc = false — normally has to mount a procfs of its own, as a
[[mount]] entry. The nested user namespace the command enters establishes its
identity map through one, by path, so a container that reads one and has none is
refused at build time. See The nested user
namespace and Identity
maps, which sets out the one configuration that reads none.
[[mount]] entries apply in the order they appear, after the managed mount
profile. The kind selects which fields the entry carries:
kind = "bind"presents a host path inside the sandbox:source,target, and an optionalread-only.kind = "raw"is the escape hatch for mounts the typed specification does not model: the target is validated and confined to the rootfs like every mount target, and thesource,fstype,flags— the kernel’s rawMS_*bits — anddatastring go to the kernel verbatim.
Both kinds share one array so that their relative order is expressible. It
matters whenever a mount covers a path an earlier one established: declaring a
raw tmpfs on a directory and then the binds that populate it builds a
directory whose contents the sandbox owns outright, and the reverse order
leaves the binds hidden beneath the tmpfs.
identity-map and [run-as] select the sandbox’s identity
map and the identity the command runs as; explicit
ranges are spelled as an [identity-map.ranges] table, shown in that
chapter. A profile requesting subordinate on a build without the subid
feature is refused when it loads, exactly as a hardening posture is refused
by a build that cannot enforce it. The identity-map delegate itself is
code, not configuration, and has no profile key.
Inspecting a loaded profile
A consumer that loads configuration it did not write usually wants to look at
it before running it — to log what a group binds, to refuse one that shares the
host network, to show a user what a RestrictedProfile from an untrusted
source actually asks for. Every setting has a get_ accessor on the builder,
so that is a read rather than a guess:
fn main() -> Result<(), Box<dyn std::error::Error>> {
use ferroday_cage::{CageBuilder, Network};
let builder: CageBuilder = toml::from_str(&std::fs::read_to_string("build.toml")?)?;
if builder.get_network() == Network::Host {
eprintln!("warning: this profile shares the host network");
}
for bind in builder.get_binds() {
println!(
"{} -> {}{}",
bind.get_source().display(),
bind.get_target().display(),
if bind.is_read_only() { " (read-only)" } else { "" },
);
}
// Or `get_mounts` for every mount in the order it will be applied, which is
// what a reviewer needs when one mount covers another.
for mount in builder.get_mounts() {
println!("mounts at {}", mount.get_target().display());
}
Ok(())
}
The prefix is there because the setters own the bare names, the same
arrangement std::process::Command uses for get_program and get_args.
For a whole-configuration view rather than a field at a time, the builder serializes as well as deserializes, so it round-trips back to the format it came from:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let builder = ferroday_cage::Cage::builder();
// Render the requested configuration — profile plus the consumer's own
// calls — for a log or a confirmation prompt.
println!("{}", toml::to_string(&builder)?);
Ok(())
}
One thing never appears there, because it is code rather than configuration:
an id_mapper delegate, which is set on the builder in a consumer’s own code,
and which a profile naming the key is rejected for rather than silently
ignored. Debug is derived on the builder too, which prints the delegate as
well.
Intent and effect
A profile records what its author asked for, not everything that will happen.
A key left out stays out through a render-and-reload cycle, so mount-dev
absent means “not stated” rather than “stated as true” — which is what lets
managed-mounts = false coexist with an unstated mount-dev instead of
contradicting it.
For what a configuration actually resolves to, build it and ask the cage:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let builder = ferroday_cage::Cage::builder().rootfs("/srv/rootfs").command("/bin/sh");
let cage = builder.build()?;
// Every mount the sandbox will establish, managed profile included, and the
// command's whole environment with the library's base composed in.
println!("{}", toml::to_string(&cage.resolved_inputs())?);
Ok(())
}
The two answer different questions, and a consumer recording provenance wants the second — see Recording what a build ran under.
Resource limits in a profile
An [rlimit] table sets the kernel’s per-process limits on the command, keyed
by resource name. One value sets the soft and hard limits alike; a
{ soft, hard } table sets them apart. Either position takes an amount in the
resource’s own unit or "unlimited".
[rlimit]
processes = 64 # 64 processes, soft and hard
address-space = 536870912 # 512 MiB of mapped address space
open-files = "unlimited"
cpu-time = { soft = 10, hard = "unlimited" } # SIGXCPU at ten seconds
The resource names are the ones the hardening
chapter lists, and the same ones fcage --rlimit takes. A soft limit above its hard limit is rejected when the builder
is built rather than when the profile is parsed, alongside every other
configuration error.
A limit is safe in an untrusted profile and RestrictedProfile permits it: it
is set on the command process, and an unprivileged setrlimit can only lower a
hard limit or move a soft one within the hard limit the caller already had.
Hardening in a profile
With the hardening feature, a profile carries the sandbox’s Landlock,
seccomp, and capability posture in a [hardening] table, so the security
configuration is versioned and reviewed alongside the rest of the sandbox.
[hardening]
drop-caps = true # drop every capability...
# keep-caps = ["net-bind-service"] # ...or keep only these, dropping the rest
seccomp = "curated" # the curated deny-list of dangerous syscalls
[[hardening.landlock-fs]]
access = "rx" # the r, w, and x letters, like a file mode
path = "/usr"
[[hardening.landlock-fs]]
access = "rwx"
path = "/work"
[[hardening.landlock-net]]
access = "bc" # the b (bind) and c (connect) letters
port = 8080
drop-caps and keep-caps set the capability posture and are mutually
exclusive; capabilities are named in kebab-case (net-bind-service). An empty
keep-caps is rejected — drop-caps = true is how a profile drops every
capability. An [[hardening.landlock-fs]] entry grants an access set — some
combination of the r, w, and x letters — beneath a path; an
[[hardening.landlock-net]] entry grants an access set — some combination of
b (bind) and c (connect) — on a TCP port. Enrolling any grant denies every
path, bind, and connect not granted; paths are sandbox paths, resolved after the
pivot, and network rights require Landlock ABI 4.
seccomp is either the string "curated" or a table naming syscalls to
filter — a deny-list, or an allow-list that must name every syscall the command
needs:
[hardening.seccomp]
deny = ["mount", "ptrace", "bpf"] # these fail with EPERM, the rest allowed
# ...or an allow-list:
[hardening.seccomp]
allow = ["read", "write", "execve"] # only these allowed, the rest fail EPERM
A syscall may be narrowed by its arguments through an allow-rule or
deny-rule array-of-tables, so the listed action applies only when the
arguments match. Each entry names a syscall and a list of arg conditions,
every one of which must hold; each condition sets an argument index (0 through
5), a width len (dword for the low 32 bits, qword for all 64), an op,
and a value. The masked-eq operator also takes a mask and tests
(arg & mask) == (value & mask):
[hardening.seccomp]
allow = ["read", "write", "exit_group"]
[[hardening.seccomp.allow-rule]]
syscall = "ioctl"
[[hardening.seccomp.allow-rule.arg]]
index = 1
len = "dword"
op = "eq"
value = 21523 # TIOCGWINSZ; ioctl allowed only for this request
The width has no safe default — comparing the wrong one lets a value hide in the
bits that are not checked — so a condition must state it. An allow and a deny
side are mutually exclusive within one table.
Syscalls are named as the kernel names them (exit_group), resolved for the
host architecture. The precompiled-program escape hatch
(SeccompPolicy::Program) has no place in a declarative profile and cannot be
written to one.
A profile that configures hardening requires a library built with the
hardening feature; a build without it refuses such a profile rather than
loading it with the restrictions silently dropped.
Untrusted profiles
Deserializing a profile straight into a CageBuilder trusts it as
code-equivalent configuration: it can bind any host path, issue raw mounts,
share the host network, and share the host PID namespace. A profile from a
source the consumer does not control — a repository, a download, a
multi-tenant store — is loaded through RestrictedProfile instead, in the same
serde format:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let text = std::fs::read_to_string("untrusted.toml")?;
let restricted: ferroday_cage::RestrictedProfile = toml::from_str(&text)?;
let builder = restricted.into_builder(); // extend with the consumer's own trusted calls
let _ = builder;
Ok(())
}
The restricted policy refuses the operations that map host resources into the
sandbox, write to the host outside it, or share a host namespace: a bind mount,
a raw mount, host networking, an overlay root, and sharing the host PID
namespace. An overlay root is refused because, unlike the rootfs, its upper
layer is created rather than required to exist: the profile would choose a host
path for the library to create directories at. A trusted consumer configures one
with overlay after loading.
The policy does not bound the host authority the command wields, which remains
the calling user’s: the rootfs the profile names is presented read-write, so it
still grants access to that host subtree. A consumer that does not trust the
profile to choose its own rootfs should override it —
into_builder().rootfs(path) with a directory the consumer controls — after
loading.
Profiles at the command line
fcage --profile FILE loads a profile; flags given alongside compose with
it:
- Scalar flags override the profile’s value. Every toggle has both
directions for this purpose —
--share-net/--isolate-net,--share-pid/--pid-ns,--proc/--no-proc, and so on. --bind,--ro-bind, and--setenvextend the profile’s lists.- A command on the command line replaces the profile’s
commandandargsas a unit. stop-with-calleris the one keyfcagedefaults differently from the library:fcagewaits for the command and exits with it, so a sandbox that outlived the process is never what a shell or a supervisor meant, and the tie is on unless something says otherwise. A profile stating the key is that something, sostop-with-caller = falseholds — the flag still overrides either way.- Hardening flags compose with a profile’s
[hardening]table:--landlock-ro,--landlock-rw,--landlock-bind, and--landlock-connectadd grants to it, while the seccomp flags,--drop-caps, and--keep-capsreplace its seccomp policy or capability posture. The seccomp flags mirror the profile’s seccomp table:--seccomp-allowand--seccomp-denytake comma-separated syscall names, and--seccomp-allow-ruleand--seccomp-deny-ruletake a syscall name witharg/len/op/value/maskconditions, so a rule reads the same on a flag as in the table.
# The profile's sandbox, its command replaced for one run:
fcage --profile build.toml -- /bin/sh -c 'ls /build'
# The profile with the network cut off:
fcage --profile build.toml --isolate-net
--profile trusts the file like a script: it can bind any host path, issue
raw mounts, share the host network, and share the host PID namespace. Load only
a profile you trust. --restricted-profile FILE loads under the restricted
policy instead, refusing those operations for a profile from an untrusted
source; the rootfs it names still grants access to that host subtree, so pass a
rootfs you control.
A profile is plain data: loading one performs no filesystem access and
grants nothing by itself. Validation happens at build, launch behavior is
identical to the equivalent builder calls, and the profile’s paths are
interpreted exactly as if they had been passed to the builder.
The same composition is available to any consumer, because a profile is a
CageBuilder: scalar setters replace, bind and env extend, and
clear_args before command replaces a profile’s command as a unit.
Building an orchestrator works this into a
group-based launcher.
Building an orchestrator
A profile is plain data, and composing a profile with per-invocation overrides is ordinary builder work. Put the two together and a small tool falls out: a profile-driven orchestrator that keeps a directory of named sandboxes and runs commands inside them. The library ships one as a worked example.
Where fcage takes a single profile file with --profile, an orchestrator
keeps a directory of profiles and selects one by name. The profiles are
groups: a group describes a sandbox posture — its root filesystem, network,
environment, and mounts — rather than a single application, so many commands
share one group.
orchestrator run offline -- cargo test
orchestrator run offline -- cargo build
orchestrator run net-dev -- curl https://example.com
Composition
Because a profile is a CageBuilder, the orchestrator composes by
deserializing the group profile and then layering builder calls onto it:
- List overrides extend.
bind,bind_ro, andenvadd to whatever the profile already carries. - Scalar overrides replace. The network posture, working directory, or root filesystem given at the prompt replaces the profile’s value.
- The command replaces as a unit. A group may carry a default command; a
command given at the prompt replaces it —
clear_argsfirst, so the new command does not inherit the group’s arguments, thencommandandargs.
fn main() -> Result<(), Box<dyn std::error::Error>> {
let text = std::fs::read_to_string("offline.toml")?;
let mut builder: ferroday_cage::CageBuilder = toml::from_str(&text)?;
// A command from the prompt replaces the group's default as a unit.
builder = builder.clear_args().command("/bin/echo").args(["hello"]);
// An override bind extends the group's mounts.
builder = builder.bind_ro("/etc/hostname", "/host-hostname");
let status = builder.build()?.run()?;
assert!(status.success());
Ok(())
}
clear_args is what makes a unit override possible from a builder alone:
command sets the program but leaves the arguments in place, and arg and
args append, so a consumer holding a deserialized profile clears the profile’s
arguments before setting the new command. The composition rules are the same
ones Profiles documents for fcage.
Running the example
The example lives at crates/ferroday-cage/examples/orchestrator.rs, with a few
sample group profiles beside it. It builds with the serde feature:
cargo run --example orchestrator --features serde -- --help
The profile directory comes from --profiles DIR or the
FERRODAY_CAGE_PROFILES environment variable. list names the groups it finds;
run launches a command in one:
PROFILES=crates/ferroday-cage/examples/orchestrator-profiles
# The groups the directory offers:
cargo run --example orchestrator --features serde -- --profiles "$PROFILES" list
# A command in the "offline" group, against a root filesystem of your own:
cargo run --example orchestrator --features serde -- \
--profiles "$PROFILES" run offline --rootfs /srv/rootfs/alpine -- /bin/echo hello
The sample profiles name a root filesystem at /srv/rootfs/alpine; --rootfs
overrides it, or edit the profile. The command’s exit code becomes the
orchestrator’s, so it composes into scripts like any other launcher.
A group profile
A group profile is a sandbox specification — the format Profiles
documents in full. The sample offline group denies the network outright and
starts in a tmpfs working directory:
network = "none"
hostname = "offline"
workdir = "/tmp"
[env]
LANG = "C.UTF-8"
Grouping by posture rather than by application is what lets a handful of
profiles serve a whole workflow: an offline group for hermetic work, a
net-dev group for commands that fetch, a builder group with a scratch mount
and a default command. Adding a group is dropping a .toml file in the
directory.
Hardening
The default sandbox is a rootless convenience for code you trust: a controlled filesystem view, a clean environment, and isolated namespaces. Confining untrusted code is this layer’s work. It adds three opt-in restrictions to the command — Landlock filesystem rules, seccomp syscall filters, and capability drops — plus a fully denied network. The default profile alone is not a boundary against hostile code.
The layer is behind the hardening feature, compiled in only when a consumer
enables it. Every restriction is configured on the builder, applies
only to the command and the processes it starts, and is enforced after the root
pivot and immediately before the command executes.
More precisely, it is enforced after the command enters its nested user namespace, and that ordering is a requirement rather than a detail of the implementation. Entering a user namespace has the kernel re-derive the whole credential: the securebits return to their defaults, the capability bounding set is refilled, and the inheritable and ambient sets are cleared. Anything on this page applied before the entry would be undone without a word. What the entry itself establishes — the locks on the sandbox’s mount flags — needs none of this layer, and holds in every build.
Landlock filesystem rules
landlock_fs grants a set of accesses beneath a path. Configuring any grant
enrolls the sandbox in Landlock: from then on the command reaches a path only
where a grant allows it, and every other path is denied.
use ferroday_cage::{Cage, FsAccess};
fn main() -> ferroday_cage::Result<()> {
let status = Cage::builder()
.rootfs("/srv/rootfs/alpine")
.command("/usr/bin/build")
.landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/usr")
.landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/lib")
.landlock_fs(FsAccess::READ | FsAccess::WRITE, "/work")
.build()?
.run()?;
let _ = status;
Ok(())
}
FsAccess composes three primitives with |: READ (read files and list
directories), WRITE (write files and create, remove, and rename entries), and
EXECUTE (execute files). Paths are sandbox paths, resolved after the pivot, so
a grant describes the mounted view — the rootfs, the bind mounts, and the
/proc, /dev, and /tmp mounts alike. A read-write grant on the tmpfs /tmp
governs exactly the files the sandbox mounts there.
The rules are enforced on any kernel that offers Landlock, best-effort down to
the kernel’s supported ABI: a rule referencing an access right a kernel does not
define is narrowed to what that kernel enforces, never widened. A kernel with no
Landlock support at all is a setup error, not a silent success — a requested
restriction never fails to apply unnoticed. host::landlock_abi reports the
running kernel’s supported ABI version, or None when Landlock is absent.
Landlock network rules
landlock_net grants a set of accesses on a TCP port. The kernel keys network
rights by port rather than by path, so these are the network counterpart of the
filesystem rules: configuring any grant — filesystem or network — enrolls the
sandbox in Landlock, and a network grant then governs both binding and
connecting.
use ferroday_cage::{Cage, FsAccess, NetAccess};
fn main() -> ferroday_cage::Result<()> {
let status = Cage::builder()
.rootfs("/srv/rootfs/alpine")
.command("/usr/bin/proxy")
.landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/usr")
.landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/lib")
.landlock_net(NetAccess::BIND, 8080)
.landlock_net(NetAccess::CONNECT, 443)
.build()?
.run()?;
let _ = status;
Ok(())
}
NetAccess composes two primitives with |: BIND (bind a TCP socket to the
port) and CONNECT (connect a TCP socket to the port). The example lets the
command listen on 8080 and reach HTTPS services on 443, and denies every other
bind and connect — enrolling a network grant denies all network access not
granted, exactly as a filesystem grant denies all paths not granted. A BIND
grant on port 0 permits binding to a kernel-assigned ephemeral port.
Network rights arrive in Landlock ABI 4. On an older kernel that still offers
Landlock they are narrowed away best-effort, the same downgrade an unsupported
filesystem right receives; a network-only request on such a kernel becomes a
no-op rather than an error, and no ruleset is built at all. Check
host::landlock_abi for a version of 4 or more when a command must not run
unless its network restriction can be enforced.
The one place that downgrade is refused rather than taken is where the grant is the sandbox’s only network boundary: a host-network cage, and the restriction fallback. Both fail the build on a kernel that cannot enforce the grant. An isolated cage keeps its network namespace either way, which is why the same kernel is tolerated there.
Seccomp syscall filters
seccomp applies a syscall filter to the command. Three policies are
available through SeccompPolicy:
Curated— a built-in deny-list of dangerous and rarely-legitimate syscalls (mounting, kernel modules, the keyring, tracing, time-setting, and similar), answering each withEPERMand allowing the rest. Two entries name an argument rather than a whole syscall:ioctlis denied for theTIOCSTIandTIOCLINUXrequests, which write to a terminal’s input queue, and allowed for everything else. A convenience posture, not a boundary claim; its roster grows as new syscalls warrant denial.Rules— a caller-authoredSeccompRulesallow-list or deny-list. An allow-list names the syscalls the command may make and denies the rest, so it must include every syscall the command needs. A deny-list names the syscalls to refuse and allows the rest. Syscalls are named by their kernel number for the host architecture.Program— a pre-compiled BPF program (SockFilterinstructions), installed verbatim, for a policy produced by an external compiler.
use ferroday_cage::{Cage, SeccompPolicy};
fn main() -> ferroday_cage::Result<()> {
let status = Cage::builder()
.rootfs("/srv/rootfs/alpine")
.command("/usr/bin/convert")
.seccomp(SeccompPolicy::Curated)
.build()?
.run()?;
let _ = status;
Ok(())
}
Argument conditions
A listed syscall may carry conditions on its arguments through
SeccompRules::rule, so the listed action applies only to invocations whose
arguments match — an allow-list that permits ioctl only for a specific
request, a deny-list that refuses socket only for AF_INET. Each condition is
a SeccompArg: an argument index (0 through 5), a width, a comparison, and a
value. Repeating a syscall accepts any of several condition sets; a syscall
listed both plainly and with conditions matches unconditionally.
use ferroday_cage::{Cage, SeccompArg, SeccompPolicy, SeccompRules};
const TIOCGWINSZ: u64 = 0x5413;
fn main() -> ferroday_cage::Result<()> {
// Allow read, write, and ioctl, but ioctl only for the TIOCGWINSZ request.
let rules = SeccompRules::allowing([libc::SYS_read, libc::SYS_write])
.rule(libc::SYS_ioctl, [SeccompArg::eq_dword(1, TIOCGWINSZ)]);
let status = Cage::builder()
.rootfs("/srv/rootfs/alpine")
.command("/usr/bin/convert")
.seccomp(SeccompPolicy::Rules(rules))
.build()?
.run()?;
let _ = status;
Ok(())
}
The width is a deliberate choice. The kernel presents every argument as a
64-bit value, and the _dword constructors compare only its low 32 bits while
the _qword constructors compare all 64. Match the width to the argument’s
kernel type: dword for an int or a 32-bit flag set, qword for a pointer or
a 64-bit value. Comparing the wrong width lets a value hide in the bits that are
not checked. The masked_eq_* constructors test individual flag bits — for
example, that CLONE_NEWUSER is clear in a clone flags argument.
The filter binds the command and its descendants. Seccomp filtering is available
on the x86_64, aarch64, and riscv64 architectures; a policy on any other
is a build error. A rootfs run for a foreign architecture through qemu-user
executes host-architecture syscalls, so a native filter does not describe the
guest’s behavior — do not combine the two. host::seccomp_available reports
whether the kernel offers seccomp at all.
Capability drops
In a user namespace the command is mapped to root and holds every capability
within that namespace. None of them confers authority over the host, but they
do grant power inside the sandbox. drop_all_capabilities sheds them all;
keep_capabilities retains only a named few:
use ferroday_cage::{Cage, Capability};
fn main() -> ferroday_cage::Result<()> {
let status = Cage::builder()
.rootfs("/srv/rootfs/alpine")
.command("/usr/sbin/service")
.keep_capabilities([Capability::NetBindService])
.build()?
.run()?;
let _ = status;
Ok(())
}
The drop narrows the bounding, ambient, permitted, effective, and inheritable
sets, so a dropped capability cannot be regained across the command’s execve.
A capability posture composes with a non-root run-as
identity. Without a keep
request the identity switch itself clears the command’s capabilities, which
is usually the point; with one, the launch locks the securebits that carry
the kept set across the switch, so the command runs as its non-root identity
holding exactly the kept capabilities. Keeping CAP_SETUID, CAP_SETGID,
or CAP_SETPCAP alongside a non-root identity is rejected at build time —
it would let the command return to the mapped uid 0.
Denied network
Beyond the default isolated network — a namespace whose loopback is brought up,
so the sandbox can talk to itself — Network::None leaves loopback down. The
command has no connectivity at all, not even to 127.0.0.1.
use ferroday_cage::{Cage, Network};
fn main() -> ferroday_cage::Result<()> {
let status = Cage::builder()
.rootfs("/srv/rootfs/alpine")
.command("/usr/bin/analyze")
.network(Network::None)
.build()?
.run()?;
let _ = status;
Ok(())
}
Resource limits
The restrictions above bound what the command can reach. They say nothing about what it can consume, and the two are independent: a command confined to a private root, denied the network, filtered by seccomp, and stripped of every capability can still fork without end, allocate without end, and fill the filesystem it was given.
[rlimit] sets the kernel’s own per-process limits on the command, and every
process it starts inherits them:
use ferroday_cage::{Cage, Limit, Resource, SeccompPolicy};
fn main() -> ferroday_cage::Result<()> {
let status = Cage::builder()
.rootfs("/srv/rootfs/alpine")
.command("/usr/bin/analyze")
.seccomp(SeccompPolicy::Curated)
.drop_all_capabilities()
// 64 processes, 512 MiB of address space, 64 MiB of file, 10 CPU seconds.
.rlimit(Resource::Processes, 64, 64)
.rlimit(Resource::AddressSpace, 512 << 20, 512 << 20)
.rlimit(Resource::FileSize, 64 << 20, 64 << 20)
.rlimit(Resource::CpuTime, 10, Limit::UNLIMITED)
.build()?
.run()?;
let _ = status;
Ok(())
}
Each call names a [Resource] and its soft and hard values in that resource’s
own unit; Limit::UNLIMITED is the kernel’s RLIM_INFINITY, and a soft limit
above its hard limit is rejected at build time. Repeating a resource replaces
the earlier setting. The limits are applied to the command process before the
hardening layer, so a seccomp filter cannot deny the call that sets them.
The resources, with the kernel limit each sets and the unit it counts in. The
name in the first column is what a profile’s [rlimit] table and the
--rlimit flag both spell:
| Name | Kernel limit | Unit |
|---|---|---|
address-space | RLIMIT_AS | bytes of mapped address space |
core-dump | RLIMIT_CORE | bytes |
cpu-time | RLIMIT_CPU | seconds of CPU time |
data | RLIMIT_DATA | bytes of heap and anonymous mappings |
file-size | RLIMIT_FSIZE | bytes, the largest file created |
locked-memory | RLIMIT_MEMLOCK | bytes lockable into RAM |
open-files | RLIMIT_NOFILE | one past the highest descriptor |
pending-signals | RLIMIT_SIGPENDING | queued signals |
processes | RLIMIT_NPROC | processes and threads |
stack | RLIMIT_STACK | bytes of main-thread stack |
Two carry kernel semantics worth knowing before relying on them:
ProcessesisRLIMIT_NPROC, which the kernel counts per real user id across the whole system, not per namespace. Under the default single-identity map the command’s real uid outside the sandbox is the calling user’s, so the cap is a ceiling on the caller and the sandbox together rather than on the sandbox alone. A range identity map gives the sandbox distinct host ids, and the count becomes its own.AddressSpacecounts mapped address space, not resident memory. A runtime that maps far more than it touches — many do — hits the limit well before its real memory use approaches the value.
On the command line, --rlimit RESOURCE=SOFT[:HARD] sets one, repeatably:
--rlimit processes=64 --rlimit cpu-time=10:unlimited. fcage --help lists
the accepted names, and so does the message rejecting one it does not know.
A profile carries the same limits in an [rlimit] table, keyed
by the resource names above. One value sets the soft and hard limits alike; a
{ soft, hard } table sets them apart. Either position takes an amount or
"unlimited".
[rlimit]
processes = 64
address-space = 536870912
file-size = 67108864
cpu-time = { soft = 10, hard = "unlimited" }
What this boundary does not cover
Two things a reader configuring the strongest posture on this page might reasonably assume are covered, and are not:
- Resource governance beyond the per-process axis. The limits above are
setrlimitvalues, which is all an unprivileged process can set on itself. CPU shares, memory ceilings that count resident rather than mapped pages, I/O throttling, and PID caps that are genuinely per-sandbox all live in cgroup controllers, and disk consumption lives in filesystem quotas. Both are the host’s to configure, and neither is something this library sets up. A consumer that needs them places the calling process in a cgroup of its own before launching, and the sandbox inherits it. - The rootfs itself. The command is root inside the sandbox and the rootfs
is mounted read-write, so it can modify anything in the provisioned tree that
the calling user could modify on the host. A Landlock grant narrows this; so
does
overlay_rootfs, which leaves the base untouched and sends every write to a disposable upper. The root is mountednosuid, so what the command writes into the tree cannot be executed back with elevated ids — but the tree’s contents are still the command’s to change.
Neither gap is silent: what the layer does enforce, it enforces completely, and what it does not, it does not claim.
In a profile
With the serde feature, every restriction on this page is expressible in a
profile: a [hardening] table carries the capability posture,
the seccomp policy, and the Landlock filesystem and network grants, so a
sandbox’s security configuration is a reviewable, versionable artifact rather
than a sequence of builder calls.
[hardening]
keep-caps = ["net-bind-service"]
seccomp = "curated"
[[hardening.landlock-fs]]
access = "rx"
path = "/usr"
[[hardening.landlock-net]]
access = "c"
port = 443
The profiles chapter documents the table
in full. A library built without the hardening feature refuses a profile that
configures it, rather than dropping the restrictions silently.
Order and interaction
The restrictions are applied to the command in a fixed order, wrapped around
the identity switch: the securebits are locked (when a non-root identity keeps
capabilities), no-new-privileges is set, and Landlock is enforced; then the
identity switch is performed; then capabilities are dropped and the seccomp
filter is installed last, so the active filter governs only the command’s
execve and what follows. No-new-privileges is set a second time by the switch
itself when a run-as identity is configured, so that a build without this
feature — and a sandbox that asks for a non-root identity and no hardening at
all — still gets it. The ordering is deliberate. Landlock and its
grant-path opens run while the process still holds its in-namespace root
credentials, before the switch and the capability drop take them away, so the
grants are established successfully; and the seccomp filter, installed after the
switch, never governs the switch’s own syscalls. Each restriction is inherited
across execve, and none touches the sandbox init or the supervisor.
On the command line, --landlock-ro and --landlock-rw add filesystem grants,
--landlock-bind and --landlock-connect add network grants by port,
--seccomp curated applies the curated profile, --seccomp-allow and
--seccomp-deny filter syscalls by name, --seccomp-allow-rule and
--seccomp-deny-rule add argument conditions in the profile’s arg/len/op/
value terms (for example --seccomp-allow-rule 'ioctl arg=1 len=dword op=eq value=0x5413'), --drop-caps and --keep-caps control capabilities, and
--deny-net selects the denied network.
The Landlock and seccomp primitives also stand on their own: on hosts without unprivileged user namespaces, the restriction fallback applies them to a plain spawned command, with no sandbox around it.
The restriction fallback
Some hosts cannot run the sandbox at all: unprivileged user namespaces are disabled, and with them every namespace facility the cage is built from. For those hosts the library offers a degraded mode, the restriction: it spawns the command directly on the host and confines it with the same Landlock filesystem rules, Landlock network rules, and seccomp filters the hardening layer provides, always under the no-new-privileges flag.
A restriction is a weaker boundary than a cage, and the difference is worth stating plainly. The command runs as the calling user, on the host filesystem (as far as the Landlock grants allow), with the host network and the host PID space visible. What a restriction delivers is filesystem-access and syscall confinement plus the library’s process model — the clean deterministic environment, output streaming, and the running handle — on hosts where nothing stronger is possible.
The restriction is part of the hardening feature and requires only
Landlock or seccomp from the kernel, not user namespaces.
Configuring a restriction
Restriction mirrors the cage’s builder shape. Because there is no
private rootfs, every path is a host path — including the grants that make
the command runnable at all: with any Landlock grant configured, everything
ungranted is denied, so the command’s own binary, its interpreter, and its
libraries need read and execute grants alongside the data paths it works on.
use ferroday_cage::{FsAccess, Restriction};
fn main() -> ferroday_cage::Result<()> {
let status = Restriction::builder()
.command("/usr/bin/sort")
.args(["/work/input"])
.landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/usr")
.landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/lib")
.landlock_fs(FsAccess::READ, "/etc")
.landlock_fs(FsAccess::READ | FsAccess::WRITE, "/work")
.build()?
.run()?;
let _ = status;
Ok(())
}
landlock_fs, landlock_net, and seccomp behave exactly as they do on
the cage builder, and at least one control must be configured: a restriction
that restricts nothing is rejected at build time rather than launching a plain
process that appears sandboxed.
landlock_net grants TCP bind and connect on a named port. Configuring any
grant — filesystem or network — enrolls the command in Landlock, so a
restriction whose only grant is a network one denies every TCP bind and connect
it did not name, and the filesystem stays open. It governs TCP alone: UDP, raw
sockets, and other socket families, AF_UNIX among them, are untouched, which
is why the abstract-socket surfaces below survive it.
Network rights need Landlock ABI 4, where filesystem rights need only ABI 1. On
an older Landlock kernel a filesystem grant narrows away best-effort, but a
restriction whose only grant is a network one would then enforce nothing at
all, so it is refused at build with
ConfigError::RestrictionLandlockUnenforceable.
The command starts in / (or the current_dir) with a deterministic base
environment plus the caller’s variables. The base is PATH alone — unlike
the cage’s, it carries no HOME, because the command runs as the real
calling user, for whom /root would be wrong; set HOME explicitly if the
command needs one. Standard streams, the stdin disposition, output
streaming through an observer, a pseudoterminal of its own (see A terminal of
the sandbox’s own), and the Running handle — wait, deadline,
terminate, kill, stop_with_caller — all work as they do for a cage.
The command starts with no capabilities. A restriction always drops to the empty capability set, and the always-set no-new-privileges flag prevents a set-user-ID or file-capabilities binary from acquiring any across the exec. This matters when the caller is privileged — a root process on a host with unprivileged user namespaces disabled is a plausible way to reach the restriction fallback — which would otherwise pass its full capability set to the command; when the caller is already unprivileged the drop is a no-op.
Because a restriction runs a host process with no namespace or root-swap
isolation, some host-reaching surfaces remain, governed only by the Landlock
and seccomp the caller configured: abstract-socket services such as D-Bus and
the X server (a Landlock network grant governs TCP alone), same-user /proc
under a seccomp-only restriction, and same-user ptrace under a Landlock-only
restriction (the curated seccomp policy blocks it). A cage closes these three
through its namespaces and swapped root.
The caller’s terminal is not among them, because it is not a namespace question:
a controlling terminal is reached through the session, so a cage and a
restriction answer it identically, by the rule described in
The dispositions and the caller’s
terminal. A command that
inherits the caller’s standard input shares the caller’s terminal deliberately;
one that does not runs in a session of its own and cannot reach it at all.
Independently of the session, the curated seccomp policy denies ioctl for the
TIOCSTI and TIOCLINUX requests, the two that write to a terminal’s input
queue.
Falling back from a cage
A cage launch on a host without user namespaces fails with
Error::UsernsUnavailable, naming the blocking configuration when the
probe identifies it. A program that wants to degrade catches that error and
runs its restriction:
use ferroday_cage::{Cage, Error, FsAccess, Restriction};
fn main() -> ferroday_cage::Result<()> {
let cage = Cage::builder().rootfs("/srv/rootfs").command("/usr/bin/job").build()?;
let status = match cage.run() {
Err(Error::UsernsUnavailable { .. }) => {
// The degraded path: host paths, weaker guarantees.
Restriction::builder()
.command("/usr/bin/job")
.landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/usr")
.landlock_fs(FsAccess::READ | FsAccess::WRITE, "/var/lib/job")
.seccomp(ferroday_cage::SeccompPolicy::Curated)
.build()?
.run()?
}
outcome => outcome?,
};
let _ = status;
Ok(())
}
The fallback is a second, deliberate configuration, not a translation of the cage’s. A cage describes a private mounted view; a restriction describes the host. The paths differ, the trust model differs, and the decision to accept the weaker boundary belongs to the caller, which is why the library never degrades automatically.
Process model
A restriction launch takes the same shape as a cage without a PID namespace: a supervisor process watches the command from outside, and the same caveats apply. Descendants of the command can outlive it, a kill reaches only the command process itself, and when output is captured, a surviving descendant that holds the streams keeps an unbounded wait blocked — prefer a deadline wait for commands that may leave children behind. The Landlock ruleset, the seccomp filter, and no-new-privileges are all inherited by every process the command starts, so the confinement itself does extend to descendants.
Host support
Landlock enforcement follows the running kernel’s ABI, best-effort downward,
exactly as in the hardening layer; a kernel with no Landlock LSM fails a
Landlock-bearing restriction as a setup error. A seccomp-only restriction
runs there. host::landlock_abi and host::seccomp_available report
what the kernel offers.
On the command line
--restrict selects the restriction instead of the sandbox. The hardening
grant flags apply with host paths, and the flags that configure namespace
facilities are rejected:
$ fcage --restrict \
--landlock-ro /usr --landlock-ro /lib --landlock-ro /etc \
--landlock-rw /work \
--seccomp curated \
-- /usr/bin/sort /work/input
Alongside the grant flags, --setenv, --no-base-env, --chdir, --stdin,
--rlimit, --timeout, --kill-after, and --stop-with-caller behave as they
do for a sandbox. rlimit matters more here than in a cage: without
namespaces, a resource limit is the only thing bounding what the command
consumes.
Userspace networking
The default network is an isolated namespace whose only interface is loopback:
the sandbox can talk to itself and nothing else. Network::Host trades that
isolation for the host’s interfaces, and Network::None removes even
loopback. Between them sits a third option — an isolated namespace that still
reaches the outside, through a userspace network stack.
A userspace network stack terminates the sandbox’s network traffic in ordinary
host sockets: a tap interface inside the namespace carries the guest’s packets,
a stack reads them, and it forwards the flows out through the host as any
application would. The library ships its own, the native stack, behind the
netstack feature; it also leaves the seam open for an external stack such as
pasta or slirp4netns. Either way the sandbox keeps its private
namespace and the host network policy stays outside it.
The seam
Attaching a stack has an ordering requirement: the tap must exist before the command relies on the network, or the command’s first socket call races the stack’s startup. The seam is a pause that removes the race.
Cage::spawn_pending builds the sandbox — creating its namespaces — but
holds the command at a gate just before it runs, returning a Pending
handle instead of a running one. The handle exposes
netns_pid, a host pid of a process inside the
sandbox’s network namespace. A stack attaches to that namespace, and
proceed then releases the command and returns the usual
Running handle. Because the stack is up before the command runs, the
sandbox sees the network from its first instruction.
Dropping a Pending without proceeding tears the launch down — the supervisor
is killed and the launch stage reaped — so an aborted attach leaves no sandbox
running. spawn_pending_with is the streaming
counterpart: it binds an observer that receives the command’s output once
proceed releases it.
The seam is meaningful only where the sandbox has a private network namespace:
Network::Isolated (the default) or Network::None. Under
Network::Host the sandbox shares the host’s namespace, where attaching a
stack is neither needed nor correct.
The native stack
With the netstack feature, NetStack is the library’s own stack, attached
from inside the caller’s own process. It creates and configures
the tap device in the sandbox’s namespace, terminates the guest’s TCP and UDP
flows in an in-process TCP/IP implementation, and forwards each over an
ordinary host socket. The result is unprivileged outbound IPv4 and IPv6: the
host sees a normal application making connections, and the sandbox sees a
gateway to the world.
A stack is built from a NetStackBuilder, attached to a pending launch, and
stopped once the command has finished:
use ferroday_cage::{Cage, NetStack};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cage = Cage::builder().rootfs("/srv/rootfs").command("/usr/bin/job").build()?;
let stack = NetStack::builder().build()?;
let pending = cage.spawn_pending()?;
let handle = stack.attach(&pending)?; // the tap is up and configured
let mut running = pending.proceed()?; // the command runs with the network live
let status = running.wait()?;
handle.stop()?; // tear the stack down after the command
let _ = status;
Ok(())
}
One NetStack attaches to any number of sandboxes; each attachment runs its
own pump and configures its guest independently. Their host resources are not
isolated from one another, though — see Resource model and
tenancy. NetStack::default is the default
configuration, ready to attach as-is.
Defaults
The builder configures the addressing and interface. Within a configured network the stack always places the gateway at host 2 and the guest at host 15.
| Setting | Default | Builder method |
|---|---|---|
| IPv4 | on | ipv4 |
| IPv6 | on | ipv6 |
| IPv4 network | 10.0.2.0/24 (guest .15, gateway .2) | ipv4_cidr |
| IPv6 network | fd00::/64 (guest ::15, gateway ::2) | ipv6_cidr |
| MTU | 1500 | mtu |
| Interface name | tap0 | interface |
| Host-loopback mapping | off | host_loopback |
With the serde feature the builder is also the stack’s profile format: it
serializes and deserializes as the stack specification, for embedding in a
consumer’s own configuration. Keys are kebab-case, unknown keys are rejected,
and the CIDR fields read and write as "address/len" strings.
The handle owns the stack
attach returns a NetStackHandle that owns the pump
thread serving the tap. The pump holds the tap descriptor open, which pins the
device and, through it, the sandbox’s network namespace — so the pump never
observes the sandbox’s death on its own. Two rules follow:
- Wait, then stop. Wait for the command to finish, then call
stop.stopsignals the pump, joins it, and reports any error the pump ended on. Stopping before the command exits simply cuts the network early. - Dropping the handle is safe. A dropped handle stops the pump the same way and discards its outcome. It never affects the sandbox: a running command merely loses connectivity. The stack is an attachment, not a lifeline.
is_running reports whether the pump is still
serving the tap — false after stop, or on its own if the pump hit an
unrecoverable error, which stop then reports.
Resource model and tenancy
Each attachment bounds what one guest can consume: a guest serves at most 128
concurrent TCP flows and tracks at most 256 UDP flows, and each is backed by a
host socket. The TCP cap counts flows in flight rather than connections made: a
flow’s slot, its buffers, and its host socket are released once the exchange has
closed on both sides, whichever side closed first, so a guest opening and
closing connections back to back is not throttled by the table. Those caps are
per attachment, not per process. The pump and every host socket run in the
caller’s own process, so several attachments in one process draw on one shared
descriptor table and one heap; the per-guest caps do not partition those shared
resources between attachments. A guest that fills its own budget can therefore
consume enough host descriptors to affect a co-resident attachment, or the
caller’s own descriptor-opening operations, once the process’s RLIMIT_NOFILE
is reached.
The native stack is accordingly intended for one — or a few mutually trusted — sandboxes per process. A caller that must isolate untrusted tenants from one another should run each in its own process, or supply an external stack whose resource accounting is the operating system’s rather than one shared address space’s.
What the stack forwards
The stack forwards where the guest could plausibly reach and refuses what it
should not. Multicast, broadcast, unspecified, loopback, and link-local
destinations are always refused. A refused TCP connection is answered with a
reset, so denial costs the guest nothing; a refused UDP datagram is simply
dropped, and whether the guest also receives an ICMP port-unreachable depends
on whether the stack happens to hold a socket on that port for another
destination — read nothing into either outcome. Refusing loopback keeps the
sandbox away from services bound to the host’s 127.0.0.1. One refusal reaches
the guest by silence rather than by a reset; it is described below.
Two refusals follow from the same principle. The IPv4 0.0.0.0/8 block is
refused in full rather than only the unspecified address, and an IPv6 address
that embeds an IPv4 one — the mapped ::ffff:a.b.c.d and the deprecated
compatible ::a.b.c.d alike — is refused so that an IPv4 refusal cannot be
re-expressed in the other family. The guest’s own subnet is refused too: it is a
private link between the guest and the stack, and forwarding it would send the
guest’s on-link traffic to the host’s view of those addresses, which collides
with a real LAN wherever the default 10.0.2.0/24 overlaps one.
That subnet refusal is the silent one. Those addresses are on-link from the guest’s point of view, so it resolves a hardware address before it sends a segment, and the stack answers ARP and neighbor discovery only for the gateway. Nothing replies, and the connection fails a few seconds later, when the guest’s own address resolution gives up.
What answers, and what a ping means
The stack holds one address per family — the gateway — and answers for that and nothing else. That is narrower than it sounds, because the interface accepts packets for every destination so that routed traffic reaches its sockets, and accepting a packet and answering for its address are the same question to the underlying stack. The pump asks the narrower one instead: an address-resolution request or an ICMP echo request naming an address the stack does not hold is dropped before the interface can answer it.
So a ping inside the guest reports on the gateway and on nothing else. Pinging
the gateway succeeds — it is the stack, and it is up. Pinging anything else
receives no reply whether or not that destination is reachable, since only TCP
and UDP are forwarded and the stack has no way to ask. A connection is what
reports reachability here; a ping reports the link to the stack.
The gateway address is the one deliberate exception. With
host_loopback(true), a connection to the
gateway (10.0.2.2 or fd00::2 by default) is forwarded to the host’s
127.0.0.1 or ::1, port preserved — the conventional way to reach a
host-local service, granted explicitly rather than by opening the whole
loopback range.
The mapping lands on that one address, so it reaches a host service only if the
service is bound there. A service on another loopback address is not reached,
and systemd-resolved’s 127.0.0.53 stub is the case that catches people out:
it is a loopback destination like any other, so the guest cannot address it
directly, and the gateway mapping goes to 127.0.0.1 where the stub is not
listening.
One datagram, one packet
The stack does not fragment IP packets, in either direction. A UDP datagram therefore has to fit in a single packet: the MTU less the family’s IP header and UDP’s own eight bytes, so 1472 bytes over IPv4 and 1452 over IPv6 at the default 1500-byte MTU. A larger reply from a host peer is dropped rather than delivered in part, and a larger datagram from the guest never leaves it, since the guest’s own kernel fragments what it cannot send whole.
This matters mostly for DNS. A resolver that advertises a large EDNS receive
buffer — 4096 bytes is a common default for diagnostic tools, and DNSSEC
answers do reach that size — asks for answers the stack cannot carry, and the
guest sees a timeout rather than an error. Raise the MTU with
mtu if the guest needs larger datagrams, or
configure its resolver for a smaller EDNS buffer.
TCP is unaffected: the stack terminates the guest’s connections, so each side segments to its own path.
DNS
The stack carries packets; it does not resolve names. A sandbox resolves
through whatever /etc/resolv.conf it has, and that file is the caller’s to
provide. One case needs care: a host running systemd-resolved lists only
127.0.0.53, a loopback stub the stack refuses, so binding that
resolv.conf into the sandbox yields no working DNS. Bind the host’s
resolv.conf only when it names a routable (non-loopback) nameserver, point
the sandbox at a public resolver, or address services by IP. The
fcage command and the fetch-sandbox example both
apply the first rule. Large answers have a size ceiling of their own — see
One datagram, one packet.
Reaching a host-local resolver through the gateway is the remaining option,
and it has a precise requirement: the resolver must listen on 127.0.0.1,
because that is the only address the gateway maps onto. Give the sandbox a
resolv.conf naming the gateway address and enable
host_loopback. This does not work for
systemd-resolved’s stub as shipped, which listens on 127.0.0.53 instead.
One namespace move, on a helper process
Joining the sandbox’s network namespace requires CAP_SYS_ADMIN in the
caller’s own user namespace as well as the target’s, and acquiring that means
entering the sandbox’s user namespace first — which the kernel refuses to a
multithreaded process. attach therefore does its namespace work on a
freshly forked, single-threaded helper process, which joins the user and
network namespaces, creates and configures the tap, hands the descriptor back,
and exits. The caller’s own threads never change namespace. This is internal
to attach; a consumer sees only the call.
The command line
The fcage tool exposes the native stack with --netstack:
fcage --rootfs ./alpine --netstack /usr/bin/wget -qO- http://example.org
Every builder setting has a flag. --netstack-cidr and --netstack-cidr6 set
the two networks, --netstack-mtu and --netstack-interface the tap itself,
and --netstack-host-loopback maps the gateway address onto host 127.0.0.1.
--netstack-no-ipv4 and --netstack-no-ipv6 run one protocol alone, for a host
or a workload where the other is unwanted; turning both off is refused, there
being no stack left to attach.
--netstack composes the sandbox’s resolv.conf from the host’s routable
nameservers. A host offering only loopback resolvers composes nothing and warns:
the sandbox keeps whatever resolv.conf its rootfs ships, which may name a
routable resolver and work perfectly well, so the warning reports what was not
composed rather than predicting the run’s DNS. --no-resolv-conf opts out of
the composition, and so does --no-managed-mounts, whose promise is that the
sandbox carries exactly the binds the command line names; a profile setting
either toggle governs it the same way. A mount of the caller’s own onto
/etc/resolv.conf — --bind, --ro-bind or --raw-mount — replaces the
composed one rather than sitting under it, so a run that supplies its own
resolver configuration need not also decline this one. A rootfs that ships no
/etc/resolv.conf gets one created to mount onto and taken away again
afterwards, as every file bind target does. The flag conflicts with
--share-net and with --restrict.
A worked example
The fetch-sandbox example provisions an Alpine root, attaches the stack, and
fetches a URL, exercising every public item of the API in order:
cargo run --example fetch-sandbox --features netstack,tarball -- \
--rootfs ./alpine --tarball alpine-minirootfs.tar.gz http://example.org
Bring your own stack
The seam also accepts an external stack. slirp4netns takes the target pid and
a tap name, creates the tap inside the namespace, and signals readiness by
writing one byte to a caller-provided descriptor. A consumer wires that
descriptor to the seam:
let pending = cage.spawn_pending()?;
let pid = pending.netns_pid();
// slirp4netns <pid> tap0 --configure --ready-fd=<fd>
let (ready_read, ready_write) = /* a pipe */;
let mut helper = Command::new("slirp4netns")
.args([&pid.to_string(), "tap0", "--configure", "--ready-fd=3"])
.fd(3, ready_write) // the ready descriptor as fd 3 in the child
.spawn()?;
// Block until slirp4netns has created and configured the tap.
read_one_byte(&ready_read)?;
let mut running = pending.proceed()?;
let status = running.wait()?;
// The helper runs for the sandbox's lifetime; it exits when the namespace
// goes away, and the caller reaps it.
helper.wait()?;
The helper is the caller’s process to own: it runs alongside the sandbox, exits when the sandbox’s network namespace is destroyed, and is reaped by the caller. The library’s responsibility ends at the namespace and the gate.
What the seam provides
The seam gives a stack exactly two things: a network namespace with a live
process to target, and the guarantee that the command does not run until the
caller proceeds. Everything else — which stack, its addressing, port
forwarding, DNS — is the stack’s, whether that is the native NetStack or an
external helper. A stack that needs the sandbox to reach it over loopback finds
loopback already up under Network::Isolated; one that configures every
interface itself can start from the bare namespace of Network::None.
Provisioning a rootfs
A sandbox runs against a root filesystem directory the caller provides. The
provision module covers how that directory comes to exist: a Provisioner
produces a root filesystem, and provision::ensure publishes it atomically
at a destination.
use ferroday_cage::provision::{self, Tarball};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let rootfs = "/var/cache/myapp/alpine-rootfs";
provision::ensure(rootfs, &mut Tarball::new("alpine-minirootfs-3.24.1-x86_64.tar.gz"))?;
let status = ferroday_cage::Cage::builder()
.rootfs(rootfs)
.command("/bin/sh")
.args(["-c", "cat /etc/alpine-release"])
.build()?
.run()?;
Ok(())
}
Atomic publication
ensure guarantees that the destination directory either does not exist or
holds a complete rootfs — never a partial one:
- If the destination already exists, it is reused and the provisioner does not run. The first launch pays for provisioning; the rest are free.
- Otherwise the provisioner fills a staging directory beside the
destination, the staging tree’s filesystem is synced, and a single
renamepublishes it. An interrupted run leaves no destination for a later caller to trust, and a crash cannot leave truncated files behind a completed rename. - Concurrent calls, including from other processes, serialize on a
<name>.lockfile beside the destination: exactly one provisions, the rest observe the published result. Locking usesflock, so the destination belongs on a local filesystem.
The return value says which happened: Provisioned::Created or
Provisioned::Existing.
The lock file persists after the call, so a later publication of the same
destination serializes on the same inode. provision::remove deletes it along
with the rootfs, so create and destroy round-trip cleanly — and, because
removal is idempotent, remove also clears the lock a failed provision left
beside no rootfs at all.
Progress and cancellation
Provisioning is the slow part of a first run. Provision — the configurable
form of ensure — takes an observer that receives what the provisioner is
doing and can stop it:
use ferroday_cage::provision::{Provision, ProvisionEvent, Tarball};
fn main() -> Result<(), ferroday_cage::provision::ProvisionError> {
Provision::new("/var/cache/myapp/alpine-rootfs")
.observe(&mut |event: ProvisionEvent<'_>| match event {
ProvisionEvent::Read { done, total: Some(total), .. } => {
eprint!("\r{}%", done * 100 / total.max(1));
}
ProvisionEvent::Entry { path, .. } => eprintln!("{}", path.display()),
_ => {}
})
.run(&mut Tarball::new("alpine-minirootfs-3.24.1-x86_64.tar.gz"))?;
Ok(())
}
A closure is an observer that reports and never cancels. To cancel as well,
implement ProvisionObserver and answer its cancelled method:
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use ferroday_cage::provision::{Provision, ProvisionObserver, Tarball};
struct Watch {
stop: Arc<AtomicBool>,
}
impl ProvisionObserver for Watch {
fn cancelled(&mut self) -> bool {
self.stop.load(Ordering::Relaxed)
}
}
fn main() -> Result<(), ferroday_cage::provision::ProvisionError> {
let stop = Arc::new(AtomicBool::new(false));
let outcome = Provision::new("/var/cache/myapp/trixie")
.observe(&mut Watch { stop: Arc::clone(&stop) })
.run(&mut Tarball::new("rootfs.tar.zst"));
let _ = outcome;
Ok(())
}
The check is consulted where stopping is clean — an archive entry boundary, a
package boundary — and a cancelled run fails with ProvisionError::Cancelled.
ensure then removes the staging tree exactly as it does for any other
failure, so the destination is left absent rather than half-built. Setting the
flag from a signal handler or a watchdog thread is the usual way to drive it;
Arc<AtomicBool> is the shape that fits both.
Every provisioner reports through this one channel, so a consumer wiring
provisioning into a logging system writes one adapter. The Tarball
provisioner reports each entry and its progress through the archive; the
Debian provisioner reports its own richer events —
fetches, resolution, downloads, and dpkg’s output — through the
ProvisionEvent::Debian variant, and honours the same cancellation check
between packages. The Alpine provisioner reports the same
progress through ProvisionEvent::Alpine, and the Gentoo
provisioner through ProvisionEvent::Gentoo, so a consumer
reports any of the userlands by changing the type it matches on.
Where the clean stopping points are is the provisioner’s own question. A package boundary answers it wherever there is one, and the Gentoo layer’s stage3 wave has none — a stage3 is a single file of several hundred megabytes — so it consults the check while the tarball is being written as well as at the step boundaries around it, and a cancelled download leaves nothing behind in the cache. Its binary-package wave has package boundaries like the other two, and uses them.
Removing a rootfs
provision::remove is ensure‘s counterpart: it deletes a rootfs
directory, including one a sandbox under a range identity
map has written. Such a tree can hold directories owned
by subordinate ids, whose contents the plain caller cannot unlink; remove
starts plain and, when refused, re-enters the same map — a forked process
unshares a user namespace, the bundled delegates establish the subordinate
map for it, and the tree is deleted from inside, where the ids are the
caller’s own. It is the rootless container runtimes’ unshare rm pattern as
a library call, and ensure uses the same escalation for its own staging
cleanup, so a crashed range-mapped provisioning run cannot wedge the
destination. Removing a path that does not exist is not an error.
remove escalates through the bundled delegate chain, which covers a tree
written under the subordinate map or a bundled-delegate range map. A tree whose
ownership a site-specific delegate established — one supplied through
CageBuilder::id_mapper that allocates ids the bundled delegates would not —
is removed through the Remove builder, whose mapper re-enters the map using
that same delegate:
#![allow(unused)]
fn main() {
use ferroday_cage::IdentityMap;
use ferroday_cage::provision::Remove;
let mapper = ferroday_cage::DirectMapper::new();
Remove::new("/var/lib/myapp/rootfs")
.map(IdentityMap::Subordinate)
.mapper(&mapper)
.run()?;
Ok::<(), ferroday_cage::provision::ProvisionError>(())
}
provision::remove(dest) is the shorthand for the common case: the bundled
chain, and nothing to configure.
A tree written under the single-identity map escalates too, named with
Remove::map(IdentityMap::Single), and there the child writes the map itself
with no delegate involved. Its ids are the caller’s own either way, so the
escalation looks redundant and is not: root of the mapped namespace holds the
capabilities that clear a directory whose own mode denies the write the
deletion needs — a read-only directory an archive carried in, for one.
At a shell prompt, fcage --remove-rootfs DIR removes the tree and its lock
together, which is the default above and the only behaviour it offers: the mode
names a published rootfs, which is the case the round-trip is right for. A
destination that holds published trees rather than being one is a library call
rather than a flag. fcage takes --identity-map for the map the tree was
provisioned under, defaulting to the subordinate one, and is a mode of its own
that accepts no other option:
fcage --remove-rootfs /var/lib/myapp/rootfs
The lock beside the tree
A removal takes the <dest>.lock ensure wrote beside the destination, so a
published rootfs and its lock round-trip together. A caller whose dest
contains published trees rather than being one — a scratch directory, a
cache, a work area holding several — passes false to Remove::remove_lock:
#![allow(unused)]
fn main() {
use ferroday_cage::provision::Remove;
Remove::new("/var/cache/myapp/work").remove_lock(false).run()?;
Ok::<(), ferroday_cage::provision::ProvisionError>(())
}
Only the caller can tell the two apart. Both are directories, and a containing
directory with no .lock sibling today looks exactly like a published one
whose lock was already cleared, so the library cannot probe for the answer. The
tree itself is removed identically either way; the flag governs the sibling
file alone.
Exporting a rootfs
With the tarball cargo feature, provision::export_tar writes a provisioned
rootfs to a POSIX/pax tar. It is the range-map counterpart of taring the
directory: under a range identity map the finished tree
carries real system ownership stored on the host at an offset — a file the
rootfs means as uid 42 is owned 100000 + 42 — so a host-side tar would
record the offset id, and could not read the security.* extended attributes a
setcap’d binary carries at all. export_tar re-enters the map the tree was
built under, exactly as remove does: a forked child reads every entry as root
of the mapped namespace, where the intended ids and attributes are its own.
The map is named explicitly and must be the one the tree was built under, so the host offset ids round-trip to the ids the tree intends. Under the single-identity map — the default — the export records every entry as root, the identity the sandbox runs everything as.
#![allow(unused)]
fn main() {
use ferroday_cage::IdentityMap;
use ferroday_cage::provision::Export;
Export::new("/var/lib/myapp/rootfs")
.map(IdentityMap::Subordinate)
// A fixed SOURCE_DATE_EPOCH makes the archive byte-reproducible; without
// it, real modification times are recorded.
.clamp_mtime(1_700_000_000)
.write_to_path("rootfs.tar")?;
Ok::<(), Box<dyn std::error::Error>>(())
}
provision::export_tar(rootfs, writer, map) is the shorthand, carrying only
what every export needs.
Where the archive goes
write_to_path names a file, and that file appears complete or not at all. The
bytes go to a staging file beside it, which is synced and then renamed over the
destination, so an export that fails part-way — an unreadable source entry, an
identity map no delegate can establish, a filesystem that filled up — leaves
whatever archive was already there. Opening the destination directly would
instead destroy a previous archive to make a partial one, before the export had
established that it could produce anything at all. The staging file is created
afresh, so the destination’s inode is replaced rather than rewritten: a hard
link to the previous archive keeps it, a symlink at the destination is replaced
rather than followed, and the new file’s permissions are 0o666 less the
process umask, as an ordinary shell redirection produces.
A destination inside the tree being exported is refused with
ProvisionError::ExportDestInside. The walk would reach the file it is being
written to, so what landed in the archive would depend on how far the encoder
had got when the walk arrived there.
write_to takes any Write sink instead, so an export can stream directly into
a compressor or an archive reader with no intermediate file. It writes what it
is given and cannot stage onto anything, which is the right shape for a pipe, a
socket, or a buffer in memory, and the wrong one for a file a previous archive
already occupies.
Regular files,
directories, symlinks, and hardlinks are emitted with their mode, ownership,
and modification time. Extended attributes come with everything but symlinks —
ACLs and file capabilities included — since the kernel confines a symlink’s
attributes to the security and trusted namespaces, which an unprivileged
export can neither read in full nor an unprivileged extraction restore.
Character and block devices, FIFOs, and sockets are skipped, as a deployed
image’s runtime provides its own. When no delegate can establish a
range map the export fails with ProvisionError::ExportUnprivileged, naming
what the host is missing.
Member names match tar -C rootfs .: the archive leads with a ./ directory
entry for the root and every other member is ./-prefixed (./etc/,
./etc/hostname). A consumer that selects a member by path or strips leading
components sees the same layout GNU tar produces, so the export is a drop-in
for it.
The archive is byte-reproducible given the tree and a clamp_mtime.
Entries are emitted in sorted order — directory children by name, extended
attributes by name — so the output does not depend on the order the filesystem
stores entries in. clamp_mtime(epoch) records each entry’s modification time
as min(mtime, epoch), capping the wall-clock stamps a bootstrap writes while
preserving any earlier time a package sets; unset, the real time is recorded. A
build that pins its inputs and clamps to a fixed epoch produces a byte-identical
archive on any host.
As with removal, a tree built under a CageBuilder::id_mapper delegate is
exported through Export::mapper, which re-enters the map using that same
delegate rather than the bundled chain.
At a shell prompt, fcage --export-rootfs DIR is the same export. It takes
--identity-map for the map the tree was provisioned under, defaulting to the
subordinate one, and --clamp-mtime for the epoch that makes the archive
byte-reproducible. Like the removal, it is a mode of its own that accepts no
other option.
The archive goes to standard output, so it pipes into a compressor without a temporary file:
fcage --export-rootfs /var/lib/myapp/rootfs --clamp-mtime 1700000000 \
| zstd -o rootfs.tar.zst
--export-to FILE writes to a file instead, with the staging and rename above,
so a failed run leaves an existing archive at that path untouched. Writing an
archive to a terminal is refused rather than filling the screen, which is the
only case where the default would surprise anyone.
What a launch leaves in the tree
An export records the tree as it is, so what a provisioning sandbox contributed to the tree is what the archive carries. A launch over a plain rootfs leaves its mount points behind and nothing else:
| Path | What is left | Mode |
|---|---|---|
/proc | the mount point, empty | 0755 |
/dev | the mount point, empty | 0755 |
/tmp | the mount point, empty | 1777 |
A mount point is created only when the rootfs does not already have the path, and a path the rootfs ships keeps the mode it shipped with — the tree owns what it ships, the sandbox owns what it creates. The mode is the directory’s own, independent of the launching process’s umask, so the same tree exports the same way from any host.
Nothing survives inside those directories. The /dev the sandbox assembles —
the six device nodes, the five symlinks, the devpts on /dev/pts and the
tmpfs on /dev/shm — is built inside the tmpfs that covers the mount point, so
it goes with the mount namespace. An exported tree holds ./dev/ and nothing
under it.
A mount that contributes a file rather than a directory takes it back. A bind
needs its target to exist, so the /etc/resolv.conf bind that host networking
performs creates an empty file when the rootfs ships none — and removes it once
the sandbox is gone, leaving the tree without the resolver configuration it never
had. A caller’s own file bind is treated the same way: the empty 0644 target it
needs is present for the mount to cover and for nothing else, and its mode is
exact, independent of the launching process’s umask. A rootfs that does ship an
entry at that path keeps it untouched, whether it is a file or a symbolic link:
the bind covers it for the sandbox’s life and reveals it again afterwards.
A caller’s own bind or raw_mount is the case where a directory target is
created and stays, as 0755. That is worth knowing when the tree being written
into is the one being shipped — a bind declared for the duration of a build
leaves its mount point in the artifact.
Where a launch must leave the tree untouched entirely, root it on an overlay. Every mount point, and every write the command makes, lands in the upper rather than in the base:
use ferroday_cage::Cage;
fn main() -> ferroday_cage::Result<()> {
Cage::builder()
.overlay_rootfs("/var/lib/myapp/rootfs", "/var/lib/myapp/build-42")
.command("/usr/bin/make")
.build()?
.run()?;
Ok(())
}
Discarding the upper reverts the run, base included, and exporting the base afterwards gives an archive of a tree no sandbox has written to.
Copying a tree into a rootfs
CopyIn lays a host directory tree into a rootfs with each entry created under
the identity map the rootfs is owned through:
use ferroday_cage::IdentityMap;
use ferroday_cage::provision::CopyIn;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let report = CopyIn::new("./overlay", "/srv/images/trixie")
.map(IdentityMap::Subordinate)
.run()?;
for entry in &report.skipped {
eprintln!("not copied: {} ({})", entry.path.display(), entry.kind);
}
Ok(())
}
It is the mirror image of the export. An export forks a child into the map to
read a tree at the ownership it intends; this forks one to write one. That
is what cp structurally cannot do: cp runs as the calling user, which can
create a file owned by nobody else, so a tree laid in by hand arrives owned by
the caller whatever the source intended.
Ownership is read through the map, which is what makes the two exact mirrors. A
file the host stores at 100000 + 42 is uid 42 as the rootfs means it, and the
copy stores it back at the host id the map puts it at — so a tree an earlier
mapped sandbox produced copies into a rootfs owned through the same map with its
ownership intact. Under IdentityMap::Single the same rule lands everything as
root, that map having only the one inside id, which is what such a sandbox sees
and what it could have written itself. A source id no extent covers is refused,
naming the path that carries it: the rootfs has no id for it.
The destination must already exist, and entries are added to it — a directory the rootfs already carries is kept and descended into, a file, symlink, or named pipe is replaced.
Directories, regular files, symbolic links, and named pipes are copied, with
their modes and modification times. A symbolic link’s modification time is its
own rather than its target’s; its mode is the one thing not carried, the kernel
fixing every symbolic link’s at 0777. Several names for one file are copied as
hard links onto the first name, so a tree keeps the sharing it was written with
and its data is streamed once; that is what lets a tree exported by Export,
which coalesces hard links of its own, come back through CopyIn unchanged.
Character and block devices and sockets cannot be copied: a device node needs a
privilege the copy does not hold whatever map it is under, and a socket’s inode
is created by the process that binds it and is meaningless without it. Rather
than dropping those in silence, the run returns a CopyReport naming every
entry it left out and what each one was, so a rootfs that ends up missing
something says so.
A source file that changes size while it is being read fails the run with
ProvisionError::SourceChanged. The frame’s length is committed before the
data is read, so such a file is written padded or truncated; the copy finishes
the frame to keep the stream in step and then refuses, rather than reporting
success for a file it did not reproduce.
Containment is structural rather than checked. Every frame the parent sends
names its entry as a single path component, never a path, and the child creates
each entry relative to the directory it currently holds open — so there is no
path for a .. or a leading / to appear in. The one exception is a hard
link’s anchor, which is a path because the two names may sit in different
directories. Containment there rests on three things together: the child refuses
an absolute anchor and any ., .., or empty component in one; the link is
made without following the final component; and every directory along the
anchor’s path was created and opened O_NOFOLLOW by that same walk, which is
what keeps linkat’s resolution of the intermediate components inside the
tree. The residual is a concurrent writer inside the destination, which the
whole copy shares. A symlink in
the source is recreated as a symlink and never traversed: the walk does not
descend through one, and the child opens each directory with O_NOFOLLOW, so a
link pointing out of the tree lands inside the destination as a link and nothing
is written through it.
The tarball provisioner
With the tarball cargo feature, Tarball provisions from a tar archive.
The archive may be uncompressed or compressed with gzip, xz, or zstd —
detected from the content, never the file name — and may use the POSIX
ustar, GNU, or pax dialect, or the pre-POSIX v7 one that predates all three.
Decoding is pure Rust throughout.
v7 is worth naming because it still turns up. It carries no magic, no
uname/gname, no device numbers, and no prefix field, so a v7 path fits the
100-byte name field or does not exist; a directory is marked by a 5 typeflag,
a trailing / on the name, or both. Contemporary tooling does not write it, but
an upstream release tarball was written by whoever released the software, on
whatever machine, in whatever year, and a sample of Debian trixie’s
.orig.tar.* files finds roughly one in twelve is v7.
Two of the three compressions let the archive name its own memory cost, in a header far smaller than the allocation it asks for: a zstd frame declares the window the decoder buffers, and an xz block declares the dictionary the decoder allocates and zeroes. Both are capped at 128 MiB, which is twice the largest either uses in the archives this library reads. Each decoder is given its cap and checks the header against it before allocating, so an archive over the cap fails the provisioning run rather than being paid for and then reported — and the check is per block and per frame, not on the first alone, since a hostile declaration in the last block of an otherwise ordinary archive costs exactly as much as one in the first. Nothing published by Debian, Alpine, postmarketOS, or Gentoo comes close to the cap.
The archive comes from a path or from any reader:
use ferroday_cage::provision::{self, Tarball};
fn fetch(url: &str) -> std::io::Result<std::fs::File> { unimplemented!() }
fn main() -> Result<(), Box<dyn std::error::Error>> {
// From a file on disk, opened afresh on each run.
provision::ensure("/var/cache/alpine", &mut Tarball::new("alpine.tar.gz"))?;
// From a stream, extracted as it arrives.
let body = fetch("https://example.invalid/alpine-minirootfs.tar.gz")?;
provision::ensure("/var/cache/alpine-net", &mut Tarball::from_reader(body))?;
Ok(())
}
from_reader is the mirror image of export_tar, which writes to any Write:
a rootfs fetched over the network is provisioned as it arrives, with no
intermediate file on either side. A stream can be read only once, so the reader
is consumed by the first run and a second run against the same Tarball says
so rather than publishing an empty rootfs; new is the form that provisions
repeatedly.
A stream that ends before its first header block holds no archive, and is
refused with ProvisionError::FormatUnrecognized — an empty file, a download
that produced nothing, and a compression stream whose decoded payload is empty
all arrive that way. The reader does tolerate an archive whose final two-block
terminator never arrived, as GNU tar does, but only once at least one entry
has been read: a publication is durable, so a stream taken for a complete
archive of nothing publishes an empty tree that every later ensure then
returns as Provisioned::Existing without consulting the source again.
Extraction is unprivileged and designed for archives that describe a root filesystem:
- Containment is kernel-enforced. Entry paths and hardlink targets
resolve through
openat2strictly beneath the destination. In-tree relative symlinks resolve normally — a merged-usr archive whosebinis a symlink tousr/binextracts correctly — while an entry that reaches outside through.., an absolute path, or a symlink chain fails withProvisionError::EntryUnsafebefore anything is written. That error means an escape and nothing else; an entry refused for another reason — a path component that is not a directory, a symbolic link with no target — is aProvisionError::EntryRefused. - Ownership maps to the calling user. Every extracted entry belongs to the calling user, whom the sandbox’s identity map presents as root, so extracted files appear root-owned inside the sandbox. Ownership recorded in the archive is ignored.
- Permission bits apply verbatim, setuid, setgid, and sticky bits included. Modification times are applied from the archive; directory times are applied after their contents, so they survive extraction.
- Device nodes and sockets are skipped. They cannot be created without
privilege, and the sandbox assembles its own minimal
/devat launch. FIFOs are extracted. Extended attributes are not applied. - A later entry for the same path replaces the earlier one, matching tar’s behavior. Sparse and multi-volume members are not supported and fail with a typed error.
Extraction imposes no size or entry-count quota; when the archive comes from an untrusted source, bound disk usage externally.
A setuid bit applying verbatim is what a root filesystem needs — /usr/bin/sudo
without it is not sudo — and it grants nothing inside the sandbox, where the
extracting user is already root. On the host it is a live bit: the extracted file
is setuid to the calling user, so another local user who can reach the
destination directory, on a filesystem not mounted nosuid, can run it as the
calling user. Extract an untrusted archive into a directory only the calling user
can reach, or onto a nosuid filesystem.
An ebuild development sandbox works this into a consumer:
it provisions a Gentoo stage3 through Tarball, then runs a package build in
the provisioned root.
Keeping a provisioned rootfs pristine
A provisioned rootfs is a durable cache: the first run pays for it and every
later run finds it published. But a sandbox writes into the root it is given,
and a bind mount creates its target inside the rootfs when the target does not
exist — a bind("/host/data", "/work") makes /work in the rootfs, and the
directory stays there on the host after the sandbox exits. Over many runs with
different binds, a shared root accretes empty mount-point directories. This
project’s own test fixture shows it: an Alpine root the tests treat as
read-only has picked up /candidates, /raw, and /work from bind targets.
Nothing is broken by this — the directories are empty, and the next run reuses them — but a root meant to be pristine no longer is, and it drifts from what provisioning produced.
overlay_rootfs is the answer. It roots the sandbox on an overlay whose lower
layer is the provisioned tree and whose upper takes every write:
use ferroday_cage::Cage;
fn main() -> ferroday_cage::Result<()> {
let status = Cage::builder()
.overlay_rootfs("/var/cache/myapp/alpine-rootfs", "/var/cache/myapp/run-42")
.bind("/home/user/project", "/work")
.command("/usr/bin/make")
.build()?
.run()?;
let _ = status;
Ok(())
}
The mount points, the command’s writes, and anything a build leaves behind all
land in /var/cache/myapp/run-42; the provisioned root is untouched and byte-identical
to what ensure published. Discarding the upper reverts the run, which is also
what makes the pattern a clean-root-per-build — see the ebuild
sandbox. Keeping the upper instead
gives an incremental root layered over a shared base.
An unprivileged overlay needs Linux 5.11 or later and an upper on a filesystem
that records user.* extended attributes, which an on-disk filesystem (ext4,
xfs, btrfs) does; a tmpfs upper needs Linux 6.6. A host that cannot establish
one is refused at build time, naming what is missing, rather than silently
falling back to writing into the base. The kernel creates a directory in the
overlay’s work area with mode 0, so discard the upper with
provision::remove, which restores traversable permissions as it descends,
rather than a plain rm -rf.
Put the upper under a directory the calling user controls, not a world-writable
one such as /var/tmp — the same requirement rootfs carries, and for a
sharper reason: the upper is where the sandbox’s writes land and where the
caller reads its result back afterwards. The library creates the upper and its
work directory with mkdirat against their parent and adopts an existing entry
only when it is a directory the calling user owns, so a symbolic link another
local user planted at the path is refused rather than followed. That closes the
create; it does not make a shared parent safe, because the mount options name
the layers as paths and the kernel resolves them again at mount time.
On the command line, --overlay-lower and --overlay-upper stand in for
--rootfs.
Custom provisioners
Provisioner is one method:
pub trait Provisioner {
fn provision(&mut self, request: &ProvisionRequest<'_>) -> Result<(), ProvisionError>;
}
An implementation fills request.staging() and must not assume that directory
is the rootfs’s final location — ensure moves it into place afterward. A
provisioner outside this crate wraps its own failures with
ProvisionError::other.
The inputs arrive as a ProvisionRequest rather than as loose parameters so
that context a provisioner comes to need can be added as accessors without
changing the method’s signature; see Stability and
versioning.
The Debian userland add-on is a provisioner of this kind: with
the debian feature it bootstraps a Debian suite from the archive, and
Building a Debian package works it into a
consumer that builds a .deb from source in a cage. The Alpine
userland is the second, and provisions an Alpine or postmarketOS
root the same way. The Gentoo userland is the third: it verifies a
signed stage3 and extracts it, then installs prebuilt packages into the result
from the archive’s binary-package host — resolving, uniquely among the three,
against a root that already has 296 packages in it.
The command line
fcage --provision-tar FILE provisions the --rootfs directory from a
tar archive before launching. The already-provisioned case is silent and
fast, so the flag can stay in an invocation permanently:
$ fcage --provision-tar alpine-minirootfs-3.24.1-x86_64.tar.gz \
--rootfs ./alpine /bin/sh -c 'cat /etc/alpine-release'
3.24.1
An ebuild development sandbox
A package build wants a clean root filesystem, the package tree to build from,
and the network to fetch sources — and it wants none of that to touch the host.
Those are exactly a cage’s parts: a provisioned root, a read-only bind for the
tree, a read-write bind for the caches, and a shared network. Put them together
and a small tool falls out: an ebuild development sandbox that provisions a
Gentoo stage3, binds a portage tree into it, and runs emerge. The library
ships one as a worked example.
Where Building an orchestrator composes a prepared rootfs with profile data, this example is the one that provisions its root: it is the worked consumer of the provisioning module, fetching a stage3 through the Gentoo provisioner before it launches anything.
ebuild-sandbox --variant amd64-openrc --rootfs ./gentoo \
--repo /var/db/repos/gentoo --distfiles ./distfiles \
app-misc/hello
--variant names one of the stage3s Gentoo publishes and the layer does the
rest: it follows the signed pointer to the build that variant currently points
at, holds the tarball to the SHA-512 a signed digest document records for it, and
extracts it. --stage3 FILE provisions from a tarball already on the host
instead, which is what an air-gapped machine or a locally built stage3 needs; the
two are alternatives.
The build posture
The example builds one CageBuilder and layers the build’s needs onto it, each
an ordinary builder call:
- The root is provisioned, once.
provision::ensureextracts the stage3 into the--rootfsdirectory and publishes it atomically. The first run pays for the download and the extraction; every later run finds the root already published and skips both, so the flag can stay in an invocation permanently.--stage3-cachekeeps the downloaded tarball across runs, which is what a caller building more than one root from a build wants. - The tree is read-only.
--repobinds a host portage tree read-only at/var/db/repos/gentoo. The build reads ebuilds and eclasses through the bind but cannot write back to the host’s tree. - The caches are read-write.
--distfilesand--binpkgsbind host directories read-write at/var/cache/distfilesand/var/cache/binpkgs, so fetched source tarballs and built binary packages persist across runs instead of being discarded with the sandbox. - The network is shared for fetches. The default is the host network, which
emergeneeds to fetch sources;--offlinedenies it for a build that works only from the distfiles cache.
With package atoms the sandbox runs emerge --oneshot over them; with a command
after -- it runs that command instead — a shell, for interactive ebuild work
in the same posture.
Why no hardening
The example applies no hardening layer, and disables portage’s
own internal sandboxing through a default FEATURES value. A package merge
legitimately writes across the tree, changes file ownership, and runs helper
processes; the boundary it needs is the cage’s namespaces and private root, not
a syscall filter layered over the top. Portage’s sandbox, usersandbox, and
*-sandbox features exist to build that boundary when there is none — inside
the cage there already is one, and nesting the two fights the cage’s own
namespaces. A --env FEATURES=... overrides the default when a build genuinely
needs a portage feature back.
Portage and the identity map
Portage, left to its defaults, assumes more than one identity: it chowns its
own state directories to the portage group when it starts, and its
userpriv and userfetch features drop to the unprivileged portage user to
build and fetch. Under the library’s default single-identity map none of those
ids exist, and every one of those operations fails — the first before a build
even begins.
The example therefore requests the subordinate identity
map: root plus the calling user’s whole subordinate-id
allocation, established through the shadow suite’s helpers. Inside the
sandbox the portage uid and gid are real, so portage needs no configuration
at all — its chowns succeed, and the example’s default FEATURES enables
userpriv so builds drop to portage:portage exactly as they would on a
host. The map requires the subid feature, the newuidmap and newgidmap
helpers, and a subordinate allocation for the calling user;
host::range_map_blocker
names whichever piece a host is missing, and the example repeats it when the
sandbox cannot be built.
Real ids have one consequence outside the sandbox: directories portage chowns
to its own user — its state and cache trees — are owned by subordinate ids on
the host, so a later rm -rf of the rootfs by the plain calling user fails
inside them. provision::remove handles exactly this case: it starts with a
plain removal and, when ownership refuses, re-enters the same map and deletes
the tree from inside, where the ids are the caller’s own.
use ferroday_cage::IdentityMap;
use ferroday_cage::provision::Remove;
fn main() -> Result<(), ferroday_cage::provision::ProvisionError> {
Remove::new("./gentoo-root").map(IdentityMap::Subordinate).run()?;
Ok(())
}
At a shell prompt, fcage removes the same tree. The example provisions under
the subordinate map, which is what the flag assumes:
fcage --remove-rootfs ./gentoo-root
Running the example
The example lives at crates/ferroday-cage/examples/ebuild-sandbox.rs. It
provisions the root through the Gentoo provisioner — falling back to the tarball
one for a --stage3 file of your own — and establishes the range map through
the subid delegate, so it builds with the gentoo and subid features:
cargo run --example ebuild-sandbox --features gentoo,subid -- --help
A complete invocation needs a portage tree of your own; the stage3 the example
fetches itself. The tree comes from the gentoo-latest.tar.xz snapshot under a
Gentoo mirror’s snapshots/, which extracts to a gentoo/ directory to point
--repo at.
# Provision the root and build a package into it:
cargo run --example ebuild-sandbox --features gentoo,subid -- \
--variant amd64-openrc --rootfs ./gentoo-root \
--stage3-cache ./stage3-cache \
--repo ./gentoo --distfiles ./distfiles \
app-misc/hello
# A shell in the same posture, for interactive work:
cargo run --example ebuild-sandbox --features gentoo,subid -- \
--rootfs ./gentoo-root --repo ./gentoo -- /bin/bash
The command’s exit code becomes the sandbox’s, so a build composes into scripts like any other launcher.
A note on clean builds
The cage mounts the root read-write, so a build writes into the provisioned root and the next build starts from where the last one left off — convenient for iterating on one ebuild, but not a fresh root each time.
For a pristine root per build, root the cage on an overlay of the stage3 instead of on the stage3 itself: the provisioned root becomes a read-only lower layer, every write lands in a disposable upper, and discarding the upper is the whole of the cleanup. One provisioned stage3 then serves any number of clean builds, with no second extraction and no copy.
use ferroday_cage::Cage;
fn main() -> ferroday_cage::Result<()> {
let cage = Cage::builder()
.overlay_rootfs("./gentoo-root", "./builds/hello")
.command("/usr/bin/emerge")
.args(["--oneshot", "app-misc/hello"])
.build()?;
let _ = cage;
Ok(())
}
Keeping the upper instead of discarding it gives an incremental build root
layered over the shared stage3 — the same base, one upper per line of work.
Discard an upper with provision::remove rather than rm -rf: the kernel
creates a mode-0 directory in the overlay’s work area that a plain recursive
delete cannot descend into.
The library models one prepared root per launch and leaves the choice of reuse or renewal to the consumer; the overlay is how the choice is expressed without duplicating the root.
A Debian userland
The debian feature provisions a Debian root filesystem: it bootstraps a
suite and architecture from the archive and installs packages into it, so a
sandbox can build or run Debian software from a userland the library assembles
for it. It speaks to the archive directly — a pure-Rust replacement for
debootstrap and mmdebstrap — and configures packages by running dpkg
inside a cage of its own.
use ferroday_cage::provision::{self, debian::Debian};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut debian = Debian::builder("trixie")
.include(["build-essential", "git"])
.cache_dir("/var/cache/fcage/debs")
.build()?;
provision::ensure("/var/lib/machines/trixie", &mut debian)?;
Ok(())
}
Debian implements Provisioner, so provision::ensure
publishes the result atomically: the destination directory either does not
exist or holds a complete, configured rootfs.
Building a Debian package works this into a
consumer: it bootstraps a suite with build tooling and builds a .deb from
source inside a cage, with the host untouched.
How it works
A bootstrap runs in stages:
- The signed release. It fetches
InRelease, verifies the OpenPGP signature against the archive keyring, confirms the release identifies as the requested suite and has not expired, and reads the release, which carries the digest of every package index. - The package set. It fetches each component’s
Packagesindex, verifies it against the release digest, and resolves the install set: the base system (every essential and required package, plusapt) together with the packages named byinclude, closed over their dependencies. - Download and extract. It downloads each package, verifies its digest,
and extracts its files into a staging tree with the same kernel-enforced
containment the tarball provisioner uses. The merged-usr symlink layout is
created first, so packages that ship
/usr-native paths resolve correctly. The staging tree’s own directory is mode0755, the modebase-filesgives/: every.debdescribes the root it is unpacked into as well as its contents, so taking the root’s mode from one of them would make it a property of whichever package happened to be extracted last. - Configuration. It configures the packages by running dpkg inside a cage
rooted at the staging tree, in waves modeled on a second-stage bootstrap.
Because configuration runs in the sandbox, it needs no privilege on the
host. It writes the rootfs’s
sources.listand installs the archive keyring as itssigned-bytrust anchor, so the finished rootfs is apt-usable: an in-cageapt-get updateverifies the release against that keyring without thedebian-archive-keyringpackage, which the base install set does not pull in. Under the single-identity map it also configures apt to run its download methods as root, since the_aptuser apt would otherwise drop to is not represented in that map; a range map keeps apt’s default sandbox. When every package reaches the installed state, the rootfs is published.
A bootstrapped rootfs carries no /etc/resolv.conf, because the provisioner
writes none: a resolver configuration belongs to wherever the tree is deployed,
not to the archive it was built from. An in-cage apt-get update still resolves
names, because host networking binds the host’s resolv.conf in — creating the
file to mount onto, and removing it once the sandbox is gone, so the tree keeps
carrying none:
use ferroday_cage::{Cage, Network};
fn main() -> ferroday_cage::Result<()> {
Cage::builder()
.rootfs("/var/lib/myapp/debian")
.network(Network::Host)
.command("/usr/bin/apt-get")
.args(["update"])
.build()?
.run()?;
Ok(())
}
What a launch leaves in the tree covers the general rule for a tree that goes on to be exported.
The package cache
cache_dir sets a directory downloaded packages are kept in, so a later
bootstrap that resolves the same version reuses the file instead of fetching it
again. Entries are content-addressed by digest, and every reuse verifies the
file against that digest, so a truncated or altered entry is downloaded again
rather than trusted. A cache directory is the caller’s: it is created if absent
and never removed, which is what makes it a cache.
Without one, packages are downloaded into a directory beside the tree being built and discarded when the run ends — whether the run succeeded or failed, so a bootstrap that fails partway leaves nothing beside the rootfs it did not publish.
A package is never held in memory. The bytes are written to a staging file as
they arrive and digested on the way through, and the file is renamed into the
cache only once the digest matches the one the archive recorded; installing
reads it back the same way, streaming the data.tar through the decompressor
into the extractor. So a 90 MB kernel package costs what a shell script costs,
and nothing unverified is ever visible at a cache path.
One cache directory serves any number of concurrent bootstraps, in one process or several, whether or not their package sets overlap. Each entry is staged under a name unique to the writer and published with a rename, so two bootstraps downloading the same package at once both succeed and both end up naming the one file. A builder that provisions several rootfs trees in parallel can therefore point them all at a single cache.
Selecting packages
The install set is the base system, plus the packages include names, closed
over their dependencies, minus the packages exclude removes.
The base system is chosen by priority, not by an enumerated list. base_priority
sets the least essential Priority band it seeds from: the base is every
essential package plus every package at least as essential as the floor. The
default, Priority::Required, is debootstrap’s minbase — essential and
required packages only. A less essential floor seeds more:
use ferroday_cage::provision::debian::{Debian, Priority};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let debian = Debian::builder("trixie")
.base_priority(Priority::Important)
.build()?;
let _ = debian;
Ok(())
}
Priority::Important additionally seeds that band — cron, logrotate, and
the like — so a package the archive marks at that priority is present without
being named in include, matching the corresponding bootstrap variant.
Choosing the base by priority rather than by listing packages keeps the set in
step with the archive: a package the archive promotes into the band appears
without the selection being revised. Floors below Priority::Standard seed the
bulk of the archive and are not meaningful for a base system.
exclude removes a package from the resolved closure. An excluded package is
dropped from the base seed and skipped as a dependency alternative, so a group
such as network-manager | isc-dhcp-client resolves to the surviving
alternative rather than pulling the excluded one — apt’s pkgname- deselection,
a way to keep out a package a broader selection would otherwise draw in:
use ferroday_cage::provision::debian::Debian;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let debian = Debian::builder("trixie")
.include(["network-manager"])
.exclude(["isc-dhcp-client", "dhcpcd-base"])
.build()?;
let _ = debian;
Ok(())
}
Exclusion is a resolver decision, so the resolved plan and the finished rootfs
agree — unlike a post-install purge, which would install a package only to
remove it. A hard dependency that only an excluded package can satisfy fails the
bootstrap rather than producing a broken closure, as does excluding a package
that is also an include.
What a resolution refuses
A dependency nothing can satisfy fails the resolution: a name no configured
archive carries, a name carried only at a version the declared constraint rules
out, or one an exclusion removed that a hard dependency needed. Installing
anyway would succeed — dpkg configures with --force-depends — and leave a root
whose packages cannot work, so the resolution refuses instead.
Every refusal is reported before the run fails, and the failure names all of
them. A resolution reports each one as a DebianEvent::Unsatisfiable carrying
what could not be supplied, who asked for it, and what the wall was, so an
install list with four mistakes in it is corrected in one pass rather than in
four bootstraps. The resolution keeps walking past a refusal to find the next:
a group nothing satisfies contributes nothing to the selection, so the rest of
the closure is unaffected by it. Two packages needing one absent name produce
two refusals rather than one, which is the fact rather than noise — both have
to change.
Repositories
A bootstrap resolves against one or more repositories. The
mirror
and its companion setters configure the primary — a single-mirror bootstrap
needs nothing more — and repository merges in additional sources. A
Repository carries an ordered list of mirror URLs, and the two things a
caller reaches for both fall out of that one type.
Every mirror URL is fetched through the configured transport, and the built-in
one speaks http:// and file:// only. An https:// mirror therefore needs a
transport of the caller’s own, supplied through fetcher; see
Trust. It is the archive signature, not the transport, that
authenticates a package.
Distinct sources in one resolution. A local trusted pool of a build’s own
.debs and one or more signed feature repositories are separate repositories,
each with its own trust anchor, contributing packages to a single dependency
closure:
use ferroday_cage::provision::{self, debian::{Debian, Repository}};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let local = Repository::builder("trixie")
.mirror("file:///srv/my-debs")
.trust_unsigned(true)
.name("local")
.build()?;
let mut debian = Debian::builder("trixie")
.include(["my-package"])
.repository(local)
.build()?;
provision::ensure("/var/lib/machines/trixie", &mut debian)?;
Ok(())
}
Resolution is highest-version-wins across every repository, so a package a
feature repository ships at a higher version supersedes the mirror’s, and a
package only the local pool ships is pulled from it while its dependencies close
against the mirror; an exact-version tie resolves to the earlier repository, the
primary first. The local pool is a dists/-structured repository — the layout
apt reads, the same one the provisioner fetches from a mirror — trusted without
a signature, which is apt’s [trusted=yes] and the shape a build produces when
it regenerates a pool of its own .debs each run. Each additional repository
writes its own /etc/apt/sources.list.d/<name>.list into the finished rootfs,
signed by its own /usr/share/keyrings/<name>.gpg when it carries a keyring.
Those file names have to be distinct, so a set in which two additional
repositories would write the same pair is refused at build time; a repository
with no name takes a generated one from its position, which a caller-supplied
name must not collide with either.
Interchangeable URLs for one source. A live mirror with a
snapshot.debian.org backstop is one source reached two ways.
mirror_fallback adds a backstop URL to the primary — and
Repository::builder
mirror_fallback
to any repository — tried in order when the live mirror reports a resource
missing, for a version that has rotated off the live pool:
use ferroday_cage::provision::debian::Debian;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let _debian = Debian::builder("trixie")
.mirror("http://deb.debian.org/debian")
.mirror_fallback("http://snapshot.debian.org/archive/debian/20250101T000000Z")
.allow_stale_release(true)
.build()?;
Ok(())
}
The freshness posture is repository-wide, so a repository with a snapshot
backstop sets allow_stale_release, the snapshot’s release being expired by
design; the signature is still verified. The backstop is a fetch-time concern
only — the finished rootfs’s sources.list names the live mirror, not the
snapshot.
Multiplying repositories does not dilute trust. Every authenticity check — the
signature against the repository’s keyring, the release bound to its suite, the
transport trust_unsigned requires — simply runs once per repository.
The deb-multirepo example provisions a root from the archive mirror plus a
local trusted pool, auditing the repository builder end to end.
Publishing a local pool
Pool produces the local trusted pool the previous section consumes: it
writes or updates a dists/-structured repository from a set of .debs, so a
pipeline that builds packages can feed each one into the resolution of the next.
use ferroday_cage::provision::debian::Pool;
fn main() -> Result<(), Box<dyn std::error::Error>> {
Pool::at("/srv/my-debs")
.suite("trixie")
.publish(["build/my-package_1.0_amd64.deb"])?;
Ok(())
}
The component defaults to main and the architecture to the host’s. Both have
setters, and both select what the call writes rather than what the suite offers;
the next section covers a suite that holds more than one of either. publish
takes anything that iterates paths, and may be called repeatedly on the same
Pool — each call merges into what the pool already indexes.
The call reads each .deb’s control stanza, copies it into the pool under
pool/, and regenerates the component’s Packages index (with its .gz) and
the suite’s Release. It is idempotent and incremental: the pool directory is
updated in place, and the index keeps the highest version of each package name —
the same rule resolution applies — so republishing over a superset of a prior
run is well-defined.
Publishing an empty set writes a valid, empty repository: the Release still
declares the architecture and component, so the pool can be created before the
first .deb exists and referenced from the start, resolving against an empty
package set rather than a missing repository. The pool is trusted, not signed —
a file:// repository under the caller’s control, apt’s [trusted=yes] — so no
OpenPGP signature is written and none is expected.
A suite accumulates
A suite holds as many components and architectures as are published into it, and
its Release describes all of them. Each publish rescans dists/<suite> for the
indexes it holds and rewrites the release from what it finds, so publishing a
second architecture leaves the first reachable, and a component published beside
another does not retire it.
use ferroday_cage::provision::debian::Pool;
fn main() -> Result<(), Box<dyn std::error::Error>> {
Pool::at("build/pool")
.suite("trixie")
.architecture("amd64")
.publish(["build/tool_1.0_amd64.deb"])?;
Pool::at("build/pool")
.suite("trixie")
.architecture("arm64")
.publish(["build/tool_1.0_arm64.deb"])?;
Ok(())
}
The release the second call writes offers both, and names the indexes of both:
Architectures: amd64 arm64
Components: main
This matters more than it looks. A reader consults the release before it knows
which digests to ask for, and verifies every index it fetches against a digest
the release records, so a section the release does not name cannot be resolved
however intact it is on disk — and the by-hash copies are no way around that,
for the same reason. A release describing only the publish that wrote it would
make each publish retire the last.
The scan costs the digest of every index in the suite rather than only the one just written. Indexes are small beside the packages they describe, and the scan runs under the publish lock along with the rest of the call, so a pipeline publishing components in parallel serializes on it exactly as it already does.
A component may be laid out as a subtree, which is how main/debian-installer
sits in the archive. It records in Components under its full path, since that
is the name a reader has to ask for to reach it.
The suite is a path too — Debian’s security archive published buster/updates
for years — so both accept an interior slash. What they refuse is a value that
would resolve somewhere other than where it says: a leading /, a .. segment,
an empty or . segment. An architecture is always a single directory name and
takes no slash at all. All three also refuse whitespace and control characters,
which a release field has no way to name: Architectures and Components are
whitespace-delimited lists, so a component with a space in it would read back as
two components, neither of them the one published. Each refusal is a
configuration error naming the field and the value.
The same rule holds on the reading side. A Repository’s suite and components
and a bootstrap’s architecture become path segments of every URL the archive is
fetched through and words of the deb line written into the finished rootfs, so
each is checked when the repository or the builder is built: a .. segment
would climb out of the mirror root, which for a file:// mirror is a local
read, and a control character would split the request line the value is
interpolated into.
What a published .deb must declare
publish derives the path a package is stored at, and the Filename the index
records, from the package’s own control stanza. Two things follow.
Package and Version are held to the alphabet Debian policy gives them — a
name of lowercase letters, digits, +, -, and ., at least two characters and
starting with a letter or a digit; a version of alphanumerics and ., +, -,
:, ~. Neither alphabet contains a separator, so the derived path stays where
the pool put it: without the check a package naming itself ../../.. would
create directories and write a file outside the pool root, as the publishing
user.
The control file must be one paragraph and must not itself carry Filename,
Size, or SHA256. A pool entry is the control file verbatim with those three
appended, and a deb822 reader resolves a field by its first occurrence, so a
package carrying one would shadow the archive’s value — a digest it chose rather
than one taken from the bytes on disk, or a Filename naming another package’s
file. A second paragraph does the same by another route: the appended fields
land on the last paragraph, which is then the one that reads back as the entry,
under whatever name and version it declares. A pool is consumed as
[trusted=yes] and resolution is highest-version-wins across repositories, so an
entry a package wrote for itself would supersede the primary archive’s.
Both are DebianError::Deb, naming the file and what is wrong with it. No
conforming .deb is affected: dpkg-deb builds a one-paragraph control file,
and the three appended names are archive fields that a control file has no
reason to carry.
The pool’s own layout
A pool reports the paths it owns, so nothing outside it needs to know how an
archive is laid out. Pool::mirror_url is the file:// URL that declares the
pool as a Repository; Pool::release_path and Pool::dists_dir are the
suite’s Release and the directory it sits in. A relative pool root is made
absolute for the URL, and none of the three requires the pool to exist, so a
pipeline can wire itself together before its first publish.
use ferroday_cage::provision::debian::{Pool, Repository};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let pool = Pool::at("build/pool").suite("trixie");
pool.publish(["build/my-package_1.0_amd64.deb"])?;
let repository = Repository::builder("trixie")
.mirror(pool.mirror_url()?)
.trust_unsigned(true)
.name("build-pool")
.build()?;
let _ = repository;
Ok(())
}
Dating a release
The Release carries Date, the required field naming when the pool was
published. A client reads it to tell how old a snapshot is and to check its own
clock against the archive’s; one that finds no Date reports the release as
malformed rather than as merely undated.
The release carries no Valid-Until, the separate and optional field, so it
never expires. A pool stays usable for as long as its packages are wanted,
however long after the build that wrote it that is. Dating a release does not
bound its freshness — expiry is enforced on Valid-Until alone.
By default the field names the time of the publish, which is what makes the
release the one part of a pool that varies between two publishes of the same
packages: the indexes are a function of the package set, and the release is a
function of the indexes and the date. Pool::date pins it, making a publish
byte-reproducible.
use ferroday_cage::provision::debian::Pool;
fn main() -> Result<(), Box<dyn std::error::Error>> {
Pool::at("build/pool")
.suite("trixie")
// The same timestamp a reproducible build pins everything else to.
.date(1_700_000_000)
.publish(["build/my-package_1.0_amd64.deb"])?;
Ok(())
}
The pinned value is a plain Unix timestamp rather than something read from the
environment, so a pipeline that already pins a build date pins the pool’s with
the same number it gives Export::clamp_mtime.
Naming a pool
A release may carry Origin, Label, and Description, which name who
produced the archive, what it is, and what it holds. None has a default and each
is emitted only when set, so a pool that names none of them is a valid archive —
inventing an origin would put a name in an archive its owner did not choose.
use ferroday_cage::provision::debian::Pool;
fn main() -> Result<(), Box<dyn std::error::Error>> {
Pool::at("build/pool")
.suite("trixie")
.origin("Example")
.label("Example build pool")
.description("Packages built by the example pipeline")
.publish(["build/my-package_1.0_amd64.deb"])?;
Ok(())
}
They are informational: nothing resolves against them. What they buy is how a
client presents the archive — apt policy renders a repository under its origin
and label, and Pin: release o=Example is the pinning form apt’s own
documentation leads with. Without them, a pool is still pinned by suite or
codename.
The three are free text and take spaces and punctuation. What they cannot carry
is a control character: a release field ends at a newline, so a value holding one
would continue into the release as further fields. A value that is empty is
refused as well, the way to have no Origin being not to set one.
Signing a pool
A pool that leaves the machine that built it is signed by its owner:
release_path names the document to sign and dists_dir the directory the
signature belongs in. The signature must be an inline cleartext-signed
InRelease — the form the provisioner reads for a repository with a
keyring — since a detached Release.gpg is not fetched. A signed pool needs
no Valid-Until; a release that carries none is simply unbounded, as Debian
stable’s is.
Publishing removes any signature the pool already carries, both spellings, so a
signature made over a superseded Release can never outlive it. That matters
more than it looks: the by-hash index copies are immutable and never removed, so
a stale-but-valid signature would not fail a reader — it would quietly resolve
the older package set. Leaving the pool unsigned between a publish and its
signature fails closed instead, and it means a pool is signed after every
publish rather than once.
What publishing costs
Publishing never holds a package in memory. A .deb’s control stanza is read by
seeking to the control member of its ar container, its digest is taken in
chunks, and its payload is copied without being buffered, so publishing a 90 MB
kernel package costs what publishing a shell script costs.
Where the build tree and the pool sit on one filesystem that can share extents — btrfs, XFS formatted with reflink, bcachefs — the payload is cloned rather than copied. The clone is a metadata operation: no time proportional to the package, and no additional disk. Everywhere else, including ext4 and any pool on a different filesystem from the build tree, the bytes are streamed through instead. The two produce the same pool.
The copy is a clone and never a hard link, and the difference is what a reader
sees. A hard link would alias the caller’s file, so rebuilding a package in place
would rewrite the pool’s copy along with it, and a reader partway through
resolving that package would fail its digest check against a Release that
described the older bytes. A clone shares storage and nothing else, so a
published file is fixed once published.
The index describes the pool’s own copy, not the file the caller offered. Both
the control stanza and the digest are read back from the published bytes, so a
recorded SHA256 always names content the pool holds. Republishing a .deb the
pool already has compares digests and writes nothing, leaving a warm pool
untouched.
Publishing in parallel
A pipeline that builds components in parallel can publish each one the moment it finishes, and resolve against the pool while other components are still publishing into it. Neither needs coordination of its own.
Publishes exclude each other with a lock on .lock in the pool root, held for
the whole call. The index is rebuilt by merging into what the pool already
holds, so without that lock two publishes would each start from the same prior
index and the later would drop the earlier’s packages.
Readers take no lock, and cannot: a reader reaches the pool through the fetch
transport as an ordinary repository and does not know a pool is what it is
reading. Instead a publish becomes visible all at once. Every file is written
atomically; the indexes are additionally written under by-hash/SHA256/<digest>,
where they are immutable and are never rewritten or removed; and the Release
that names those digests is written last. A bootstrap that has read a Release
therefore keeps resolving the indexes that Release described, however many
publishes land while it works.
One case falls outside this. Republishing a package at a version the pool already holds, with different bytes, writes to the path the older bytes occupy, because the archive layout stores both there. A reader partway through resolving the older bytes fails its digest check. In a build pipeline that means two components produced the same package version. Republishing identical bytes is the ordinary incremental case and rewrites nothing.
The resolved plan
resolve reports what a bootstrap would install without installing it. It
runs the read half of the pipeline — the signed release and the package index,
then dependency resolution — and returns the exact package set, each with its
version, architecture, and archive-verified SHA-256, downloading nothing:
use ferroday_cage::provision::debian::Debian;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut debian = Debian::builder("trixie").include(["build-essential"]).build()?;
let plan = debian.resolve()?;
for package in &plan.packages {
println!("{} {} {}", package.name, package.version, package.sha256);
}
Ok(())
}
The plan is the same closure a full bootstrap installs — both resolve through
one code path — so it is a faithful preview and a stable key for a
content-addressed build cache. Every digest chains back to the release
signature, so the key is anchored to the archive rather than to a mirror’s word.
Because it runs nothing, resolve needs neither a qemu-user binfmt handler
nor an establishable identity map, and so serves an architecture the host cannot
execute.
A full bootstrap reports the same plan inline: it emits DebianEvent::Resolved
once the closure is resolved and before the first download, carrying the plan it
is about to install. A progress sink can read it there, keying a
build cache on what the running bootstrap actually resolved without a separate
resolve pass.
The archive state a plan resolved against
A package set says what was selected, not what it was selected from, and the
same suite resolves to different versions a week apart. plan.archives records
the state each repository was in, one entry per configured repository with the
primary first:
use ferroday_cage::provision::debian::Debian;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut debian = Debian::builder("trixie").build()?;
let plan = debian.resolve()?;
for archive in &plan.archives {
println!(
"{} {} released {} (verified by {})",
archive.mirror,
archive.suite,
archive.date.as_deref().unwrap_or("an unstated date"),
archive.signed_by.join(", "),
);
}
// Which archive answered for a package when the plan was resolved.
for package in &plan.packages {
println!("{} <- {}", package.name, plan.archives[package.archive].mirror);
}
Ok(())
}
Three details are worth stating.
The mirror recorded is the URL that answered, not the configured list. A
repository with a mirror_fallback snapshot backstop resolves against
whichever URL served, and recording the list would describe a choice rather than
the choice made.
The release digest is of the body that was verified — for a signed
repository, the cleartext the signature covers, not the InRelease armor around
it and not a re-fetch. So it identifies the exact archive state the signature
vouched for.
Two fingerprints record who vouched for it, both in the uppercase hex the
OpenPGP tools print, and both empty for a repository configured as
trust_unsigned — where nothing was verified, which is a fact worth recording
rather than an omission. signed_by is the certificate’s primary-key
fingerprint: the identity a keyring entry is named by, and the one to pin a
repository against. signing_key is the component key that actually made the
signature. Debian’s archive keys sign with a dedicated signing subkey, so the
two normally differ, and only signed_by stays put when an archive rotates that
subkey.
package.archive indexes into plan.archives. Resolution is
highest-version-wins across the merged repositories, so with more than one
configured this is the only thing that says where a package will actually come
from.
Installing a plan without resolving again
plan hands a previously resolved Plan back to the bootstrap, which then
fetches exactly the packages it records, by the digests it records, and never
touches a release or a package index:
use ferroday_cage::provision::{self, debian::Debian};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Once, when the build is first cut.
let plan = Debian::builder("trixie").include(["build-essential"]).build()?.resolve()?;
let stored = plan.clone();
// Later, to reproduce it exactly.
let mut debian = Debian::builder("trixie").plan(stored).build()?;
provision::ensure("/srv/images/replay", &mut debian)?;
Ok(())
}
Ordinarily resolve and a bootstrap each run a full resolution, so a pipeline
that resolves once to key its cache and then provisions resolves twice — and has
to reconcile the two when the archive publishes in between. Pinning removes both
the second resolution and the divergence, along with the roughly 9 MB index
download that dominates it.
The trust model changes
Skipping the release and the index is the win, and it means the package digests
no longer chain to an archive signature at install time. The plan becomes the
trust anchor. It was archive-verified when it was produced, and installing
from it asserts that whoever kept it kept it intact. Each .deb is still
verified against the digest the plan records, so a tampered mirror is still
caught; what is no longer checked is that the plan still describes what the
archive says.
That is exactly what a reproduce mode wants and exactly what an ordinary build should not take, so pinning is opt-in by construction: a builder that sets no plan resolves as it always has, chaining every digest to a repository signature at install time. Nothing about an ordinary bootstrap changes.
What a plan pins, and what it does not
A plan describes a resolution that already happened, so anything that would
shape a resolution contradicts it and is refused at build with
DebianError::Config rather than given a silent precedence: include,
exclude, and base_priority. A plan whose suite or architecture disagrees
with the builder’s is refused the same way, as is one naming more archives than
there are repositories to fetch them from. Where the builder names no
architecture the plan’s is adopted, since a plan states which architecture it is
for.
Everything that shapes how rather than what still applies: cache_dir,
identity_map, pre_configure_overlay, the repositories, and the fetcher. A
package is fetched from the repository its archive index names, so the same
plan can be replayed against a snapshot mirror by configuring one.
A pinned install against an archive that has moved on installs the pinned
versions, which is the point. If a .deb the plan names is no longer on the
mirror, the failure names the package and its digest rather than reporting a
resolution that found nothing — the plan is intact, and the archive no longer
holds what it names.
Keeping a plan
Handing a Plan straight back to plan covers one process: resolve once,
install once, no second resolution and no divergence if the archive publishes in
between. A reproduce mode resolves on Monday and replays in a month, and for
that the plan has to leave the process. to_document and parse_document are
that form:
use ferroday_cage::provision::debian::{Debian, Plan};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Resolve now, and keep what was resolved.
let plan = Debian::builder("trixie").build()?.resolve()?;
std::fs::write("trixie.plan", plan.to_document()?)?;
// Replay later, against a snapshot mirror.
let kept = Plan::parse_document(&std::fs::read_to_string("trixie.plan")?)?;
let mut debian = Debian::builder("trixie")
.mirror("http://snapshot.debian.org/archive/debian/20260802T000000Z")
.plan(kept)
.build()?;
let _ = &mut debian;
Ok(())
}
The document is deb822 — the archive’s own control format, the one a Release
and a Packages index are written in — so it reads in a terminal and diffs in a
review:
Format: ferroday-cage-plan 2
Suite: trixie
Architecture: arm64
Archive: 0
Mirror: http://deb.debian.org/debian
Suite: trixie
Components: main
Release-SHA256: 9f3a9f3a9f3a9f3a9f3a9f3a9f3a9f3a9f3a9f3a9f3a9f3a9f3a9f3a9f3a9f3a
Date: Sat, 02 Aug 2026 08:14:33 UTC
Valid-Until: Sat, 09 Aug 2026 08:14:33 UTC
Signed-By: 04B54C3CDCA79751B16BC6B5225629DF75B188BD
Signing-Key: B8E5F13176D2A7A75220028078DBA3BC47EF2265
Archive: 1
Mirror: file:///srv/pool
Suite: trixie
Components: main
Release-SHA256: 1c0f1c0f1c0f1c0f1c0f1c0f1c0f1c0f1c0f1c0f1c0f1c0f1c0f1c0f1c0f1c0f
Date: Sat, 02 Aug 2026 09:02:11 UTC
Signed-By:
Signing-Key:
Package: base-files
Version: 13.6
Architecture: arm64
SHA256: 7b217b217b217b217b217b217b217b217b217b217b217b217b217b217b217b21
Filename: pool/main/b/base-files/base-files_13.6_arm64.deb
Archive: 0
Installed-Size: 340
Package: local-tool
Version: 2.4-1
Architecture: all
SHA256: e0d5e0d5e0d5e0d5e0d5e0d5e0d5e0d5e0d5e0d5e0d5e0d5e0d5e0d5e0d5e0d5
Filename: pool/main/l/local-tool/local-tool_2.4-1_all.deb
Archive: 1
Source: local-suite
Archive 0 names both the archive certificate and the signing subkey that made
the signature. Archive 1 is a locally published
pool configured with trust_unsigned, so nothing
verified its release and both fields are empty — written bare, with nothing
after the colon, because a line ending in whitespace would not survive the
editors and hooks that strip it. It carries no
Valid-Until either, an absent optional field being absent rather than empty.
local-tool names Archive: 1, which is what says it will be fetched from the
pool rather than from the Debian mirror.
Installed-Size and Source are optional, and each stanza above carries one of
them to show it. Installed-Size is the archive’s own figure, in the kibibytes
Debian Policy
5.6.20
defines it in rather than normalized to bytes, so it can be compared against a
mirror’s value directly. It is an estimate: the package’s build produces it from
its staged tree, so it neither measures an installed filesystem nor accounts for
the target’s block size, and a total over a plan is best presented as the
archive’s accounting rather than as what an image will weigh. Source names the
source package a binary package was built from, with any parenthesized source
version dropped, and is absent for the common case of a source that shares the
binary package’s name — which is how the archive itself says it. Reading it that
way is what lets several binary packages be attributed to the one source that
produced them.
A plan the library resolved never states a Source equal to the package’s own
name. A plan read back from a document states whatever the document does: the
reader takes the field verbatim, so a hand-written stanza naming a source that
matches reads that way and is written back unchanged. Attributing a plan by
source is therefore a question of the source name where there is one and the
package’s own name where there is not, not of whether the field is present.
Both of a plan’s lists are already ordered, so two renderings of one plan are byte-identical and a plan that changes shows the change as a diff rather than as a different blob.
A field a reader does not know is carried rather than refused, so a document
written by a later version of the library still reads here as long as the
Format version is unchanged — that version is bumped only for a change an
older reader would misread. Adding a field is not one.
A field that keeps its name and changes its meaning is, and version 2 is what
one of them required. In version 1, Signed-By named the key that made the
release signature; in version 2 it names the verifying certificate, and the
signing key has a field of its own. A version 1 document is therefore refused
rather than read: the fingerprint under Signed-By answers a different
question, and a consumer comparing it against a recorded one would read the
difference as the archive having rotated its key. Re-resolving against the same
archive writes a version 2 document.
Carried on the way in does not mean dropped on the way out. A field this version does not know is carried with its stanza and re-emitted after the ones it does, so reading a plan and writing it back preserves every field. That matters because re-emitting a plan is a thing a consumer does — into a provenance record, or to normalize a file it commits — and a round trip that quietly discarded whatever a newer writer had added would corrupt exactly the record it was kept for.
Within one version a round trip is byte-identical, carried fields included, so
re-emitting does not churn a committed file. Between two versions of this
library that share a Format version it is byte-identical too, in both
directions: a document the newer writes reads and re-writes unchanged through
the older, because a field added to the format is written after every field the
older readers knew — which is exactly where those readers put a field they do
not know back. Installed-Size and Source sit after Archive for that reason
rather than beside the fields they describe.
What no round trip can promise is byte identity across writers: another implementation of this format may interleave its fields with the known ones rather than append them, and a reader cannot know where they belong. If you need bytes that are stable against anything, digest the document you were given rather than a re-render of it.
No line the format emits ends in whitespace, so a plan survives the editors and
pre-commit hooks that strip it. An empty field — Signed-By for a repository
trusted unsigned, Components for none — is written bare, as Signed-By: with
nothing after the colon.
What is refused is anything that would read back as a different plan: a package naming an archive the document does not carry, archive stanzas numbered out of order, a missing required field, and, on the writing side, a value the format cannot carry on one line or one whose own leading or trailing whitespace a reader would strip.
Two fields are also held to their shape, because they are what a plan install
composes rather than merely reports. A package’s Filename is interpolated
verbatim into the URL its .deb is fetched from, so it is held to the same rule
an archive index’s is: a relative path, no .. component, printable ASCII only,
as written and as a server that percent-decodes it would read it. And its
SHA256 must be 64 lowercase hex characters, since that is the value the fetched
bytes are compared against. A document is a trust anchor, so neither is a
defense against the plan itself — it is what keeps a plan that was edited by hand
into something unfetchable from being reported as a mirror that served the wrong
bytes. Both are checked again at build, since a Plan taken from resolve
and modified in memory reaches the bootstrap without passing a reader.
Holding a resolution to a plan’s versions
pin takes the same Plan and does something else with it. Where plan
replaces a resolution, pin constrains one: the bootstrap fetches and
verifies every release and index as it always does and computes the closure over
what the archives offer now, and each package the pin names is selected at the
pinned version rather than at the highest offered.
use ferroday_cage::provision::debian::{Debian, Plan, Repository};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// The committed document, covering the archives and nothing else.
let kept = Plan::parse_document(&std::fs::read_to_string("archives.plan")?)?;
let mut debian = Debian::builder("trixie")
.repository(Repository::builder("trixie")
.mirror("file:///srv/pool")
.trust_unsigned(true)
.build()?)
.include(["build-essential", "kernel-image"])
.pin(kept)
.build()?;
let _ = &mut debian;
Ok(())
}
When this is the one that fits
A build whose inputs are not all archives. Where some of the packages are
compiled by the build itself and published to a local pool,
plan requires those compiles to be byte-reproducible: it installs every
package by a recorded digest, and a compile that differs by a timestamp no
longer matches its own plan. Where the compile is not reproducible, replaying
becomes unavailable for exactly the builds that most want it, for a reason that
has nothing to do with the archive.
Pinning only the archive-sourced packages splits the two apart. The document fixes the half a mirror controls — the versions it served, which is what a plan exists to record and what a source-level lock cannot pin — and the locally built half resolves at whatever the pool now holds, its digest read from the pool’s own index as any other package’s is.
The trust model does not change
Nothing here leaves the archive signature chain. Every digest installed is still
read from an index whose own digest a verified release records, so a pinned
bootstrap has the trust properties of an ordinary one — which is the difference
from plan, where the plan becomes the anchor. The pin narrows which version is
selected; it never becomes the authority for what the bytes are.
A pin whose recorded digest disagrees with the archive’s for the same version is refused rather than preferred. That is one version published twice over different bytes, which is the event a recorded digest exists to catch.
What composes, and what is refused
include, exclude, and base_priority all compose with a pin, where a plan
refuses them. That is the whole of what separates the two: a plan names every
package to install, so a selection has nothing to add to it, while a pin names
the versions a selection resolves to and says nothing about what is selected.
The repository list is free to differ from the one the pin was resolved against,
and the pin’s own archive records are not read — a pin constrains packages.
A pin alongside a plan is refused at build with DebianError::Config: a
plan resolves nothing, so there is no resolution left to constrain. So is a pin
whose suite or architecture disagrees with the builder’s, and one naming the
same package twice, which states two versions to hold it at. So is one whose
entries fail the shape a plan document’s do — a digest that is not 64 lowercase
hex characters is refused as the malformed entry it is, rather than compared and
reported as an archive that had published past the pin. Where the builder names
no architecture the pin’s is adopted, as a plan’s is.
When the archives have moved past it
A pin the archives cannot supply fails the resolve or the bootstrap with
DebianError::Pin, before the closure is computed and before anything is
downloaded. It names every package that could not be held at once, in name
order, since one archive publish moves many together — and for each of them
which of the three happened: the archives offer another version, offer no
version of it at all, or record different bytes for the one pinned.
A live mirror serving a single version per suite will move past a pin as soon as it publishes. A snapshot mirror is what holds one open, and it is the same configuration a replayed plan uses.
Asking what an archive carries
available answers a different question: not what a bootstrap would install,
but which names the configured archives offer at all. It runs the same read half
— each repository’s signed release and index, merged — and returns the name set
rather than a closure:
use ferroday_cage::provision::debian::Debian;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut debian = Debian::builder("trixie").build()?;
let available = debian.available()?;
for name in ["build-essential", "awk", "no-such-package"] {
println!("{name}: {}", available.contains(name));
}
// A virtual name reports what satisfies it.
for provider in available.providers("awk") {
println!("awk is provided by {provider}");
}
Ok(())
}
resolve cannot stand in for this, and neither of its behaviours is a defect.
A resolve fails where a name cannot be satisfied — a top-level include that
names nothing, or a transitive dependency nothing supplies — rather than
answering a question about it. It does name every one of them rather than the
first, so a failed resolve is a usable list of what to correct; but it is still
a failure, and it is a failure about a whole closure. A name that is perfectly
available can appear in one because something else in its dependency tree is
not. Nor is the cost the same: one available pass answers any number of names,
where a resolve per name re-fetches and re-parses the index every time — a
Packages.xz for trixie amd64 is around 9 MB, and cache_dir caches .deb
files, not indexes.
Availability is answered at the level of the name: contains reports that the
archives offer a name, not that they offer a version satisfying some
constraint. The resolver does enforce a dependency’s version constraint — it
has to, since a layered resolve closes over a base provisioned at some earlier
archive state — but that is a question about a particular closure, and this
query computes none. Answering it here would mean resolving, which is the cost
available exists to avoid.
The builder’s include, exclude, and base_priority shape a resolution and
so do not apply; the suite, the architecture, the repositories, and the fetcher
are what the query reads. Available is a snapshot: an archive can publish at
any time, so a name it reports is a name that was there when the index was read.
Layered build roots
A pipeline that builds many packages against one shared base — a build root of
base + toolchain + this component's build-dependencies, rebuilt per component —
need not re-provision the shared part each time. Provision the base once, then
stage each component’s increment over it as a disposable overlay layer:
base_layer marks the pristine base, and stage_layer resolves and installs
only the packages the base does not already carry into an overlay upper, leaving
the base untouched.
use ferroday_cage::Cage;
use ferroday_cage::provision::{self, debian::Debian};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Provision the shared base once: system plus build toolchain.
let base = "/var/cache/build/base-trixie";
provision::ensure(
base,
&mut Debian::builder("trixie").include(["build-essential"]).build()?,
)?;
// Per component, stage only its build-dependencies over the base.
let layer = Debian::builder("trixie")
.base_layer(base)
.include(["libssl-dev", "pkg-config"])
.build()?
.stage_layer("/var/cache/build/component-upper")?;
// Build against the merged base-plus-increment view.
let status = Cage::builder()
.overlay_rootfs(base, layer.path())
.command("/usr/bin/dpkg-buildpackage")
.build()?
.run()?;
let _ = status;
// Dropping the layer discards the increment; the base stays pristine.
drop(layer);
Ok(())
}
The increment resolves against the base’s already-installed set, read from the
base’s own dpkg status database: every package the base configured, and the
virtual packages they provide, is treated as satisfied, so the resolution closes
over only the delta. Because most components share build-dependencies already
present in base + toolchain, that delta is small — which is the point.
resolve_layer previews it without staging, the layered counterpart of
resolve, so a build-root cache can be keyed on the increment inline.
A plan reproduces a layer exactly as it reproduces a whole root. Keep the one
resolve_layer returned, hand it back through plan alongside the same
base_layer, and stage_layer installs those packages at those versions
without fetching a release or an index — the increment’s counterpart of the
pinned install below.
stage_layer installs the delta into the overlay upper and returns a
BuildLayer. Root a cage on overlay_rootfs with the base as the read-only
lower and the layer’s path() as the writable upper, and the build sees the
merged view; every write lands in the upper, and the base is never mutated.
Dropping the BuildLayer removes the upper — and the overlay work directory
beside it — reverting the increment and leaving the base ready for the next
component. A stage_layer that fails disposes of them itself, since it hands the
caller no handle to drop, so a failed staging leaves nothing behind either.
The base must be a full, configured bootstrap, not an
extract_only tree: the overlay’s
lower supplies the dpkg database the increment’s configuration reads. A base and
its layers must share a suite, an architecture, and an identity
map — a base configured under one map carries ownership a
layer under another would not agree with. The increment’s configuration runs the
target’s binaries, so a foreign architecture needs the same qemu-user binfmt
handler a full bootstrap does. Staging roots a cage on an unprivileged overlay,
which the host must support; see overlay-rooted
cages, reported by
host::overlay_blocker. stage_layer refuses a host that cannot establish one
before downloading anything.
The pre-configure overlay
pre_configure_overlay lays a directory of configuration into the rootfs
after the packages are unpacked and before their maintainer scripts run, so a
script observes the injected values — a debconf pre-seed, a locale selection, a
hardware-probe guard:
use ferroday_cage::provision::{self, debian::Debian};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut debian = Debian::builder("trixie")
.pre_configure_overlay("./rootfs-config")
.build()?;
provision::ensure("/var/lib/machines/trixie", &mut debian)?;
Ok(())
}
The source is a rootfs-shaped tree: ./rootfs-config/etc/locale.gen becomes
/etc/locale.gen. File modes and symlinks are preserved, and a file replaces
the package’s own copy of the same path, which is how a shipped conffile is
overridden. The tree is laid with the same kernel-enforced containment as
package extraction, so nothing in it can be written outside the rootfs. The
overlay applies only to a full bootstrap; because the base system is configured
first, it governs the configuration of the non-essential packages.
Entries land owned by the calling user, which the identity map presents as root
inside. A tree that has to ship a file owned by some other in-rootfs id wants
CopyIn instead, which lays one
in through the map.
Trust
Authenticity comes from the archive’s OpenPGP signature, not the transport, so
packages are fetched over plain HTTP by default. The InRelease signature is
verified against an embedded copy of the Debian archive keyring; every index
and every package is then checked against a SHA-256 digest that chains back to
that signature. A different keyring is supplied with
keyring, and
trust_unsigned accepts an unsigned Release
for a local mirror under the caller’s control — the equivalent of apt’s
trusted=yes. It removes the only authenticity check, leaving the transport to
supply one, so build requires a transport that does: every mirror of an
unsigned repository must be file:// or https://. Any other scheme is
refused, http:// among them. The rule is an allow-list rather than a refusal
of http:// because a refusal names only the plaintext scheme it was written
for, and a caller-supplied transport can speak any of them; schemes are
compared case-insensitively, as RFC 3986 §3.1 defines them. A transport
routing on the scheme itself reads it from FetchRequest::scheme, which
applies the same rule, so the two layers cannot disagree about which URLs name
which transport.
The signature alone does not say which suite was delivered — a single archive
key signs every suite — so the release is also bound to the request: its
Suite or Codename must equal the requested suite, which stops a mirror
from answering a request for one suite with a genuine, validly-signed release
for another.
Key validity is enforced beyond the cryptographic check, which on its own accepts a signature from any key whose material fits. A revoked archive key is dropped from the keyring, and a signature made outside its signing key’s validity window — before the key existed, or after it expired — is rejected, so a retired key cannot vouch for a release. Debian’s archive keys sign with a dedicated signing subkey rather than the primary, and a subkey is admitted only on the terms OpenPGP sets for one: the primary must have bound it with the signing capability, the subkey must have counter-signed that binding, and the binding must not be revoked. Where a subkey has been re-bound over time, the newest binding the primary issued is the one judged: it is the certificate’s latest word on that subkey, so a defect in it refuses the subkey rather than returning authority to the superseded, possibly broader grant it replaced. A subkey’s authority is delegated from its primary, so it lapses when the primary’s does — the expiry that governs is whichever of the certificate’s, the binding’s and the counter-signature’s comes first. A keyring holding several keys is searched in full, and every signature a key made is judged, so a release is accepted whenever any of its signatures is acceptable.
Beyond the key, the signature itself has to be worth resting a decision on. It carries a creation time, every check being relative to it. Its digest is not MD5, SHA-1 or RIPEMD-160, whose collisions are within reach, and neither the signing key nor the certificate behind it is DSA or an RSA key narrower than 2048 bits: a signature is worth no more than the weaker of the digest it covers and the key material that binds it. And it carries no critical subpacket that cannot be interpreted, which RFC 9580 requires a verifier meeting one to treat as invalidating. Only the signed part of a signature is searched for such a subpacket, so a relay cannot append one in transit and fail every fetch.
The release must also be fresh. A Valid-Until the release carries must be
parseable and in the future, and the archive signature must not itself have
expired; this refuses a stale but validly-signed release, which a mirror or an
on-path attacker could otherwise replay to deliver superseded, potentially
vulnerable packages. A release that omits Valid-Until — as the Debian stable
suite does, so its release does not expire between point releases — carries no
freshness bound and is accepted, as apt accepts it.
allow_stale_release relaxes the freshness
check entirely — the signature is still verified — for the legitimate case of
pinning a historical archive state, such as a snapshot.debian.org suite. A
trust_unsigned mirror is never freshness-checked.
The transport is a trait. The built-in fetcher speaks plain HTTP and
file://, addressing a mirror by DNS name, IPv4 literal, or bracketed IPv6
literal (http://[::1]:8080/debian); a consumer that needs HTTPS, a proxy, or a
private mirror protocol supplies their own through
fetcher. A file:// URL names a path on the machine it is read on, in either
of RFC 8089’s spellings for that — file:///srv/pool and
file://localhost/srv/pool. One naming any other host is refused rather than
read as a path relative to the current directory. file_url builds the URL
for a local path, and is what Pool::mirror_url answers with: it makes a
relative path absolute, since such a URL carries a path from a root and has no
way to spell one that is not. A transport is Send, because the provisioner owns it and is
itself Send — see thread bounds.
The bundled fetcher holds its connection open between requests, as HTTP/1.1 does by default, so a bootstrap installing a few hundred packages from one host pays one handshake rather than a few hundred.
A bootstrap is nearly all network on a cold cache, so the packages it is about to
install are asked for in batches through Fetch::fetch_all before the
package-by-package wave runs. That method has a default body — one fetch per
job, in order — so a transport that overrides nothing behaves exactly as it did
before it existed. HttpFetch overrides it and runs four at once, each on a
connection of its own; HttpFetch::concurrency changes how many. The ceiling is
deliberately low: an archive mirror is usually somebody else’s bandwidth, and the
win is in overlapping latency rather than in saturating a link.
The batch decides nothing. A job it does not deliver simply leaves the cache without that package, and the wave behind it downloads that package with every mirror available to it, exactly as though the batch had not run. Whether a package is present is still the rename, and the rename happens only over bytes that verified.
A transport therefore sees calls from more than one thread only when it says so,
by overriding fetch_all; the default body, and every fetch, run on the thread
that called into the provisioner.
Ownership
The bootstrap’s identity map decides the ownership the finished rootfs carries, and the right map follows from what the rootfs is for.
The default is the single-identity map: the sandbox maps the calling user to root and nothing else, so every file in the bootstrapped rootfs is owned by root. Debian expects some files to belong to other system users and groups; those ownerships cannot be represented under this map, and the bootstrap reconciles the difference — recording the intended overrides in dpkg’s database and neutralizing the ownership changes that would otherwise fail — so configuration completes and the rootfs is internally consistent. The result is a working userland whose files are uniformly root-owned: exactly right for a build or run environment consumed back inside single-identity cages, which cannot observe the flattening, and whose caller-owned output needs no extra host support to copy or delete.
The overrides are recorded one per path. dpkg treats two records for one path as
an unrecoverable fatal error, and an install set reaches that honestly: two
selected packages can ship one path, since the resolver reads Depends and
Provides and never Conflicts. Where that happens the last record wins, which
is the extraction’s own rule — a later entry supersedes an earlier one — so the
record describes the file that survived. A layered build lays the increment’s
records over the base’s the same way.
A rootfs that is itself the product — deployed, exported, or run under a
range map — wants real ownership, and
identity_map
selects it:
#[cfg(all(feature = "debian", feature = "subid"))]
fn main() -> Result<(), Box<dyn std::error::Error>> {
use ferroday_cage::IdentityMap;
use ferroday_cage::provision::{self, debian::Debian};
let mut debian = Debian::builder("trixie")
.identity_map(IdentityMap::Subordinate)
.build()?;
provision::ensure("/srv/images/trixie", &mut debian)?;
Ok(())
}
#[cfg(not(all(feature = "debian", feature = "subid")))]
fn main() {}
Under the subordinate map the system ids genuinely exist, every chown a
maintainer script or compiled tool performs happens for real, and no stub or
override is involved: the tree is a correct Debian system as dpkg and
base-passwd define one. The map requires the subid feature and a host
that can establish it, and a bootstrap that cannot fails before any download
rather than falling back to the flattened form. Because the ownership is
real, it reaches the host: a directory the system chowns to a non-root id
refuses the plain caller’s removal, and
provision::remove deletes the
produced rootfs either way, re-entering the map where ownership requires it.
Extract-only and foreign architectures
The bootstrap targets the host architecture by default; another is selected
with architecture. A full bootstrap for a
foreign architecture runs that architecture’s dpkg and maintainer scripts, and
so requires a registered qemu-user binfmt handler (see
Host requirements); the bootstrap checks for one and
reports an actionable error when it is absent.
Where the foreign binaries cannot run, extract_only lays out the packages’
files without configuring them. An extract-only rootfs has run no maintainer
scripts and has no configured dpkg database; it is the raw file tree, suitable
for completing the configuration elsewhere.
Recording which interpreter ran
A changed emulator silently changes compiled output, so a build that publishes
artifacts wants the interpreter in its signature. foreign_interpreter reports
it, read from the kernel’s own binfmt_misc registration:
#![allow(unused)]
fn main() {
use ferroday_cage::provision::debian::foreign_interpreter;
match foreign_interpreter("arm64") {
Some(interpreter) => println!(
"{} through {} (enabled: {}, flags: {})",
interpreter.name,
interpreter.path.display(),
interpreter.enabled,
interpreter.flags,
),
None => println!("nothing emulated: run natively, or no handler registered"),
}
}
The registration is the right source, and a PATH lookup is not. The kernel
reaches the interpreter through the registration, so the two can disagree — and
a build under a harness that strips PATH records no interpreter at all while
one is executing every target binary.
None is returned in three cases, each the honest answer rather than a failure:
the host runs the architecture natively, the architecture is not one a
qemu-user handler covers, or no handler is registered. A registered handler
that is switched off reports as present and not enabled, which is the difference
between “turn it on” and “install it”.
Two paths are reported because they are two facts. path is what the kernel
recorded; resolved is what it canonicalizes to. On the common Debian layout
the registration names a wrapper — /usr/libexec/qemu-binfmt/aarch64-binfmt-P —
that is a symlink to the real binary, and repointing that symlink changes the
interpreter without changing the registration.
Hash the path; do not probe the binary. qemu refuses to run under its binfmt wrapper name:
$ /usr/libexec/qemu-binfmt/aarch64-binfmt-P --version
qemu: /usr/libexec/qemu-binfmt/aarch64-binfmt-P has to be run using kernel
binfmt-misc subsystem
So the registered path is good for identity and not for interrogation. Hashing
it works unchanged — open follows the symlink, so no canonicalization is
needed — and is the better answer anyway: a version string is a claim the binary
makes about itself, and a digest is what it is. Only executing the interpreter
needs resolved.
The digest is the caller’s to take. The F flag means the kernel opened and
holds the interpreter at registration time, so the file at that path may have
been replaced since and a digest taken now may be of something that never ran.
The library reports the path and states the caveat rather than handing back a
digest that can be quietly wrong.
The command line
fcage --provision-debian SUITE bootstraps the --rootfs directory before
launching. Given without a command, it provisions and exits; the
already-provisioned case is fast, so the flag can stay in an invocation
permanently.
$ fcage --rootfs ./trixie --provision-debian trixie \
--debian-include git,build-essential --debian-cache ./deb-cache
$ fcage --rootfs ./trixie /usr/bin/dpkg --list
The --debian-arch, --debian-mirror, --debian-components,
--debian-include, --debian-extract-only, --debian-cache, and
--debian-keyring options map onto the builder. --debian-include and
--debian-components take comma-separated lists and may be repeated.
Building a Debian package
Building a Debian package from source wants a root filesystem with the build
toolchain, the package’s build dependencies, and the source tree — and it wants
none of that on the host. Those are a cage’s parts again: a provisioned root, a
read-only bind for the source, a read-write bind for the results. Put them
together and the Debian counterpart of the ebuild
sandbox falls out: a tool that bootstraps a Debian suite
with build tooling and runs dpkg-buildpackage inside a cage. The library ships
one as a worked example.
Where the ebuild sandbox provisions a Gentoo stage3 from a tarball, this example bootstraps a Debian suite from the archive through the Debian provisioner; it is that provisioner’s worked consumer, the way the ebuild sandbox is the tarball provisioner’s.
deb-build --rootfs ./trixie-build --cache ./deb-cache \
--source ./hello-2.10 --output ./out
The build posture
The example builds one CageBuilder and layers the build’s needs onto it, each
an ordinary builder call:
- The root is provisioned once, with the build environment baked in. The
bootstrap installs the generic Debian build toolchain and the source tree’s
own build dependencies, then
provision::ensurepublishes it. The first run pays for the bootstrap; every later run finds the root already published and skips it, so the--rootfsflag can stay in an invocation permanently. - The source is read-only.
--sourcebinds the unpacked source tree read-only at/src. The build copies it to a writable directory in the root and builds the copy, so the host’s tree keeps its original state —dpkg-buildpackagewrites build products throughout a tree it builds. - The output is read-write.
--outputbinds a host directory read-write at/out. Only the finished packages are copied there; the source copy and the intermediate build tree stay inside the cage and vanish with it. - The network is isolated. Because the build dependencies are baked into the
root, the build resolves nothing at build time and runs with a loopback-only
network — which a policy-conforming Debian build does not need anyway.
--networkshares the host network for a build that reaches for it; note that provisioning always fetches from the archive over the host network, since that is where the userland comes from.
With no trailing command the sandbox runs dpkg-buildpackage -us -uc -b over
the copied source and collects the built packages; with a command after -- it
runs that instead — a shell, for interactive packaging work in the same posture.
Build dependencies are discovered, then baked
A source package declares its build dependencies in debian/control. The
example reads them from the tree through
debian::build_depend_names, which parses the
Build-Depends, Build-Depends-Arch, and Build-Depends-Indep fields and
returns the package names, and folds them into the bootstrap’s install set
alongside the toolchain. --build-dep adds packages beyond the declared set.
Discovering build dependencies this way, and baking them into the root, keeps to the provisioner’s model: it speaks to the archive directly and resolves the whole install set — toolchain, declared build dependencies, and their closure — with its own resolver. The build that follows runs offline, entirely from the build environment installed in the root.
The alternative, running apt-get build-dep inside the cage, would need a
deb-src line in the root’s sources.list and an apt-get update before it
could resolve anything, and would move dependency resolution to build time over
the network. The bootstrap writes only a binary package line, so that path is
deliberately not the one the example takes.
Because the dependencies are baked into the root, changing them means
provisioning a fresh --rootfs: an existing root is reused as it stands, with
whatever build environment the first run installed. dpkg-buildpackage reports
an unmet build dependency by name, so the loop when one is missing is to add a
--build-dep and provision a new root.
Why no hardening
The example applies no hardening layer. A package build compiles code, writes across a tree, and runs helper processes; the boundary it needs is the cage’s namespaces and private root, not a syscall filter layered over the top.
The identity map
The cage maps the calling user to root and nothing else, and a Debian source
build is content with that. A package’s file ownership is stored in the package
itself — in the archive’s data member — and applied by dpkg when the package
is installed, not by changing ownership on the build tree. So a build performs
no ownership change to a user the map does not contain, and it completes under
the single root identity. Modern packages go further: hello, like a growing
share of the archive, declares Rules-Requires-Root: no, so
dpkg-buildpackage builds it with no privilege emulation at all, packaging
every file as root:root directly.
This is the contrast with the ebuild sandbox. A Gentoo emerge fails under the
same map because portage chowns its own state directories to a dedicated
portage uid, which a single-identity map cannot represent; the ebuild sandbox
works around it by running portage wholly as root. A Debian build needs no such
accommodation. Where a package does still require root during its packaging step
— the historical default — dpkg-buildpackage fakes it in userspace with
fakeroot, one of the baked-in tools, so even then no real ownership change
reaches the kernel.
Provisioning progress
The bootstrap is the heavy part of a first run: it fetches and verifies the
release, resolves the toolchain and build dependencies, downloads the packages,
and configures them. The example wires an observe sink to the provisioner and prints each event,
so the bootstrap reports what it is doing rather than sitting silent. The sink
attaches to the Debian provisioner, not to its builder, and yields a value
that is itself a Provisioner:
let mut sink = print_event;
provision::ensure(rootfs, &mut debian.observe(&mut sink))?;
That is the Debian-specific channel, carrying the events only a bootstrap has —
fetches, resolution, per-package downloads, and dpkg’s own output. The same
events also reach a Provision::observe
observer, wrapped as ProvisionEvent::Debian, for a consumer that wants one
adapter across every provisioner; and either way the run honours that observer’s
cancellation check between packages.
The sink is a DebianObserver, and a closure is one — which is why the example
passes a function. A caller that also wants to stop a bootstrap implements the
trait instead and answers its cancelled, which is consulted at the same
boundaries: a package, an extraction. That is the only way to cancel a
Debian::observe(...).resolve() or .available(), which run outside a
provisioning run and so have no Provision observer to ask.
impl DebianObserver for Progress {
fn progress(&mut self, event: DebianEvent<'_>) {
eprintln!("{event:?}");
}
fn cancelled(&mut self) -> bool {
self.stop.load(Ordering::Relaxed)
}
}
Running the example
The example lives at crates/ferroday-cage/examples/deb-build.rs. It bootstraps
through the Debian provisioner, so it builds with the debian feature:
cargo run --example deb-build --features debian -- --help
A complete invocation needs an unpacked Debian source tree. One comes from the
archive with dpkg-source, or with apt-get source on a Debian or derivative
host:
# Fetch and unpack a source package to build:
apt-get source hello # leaves ./hello-2.10/ (version may differ)
# Provision the build root and build the package into ./out:
cargo run --example deb-build --features debian -- \
--rootfs ./trixie-build --cache ./deb-cache \
--source ./hello-2.10 --output ./out
# A shell in the same posture, for interactive packaging work:
cargo run --example deb-build --features debian -- \
--rootfs ./trixie-build --source ./hello-2.10 --output ./out -- /bin/bash
The first run reports the bootstrap’s progress and prints each built package;
the built .deb lands in ./out. The command’s exit code becomes the
sandbox’s, so a build composes into scripts like any other tool.
A foreign-architecture test run
A sandbox whose root filesystem is built for an architecture the host cannot
execute turns that host into a test environment for the other one: build on
x86_64, run the suite in an arm64 userland, on the machine that is already
there. The kernel supplies the mechanism through binfmt_misc, which hands a
foreign binary to an interpreter — qemu-user — instead of refusing to execute
it. Everything else is a cage as usual.
The Debian provisioner takes an architecture, so the root comes from the archive the same way any other does. What is different is that every process the sandbox runs afterwards is emulated, including the ones the bootstrap itself runs. The library ships a worked example.
cross-arch-test --rootfs ./trixie-arm64 --cache ./deb-cache
It provisions an arm64 Debian root with a C toolchain, compiles a program inside it, and runs the program. The compiler, the linker, and the program they produce are all arm64 binaries on an x86_64 host.
What the host has to provide
A registered qemu-user handler for the target architecture, enabled, and
carrying the fix-binary (F) flag. On Debian and Ubuntu hosts that is the
qemu-user-static and binfmt-support packages; the registration then looks
like this:
$ cat /proc/sys/fs/binfmt_misc/qemu-aarch64
enabled
interpreter /usr/libexec/qemu-binfmt/aarch64-binfmt-P
flags: POF
offset 0
The F flag is the one that matters here, and it is why a foreign sandbox works
at all. Without it the kernel resolves the interpreter’s path at execution time,
inside whatever mount namespace and root the process has — and a cage has
pivoted into the target rootfs by then, where /usr/libexec/qemu-binfmt does not
exist. With F the kernel opens the interpreter at registration time and
holds it, so it keeps working wherever the process ends up. Nothing in the
library’s launch path touches the registration.
foreign_interpreter reports what is registered, so a consumer can check
before committing to a bootstrap and tell the three failures apart:
use ferroday_cage::provision::debian::foreign_interpreter;
fn main() {
match foreign_interpreter("arm64") {
None => println!("run natively, or no handler registered"),
Some(interpreter) if !interpreter.enabled => {
println!("qemu-{} is registered but switched off", interpreter.name);
}
Some(interpreter) if !interpreter.flags.contains('F') => {
println!("qemu-{} lacks the fix-binary flag", interpreter.name);
}
Some(interpreter) => {
println!("emulated by {}", interpreter.path.display());
}
}
}
Those are three different problems with three different fixes — install it, enable it, re-register it — and a message that collapsed them would send a reader to the wrong one. The example checks before it provisions, because the provisioner’s own preflight runs at the point it first executes a target binary, which is after every package has been downloaded and unpacked.
Interpreter also carries resolved, the interpreter path canonicalized. The
two are separate facts worth recording separately: the registration usually
names a wrapper that is a symlink to the real binary, and repointing that
symlink changes the interpreter — and so, silently, the compiled output — without
changing the registration. An artifact signature that records provenance wants
both.
Where the architecture is decided, and where it is not
Three answers, and it is worth knowing which is which.
host_architecture reports the host’s Debian architecture, and it comes
from uname, not from std::env::consts::ARCH. The two differ in a way that
matters: ARCH reports what the calling binary was compiled for, and it reports
powerpc64 for both endiannesses, so it cannot tell ppc64 from ppc64el —
different Debian architectures with incompatible binaries.
architecture sets the target, and defaults to the host’s. Nothing above
the provisioner reads it back: a cage is told a root filesystem and a command,
and it neither knows nor asks what either was built for. That is what makes the
foreign case work without a mode of its own.
A seccomp filter is the one place a reader might expect an architecture
problem and not find one. A filter gates on the audit architecture of the task
making the syscall, and under qemu-user that task is the emulator — a native
binary. The syscalls reaching the kernel are the host’s, issued by qemu on the
guest’s behalf, so a filter compiled for the host applies exactly as it does to
any other process. There is no translation to get wrong.
What does change is the set of syscalls a workload makes. An emulator maps the guest’s address space, keeps threads of its own, and turns guest calls into host calls that need not correspond one to one, so a policy narrow enough for a native workload can be too narrow for the same workload emulated. That is a property of the emulator rather than a defect.
In practice the curated policy is wide enough: cross-arch-test --harden
compiles and runs the probe under it unchanged. The caveat is worth keeping for
a caller-authored allowlist, which is narrow by construction and was written
against whatever syscalls the workload made natively.
When the binaries cannot run at all
An architecture with no qemu-user handler — or a host with no binfmt_misc —
can still have a root laid out for it, just not configured. extract_only
unpacks every package and writes the dpkg metadata without running a single
target binary, producing a tree that is complete on disk and unconfigured. It is
what a cross-build wants when it needs headers and libraries rather than a
working userland, and it is the documented alternative the preflight’s failure
message points at.
resolve and available need neither a handler nor an identity map either,
since they talk to the archive and run nothing. Asking what an architecture’s
archive contains works from any host.
Running it
# Provision an arm64 root and run the built-in probe in it:
cargo run --example cross-arch-test --features debian,hardening -- \
--rootfs ./trixie-arm64 --cache ./deb-cache
# The same, under the curated seccomp policy:
cargo run --example cross-arch-test --features debian,hardening -- \
--rootfs ./trixie-arm64 --harden
# A shell in the foreign root, for looking around:
cargo run --example cross-arch-test --features debian,hardening -- \
--rootfs ./trixie-arm64 -- /bin/bash
The first run reports the interpreter it will use, bootstraps the root, and then compiles and runs the probe:
cross-arch-test: arm64 runs through /usr/libexec/qemu-binfmt/aarch64-binfmt-P (flags POF)
cross-arch-test: provisioned ./trixie-arm64 for arm64
uname -m inside the cage: aarch64
readelf: machine is AArch64
probe: built for arm64, 64-bit pointers, little-endian
probe: all checks passed
The probe asserts what it was built for rather than reporting what it
observes, which is what makes it a test rather than a demonstration. Its
architecture comes from the compiler’s own predefined macros, so a toolchain
that quietly produced host binaries fails to satisfy it; readelf reads the
machine out of the ELF header before the program is ever executed, which is the
check a running program cannot make about itself.
The bootstrap is the slow part, and it is slow because of emulation rather than
download: every dpkg invocation and every maintainer script in the
configuration wave is an arm64 binary running under the interpreter. The root is
published once and reused, so only the first run pays for it.
An Alpine userland
The alpine feature provisions an Alpine Linux root filesystem: it resolves a
package set from an apk repository and installs it, so a sandbox can build or run
software from a userland the library assembles for it. It speaks to the archive
directly — a pure-Rust replacement for apk add --root — and runs each package’s
install scripts inside a cage of its own.
use ferroday_cage::provision::{self, alpine::Alpine};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut alpine = Alpine::builder("v3.23")
.include(["alpine-base", "build-base"])
.cache_dir("/var/cache/fcage/apk")
.build()?;
provision::ensure("/var/lib/machines/alpine", &mut alpine)?;
Ok(())
}
Alpine implements Provisioner, so provision::ensure
publishes the result atomically: the destination directory either does not exist
or holds a complete, configured rootfs.
The surface mirrors the Debian layer’s closely enough that a
consumer of one recognizes the other — the same builder setters, the same
resolve/available/plan/pin pair of read and replay modes, the same
layered build, and an observe() sink over AlpineEvent carrying the same
progress. What differs is listed under Where the two layers
differ, and every difference is one the archives
themselves make.
The musl-build example works this into a consumer: it provisions a root with
the toolchain and links a C project statically against musl, which is the thing
an Alpine root is most often reached for.
How it works
A bootstrap runs in stages:
- The signed index. It fetches each repository’s
APKINDEX.tar.gz, verifies the RSA signature against the repository’s keys, and parses the records. There is no release document above the index: the index is the signed thing. - The package set. It resolves the closure over what
includenames — dependencies, virtual providers, andinstall_ifconditions to a fixed point — against the merged records of every configured repository. - Download and verify. It downloads each package and binds it to the index that named it before anything is unpacked. See Trust.
- Extraction and scripts. It lays every package’s files down in one wave and runs the install scripts in a second, both in dependency order, then fires the triggers whose watched directories the install changed. The scripts run inside a cage rooted at the staging tree, so they need no privilege on the host. When they have run, the rootfs is published.
The finished root carries /lib/apk/db/installed, /etc/apk/world,
/etc/apk/repositories, /etc/apk/arch and the repositories’ trusted keys, so
an apk inside it reads back the state this bootstrap wrote and can go on
installing. world is what include named, which is what a later apk treats
as wanted; a bootstrap installing a plan writes every package the plan names,
since a plan is the whole closure and records no seeds.
Selecting packages
The install set is exactly what include names, closed over its dependencies,
minus what exclude removes. There is no base system underneath it.
That is the sharpest practical difference from the Debian layer, and it follows
from the archive: apk records no priority for a package, so there is no band to
seed from and nothing corresponds to base_priority. A root that names only a
compiler has only a compiler and its dependencies — no shell, no apk binary, no
/etc/passwd — because nothing in that closure happens to require one.
use ferroday_cage::provision::alpine::Alpine;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// A conventional minimal system, which is a package like any other.
let _system = Alpine::builder("v3.23").include(["alpine-base"]).build()?;
// A build root that is only what a build needs. `busybox-binsh` is named
// because it ships /bin/sh, which the toolchain's closure does not require.
let _build = Alpine::builder("v3.23")
.include(["build-base", "busybox-binsh"])
.build()?;
Ok(())
}
alpine-base is the package Alpine’s own minimal images are built from, and
naming it gives the familiar system: busybox, apk-tools, the layout packages,
and the signing keys. Naming nothing at all is refused rather than resolving an
empty root.
exclude removes a package from the closure. An excluded package that something
in the closure genuinely requires fails the resolve rather than producing a
broken root.
Repositories
A bootstrap resolves against one or more repositories. The builder’s mirror,
components and keys setters configure the primary — a single-mirror bootstrap
needs no repository at all — and repository merges in additional sources,
each with its own KeySet, so multiplying repositories repeats the
authenticity check per source rather than weakening it.
Resolution is highest-version-wins across the merged repositories. An exact tie resolves to the later repository, which is what makes an additional repository an overlay: a package it republishes at the version the primary already offers is the one that wins.
Two layouts, one type
Alpine publishes <release>/<component>/<architecture>; postmarketOS publishes
<release>/<architecture>, with no component level at all. A repository that
names no components takes the second layout, and that is the whole of the
difference between them:
use ferroday_cage::provision::{self, alpine::{Alpine, KeySet, Repository}};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let postmarketos = Repository::builder("v26.06")
.mirror("http://mirror.postmarketos.org/postmarketos")
.keys(KeySet::postmarketos())
.build()?;
let mut alpine = Alpine::builder("v3.23")
.repository(postmarketos)
.include(["alpine-base", "postmarketos-baselayout"])
.build()?;
provision::ensure("/var/lib/machines/pmos", &mut alpine)?;
Ok(())
}
postmarketOS is an overlay rather than a distribution — a postmarketOS root is
Alpine’s repositories plus its own — so it is configured as an additional
repository and never as the primary. Its release cycle is its own: v26.06
against Alpine’s v3.23.
Both of its ways of overriding an Alpine package fall out of the merge rather
than needing anything of their own. It republishes akms at 99990.3.0-r1, a
version no Alpine release will reach, so the higher version wins wherever the
repository sits in the order; and postmarketos-base provides
alpine-base=1000-r0, so asking for alpine-base with the overlay configured
resolves postmarketOS’s base system instead of Alpine’s. Both are what apk
does with the same two repositories configured.
Where a package’s bytes come from
An apk repository publishes one index per component, and a package is addressed
relative to the directory its own index sits in. So the unit a resolution
attributes a package to is the index, not the repository: a repository
configured with main and community contributes two, and plan.indexes
records each one’s mirror, component, digest, and the key whose signature over it
verified.
use ferroday_cage::provision::alpine::Alpine;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut alpine = Alpine::builder("v3.23").include(["alpine-base"]).build()?;
let plan = alpine.resolve()?;
for package in &plan.packages {
let index = &plan.indexes[package.index];
println!(
"{} {} <- {} ({})",
package.name,
package.version,
index.mirror,
index.component.as_deref().unwrap_or("no component"),
);
}
Ok(())
}
The package cache
cache_dir keeps downloaded packages for reuse across bootstraps. Every entry is
re-verified in full on every read — signature segment, the binding to the plan,
and datahash over the file tree — and an entry that does not read back as the
package the plan names is downloaded again rather than refused.
It has to work that way, and the reason is a real difference from the Debian
pool: an apk index publishes no digest of the whole .apk file, so there is no
content address to name a cache entry by. Entries are keyed by published file
name instead, and the full re-verification is what makes that safe.
A package is never held in memory: the bytes are written to a staging file as they arrive, and installing reads that file back.
Resolving, replaying, and pinning
resolve reports what a bootstrap would install without installing it, and a
full bootstrap emits the same plan through AlpineEvent::Resolved before the
first download. plan hands a resolved Plan back to the bootstrap, which
then fetches exactly what it records and never reads an index; pin instead
constrains a fresh resolution to the versions a plan recorded. The three behave
exactly as their Debian counterparts do, including
what a plan refuses to compose with and the trust model a plan carries, so that
chapter is the reference for all of it.
One refusal is this layer’s own, and it follows from where an apk package lives.
A package is fetched from the directory of the index that named it, so the
correspondence between a plan’s indexes and the configured repositories is
positional: index i of the plan is served by whichever repository publishes the
ith index. build therefore refuses a plan that records more indexes than the
configuration publishes, and one whose index i is not the release and component
the configured repository publishes there — both of which would otherwise compose
URLs no mirror serves and fail as an exhausted mirror walk. The mirror a plan
records is deliberately not compared: a plan carried to another configuration
fetches over that configuration’s mirrors, which is what makes it portable.
A plan is written and read as a document with to_document and
parse_document. The syntax is the same field-and-stanza form the Debian plan
uses — it reads in a terminal and diffs in a review — but the vocabulary and the
format version are this layer’s, and the two are separate formats:
Format: ferroday-cage-alpine-plan 1
Release: v3.23
Architecture: x86_64
Index: 0
Mirror: http://dl-cdn.alpinelinux.org/alpine
Release: v3.23
Component: main
SHA256: 4c1d...
Signed-By: alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub
Description: v3.23.5-112-gd78b27bd62b
Package: musl
Version: 1.2.5-r23
Architecture: x86_64
Control-Identity: Q1G2xQQm08BlbpxaVnpaWWAcf0MHo=
Index: 0
Size: 405504
Installed-Size: 622592
An index’s stanza records what an apk repository can be pinned to and no more.
There is no Date and no Valid-Until, because the format carries neither;
Description is the archive’s own build string, recorded as a fact about the
publication rather than read for meaning. Component is absent rather than empty
for the componentless layout, a repository that publishes no component and one
that publishes an unnamed one not being the same repository.
Size, Installed-Size and Origin are optional. Origin names the source
package a package was split from and is absent for the common case of a source
that shares its name, which is how the index itself says it — the same reading
the Debian document’s Source takes.
The rest behaves as the Debian document does, and for the same reasons: a field a reader does not know is carried and re-emitted after the ones it does, both lists are already ordered so two renderings are byte-identical, and no line ends in whitespace.
Layered build roots
base_layer, resolve_layer and stage_layer work as the Debian layer’s
do: provision a shared base once, then stage each
increment over it into a disposable overlay upper, and root a cage on
overlay_rootfs with the base as the lower.
use ferroday_cage::Cage;
use ferroday_cage::provision::{self, alpine::Alpine};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let base = "/var/cache/build/base-alpine";
provision::ensure(
base,
&mut Alpine::builder("v3.23").include(["alpine-base", "build-base"]).build()?,
)?;
let layer = Alpine::builder("v3.23")
.base_layer(base)
.include(["openssl-dev"])
.build()?
.stage_layer("/var/cache/build/component-upper")?;
let status = Cage::builder()
.overlay_rootfs(base, layer.path())
.command("/usr/bin/make")
.build()?
.run()?;
let _ = status;
// Dropping the layer discards the increment; the base stays pristine.
drop(layer);
Ok(())
}
Two things this layer has to do for itself that dpkg does for the Debian one,
because nothing here runs a package manager inside the root:
The database in the upper describes the merged root. An overlay unions files,
so the installed an increment writes shadows the base’s outright rather than
adding to it. The base’s records are read and carried through byte for byte —
they may be records apk wrote — with the increment’s sorted in among them, and
/etc/apk/world, /etc/apk/repositories, triggers and the script archive are
merged the same way. apk audit --system inside the merged root passes, which is
the check that the file describes the tree.
The base’s own triggers fire. A trigger belongs to the installed root rather
than to the transaction, so a directory the increment creates runs the base’s
watcher for it — busybox relinking applets, ca-certificates rebuilding its
bundle.
The increment resolves against the base’s installed set read at the versions it
records, not merely at its names, because apk spells a soname
so:libc.musl-x86_64.so.1=1.2.5-r23. A dependency the base’s version cannot meet
is reported as the upgrade it would be, naming both versions and the entry that
forced it: an increment installs over a base rather than replacing part of one.
A file the increment ships over one the base owns simply wins in the merged view, since the base is read-only and the shadow lives in the disposable upper. A collision within the increment is refused exactly as a full bootstrap refuses one.
Trust
Authenticity comes from the archive’s own signatures, not the transport, so
packages are fetched over plain HTTP by default. Both archives this layer is
written against serve it, and an https:// mirror needs a transport of the
caller’s own supplied through fetcher.
The chain has three links, and it is worth stating plainly because two of them rest on SHA-1:
- The index is signed with RSA-4096 over SHA-1 (
.SIGN.RSA.), verified against the repository’s key set. - The index’s
C:field identifies each package’s control segment by SHA-1 of its compressed bytes. .PKGINFO’sdatahashbinds the file tree to that control segment by SHA-256.
So the payload is bound by a strong digest and the two links above it are not.
There is nothing stronger available in the published v2 format, and verifying
less than apk does would be worse rather than safer, so the layer verifies
exactly this and offers no knob: a knob would be a choice between verifying and
not verifying. Forging a package against this chain requires a chosen-prefix
SHA-1 collision whose second preimage is a well-formed gzip of a tar containing a
.PKGINFO — expensive, but no longer theoretical. The question is revisited when
Alpine publishes apk v3 indexes, which no release does today.
What binds a package to what was asked for
A signature says that some package was signed. Without more, a mirror could
answer a request for musl-1.2.5-r23 with a validly signed musl-1.2.5-r0. So
before anything is unpacked, the layer recomputes the control identity over the
bytes that arrived and compares it to the C: the verified index recorded, and
compares the package’s own pkgname, pkgver and arch to what was planned.
A package’s own .SIGN member is read but not verified, and this is apk’s
rule rather than a relaxation of it. The identity above already fixes exactly the
bytes such a signature would cover, while the signature itself sits in the
segment before them, which no digest in the format accounts for — so whoever
served the file could replace it without anything downstream differing. It is
also the only rule under which postmarketOS can be installed at all: its index is
signed by build.postmarketos.org, the key postmarketos-keys ships as the
trust anchor, while each of its packages is signed by whichever per-builder key
made it. apk installs them from the index and refuses the same file offered as
a bare apk add ./file.apk, where no index vouches for it.
The keyring
The bundled key set is the whole trust anchor for the repository it is attached
to, and it differs from an OpenPGP keyring in a way worth knowing: an Alpine
signing key is a bare RSA public key. It carries no expiry, no self-signature and
no revocation, so none of the freshness and validity rules the Debian
layer enforces on a certificate has an analogue here. What the
set holds is what is trusted, and the only way to withdraw a key is to stop
shipping it — which means a bundled set is refreshed by a release of this crate.
A caller who needs a different answer sooner assembles a set with
KeySet::insert.
The bundle is partitioned by architecture, because Alpine is: alpine-keys
installs three keys on x86_64, two entirely different ones on aarch64, two more
on armv7, and so on — eighteen keys across ten architectures, with only the two
oldest serving more than one. An x86_64 signature does not verify on an aarch64
root under apk, and it does not here. KeySet::alpine takes the architecture
for that reason, and the bundle mirrors that package’s own key tree rather than
selecting from it.
A private repository signs its index
There is no equivalent of the Debian layer’s trust_unsigned, and none is
planned. apk has no unsigned mode in practice, so admitting one would be this
layer’s invention rather than interoperability with anything.
The route for a caller with a repository of their own is the one apk itself
takes: sign the index with their own key, as abuild-sign does, and pass that key
through the repository’s keys. Supplying a transport over file:// is not on
its own enough — an unsigned index fails verification wherever it comes from.
use ferroday_cage::provision::alpine::{Alpine, KeySet, Repository};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut keys = KeySet::new();
keys.insert("build@example.com-64f0c0a1.rsa.pub", &std::fs::read_to_string("build.rsa.pub")?)?;
let local = Repository::builder("v3.23")
.mirror("file:///srv/my-apks")
.keys(keys)
.build()?;
let _alpine = Alpine::builder("v3.23")
.repository(local)
.include(["my-package"])
.build()?;
Ok(())
}
The key’s name matters as much as its bytes: a signature names its key by exactly the file name the repository publishes it under, so a set holding the right key under the wrong name verifies nothing.
No freshness policy
There is no equivalent of allow_stale_release either, because there is nothing
to be stale against. The apk index carries no validity window and no date — its
DESCRIPTION member is a build string, not a signed expiry — so a freshness rule
would be this layer’s fiction rather than the archive’s policy.
The same reasoning is why the index is never cached. A cache would need a
staleness rule the format does not express. cache_dir holds packages, which are
identified by digests the index states, and indexes are re-fetched.
Ownership
The identity map decides the ownership the finished rootfs carries, and Alpine asks less of it than Debian does.
The default single-identity map maps the calling user to root and nothing else,
and the archive is content with that: every file of every package in an
alpine-base closure is owned by root, so extraction needs none of the ownership
emulation the Debian bootstrap installs stubs for. The one place it shows is that
alpine-baselayout’s install script gives /etc/shadow to the shadow group,
which the single map cannot represent. That call fails, harmlessly and exactly as
it fails under apk in the same situation — Alpine’s scripts tolerate it — and
the resulting root is a working userland with that one group ownership flattened.
A rootfs that is itself the product wants real ownership, and identity_map
selects it. Under IdentityMap::Subordinate the system ids genuinely exist and
that chown succeeds.
Extract-only and foreign architectures
The bootstrap targets the host architecture by default; architecture selects
another. A full bootstrap runs that architecture’s install scripts and so needs a
registered qemu-user binfmt handler (see Host
requirements), which the bootstrap checks for before
downloading anything.
Where the foreign binaries cannot run, extract_only lays the files down and
still registers them. That is a real difference from the Debian layer, where
an extract-only root has no configured dpkg database: an apk installed record is
a file format rather than a program’s output, so writing it needs nothing
executed. An extract-only Alpine root for a foreign architecture therefore knows
exactly what it holds, and an apk running there later reads it back.
Where the two layers differ
Everything in this table is a difference the archives make, not a gap:
| Debian | Alpine | |
|---|---|---|
| Base set | A priority band, seeded by base_priority | None: apk records no priority. The set is exactly include closed over its dependencies |
| Signed thing | InRelease, which carries the indexes’ digests | The index itself; there is nothing above it |
| Digest chain | SHA-256 throughout | SHA-1 to the control segment, SHA-256 to the file tree |
| Trust anchor | An OpenPGP keyring, with expiry and revocation | A set of bare RSA keys, with neither, partitioned by architecture |
| Unsigned mode | trust_unsigned, for a local pool | None; a private repository signs its index |
| Freshness | Valid-Until, and allow_stale_release to relax it | None; the format expresses no validity window |
| Package identity | A SHA-256 of the whole file, from the index | A SHA-1 of the control segment, plus datahash over the tree |
| Extract-only | Files only; no configured database | Files and the installed database |
| Configuration | dpkg runs in a cage | The install scripts run directly, in dependency order |
The command line
fcage --provision-alpine RELEASE bootstraps the --rootfs directory before
launching. Given without a command, it provisions and exits; the
already-provisioned case is fast, so the flag can stay in an invocation
permanently.
$ fcage --rootfs ./alpine --provision-alpine v3.23 \
--alpine-include build-base,busybox-binsh --alpine-cache ./apk-cache
$ fcage --rootfs ./alpine /usr/bin/cc --version
The --alpine-arch, --alpine-mirror, --alpine-mirror-fallback,
--alpine-components, --alpine-include, --alpine-exclude,
--alpine-extract-only, --alpine-cache, --alpine-keys,
--alpine-pre-configure-overlay, --alpine-identity-map and
--alpine-repository options map onto the builder. --alpine-include,
--alpine-exclude and --alpine-components take comma-separated lists and may
be repeated.
--alpine-keys takes alpine, postmarketos, or a directory read the way
apk reads /etc/apk/keys: every file in it is a PEM public key held under its
own file name. A repository added with --alpine-repository must name its keys
the same way, and naming no components selects the shallower layout:
$ fcage --rootfs ./pmos --provision-alpine v3.23 \
--alpine-include alpine-base,postmarketos-baselayout \
--alpine-repository 'release=v26.06 keys=postmarketos
mirror=http://mirror.postmarketos.org/postmarketos'
A Gentoo userland
The gentoo feature provisions a Gentoo root filesystem from a stage3: it
resolves which build the archive currently publishes for a variant, verifies the
signed documents that vouch for it, and extracts the tarball. It speaks to the
archive directly, and verifies what it fetches against a keyring the crate
vendors.
use ferroday_cage::provision::{self, gentoo::Gentoo};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut gentoo = Gentoo::builder("amd64")
.variant("amd64-openrc")
.cache_dir("/var/cache/fcage/stage3")
.build()?;
provision::ensure("/var/lib/machines/gentoo", &mut gentoo)?;
Ok(())
}
Gentoo implements Provisioner, so provision::ensure
publishes the result atomically: the destination directory either does not exist
or holds a complete root.
It also installs prebuilt packages from the archive’s binary-package host into the root it bootstrapped, which is the second half of this chapter. A run that asks for no package is exactly a stage3 bootstrap, byte for byte.
Two examples work it into consumers: ebuild-sandbox provisions a stage3, binds
a portage tree into it read-only, and runs emerge in a cage; gentoo-binhost
provisions one and installs prebuilt packages into it instead.
How it works
A bootstrap runs in three steps:
- The signed enumeration. It fetches
releases/<arch>/autobuilds/latest-stage3.txt, a cleartext-signed document listing every stage3 that architecture currently offers with the build each points at and the exact size of its tarball, and verifies it against the vendored keyring. - The signed digest. It matches the configured variant against that list,
then fetches the
.DIGESTSdocument beside the chosen build — cleartext- signed by the same key — and reads the SHA-512 it records for the tarball. - Download and verify. It downloads the tarball into a cache, bounded by the size the enumeration recorded, checks it against that SHA-512, and only then extracts it. The bytes are never streamed into the extractor unverified: an archive that turned out not to be the one the document named would otherwise already have written most of a root filesystem.
The finished root is the stage3 as Gentoo built it — portage, its configuration,
a toolchain and a base system — ready for a cage to run emerge in.
Naming a variant
Gentoo publishes far more than one stage3 per architecture: amd64 alone offers
twenty-one, from a desktop root to hardened, musl, LLVM and nomultilib builds.
There is no default among them, so a variant must be named.
The spelling is the whole compound the archive publishes, not a name relative to the architecture:
use ferroday_cage::provision::gentoo::Gentoo;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut gentoo = Gentoo::builder("amd64").build()?;
for variant in gentoo.available()?.variants() {
println!("{variant}");
}
// amd64-desktop-openrc, amd64-hardened-systemd, amd64-openrc,
// amd64-openrc-splitusr, x32-openrc, ...
Ok(())
}
x32-openrc appears in amd64’s own document, as a sibling of amd64-openrc:
what sits between stage3- and the build id is a sub-architecture and variant
compound rather than a variant of a fixed architecture. A name relative to the
architecture could not reach the x32 entries at all.
The match is exact rather than by prefix, because amd64-openrc-splitusr sits
beside amd64-openrc in that same document. A variant the architecture does not
publish is refused before anything is downloaded, and the refusal names what is
on offer — checked against what Gentoo publishes now rather than against a list
compiled into this crate.
Each entry keeps its own build id: a sub-architecture is built on its own cadence, so one document routinely points different variants at different builds.
Pinning a build
build_id provisions a named build instead of the current one:
use ferroday_cage::provision::gentoo::Gentoo;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut gentoo = Gentoo::builder("amd64")
.variant("amd64-openrc")
.build_id("20260810T204554Z")
.cache_dir("/var/cache/fcage/stage3")
.build()?;
let _ = gentoo.resolve()?;
Ok(())
}
A pin skips the enumeration entirely — it is the caller saying which build they want, and asking the archive which is current would not change the answer — so the digest document beside that build is what vouches for the tarball.
A pin is not indefinite. The autobuilds tree holds about five weeks of
builds and Gentoo publishes no equivalent of snapshot.debian.org, so a build id
resolves against the archive for roughly a month and against a populated cache
for as long as the cache lives. Past that the archive answers 404. That is a
difference from the Debian and Alpine layers, whose plan documents replay against
a snapshot service or a repository that keeps old versions, and it is a property
of the archive rather than of this layer.
Within one process, a resolution carries over whole:
use ferroday_cage::provision::{self, gentoo::Gentoo};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut gentoo = Gentoo::builder("amd64").variant("amd64-openrc").build()?;
let stage3 = gentoo.resolve()?;
println!("{} at {}", stage3.build_id(), stage3.sha512());
// A second root from the same answer, with nothing re-fetched.
let mut again = Gentoo::builder("amd64").stage3(stage3).build()?;
provision::ensure("/var/lib/machines/gentoo-2", &mut again)?;
Ok(())
}
There is no plan document to write. A single tarball does not need a versioned serialization, and one would name a build the archive deletes in about five weeks — a document outliving what it describes. A caller who wants to provision the same root again records the build id and variant however they already record configuration.
Binary packages
Gentoo publishes prebuilt packages beside the stage3 tarballs, under
releases/<arch>/binpackages/, and this layer installs them. Name the
sub-architecture tree and the atoms to install:
use ferroday_cage::provision::{self, gentoo::Gentoo};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut gentoo = Gentoo::builder("amd64")
.variant("amd64-openrc")
.binhost("x86-64")
.install(["dev-vcs/git", "app-editors/vim"])
.cache_dir("/var/cache/fcage/gentoo")
.build()?;
provision::ensure("/var/lib/machines/gentoo", &mut gentoo)?;
Ok(())
}
A run is then two waves: the stage3 is fetched, verified and extracted, and then the packages are resolved against the root that produced, fetched, verified and merged into it. A builder that names no package does exactly the first, byte for byte.
There is no default sub-architecture. amd64 publishes x86-64, x86-64-v3,
x86-64_hardened and x32 today, and the set moves — it published eight not
long ago. The only thing that enumerates it is an unsigned directory listing,
and reading that as discovery would put an unauthenticated document at the head
of the trust chain, which is the reason the keyring is vendored. So the
sub-architecture is yours to name, and a wrong one is a 404 rather than a
silently different userland.
Resolution starts from the root
This is the shape neither the Debian nor the Alpine layer has. Both bootstrap from nothing, so their resolvers start with an empty root and ask what is needed. A Gentoo stage3 unpacks with 296 packages already installed and a database describing them, so resolution here starts from a populated root and asks what is missing.
That is load-bearing rather than an efficiency. One of those 296 —
app-crypt/pinentry — the binhost publishes in no version at all, and it is in
dev-vcs/git’s runtime closure. Resolving git against an empty root does not
merely produce a larger plan: it fails, on an atom the real root answers. The
general case is the same one, less dramatically: the median closure over four
hundred sampled packages is five, because everything else is already there.
So resolve_packages takes the root it would install into:
use ferroday_cage::provision::gentoo::Gentoo;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut gentoo = Gentoo::builder("amd64")
.binhost("x86-64")
.install(["dev-vcs/git"])
.build()?;
let plan = gentoo.resolve_packages("/var/lib/machines/gentoo")?;
for package in &plan.packages {
println!("{}-{} build {}", package.name, package.version, package.build_id);
}
Ok(())
}
gentoo::installed answers the same question on its own, for any Gentoo root
including one this crate never built:
use ferroday_cage::provision::gentoo;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let installed = gentoo::installed("/var/lib/machines/gentoo")?;
println!("{} packages", installed.len());
Ok(())
}
Which build, and why the rule is that rule
The archive publishes a version more than once. binpkg-multi-instance gives
2,774 of the index’s versions between two and twenty-four builds, differing only
in the USE flags they were compiled with — and therefore in what they depend on.
dev-vcs/git is the clean illustration: the build without keyring pulls
twelve packages, and the build with it pulls seventy through the GTK stack.
Choosing among them is therefore not a tie-break, it decides whether resolution
succeeds at all. Measured over four hundred sampled package names against an
amd64-openrc stage3:
| Rule | Resolves cleanly | Unsatisfiable atoms | Conflicts | p90 closure |
|---|---|---|---|---|
| newest build time | 262/400 | 884 | 129 | 173 |
| fewest dependencies | 338/400 | 206 | 74 | 148 |
| fewest USE flags | 361/400 | 26 | 50 | 126 |
Fewest USE flags wins every column at once, which is the argument for it: it is not a trade. Portage makes the same choice from a profile’s computed USE; this layer has no profile, so it needs a rule of its own, and this is the one the numbers chose. Each atom takes the highest version that satisfies it and then the fewest-flags build of that version.
A constraint fails loudly; a preference cannot
Two ways to reach a build the default rule would not choose, and they differ in kind rather than in spelling.
A USE dependency in the atom is a constraint. dev-vcs/git[keyring] removes
every build without the flag from consideration, and if the archive publishes
none the atom is unsatisfiable and the run says so. It costs no extra API: the
atom grammar the resolver already speaks parses it.
prefer_use is a preference. It reorders the candidates and removes none,
so it provably cannot turn a resolvable request into an unresolvable one. Over
the same four hundred seeds:
| Preference | Resolves cleanly | Unsatisfiable atoms | Conflicts | p90 closure |
|---|---|---|---|---|
| none | 361/400 | 26 | 50 | 126 |
keyring | 361/400 | 26 | 50 | 127 |
-X | 361/400 | 26 | 50 | 126 |
X | 354/400 | 26 | 60 | 164 |
X, gtk, qt6, systemd | 284/400 | 26 | 110 | 179 |
The unsatisfiable count is 26 in every row: that is the guarantee stated as a number. What a broad preference does cost is a larger closure, and a larger closure meets more of the archive’s own conflicts — the whole cost lands in that column. A narrow one costs nothing measurable.
use ferroday_cage::provision::gentoo::Gentoo;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut gentoo = Gentoo::builder("amd64")
.binhost("x86-64")
// A constraint: this build must have the flag, or the run fails.
.install(["dev-vcs/git[keyring]"])
// A taste: prefer builds without X, and never fail because of it.
.prefer_use(["-X"])
.build()?;
let _ = gentoo.resolve_packages("/var/lib/machines/gentoo")?;
Ok(())
}
State a flag the install must have in the atom; state a flag you would rather it had as a preference.
Conflicts are reported, not resolved around
A blocker atom that matches something the root has or the resolution chose is a
conflict, and the run fails naming both sides. There is no backtracking, and the
evidence is why: an any-of policy that preferred a non-conflicting alternative
changed nothing over the same four hundred seeds, and the conflicts that remain
are ones portage refuses too. Almost all of them are a desktop stack asked for
in a root built against the other init system — 27 of 50 are
!sys-apps/sysvinit against the openrc stage3’s own sys-apps/sysvinit.
Every refusal is reported before the run fails, so a caller fixing an install
list sees the whole list rather than its first entry, and each names which wall
it hit: the package the binhost does not publish, the version, the slot, or the
USE flag. Where the binhost publishes the package in no version and the root has
it anyway — the app-crypt/pinentry shape again — the refusal names the version
the root has, which is the half a caller can act on.
One instance per slot
Portage keeps one instance of each (package, slot) and reports a root holding
two as a conflict. A resolution that would produce one is therefore refused with
both builds named, rather than merged: merging both would lay two images down
with the second overwriting the first’s shared paths, and report success.
It is reached by an ordinary install list rather than by a contrived one. Two atoms constraining one package from opposite directions both select:
--gentoo-install '<dev-libs/foo-2' --gentoo-install '>=dev-libs/foo-2'
and so does a closure holding one package constrained upward and another
constrained downward on the same dependency, with no caller writing the pair by
hand. The likelier trigger is a USE constraint: ask for dev-vcs/git and for
something that needs dev-vcs/git[keyring], and the first takes the build
without the flag while the second needs the build with it. Both are git in
slot 0, so the second is refused naming both builds — constrain the first atom
the same way, or state the flag as a prefer_use and let one build answer both.
Since nothing backtracks, the resolution keeps the build it chose first and reports what could not join it. The order is a resolution’s own and is stable, so the same install list is refused the same way every time.
The root holds a slot as firmly as the resolution does. Merging registers a database entry beside what is already installed and unmerges nothing, so an atom answered only by a version later than the one the root has in that slot is refused the same way — naming the installed version rather than a second build. Bootstrap a root whose version the atom admits, or constrain the atom to the version the root already has.
Two slots of one package are a different thing and are not a conflict. The
archive publishes dev-lang/python in 3.11 and 3.12, llvm, gcc, ruby,
postgresql and qt in several each, and Gentoo installs them side by side.
The install order carries an edge to each of them, so a package depending on two
slots merges after both.
The plan is the reproducibility story
Unlike the stage3 half, the binhost half has a plan document:
use ferroday_cage::provision::gentoo::{Gentoo, Plan};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut gentoo = Gentoo::builder("amd64").binhost("x86-64").build()?;
// Resolve now, and keep what was resolved.
let plan = gentoo.resolve_packages("/var/lib/machines/gentoo")?;
std::fs::write("binhost.plan", plan.to_document()?)?;
// Replay later: nothing is fetched but the packages themselves.
let kept = Plan::parse_document(&std::fs::read_to_string("binhost.plan")?)?;
let mut replay = Gentoo::builder("amd64").binhost("x86-64").plan(kept).build()?;
let _ = replay;
Ok(())
}
A plan names each package’s build id, path, size and digest, and the order they
merge in. It is worth serializing where a Stage3 is not, and for the opposite
reason: the autobuilds tree keeps about five weeks of stage3 builds, while the
binhost keeps a published version far longer, so a plan replays for as long as
the archive carries what it names.
A plan also names the architecture and the binhost it was resolved for, and
build() refuses one that does not agree with the builder. The builder above
names both and they match; a builder that named a different architecture would
otherwise compose its own with the plan’s binhost, fetch a directory neither of
them names, and report a 404 against a mirror that was spelled correctly. A
builder that names no binhost takes the plan’s, which is what it resolved
against.
The stanza order is the install order, which is where this format departs from the other two layers’ plans. That order is a resolution’s own output — a topological sort over runtime dependencies — and a plan that lost it would have to derive it again from the index the plan exists to avoid needing.
What a merge writes, and what it does not run
Each package’s files are laid into the root and a /var/db/pkg entry is
registered, because a stage3 arrives with a populated database and a root this
layer installed into without registering would be a root whose database is a
lie: portage would rebuild what is present, and --depclean would remove it.
An entry is the container’s own metadata directory plus the three files portage
computes at merge — CONTENTS, COUNTER and BINPKGMD5 — which is exactly
what a stage3’s own entries turn out to be. The merge ordinal continues the
root’s sequence rather than starting again.
CONTENTS uses portage’s own line types: dir for a directory, obj with a
digest and an mtime for a file, sym with its target for a symbolic link, and
fif for a named pipe. A fifo has no contents to digest and is recorded without
one — opening it to look for some would block until something opened the write
end, which inside a provisioning run nothing does. Character and block devices
never appear: laying one down needs privilege the layer does not have, and the
sandbox provides its own /dev.
Phase functions are not run. Around 30% of packages define a pkg_postinst
or a pkg_preinst, and they live in the container’s environment.bz2 as a
serialized bash environment: running one means running portage’s own ebuild.sh
with the eclasses it was built against. So an acct-user or acct-group
package ships a sysusers.d fragment and does not create the account,
ca-certificates does not regenerate its bundle, and icon and font caches are
not rebuilt.
This is the same seam the Debian layer has and the opposite
decision. Debian’s maintainer scripts are executables that layer can run inside
the sandbox, so it runs them; Gentoo’s are bash functions that need the guest’s
package manager. What this layer gives you instead is a root portage can act on,
which is the strongest argument for registering the entry properly — run the
phases with emerge inside the sandbox if you need them.
CONFIG_PROTECT is not honoured. An existing file under /etc is
overwritten rather than given portage’s ._cfg0000_ treatment. The layer builds
roots rather than upgrading live systems, and a root whose configuration depends
on merge order is not reproducible.
Coverage is a property of the archive
The binhost publishes 6,904 distinct package names across 15,773 builds. That is
most of the tree and not all of it: dev-build/cmake is there, app-misc/jq is
not, in any version. With around 90% of sampled packages resolving cleanly, the
honest statement is that the binhost installs most things and cannot install
everything, that the failures are the archive’s shape rather than the resolver’s,
and that the layer says which atom it could not satisfy and why.
packages lists what is on offer before you commit to a resolution:
use ferroday_cage::provision::gentoo::Gentoo;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut gentoo = Gentoo::builder("amd64").binhost("x86-64").build()?;
let catalogue = gentoo.packages()?;
println!("{} packages in {} builds", catalogue.len(), catalogue.builds());
for version in catalogue.versions("dev-vcs/git") {
println!("{version}");
}
Ok(())
}
Adding packages to a root that already exists
provision::ensure reports a directory that already exists as Existing and
does not touch it, so the way to add packages to a root you already have is a
layered build: the base stays pristine and the increment lives in an overlay
upper that the returned handle removes when dropped.
use ferroday_cage::provision::gentoo::Gentoo;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut increment = Gentoo::builder("amd64")
.binhost("x86-64")
.install(["dev-vcs/git"])
.base_layer("/var/lib/machines/gentoo")
.build()?;
let layer = increment.stage_layer("/var/tmp/build-upper")?;
// Root a cage on overlay_rootfs(base, layer.path()) to build against the
// merged view; drop the handle to revert the increment.
let _ = layer;
Ok(())
}
Resolution reads the merged view — the base’s database and whatever the upper already holds — because that is what a package manager inside the finished sandbox sees. An increment resolving against its own empty upper would ask the archive for everything the base already has, and would fail on the packages the binhost does not publish.
Trust
The stage3 chain has three links, and the first is not fetched at all:
vendored service keyring
-> latest-stage3.txt (cleartext-signed) the variants, their sizes
-> <tarball>.DIGESTS (cleartext-signed) the tarball's SHA-512
-> <tarball> (bytes) verified, then extracted
Both documents are cleartext-signed OpenPGP messages, verified by the same
machinery the Debian layer uses for an InRelease: the
signature must verify against a certificate in the keyring, the key must have
been within its validity window when it signed, and the algorithms involved must
be ones this crate rests a trust decision on. Gentoo signs with a dedicated
signing subkey of its Automated Weekly Release Key, so the subkey’s binding is
verified back to the primary before the signature it made counts.
The digest document records SHA-512 and BLAKE2B for the tarball and for its
.CONTENTS.gz sidecar. The layer reads the SHA-512 for the tarball, keyed on the
algorithm and the file name together: keying on the algorithm alone would take
whichever line came first and hold the tarball to the sidecar’s digest.
The .sha256 sidecar is never read. One sits beside every tarball, and it is
a trap: nothing signs it, and it is not named in the signed digest document.
Reading it would leave the chain resting on an unauthenticated file.
What a binary package’s chain proves, and what it does not
A binary package’s chain is shorter and it is anchored to the same key:
vendored service keyring
-> <package>.gpkg.tar/Manifest (cleartext-signed) each member's SHA-512
-> the members (bytes) verified, then read
The subkey that signs a Manifest is the same one that signs
latest-stage3.txt and the .DIGESTS documents, so the binary-package half
adds no trust anchor, no keyring and no verification path — Keyring::verify is
called on one more document.
The index is not signed, and nothing about that is fixable. No
Packages.gpg, .asc, .sig, Manifest or .DIGESTS is published beside it
in any form, and its own per-package digests are MD5 and SHA-1. So a hostile
mirror can decide which package a resolution asks for, even though it cannot
forge the package it then serves.
It also decides each download’s declared size, which is why the layer keeps a
ceiling of its own beside it. The index’s SIZE bounds a fetch, and an
understated one fails closed — the download stops short and the container fails
its digest. An overstated one only relaxes the bound, since a transport caps at
the smaller of the declared size and its own ceiling, so a hundred packages each
declaring SIZE: 18446744073709551615 would buy that ceiling a hundred times
over before anything was verified. The layer’s own bound is 1 GiB per package —
above the 796 MB a whole stage3 weighs, and below the bundled client’s own
2 GiB, so it binds where the transport does not. The smaller of the two wins, as
a ceiling should.
And a Manifest proves authorship, not identity. Its lines are DATA <member> <size> SHA512 <hex> BLAKE2B <hex> and there is no field naming the package.
GLEP 78 is explicit that the container’s directory prefix cannot supply the
identity either — the name “should” match the file name, but “the implementation
must be able to process an archive where the directory name is mismatched” — so
a conforming reader cannot treat the prefix as a claim.
Put together, that is a real substitution: a mirror answering a request for one
package with a genuine, correctly signed other package passes every signature
check there is. The layer closes it after verification, from the metadata inside
the container — CATEGORY, PF and BUILD_ID compared against the record the
resolution chose. A mismatch is a refusal, not a warning.
The Manifest is the container’s last member, so the digests arrive after the
bytes they cover and a single forward pass cannot verify before extracting. The
container is already in a cache, so this costs a second pass over a local file
rather than a second fetch, and nothing is decompressed before its digest
matches. The two detached .sig members go unread for the reason the stage3’s
.asc does — the cleartext-signed Manifest binds the same bytes — and they
are digested by it like every other member, so they are verified as data even
though they are not used as signatures.
The keyring
The crate vendors Gentoo’s service keys — the same
qa-reports.gentoo.org/output/service-keys.gpg the distribution publishes,
mirrored whole. Vendoring is the security property rather than a saved round
trip: a trust anchor fetched over the same plain-HTTP channel as the artifact it
authenticates authenticates nothing, because whoever can serve a forged stage3
can serve the key that signs it.
It is mirrored mechanically, certificates this layer never meets included. An unusable key in an anchor weakens nothing — nothing rests on a key until it produces a signature that passes the gates — and a curated subset would be a judgement a later reader has to reconstruct rather than a rule they can check.
A vendored anchor has a shelf life, and Gentoo’s certificates do not share one expiry:
use ferroday_cage::provision::gentoo::Gentoo;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let gentoo = Gentoo::builder("amd64").build()?;
for held in gentoo.keyring_horizon() {
println!(
"{} {} {:?}",
held.fingerprint,
if held.signs { "signs" } else { "cannot sign" },
held.expires,
);
}
Ok(())
}
The report is per certificate rather than a single earliest date, because two of
the eleven lapse about twenty months before the rest and neither signs anything
this layer reads: one number would say the keyring is nearly stale while the key
that matters is good for years. signs is the other half of reducing the list
honestly — the keyring holds a DSA-1024 release key from 2004 whose expiry reads
like every other certificate’s, though the public-key floor refuses it and it
delegates to no signing subkey that would pass. The earliest expiry among the
certificates that can sign is the number to act on.
Nothing refreshes the keyring at run time, and nothing should: fetching a trust anchor at provisioning time would hand the network the power to replace the very thing vendoring exists to protect. A newer keyring arrives in a release of this crate.
Staleness, and what bounds it
Gentoo publishes no deadline of any kind. There is no Valid-Until above the
documents and no expiration on the signatures themselves, which leaves a gap the
first two layers do not have: a mirror serving a genuinely signed but months-old
pointer, and its matching digest document, passes every other check there is. The
signature verifies, the key was valid when it signed, and the digest matches the
bytes. That is a rollback, and it is the standard attack on a chain with no
deadline.
The pointer closes it itself. Inside its signed body, above the entries:
# Latest as of Mon, 10 Aug 2026 23:30:01 +0000
# ts=1786404601
Every architecture carries the line, and the documents are regenerated on a
common schedule whether or not new builds landed in them — amd64’s x32 entries
can be two months old while the document’s own timestamp is minutes old — so the
value measures the pointer’s currency rather than the age of what it points at.
A pointer older than thirty days is refused. That is about four regeneration cycles, so a live archive never approaches it. A pointer stating no timestamp is refused on the same terms: it is the only bound there is, and reading its absence as permission would let whoever serves the document disable the check by deleting a line.
use ferroday_cage::provision::gentoo::Gentoo;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// A deliberately archived mirror, where a stale pointer is the point.
let gentoo = Gentoo::builder("amd64")
.variant("amd64-openrc")
.mirror("http://archive.invalid/gentoo")
.max_pointer_age(None)
.build()?;
let _ = gentoo;
Ok(())
}
Clearing the bound gives up the only defence against a replayed pointer, and it accepts an undated one too. It is the right answer for an archived mirror and the wrong one for a live network.
The cache
cache_dir names a directory the downloaded tarball is kept in across runs. A
stage3 is hundreds of megabytes, so a caller provisioning more than one root from
the same build wants one. Without it the tarball is downloaded beside the tree
being built and removed with it.
A cached tarball is verified on the read, not trusted for having been verified once: what sits at a cache path is bytes on a disk this crate does not own, so a cache hit and a fresh download go through the same check. A tarball that does not match is removed rather than left behind — it is not the archive’s file whatever it is, and keeping it would make every later run of that build fail identically out of its own cache. Binary packages are cached the same way and on the same terms.
The cache directory is inside the trust boundary. A container is verified in
one pass and extracted in a second, both over the file on disk, so anything able
to write the directory between the two substitutes content nothing verified into
the merge. In normal use nobody else can: the cache is cache_dir, or a sibling
of the tree being built where you name none. Pointing cache_dir at a shared or
world-writable location extends the boundary to whoever can write there.
Mirrors
mirror replaces the default http://distfiles.gentoo.org, and
mirror_fallback adds URLs tried after it. The two are separate settings, as
they are on the Debian and Alpine builders: the walk
list is the primary followed by its backstops whichever order the calls are
written in, repeating mirror takes the last value, and a backstop on its own
backs the default archive rather than replacing it.
The list is walked in order, advancing past a mirror that could not serve a document. A mirror that serves one that does not verify is fatal rather than a reason to try the next: what it served was answered for by the URL that was asked for, and the answer to a refused signature is never to ask somewhere else.
The archive serves plain HTTP with no redirect to HTTPS, and the bundled client
speaks it. Every byte it brings back is verified against the vendored keyring, so
the transport is not what the trust rests on; a caller who wants TLS, a proxy, or
a private mirror protocol supplies their own Fetch.
Progress
A bootstrap reports through the run’s observer, as every provisioner does:
use ferroday_cage::provision::gentoo::GentooEvent;
use ferroday_cage::provision::{Provision, ProvisionEvent, gentoo::Gentoo};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut gentoo = Gentoo::builder("amd64").variant("amd64-openrc").build()?;
Provision::new("/var/lib/machines/gentoo")
.observe(&mut |event: ProvisionEvent<'_>| match event {
ProvisionEvent::Gentoo(GentooEvent::Fetching { url, .. }) => {
eprintln!("fetching {url}");
}
ProvisionEvent::Gentoo(GentooEvent::Resolved { stage3, .. }) => {
eprintln!("build {}", stage3.build_id());
}
_ => {}
})
.run(&mut gentoo)?;
Ok(())
}
A read-half call drives no run and so has no run observer to report through.
Gentoo::observe binds a GentooObserver for one call instead, which is how a
caller previewing an install sees what the resolution refused:
use ferroday_cage::provision::gentoo::{Gentoo, GentooEvent};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut gentoo = Gentoo::builder("amd64")
.binhost("x86-64")
.install(["dev-vcs/git"])
.build()?;
let mut sink = |event: GentooEvent<'_>| match event {
GentooEvent::Conflict { atom, blocks, .. } => eprintln!("{atom} blocks {blocks}"),
GentooEvent::DependencyCycle { packages, .. } => {
eprintln!("cycle: {}", packages.join(" -> "));
}
_ => {}
};
let plan = gentoo
.observe(&mut sink)
.resolve_packages("/var/lib/machines/gentoo")?;
drop(sink);
println!("{} packages", plan.packages.len());
Ok(())
}
Conflict, DependencyCycle and Occupied describe how a resolution reached
its answer, which is the half a preview is for: resolve_packages returns the
plan, and the sink says which atom blocked which, whether the blocker is already
installed, and which packages form a cycle. An observed call reports the same
events a bootstrap does, because both go through one code path.
The same observer stops the run. ProvisionObserver::cancelled is consulted at
each step boundary — before a document is fetched, once the resolution is in
hand, before the download, and before the digest pass — and, unlike the other
userlands, while the tarball is arriving: this layer has no package boundary to
break the work up, and a check that ran only around the download would answer a
caller who asked to stop once all of it had been fetched. A cancelled run fails
with ProvisionError::Cancelled, leaves no root behind, and leaves no partial
tarball in the cache.
The command line
fcage --provision-gentoo ARCH provisions the --rootfs directory:
$ fcage --provision-gentoo amd64 --gentoo-variant amd64-openrc \
--gentoo-cache ./stage3-cache --rootfs ./gentoo \
/bin/bash -c 'cat /etc/gentoo-release'
Gentoo Base System release 2.18
The architecture is the coordinate rather than a release, since Gentoo publishes
one autobuilds tree per architecture and no suite. --gentoo-build-id,
--gentoo-mirror, --gentoo-mirror-fallback and --gentoo-max-pointer-age
mirror the builder setters above; the last takes a count of days or none.
Two listings answer without provisioning anything:
$ fcage --gentoo-variants amd64
amd64-desktop-openrc 20260810T204554Z 829735700
amd64-hardened-openrc 20260809T143052Z 496484932
amd64-openrc 20260810T204554Z 495865168
...
$ fcage --gentoo-keyring-horizon
13EBBDBEDE7A12775DFDB1BABB572E0E2D182910 signs 1846022400 Gentoo Linux Release Engineering (Automated Weekly Release Key) <releng@gentoo.org>
...
earliest expiry among the certificates that can sign 1795718963
--gentoo-variants reads the signed enumeration, taking --gentoo-mirror,
--gentoo-mirror-fallback and --gentoo-max-pointer-age with the meanings they
have on a provisioning command line, so the archive a listing reads is the
archive an install would; --gentoo-keyring-horizon reaches no network at all.
Binary packages are installed by naming the tree and the atoms:
$ fcage --provision-gentoo amd64 --gentoo-variant amd64-openrc \
--gentoo-binhost x86-64 \
--gentoo-install 'dev-vcs/git[keyring]' \
--gentoo-prefer-use '-X' \
--gentoo-cache ./gentoo-cache --rootfs ./gentoo \
/usr/bin/git --version
fcage: the binhost index names 15773 builds, generated at 1787145736
fcage: installing 74 packages (215048192 bytes)
fcage: merging dev-lang/perl-5.42.2
...
git version 2.54.0
--gentoo-install is repeatable and --gentoo-plan installs a plan document
instead of resolving. Two further listings answer without provisioning:
$ fcage --gentoo-packages amd64 --gentoo-binhost x86-64
acct-group/3proxy 0
acct-group/adm 0-r3
...
$ fcage --gentoo-installed ./gentoo
acct-group/audio-0-r3 0 abi_x86_64 amd64 elibc_glibc kernel_linux
...
--gentoo-packages reads the binhost index; --gentoo-installed reads a root’s
own package database and reaches no network.
Where this layer differs from the other two
Every difference is one the archive itself makes.
| Debian and Alpine | Gentoo | |
|---|---|---|
| What is installed | a package closure this crate resolves | a prebuilt tarball, and prebuilt packages over it |
| The coordinate | a suite or release, plus an architecture | an architecture, plus a variant |
| Where resolution starts | an empty root | a root with 296 packages already in it |
| Replay | a plan document, versioned and portable | the build id and variant for the stage3, a plan document for the packages |
| How long a pin lasts | as long as the archive or a snapshot service keeps the versions | about five weeks against the archive |
| Freshness | Valid-Until, or an index refetched every run | the pointer’s own signed regeneration timestamp |
| What runs during a bootstrap | maintainer or install scripts, in a cage | nothing; phase functions are not run |
Building packages inside a provisioned stage3 is emerge’s job rather than
this library’s, and the ebuild sandbox is the worked example
of driving it.
What is not here
Building from source. emerge inside a provisioned stage3 is what the
ebuild sandbox shows, and it stays that example’s job.
Phase functions. Named above, with what their absence costs.
The ebuild tree, and EAPI evaluation. Neither is needed: the binhost index
is USE-evaluated, so its dependency strings are the ones that survived the
conditionals at build time and no flag? ( ... ) group appears in any of them.
That is the finding the whole binary-package half rests on.
Unmerging, upgrading and --depclean. The layer installs into a root it is
building. Removing and replacing packages in a root that exists is portage’s job,
and the database this layer writes is one portage can do it with.
Detached signatures. Gentoo publishes an .asc beside every tarball and a
.sig beside each container member. They are an alternative path for someone
verifying a download by hand, not the only one: the cleartext-signed digest
document and Manifest bind the same bytes, and that is what this layer reads.
BLAKE2B. It sits beside SHA-512 on every Manifest line. A second algorithm
agreeing with the first buys nothing the first does not already give.
A binhost of your own. The mirror is configurable, so you can point this at one. What is not here is a second trust configuration for a keyring that is not Gentoo’s.
Stability and versioning
ferroday-cage publishes two interfaces: the Rust API and the TOML profile format. This chapter states what each promises, and — as importantly — what neither covers.
Versioning
The library follows Cargo’s semantic versioning. Before 1.0 the minor position is the breaking one: releases sharing a minor version are compatible with each other, and a new minor version may break. A release that breaks says so, entry by entry, in the changelog.
fcage, the command-line interface, versions independently of the library. Its
compatibility contract is its flags, exit codes, and output format, so a
library release does not move it and a library break does not force one.
The userlands version with the library, deliberately
provision::debian, provision::alpine and provision::gentoo are a little
over a third of the library’s public functions, and their shape is driven by
three external archives’ formats rather than by this library’s design. That is
the part of the surface most likely to have to move, tied to the version number
of the part least likely to: a Debian layer that has to break to follow a change
in the archive would break the whole library’s minor version with it.
They stay in the library for now, and the reason is that nothing has needed the break. The argument for splitting is real and gets stronger with each release; the argument against splitting today is that four crates cost four release cycles, four sets of feature plumbing, and a set of items that are internal today becoming public because a separate crate would need them.
Two things would settle it. A userland needing a breaking change the rest of the
library does not is the direct one. So is a consumer who wants one archive
format without the others’ dependency weight: the Debian layer pulls
flate2/zlib-rs in through pgp, which every build that enables it carries.
What keeps the decision cheap in the meantime is that the layers already sit on
a seam – Provisioner, Fetch, ProvisionError, BuildLayer, and the shared
tar, compress, extract, digest, document and OpenPGP machinery – and reach
around it in nineteen places, eight of which are internal. tools/seam.toml
records every one with what it would cost, tools/check-seam.py fails on any
difference, and a layer naming another layer is refused outright. A split, when
it comes, is that list plus the mechanical work.
What the Rust API promises
Within a compatible release series:
- No public item is removed or renamed, and no signature changes.
- No trait gains a method without a default body.
- No public type loses
SendorSync. These are pinned by assertions in the test suite, because losing an auto trait produces no compiler error inside the crate and no diagnostic from a semver checker. - Cargo features are purely additive: enabling one adds items and never changes or removes existing ones. No feature is renamed or removed.
The two opt-outs
Two settings are promises about what the library will not do, and they are covered here rather than in the uncovered list below:
base_env(false). The command’s environment is exactly the pairs the caller set. The library supplies no variable of its own, in this release or any later one.managed_mounts(false). The library establishes no mount of its own. A managed mount introduced in a later release is suppressed too.
Both exist because the corresponding defaults are not covered, and a consumer cannot pre-empt an addition it cannot yet name. Overriding a base variable requires knowing its name; disabling a managed mount requires knowing it exists. The opt-outs are the form of protection that does not require knowing what comes next, which is why the guarantee has to sit here — an opt-out the library could later add to would have exactly the defect it exists to fix.
Neither promises anything about the defaults, which remain free to change.
Matching on a #[non_exhaustive] type
Every public enum is #[non_exhaustive], and so is every struct variant. Both
levels are load-bearing, and each needs its own escape in a match:
match event {
// `..` absorbs a field added to a variant already matched here.
DebianEvent::Downloading { package, index, total, .. } => { /* ... */ }
DebianEvent::Extracting { package, .. } => { /* ... */ }
// The wildcard absorbs a variant added later.
_ => {}
}
A wildcard arm alone is not enough: without .., adding a field to
Downloading would still fail to compile. Writing both means new variants and
new fields both arrive as patch releases.
Unit and tuple variants — DebianEvent::Resolving, Network::Host,
IdentityMap::Subordinate — are never marked #[non_exhaustive], because that
attribute makes them unnameable outside the crate rather than merely
unconstructible. They are matched and constructed as ordinary variants.
Constructing errors
Error types a consumer produces, rather than only inspects, carry
constructors: FetchError::not_found, IdMapError::unsatisfiable,
ProvisionError::other, and their siblings. Use them. The variants themselves
are #[non_exhaustive] so they can gain fields, which means struct-literal
construction is reserved to the crate.
Extension traits
Provisioner, Fetch, IdMapper, and Observer are the seams a consumer
implements. Every method added to any of them in a later release will carry a
default body, so an existing implementation keeps compiling.
New inputs to an existing method are a separate problem, since adding a
parameter breaks the signature whatever the defaults. Provisioner::provision
and Fetch::fetch therefore take a request value — ProvisionRequest,
FetchRequest — rather than loose parameters, and new inputs arrive as
accessors on it.
Thread bounds
Which of the four carry Send/Sync follows one rule: a seam the library
stores is bounded; a seam it only borrows for the duration of a call is not.
| Trait | How the library holds it | Bounds |
|---|---|---|
IdMapper | Stored in IdentityMap | Send + Sync |
Fetch | Stored in Debian | Send |
Provisioner | &mut dyn, for one ensure call | None |
Observer | &mut dyn, for one run | None |
A stored seam’s bounds become the bounds of the type that owns it, so leaving
Fetch unbounded would make Debian unable to cross a thread. A borrowed seam
has no such reach: the library calls it on the caller’s own thread, from inside
a blocking wait, and does not outlive the call. Bounding those would cost
implementations something and buy nothing.
Fetch requires Send and not Sync, because every operation on a Debian
takes &mut self — a shared reference to one cannot do any work, so requiring
Sync would only rule out a transport that caches behind a Cell. The
requirement is on moving a transport, never on calling it concurrently.
That &mut self is also why fetching several resources at once is a method
rather than something a caller can arrange from outside: the provisioner holds
the only handle. Fetch::fetch_all is that method. It carries a default body —
one fetch per job, in order — so it is not a requirement on an implementation,
and a transport that can overlap requests says so by overriding it. Every method
added to this trait in a later release will carry one, for the same reason.
An override is written against the surface the bundled client is written
against: FetchJob::parts hands a job’s request and its sink back together, and
FetchJob::new rebuilds a job from them, so a transport that speaks for only
part of a batch passes the rest on as a batch of its own rather than one request
at a time. Nothing about this seam is reserved to the crate.
The consequence for a consumer: Debian and DebianBuilder are Send, so a
provisioner can be built on one thread and run on another. Running, Pending,
and Debian::observe’s Observed are neither, because each borrows an
unbounded sink; they stay on the thread that created them, and KillHandle is
the piece that crosses threads to stop a run.
Parameter conventions
Settled so that generalising a parameter later is never a breaking change:
| Input | Type |
|---|---|
| A filesystem path | impl AsRef<Path> |
| An owned string the callee keeps | impl Into<String> |
| Borrowed text the callee only reads | &str |
| A collection | impl IntoIterator<Item = ...> |
What the profile format promises
A profile is a serialized CageBuilder. Every key is the kebab-case form of the
field’s name, derived by rename_all on the container; the two exceptions are
stated explicitly, and both are a plural Rust field naming a singular TOML table
(rlimits becomes rlimit, mounts becomes mount). A test pins the exact
key set the format can carry, so a Rust-side field rename, a changed
rename_all, or a newly added field moves a key only where that diff is
reviewed.
Compatibility runs in one direction: a profile works with the library version that wrote it, or any later one, and never with an earlier one.
Unknown keys are rejected rather than ignored. This is deliberate. A profile
key names a security-relevant posture, and silently discarding one the library
does not understand would run a sandbox weaker than the profile asked for — the
same reasoning that makes a serde-without-hardening build refuse a profile
configuring hardening, rather than drop it. An older library therefore refuses
a newer profile by design; upgrade the library rather than trimming the
profile.
What is not covered
None of the following is part of either interface. Each can change in any release, including a patch.
Displaystrings on every type. Error messages are for humans and change as diagnostics improve. Match on the variant, never on the text.SetupStepdiscriminants. The explicit#[repr(u32)]values are the setup-error pipe’s wire encoding, written and read by the same build. A step inserted later renumbers the ones after it. Do not persist, transmit, or compare them.- Event granularity and ordering. How many
ProgressorDebianEventevents an operation emits, and in what order, is not fixed. Treat them as advisory. - The curated seccomp roster’s exact contents. The roster grows, and a
syscall it comes to deny is a syscall some command needs. Roster additions
land in minor releases, never patches — but the precise set is not a
contract, and a consumer that depends on one should pin an explicit
SeccompPolicyinstead. - The default mount profile. The six
/devdevice nodes, the five/devsymlinks, the tmpfs/tmp, and the fresh/procare defaults, not guarantees. A consumer that needs an exact set should turn the managed mounts off withmanaged_mounts(false)and declare its own — mounts apply in declaration order, so araw_mounttmpfs followed by the binds that populate it reproduces a managed mount’s structure as well as its contents. - The base environment.
PATHandHOME=/root, and nothing else, is the current default. A build whose output depends on the environment should set every variable it cares about explicitly throughenv, and usebase_env(false)so that a variable added to the base later cannot reach the command. - The embedded Debian archive keyring. This one is expected to change in
patch releases: a keyring rotation is a security fix, changes no types, and
must be shippable without a version bump. A consumer pinning a specific
keyring should supply its own through
DebianBuilder::keyring.
The last three matter most to a caller making a reproducibility claim. The mount profile and the base environment are inputs to what a build inside the sandbox produces, and a version number alone does not record which ones were in force. A pipeline whose output must be reproducible has two tools for this, and they are complements rather than alternatives:
- Declare, with the two opt-outs above.
managed_mounts(false)andbase_env(false)reduce the library’s contribution to nothing, so the sandbox carries exactly what the consumer declared. This is prevention: the inputs cannot drift under a library upgrade. - Record, with
Cage::resolved_inputs. The built cage reports the environment it will apply and the mounts it will establish, as data. Stamped into an artifact’s provenance beside the library version, it states the inputs a build actually ran under rather than leaving them to be inferred. This is verification, and it is worth doing even with both opt-outs set, because it is what catches the case where a declaration and the sandbox disagree.
Minimum supported Rust version
rust-version in Cargo.toml is the supported floor. It may rise in a minor
release and never in a patch. CI builds at the declared version on every
change, so the floor is verified rather than asserted.
rust-toolchain.toml pins a toolchain for contributors, to keep rustfmt and
clippy output identical everywhere. That pin is a convenience and may sit
ahead of the floor; the rust-version field is the supported minimum.
How the promises are kept
Policy that only lives in a document decays. These are enforced:
| Promise | Enforcement |
|---|---|
| No breaking API change without a version bump | cargo-semver-checks against the last published release |
| No unintended public item, path, or re-export | A committed cargo-public-api snapshot, diffed in review |
No public type loses Send/Sync | const assertions in tests/auto_traits.rs |
| Every feature combination compiles | cargo hack --feature-powerset --depth 2, plus an explicit featureless check |
| The MSRV holds | A CI build at the declared rust-version |
| The profile key set is deliberate | Explicit #[serde(rename)] on every field, plus a test pinning the key set |
| The untrusted-profile surface cannot widen silently | check_restricted destructures CageBuilder exhaustively, so a new field fails to compile until it is classified |
| A userland reaching past the provisioning seam is a decision | tools/check-seam.py against the record in tools/seam.toml |