mod terminal;
use std::ffi::{OsStr, OsString};
use std::net::{Ipv4Addr, Ipv6Addr};
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Duration;
use ferroday_cage::provision::alpine::KeySet as AlpineKeySet;
use ferroday_cage::provision::debian::{Priority, Repository};
use ferroday_cage::relay::{Escalation, RelayError, Signals};
use ferroday_cage::{
Bind, Cage, CageBuilder, Capability, Error, ExitStatus, FsAccess, IdRange, Identity,
IdentityMap, Limit, Mount, NetAccess, NetStack, Network, Overlay, RawMount, Resource,
RestrictedProfile, Restriction, Running, SeccompArg, SeccompArgLen, SeccompCompare,
SeccompPolicy, SeccompRules, Stdio,
};
use serde::Deserialize as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Arity {
Bare,
One,
Two,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Launch,
Remove,
Export,
GentooVariants,
GentooPackages,
GentooInstalled,
GentooKeyring,
Help,
Version,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Section {
Provisioning,
Profiles,
Identity,
Command,
Networking,
Hardening,
Off,
Help,
Counterpart,
}
impl Section {
const ALL: &'static [Section] = &[
Section::Provisioning,
Section::Profiles,
Section::Identity,
Section::Command,
Section::Networking,
Section::Hardening,
Section::Off,
Section::Help,
Section::Counterpart,
];
const fn heading(self) -> Option<&'static str> {
match self {
Section::Provisioning => Some("Provisioning the root filesystem"),
Section::Profiles => Some("Profiles, overlays, and mounts"),
Section::Identity => Some("Identity and limits"),
Section::Command => Some("The command and its process"),
Section::Networking => Some("Networking"),
Section::Hardening => Some("Hardening"),
Section::Off => Some("Turning off what is on by default"),
Section::Help => Some("Help and version"),
Section::Counterpart => None,
}
}
}
#[derive(Clone, Copy)]
enum Take {
Bare(fn(&mut Options)),
One(fn(&mut Options, &'static str, OsString) -> Result<(), String>),
Two(fn(&mut Options, &'static str, OsString, OsString) -> Result<(), String>),
Refused(Arity),
}
#[derive(Clone, Copy)]
struct Flag {
long: &'static str,
short: Option<&'static str>,
placeholder: &'static str,
section: Section,
help: &'static [&'static str],
roster: bool,
mode: Mode,
selects: bool,
needs: &'static str,
take: Take,
}
impl Flag {
const DEFAULTS: Flag = Flag {
long: "",
short: None,
placeholder: "",
section: Section::Command,
help: &[],
roster: false,
mode: Mode::Launch,
selects: false,
needs: "a value",
take: Take::Refused(Arity::Bare),
};
const fn arity(&self) -> Arity {
match self.take {
Take::Bare(_) => Arity::Bare,
Take::One(_) => Arity::One,
Take::Two(_) => Arity::Two,
Take::Refused(arity) => arity,
}
}
fn spelled(&self) -> String {
let mut out = String::new();
if let Some(short) = self.short {
out.push_str(short);
out.push_str(", ");
}
out.push_str(self.long);
if !self.placeholder.is_empty() {
out.push(' ');
out.push_str(self.placeholder);
}
out
}
}
const FLAGS: &[Flag] = &[
Flag {
long: "--rootfs",
section: Section::Provisioning,
placeholder: "DIR",
help: &["Directory to present as the sandbox root filesystem"],
take: Take::One(|options, _flag, value| {
options.rootfs = Some(PathBuf::from(value));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--provision-tar",
section: Section::Provisioning,
placeholder: "FILE",
help: &[
"Provision --rootfs from a tar archive (plain, gzip,",
"xz, or zstd) when the directory does not exist yet",
],
take: Take::One(|options, _flag, value| {
options.provision_tar = Some(PathBuf::from(value));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--provision-debian",
section: Section::Provisioning,
placeholder: "SUITE",
help: &[
"Bootstrap --rootfs as a Debian SUITE (e.g. trixie)",
"from the archive; given without a command, provision",
"and exit",
],
take: Take::One(|options, flag, value| {
options.provision_debian = Some(into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--debian-arch",
section: Section::Provisioning,
placeholder: "ARCH",
help: &[
"Target architecture for --provision-debian (default:",
"the host architecture)",
],
take: Take::One(|options, flag, value| {
options.debian_arch = Some(into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--debian-mirror",
section: Section::Provisioning,
placeholder: "URL",
help: &["Archive mirror (default: http://deb.debian.org/debian)"],
take: Take::One(|options, flag, value| {
options.debian_mirror = Some(into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--debian-components",
section: Section::Provisioning,
placeholder: "L",
help: &["Comma-separated components (default: main; repeatable)"],
take: Take::One(|options, flag, value| {
extend_comma(&mut options.debian_components, &into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--debian-include",
section: Section::Provisioning,
placeholder: "L",
help: &["Extra packages to install (comma-separated, repeatable)"],
take: Take::One(|options, flag, value| {
extend_comma(&mut options.debian_include, &into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--debian-exclude",
section: Section::Provisioning,
placeholder: "L",
help: &[
"Packages to keep out of the resolution, with their",
"dependents (comma-separated, repeatable)",
],
take: Take::One(|options, flag, value| {
extend_comma(&mut options.debian_exclude, &into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--debian-plan",
section: Section::Provisioning,
placeholder: "FILE",
help: &[
"Install exactly the packages FILE names, resolving",
"nothing. FILE is a plan document a previous resolution",
"wrote; it supersedes --debian-include and",
"--debian-exclude",
],
take: Take::One(|options, _flag, value| {
options.debian_plan = Some(PathBuf::from(value));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--debian-pin",
section: Section::Provisioning,
placeholder: "FILE",
help: &[
"Hold the resolution to the versions the plan document",
"FILE records, resolving everything it does not name.",
"Where --debian-plan replaces a resolution, this",
"constrains one",
],
take: Take::One(|options, _flag, value| {
options.debian_pin = Some(PathBuf::from(value));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--debian-extract-only",
section: Section::Provisioning,
help: &[
"Lay out files without configuring them (runs no",
"maintainer scripts; for a foreign architecture)",
],
take: Take::Bare(|options| {
options.debian_extract_only = true;
}),
..Flag::DEFAULTS
},
Flag {
long: "--debian-cache",
section: Section::Provisioning,
placeholder: "DIR",
help: &["Cache downloaded packages in DIR, reused across runs"],
take: Take::One(|options, _flag, value| {
options.debian_cache = Some(PathBuf::from(value));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--debian-keyring",
section: Section::Provisioning,
placeholder: "FILE",
help: &["Verify the archive with FILE, not the embedded keyring"],
take: Take::One(|options, _flag, value| {
options.debian_keyring = Some(PathBuf::from(value));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--debian-mirror-fallback",
section: Section::Provisioning,
placeholder: "URL",
help: &[
"A further URL for the same archive, tried when the",
"primary mirror does not serve a resource (repeatable,",
"in order)",
],
take: Take::One(|options, flag, value| {
options
.debian_mirror_fallback
.push(into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--debian-base-priority",
section: Section::Provisioning,
placeholder: "P",
help: &[
"The priority floor the base set is drawn from:",
"required (default), important, standard, optional,",
"or extra",
],
take: Take::One(|options, flag, value| {
options.debian_base_priority = Some(parse_priority(&into_string(flag, value)?)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--debian-trust-unsigned",
section: Section::Provisioning,
help: &[
"Accept the archive without verifying its signature.",
"Every mirror must then be file:// or https://, the",
"transports that authenticate what they serve",
],
take: Take::Bare(|options| {
options.debian_trust_unsigned = true;
}),
..Flag::DEFAULTS
},
Flag {
long: "--debian-allow-stale-release",
section: Section::Provisioning,
help: &["Accept a signed release whose validity has expired"],
take: Take::Bare(|options| {
options.debian_allow_stale_release = true;
}),
..Flag::DEFAULTS
},
Flag {
long: "--debian-pre-configure-overlay",
section: Section::Provisioning,
placeholder: "DIR",
help: &[
"Overlay DIR onto the root after the files are laid",
"out and before any maintainer script runs",
],
take: Take::One(|options, _flag, value| {
options.debian_pre_configure_overlay = Some(PathBuf::from(value));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--debian-identity-map",
section: Section::Provisioning,
placeholder: "M",
help: &[
"Identity map for the bootstrap's own cages, in the",
"form --identity-map takes; a range map records real",
"ownership instead of the single-map stubs",
],
take: Take::One(|options, flag, value| {
options.debian_identity_map =
Some(parse_identity_map(flag, &into_string(flag, value)?)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--debian-repository",
section: Section::Provisioning,
placeholder: "SPEC",
help: &[
"An additional archive source, merged into the same",
"resolution. SPEC is space-separated key=value fields:",
"'suite=S mirror=URL [mirror-fallback=URL]",
"[components=a,b] [keyring=PATH] [name=NAME]",
"[trust-unsigned] [allow-stale-release]'. Repeatable",
],
take: Take::One(|options, flag, value| {
options
.debian_repositories
.push(parse_repository(&into_string(flag, value)?)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--provision-alpine",
section: Section::Provisioning,
placeholder: "RELEASE",
help: &[
"Bootstrap --rootfs as an Alpine RELEASE (e.g. v3.23)",
"from the archive; given without a command, provision",
"and exit",
],
take: Take::One(|options, flag, value| {
options.provision_alpine = Some(into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--alpine-arch",
section: Section::Provisioning,
placeholder: "ARCH",
help: &[
"Target architecture for --provision-alpine (default:",
"the host architecture)",
],
take: Take::One(|options, flag, value| {
options.alpine_arch = Some(into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--alpine-mirror",
section: Section::Provisioning,
placeholder: "URL",
help: &[
"Archive mirror (default:",
"http://dl-cdn.alpinelinux.org/alpine)",
],
take: Take::One(|options, flag, value| {
options.alpine_mirror = Some(into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--alpine-components",
section: Section::Provisioning,
placeholder: "L",
help: &["Comma-separated components (default: main; repeatable)"],
take: Take::One(|options, flag, value| {
extend_comma(&mut options.alpine_components, &into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--alpine-include",
section: Section::Provisioning,
placeholder: "L",
help: &[
"Packages to install (comma-separated, repeatable).",
"apk has no priority band, so nothing is installed that",
"was not named here or required by something that was",
],
take: Take::One(|options, flag, value| {
extend_comma(&mut options.alpine_include, &into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--alpine-exclude",
section: Section::Provisioning,
placeholder: "L",
help: &[
"Packages to keep out of the resolution (comma-separated,",
"repeatable)",
],
take: Take::One(|options, flag, value| {
extend_comma(&mut options.alpine_exclude, &into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--alpine-plan",
section: Section::Provisioning,
placeholder: "FILE",
help: &[
"Install exactly the packages FILE names, resolving",
"nothing. FILE is a plan document a previous resolution",
"wrote; it supersedes --alpine-include and",
"--alpine-exclude",
],
take: Take::One(|options, _flag, value| {
options.alpine_plan = Some(PathBuf::from(value));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--alpine-pin",
section: Section::Provisioning,
placeholder: "FILE",
help: &[
"Hold the resolution to the versions the plan document",
"FILE records, resolving everything it does not name.",
"Where --alpine-plan replaces a resolution, this",
"constrains one",
],
take: Take::One(|options, _flag, value| {
options.alpine_pin = Some(PathBuf::from(value));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--alpine-extract-only",
section: Section::Provisioning,
help: &[
"Lay out files and register them without running any",
"install script (for a foreign architecture)",
],
take: Take::Bare(|options| {
options.alpine_extract_only = true;
}),
..Flag::DEFAULTS
},
Flag {
long: "--alpine-cache",
section: Section::Provisioning,
placeholder: "DIR",
help: &["Cache downloaded packages in DIR, reused across runs"],
take: Take::One(|options, _flag, value| {
options.alpine_cache = Some(PathBuf::from(value));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--alpine-keys",
section: Section::Provisioning,
placeholder: "SOURCE",
help: &[
"The keys the archive is verified against: 'alpine' (the",
"bundled set for the target architecture, the default),",
"'postmarketos', or a directory of PEM public keys named",
"as the repository publishes them",
],
take: Take::One(|options, flag, value| {
options.alpine_keys = Some(parse_alpine_keys(flag, &into_string(flag, value)?)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--alpine-mirror-fallback",
section: Section::Provisioning,
placeholder: "URL",
help: &[
"A further URL for the same archive, tried when the",
"primary mirror does not serve a resource (repeatable,",
"in order)",
],
take: Take::One(|options, flag, value| {
options
.alpine_mirror_fallback
.push(into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--alpine-pre-configure-overlay",
section: Section::Provisioning,
placeholder: "DIR",
help: &[
"Overlay DIR onto the root after the files are laid",
"out and before any install script runs",
],
take: Take::One(|options, _flag, value| {
options.alpine_pre_configure_overlay = Some(PathBuf::from(value));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--alpine-identity-map",
section: Section::Provisioning,
placeholder: "M",
help: &[
"Identity map for the bootstrap's own cages, in the",
"form --identity-map takes",
],
take: Take::One(|options, flag, value| {
options.alpine_identity_map =
Some(parse_identity_map(flag, &into_string(flag, value)?)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--alpine-repository",
section: Section::Provisioning,
placeholder: "SPEC",
help: &[
"An additional archive source, merged into the same",
"resolution. SPEC is space-separated key=value fields:",
"'release=R mirror=URL keys=SOURCE [mirror-fallback=URL]",
"[components=a,b]'. Naming no components selects the",
"<release>/<architecture> layout postmarketOS publishes.",
"Repeatable",
],
take: Take::One(|options, flag, value| {
options
.alpine_repositories
.push(parse_alpine_repository(&into_string(flag, value)?)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--provision-gentoo",
section: Section::Provisioning,
placeholder: "ARCH",
help: &[
"Bootstrap --rootfs from a signed Gentoo stage3 for",
"ARCH (e.g. amd64); given without a command, provision",
"and exit. Gentoo publishes one tree per architecture",
"rather than a release, so ARCH is the coordinate",
],
take: Take::One(|options, flag, value| {
options.provision_gentoo = Some(into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--gentoo-variant",
section: Section::Provisioning,
placeholder: "NAME",
help: &[
"The stage3 to install, as the archive spells it:",
"'amd64-openrc', 'amd64-hardened-systemd', 'x32-openrc'.",
"--gentoo-variants lists what an architecture offers",
],
take: Take::One(|options, flag, value| {
options.gentoo_variant = Some(into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--gentoo-build-id",
section: Section::Provisioning,
placeholder: "ID",
help: &[
"Install the build in directory ID (e.g.",
"20260810T204554Z) rather than the current one. The",
"archive keeps about five weeks of builds, so a pinned",
"id resolves against it for roughly a month and against",
"a populated --gentoo-cache indefinitely",
],
take: Take::One(|options, flag, value| {
options.gentoo_build_id = Some(into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--gentoo-binhost",
section: Section::Provisioning,
placeholder: "SUBARCH",
help: &[
"Install binary packages from the binhost tree for",
"SUBARCH ('x86-64', 'x86-64-v3', 'x86-64_hardened',",
"'x32'). There is no default: nothing signed enumerates",
"the trees, so a wrong one is a 404 rather than a",
"silently different userland",
],
take: Take::One(|options, flag, value| {
options.gentoo_binhost = Some(into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--gentoo-install",
section: Section::Provisioning,
placeholder: "ATOM",
help: &[
"Install ATOM and its runtime dependencies from the",
"binhost, in Gentoo's own dependency grammar:",
"'dev-vcs/git', '>=dev-lang/python-3.12',",
"'dev-libs/openssl:0'. Repeatable. A USE dependency in",
"an atom is a constraint -- 'dev-vcs/git[keyring]'",
"refuses to install a build without the flag",
],
take: Take::One(|options, flag, value| {
options.gentoo_install.push(into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--gentoo-prefer-use",
section: Section::Provisioning,
placeholder: "FLAGS",
help: &[
"Prefer builds carrying these space-separated USE flags,",
"and builds without the ones written '-flag'. A",
"preference, not a constraint: it reorders the candidate",
"builds and cannot make an atom unsatisfiable. Repeatable",
],
take: Take::One(|options, flag, value| {
options.gentoo_prefer_use.extend(
into_string(flag, value)?
.split_ascii_whitespace()
.map(str::to_string),
);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--gentoo-plan",
section: Section::Provisioning,
placeholder: "FILE",
help: &[
"Install exactly the packages FILE names, resolving",
"nothing. FILE is a plan document a previous run wrote;",
"it supersedes --gentoo-install and --gentoo-prefer-use",
],
take: Take::One(|options, _flag, value| {
options.gentoo_plan = Some(PathBuf::from(value));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--gentoo-mirror",
section: Section::Provisioning,
placeholder: "URL",
help: &["Archive mirror (default:", "http://distfiles.gentoo.org)"],
take: Take::One(|options, flag, value| {
options.gentoo_mirror = Some(into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--gentoo-mirror-fallback",
section: Section::Provisioning,
placeholder: "URL",
help: &[
"A further URL for the same archive, tried when the",
"primary mirror does not serve a resource (repeatable,",
"in order)",
],
take: Take::One(|options, flag, value| {
options
.gentoo_mirror_fallback
.push(into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--gentoo-cache",
section: Section::Provisioning,
placeholder: "DIR",
help: &[
"Cache the downloaded stage3 and binary packages in DIR,",
"reused across runs",
],
take: Take::One(|options, _flag, value| {
options.gentoo_cache = Some(PathBuf::from(value));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--gentoo-max-pointer-age",
section: Section::Provisioning,
placeholder: "DAYS",
help: &[
"Refuse a signed pointer document regenerated more than",
"DAYS ago (default: 30), or 'none' to accept one of any",
"age. Gentoo publishes no expiry of any kind, so this",
"timestamp is the only thing that says when a document",
"stopped being current; clearing the bound accepts a",
"replayed one, which is what an archived mirror needs",
],
take: Take::One(|options, flag, value| {
options.gentoo_max_pointer_age = Some(parse_pointer_age(&into_string(flag, value)?)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--gentoo-variants",
section: Section::Provisioning,
placeholder: "ARCH",
help: &[
"Print the stage3 variants ARCH publishes, from the",
"signed enumeration, and exit. Takes --gentoo-mirror,",
"--gentoo-mirror-fallback and --gentoo-max-pointer-age",
],
mode: Mode::GentooVariants,
selects: true,
take: Take::Refused(Arity::One),
..Flag::DEFAULTS
},
Flag {
long: "--gentoo-packages",
section: Section::Provisioning,
placeholder: "ARCH",
help: &[
"Print what the binhost publishes for ARCH: every",
"package name and its versions, and exit. Takes",
"--gentoo-binhost, --gentoo-mirror and",
"--gentoo-mirror-fallback",
],
mode: Mode::GentooPackages,
selects: true,
take: Take::Refused(Arity::One),
..Flag::DEFAULTS
},
Flag {
long: "--gentoo-installed",
section: Section::Provisioning,
placeholder: "ROOT",
help: &[
"Print what the Gentoo root at ROOT already has",
"installed, from its own package database, and exit.",
"Reaches no network",
],
mode: Mode::GentooInstalled,
selects: true,
take: Take::Refused(Arity::One),
..Flag::DEFAULTS
},
Flag {
long: "--gentoo-keyring-horizon",
section: Section::Provisioning,
help: &[
"Print the vendored Gentoo keyring: every certificate,",
"whether it can still sign, and when its authority",
"lapses. Reaches no network",
],
mode: Mode::GentooKeyring,
selects: true,
take: Take::Refused(Arity::Bare),
..Flag::DEFAULTS
},
Flag {
long: "--remove-rootfs",
section: Section::Provisioning,
placeholder: "DIR",
help: &[
"Delete the provisioned rootfs at DIR and exit,",
"including one a range-mapped sandbox wrote and a plain",
"'rm -rf' cannot remove. Give the --identity-map the",
"tree was provisioned under; the default is",
"'subordinate'. Removing a path that is not there",
"succeeds, so the call is repeatable. Takes no other",
"option: it removes a rootfs rather than running a",
"command in one",
],
mode: Mode::Remove,
selects: true,
take: Take::Refused(Arity::One),
..Flag::DEFAULTS
},
Flag {
long: "--export-rootfs",
section: Section::Provisioning,
placeholder: "DIR",
help: &[
"Write the provisioned rootfs at DIR to a tar archive",
"and exit, preserving the ownership the tree intends",
"and the extended attributes a host-side 'tar' cannot",
"read. Give the --identity-map the tree was provisioned",
"under; the default is 'subordinate'. Takes no other",
"option: it exports a rootfs rather than running a",
"command in one",
],
mode: Mode::Export,
selects: true,
take: Take::Refused(Arity::One),
..Flag::DEFAULTS
},
Flag {
long: "--export-to",
section: Section::Provisioning,
placeholder: "FILE",
help: &[
"Where --export-rootfs writes. The default is standard",
"output, so the archive pipes into a compressor without",
"a temporary file; writing one to a terminal is refused",
],
mode: Mode::Export,
take: Take::Refused(Arity::One),
..Flag::DEFAULTS
},
Flag {
long: "--clamp-mtime",
section: Section::Provisioning,
placeholder: "EPOCH",
help: &[
"Cap every modification time --export-rootfs records at",
"EPOCH, a Unix timestamp in seconds. With it the archive",
"is byte-reproducible given the tree",
],
mode: Mode::Export,
take: Take::Refused(Arity::One),
..Flag::DEFAULTS
},
Flag {
long: "--profile",
section: Section::Profiles,
placeholder: "FILE",
help: &[
"Load a TOML sandbox profile; flags given alongside",
"override its settings, binds and variables extend it,",
"and any [hardening] table it carries takes effect. A",
"profile is trusted 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, or use --restricted-profile",
],
take: Take::One(|options, _flag, value| {
let path = PathBuf::from(value);
set_profile(options, path, false)?;
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--restricted-profile",
section: Section::Profiles,
placeholder: "FILE",
help: &[
"Load a TOML profile from an untrusted source: like",
"--profile, but the operations that map host resources",
"into the sandbox or share a host namespace (binds, raw",
"mounts, host networking, the host PID namespace) are",
"refused. The rootfs it names still grants access to that",
"host subtree, so pass a rootfs you control",
],
take: Take::One(|options, _flag, value| {
let path = PathBuf::from(value);
set_profile(options, path, true)?;
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--overlay-lower",
section: Section::Profiles,
placeholder: "DIR",
help: &[
"Root the sandbox on an overlay whose base is DIR",
"(repeatable and ordered: the first is the base, each",
"later one is laid over it). Alternative to --rootfs",
],
take: Take::One(|options, _flag, value| {
options.overlay_lowers.push(PathBuf::from(value));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--overlay-upper",
section: Section::Profiles,
placeholder: "DIR",
help: &[
"Where the overlay's writes land; DIR persists after",
"the sandbox exits, so discarding it reverts the run.",
"Required with --overlay-lower",
],
take: Take::One(|options, _flag, value| {
options.overlay_upper = Some(PathBuf::from(value));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--overlay-work",
section: Section::Profiles,
placeholder: "DIR",
help: &[
"Override the overlay's work directory, which must sit",
"on the same filesystem as the upper",
],
take: Take::One(|options, _flag, value| {
options.overlay_work = Some(PathBuf::from(value));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--bind",
section: Section::Profiles,
placeholder: "SRC DEST",
help: &["Bind-mount host path SRC read-write at DEST"],
needs: "a source and a target",
take: Take::Two(|options, _flag, value, second| {
let source = value;
let target = second;
options.mounts.push(Mount::Bind(
Bind::new(PathBuf::from(source), PathBuf::from(target)).read_only(false),
));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--ro-bind",
section: Section::Profiles,
placeholder: "SRC DEST",
help: &["Bind-mount host path SRC read-only at DEST"],
needs: "a source and a target",
take: Take::Two(|options, _flag, value, second| {
let source = value;
let target = second;
options.mounts.push(Mount::Bind(
Bind::new(PathBuf::from(source), PathBuf::from(target)).read_only(true),
));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--raw-mount",
section: Section::Profiles,
placeholder: "SPEC",
help: &[
"A mount the typed options do not model, passed to the",
"kernel as given. SPEC is space-separated key=value",
"fields: 'target=/sys fstype=sysfs flags=0xE",
"[source=...] [data=...]'. Only the target is validated",
"and confined. Repeatable.",
"Mounts apply in the order these flags appear, so a",
"--raw-mount tmpfs followed by --bind flags into it",
"builds a directory the sandbox owns outright",
],
take: Take::One(|options, flag, value| {
options
.mounts
.push(Mount::Raw(parse_raw_mount(&into_string(flag, value)?)?));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--identity-map",
section: Section::Identity,
placeholder: "M",
help: &[
"How the user namespace maps identities: 'single'",
"(default: root inside is you outside, and no other id",
"exists), 'subordinate' (root plus your whole",
"subordinate allocation, through newuidmap/newgidmap),",
"or explicit extents,",
"'uid=IN:OUT:COUNT[,...] gid=IN:OUT:COUNT[,...]'",
],
take: Take::One(|options, flag, value| {
options.identity_map = Some(parse_identity_map(flag, &into_string(flag, value)?)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--run-as",
section: Section::Identity,
placeholder: "UID:GID[:G,...]",
help: &[
"Run the command as a non-root identity inside the",
"sandbox, with optional supplementary groups. Every id",
"must be contained in the identity map; groups need a",
"range gid map",
],
take: Take::One(|options, flag, value| {
options.run_as = Some(parse_run_as(&into_string(flag, value)?)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--rlimit",
section: Section::Identity,
placeholder: "RES=SOFT[:HARD]",
help: &[
"Set a resource limit on the command, inherited by",
"every process it starts. A value may be 'unlimited',",
"and an omitted hard limit repeats the soft one.",
"Repeatable (e.g. --rlimit processes=64 --rlimit",
"address-space=536870912). RES is one of:",
],
roster: true,
take: Take::One(|options, flag, value| {
options
.rlimits
.push(parse_rlimit(&into_string(flag, value)?)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--path-lookup",
section: Section::Command,
help: &[
"Resolve a command with no slash against the sandbox's",
"PATH, the way a shell does",
],
take: Take::Bare(|options| {
options.path_lookup = Some(true);
}),
..Flag::DEFAULTS
},
Flag {
long: "--setenv",
section: Section::Command,
placeholder: "NAME VALUE",
help: &["Set an environment variable for the command"],
needs: "a name and a value",
take: Take::Two(|options, flag, value, second| {
if value.is_empty() || value.as_bytes().contains(&b'=') {
return Err(format!(
"{flag}: {value:?} is not a valid environment variable name \
(it is empty or contains '=')"
));
}
options.setenv.push((value, second));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--hostname",
section: Section::Command,
placeholder: "NAME",
help: &["Set the hostname inside the sandbox"],
take: Take::One(|options, _flag, value| {
options.hostname = Some(value);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--chdir",
section: Section::Command,
placeholder: "DIR",
help: &["Working directory inside the sandbox (default /)"],
take: Take::One(|options, _flag, value| {
options.chdir = Some(PathBuf::from(value));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--stdin",
section: Section::Command,
placeholder: "MODE",
help: &[
"Standard input: inherit (default) or null. With null",
"the command also runs in a session of its own, so the",
"terminal fcage was started from is not its controlling",
"terminal; an interrupt there stops fcage, which stops",
"the sandbox. Standard output and standard error still",
"reach that terminal unless redirected, which a shell",
"already spells and a profile carries as stdout/stderr",
],
take: Take::One(|options, _flag, value| {
options.stdin = Some(parse_stdin(&value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--terminal",
section: Section::Command,
help: &[
"Give the sandbox a pseudoterminal of its own and relay",
"this terminal to it: interactive programs work, and the",
"sandbox reaches nothing of the terminal fcage was",
"started from. TERM passes through as ever, so set it",
"with --setenv TERM to override. From a script, where",
"fcage's own input is not a terminal, this degrades to a",
"plain byte relay around a sandbox that still has a real",
"terminal of its own, at 80x24",
],
take: Take::Bare(|options| {
options.terminal = true;
}),
..Flag::DEFAULTS
},
Flag {
long: "--timeout",
section: Section::Command,
placeholder: "SECS",
help: &[
"Terminate the command after SECS seconds, kill it",
"--kill-after seconds later, and exit 124",
],
take: Take::One(|options, flag, value| {
options.timeout = Some(parse_seconds(flag, &value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--kill-after",
section: Section::Command,
placeholder: "SECS",
help: &["Grace between terminate and kill (default 10)"],
take: Take::One(|options, flag, value| {
options.kill_after = parse_seconds(flag, &value)?;
options.kill_after_given = true;
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--stop-with-caller",
section: Section::Command,
help: &[
"Stop the sandbox when fcage itself exits (the default;",
"--no-stop-with-caller leaves it running)",
],
take: Take::Bare(|options| {
options.stop_with_caller = Some(true);
}),
..Flag::DEFAULTS
},
Flag {
long: "--share-net",
section: Section::Networking,
help: &["Share the host network (default: isolated loopback)"],
take: Take::Bare(|options| {
options.network = Some(Network::Host);
}),
..Flag::DEFAULTS
},
Flag {
long: "--deny-net",
section: Section::Networking,
help: &[
"Isolated network with loopback left down (no",
"connectivity at all, not even 127.0.0.1)",
],
take: Take::Bare(|options| {
options.network = Some(Network::None);
}),
..Flag::DEFAULTS
},
Flag {
long: "--netstack",
section: Section::Networking,
help: &[
"Attach the native userspace network stack: outbound",
"IPv4 and IPv6 for the isolated network, forwarded over",
"host sockets with no external helper. Composes the",
"sandbox's resolv.conf from the host's routable",
"nameservers unless --no-resolv-conf or",
"--no-managed-mounts is given; a host with only loopback",
"nameservers composes nothing, leaving the rootfs's own",
"resolv.conf in place",
],
take: Take::Bare(|options| {
options.netstack = true;
}),
..Flag::DEFAULTS
},
Flag {
long: "--netstack-cidr",
section: Section::Networking,
placeholder: "V4/LEN",
help: &[
"IPv4 network for --netstack (default 10.0.2.0/24; the",
"gateway is host 2, the guest host 15)",
],
take: Take::One(|options, flag, value| {
options.netstack_cidr = Some(parse_cidr(&into_string(flag, value)?)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--netstack-cidr6",
section: Section::Networking,
placeholder: "V6/LEN",
help: &[
"IPv6 network for --netstack (default fd00::/64; the",
"gateway is host 2, the guest host 15)",
],
take: Take::One(|options, flag, value| {
options.netstack_cidr6 = Some(parse_cidr6(&into_string(flag, value)?)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--netstack-no-ipv4",
section: Section::Networking,
help: &[
"Run the stack without IPv4, for a workload or a host",
"where it is unwanted. The guest is then given no IPv4",
"address and the stack answers no IPv4 traffic",
],
take: Take::Bare(|options| {
options.netstack_no_ipv4 = true;
}),
..Flag::DEFAULTS
},
Flag {
long: "--netstack-no-ipv6",
section: Section::Networking,
help: &[
"Run the stack without IPv6, for a host that offers no",
"IPv6 connectivity. The guest is then given no IPv6",
"address and the stack answers no IPv6 traffic",
],
take: Take::Bare(|options| {
options.netstack_no_ipv6 = true;
}),
..Flag::DEFAULTS
},
Flag {
long: "--netstack-interface",
section: Section::Networking,
placeholder: "NAME",
help: &[
"Name the stack's interface inside the sandbox (default",
"tap0; 1 to 15 bytes, free of '/', ':', and whitespace)",
],
take: Take::One(|options, flag, value| {
options.netstack_interface = Some(into_string(flag, value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--netstack-mtu",
section: Section::Networking,
placeholder: "N",
help: &[
"Interface MTU for --netstack (default 1500; the minimum",
"is 576, or 1280 while IPv6 is enabled)",
],
take: Take::One(|options, flag, value| {
options.netstack_mtu = Some(parse_mtu(&into_string(flag, value)?)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--netstack-host-loopback",
section: Section::Networking,
help: &[
"Map connections to the gateway address onto the host's",
"127.0.0.1, port preserved, to reach a service bound",
"there (off by default; every other loopback destination",
"stays blocked, including a resolver on another loopback",
"address such as systemd-resolved's 127.0.0.53 stub)",
],
take: Take::Bare(|options| {
options.netstack_host_loopback = true;
}),
..Flag::DEFAULTS
},
Flag {
long: "--restrict",
section: Section::Hardening,
help: &[
"Confine the command with Landlock and seccomp only:",
"no namespaces, no root filesystem swap, and paths are",
"host paths. The fallback for hosts without",
"unprivileged user namespaces; requires at least one",
"--landlock-* or --seccomp flag, and accepts only",
"those plus --setenv, --no-base-env, --chdir,",
"--stdin, --terminal, --timeout, --kill-after,",
"--rlimit, and --stop-with-caller",
],
take: Take::Bare(|options| {
options.restrict = true;
}),
..Flag::DEFAULTS
},
Flag {
long: "--landlock-ro",
section: Section::Hardening,
placeholder: "PATH",
help: &[
"Grant the command read and execute beneath PATH under",
"a Landlock ruleset (repeatable; enrolling any grant",
"denies all filesystem access not granted)",
],
take: Take::One(|options, _flag, value| {
let path = value;
options.landlock.push((PathBuf::from(path), false));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--landlock-rw",
section: Section::Hardening,
placeholder: "PATH",
help: &[
"Grant read, write, and execute beneath PATH",
"(repeatable)",
],
take: Take::One(|options, _flag, value| {
let path = value;
options.landlock.push((PathBuf::from(path), true));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--landlock-bind",
section: Section::Hardening,
placeholder: "PORT",
help: &[
"Allow binding a TCP socket to PORT under a Landlock",
"ruleset (repeatable; enrolling any network grant denies",
"every bind and connect not granted; port 0 permits a",
"kernel-assigned ephemeral port; needs Landlock ABI 4)",
],
take: Take::One(|options, flag, value| {
let value = into_string(flag, value)?;
options
.landlock_net
.push((parse_port(flag, &value)?, NetAccess::BIND));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--landlock-connect",
section: Section::Hardening,
placeholder: "PORT",
help: &["Allow connecting a TCP socket to PORT (repeatable)"],
take: Take::One(|options, flag, value| {
let value = into_string(flag, value)?;
options
.landlock_net
.push((parse_port(flag, &value)?, NetAccess::CONNECT));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--seccomp",
section: Section::Hardening,
placeholder: "curated",
help: &[
"Apply the curated seccomp deny-list of dangerous",
"syscalls to the command",
],
take: Take::One(|options, flag, value| {
parse_seccomp(&into_string(flag, value)?, &mut options.seccomp_curated)?;
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--seccomp-allow",
section: Section::Hardening,
placeholder: "LIST",
help: &[
"Allow only these syscalls, denying the rest with EPERM",
"(comma-separated names, repeatable; must name every",
"syscall the command needs)",
],
take: Take::One(|options, flag, value| {
let value = into_string(flag, value)?;
parse_seccomp_names(flag, &value, &mut options.seccomp_allow)?;
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--seccomp-deny",
section: Section::Hardening,
placeholder: "LIST",
help: &[
"Deny these syscalls with EPERM, allowing the rest",
"(comma-separated names, repeatable)",
],
take: Take::One(|options, flag, value| {
let value = into_string(flag, value)?;
parse_seccomp_names(flag, &value, &mut options.seccomp_deny)?;
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--seccomp-allow-rule",
section: Section::Hardening,
placeholder: "RULE",
help: &[
"Allow a syscall only when its arguments match; RULE is",
"a syscall name and comma-separated conditions, each of",
"the form 'arg=N len=dword|qword op=OP value=V', with",
"op one of eq, ne, ge, gt, le, lt, or masked-eq (which",
"also takes mask=M). Repeatable; repeating a syscall",
"accepts any of the rules. Example:",
"'ioctl arg=1 len=dword op=eq value=0x5413'",
],
take: Take::One(|options, flag, value| {
let value = into_string(flag, value)?;
options
.seccomp_allow_rules
.push(parse_seccomp_rule(flag, &value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--seccomp-deny-rule",
section: Section::Hardening,
placeholder: "RULE",
help: &[
"Deny a syscall only when its arguments match, in the",
"same form as --seccomp-allow-rule",
],
take: Take::One(|options, flag, value| {
let value = into_string(flag, value)?;
options
.seccomp_deny_rules
.push(parse_seccomp_rule(flag, &value)?);
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--drop-caps",
section: Section::Hardening,
help: &["Drop every capability from the command"],
take: Take::Bare(|options| {
options.caps = Some(CapsChoice::DropAll);
}),
..Flag::DEFAULTS
},
Flag {
long: "--keep-caps",
section: Section::Hardening,
placeholder: "LIST",
help: &[
"Drop every capability except LIST (comma-separated,",
"e.g. net-bind-service,sys-chroot)",
],
take: Take::One(|options, flag, value| {
options.caps = Some(CapsChoice::Keep(parse_caps(&into_string(flag, value)?)?));
Ok(())
}),
..Flag::DEFAULTS
},
Flag {
long: "--share-pid",
section: Section::Off,
help: &["Share the host PID namespace (default: isolated)"],
take: Take::Bare(|options| {
options.pid_namespace = Some(false);
}),
..Flag::DEFAULTS
},
Flag {
long: "--no-proc",
section: Section::Off,
help: &[
"Do not mount /proc. A cage needs a procfs from",
"somewhere -- the nested user namespace that locks its",
"mount flags establishes its identity map through one --",
"so a run that sets this supplies its own with --bind",
],
take: Take::Bare(|options| {
options.mount_proc = Some(false);
}),
..Flag::DEFAULTS
},
Flag {
long: "--no-dev",
section: Section::Off,
help: &["Do not assemble the minimal /dev"],
take: Take::Bare(|options| {
options.mount_dev = Some(false);
}),
..Flag::DEFAULTS
},
Flag {
long: "--no-tmp",
section: Section::Off,
help: &["Do not mount a tmpfs on /tmp"],
take: Take::Bare(|options| {
options.mount_tmp = Some(false);
}),
..Flag::DEFAULTS
},
Flag {
long: "--no-resolv-conf",
section: Section::Off,
help: &[
"Do not bind the host resolv.conf: neither the bind",
"--share-net makes nor the one --netstack composes",
],
take: Take::Bare(|options| {
options.resolv_conf = Some(false);
}),
..Flag::DEFAULTS
},
Flag {
long: "--no-managed-mounts",
section: Section::Off,
help: &[
"Establish no mount of the library's own, so the sandbox",
"carries exactly the --bind and --raw-mount flags given.",
"Unlike the individual --no-* toggles this also excludes",
"any managed mount a later release adds. One of the flags",
"given must supply a procfs; see --no-proc",
],
take: Take::Bare(|options| {
options.managed_mounts = Some(false);
}),
..Flag::DEFAULTS
},
Flag {
long: "--no-base-env",
section: Section::Off,
help: &[
"Give the command exactly the --setenv variables, with",
"no PATH or HOME supplied underneath them",
],
take: Take::Bare(|options| {
options.base_env = Some(false);
}),
..Flag::DEFAULTS
},
Flag {
long: "--isolate-net",
section: Section::Counterpart,
take: Take::Bare(|options| {
options.network = Some(Network::Isolated);
}),
..Flag::DEFAULTS
},
Flag {
long: "--pid-ns",
section: Section::Counterpart,
take: Take::Bare(|options| {
options.pid_namespace = Some(true);
}),
..Flag::DEFAULTS
},
Flag {
long: "--proc",
section: Section::Counterpart,
take: Take::Bare(|options| {
options.mount_proc = Some(true);
}),
..Flag::DEFAULTS
},
Flag {
long: "--dev",
section: Section::Counterpart,
take: Take::Bare(|options| {
options.mount_dev = Some(true);
}),
..Flag::DEFAULTS
},
Flag {
long: "--tmp",
section: Section::Counterpart,
take: Take::Bare(|options| {
options.mount_tmp = Some(true);
}),
..Flag::DEFAULTS
},
Flag {
long: "--resolv-conf",
section: Section::Counterpart,
take: Take::Bare(|options| {
options.resolv_conf = Some(true);
}),
..Flag::DEFAULTS
},
Flag {
long: "--managed-mounts",
section: Section::Counterpart,
take: Take::Bare(|options| {
options.managed_mounts = Some(true);
}),
..Flag::DEFAULTS
},
Flag {
long: "--base-env",
section: Section::Counterpart,
take: Take::Bare(|options| {
options.base_env = Some(true);
}),
..Flag::DEFAULTS
},
Flag {
long: "--no-path-lookup",
section: Section::Counterpart,
take: Take::Bare(|options| {
options.path_lookup = Some(false);
}),
..Flag::DEFAULTS
},
Flag {
long: "--no-stop-with-caller",
section: Section::Counterpart,
take: Take::Bare(|options| {
options.stop_with_caller = Some(false);
}),
..Flag::DEFAULTS
},
Flag {
long: "--help",
section: Section::Help,
short: Some("-h"),
help: &["Print this help"],
mode: Mode::Help,
take: Take::Refused(Arity::Bare),
..Flag::DEFAULTS
},
Flag {
long: "--version",
section: Section::Help,
short: Some("-V"),
help: &["Print the version"],
mode: Mode::Version,
take: Take::Refused(Arity::Bare),
..Flag::DEFAULTS
},
];
const SYNOPSIS: &str = "\
Run a command inside an unprivileged Linux sandbox.
Usage: fcage [--rootfs DIR | --overlay-lower DIR | --profile FILE | --restricted-profile FILE | --restrict] [OPTIONS] [--] [COMMAND [ARGS...]]
fcage --remove-rootfs DIR [--identity-map M]
fcage --export-rootfs DIR [--export-to FILE] [--identity-map M] [--clamp-mtime EPOCH]
";
const CLOSING: &str = "\
Each toggle has a counterpart to override a profile in the other direction:
--isolate-net, --pid-ns, --proc, --dev, --tmp, --resolv-conf,
--managed-mounts, --base-env, --stop-with-caller, and --no-path-lookup.
A profile's [hardening] table composes with the hardening flags:
--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 are mutually
constrained: --seccomp curated stands alone, and an allow side
(--seccomp-allow, --seccomp-allow-rule) cannot be mixed with a deny side
(--seccomp-deny, --seccomp-deny-rule).
The command path is interpreted inside the sandbox (with --restrict, on the
host) and must be absolute; a command given on the command line replaces one
named by the profile. The command runs with inherited standard streams and a
clean environment: PATH and HOME, the host TERM when set, profile variables,
and the --setenv values. With --restrict the base is PATH alone, there being
no sandbox root for HOME to name. --no-base-env drops the base either way.
Exit status: the command's own exit code, or 128 plus the signal number
when it is terminated by a signal, or 124 when --timeout expires. fcage
itself exits 125 when the sandbox cannot be built or launched, 126 when
the command exists but cannot be executed, 127 when the command does not
exist, and 2 on usage errors.
";
enum Invocation {
Run(Box<Options>),
Remove {
dest: PathBuf,
map: IdentityMap,
},
GentooVariants {
architecture: String,
mirror: Option<String>,
fallbacks: Vec<String>,
max_pointer_age: Option<Option<u64>>,
},
GentooPackages {
architecture: String,
binhost: Option<String>,
mirror: Option<String>,
fallbacks: Vec<String>,
},
GentooInstalled {
root: PathBuf,
},
GentooKeyring,
Export {
source: PathBuf,
dest: Option<PathBuf>,
map: IdentityMap,
clamp_mtime: Option<i64>,
},
Help,
Version,
}
struct Options {
profile: Option<PathBuf>,
profile_restricted: bool,
rootfs: Option<PathBuf>,
provision_tar: Option<PathBuf>,
provision_debian: Option<String>,
debian_arch: Option<String>,
debian_mirror: Option<String>,
debian_components: Vec<String>,
debian_include: Vec<String>,
debian_exclude: Vec<String>,
debian_plan: Option<PathBuf>,
debian_pin: Option<PathBuf>,
debian_extract_only: bool,
debian_cache: Option<PathBuf>,
debian_keyring: Option<PathBuf>,
debian_mirror_fallback: Vec<String>,
debian_base_priority: Option<Priority>,
debian_trust_unsigned: bool,
debian_allow_stale_release: bool,
debian_pre_configure_overlay: Option<PathBuf>,
debian_identity_map: Option<IdentityMap>,
debian_repositories: Vec<Repository>,
provision_alpine: Option<String>,
alpine_arch: Option<String>,
alpine_mirror: Option<String>,
alpine_components: Vec<String>,
alpine_include: Vec<String>,
alpine_exclude: Vec<String>,
alpine_plan: Option<PathBuf>,
alpine_pin: Option<PathBuf>,
alpine_extract_only: bool,
alpine_cache: Option<PathBuf>,
alpine_keys: Option<AlpineKeys>,
alpine_mirror_fallback: Vec<String>,
alpine_pre_configure_overlay: Option<PathBuf>,
alpine_identity_map: Option<IdentityMap>,
alpine_repositories: Vec<AlpineRepositorySpec>,
provision_gentoo: Option<String>,
gentoo_variant: Option<String>,
gentoo_binhost: Option<String>,
gentoo_install: Vec<String>,
gentoo_prefer_use: Vec<String>,
gentoo_plan: Option<PathBuf>,
gentoo_build_id: Option<String>,
gentoo_mirror: Option<String>,
gentoo_mirror_fallback: Vec<String>,
gentoo_cache: Option<PathBuf>,
gentoo_max_pointer_age: Option<Option<u64>>,
command_line: Vec<OsString>,
path_lookup: Option<bool>,
mounts: Vec<Mount>,
overlay_lowers: Vec<PathBuf>,
overlay_upper: Option<PathBuf>,
overlay_work: Option<PathBuf>,
identity_map: Option<IdentityMap>,
run_as: Option<Identity>,
rlimits: Vec<(Resource, Limit, Limit)>,
setenv: Vec<(OsString, OsString)>,
hostname: Option<OsString>,
chdir: Option<PathBuf>,
stdin: Option<Stdio>,
terminal: bool,
timeout: Option<Duration>,
kill_after: Duration,
kill_after_given: bool,
network: Option<Network>,
pid_namespace: Option<bool>,
mount_proc: Option<bool>,
mount_dev: Option<bool>,
mount_tmp: Option<bool>,
resolv_conf: Option<bool>,
managed_mounts: Option<bool>,
base_env: Option<bool>,
stop_with_caller: Option<bool>,
landlock: Vec<(PathBuf, bool)>,
landlock_net: Vec<(u16, NetAccess)>,
seccomp: Option<SeccompPolicy>,
seccomp_curated: bool,
seccomp_allow: Vec<i64>,
seccomp_deny: Vec<i64>,
seccomp_allow_rules: Vec<(i64, Vec<SeccompArg>)>,
seccomp_deny_rules: Vec<(i64, Vec<SeccompArg>)>,
caps: Option<CapsChoice>,
restrict: bool,
netstack: bool,
netstack_cidr: Option<(Ipv4Addr, u8)>,
netstack_cidr6: Option<(Ipv6Addr, u8)>,
netstack_no_ipv4: bool,
netstack_no_ipv6: bool,
netstack_interface: Option<String>,
netstack_mtu: Option<u16>,
netstack_host_loopback: bool,
}
enum CapsChoice {
DropAll,
Keep(Vec<Capability>),
}
fn usage() -> String {
const DESCRIPTION: usize = 24;
const NAME_ROOM: usize = DESCRIPTION - 2 - 1;
let mut out = String::from(SYNOPSIS);
for section in Section::ALL {
let Some(heading) = section.heading() else {
continue;
};
out.push('\n');
out.push_str(heading);
out.push_str(":\n");
for flag in FLAGS.iter().filter(|flag| flag.section == *section) {
let name = flag.spelled();
let mut body = flag.help.iter();
if name.len() <= NAME_ROOM
&& let Some(first) = body.next()
{
out.push_str(&format!(
" {name:<width$}{first}\n",
width = DESCRIPTION - 2
));
} else {
out.push_str(&format!(" {name}\n"));
}
for line in body {
out.push_str(&format!("{:DESCRIPTION$}{line}\n", ""));
}
if flag.roster {
let names: Vec<&str> = Resource::ALL.iter().map(|r| r.spelling()).collect();
out.push_str(&wrapped(&names));
out.push('\n');
}
}
}
out.push('\n');
out.push_str(CLOSING);
out
}
fn unrecognized_option(arg: &OsStr) -> String {
let given = arg.to_string_lossy();
let name = flag_name(&given);
if let Some(owner) = export_only_owner(name) {
return format!(
"unrecognized option {given}\n{name} is an option of {owner}, which is a mode of \
its own.",
);
}
let candidates: Vec<&str> = FLAGS
.iter()
.map(|flag| flag.long)
.filter(|flag| export_only_owner(flag).is_none())
.collect();
match nearest_flag(name, &candidates) {
Some(flag) => format!("unrecognized option {given}\nTry 'fcage {flag}'."),
None => format!("unrecognized option {given}"),
}
}
fn flag_name(arg: &str) -> &str {
arg.split_once('=').map_or(arg, |(name, _)| name)
}
fn export_only_owner(flag: &str) -> Option<&'static str> {
let entry = FLAGS.iter().find(|entry| entry.long == flag)?;
if entry.mode == Mode::Launch || entry.selects {
return None;
}
FLAGS
.iter()
.find(|other| other.selects && other.mode == entry.mode)
.map(|other| other.long)
}
fn mode_conflict(arg: &OsStr, mode: &str, vocabulary: &[&'static str]) -> String {
let given = arg.to_string_lossy();
match nearest_flag(flag_name(&given), vocabulary) {
Some(flag) => format!("unrecognized option {given}\nTry 'fcage {flag}'."),
None => format!("{given} cannot be used with {mode}"),
}
}
fn nearest_flag<'a>(given: &str, flags: &[&'a str]) -> Option<&'a str> {
const FURTHEST: usize = 2;
if given.trim_start_matches('-').is_empty() {
return None;
}
flags
.iter()
.filter_map(|flag| {
let distance = edit_distance(given, flag);
let abbreviates = flag.starts_with(given);
let extends = given.starts_with(flag);
(abbreviates || extends || distance <= FURTHEST).then_some((
usize::from(!abbreviates),
distance,
*flag,
))
})
.min()
.map(|(_, _, flag)| flag)
}
fn edit_distance(left: &str, right: &str) -> usize {
let right: Vec<char> = right.chars().collect();
let mut previous: Vec<usize> = (0..=right.len()).collect();
let mut current = vec![0; right.len() + 1];
for (row, from) in left.chars().enumerate() {
current[0] = row + 1;
for (column, to) in right.iter().enumerate() {
let substitute = previous[column] + usize::from(from != *to);
let delete = previous[column + 1] + 1;
let insert = current[column] + 1;
current[column + 1] = substitute.min(delete).min(insert);
}
std::mem::swap(&mut previous, &mut current);
}
previous[right.len()]
}
fn wrapped(items: &[&str]) -> String {
const INDENT: usize = 24;
const WIDTH: usize = 78;
let mut lines = vec![String::new()];
for (position, item) in items.iter().enumerate() {
let separator = if position + 1 == items.len() { "" } else { "," };
let line = lines.last_mut().expect("the first line is always present");
if !line.is_empty() && INDENT + line.len() + 1 + item.len() + separator.len() > WIDTH {
lines.push(String::new());
}
let line = lines.last_mut().expect("a line was just ensured");
if !line.is_empty() {
line.push(' ');
}
line.push_str(item);
line.push_str(separator);
}
lines
.iter()
.map(|line| format!("{:INDENT$}{line}", ""))
.collect::<Vec<_>>()
.join("\n")
}
fn selected_mode(args: &[OsString]) -> Option<Mode> {
let mut selected = None;
let mut rest = args.iter();
while let Some(arg) = rest.next() {
if arg == "--" {
break;
}
let Some(flag) = named(arg) else {
if arg.as_bytes().starts_with(b"-") && arg != "-" {
continue;
}
break;
};
if flag.selects && selected.is_none() {
selected = Some(flag.mode);
}
if flag.arity() != Arity::Bare && !joined(arg) {
let count = if flag.arity() == Arity::Two { 2 } else { 1 };
for _ in 0..count {
if rest.next().is_none() {
break;
}
}
}
}
selected
}
fn joined(arg: &OsStr) -> bool {
arg.as_bytes().contains(&b'=')
}
fn named(arg: &OsStr) -> Option<&'static Flag> {
let text = arg.to_str()?;
if let Some(flag) = FLAGS.iter().find(|flag| flag.long == text) {
return Some(flag);
}
let (name, _) = text.split_once('=')?;
FLAGS
.iter()
.find(|flag| flag.long == name && flag.arity() == Arity::One)
}
fn apply_flag(
flag: &Flag,
options: &mut Options,
joined: Option<OsString>,
args: &mut dyn Iterator<Item = OsString>,
) -> Result<(), String> {
match flag.take {
Take::Bare(set) => {
set(options);
Ok(())
}
Take::One(set) => {
let value = match joined {
Some(value) => value,
None => value_for(flag.long, flag.needs, args)?,
};
set(options, flag.long, value)
}
Take::Two(set) => {
let first = value_for(flag.long, flag.needs, args)?;
let second = value_for(flag.long, flag.needs, args)?;
set(options, flag.long, first, second)
}
Take::Refused(_) => Err(unrecognized_option(OsStr::new(flag.long))),
}
}
fn main() -> ExitCode {
match parse(std::env::args_os().skip(1)) {
Ok(Invocation::Help) => {
print!("{}", usage());
ExitCode::SUCCESS
}
Ok(Invocation::Version) => {
println!("fcage {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
Ok(Invocation::Run(options)) => run(*options),
Ok(Invocation::Remove { dest, map }) => remove_rootfs(&dest, map),
Ok(Invocation::GentooVariants {
architecture,
mirror,
fallbacks,
max_pointer_age,
}) => gentoo_variants(
&architecture,
mirror.as_deref(),
&fallbacks,
max_pointer_age,
),
Ok(Invocation::GentooPackages {
architecture,
binhost,
mirror,
fallbacks,
}) => gentoo_packages(
&architecture,
binhost.as_deref(),
mirror.as_deref(),
&fallbacks,
),
Ok(Invocation::GentooInstalled { root }) => gentoo_installed(&root),
Ok(Invocation::GentooKeyring) => gentoo_keyring_horizon(),
Ok(Invocation::Export {
source,
dest,
map,
clamp_mtime,
}) => export_rootfs(&source, dest.as_deref(), map, clamp_mtime),
Err(message) => {
eprintln!("fcage: {message}");
eprintln!("Try 'fcage --help' for usage.");
ExitCode::from(2)
}
}
}
fn parse(args: impl Iterator<Item = OsString>) -> Result<Invocation, String> {
let args: Vec<OsString> = args.collect();
match selected_mode(&args) {
Some(Mode::GentooVariants) => return parse_gentoo_variants(args),
Some(Mode::GentooPackages) => return parse_gentoo_packages(args),
Some(Mode::GentooInstalled) => return parse_gentoo_installed(args),
Some(Mode::GentooKeyring) => return parse_gentoo_keyring(args),
Some(Mode::Remove) => return parse_removal(args),
Some(Mode::Export) => return parse_export(args),
_ => {}
}
let raw = args.clone();
let mut args = args.into_iter();
let mut options = Options {
profile: None,
profile_restricted: false,
rootfs: None,
provision_tar: None,
provision_debian: None,
debian_arch: None,
debian_mirror: None,
debian_components: Vec::new(),
debian_include: Vec::new(),
debian_exclude: Vec::new(),
debian_plan: None,
debian_pin: None,
debian_extract_only: false,
debian_cache: None,
debian_keyring: None,
debian_mirror_fallback: Vec::new(),
debian_base_priority: None,
debian_trust_unsigned: false,
debian_allow_stale_release: false,
debian_pre_configure_overlay: None,
debian_identity_map: None,
debian_repositories: Vec::new(),
provision_alpine: None,
alpine_arch: None,
alpine_mirror: None,
alpine_components: Vec::new(),
alpine_include: Vec::new(),
alpine_exclude: Vec::new(),
alpine_plan: None,
alpine_pin: None,
alpine_extract_only: false,
alpine_cache: None,
alpine_keys: None,
alpine_mirror_fallback: Vec::new(),
provision_gentoo: None,
gentoo_variant: None,
gentoo_binhost: None,
gentoo_install: Vec::new(),
gentoo_prefer_use: Vec::new(),
gentoo_plan: None,
gentoo_build_id: None,
gentoo_mirror: None,
gentoo_mirror_fallback: Vec::new(),
gentoo_cache: None,
gentoo_max_pointer_age: None,
alpine_pre_configure_overlay: None,
alpine_identity_map: None,
alpine_repositories: Vec::new(),
command_line: Vec::new(),
path_lookup: None,
mounts: Vec::new(),
overlay_lowers: Vec::new(),
overlay_upper: None,
overlay_work: None,
identity_map: None,
run_as: None,
rlimits: Vec::new(),
setenv: Vec::new(),
hostname: None,
chdir: None,
stdin: None,
terminal: false,
timeout: None,
kill_after: Duration::from_secs(10),
kill_after_given: false,
network: None,
pid_namespace: None,
mount_proc: None,
mount_dev: None,
mount_tmp: None,
resolv_conf: None,
managed_mounts: None,
base_env: None,
stop_with_caller: None,
landlock: Vec::new(),
landlock_net: Vec::new(),
seccomp: None,
seccomp_curated: false,
seccomp_allow: Vec::new(),
seccomp_deny: Vec::new(),
seccomp_allow_rules: Vec::new(),
seccomp_deny_rules: Vec::new(),
caps: None,
restrict: false,
netstack: false,
netstack_cidr: None,
netstack_cidr6: None,
netstack_no_ipv4: false,
netstack_no_ipv6: false,
netstack_interface: None,
netstack_mtu: None,
netstack_host_loopback: false,
};
while let Some(arg) = args.next() {
if arg == "-h" || arg == "--help" {
return Ok(Invocation::Help);
}
if arg == "-V" || arg == "--version" {
return Ok(Invocation::Version);
}
if arg == "--" {
options.command_line.extend(args);
break;
}
if let Some(flag) = named(&arg) {
let joined = flag_value(&arg, format!("{}=", flag.long).as_bytes());
apply_flag(flag, &mut options, joined, &mut args)?;
continue;
}
if arg.as_bytes().starts_with(b"-") && arg != "-" {
return Err(unrecognized_option(&arg));
}
options.command_line.push(arg);
options.command_line.extend(args);
break;
}
options.seccomp = assemble_seccomp(
options.seccomp_curated,
std::mem::take(&mut options.seccomp_allow),
std::mem::take(&mut options.seccomp_deny),
std::mem::take(&mut options.seccomp_allow_rules),
std::mem::take(&mut options.seccomp_deny_rules),
)?;
if options.kill_after_given && options.timeout.is_none() {
return Err("--kill-after requires --timeout".to_string());
}
if options.terminal && matches!(options.stdin, Some(Stdio::Null)) {
return Err("--terminal cannot be used with --stdin null".to_string());
}
if options.restrict {
let conflicts = [
(options.rootfs.is_some(), "--rootfs"),
(
options.profile.is_some(),
"--profile or --restricted-profile",
),
(options.provision_tar.is_some(), "--provision-tar"),
(options.provision_debian.is_some(), "--provision-debian"),
(has_debian_modifiers(&options), "the --debian-* options"),
(options.provision_alpine.is_some(), "--provision-alpine"),
(has_alpine_modifiers(&options), "the --alpine-* options"),
(options.provision_gentoo.is_some(), "--provision-gentoo"),
(has_gentoo_modifiers(&options), "the --gentoo-* options"),
(
options
.mounts
.iter()
.any(|mount| matches!(mount, Mount::Bind(_))),
"--bind and --ro-bind",
),
(options.hostname.is_some(), "--hostname"),
(options.network.is_some(), "the network options"),
(options.pid_namespace.is_some(), "the PID-namespace options"),
(
options.mount_proc.is_some()
|| options.mount_dev.is_some()
|| options.mount_tmp.is_some()
|| options.managed_mounts.is_some(),
"the mount toggles",
),
(options.resolv_conf.is_some(), "the resolv.conf options"),
(options.caps.is_some(), "the capability options"),
(
options
.mounts
.iter()
.any(|mount| matches!(mount, Mount::Raw(_))),
"--raw-mount",
),
(
!options.overlay_lowers.is_empty()
|| options.overlay_upper.is_some()
|| options.overlay_work.is_some(),
"the --overlay-* options",
),
(options.identity_map.is_some(), "--identity-map"),
(options.run_as.is_some(), "--run-as"),
(options.path_lookup.is_some(), "the path-lookup options"),
(options.netstack, "--netstack"),
(has_netstack_modifiers(&options), "the --netstack-* options"),
];
for (given, name) in conflicts {
if given {
return Err(format!("{name} cannot be used with --restrict"));
}
}
if options.command_line.is_empty() {
return Err("no command given".to_string());
}
if options.landlock.is_empty()
&& options.landlock_net.is_empty()
&& options.seccomp.is_none()
{
return Err(
"--restrict requires at least one grant: a --landlock-* rule or a --seccomp filter"
.to_string(),
);
}
return Ok(Invocation::Run(Box::new(options)));
}
if options.netstack && options.network == Some(Network::Host) {
return Err("--netstack cannot be used with --share-net".to_string());
}
if !options.netstack && has_netstack_modifiers(&options) {
return Err("the --netstack-* options require --netstack".to_string());
}
let overlay_given = !options.overlay_lowers.is_empty()
|| options.overlay_upper.is_some()
|| options.overlay_work.is_some();
if overlay_given {
if options.overlay_lowers.is_empty() {
return Err("the --overlay-* options require at least one --overlay-lower".to_string());
}
if options.overlay_upper.is_none() {
return Err("the --overlay-* options require --overlay-upper".to_string());
}
if options.rootfs.is_some() {
return Err("--rootfs and the --overlay-* options are alternatives".to_string());
}
}
if options.rootfs.is_none() && options.profile.is_none() && !overlay_given {
return Err(
"--rootfs, --overlay-lower, --profile, --restricted-profile, or --restrict is required"
.to_string(),
);
}
let provisioners: Vec<&str> = [
(options.provision_tar.is_some(), "--provision-tar"),
(options.provision_debian.is_some(), "--provision-debian"),
(options.provision_alpine.is_some(), "--provision-alpine"),
(options.provision_gentoo.is_some(), "--provision-gentoo"),
]
.into_iter()
.filter_map(|(given, flag)| given.then_some(flag))
.collect();
if let [first, second, ..] = provisioners[..] {
return Err(format!("{first} and {second} are mutually exclusive"));
}
if let [only] = provisioners[..]
&& options.rootfs.is_none()
{
return Err(format!("{only} requires --rootfs to name the destination"));
}
if options.provision_debian.is_none() && has_debian_modifiers(&options) {
return Err("the --debian-* options require --provision-debian".to_string());
}
if options.provision_alpine.is_none() && has_alpine_modifiers(&options) {
return Err("the --alpine-* options require --provision-alpine".to_string());
}
if options.provision_gentoo.is_none() && has_gentoo_modifiers(&options) {
return Err("the --gentoo-* options require --provision-gentoo".to_string());
}
if options.provision_gentoo.is_some()
&& options.gentoo_variant.is_none()
&& options.rootfs.is_some()
{
return Err(
"--provision-gentoo requires --gentoo-variant; --gentoo-variants lists what an \
architecture offers"
.to_string(),
);
}
let provisioning = options.provision_tar.is_some()
|| options.provision_debian.is_some()
|| options.provision_alpine.is_some()
|| options.provision_gentoo.is_some();
if options.command_line.is_empty() && options.profile.is_none() && !provisioning {
return Err("no command given".to_string());
}
if options.command_line.is_empty()
&& options.profile.is_none()
&& let Some(flag) = first_launch_flag(&raw)
{
return Err(format!(
"{flag} configures a launch, and a provisioning run without a command does not \
launch one",
));
}
Ok(Invocation::Run(Box::new(options)))
}
fn first_launch_flag(args: &[OsString]) -> Option<String> {
args.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.find(|arg| arg != "--" && arg.starts_with("--") && !provisions(flag_name(arg)))
}
fn provisions(flag: &str) -> bool {
matches!(
flag,
"--rootfs"
| "--provision-tar"
| "--provision-debian"
| "--provision-alpine"
| "--provision-gentoo"
) || flag.starts_with("--debian-")
|| flag.starts_with("--alpine-")
|| flag.starts_with("--gentoo-")
}
fn set_profile(options: &mut Options, path: PathBuf, restricted: bool) -> Result<(), String> {
if options.profile.is_some() && options.profile_restricted != restricted {
return Err("--profile and --restricted-profile are alternatives".to_string());
}
options.profile = Some(path);
options.profile_restricted = restricted;
Ok(())
}
fn help_or_version(args: &[OsString]) -> Option<Invocation> {
if args.iter().any(|arg| arg == "-h" || arg == "--help") {
return Some(Invocation::Help);
}
if args.iter().any(|arg| arg == "-V" || arg == "--version") {
return Some(Invocation::Version);
}
None
}
fn parse_removal(args: Vec<OsString>) -> Result<Invocation, String> {
if let Some(answer) = help_or_version(&args) {
return Ok(answer);
}
let mut dest: Option<PathBuf> = None;
let mut map = IdentityMap::Subordinate;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if arg == "--remove-rootfs" {
dest = Some(PathBuf::from(value_for(
"--remove-rootfs",
"a value",
&mut args,
)?));
} else if let Some(value) = flag_value(&arg, b"--remove-rootfs=") {
dest = Some(PathBuf::from(value));
} else if arg == "--identity-map" {
map = parse_identity_map(
"--identity-map",
&string_value("--identity-map", &mut args)?,
)?;
} else if let Some(value) = flag_value(&arg, b"--identity-map=") {
map = parse_identity_map("--identity-map", &into_string("--identity-map", value)?)?;
} else {
return Err(mode_conflict(
&arg,
"--remove-rootfs",
&["--remove-rootfs", "--identity-map"],
));
}
}
let dest = dest.ok_or_else(|| "--remove-rootfs requires a value".to_string())?;
Ok(Invocation::Remove { dest, map })
}
fn parse_gentoo_variants(args: Vec<OsString>) -> Result<Invocation, String> {
if let Some(answer) = help_or_version(&args) {
return Ok(answer);
}
let mut architecture: Option<String> = None;
let mut mirror: Option<String> = None;
let mut fallbacks: Vec<String> = Vec::new();
let mut max_pointer_age: Option<Option<u64>> = None;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if arg == "--gentoo-variants" {
architecture = Some(string_value("--gentoo-variants", &mut args)?);
} else if let Some(value) = flag_value(&arg, b"--gentoo-variants=") {
architecture = Some(into_string("--gentoo-variants", value)?);
} else if arg == "--gentoo-mirror" {
mirror = Some(string_value("--gentoo-mirror", &mut args)?);
} else if let Some(value) = flag_value(&arg, b"--gentoo-mirror=") {
mirror = Some(into_string("--gentoo-mirror", value)?);
} else if arg == "--gentoo-mirror-fallback" {
fallbacks.push(string_value("--gentoo-mirror-fallback", &mut args)?);
} else if let Some(value) = flag_value(&arg, b"--gentoo-mirror-fallback=") {
fallbacks.push(into_string("--gentoo-mirror-fallback", value)?);
} else if arg == "--gentoo-max-pointer-age" {
max_pointer_age = Some(parse_pointer_age(&string_value(
"--gentoo-max-pointer-age",
&mut args,
)?)?);
} else if let Some(value) = flag_value(&arg, b"--gentoo-max-pointer-age=") {
max_pointer_age = Some(parse_pointer_age(&into_string(
"--gentoo-max-pointer-age",
value,
)?)?);
} else {
return Err(mode_conflict(
&arg,
"--gentoo-variants",
&[
"--gentoo-variants",
"--gentoo-mirror",
"--gentoo-mirror-fallback",
"--gentoo-max-pointer-age",
],
));
}
}
let architecture =
architecture.ok_or_else(|| "--gentoo-variants requires a value".to_string())?;
Ok(Invocation::GentooVariants {
architecture,
mirror,
fallbacks,
max_pointer_age,
})
}
fn parse_gentoo_keyring(args: Vec<OsString>) -> Result<Invocation, String> {
if let Some(answer) = help_or_version(&args) {
return Ok(answer);
}
for arg in args {
if arg != "--gentoo-keyring-horizon" {
return Err(mode_conflict(
&arg,
"--gentoo-keyring-horizon",
&["--gentoo-keyring-horizon"],
));
}
}
Ok(Invocation::GentooKeyring)
}
fn parse_gentoo_packages(args: Vec<OsString>) -> Result<Invocation, String> {
if let Some(answer) = help_or_version(&args) {
return Ok(answer);
}
let mut architecture: Option<String> = None;
let mut binhost: Option<String> = None;
let mut mirror: Option<String> = None;
let mut fallbacks: Vec<String> = Vec::new();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if arg == "--gentoo-packages" {
architecture = Some(string_value("--gentoo-packages", &mut args)?);
} else if let Some(value) = flag_value(&arg, b"--gentoo-packages=") {
architecture = Some(into_string("--gentoo-packages", value)?);
} else if arg == "--gentoo-binhost" {
binhost = Some(string_value("--gentoo-binhost", &mut args)?);
} else if let Some(value) = flag_value(&arg, b"--gentoo-binhost=") {
binhost = Some(into_string("--gentoo-binhost", value)?);
} else if arg == "--gentoo-mirror" {
mirror = Some(string_value("--gentoo-mirror", &mut args)?);
} else if let Some(value) = flag_value(&arg, b"--gentoo-mirror=") {
mirror = Some(into_string("--gentoo-mirror", value)?);
} else if arg == "--gentoo-mirror-fallback" {
fallbacks.push(string_value("--gentoo-mirror-fallback", &mut args)?);
} else if let Some(value) = flag_value(&arg, b"--gentoo-mirror-fallback=") {
fallbacks.push(into_string("--gentoo-mirror-fallback", value)?);
} else {
return Err(mode_conflict(
&arg,
"--gentoo-packages",
&[
"--gentoo-packages",
"--gentoo-binhost",
"--gentoo-mirror",
"--gentoo-mirror-fallback",
],
));
}
}
match architecture {
Some(architecture) => Ok(Invocation::GentooPackages {
architecture,
binhost,
mirror,
fallbacks,
}),
None => Err("--gentoo-packages requires a value".to_string()),
}
}
fn parse_gentoo_installed(args: Vec<OsString>) -> Result<Invocation, String> {
if let Some(answer) = help_or_version(&args) {
return Ok(answer);
}
let mut root: Option<PathBuf> = None;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if arg == "--gentoo-installed" {
root = Some(PathBuf::from(value_for(
"--gentoo-installed",
"a value",
&mut args,
)?));
} else if let Some(value) = flag_value(&arg, b"--gentoo-installed=") {
root = Some(PathBuf::from(value));
} else {
return Err(mode_conflict(
&arg,
"--gentoo-installed",
&["--gentoo-installed"],
));
}
}
match root {
Some(root) => Ok(Invocation::GentooInstalled { root }),
None => Err("--gentoo-installed requires a value".to_string()),
}
}
fn parse_export(args: Vec<OsString>) -> Result<Invocation, String> {
if let Some(answer) = help_or_version(&args) {
return Ok(answer);
}
let mut source: Option<PathBuf> = None;
let mut dest: Option<PathBuf> = None;
let mut map = IdentityMap::Subordinate;
let mut clamp_mtime: Option<i64> = None;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if arg == "--export-rootfs" {
let value = args
.next()
.ok_or_else(|| "--export-rootfs requires a value".to_string())?;
source = Some(PathBuf::from(value));
} else if let Some(value) = flag_value(&arg, b"--export-rootfs=") {
source = Some(PathBuf::from(value));
} else if arg == "--export-to" {
let value = args
.next()
.ok_or_else(|| "--export-to requires a value".to_string())?;
dest = Some(PathBuf::from(value));
} else if let Some(value) = flag_value(&arg, b"--export-to=") {
dest = Some(PathBuf::from(value));
} else if arg == "--identity-map" {
map = parse_identity_map(
"--identity-map",
&string_value("--identity-map", &mut args)?,
)?;
} else if let Some(value) = flag_value(&arg, b"--identity-map=") {
map = parse_identity_map("--identity-map", &into_string("--identity-map", value)?)?;
} else if arg == "--clamp-mtime" {
clamp_mtime = Some(parse_epoch(&string_value("--clamp-mtime", &mut args)?)?);
} else if let Some(value) = flag_value(&arg, b"--clamp-mtime=") {
clamp_mtime = Some(parse_epoch(&into_string("--clamp-mtime", value)?)?);
} else {
return Err(mode_conflict(
&arg,
"--export-rootfs",
&[
"--export-rootfs",
"--export-to",
"--identity-map",
"--clamp-mtime",
],
));
}
}
let source = source.ok_or_else(|| "--export-rootfs requires a value".to_string())?;
Ok(Invocation::Export {
source,
dest,
map,
clamp_mtime,
})
}
fn parse_epoch(value: &str) -> Result<i64, String> {
value
.parse::<i64>()
.map_err(|_| format!("--clamp-mtime: {value:?} is not a Unix timestamp in whole seconds"))
}
fn has_debian_modifiers(options: &Options) -> bool {
options.debian_arch.is_some()
|| options.debian_mirror.is_some()
|| !options.debian_components.is_empty()
|| !options.debian_include.is_empty()
|| !options.debian_exclude.is_empty()
|| options.debian_plan.is_some()
|| options.debian_pin.is_some()
|| options.debian_extract_only
|| options.debian_cache.is_some()
|| options.debian_keyring.is_some()
|| !options.debian_mirror_fallback.is_empty()
|| options.debian_base_priority.is_some()
|| options.debian_trust_unsigned
|| options.debian_allow_stale_release
|| options.debian_pre_configure_overlay.is_some()
|| options.debian_identity_map.is_some()
|| !options.debian_repositories.is_empty()
}
fn has_alpine_modifiers(options: &Options) -> bool {
options.alpine_arch.is_some()
|| options.alpine_mirror.is_some()
|| !options.alpine_components.is_empty()
|| !options.alpine_include.is_empty()
|| !options.alpine_exclude.is_empty()
|| options.alpine_plan.is_some()
|| options.alpine_pin.is_some()
|| options.alpine_extract_only
|| options.alpine_cache.is_some()
|| options.alpine_keys.is_some()
|| !options.alpine_mirror_fallback.is_empty()
|| options.alpine_pre_configure_overlay.is_some()
|| options.alpine_identity_map.is_some()
|| !options.alpine_repositories.is_empty()
}
fn has_gentoo_modifiers(options: &Options) -> bool {
options.gentoo_variant.is_some()
|| options.gentoo_binhost.is_some()
|| !options.gentoo_install.is_empty()
|| !options.gentoo_prefer_use.is_empty()
|| options.gentoo_plan.is_some()
|| options.gentoo_build_id.is_some()
|| options.gentoo_mirror.is_some()
|| !options.gentoo_mirror_fallback.is_empty()
|| options.gentoo_cache.is_some()
|| options.gentoo_max_pointer_age.is_some()
}
fn parse_pointer_age(value: &str) -> Result<Option<u64>, String> {
if value == "none" {
return Ok(None);
}
let days: u64 = value.parse().map_err(|_| {
format!("--gentoo-max-pointer-age expects a number of days or 'none', not {value:?}")
})?;
Ok(Some(days))
}
fn has_netstack_modifiers(options: &Options) -> bool {
options.netstack_cidr.is_some()
|| options.netstack_cidr6.is_some()
|| options.netstack_no_ipv4
|| options.netstack_no_ipv6
|| options.netstack_interface.is_some()
|| options.netstack_mtu.is_some()
|| options.netstack_host_loopback
}
fn parse_cidr(value: &str) -> Result<(Ipv4Addr, u8), String> {
let (address, len) = value
.split_once('/')
.ok_or_else(|| format!("--netstack-cidr expects ADDRESS/LEN, not {value:?}"))?;
let address = address
.parse::<Ipv4Addr>()
.map_err(|_| format!("--netstack-cidr: {address:?} is not an IPv4 address"))?;
let len = len
.parse::<u8>()
.map_err(|_| format!("--netstack-cidr: {len:?} is not a prefix length"))?;
if len > 32 {
return Err(format!(
"--netstack-cidr: {len} is not an IPv4 prefix length (0-32)"
));
}
Ok((address, len))
}
fn parse_cidr6(value: &str) -> Result<(Ipv6Addr, u8), String> {
let (address, len) = value
.split_once('/')
.ok_or_else(|| format!("--netstack-cidr6 expects ADDRESS/LEN, not {value:?}"))?;
let address = address
.parse::<Ipv6Addr>()
.map_err(|_| format!("--netstack-cidr6: {address:?} is not an IPv6 address"))?;
let len = len
.parse::<u8>()
.map_err(|_| format!("--netstack-cidr6: {len:?} is not a prefix length"))?;
if len > 128 {
return Err(format!(
"--netstack-cidr6: {len} is not an IPv6 prefix length (0-128)"
));
}
Ok((address, len))
}
fn parse_mtu(value: &str) -> Result<u16, String> {
value
.parse::<u16>()
.map_err(|_| format!("--netstack-mtu: {value:?} is not a valid MTU"))
}
fn spec_pairs<'a>(flag: &str, spec: &'a str) -> Result<Vec<(&'a str, &'a str)>, String> {
let mut pairs = Vec::new();
for field in spec.split_whitespace() {
match field.split_once('=') {
Some((key, value)) if !key.is_empty() => pairs.push((key, value)),
Some(_) => return Err(format!("{flag}: {field:?} has an empty key")),
None => pairs.push((field, "")),
}
}
if pairs.is_empty() {
return Err(format!("{flag} requires at least one key=value field"));
}
Ok(pairs)
}
fn bare_field(flag: &str, key: &str, value: &str) -> Result<bool, String> {
if value.is_empty() {
Ok(true)
} else {
Err(format!(
"{flag}: {key} takes no value, and {key}={value} does not turn it off; write {key} on \
its own, or leave it out"
))
}
}
fn parse_raw_mount(spec: &str) -> Result<RawMount, String> {
let mut target = None;
let mut source = None;
let mut fstype = None;
let mut data = None;
let mut flags = 0u64;
for (key, value) in spec_pairs("--raw-mount", spec)? {
match key {
"target" => target = Some(PathBuf::from(value)),
"source" => source = Some(PathBuf::from(value)),
"fstype" => fstype = Some(value.to_string()),
"data" => data = Some(value.to_string()),
"flags" => flags = parse_u64_literal("--raw-mount", "flags", value)?,
other => {
return Err(format!(
"--raw-mount: unknown field {other:?}; expected target, source, fstype, \
flags, or data"
));
}
}
}
let target = target.ok_or("--raw-mount requires a target= field".to_string())?;
let mut mount = RawMount::new(target).flags(flags);
if let Some(source) = source {
mount = mount.source(source);
}
if let Some(fstype) = fstype {
mount = mount.fstype(fstype);
}
if let Some(data) = data {
mount = mount.data(data);
}
Ok(mount)
}
fn parse_identity_map(flag: &str, spec: &str) -> Result<IdentityMap, String> {
match spec.trim() {
"single" => return Ok(IdentityMap::Single),
"subordinate" => return Ok(IdentityMap::Subordinate),
_ => {}
}
let mut uid = Vec::new();
let mut gid = Vec::new();
for (key, value) in spec_pairs(flag, spec)? {
let target = match key {
"uid" => &mut uid,
"gid" => &mut gid,
other => {
return Err(format!(
"{flag}: expected 'single', 'subordinate', or uid=/gid= extents, not {other:?}"
));
}
};
for extent in value.split(',').filter(|extent| !extent.is_empty()) {
target.push(parse_id_range(flag, extent)?);
}
}
if uid.is_empty() || gid.is_empty() {
return Err(format!(
"{flag}: an explicit range map needs both uid= and gid= extents"
));
}
Ok(IdentityMap::ranges(uid, gid))
}
fn parse_id_range(flag: &str, extent: &str) -> Result<IdRange, String> {
let parts: Vec<&str> = extent.split(':').collect();
let [inside, outside, count] = parts.as_slice() else {
return Err(format!(
"{flag}: {extent:?} is not an INSIDE:OUTSIDE:COUNT extent"
));
};
let field = |name: &str, text: &str| {
text.parse::<u32>()
.map_err(|_| format!("{flag}: {text:?} is not a valid {name}"))
};
Ok(IdRange {
inside: field("inside id", inside)?,
outside: field("outside id", outside)?,
count: field("count", count)?,
})
}
fn parse_run_as(spec: &str) -> Result<Identity, String> {
let mut fields = spec.splitn(3, ':');
let id = |name: &str, text: Option<&str>| match text {
Some(text) => text
.parse::<u32>()
.map_err(|_| format!("--run-as: {text:?} is not a valid {name}")),
None => Err(format!("--run-as requires UID:GID, not {spec:?}")),
};
let uid = id("uid", fields.next().filter(|field| !field.is_empty()))?;
let gid = id("gid", fields.next())?;
let identity = Identity::new(uid, gid);
match fields.next().filter(|groups| !groups.is_empty()) {
None => Ok(identity),
Some(groups) => {
let groups = groups
.split(',')
.filter(|group| !group.is_empty())
.map(|group| {
group
.parse::<u32>()
.map_err(|_| format!("--run-as: {group:?} is not a valid group id"))
})
.collect::<Result<Vec<_>, _>>()?;
Ok(identity.groups(groups))
}
}
}
fn parse_rlimit(spec: &str) -> Result<(Resource, Limit, Limit), String> {
let (name, values) = spec
.split_once('=')
.ok_or_else(|| format!("--rlimit expects RESOURCE=SOFT[:HARD], not {spec:?}"))?;
let resource = Resource::deserialize(serde::de::value::StrDeserializer::<
serde::de::value::Error,
>::new(name.trim()))
.map_err(|error| format!("--rlimit: {error}"))?;
let limit = |text: &str| -> Result<Limit, String> {
if text == "unlimited" {
return Ok(Limit::UNLIMITED);
}
text.parse::<u64>()
.map(Limit::of)
.map_err(|_| format!("--rlimit: {text:?} is not a limit value or 'unlimited'"))
};
let (soft, hard) = match values.split_once(':') {
Some((soft, hard)) => (limit(soft)?, limit(hard)?),
None => {
let both = limit(values)?;
(both, both)
}
};
Ok((resource, soft, hard))
}
fn parse_priority(value: &str) -> Result<Priority, String> {
value
.parse()
.map_err(|err| format!("--debian-base-priority: {err}"))
}
#[derive(Debug, Default)]
struct MirrorSpec {
primary: Option<String>,
fallbacks: Vec<String>,
}
impl MirrorSpec {
fn primary(&mut self, flag: &str, url: &str) -> Result<(), String> {
if let Some(first) = &self.primary {
return Err(format!(
"{flag}: mirror= is given twice, as {first:?} and {url:?}; a repository has one \
primary mirror, and the rest are mirror-fallback="
));
}
self.primary = Some(url.to_string());
Ok(())
}
fn fallback(&mut self, url: &str) {
self.fallbacks.push(url.to_string());
}
fn finish(self, flag: &str) -> Result<(Option<String>, Vec<String>), String> {
if self.primary.is_none() && !self.fallbacks.is_empty() {
return Err(format!(
"{flag}: mirror-fallback= names a backstop for a mirror= that was not given; a \
repository is fetched from its primary mirror first"
));
}
Ok((self.primary, self.fallbacks))
}
}
fn parse_repository(spec: &str) -> Result<Repository, String> {
let mut suite = None;
let mut mirrors = MirrorSpec::default();
let mut components: Vec<String> = Vec::new();
let mut keyring = None;
let mut name = None;
let mut trust_unsigned = false;
let mut allow_stale = false;
for (key, value) in spec_pairs("--debian-repository", spec)? {
match key {
"suite" => suite = Some(value.to_string()),
"mirror" => mirrors.primary("--debian-repository", value)?,
"mirror-fallback" => mirrors.fallback(value),
"components" => extend_comma(&mut components, value),
"keyring" => keyring = Some(PathBuf::from(value)),
"name" => name = Some(value.to_string()),
"trust-unsigned" => trust_unsigned = bare_field("--debian-repository", key, value)?,
"allow-stale-release" => allow_stale = bare_field("--debian-repository", key, value)?,
other => {
return Err(format!(
"--debian-repository: unknown field {other:?}; expected suite, mirror, \
mirror-fallback, components, keyring, name, trust-unsigned, or \
allow-stale-release"
));
}
}
}
let suite = suite.ok_or("--debian-repository requires a suite= field".to_string())?;
let (primary, fallbacks) = mirrors.finish("--debian-repository")?;
let mut builder = Repository::builder(suite)
.trust_unsigned(trust_unsigned)
.allow_stale_release(allow_stale);
if let Some(primary) = primary {
builder = builder.mirror(primary);
}
for fallback in fallbacks {
builder = builder.mirror_fallback(fallback);
}
if !components.is_empty() {
builder = builder.components(components);
}
if let Some(keyring) = keyring {
builder = builder.keyring(keyring);
}
if let Some(name) = name {
builder = builder.name(name);
}
builder
.build()
.map_err(|error| format!("--debian-repository: {error}"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum AlpineKeys {
Alpine,
PostmarketOs,
Dir(PathBuf),
}
#[derive(Debug, Clone)]
struct AlpineRepositorySpec {
release: String,
mirror: Option<String>,
fallbacks: Vec<String>,
components: Vec<String>,
keys: AlpineKeys,
}
fn parse_alpine_keys(flag: &str, value: &str) -> Result<AlpineKeys, String> {
Ok(match value {
"alpine" => AlpineKeys::Alpine,
"postmarketos" => AlpineKeys::PostmarketOs,
"" => {
return Err(format!(
"{flag}: keys= names no key set; write 'alpine', 'postmarketos', or a directory \
of PEM public keys"
));
}
path => AlpineKeys::Dir(PathBuf::from(path)),
})
}
fn alpine_key_set(
flag: &str,
keys: &AlpineKeys,
architecture: &str,
) -> Result<AlpineKeySet, String> {
let directory = match keys {
AlpineKeys::Alpine => {
return AlpineKeySet::alpine(architecture).map_err(|err| format!("{flag}: {err}"));
}
AlpineKeys::PostmarketOs => return Ok(AlpineKeySet::postmarketos()),
AlpineKeys::Dir(directory) => directory,
};
let mut entries: Vec<PathBuf> = std::fs::read_dir(directory)
.map_err(|err| format!("{flag}: reading {}: {err}", directory.display()))?
.map(|entry| {
entry
.map(|entry| entry.path())
.map_err(|err| format!("{flag}: reading {}: {err}", directory.display()))
})
.collect::<Result<_, _>>()?;
entries.sort();
let mut set = AlpineKeySet::new();
for path in entries {
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
return Err(format!(
"{flag}: {} is not a usable key name; a signature names its key by exactly the \
file name the repository publishes it under",
path.display(),
));
};
let pem = std::fs::read_to_string(&path)
.map_err(|err| format!("{flag}: reading {}: {err}", path.display()))?;
set.insert(name, &pem)
.map_err(|err| format!("{flag}: {err}"))?;
}
if set.is_empty() {
return Err(format!(
"{flag}: {} holds no keys, so nothing the repository serves could be verified",
directory.display(),
));
}
Ok(set)
}
fn parse_alpine_repository(spec: &str) -> Result<AlpineRepositorySpec, String> {
let mut release = None;
let mut mirrors = MirrorSpec::default();
let mut components: Vec<String> = Vec::new();
let mut keys = None;
for (key, value) in spec_pairs("--alpine-repository", spec)? {
match key {
"release" => release = Some(value.to_string()),
"mirror" => mirrors.primary("--alpine-repository", value)?,
"mirror-fallback" => mirrors.fallback(value),
"components" => extend_comma(&mut components, value),
"keys" => keys = Some(parse_alpine_keys("--alpine-repository", value)?),
other => {
return Err(format!(
"--alpine-repository: unknown field {other:?}; expected release, mirror, \
mirror-fallback, components, or keys"
));
}
}
}
let (primary, fallbacks) = mirrors.finish("--alpine-repository")?;
Ok(AlpineRepositorySpec {
release: release.ok_or("--alpine-repository requires a release= field".to_string())?,
mirror: primary,
fallbacks,
components,
keys: keys.ok_or(
"--alpine-repository requires a keys= field, one of 'alpine', 'postmarketos', or a \
directory of PEM public keys"
.to_string(),
)?,
})
}
fn value_for(
name: &str,
needs: &str,
args: &mut dyn Iterator<Item = OsString>,
) -> Result<OsString, String> {
let value = args
.next()
.ok_or_else(|| format!("{name} requires {needs}"))?;
if named(&value).is_some() {
return Err(format!(
"{name} requires {needs}, and {} names a flag; write it joined as \
{name}=VALUE where it is the value",
value.to_string_lossy(),
));
}
Ok(value)
}
fn string_value(name: &str, args: &mut dyn Iterator<Item = OsString>) -> Result<String, String> {
into_string(name, value_for(name, "a value", args)?)
}
fn into_string(name: &str, value: OsString) -> Result<String, String> {
value
.into_string()
.map_err(|_| format!("{name} requires a valid UTF-8 value"))
}
fn extend_comma(target: &mut Vec<String>, value: &str) {
target.extend(
value
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(str::to_string),
);
}
fn flag_value(arg: &OsStr, prefix: &[u8]) -> Option<OsString> {
arg.as_bytes()
.strip_prefix(prefix)
.map(|value| OsString::from_vec(value.to_vec()))
}
fn parse_stdin(value: &OsStr) -> Result<Stdio, String> {
if value == "inherit" {
Ok(Stdio::Inherit)
} else if value == "null" {
Ok(Stdio::Null)
} else {
Err(format!(
"--stdin accepts 'inherit' or 'null', not {}",
value.to_string_lossy()
))
}
}
fn parse_seccomp(value: &str, curated: &mut bool) -> Result<(), String> {
match value {
"curated" => {
*curated = true;
Ok(())
}
other => Err(format!(
"--seccomp accepts 'curated', not {other:?}; name syscalls with \
--seccomp-allow/--seccomp-deny or condition them with \
--seccomp-allow-rule/--seccomp-deny-rule"
)),
}
}
fn parse_seccomp_names(flag: &str, value: &str, target: &mut Vec<i64>) -> Result<(), String> {
for name in value
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
{
target.push(resolve_syscall_name(flag, name)?);
}
Ok(())
}
fn parse_seccomp_rule(flag: &str, value: &str) -> Result<(i64, Vec<SeccompArg>), String> {
let value = value.trim();
let (name, rest) = match value.split_once(char::is_whitespace) {
Some((name, rest)) => (name, rest.trim()),
None => (value, ""),
};
if name.is_empty() {
return Err(format!("{flag} requires a syscall name"));
}
let syscall = resolve_syscall_name(flag, name)?;
let conditions = rest
.split(',')
.map(str::trim)
.filter(|clause| !clause.is_empty())
.map(|clause| parse_seccomp_condition(flag, clause))
.collect::<Result<Vec<_>, _>>()?;
Ok((syscall, conditions))
}
fn parse_seccomp_condition(flag: &str, clause: &str) -> Result<SeccompArg, String> {
let mut index: Option<u8> = None;
let mut len: Option<SeccompArgLen> = None;
let mut op: Option<String> = None;
let mut value: Option<u64> = None;
let mut mask: Option<u64> = None;
for field in clause.split_whitespace() {
let (key, raw) = field
.split_once('=')
.ok_or_else(|| format!("{flag}: expected key=value, not {field:?}"))?;
match key {
"arg" => {
let parsed = raw
.parse::<u8>()
.ok()
.filter(|index| *index <= 5)
.ok_or_else(|| format!("{flag}: arg must be 0 through 5, not {raw:?}"))?;
index = Some(parsed);
}
"len" => {
len = Some(match raw {
"dword" => SeccompArgLen::Dword,
"qword" => SeccompArgLen::Qword,
other => {
return Err(format!("{flag}: len must be dword or qword, not {other:?}"));
}
});
}
"op" => op = Some(raw.to_string()),
"value" => value = Some(parse_u64_literal(flag, "value", raw)?),
"mask" => mask = Some(parse_u64_literal(flag, "mask", raw)?),
other => return Err(format!("{flag}: unknown condition key {other:?}")),
}
}
let index = index.ok_or_else(|| format!("{flag}: a condition needs arg="))?;
let len = len.ok_or_else(|| format!("{flag}: a condition needs len= (dword or qword)"))?;
let op = op.ok_or_else(|| format!("{flag}: a condition needs op="))?;
let value = value.ok_or_else(|| format!("{flag}: a condition needs value="))?;
let compare = match op.as_str() {
"masked-eq" => SeccompCompare::MaskedEq(
mask.ok_or_else(|| format!("{flag}: op=masked-eq needs mask="))?,
),
simple => {
if mask.is_some() {
return Err(format!("{flag}: mask= is only valid with op=masked-eq"));
}
match simple {
"eq" => SeccompCompare::Eq,
"ne" => SeccompCompare::Ne,
"ge" => SeccompCompare::Ge,
"gt" => SeccompCompare::Gt,
"le" => SeccompCompare::Le,
"lt" => SeccompCompare::Lt,
other => {
return Err(format!(
"{flag}: op must be eq, ne, ge, gt, le, lt, or masked-eq, not {other:?}"
));
}
}
}
};
Ok(SeccompArg::new(index, len, compare, value))
}
fn parse_u64_literal(flag: &str, field: &str, raw: &str) -> Result<u64, String> {
let parsed = match raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) {
Some(hex) => u64::from_str_radix(hex, 16),
None => raw.parse::<u64>(),
};
parsed.map_err(|_| format!("{flag}: {field} must be a number (decimal or 0x hex), not {raw:?}"))
}
fn resolve_syscall_name(flag: &str, name: &str) -> Result<i64, String> {
name.parse::<syscalls::Sysno>()
.map(|sysno| i64::from(sysno.id()))
.map_err(|_| format!("{flag}: unknown syscall {name:?}"))
}
fn assemble_seccomp(
curated: bool,
allow: Vec<i64>,
deny: Vec<i64>,
allow_rules: Vec<(i64, Vec<SeccompArg>)>,
deny_rules: Vec<(i64, Vec<SeccompArg>)>,
) -> Result<Option<SeccompPolicy>, String> {
let allow_side = !allow.is_empty() || !allow_rules.is_empty();
let deny_side = !deny.is_empty() || !deny_rules.is_empty();
if curated && (allow_side || deny_side) {
return Err(
"--seccomp curated cannot be combined with --seccomp-allow/--seccomp-deny rules"
.to_string(),
);
}
if curated {
return Ok(Some(SeccompPolicy::Curated));
}
if allow_side && deny_side {
return Err(
"--seccomp-allow/--seccomp-allow-rule cannot be combined with \
--seccomp-deny/--seccomp-deny-rule"
.to_string(),
);
}
let (bare, rules) = if allow_side {
(SeccompRules::allowing(allow), allow_rules)
} else if deny_side {
(SeccompRules::denying(deny), deny_rules)
} else {
return Ok(None);
};
let policy = rules
.into_iter()
.fold(bare, |rules, (syscall, conditions)| {
rules.rule(syscall, conditions)
});
Ok(Some(SeccompPolicy::Rules(policy)))
}
fn parse_port(flag: &str, value: &str) -> Result<u16, String> {
value
.parse::<u16>()
.map_err(|_| format!("{flag} requires a TCP port in 0..=65535, not {value:?}"))
}
fn parse_caps(value: &str) -> Result<Vec<Capability>, String> {
value
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(|name| name.parse::<Capability>().map_err(|err| err.to_string()))
.collect()
}
fn parse_seconds(name: &str, value: &OsStr) -> Result<Duration, String> {
let seconds: f64 = value
.to_str()
.and_then(|value| value.parse().ok())
.ok_or(format!("{name} requires a number of seconds"))?;
if !seconds.is_finite() || seconds <= 0.0 {
return Err(format!("{name} requires a positive number of seconds"));
}
Duration::try_from_secs_f64(seconds).map_err(|_| format!("{name} is too large"))
}
struct ProfileMeta {
sets_term: bool,
network_host: bool,
sets_stop_with_caller: bool,
}
fn stop_with_caller_default(flag: Option<bool>, profile_states_it: bool) -> Option<bool> {
flag.or((!profile_states_it).then_some(true))
}
fn load_profile(
path: &Path,
override_command: bool,
restricted: bool,
) -> Result<(CageBuilder, ProfileMeta), String> {
let text = std::fs::read_to_string(path)
.map_err(|err| format!("cannot read profile {}: {err}", path.display()))?;
let mut table: toml::Table = text
.parse()
.map_err(|err| format!("cannot parse profile {}: {err}", path.display()))?;
if override_command {
table.remove("command");
table.remove("args");
}
let meta = ProfileMeta {
sets_term: table
.get("env")
.and_then(|env| env.as_table())
.is_some_and(|env| env.contains_key("TERM")),
network_host: table.get("network").and_then(|value| value.as_str()) == Some("host"),
sets_stop_with_caller: table.contains_key("stop-with-caller"),
};
let builder = if restricted {
let profile: RestrictedProfile = table
.try_into()
.map_err(|err| format!("cannot load profile {}: {err}", path.display()))?;
profile.into_builder()
} else {
table
.try_into()
.map_err(|err| format!("cannot load profile {}: {err}", path.display()))?
};
Ok((builder, meta))
}
fn remove_rootfs(dest: &Path, map: IdentityMap) -> ExitCode {
match ferroday_cage::provision::Remove::new(dest).map(map).run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("fcage: {error}");
ExitCode::from(125)
}
}
}
fn export_rootfs(
source: &Path,
dest: Option<&Path>,
map: IdentityMap,
clamp_mtime: Option<i64>,
) -> ExitCode {
use std::io::IsTerminal as _;
if dest.is_none() && std::io::stdout().is_terminal() {
eprintln!("fcage: refusing to write an archive to the terminal");
eprintln!("Try '--export-to FILE', or pipe the output into a compressor.");
return ExitCode::from(2);
}
let mut export = ferroday_cage::provision::Export::new(source).map(map);
if let Some(epoch) = clamp_mtime {
export = export.clamp_mtime(epoch);
}
let written = match dest {
Some(path) => export.write_to_path(path),
None => export.write_to(std::io::stdout().lock()),
};
match written {
Ok(()) => ExitCode::SUCCESS,
Err(ferroday_cage::provision::ProvisionError::Io { source, .. })
if dest.is_none() && source.kind() == std::io::ErrorKind::BrokenPipe =>
{
ExitCode::from(141)
}
Err(error) => {
eprintln!("fcage: {error}");
ExitCode::from(125)
}
}
}
fn run(options: Options) -> ExitCode {
if options.restrict {
return run_restricted(options);
}
if let Some(tarball) = &options.provision_tar {
let rootfs = options.rootfs.as_deref().expect("checked at parse time");
let mut provisioner = ferroday_cage::provision::Tarball::new(tarball);
if let Err(error) = ferroday_cage::provision::ensure(rootfs, &mut provisioner) {
eprintln!("fcage: {error}");
return ExitCode::from(125);
}
}
if let Some(suite) = &options.provision_debian {
let rootfs = options.rootfs.as_deref().expect("checked at parse time");
if let Err(code) = provision_debian(suite, &options, rootfs) {
return code;
}
}
if let Some(release) = &options.provision_alpine {
let rootfs = options.rootfs.as_deref().expect("checked at parse time");
if let Err(code) = provision_alpine(release, &options, rootfs) {
return code;
}
}
if let Some(architecture) = &options.provision_gentoo {
let rootfs = options.rootfs.as_deref().expect("checked at parse time");
if let Err(code) = provision_gentoo(architecture, &options, rootfs) {
return code;
}
}
if options.command_line.is_empty() && options.profile.is_none() {
return ExitCode::SUCCESS;
}
let stack = if options.netstack {
match netstack_from(&options) {
Ok(stack) => Some(stack),
Err(error) => {
eprintln!("fcage: {error}");
return ExitCode::from(125);
}
}
} else {
None
};
let mut profile_sets_term = false;
let mut profile_network_host = false;
let mut profile_sets_stop_with_caller = false;
let mut builder = match &options.profile {
Some(path) => match load_profile(
path,
!options.command_line.is_empty(),
options.profile_restricted,
) {
Ok((builder, meta)) => {
profile_sets_term = meta.sets_term;
profile_network_host = meta.network_host;
profile_sets_stop_with_caller = meta.sets_stop_with_caller;
builder
}
Err(message) => {
eprintln!("fcage: {message}");
return ExitCode::from(125);
}
},
None => Cage::builder(),
};
if options.netstack && options.network.is_none() && profile_network_host {
eprintln!("fcage: --netstack cannot be used with a profile that sets network = \"host\"");
eprintln!("Try 'fcage --help' for usage.");
return ExitCode::from(2);
}
if let Some(rootfs) = options.rootfs {
builder = builder.rootfs(rootfs);
}
if !options.overlay_lowers.is_empty() {
let mut overlay = Overlay::new();
for lower in options.overlay_lowers {
overlay = overlay.lower(lower);
}
if let Some(upper) = options.overlay_upper {
overlay = overlay.upper(upper);
}
if let Some(work) = options.overlay_work {
overlay = overlay.work(work);
}
builder = builder.overlay(overlay);
}
let mut command_line = options.command_line.into_iter();
if let Some(command) = command_line.next() {
builder = builder.command(PathBuf::from(command)).args(command_line);
}
let mut env = options.setenv;
let setenv_sets_term = env.iter().any(|(key, _)| key.to_str() == Some("TERM"));
if !profile_sets_term
&& !setenv_sets_term
&& let Some(term) = std::env::var_os("TERM")
{
env.push((OsString::from("TERM"), term));
}
builder = builder.envs(env);
for mount in options.mounts {
builder = match mount {
Mount::Bind(bind) => {
if bind.is_read_only() {
builder.bind_ro(bind.get_source(), bind.get_target())
} else {
builder.bind(bind.get_source(), bind.get_target())
}
}
Mount::Raw(raw) => builder.raw_mount(raw),
other => {
eprintln!("fcage: unsupported mount kind: {other:?}");
return ExitCode::from(125);
}
};
}
for (resource, soft, hard) in options.rlimits {
builder = builder.rlimit(resource, soft, hard);
}
if let Some(map) = options.identity_map {
builder = builder.identity_map(map);
}
if let Some(identity) = options.run_as {
builder = builder.run_as(identity);
}
if let Some(enabled) = options.path_lookup {
builder = builder.path_lookup(enabled);
}
if let Some(hostname) = options.hostname {
builder = builder.hostname(hostname);
}
if let Some(chdir) = options.chdir {
builder = builder.current_dir(chdir);
}
if let Some(stdin) = options.stdin {
builder = builder.stdin(stdin);
}
if let Some(network) = options.network {
builder = builder.network(network);
}
if let Some(isolate) = options.pid_namespace {
builder = builder.pid_namespace(isolate);
}
if let Some(mount) = options.mount_proc {
builder = builder.mount_proc(mount);
}
if let Some(mount) = options.mount_dev {
builder = builder.mount_dev(mount);
}
if let Some(mount) = options.mount_tmp {
builder = builder.mount_tmp(mount);
}
if let Some(bind) = options.resolv_conf {
builder = builder.resolv_conf(bind);
}
if let Some(managed) = options.managed_mounts {
builder = builder.managed_mounts(managed);
}
if let Some(base) = options.base_env {
builder = builder.base_env(base);
}
if let Some(tie) =
stop_with_caller_default(options.stop_with_caller, profile_sets_stop_with_caller)
{
builder = builder.stop_with_caller(tie);
}
for (path, writable) in options.landlock {
builder = builder.landlock_fs(landlock_access(writable), path);
}
for (port, access) in options.landlock_net {
builder = builder.landlock_net(access, port);
}
if let Some(policy) = options.seccomp {
builder = builder.seccomp(policy);
}
match options.caps {
Some(CapsChoice::DropAll) => builder = builder.drop_all_capabilities(),
Some(CapsChoice::Keep(caps)) => builder = builder.keep_capabilities(caps),
None => {}
}
if options.netstack {
if builder.get_resolv_conf() {
builder = compose_netstack_resolv_conf(builder);
}
return run_with_netstack(
builder,
stack.expect("assembled above for exactly this branch"),
options.terminal,
options.timeout,
options.kill_after,
);
}
if options.terminal {
return run_on_a_terminal(builder, options.timeout, options.kill_after);
}
let outcome = builder
.build()
.and_then(|cage| cage.spawn())
.and_then(|running| wait_with_deadline(running, options.timeout, options.kill_after));
conclude(outcome)
}
fn run_on_a_terminal(
builder: CageBuilder,
timeout: Option<Duration>,
kill_after: Duration,
) -> ExitCode {
let signals = match Signals::install() {
Ok(signals) => signals,
Err(error) => return conclude_relay(Err(error)),
};
let launched = builder
.build()
.and_then(|cage| cage.spawn_terminal(&terminal::terminal_for_caller()));
let (running, pty) = match launched {
Ok(launched) => launched,
Err(error) => {
eprintln!("fcage: {error}");
return ExitCode::from(error.shell_code());
}
};
conclude_relay(terminal::relay(running, pty, signals, timeout, kill_after))
}
fn conclude_relay(outcome: Result<Option<ExitStatus>, RelayError>) -> ExitCode {
match outcome {
Ok(outcome) => conclude(Ok(outcome)),
Err(RelayError::Cage(error)) => conclude(Err(error)),
Err(error) => {
eprintln!("fcage: {error}");
ExitCode::from(125)
}
}
}
fn netstack_from(options: &Options) -> Result<NetStack, ferroday_cage::NetStackError> {
let mut stack = NetStack::builder()
.ipv4(!options.netstack_no_ipv4)
.ipv6(!options.netstack_no_ipv6)
.host_loopback(options.netstack_host_loopback);
if let Some((network, prefix_len)) = options.netstack_cidr {
stack = stack.ipv4_cidr(network, prefix_len);
}
if let Some((network, prefix_len)) = options.netstack_cidr6 {
stack = stack.ipv6_cidr(network, prefix_len);
}
if let Some(name) = &options.netstack_interface {
stack = stack.interface(name);
}
if let Some(mtu) = options.netstack_mtu {
stack = stack.mtu(mtu);
}
stack.build()
}
fn run_with_netstack(
builder: CageBuilder,
stack: NetStack,
terminal: bool,
timeout: Option<Duration>,
kill_after: Duration,
) -> ExitCode {
let signals = if terminal {
match Signals::install() {
Ok(signals) => Some(signals),
Err(error) => return conclude_relay(Err(error)),
}
} else {
None
};
let cage = match builder.build() {
Ok(cage) => cage,
Err(error) => {
eprintln!("fcage: {error}");
return ExitCode::from(error.shell_code());
}
};
let held = if terminal {
cage.spawn_pending_terminal(&terminal::terminal_for_caller())
.map(|(pending, pty)| (pending, Some(pty)))
} else {
cage.spawn_pending().map(|pending| (pending, None))
};
let (pending, pty) = match held {
Ok(held) => held,
Err(error) => {
eprintln!("fcage: {error}");
return ExitCode::from(error.shell_code());
}
};
let handle = match stack.attach(&pending) {
Ok(handle) => handle,
Err(error) => {
eprintln!("fcage: {error}");
return ExitCode::from(125);
}
};
let running = match pending.proceed() {
Ok(running) => running,
Err(error) => {
eprintln!("fcage: {error}");
return ExitCode::from(error.shell_code());
}
};
let outcome = match (pty, signals) {
(Some(pty), Some(signals)) => terminal::relay(running, pty, signals, timeout, kill_after),
_ => wait_with_deadline(running, timeout, kill_after).map_err(Into::into),
};
if let Err(error) = handle.stop() {
eprintln!("fcage: the network stack did not stop cleanly: {error}");
}
conclude_relay(outcome)
}
fn mounts_resolv_conf(builder: &CageBuilder) -> bool {
builder
.get_mounts()
.iter()
.any(|mount| mount.get_target() == Path::new("/etc/resolv.conf"))
}
fn compose_netstack_resolv_conf(builder: CageBuilder) -> CageBuilder {
if mounts_resolv_conf(&builder) {
return builder;
}
let Ok(contents) = std::fs::read_to_string("/etc/resolv.conf") else {
return builder;
};
if resolv_conf_has_routable_nameserver(&contents) {
return builder.bind_ro("/etc/resolv.conf", "/etc/resolv.conf");
}
eprintln!(
"fcage: the host's nameservers are all on loopback, which the network stack \
refuses, so no resolv.conf was composed; the sandbox resolves through whatever \
its rootfs ships (point it at a routable nameserver, or give it the gateway \
address and pass --netstack-host-loopback, which reaches a host resolver only \
when that resolver listens on 127.0.0.1 — not systemd-resolved's 127.0.0.53 \
stub)"
);
builder
}
fn resolv_conf_has_routable_nameserver(contents: &str) -> bool {
contents.lines().any(|line| {
let line = line.trim();
let Some(address) = line.strip_prefix("nameserver") else {
return false;
};
if !address.starts_with([' ', '\t']) {
return false;
}
match address.trim().parse::<std::net::IpAddr>() {
Ok(address) => !address.is_loopback(),
Err(_) => false,
}
})
}
fn run_restricted(options: Options) -> ExitCode {
let mut builder = Restriction::builder();
let mut command_line = options.command_line.into_iter();
if let Some(command) = command_line.next() {
builder = builder.command(PathBuf::from(command)).args(command_line);
}
let mut env = options.setenv;
if let Some(term) = std::env::var_os("TERM") {
env.insert(0, (OsString::from("TERM"), term));
}
builder = builder.envs(env);
if let Some(base) = options.base_env {
builder = builder.base_env(base);
}
if let Some(chdir) = options.chdir {
builder = builder.current_dir(chdir);
}
if let Some(stdin) = options.stdin {
builder = builder.stdin(stdin);
}
for (resource, soft, hard) in options.rlimits {
builder = builder.rlimit(resource, soft, hard);
}
if let Some(tie) = stop_with_caller_default(options.stop_with_caller, false) {
builder = builder.stop_with_caller(tie);
}
for (path, writable) in options.landlock {
builder = builder.landlock_fs(landlock_access(writable), path);
}
for (port, access) in options.landlock_net {
builder = builder.landlock_net(access, port);
}
if let Some(policy) = options.seccomp {
builder = builder.seccomp(policy);
}
if options.terminal {
let signals = match Signals::install() {
Ok(signals) => signals,
Err(error) => return conclude_relay(Err(error)),
};
let launched = builder
.build()
.and_then(|restriction| restriction.spawn_terminal(&terminal::terminal_for_caller()));
let (running, pty) = match launched {
Ok(launched) => launched,
Err(error) => {
eprintln!("fcage: {error}");
return ExitCode::from(error.shell_code());
}
};
return conclude_relay(terminal::relay(
running,
pty,
signals,
options.timeout,
options.kill_after,
));
}
let outcome = builder
.build()
.and_then(|restriction| restriction.spawn())
.and_then(|running| wait_with_deadline(running, options.timeout, options.kill_after));
conclude(outcome)
}
fn landlock_access(writable: bool) -> FsAccess {
if writable {
FsAccess::READ | FsAccess::WRITE | FsAccess::EXECUTE
} else {
FsAccess::READ | FsAccess::EXECUTE
}
}
fn wait_with_deadline(
mut running: Running<'_>,
timeout: Option<Duration>,
kill_after: Duration,
) -> Result<Option<ExitStatus>, Error> {
let mut escalation = Escalation::new(timeout, kill_after);
let status = loop {
let Some(deadline) = escalation.deadline() else {
break running.wait()?;
};
match running.wait_deadline(deadline)? {
Some(status) => break status,
None => escalation.expire(&mut running)?,
}
};
Ok(escalation.outcome(status))
}
fn conclude(outcome: Result<Option<ExitStatus>, Error>) -> ExitCode {
match outcome {
Ok(None) => ExitCode::from(124),
Ok(Some(status)) => ExitCode::from(status.shell_code()),
Err(error) => {
eprintln!("fcage: {error}");
ExitCode::from(error.shell_code())
}
}
}
fn plan_from<P, E: std::fmt::Display>(
path: &Path,
parse: impl FnOnce(&str) -> Result<P, E>,
) -> Result<P, ExitCode> {
let document = std::fs::read_to_string(path).map_err(|error| {
eprintln!("fcage: reading {}: {error}", path.display());
ExitCode::from(125)
})?;
parse(&document).map_err(|error| {
eprintln!("fcage: {}: {error}", path.display());
ExitCode::from(125)
})
}
fn provision_debian(suite: &str, options: &Options, rootfs: &Path) -> Result<(), ExitCode> {
use ferroday_cage::provision::debian::{Debian, Plan};
let mut builder = Debian::builder(suite).extract_only(options.debian_extract_only);
if let Some(arch) = &options.debian_arch {
builder = builder.architecture(arch.as_str());
}
if let Some(mirror) = &options.debian_mirror {
builder = builder.mirror(mirror.as_str());
}
if !options.debian_components.is_empty() {
builder = builder.components(options.debian_components.clone());
}
if !options.debian_include.is_empty() {
builder = builder.include(options.debian_include.clone());
}
if !options.debian_exclude.is_empty() {
builder = builder.exclude(options.debian_exclude.clone());
}
if let Some(path) = &options.debian_plan {
builder = builder.plan(plan_from(path, Plan::parse_document)?);
}
if let Some(path) = &options.debian_pin {
builder = builder.pin(plan_from(path, Plan::parse_document)?);
}
if let Some(cache) = &options.debian_cache {
builder = builder.cache_dir(cache);
}
if let Some(keyring) = &options.debian_keyring {
builder = builder.keyring(keyring);
}
for fallback in &options.debian_mirror_fallback {
builder = builder.mirror_fallback(fallback.as_str());
}
if let Some(priority) = options.debian_base_priority {
builder = builder.base_priority(priority);
}
if options.debian_trust_unsigned {
builder = builder.trust_unsigned(true);
}
if options.debian_allow_stale_release {
builder = builder.allow_stale_release(true);
}
if let Some(overlay) = &options.debian_pre_configure_overlay {
builder = builder.pre_configure_overlay(overlay);
}
if let Some(map) = &options.debian_identity_map {
builder = builder.identity_map(map.clone());
}
for repository in &options.debian_repositories {
builder = builder.repository(repository.clone());
}
let mut debian = builder.build().map_err(|error| {
eprintln!("fcage: {error}");
ExitCode::from(125)
})?;
let mut progress = print_debian_event;
ferroday_cage::provision::ensure(rootfs, &mut debian.observe(&mut progress)).map_err(
|error| {
eprintln!("fcage: {error}");
ExitCode::from(125)
},
)?;
Ok(())
}
fn print_debian_event(event: ferroday_cage::provision::debian::DebianEvent<'_>) {
use ferroday_cage::provision::debian::DebianEvent;
use std::io::Write;
match event {
DebianEvent::Fetching { url, .. } => eprintln!("fcage: fetching {url}"),
DebianEvent::Resolving => eprintln!("fcage: resolving the package set"),
DebianEvent::Unsatisfiable {
requirement,
required_by,
reason,
..
} => eprintln!("fcage: {required_by} requires {requirement}, and {reason}"),
DebianEvent::Downloading {
package,
index,
total,
..
} => eprintln!("fcage: downloading {package} ({index}/{total})"),
DebianEvent::Extracting { package, .. } => eprintln!("fcage: extracting {package}"),
DebianEvent::CommandOutput { bytes, .. } => {
let _ = std::io::stderr().write_all(bytes);
}
_ => {}
}
}
fn provision_alpine(release: &str, options: &Options, rootfs: &Path) -> Result<(), ExitCode> {
use ferroday_cage::provision::alpine::{Alpine, Plan, Repository};
let usage = |message: String| {
eprintln!("fcage: {message}");
ExitCode::from(2)
};
let environment = |message: String| {
eprintln!("fcage: {message}");
ExitCode::from(125)
};
let architecture = options
.alpine_arch
.clone()
.unwrap_or_else(ferroday_cage::provision::alpine::host_architecture);
let mut builder = Alpine::builder(release)
.architecture(&architecture)
.extract_only(options.alpine_extract_only);
if let Some(mirror) = &options.alpine_mirror {
builder = builder.mirror(mirror);
}
for fallback in &options.alpine_mirror_fallback {
builder = builder.mirror_fallback(fallback);
}
if !options.alpine_components.is_empty() {
builder = builder.components(options.alpine_components.clone());
}
if !options.alpine_include.is_empty() {
builder = builder.include(options.alpine_include.clone());
}
if !options.alpine_exclude.is_empty() {
builder = builder.exclude(options.alpine_exclude.clone());
}
if let Some(path) = &options.alpine_plan {
builder = builder.plan(plan_from(path, Plan::parse_document)?);
}
if let Some(path) = &options.alpine_pin {
builder = builder.pin(plan_from(path, Plan::parse_document)?);
}
if let Some(cache) = &options.alpine_cache {
builder = builder.cache_dir(cache);
}
if let Some(keys) = &options.alpine_keys {
builder = builder
.keys(alpine_key_set("--alpine-keys", keys, &architecture).map_err(environment)?);
}
if let Some(overlay) = &options.alpine_pre_configure_overlay {
builder = builder.pre_configure_overlay(overlay);
}
if let Some(map) = &options.alpine_identity_map {
builder = builder.identity_map(map.clone());
}
for spec in &options.alpine_repositories {
let mut repository = Repository::builder(&spec.release).keys(
alpine_key_set("--alpine-repository", &spec.keys, &architecture)
.map_err(environment)?,
);
if let Some(primary) = &spec.mirror {
repository = repository.mirror(primary);
}
for fallback in &spec.fallbacks {
repository = repository.mirror_fallback(fallback);
}
if !spec.components.is_empty() {
repository = repository.components(spec.components.clone());
}
builder = builder.repository(
repository
.build()
.map_err(|error| usage(format!("--alpine-repository: {error}")))?,
);
}
let mut alpine = builder.build().map_err(|error| {
eprintln!("fcage: {error}");
ExitCode::from(125)
})?;
let mut progress = print_alpine_event;
ferroday_cage::provision::ensure(rootfs, &mut alpine.observe(&mut progress)).map_err(
|error| {
eprintln!("fcage: {error}");
ExitCode::from(125)
},
)?;
Ok(())
}
fn provision_gentoo(architecture: &str, options: &Options, rootfs: &Path) -> Result<(), ExitCode> {
use ferroday_cage::provision::ProvisionEvent;
use ferroday_cage::provision::gentoo::{Gentoo, Plan};
let mut builder = Gentoo::builder(architecture);
if let Some(variant) = &options.gentoo_variant {
builder = builder.variant(variant);
}
if let Some(binhost) = &options.gentoo_binhost {
builder = builder.binhost(binhost);
}
if !options.gentoo_install.is_empty() {
builder = builder.install(&options.gentoo_install);
}
if !options.gentoo_prefer_use.is_empty() {
builder = builder.prefer_use(&options.gentoo_prefer_use);
}
if let Some(path) = &options.gentoo_plan {
builder = builder.plan(plan_from(path, Plan::parse_document)?);
}
if let Some(build_id) = &options.gentoo_build_id {
builder = builder.build_id(build_id);
}
if let Some(mirror) = &options.gentoo_mirror {
builder = builder.mirror(mirror);
}
for fallback in &options.gentoo_mirror_fallback {
builder = builder.mirror_fallback(fallback);
}
if let Some(cache) = &options.gentoo_cache {
builder = builder.cache_dir(cache);
}
if let Some(age) = options.gentoo_max_pointer_age {
builder = builder.max_pointer_age(age.map(days));
}
let mut gentoo = builder.build().map_err(|error| {
eprintln!("fcage: {error}");
ExitCode::from(125)
})?;
let mut progress = |event: ProvisionEvent<'_>| {
if let ProvisionEvent::Gentoo(event) = event {
print_gentoo_event(event);
}
};
ferroday_cage::provision::Provision::new(rootfs)
.observe(&mut progress)
.run(&mut gentoo)
.map_err(|error| {
eprintln!("fcage: {error}");
ExitCode::from(125)
})?;
Ok(())
}
fn days(count: u64) -> std::time::Duration {
std::time::Duration::from_secs(count.saturating_mul(24 * 60 * 60))
}
fn print_gentoo_event(event: &ferroday_cage::provision::gentoo::GentooEvent<'_>) {
use ferroday_cage::provision::gentoo::GentooEvent;
match event {
GentooEvent::Fetching { url, .. } => eprintln!("fcage: fetching {url}"),
GentooEvent::Resolved { stage3, .. } => eprintln!(
"fcage: resolved {} to build {} ({} bytes), vouched for by {}",
stage3.variant(),
stage3.build_id(),
stage3.size(),
stage3.certificate(),
),
GentooEvent::Verifying { path, .. } => {
eprintln!("fcage: verifying {}", path.display());
}
GentooEvent::Extracting { path, .. } => {
eprintln!("fcage: extracting {}", path.display());
}
GentooEvent::Index {
builds,
skipped,
generated,
..
} => eprintln!(
"fcage: the binhost index names {builds} builds{}{}",
match skipped {
0 => String::new(),
skipped => format!(", {skipped} of which this layer cannot act on"),
},
generated.map_or_else(String::new, |at| format!(", generated at {at}")),
),
GentooEvent::Planned {
packages, bytes, ..
} => eprintln!("fcage: installing {packages} packages ({bytes} bytes)"),
GentooEvent::Merging {
package, version, ..
} => eprintln!("fcage: merging {package}-{version}"),
GentooEvent::DependencyCycle { packages, .. } => eprintln!(
"fcage: a runtime dependency cycle among {} was broken to order the merge",
packages.join(", "),
),
GentooEvent::Unsatisfiable {
atom,
wanted_by,
reason,
..
} => eprintln!("fcage: {wanted_by} requires {atom}, and no build {reason}"),
GentooEvent::Conflict {
atom,
stated_by,
blocks,
installed,
..
} => eprintln!(
"fcage: {stated_by} blocks {blocks} with {atom}, which {}",
if *installed {
"the root already has"
} else {
"this same resolution would install"
},
),
_ => {}
}
}
fn gentoo_variants(
architecture: &str,
mirror: Option<&str>,
fallbacks: &[String],
max_pointer_age: Option<Option<u64>>,
) -> ExitCode {
use ferroday_cage::provision::gentoo::Gentoo;
let mut builder = Gentoo::builder(architecture);
if let Some(mirror) = mirror {
builder = builder.mirror(mirror);
}
for fallback in fallbacks {
builder = builder.mirror_fallback(fallback);
}
if let Some(age) = max_pointer_age {
builder = builder.max_pointer_age(age.map(days));
}
let mut gentoo = match builder.build() {
Ok(gentoo) => gentoo,
Err(error) => {
eprintln!("fcage: {error}");
return ExitCode::from(125);
}
};
let available = match gentoo.available() {
Ok(available) => available,
Err(error) => {
eprintln!("fcage: {error}");
return ExitCode::from(125);
}
};
print_listing("the variant listing", |out| {
for variant in available.variants() {
let build = available.build_id(variant).unwrap_or("");
let size = available.size(variant).unwrap_or(0);
writeln!(out, "{variant}\t{build}\t{size}")?;
}
Ok(())
})
}
fn print_listing(
subject: &str,
records: impl FnOnce(&mut dyn std::io::Write) -> std::io::Result<()>,
) -> ExitCode {
use std::io::Write as _;
let mut out = std::io::BufWriter::new(std::io::stdout().lock());
let written = records(&mut out).and_then(|()| out.flush());
ExitCode::from(listing_status(subject, written))
}
fn listing_status(subject: &str, written: std::io::Result<()>) -> u8 {
match written {
Ok(()) => 0,
Err(error) if error.kind() == std::io::ErrorKind::BrokenPipe => 141,
Err(error) => {
eprintln!("fcage: cannot write {subject}: {error}");
125
}
}
}
fn gentoo_packages(
architecture: &str,
binhost: Option<&str>,
mirror: Option<&str>,
fallbacks: &[String],
) -> ExitCode {
use ferroday_cage::provision::gentoo::Gentoo;
let mut builder = Gentoo::builder(architecture);
if let Some(binhost) = binhost {
builder = builder.binhost(binhost);
}
if let Some(mirror) = mirror {
builder = builder.mirror(mirror);
}
for fallback in fallbacks {
builder = builder.mirror_fallback(fallback);
}
let mut gentoo = match builder.build() {
Ok(gentoo) => gentoo,
Err(error) => {
eprintln!("fcage: {error}");
return ExitCode::from(125);
}
};
let catalogue = match gentoo.packages() {
Ok(catalogue) => catalogue,
Err(error) => {
eprintln!("fcage: {error}");
return ExitCode::from(125);
}
};
print_listing("the package listing", |out| {
for name in catalogue.names() {
let versions: Vec<&str> = catalogue.versions(name).collect();
writeln!(out, "{name}\t{}", versions.join(" "))?;
}
Ok(())
})
}
fn gentoo_installed(root: &Path) -> ExitCode {
let installed = match ferroday_cage::provision::gentoo::installed(root) {
Ok(installed) => installed,
Err(error) => {
eprintln!("fcage: {error}");
return ExitCode::from(125);
}
};
print_listing("the installed listing", |out| {
for package in installed.packages() {
writeln!(
out,
"{}-{}\t{}\t{}",
package.name(),
package.version(),
package.slot(),
package.use_flags().collect::<Vec<_>>().join(" "),
)?;
}
Ok(())
})
}
fn gentoo_keyring_horizon() -> ExitCode {
use ferroday_cage::provision::gentoo::Gentoo;
let gentoo = match Gentoo::builder("amd64").build() {
Ok(gentoo) => gentoo,
Err(error) => {
eprintln!("fcage: {error}");
return ExitCode::from(125);
}
};
let horizon = gentoo.keyring_horizon();
print_listing("the keyring horizon", |out| {
for held in &horizon {
writeln!(
out,
"{}\t{}\t{}\t{}",
held.fingerprint,
if held.signs { "signs" } else { "cannot-sign" },
held.expires
.map_or_else(|| "never".to_string(), |at| at.to_string()),
held.user_id.as_deref().unwrap_or(""),
)?;
}
match horizon
.iter()
.filter(|held| held.signs)
.filter_map(|held| held.expires)
.min()
{
Some(earliest) => writeln!(
out,
"earliest expiry among the certificates that can sign\t{earliest}"
),
None => writeln!(out, "no certificate in the keyring can sign"),
}
})
}
fn print_alpine_event(event: ferroday_cage::provision::alpine::AlpineEvent<'_>) {
use ferroday_cage::provision::alpine::AlpineEvent;
use std::io::Write;
match event {
AlpineEvent::Fetching { url, .. } => eprintln!("fcage: fetching {url}"),
AlpineEvent::Resolving => eprintln!("fcage: resolving the package set"),
AlpineEvent::Downloading {
package,
index,
total,
..
} => eprintln!("fcage: downloading {package} ({index}/{total})"),
AlpineEvent::Extracting { package, .. } => eprintln!("fcage: extracting {package}"),
AlpineEvent::Script {
package, script, ..
} => eprintln!("fcage: {package}: {script}"),
AlpineEvent::CommandOutput { bytes, .. } => {
let _ = std::io::stderr().write_all(bytes);
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_args(args: &[&str]) -> Result<Invocation, String> {
parse(args.iter().map(OsString::from))
}
fn parse_error(args: &[&str]) -> String {
match parse_args(args) {
Err(message) => message,
Ok(_) => panic!("{args:?} should be refused"),
}
}
#[test]
fn every_kind_of_caller_mount_claims_the_resolv_conf_target() {
for claimed in [
Cage::builder().bind_ro("/etc/hosts", "/etc/resolv.conf"),
Cage::builder().bind_ro("/etc/hosts", "/etc/./resolv.conf"),
Cage::builder().raw_mount(
RawMount::new("/etc/resolv.conf")
.source("/etc/hosts")
.flags(0x1000),
),
Cage::builder().raw_mount(RawMount::new("/etc/resolv.conf").fstype("tmpfs")),
] {
assert!(
mounts_resolv_conf(&claimed),
"a caller's own mount at the target must be left in force",
);
}
assert!(!mounts_resolv_conf(
&Cage::builder().bind_ro("/etc/hosts", "/etc/hosts")
));
assert!(!mounts_resolv_conf(&Cage::builder()));
}
#[test]
fn a_misspelled_flag_suggests_the_one_it_was_meant_to_be() {
for (typed, meant) in [
("--rootsf", "--rootfs"),
("--seccomp-alow", "--seccomp-allow"),
("--no-prc", "--no-proc"),
("--bnid", "--bind"),
("--debian-includ", "--debian-include"),
("--netstack-host-loop", "--netstack-host-loopback"),
("--managed-mount", "--managed-mounts"),
] {
let error = parse_error(&[typed]);
assert!(
error.contains(&format!("Try 'fcage {meant}'.")),
"{typed} should have suggested {meant}, got {error:?}",
);
assert!(error.contains(typed), "the report drops what was typed");
}
}
#[test]
fn a_joined_value_does_not_hide_the_flag_it_misspells() {
let error = parse_error(&["--rootsf=/srv/alpine"]);
assert!(
error.contains("Try 'fcage --rootfs'."),
"the joined form drew no suggestion: {error:?}",
);
}
#[test]
fn an_option_of_another_mode_names_that_mode() {
for typed in ["--export-to", "--export-to=out.tar", "--clamp-mtime"] {
let error = parse_error(&["--rootfs", "/srv/alpine", typed, "/bin/sh"]);
assert!(
error.contains("is an option of --export-rootfs"),
"{typed} was not attributed to its mode: {error:?}",
);
assert!(
!error.contains("Try 'fcage --export-to'."),
"{typed} was answered with the spelling just refused: {error:?}",
);
}
}
#[test]
fn a_mode_suggests_a_misspelling_of_its_own_vocabulary() {
let error = parse_error(&["--export-rootfs", "/roots/a", "--clamp-mtme", "5"]);
assert!(
error.contains("Try 'fcage --clamp-mtime'."),
"the export mode did not answer its own misspelling: {error:?}",
);
let error = parse_error(&["--remove-rootfs", "/roots/a", "--identity-mp", "single"]);
assert!(
error.contains("Try 'fcage --identity-map'."),
"the removal mode did not answer its own misspelling: {error:?}",
);
let error = parse_error(&["--remove-rootfs", "/roots/a", "--hostname", "box"]);
assert!(
error.contains("cannot be used with --remove-rootfs"),
"a launch flag stopped reading as a conflict: {error:?}",
);
}
#[test]
fn a_commands_own_flags_do_not_choose_the_mode() {
let invocation = parse_args(&[
"--rootfs",
"/srv/alpine",
"--",
"/bin/mytool",
"--export-rootfs",
"/tmp/out",
])
.expect("the launch parses");
let Invocation::Run(options) = invocation else {
panic!("the invocation is a launch, not a mode");
};
assert_eq!(
options.command_line,
["/bin/mytool", "--export-rootfs", "/tmp/out"],
);
}
#[test]
fn a_commands_own_flags_do_not_choose_the_mode_without_a_separator() {
let invocation = parse_args(&[
"--rootfs",
"/srv/alpine",
"/bin/mytool",
"--export-rootfs",
"/tmp/out",
])
.expect("the launch parses");
let Invocation::Run(options) = invocation else {
panic!("the invocation is a launch, not a mode");
};
assert_eq!(
options.command_line,
["/bin/mytool", "--export-rootfs", "/tmp/out"],
);
}
#[test]
fn a_mode_flag_after_another_flags_value_still_chooses_the_mode() {
let invocation = parse_args(&["--export-to", "/tmp/out", "--export-rootfs", "/roots/a"])
.expect("the export parses");
assert!(
matches!(invocation, Invocation::Export { .. }),
"the invocation is an export",
);
}
#[test]
fn a_two_value_flag_does_not_hide_the_command() {
let invocation = parse_args(&["--rootfs", "/srv/a", "--setenv", "K", "V", "/bin/sh"])
.expect("the launch parses");
let Invocation::Run(options) = invocation else {
panic!("the invocation is a launch, not a mode");
};
assert_eq!(options.command_line, ["/bin/sh"]);
assert_eq!(options.setenv.len(), 1);
}
#[test]
fn the_export_only_roster_holds_only_export_options() {
let export_only: Vec<&str> = FLAGS
.iter()
.filter(|flag| flag.mode == Mode::Export && !flag.selects)
.map(|flag| flag.long)
.collect();
assert!(!export_only.is_empty(), "the roster is not empty");
for flag in export_only {
let launch = parse_error(&["--rootfs", "/srv/alpine", flag, "/bin/sh"]);
assert!(
launch.contains("unrecognized option"),
"a launch accepts {flag}, which the roster calls an export's: {launch:?}",
);
let export = parse_args(&["--export-rootfs", "/roots/a", flag, "1700000000"]);
assert!(
export.is_ok(),
"an export refuses {flag}, which the roster calls its own: {:?}",
export.err(),
);
}
}
#[test]
fn a_provision_and_exit_run_refuses_a_launch_flag() {
let error = parse_error(&[
"--rootfs",
"/roots/trixie",
"--provision-debian",
"trixie",
"--hostname",
"box",
]);
assert!(
error.contains("--hostname configures a launch"),
"the dropped flag went unreported: {error:?}",
);
assert!(
parse_args(&[
"--rootfs",
"/roots/trixie",
"--provision-debian",
"trixie",
"--debian-arch",
"amd64",
])
.is_ok()
);
assert!(
parse_args(&[
"--rootfs",
"/roots/trixie",
"--provision-debian",
"trixie",
"--hostname",
"box",
"/bin/sh",
])
.is_ok()
);
assert!(
parse_args(&[
"--rootfs",
"/roots/trixie",
"--provision-debian",
"trixie",
"--"
])
.is_ok()
);
}
fn run_options(args: &[&str]) -> Box<Options> {
match parse_args(args) {
Ok(Invocation::Run(options)) => options,
Ok(_) => panic!("{args:?} should parse as a run"),
Err(error) => panic!("{args:?} should parse: {error}"),
}
}
#[test]
fn the_alpine_options_reach_the_builder_they_mirror() {
let options = run_options(&[
"--rootfs",
"/roots/alpine",
"--provision-alpine",
"v3.23",
"--alpine-arch",
"aarch64",
"--alpine-include",
"build-base,git",
"--alpine-include",
"curl",
"--alpine-exclude",
"docs",
"--alpine-components",
"main,community",
"--alpine-cache",
"/var/cache/apk",
"--alpine-extract-only",
]);
assert_eq!(options.provision_alpine.as_deref(), Some("v3.23"));
assert_eq!(options.alpine_arch.as_deref(), Some("aarch64"));
assert_eq!(options.alpine_include, ["build-base", "git", "curl"]);
assert_eq!(options.alpine_exclude, ["docs"]);
assert_eq!(options.alpine_components, ["main", "community"]);
assert!(options.alpine_extract_only);
}
#[test]
fn every_userland_takes_a_plan_document_the_same_way() {
let options = run_options(&[
"--rootfs",
"/roots/trixie",
"--provision-debian",
"trixie",
"--debian-plan",
"/plans/trixie.plan",
"--debian-pin",
"/plans/trixie.pin",
]);
assert_eq!(
options.debian_plan.as_deref(),
Some(Path::new("/plans/trixie.plan"))
);
assert_eq!(
options.debian_pin.as_deref(),
Some(Path::new("/plans/trixie.pin"))
);
let options = run_options(&[
"--rootfs",
"/roots/alpine",
"--provision-alpine",
"v3.23",
"--alpine-plan",
"/plans/alpine.plan",
"--alpine-pin",
"/plans/alpine.pin",
]);
assert_eq!(
options.alpine_plan.as_deref(),
Some(Path::new("/plans/alpine.plan"))
);
assert_eq!(
options.alpine_pin.as_deref(),
Some(Path::new("/plans/alpine.pin"))
);
for flag in ["--debian-plan", "--debian-pin"] {
let error = parse_error(&["--rootfs", "/roots/r", flag, "/plans/p"]);
assert!(
error.contains("require --provision-debian"),
"{flag}: {error:?}",
);
}
for flag in ["--alpine-plan", "--alpine-pin"] {
let error = parse_error(&["--rootfs", "/roots/r", flag, "/plans/p"]);
assert!(
error.contains("require --provision-alpine"),
"{flag}: {error:?}",
);
}
}
#[test]
fn an_overlay_repository_is_spelled_by_naming_no_components() {
let options = run_options(&[
"--rootfs",
"/roots/alpine",
"--provision-alpine",
"v3.23",
"--alpine-repository",
"release=v26.06 mirror=http://mirror.invalid/pmos keys=postmarketos",
]);
let [overlay] = &options.alpine_repositories[..] else {
panic!(
"one repository was given: {:?}",
options.alpine_repositories
);
};
assert_eq!(overlay.release, "v26.06");
assert_eq!(
overlay.mirror.as_deref(),
Some("http://mirror.invalid/pmos")
);
assert!(overlay.fallbacks.is_empty());
assert!(overlay.components.is_empty(), "the shallower layout");
assert_eq!(overlay.keys, AlpineKeys::PostmarketOs);
}
#[test]
fn a_repository_spec_states_one_primary_mirror_and_names_it_mirror() {
for (flag, spec) in [
(
"--alpine-repository",
"release=v26.06 keys=postmarketos mirror-fallback=http://backstop.invalid",
),
(
"--debian-repository",
"suite=trixie mirror-fallback=http://backstop.invalid",
),
] {
let error = parse_error(&[
"--rootfs",
"/roots/r",
"--provision-alpine",
"v3.23",
flag,
spec,
]);
assert!(error.contains("mirror-fallback="), "{flag}: {error}");
assert!(error.contains("was not given"), "{flag}: {error}");
}
for (flag, spec) in [
(
"--alpine-repository",
"release=v26.06 keys=postmarketos mirror=http://one.invalid \
mirror=http://two.invalid",
),
(
"--debian-repository",
"suite=trixie mirror=http://one.invalid mirror=http://two.invalid",
),
] {
let error = parse_error(&[
"--rootfs",
"/roots/r",
"--provision-alpine",
"v3.23",
flag,
spec,
]);
assert!(error.contains("given twice"), "{flag}: {error}");
}
}
#[test]
fn a_repository_without_keys_is_refused_where_it_is_written() {
let error = parse_error(&[
"--rootfs",
"/roots/alpine",
"--provision-alpine",
"v3.23",
"--alpine-repository",
"release=v26.06 mirror=http://mirror.invalid/pmos",
]);
assert!(error.contains("keys="), "{error}");
let error = parse_error(&[
"--rootfs",
"/roots/alpine",
"--provision-alpine",
"v3.23",
"--alpine-repository",
"release=v26.06 mirror=http://mirror.invalid/pmos keys=alpine suite=trixie",
]);
assert!(
error.contains("suite"),
"an unknown field is named: {error}"
);
}
#[test]
fn the_gentoo_binhost_options_reach_the_builder_they_mirror() {
let options = run_options(&[
"--rootfs",
"/roots/gentoo",
"--provision-gentoo",
"amd64",
"--gentoo-variant",
"amd64-openrc",
"--gentoo-binhost",
"x86-64",
"--gentoo-install",
"dev-vcs/git[keyring]",
"--gentoo-install=app-editors/vim",
"--gentoo-prefer-use",
"-X gtk",
"--gentoo-prefer-use=keyring",
"--gentoo-plan",
"/plans/binhost.plan",
]);
assert_eq!(options.gentoo_binhost.as_deref(), Some("x86-64"));
assert_eq!(
options.gentoo_install,
["dev-vcs/git[keyring]", "app-editors/vim"],
);
assert_eq!(options.gentoo_prefer_use, ["-X", "gtk", "keyring"]);
assert_eq!(
options.gentoo_plan.as_deref(),
Some(Path::new("/plans/binhost.plan")),
);
}
#[test]
fn the_gentoo_binhost_listings_are_modes_of_their_own() {
match parse_args(&["--gentoo-packages", "amd64", "--gentoo-binhost", "x86-64"])
.expect("the listing parses")
{
Invocation::GentooPackages {
architecture,
binhost,
..
} => {
assert_eq!(architecture, "amd64");
assert_eq!(binhost.as_deref(), Some("x86-64"));
}
_ => panic!("expected a binhost listing"),
}
match parse_args(&["--gentoo-installed=/roots/gentoo"]).expect("the listing parses") {
Invocation::GentooInstalled { root } => assert_eq!(root, Path::new("/roots/gentoo")),
_ => panic!("expected an installed listing"),
}
let error = parse_error(&["--gentoo-packages", "amd64", "--rootfs", "/roots/x"]);
assert!(error.contains("--gentoo-packages"), "{error}");
let error = parse_error(&["--gentoo-installed", "/roots/x", "--gentoo-mirror", "u"]);
assert!(error.contains("--gentoo-installed"), "{error}");
assert!(parse_error(&["--gentoo-installed"]).contains("requires a value"));
}
#[test]
fn the_gentoo_options_reach_the_builder_they_mirror() {
let options = run_options(&[
"--rootfs",
"/roots/gentoo",
"--provision-gentoo",
"amd64",
"--gentoo-variant",
"amd64-hardened-openrc",
"--gentoo-build-id",
"20260810T204554Z",
"--gentoo-mirror",
"http://mirror.invalid/gentoo",
"--gentoo-mirror-fallback",
"http://backup.invalid/gentoo",
"--gentoo-cache",
"/var/cache/stage3",
"--gentoo-max-pointer-age",
"7",
]);
assert_eq!(options.provision_gentoo.as_deref(), Some("amd64"));
assert_eq!(
options.gentoo_variant.as_deref(),
Some("amd64-hardened-openrc"),
);
assert_eq!(options.gentoo_build_id.as_deref(), Some("20260810T204554Z"));
assert_eq!(
options.gentoo_mirror.as_deref(),
Some("http://mirror.invalid/gentoo"),
);
assert_eq!(
options.gentoo_mirror_fallback,
["http://backup.invalid/gentoo"],
);
assert_eq!(options.gentoo_max_pointer_age, Some(Some(7)));
let cleared = run_options(&[
"--rootfs",
"/roots/gentoo",
"--provision-gentoo",
"amd64",
"--gentoo-variant=amd64-openrc",
"--gentoo-max-pointer-age=none",
]);
assert_eq!(cleared.gentoo_max_pointer_age, Some(None));
assert_eq!(cleared.gentoo_variant.as_deref(), Some("amd64-openrc"));
let error = parse_error(&[
"--rootfs",
"/roots/gentoo",
"--provision-gentoo",
"amd64",
"--gentoo-variant",
"amd64-openrc",
"--gentoo-max-pointer-age",
"a-while",
]);
assert!(error.contains("number of days or 'none'"), "{error}");
}
#[test]
fn a_gentoo_bootstrap_names_the_variant_it_installs() {
let error = parse_error(&["--rootfs", "/roots/gentoo", "--provision-gentoo", "amd64"]);
assert!(error.contains("--gentoo-variant"), "{error}");
assert!(error.contains("--gentoo-variants"), "{error}");
}
#[test]
fn the_gentoo_listings_are_modes_of_their_own() {
match parse_args(&[
"--gentoo-variants",
"arm64",
"--gentoo-mirror",
"http://mirror.invalid/gentoo",
"--gentoo-mirror-fallback=http://backup.invalid/gentoo",
"--gentoo-max-pointer-age=none",
])
.expect("the listing parses")
{
Invocation::GentooVariants {
architecture,
mirror,
fallbacks,
max_pointer_age,
} => {
assert_eq!(architecture, "arm64");
assert_eq!(mirror.as_deref(), Some("http://mirror.invalid/gentoo"));
assert_eq!(fallbacks, ["http://backup.invalid/gentoo"]);
assert_eq!(max_pointer_age, Some(None));
}
_ => panic!("expected a variant listing"),
}
let listing = |args: &[&str]| match parse_args(args).expect("the listing parses") {
Invocation::GentooVariants {
mirror, fallbacks, ..
} => (mirror, fallbacks),
_ => panic!("expected a variant listing"),
};
assert_eq!(
listing(&[
"--gentoo-variants",
"amd64",
"--gentoo-mirror-fallback",
"http://backup.invalid/gentoo",
]),
(None, vec!["http://backup.invalid/gentoo".to_string()]),
);
assert_eq!(
listing(&[
"--gentoo-variants",
"amd64",
"--gentoo-mirror",
"http://a.invalid",
"--gentoo-mirror",
"http://b.invalid",
]),
(Some("http://b.invalid".to_string()), Vec::new()),
);
assert!(matches!(
parse_args(&["--gentoo-keyring-horizon"]).expect("the horizon parses"),
Invocation::GentooKeyring,
));
let error = parse_error(&["--gentoo-variants", "amd64", "--rootfs", "/roots/gentoo"]);
assert!(error.contains("--rootfs"), "{error}");
let error = parse_error(&["--gentoo-keyring-horizon", "--gentoo-mirror", "http://x"]);
assert!(error.contains("--gentoo-mirror"), "{error}");
}
#[test]
fn the_provisioners_are_mutually_exclusive_and_need_a_destination() {
let error = parse_error(&[
"--rootfs",
"/roots/mixed",
"--provision-debian",
"trixie",
"--provision-alpine",
"v3.23",
]);
assert!(
error.contains("--provision-debian and --provision-alpine"),
"{error}",
);
let error = parse_error(&[
"--rootfs",
"/roots/mixed",
"--provision-tar",
"rootfs.tar",
"--provision-gentoo",
"amd64",
]);
assert!(
error.contains("--provision-tar and --provision-gentoo"),
"{error}",
);
for provisioner in [
["--provision-tar", "rootfs.tar"],
["--provision-debian", "trixie"],
["--provision-alpine", "v3.23"],
["--provision-gentoo", "amd64"],
] {
let error = parse_error(&provisioner);
assert!(error.contains("--rootfs"), "{provisioner:?}: {error}");
let error = parse_error(&[
"--profile",
"/etc/fcage/profile.toml",
provisioner[0],
provisioner[1],
]);
assert_eq!(
error,
format!(
"{} requires --rootfs to name the destination",
provisioner[0]
),
);
}
}
#[test]
fn the_alpine_options_require_the_provisioner_they_configure() {
let error = parse_error(&[
"--rootfs",
"/roots/alpine",
"--alpine-include",
"build-base",
"/bin/sh",
]);
assert!(
error.contains("the --alpine-* options require --provision-alpine"),
"{error}",
);
}
#[test]
fn the_gentoo_options_require_the_provisioner_they_configure() {
let error = parse_error(&[
"--rootfs",
"/roots/gentoo",
"--gentoo-variant",
"amd64-openrc",
"/bin/sh",
]);
assert!(
error.contains("the --gentoo-* options require --provision-gentoo"),
"{error}",
);
}
#[test]
fn a_mode_answers_help_rather_than_taking_it_as_a_path() {
for args in [
["--remove-rootfs", "--help"].as_slice(),
["--export-rootfs", "--help"].as_slice(),
["--remove-rootfs", "/roots/a", "--help"].as_slice(),
] {
assert!(
matches!(parse_args(args), Ok(Invocation::Help)),
"{args:?} did not ask for help",
);
}
assert!(matches!(
parse_args(&["--export-rootfs", "--version"]),
Ok(Invocation::Version)
));
}
#[test]
fn the_sandbox_is_tied_to_fcage_unless_the_run_says_otherwise() {
let options = parse_run(&["--rootfs", "/srv/alpine", "/bin/true"]).expect("it parses");
assert_eq!(options.stop_with_caller, None);
assert_eq!(
stop_with_caller_default(options.stop_with_caller, false),
Some(true)
);
let options = parse_run(&[
"--rootfs",
"/srv/alpine",
"--no-stop-with-caller",
"/bin/true",
])
.expect("it parses");
assert_eq!(options.stop_with_caller, Some(false));
assert_eq!(
stop_with_caller_default(options.stop_with_caller, false),
Some(false),
);
}
#[test]
fn a_listing_whose_reader_stops_early_ends_quietly() {
assert_eq!(
listing_status("a listing", Err(std::io::ErrorKind::BrokenPipe.into())),
141,
);
assert_eq!(
listing_status("a listing", Err(std::io::ErrorKind::StorageFull.into())),
125,
);
assert_eq!(listing_status("a listing", Ok(())), 0);
}
#[test]
fn a_profiles_caller_tie_is_reachable_through_fcage() {
assert_eq!(stop_with_caller_default(None, true), None);
assert_eq!(stop_with_caller_default(Some(false), true), Some(false));
assert_eq!(stop_with_caller_default(Some(true), true), Some(true));
let dir = ferroday_cage_testkit::scratch::scratch_dir("fcage-profile-tie");
let stated = dir.join("stated.toml");
std::fs::write(
&stated,
"rootfs = \"/tmp\"\ncommand = \"/bin/true\"\nstop-with-caller = false\n",
)
.expect("the profile is written");
let (_, meta) = load_profile(&stated, false, false).expect("the profile loads");
assert!(meta.sets_stop_with_caller);
let silent = dir.join("silent.toml");
std::fs::write(&silent, "rootfs = \"/tmp\"\ncommand = \"/bin/true\"\n")
.expect("the profile is written");
let (_, meta) = load_profile(&silent, false, false).expect("the profile loads");
assert!(!meta.sets_stop_with_caller);
}
#[test]
fn a_string_resembling_no_flag_is_reported_without_a_guess() {
for typed in ["--qqqzzz", "--", "-x", "--wildly-unlike-anything"] {
let error = parse_error(&[typed]);
assert!(
!error.contains("Try 'fcage --"),
"{typed} drew a suggestion out of nothing: {error:?}",
);
}
for typed in ["--", "-", "---"] {
assert_eq!(
nearest_flag(typed, &["--identity-map", "--remove-rootfs"]),
None
);
let error = parse_error(&["--remove-rootfs=/roots/a", typed]);
assert!(
!error.contains("Try 'fcage --"),
"{typed} drew a suggestion out of nothing: {error:?}",
);
}
assert_eq!(
nearest_flag("--remove", &["--identity-map", "--remove-rootfs"]),
Some("--remove-rootfs"),
);
}
#[test]
fn an_alpine_key_set_that_names_nothing_is_a_usage_error() {
let error = parse_error(&[
"--rootfs",
"/r",
"--alpine-repository",
"release=v3.23 mirror=https://dl-cdn.alpinelinux.org/alpine keys=",
"/bin/true",
]);
assert!(error.contains("names no key set"), "{error}");
assert_eq!(
parse_alpine_keys("--flag", "alpine"),
Ok(AlpineKeys::Alpine)
);
assert_eq!(
parse_alpine_keys("--flag", "postmarketos"),
Ok(AlpineKeys::PostmarketOs)
);
assert_eq!(
parse_alpine_keys("--flag", "keys"),
Ok(AlpineKeys::Dir(PathBuf::from("keys")))
);
}
#[test]
fn every_flag_the_parser_accepts_is_in_the_help() {
let usage = usage();
for flag in FLAGS {
if flag.section != Section::Counterpart {
assert!(
usage.contains(&format!(" {}", flag.spelled())),
"the help does not list {}, which is not a prose entry",
flag.long,
);
continue;
}
assert!(
usage.contains(flag.long),
"the parser accepts {}, which --help never names",
flag.long,
);
}
}
#[test]
fn every_flag_the_help_names_in_prose_is_one_the_parser_accepts() {
let usage = usage();
let prose = usage
.split_once("Each toggle")
.expect("the closing prose")
.1;
for token in prose.split_whitespace() {
let name = token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-');
if !name.starts_with("--") || name.len() <= 2 || name.ends_with('-') {
continue;
}
assert!(
FLAGS.iter().any(|flag| flag.long == name),
"the closing prose names {name}, which no flag is",
);
}
}
#[test]
fn every_flag_the_help_names_is_one_the_parser_accepts() {
for flag in FLAGS {
if matches!(flag.mode, Mode::Help | Mode::Version) {
continue;
}
let long = flag.long;
let vocabularies: [Vec<&str>; 3] = [
vec![long],
vec!["--remove-rootfs", "/roots/a", long],
vec!["--export-rootfs", "/roots/a", long],
];
let recognized = vocabularies.iter().any(|args| match parse_args(args) {
Err(error) => {
!error.contains("unrecognized option") && !error.contains("cannot be used with")
}
Ok(_) => true,
});
assert!(
recognized,
"the table holds {long}, which no mode's parser accepts",
);
}
}
#[test]
fn the_help_files_every_option_under_a_section() {
let usage = usage();
let mut section: Option<&str> = None;
for line in usage.lines() {
if line.ends_with(':') && !line.starts_with(' ') && !line.starts_with("Usage") {
section = Some(line);
} else if line.starts_with(" -") {
assert!(
section.is_some(),
"the option {line:?} sits above the first section header",
);
}
}
for header in [
"Provisioning the root filesystem:",
"Profiles, overlays, and mounts:",
"Identity and limits:",
"The command and its process:",
"Networking:",
"Hardening:",
"Turning off what is on by default:",
"Help and version:",
] {
assert!(
usage.contains(header),
"the help lost the section {header:?}"
);
}
}
#[test]
fn edit_distance_counts_single_character_edits() {
assert_eq!(edit_distance("", ""), 0);
assert_eq!(edit_distance("--rootfs", "--rootfs"), 0);
assert_eq!(edit_distance("--rootfs", "--roatfs"), 1);
assert_eq!(edit_distance("--rootfs", "--rootf"), 1);
assert_eq!(edit_distance("--rootfs", "--rootfss"), 1);
assert_eq!(edit_distance("--rootfs", "--rootsf"), 2);
assert_eq!(edit_distance("--bind", ""), 6);
assert_eq!(edit_distance("", "--bind"), 6);
}
#[test]
fn remove_rootfs_takes_a_destination_in_either_form() {
for args in [
["--remove-rootfs", "/var/lib/roots/trixie"].as_slice(),
["--remove-rootfs=/var/lib/roots/trixie"].as_slice(),
] {
match parse_args(args).expect("the removal should parse") {
Invocation::Remove { dest, map } => {
assert_eq!(dest, PathBuf::from("/var/lib/roots/trixie"));
assert_eq!(
map,
IdentityMap::Subordinate,
"the default is the map a bundled-delegate sandbox writes under"
);
}
_ => panic!("expected a removal for {args:?}"),
}
}
}
#[test]
fn remove_rootfs_takes_the_identity_map_the_tree_was_written_under() {
for args in [
["--identity-map", "single", "--remove-rootfs", "/roots/a"].as_slice(),
["--remove-rootfs", "/roots/a", "--identity-map=single"].as_slice(),
] {
match parse_args(args).expect("the removal should parse") {
Invocation::Remove { map, .. } => assert_eq!(map, IdentityMap::Single),
_ => panic!("expected a removal for {args:?}"),
}
}
}
#[test]
fn a_separated_value_is_never_another_flag() {
for args in [
["--remove-rootfs", "--identity-map=subordinate"].as_slice(),
["--remove-rootfs", "/roots/a", "--identity-map", "--rootfs"].as_slice(),
["--rootfs", "--share-net", "/bin/true"].as_slice(),
["--bind", "--rootfs", "/target", "/r", "/bin/true"].as_slice(),
["--gentoo-variants", "--gentoo-mirror"].as_slice(),
] {
let error = parse_error(args);
assert!(
error.contains("names a flag"),
"expected a refusal for {args:?}, got {error:?}"
);
}
}
#[test]
fn a_value_that_merely_begins_with_a_dash_is_the_callers() {
let options = parse_run(&[
"--rootfs",
"/r",
"--provision-gentoo",
"amd64",
"--gentoo-variant",
"openrc",
"--gentoo-prefer-use",
"-systemd",
"/bin/true",
])
.expect("a valid command line");
assert_eq!(options.gentoo_prefer_use, ["-systemd"]);
}
#[test]
fn a_value_that_is_a_flags_spelling_is_written_joined() {
match parse_args(&["--remove-rootfs=--identity-map"]).expect("the removal should parse") {
Invocation::Remove { dest, .. } => {
assert_eq!(dest, PathBuf::from("--identity-map"));
}
_ => panic!("expected a removal"),
}
}
#[test]
fn remove_rootfs_refuses_every_launch_option() {
for flag in [
"--rootfs",
"--restrict",
"--share-net",
"--provision-tar",
"--profile",
"--bind",
"--seccomp",
] {
let error = parse_error(&["--remove-rootfs", "/roots/a", flag]);
assert!(
error.contains(flag) && error.contains("--remove-rootfs"),
"the error should name both flags, got {error:?}"
);
}
}
#[test]
fn remove_rootfs_refuses_a_command() {
let error = parse_error(&["--remove-rootfs", "/roots/a", "/bin/true"]);
assert!(error.contains("/bin/true"), "{error}");
}
#[test]
fn remove_rootfs_requires_a_destination() {
assert!(parse_args(&["--remove-rootfs"]).is_err());
assert!(parse_args(&["--identity-map", "--remove-rootfs"]).is_err());
}
fn parse_export_args(args: &[&str]) -> (PathBuf, Option<PathBuf>, IdentityMap, Option<i64>) {
match parse_args(args).expect("the export should parse") {
Invocation::Export {
source,
dest,
map,
clamp_mtime,
} => (source, dest, map, clamp_mtime),
_ => panic!("expected an export for {args:?}"),
}
}
#[test]
fn export_rootfs_takes_its_settings_in_either_form() {
for args in [
[
"--export-rootfs",
"/roots/trixie",
"--export-to",
"/tmp/out.tar",
"--identity-map",
"single",
"--clamp-mtime",
"1700000000",
]
.as_slice(),
[
"--export-rootfs=/roots/trixie",
"--export-to=/tmp/out.tar",
"--identity-map=single",
"--clamp-mtime=1700000000",
]
.as_slice(),
] {
let (source, dest, map, clamp) = parse_export_args(args);
assert_eq!(source, PathBuf::from("/roots/trixie"));
assert_eq!(dest, Some(PathBuf::from("/tmp/out.tar")));
assert_eq!(map, IdentityMap::Single);
assert_eq!(clamp, Some(1_700_000_000));
}
}
#[test]
fn export_rootfs_defaults_to_standard_output_and_the_subordinate_map() {
let (source, dest, map, clamp) = parse_export_args(&["--export-rootfs", "/roots/a"]);
assert_eq!(source, PathBuf::from("/roots/a"));
assert_eq!(dest, None, "the default sink is standard output");
assert_eq!(map, IdentityMap::Subordinate);
assert_eq!(clamp, None, "an unclamped export records real times");
}
#[test]
fn export_rootfs_refuses_every_launch_option_and_a_command() {
for flag in [
"--rootfs",
"--restrict",
"--share-net",
"--provision-tar",
"--profile",
"--bind",
"--remove-rootfs",
] {
let error = parse_error(&["--export-rootfs", "/roots/a", flag]);
assert!(
error.contains(flag) && error.contains("--export-rootfs"),
"the error should name both flags, got {error:?}",
);
}
let error = parse_error(&["--export-rootfs", "/roots/a", "/bin/true"]);
assert!(error.contains("/bin/true"), "{error}");
}
#[test]
fn export_rootfs_requires_a_source_and_a_well_formed_ceiling() {
assert!(parse_args(&["--export-rootfs"]).is_err());
assert!(parse_args(&["--export-rootfs", "/roots/a", "--export-to"]).is_err());
for bad in ["yesterday", "1.5", ""] {
assert!(
parse_args(&["--export-rootfs", "/roots/a", "--clamp-mtime", bad]).is_err(),
"{bad:?} must be refused as a timestamp",
);
}
let (_, _, _, clamp) =
parse_export_args(&["--export-rootfs", "/roots/a", "--clamp-mtime=-1"]);
assert_eq!(clamp, Some(-1));
}
#[test]
fn export_rootfs_still_answers_help_and_version() {
assert!(matches!(
parse_args(&["--export-rootfs", "/roots/a", "--help"]),
Ok(Invocation::Help)
));
assert!(matches!(
parse_args(&["--export-rootfs", "/roots/a", "--version"]),
Ok(Invocation::Version)
));
}
#[test]
fn remove_rootfs_still_answers_help_and_version() {
assert!(matches!(
parse_args(&["--remove-rootfs", "/roots/a", "--help"]),
Ok(Invocation::Help)
));
assert!(matches!(
parse_args(&["--remove-rootfs", "/roots/a", "--version"]),
Ok(Invocation::Version)
));
}
#[test]
fn rlimit_parses_a_shared_and_a_split_value() {
let (resource, soft, hard) = parse_rlimit("processes=64").unwrap();
assert_eq!(resource, Resource::Processes);
assert_eq!(soft, Limit::of(64));
assert_eq!(
hard,
Limit::of(64),
"an omitted hard limit repeats the soft"
);
let (resource, soft, hard) = parse_rlimit("cpu-time=10:unlimited").unwrap();
assert_eq!(resource, Resource::CpuTime);
assert_eq!(soft, Limit::of(10));
assert_eq!(hard, Limit::UNLIMITED);
}
#[test]
fn rlimit_rejects_bad_input() {
for bad in ["processes", "nonsense=1", "processes=x", "processes=1:x"] {
assert!(parse_rlimit(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn rlimit_names_the_resources_it_accepts() {
let error = parse_rlimit("nonsense=1").unwrap_err();
assert!(error.contains("address-space"), "{error}");
assert!(error.contains("stack"), "{error}");
}
#[test]
fn identity_map_parses_the_unit_and_range_forms() {
assert!(matches!(
parse_identity_map("--identity-map", "single").unwrap(),
IdentityMap::Single
));
assert!(matches!(
parse_identity_map("--identity-map", "subordinate").unwrap(),
IdentityMap::Subordinate
));
let map = parse_identity_map("--identity-map", "uid=0:1000:1,1:100000:65536 gid=0:1000:1")
.unwrap();
match map {
IdentityMap::Ranges { uid, gid, .. } => {
assert_eq!(uid.len(), 2);
assert_eq!(
uid[1],
IdRange {
inside: 1,
outside: 100_000,
count: 65_536
}
);
assert_eq!(gid.len(), 1);
}
other => panic!("expected an explicit range map, got {other:?}"),
}
}
#[test]
fn identity_map_rejects_a_half_specified_range() {
assert!(parse_identity_map("--identity-map", "uid=0:1000:1").is_err());
assert!(parse_identity_map("--identity-map", "nonsense").is_err());
assert!(parse_identity_map("--identity-map", "uid=0:1000").is_err());
}
#[test]
fn run_as_parses_ids_and_groups() {
let identity = parse_run_as("250:250").unwrap();
assert_eq!(identity, Identity::new(250, 250));
let with_groups = parse_run_as("250:250:10,20").unwrap();
assert_eq!(with_groups, Identity::new(250, 250).groups([10, 20]));
for bad in ["", "250", "250:", "x:1", "250:250:x"] {
assert!(parse_run_as(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn raw_mount_parses_its_fields() {
let mount = parse_raw_mount("target=/sys fstype=sysfs flags=0xE").unwrap();
assert_eq!(mount.get_target(), Path::new("/sys"));
assert_eq!(mount.get_fstype(), Some("sysfs"));
assert_eq!(mount.get_flags(), 0xE);
assert_eq!(mount.get_source(), None);
assert!(parse_raw_mount("fstype=sysfs").is_err());
assert!(parse_raw_mount("target=/sys nonsense=1").is_err());
}
#[test]
fn repository_requires_a_suite_and_orders_its_mirrors() {
let repository = parse_repository(
"suite=trixie mirror=file:///srv/pool mirror-fallback=file:///srv/snapshot \
components=main,contrib trust-unsigned name=local",
)
.unwrap();
let rendered = format!("{repository:?}");
assert!(rendered.contains("file:///srv/pool"), "{rendered}");
assert!(rendered.contains("trust: Unsigned"), "{rendered}");
assert!(parse_repository("mirror=file:///srv/pool").is_err());
}
#[test]
fn a_field_that_takes_no_value_refuses_one() {
for spec in [
"suite=trixie mirror=file:///srv/pool trust-unsigned=false",
"suite=trixie mirror=file:///srv/pool allow-stale-release=no",
] {
let error = parse_repository(spec).expect_err("a value on a bare field is refused");
assert!(error.contains("takes no value"), "{error}");
}
let repository = parse_repository("suite=trixie mirror=file:///srv/pool trust-unsigned")
.expect("the bare field parses");
assert!(format!("{repository:?}").contains("trust: Unsigned"));
}
#[test]
fn priority_parses_the_archive_bands() {
assert_eq!(parse_priority("required").unwrap(), Priority::Required);
assert_eq!(parse_priority("optional").unwrap(), Priority::Optional);
assert!(parse_priority("urgent").is_err());
}
#[test]
fn an_overlay_root_needs_a_lower_and_an_upper() {
let args = |items: &[&str]| {
items
.iter()
.map(|item| OsString::from(*item))
.collect::<Vec<_>>()
.into_iter()
};
assert!(parse(args(&["--overlay-upper", "/tmp/up", "/bin/true"])).is_err());
assert!(parse(args(&["--overlay-lower", "/tmp/low", "/bin/true"])).is_err());
assert!(
parse(args(&[
"--rootfs",
"/tmp/root",
"--overlay-lower",
"/tmp/low",
"--overlay-upper",
"/tmp/up",
"/bin/true",
]))
.is_err()
);
assert!(
parse(args(&[
"--overlay-lower",
"/tmp/low",
"--overlay-upper",
"/tmp/up",
"/bin/true",
]))
.is_ok()
);
}
#[test]
fn seconds_parse() {
let parsed = parse_seconds("--timeout", OsStr::new("1.5")).unwrap();
assert_eq!(parsed, Duration::from_millis(1500));
}
#[test]
fn seconds_reject_bad_values() {
for bad in ["", "abc", "0", "-1", "nan", "inf", "1e300"] {
assert!(
parse_seconds("--timeout", OsStr::new(bad)).is_err(),
"{bad:?} must be rejected"
);
}
}
#[test]
fn cidr_rejects_an_out_of_range_prefix() {
let (addr, len) = parse_cidr("10.0.2.0/24").unwrap();
assert_eq!(addr, Ipv4Addr::new(10, 0, 2, 0));
assert_eq!(len, 24);
for bad in ["10.0.2.0/33", "10.0.2.0/255"] {
assert!(parse_cidr(bad).is_err(), "{bad:?} must be rejected");
}
}
fn sysno(name: &str) -> i64 {
resolve_syscall_name("--test", name).expect("a known syscall name")
}
#[test]
fn seccomp_names_resolve_and_accumulate() {
let mut target = Vec::new();
parse_seccomp_names("--seccomp-allow", "read, write ,exit_group", &mut target).unwrap();
assert_eq!(
target,
vec![sysno("read"), sysno("write"), sysno("exit_group")]
);
parse_seccomp_names("--seccomp-allow", "ioctl", &mut target).unwrap();
assert_eq!(target.last(), Some(&sysno("ioctl")));
}
#[test]
fn seccomp_rule_parses_a_simple_condition() {
let (syscall, conditions) = parse_seccomp_rule(
"--seccomp-allow-rule",
"ioctl arg=1 len=dword op=eq value=0x5413",
)
.unwrap();
assert_eq!(syscall, sysno("ioctl"));
assert_eq!(conditions, vec![SeccompArg::eq_dword(1, 0x5413)]);
}
#[test]
fn seccomp_rule_parses_masked_eq_and_decimal() {
let (syscall, conditions) = parse_seccomp_rule(
"--seccomp-allow-rule",
"clone arg=0 len=qword op=masked-eq value=0 mask=268435456",
)
.unwrap();
assert_eq!(syscall, sysno("clone"));
assert_eq!(
conditions,
vec![SeccompArg::masked_eq_qword(0, 0x1000_0000, 0)]
);
}
#[test]
fn seccomp_rule_parses_anded_conditions() {
let (_, conditions) = parse_seccomp_rule(
"--seccomp-deny-rule",
"socket arg=0 len=dword op=eq value=2, arg=1 len=dword op=eq value=1",
)
.unwrap();
assert_eq!(
conditions,
vec![SeccompArg::eq_dword(0, 2), SeccompArg::eq_dword(1, 1)]
);
}
#[test]
fn seccomp_rule_without_conditions_lists_the_syscall() {
let (syscall, conditions) = parse_seccomp_rule("--seccomp-deny-rule", "ptrace").unwrap();
assert_eq!(syscall, sysno("ptrace"));
assert!(conditions.is_empty());
}
#[test]
fn seccomp_rule_rejects_malformed_conditions() {
let cases = [
("ioctl arg=1 op=eq value=1", "len"), ("ioctl len=dword op=eq value=1", "arg"), ("ioctl arg=1 len=dword value=1", "op"), ("ioctl arg=1 len=dword op=eq", "value"), ("ioctl arg=1 len=dword op=eq value=1 mask=3", "masked-eq"), ("clone arg=0 len=qword op=masked-eq value=0", "mask"), ("ioctl arg=9 len=dword op=eq value=1", "0 through 5"), ("ioctl arg=1 len=word op=eq value=1", "dword"), ("ioctl arg=1 len=dword op=lol value=1", "op must be"), ("ioctl arg=1 len=dword op=eq value=xyz", "number"), ("ioctl arg=1 len=dword eq value=1", "key=value"), (
"ioctl arg=1 len=dword op=eq value=1 extra=2",
"unknown condition key",
),
("nope arg=1 len=dword op=eq value=1", "unknown syscall"),
];
for (input, needle) in cases {
let err = parse_seccomp_rule("--seccomp-allow-rule", input)
.expect_err(&format!("{input:?} must be rejected"));
assert!(
err.contains(needle),
"error {err:?} for {input:?} should mention {needle:?}"
);
}
}
#[test]
fn u64_literals_accept_decimal_and_hex() {
assert_eq!(parse_u64_literal("--f", "value", "42").unwrap(), 42);
assert_eq!(parse_u64_literal("--f", "value", "0x2a").unwrap(), 42);
assert_eq!(parse_u64_literal("--f", "value", "0X2A").unwrap(), 42);
assert!(parse_u64_literal("--f", "value", "").is_err());
assert!(parse_u64_literal("--f", "value", "0xzz").is_err());
}
#[test]
fn assemble_seccomp_reconciles_the_flags() {
assert!(
assemble_seccomp(false, vec![], vec![], vec![], vec![])
.unwrap()
.is_none()
);
assert!(matches!(
assemble_seccomp(true, vec![], vec![], vec![], vec![]).unwrap(),
Some(SeccompPolicy::Curated)
));
assert!(matches!(
assemble_seccomp(
false,
vec![sysno("read")],
vec![],
vec![(sysno("ioctl"), vec![SeccompArg::eq_dword(1, 0x5413)])],
vec![],
)
.unwrap(),
Some(SeccompPolicy::Rules(_))
));
}
#[test]
fn assemble_seccomp_rejects_contradictions() {
assert!(
assemble_seccomp(true, vec![sysno("read")], vec![], vec![], vec![])
.unwrap_err()
.contains("curated")
);
assert!(
assemble_seccomp(
false,
vec![sysno("read")],
vec![sysno("write")],
vec![],
vec![]
)
.unwrap_err()
.contains("cannot be combined")
);
}
#[test]
fn seccomp_flags_flow_through_parse() {
let args = [
"--restrict",
"--seccomp-deny-rule",
"write arg=0 len=dword op=eq value=1",
"--",
"/bin/echo",
"hi",
]
.into_iter()
.map(OsString::from);
let parsed = parse(args).expect("a valid command line");
match parsed {
Invocation::Run(options) => {
assert!(matches!(options.seccomp, Some(SeccompPolicy::Rules(_))));
}
_ => panic!("expected a run invocation"),
}
}
fn parse_run(args: &[&str]) -> Result<Options, String> {
match parse(args.iter().map(|arg| OsString::from(*arg)))? {
Invocation::Run(options) => Ok(*options),
_ => panic!("expected a run invocation"),
}
}
#[test]
fn profile_flags_set_the_trust_mode() {
let options =
parse_run(&["--profile", "/p.toml", "/bin/true"]).expect("a valid command line");
assert_eq!(options.profile.as_deref(), Some(Path::new("/p.toml")));
assert!(!options.profile_restricted);
let options = parse_run(&["--restricted-profile", "/p.toml", "/bin/true"])
.expect("a valid command line");
assert_eq!(options.profile.as_deref(), Some(Path::new("/p.toml")));
assert!(options.profile_restricted);
assert!(
parse_run(&["--profile", "/a", "--restricted-profile", "/b", "/bin/true"]).is_err()
);
}
#[test]
fn netstack_flags_flow_through_parse() {
let options = parse_run(&[
"--rootfs",
"/r",
"--netstack",
"--netstack-cidr",
"192.168.5.0/24",
"--netstack-mtu",
"1400",
"--netstack-host-loopback",
"/bin/true",
])
.expect("a valid command line");
assert!(options.netstack);
assert_eq!(
options.netstack_cidr,
Some((Ipv4Addr::new(192, 168, 5, 0), 24))
);
assert_eq!(options.netstack_mtu, Some(1400));
assert!(options.netstack_host_loopback);
}
#[test]
fn every_netstack_setting_the_library_offers_has_a_flag_that_reaches_it() {
let options = parse_run(&[
"--rootfs",
"/r",
"--netstack",
"--netstack-cidr",
"192.168.5.0/24",
"--netstack-cidr6",
"fd11:2233::/64",
"--netstack-no-ipv6",
"--netstack-interface",
"eth9",
"--netstack-mtu",
"1400",
"/bin/true",
])
.expect("a valid command line");
assert_eq!(
options.netstack_cidr6,
Some(("fd11:2233::".parse::<Ipv6Addr>().unwrap(), 64))
);
assert!(options.netstack_no_ipv6);
assert!(!options.netstack_no_ipv4);
assert_eq!(options.netstack_interface.as_deref(), Some("eth9"));
netstack_from(&options).expect("the settings compose a stack");
let neither = parse_run(&[
"--rootfs",
"/r",
"--netstack",
"--netstack-no-ipv4",
"--netstack-no-ipv6",
"/bin/true",
])
.expect("a valid command line");
assert!(netstack_from(&neither).is_err());
}
#[test]
fn netstack_cidr_and_mtu_reject_bad_values() {
assert!(
parse_cidr("10.0.2.0").is_err(),
"a CIDR needs a prefix length"
);
assert!(parse_cidr("not-an-ip/24").is_err());
assert!(parse_cidr("10.0.2.0/schwa").is_err());
parse_cidr("10.0.2.0/24").expect("a well-formed CIDR parses");
assert!(parse_mtu("70000").is_err(), "an MTU must fit a u16");
parse_mtu("1500").expect("a valid MTU parses");
assert!(
parse_cidr6("fd00::").is_err(),
"a CIDR needs a prefix length"
);
assert!(
parse_cidr6("10.0.2.0/24").is_err(),
"that is not an IPv6 address"
);
assert!(parse_cidr6("fd00::/schwa").is_err());
assert!(
parse_cidr6("fd00::/129").is_err(),
"129 is past the ceiling"
);
parse_cidr6("fd00::/64").expect("a well-formed CIDR parses");
}
#[test]
fn netstack_conflicts_with_share_net() {
let err = parse_run(&["--rootfs", "/r", "--netstack", "--share-net", "/bin/true"])
.err()
.expect("sharing the host network contradicts the native stack");
assert!(err.contains("--share-net"), "{err}");
}
#[test]
fn netstack_modifiers_require_netstack() {
let err = parse_run(&["--rootfs", "/r", "--netstack-mtu", "1400", "/bin/true"])
.err()
.expect("a sub-flag without --netstack is a usage error");
assert!(err.contains("require --netstack"), "{err}");
}
#[test]
fn terminal_conflicts_with_a_null_stdin() {
let err = parse_run(&[
"--rootfs",
"/r",
"--terminal",
"--stdin",
"null",
"/bin/true",
])
.err()
.expect("a directed stream contradicts a terminal");
assert!(err.contains("--terminal"), "{err}");
assert!(err.contains("--stdin"), "{err}");
}
#[test]
fn terminal_conflicts_with_a_null_stdin_under_restrict() {
let err = parse_run(&[
"--restrict",
"--seccomp",
"curated",
"--terminal",
"--stdin=null",
"/bin/true",
])
.err()
.expect("a directed stream contradicts a terminal here too");
assert!(err.contains("--terminal"), "{err}");
}
#[test]
fn terminal_composes_with_a_restriction_and_with_the_native_stack() {
parse_run(&[
"--restrict",
"--seccomp",
"curated",
"--terminal",
"/bin/true",
])
.expect("a restriction takes a terminal");
parse_run(&["--rootfs", "/r", "--netstack", "--terminal", "/bin/true"])
.expect("the native stack takes a terminal");
}
#[test]
fn netstack_conflicts_with_restrict() {
let err = parse_run(&[
"--restrict",
"--netstack",
"--landlock-ro",
"/lib",
"/bin/true",
])
.err()
.expect("the native stack has no meaning under a restriction");
assert!(err.contains("--netstack"), "{err}");
}
#[test]
fn restrict_requires_a_grant() {
let err = parse_run(&["--restrict", "/bin/true"])
.err()
.expect("a restriction with no grant is rejected");
assert!(err.contains("at least one grant"), "{err}");
parse_run(&["--restrict", "--landlock-ro", "/lib", "/bin/true"])
.expect("a Landlock grant is enough");
}
#[test]
fn kill_after_requires_timeout() {
let err = parse_run(&["--rootfs", "/r", "--kill-after", "5", "/bin/true"])
.err()
.expect("--kill-after alone has nothing to hang on");
assert!(err.contains("--kill-after requires --timeout"), "{err}");
parse_run(&[
"--rootfs",
"/r",
"--timeout",
"10",
"--kill-after",
"5",
"/bin/true",
])
.expect("--kill-after with --timeout is valid");
}
#[test]
fn a_loopback_only_resolv_conf_is_not_routable() {
assert!(!resolv_conf_has_routable_nameserver(
"nameserver 127.0.0.53\noptions edns0\n"
));
assert!(resolv_conf_has_routable_nameserver(
"nameserver 127.0.0.53\nnameserver 9.9.9.9\n"
));
assert!(resolv_conf_has_routable_nameserver(
"nameserver 2606:4700:4700::1111\n"
));
assert!(!resolv_conf_has_routable_nameserver("# only a comment\n"));
}
}