Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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

FeatureWhat it addsWhat it pulls in
serdeThe profile format: CageBuilder serializes and deserializes as the sandbox specification, and RestrictedProfile loads one from an untrusted sourceserde
tarballThe Tarball provisioner (gzip, xz, and zstd, detected by content) and export_tar, its ownership-preserving counterpartflate2, lzma-rust2, ruzstd
debianThe Debian provisioner: bootstrap a suite and architecture from the archive, with signature verification and dependency resolution. Implies tarballpgp, sha2, and tarball’s
alpineThe Alpine provisioner: resolve and install a package set from an apk repository, with signature verification. Serves postmarketOS’s repositories too. Implies tarballrsa, sha1, sha2, and tarball’s
gentooThe 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 tarballpgp, sha2, md-5, and tarball’s
hardeningLandlock filesystem and network rules, seccomp syscall filters, capability drops, and the restriction fallback for hosts without user namespacesseccompiler
netstackA native userspace TCP/IP stack giving the isolated network outbound IPv4 and IPv6, terminated in-processsmoltcp
subidThe subordinate-id delegate: uid/gid range maps established through the shadow suite’s newuidmap/newgidmapnothing (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. tarball for an archive, debian for a bootstrapped Debian userland, alpine for an Alpine or postmarketOS one, gentoo for one bootstrapped from a signed stage3. An alpine-only build is the lightest of the three userlands: it needs no OpenPGP implementation, which debian and gentoo each pull in.
  • Giving the sandbox network access. netstack for 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:

SurfaceIn fcage
The launch builder, and the restriction fallback behind --restrictEvery 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 stackEvery setting, as --netstack-*.
The provisioners’ bootstrap surfaceEvery setting, plan and pin documents included, so a reproducible install is expressible at a prompt. A custom transport is code and has none.
Layered provisioningNothing. 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 inspectionGentoo’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:

ConfigurationEffect
kernel.unprivileged_userns_clone = 0Denies creation outright. An out-of-tree sysctl found on Debian-derived kernels.
user.max_user_namespaces = 0Denies creation outright by setting the namespace limit to zero.
user.max_user_namespaces = 1Admits 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 = 1Restricts creation to programs granted the capability by an AppArmor profile. The default on Ubuntu 23.10 and later.
Container seccomp profilesContainer 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 overlay inside 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 — in user.* 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

  1. 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.
  2. Unshare. One unshare call 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.
  3. 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.
  4. 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: deny to /proc/self/setgroups, then a one-line gid map, then a one-line uid map, assigning the calling user’s effective ids to root — denying setgroups first 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, leaving setgroups at allow; 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.
  5. 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.
  6. Hostname. When a hostname is configured, sethostname sets 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.
  7. 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.
  8. Privatize propagation. Mount propagation for the inherited tree is made recursively private, so nothing done here propagates back to the host, and because pivot_root refuses to operate under shared mounts.
  9. Bind the rootfs. The rootfs is bind-mounted onto itself, recursively. pivot_root requires 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.
  10. The mount profile. The init executes the frozen mount operations in order: the fresh /proc, the minimal /dev, the /tmp tmpfs, the caller’s own mounts, and the resolv.conf bind, as configured. Bind sources are host paths and resolve normally — the host tree is still mounted. Targets resolve against the new root with openat2 and RESOLVE_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/fd path. 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 with mount_setattr and AT_RECURSIVE, so the bind and every mount beneath it become read-only in one step; on a kernel before 5.12, which lacks mount_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.
  11. 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.
  12. 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 (PATH and HOME) 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 — so ping does 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:

  • /proc is a fresh procfs instance, mounted from inside the new PID namespace, so it presents the sandbox’s processes and nothing else. With pid_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 /proc carries overmounts, as common host configurations do. Either way, /proc/net and /proc/sys/net resolve through the reading process’s own network namespace, so they reflect the sandbox’s network, not the host’s.
  • /dev is a tmpfs holding the host’s null, zero, full, random, urandom, and tty devices — bind-mounted read-write, since mknod for character devices is denied in an unprivileged user namespace — together with the stdin, stdout, stderr, fd, and ptmx symlinks, a fresh devpts instance on /dev/pts, and a tmpfs on /dev/shm. The /dev tmpfs is nosuid but, by design, not nodev: it has to carry those device nodes, which nodev would 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. tty is the exception to “the host’s own”: it is the character device 5: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.
  • /tmp is 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 as ENOENT against 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 ignored SIGPIPE every 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 delivers SIGTERM to the command process, which may trap it, clean up, or ignore it.
  • kill() ends the sandbox: SIGKILL to 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 ^C delivers SIGINT to 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 SIGTERM to stop the sandbox calls terminate/kill from 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 for SIGKILL. 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_SETUID and CAP_SETGID over 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:

  • DirectMapper writes the map itself — pure Rust, in process. It works when the caller already holds CAP_SETUID and CAP_SETGID over 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 the subid feature, executes the shadow suite’s privileged newuidmap and newgidmap helpers — 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:

HostPackage
Debian, Devuan, Ubuntuuidmap
Alpineshadow-subids
Gentoosys-apps/shadow
Voidshadow

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_FIXUP before 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, or CAP_SETPCAP alongside 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 at build by 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_with and 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 kill drains 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 Cage is 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:

  • TERM is an environment variable rather than terminal configuration, so set it with .env("TERM", ...). Without one, vim fails 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::relay does 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 EIO rather 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:

GroupFlags
InputICRNL, IXON
OutputOPOST, ONLCR
ControlB38400, CS8, CREAD, HUPCL
LocalISIG, ICANON, ECHO, ECHOE, ECHOK, ECHOCTL, ECHOKE, IEXTEN
CharactersVINTR ^C, VQUIT ^\, VERASE DEL, VKILL ^U, VEOF ^D, VSTART ^Q, VSTOP ^S, VSUSP ^Z, VREPRINT ^R, VDISCARD ^O, VWERASE ^W, VLNEXT ^V
ReadsVMIN 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 something run does 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, SIGHUP and SIGQUIT entirely — a shell’s ^C and a supervisor’s SIGTERM both do nothing, and only SIGKILL is left. For fcage the 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 answer SIGINT by 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::raw is off unless asked. Whether the caller’s own standard input is a terminal is the program’s reading to make; fcage asks tcgetattr and passes the answer.

  • The panic hook. install_panic_restore() is one call, or a program with a hook of its own calls restore_terminal() from it.

  • The sandbox’s terminal. Terminal states 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_deadline inside 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:

TypeSendSyncWhy
Cage, CageBuilderyesyesFrozen configuration, no live resources
KillHandleyesyesOwns duplicated channel ends; exists to cross threads
Error, ConfigErroryesyesA worker returns one to a coordinator
Running, PendingnonoBorrow a &mut dyn Observer, called on the waiting thread
Provision, ProvisionRequestnonoBorrow 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’s Rules-Requires-Root handling 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 to RLIMIT_NOFILE, or fails a link under RLIMIT_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 optional read-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 the source, fstype, flags — the kernel’s raw MS_* bits — and data string 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 --setenv extend the profile’s lists.
  • A command on the command line replaces the profile’s command and args as a unit.
  • stop-with-caller is the one key fcage defaults differently from the library: fcage waits 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, so stop-with-caller = false holds — the flag still overrides either way.
  • Hardening flags compose with a profile’s [hardening] table: --landlock-ro, --landlock-rw, --landlock-bind, and --landlock-connect add grants to it, while the seccomp flags, --drop-caps, and --keep-caps replace its seccomp policy or capability posture. The seccomp flags mirror the profile’s seccomp table: --seccomp-allow and --seccomp-deny take comma-separated syscall names, and --seccomp-allow-rule and --seccomp-deny-rule take a syscall name with arg/len/op/value/mask conditions, 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, and env add 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_args first, so the new command does not inherit the group’s arguments, then command and args.
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 with EPERM and allowing the rest. Two entries name an argument rather than a whole syscall: ioctl is denied for the TIOCSTI and TIOCLINUX requests, 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-authored SeccompRules allow-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 (SockFilter instructions), 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:

NameKernel limitUnit
address-spaceRLIMIT_ASbytes of mapped address space
core-dumpRLIMIT_COREbytes
cpu-timeRLIMIT_CPUseconds of CPU time
dataRLIMIT_DATAbytes of heap and anonymous mappings
file-sizeRLIMIT_FSIZEbytes, the largest file created
locked-memoryRLIMIT_MEMLOCKbytes lockable into RAM
open-filesRLIMIT_NOFILEone past the highest descriptor
pending-signalsRLIMIT_SIGPENDINGqueued signals
processesRLIMIT_NPROCprocesses and threads
stackRLIMIT_STACKbytes of main-thread stack

Two carry kernel semantics worth knowing before relying on them:

  • Processes is RLIMIT_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.
  • AddressSpace counts 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 setrlimit values, 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 mounted nosuid, 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.

SettingDefaultBuilder method
IPv4onipv4
IPv6onipv6
IPv4 network10.0.2.0/24 (guest .15, gateway .2)ipv4_cidr
IPv6 networkfd00::/64 (guest ::15, gateway ::2)ipv6_cidr
MTU1500mtu
Interface nametap0interface
Host-loopback mappingoffhost_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. stop signals 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 rename publishes 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>.lock file beside the destination: exactly one provisions, the rest observe the published result. Locking uses flock, 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:

PathWhat is leftMode
/procthe mount point, empty0755
/devthe mount point, empty0755
/tmpthe mount point, empty1777

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 openat2 strictly beneath the destination. In-tree relative symlinks resolve normally — a merged-usr archive whose bin is a symlink to usr/bin extracts correctly — while an entry that reaches outside through .., an absolute path, or a symlink chain fails with ProvisionError::EntryUnsafe before 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 a ProvisionError::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 /dev at 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::ensure extracts the stage3 into the --rootfs directory 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-cache keeps the downloaded tarball across runs, which is what a caller building more than one root from a build wants.
  • The tree is read-only. --repo binds 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. --distfiles and --binpkgs bind host directories read-write at /var/cache/distfiles and /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 emerge needs to fetch sources; --offline denies 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:

  1. 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.
  2. The package set. It fetches each component’s Packages index, verifies it against the release digest, and resolves the install set: the base system (every essential and required package, plus apt) together with the packages named by include, closed over their dependencies.
  3. 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 mode 0755, the mode base-files gives /: every .deb describes 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.
  4. 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.list and installs the archive keyring as its signed-by trust anchor, so the finished rootfs is apt-usable: an in-cage apt-get update verifies the release against that keyring without the debian-archive-keyring package, 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 _apt user 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::ensure publishes it. The first run pays for the bootstrap; every later run finds the root already published and skips it, so the --rootfs flag can stay in an invocation permanently.
  • The source is read-only. --source binds 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-buildpackage writes build products throughout a tree it builds.
  • The output is read-write. --output binds 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. --network shares 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:

  1. 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.
  2. The package set. It resolves the closure over what include names — dependencies, virtual providers, and install_if conditions to a fixed point — against the merged records of every configured repository.
  3. Download and verify. It downloads each package and binds it to the index that named it before anything is unpacked. See Trust.
  4. 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:

  1. The index is signed with RSA-4096 over SHA-1 (.SIGN.RSA.), verified against the repository’s key set.
  2. The index’s C: field identifies each package’s control segment by SHA-1 of its compressed bytes.
  3. .PKGINFO’s datahash binds 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:

DebianAlpine
Base setA priority band, seeded by base_priorityNone: apk records no priority. The set is exactly include closed over its dependencies
Signed thingInRelease, which carries the indexes’ digestsThe index itself; there is nothing above it
Digest chainSHA-256 throughoutSHA-1 to the control segment, SHA-256 to the file tree
Trust anchorAn OpenPGP keyring, with expiry and revocationA set of bare RSA keys, with neither, partitioned by architecture
Unsigned modetrust_unsigned, for a local poolNone; a private repository signs its index
FreshnessValid-Until, and allow_stale_release to relax itNone; the format expresses no validity window
Package identityA SHA-256 of the whole file, from the indexA SHA-1 of the control segment, plus datahash over the tree
Extract-onlyFiles only; no configured databaseFiles and the installed database
Configurationdpkg runs in a cageThe 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:

  1. 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.
  2. The signed digest. It matches the configured variant against that list, then fetches the .DIGESTS document beside the chosen build — cleartext- signed by the same key — and reads the SHA-512 it records for the tarball.
  3. 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:

RuleResolves cleanlyUnsatisfiable atomsConflictsp90 closure
newest build time262/400884129173
fewest dependencies338/40020674148
fewest USE flags361/4002650126

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:

PreferenceResolves cleanlyUnsatisfiable atomsConflictsp90 closure
none361/4002650126
keyring361/4002650127
-X361/4002650126
X354/4002660164
X, gtk, qt6, systemd284/40026110179

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 AlpineGentoo
What is installeda package closure this crate resolvesa prebuilt tarball, and prebuilt packages over it
The coordinatea suite or release, plus an architecturean architecture, plus a variant
Where resolution startsan empty roota root with 296 packages already in it
Replaya plan document, versioned and portablethe build id and variant for the stage3, a plan document for the packages
How long a pin lastsas long as the archive or a snapshot service keeps the versionsabout five weeks against the archive
FreshnessValid-Until, or an index refetched every runthe pointer’s own signed regeneration timestamp
What runs during a bootstrapmaintainer or install scripts, in a cagenothing; 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 Send or Sync. 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.

TraitHow the library holds itBounds
IdMapperStored in IdentityMapSend + Sync
FetchStored in DebianSend
Provisioner&mut dyn, for one ensure callNone
Observer&mut dyn, for one runNone

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:

InputType
A filesystem pathimpl AsRef<Path>
An owned string the callee keepsimpl Into<String>
Borrowed text the callee only reads&str
A collectionimpl 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.

  • Display strings on every type. Error messages are for humans and change as diagnostics improve. Match on the variant, never on the text.
  • SetupStep discriminants. 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 Progress or DebianEvent events 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 SeccompPolicy instead.
  • The default mount profile. The six /dev device nodes, the five /dev symlinks, the tmpfs /tmp, and the fresh /proc are defaults, not guarantees. A consumer that needs an exact set should turn the managed mounts off with managed_mounts(false) and declare its own — mounts apply in declaration order, so a raw_mount tmpfs followed by the binds that populate it reproduces a managed mount’s structure as well as its contents.
  • The base environment. PATH and HOME=/root, and nothing else, is the current default. A build whose output depends on the environment should set every variable it cares about explicitly through env, and use base_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) and base_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:

PromiseEnforcement
No breaking API change without a version bumpcargo-semver-checks against the last published release
No unintended public item, path, or re-exportA committed cargo-public-api snapshot, diffed in review
No public type loses Send/Syncconst assertions in tests/auto_traits.rs
Every feature combination compilescargo hack --feature-powerset --depth 2, plus an explicit featureless check
The MSRV holdsA CI build at the declared rust-version
The profile key set is deliberateExplicit #[serde(rename)] on every field, plus a test pinning the key set
The untrusted-profile surface cannot widen silentlycheck_restricted destructures CageBuilder exhaustively, so a new field fails to compile until it is classified
A userland reaching past the provisioning seam is a decisiontools/check-seam.py against the record in tools/seam.toml