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

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.