Skip to main content

ferroday_cage/
error.rs

1//! Typed errors for sandbox configuration, spawn, setup, and wait failures.
2//!
3//! # Every `Display` here is written out, and none is derived
4//!
5//! The crate carries roughly 1,100 lines of hand-written `Display` and
6//! `source`, one match arm per variant, and a derive macro would remove almost
7//! all of it. It is written out on purpose.
8//!
9//! What these messages are is the reason. Several are three sentences of
10//! guidance — what failed, what it was operating on, and what to do about it —
11//! and one of them is chosen by a guard rather than by a variant:
12//! `CommandNotAbsolute` says something different about a bare name than about
13//! a relative path with a slash, because path lookup is the remedy for one and
14//! misdirection for the other. Attribute strings hold a label well and a
15//! paragraph badly, and the paragraph is what a caller acts on.
16//!
17//! The cost is real and worth naming: a variant, its message, and its cause sit
18//! in three places in this file. What the arrangement buys is that the messages
19//! read as prose someone wrote, and that the crate's manifest still justifies
20//! every dependency in it — a rendering convenience is not a reason to add a
21//! proc macro to a sandbox library's build.
22//!
23//! The `Io { op, path, source }` shape those errors share *is* declared once,
24//! by `failure::path_io_error!`; it is the constructors that were four copies,
25//! not the messages.
26
27use std::fmt;
28use std::io;
29use std::path::PathBuf;
30
31use crate::host::UsernsBlocker;
32use crate::spec::command_has_slash;
33use crate::wire::wire_enum;
34
35wire_enum! {
36    /// A step of the sandbox setup sequence, performed in the child process.
37    ///
38    /// When a setup step fails inside the sandbox process, the failure is
39    /// reported to the caller as [`Error::Setup`], naming the step and the
40    /// `errno` the kernel returned.
41    ///
42    /// The explicit discriminants are the wire encoding of the setup-error
43    /// pipe, which both ends of a single build write and read. They are an
44    /// implementation detail: a step inserted in a later release renumbers the
45    /// ones after it, and no discriminant is guaranteed to denote the same step
46    /// across versions. Do not persist, transmit, or compare them — match on
47    /// the variant, and use [`Display`](std::fmt::Display) for a human-readable
48    /// name.
49    #[non_exhaustive]
50    pub enum SetupStep {
51        /// Unshare into the new namespaces.
52        Unshare = 1 => "unsharing the namespaces",
53        /// Write `deny` to `/proc/self/setgroups`.
54        DenySetgroups = 2 => "denying setgroups",
55        /// Write the single-identity gid map.
56        WriteGidMap = 3 => "writing the gid map",
57        /// Write the single-identity uid map.
58        WriteUidMap = 4 => "writing the uid map",
59        /// Set the sandbox hostname.
60        SetHostname = 5 => "setting the hostname",
61        /// Bring up the loopback interface in the isolated network namespace.
62        ConfigureLoopback = 6 => "bringing up the loopback interface",
63        /// Make mount propagation recursively private.
64        PrivatizeMounts = 7 => "making mount propagation private",
65        /// Bind the rootfs onto itself so it becomes a mount point.
66        BindRootfs = 8 => "binding the rootfs onto itself",
67        /// Enter the rootfs mount point.
68        EnterRootfs = 9 => "entering the rootfs mount point",
69        /// Create a missing mount target inside the rootfs.
70        CreateMountTarget = 10 => "creating a mount target",
71        /// Open a mount target inside the rootfs.
72        OpenMountTarget = 11 => "opening a mount target",
73        /// Mount a tmpfs.
74        MountTmpfs = 12 => "mounting a tmpfs",
75        /// Mount a devpts instance.
76        MountDevpts = 13 => "mounting devpts",
77        /// Bind-mount a host path into the rootfs.
78        BindMount = 14 => "bind-mounting",
79        /// Remount a bind mount read-only.
80        RemountReadOnly = 15 => "remounting a bind mount read-only",
81        /// Create a symlink inside the rootfs.
82        CreateSymlink = 16 => "creating a symlink",
83        /// Pivot the root into the rootfs.
84        PivotRoot = 17 => "pivoting into the rootfs",
85        /// Lazily detach the old root mount.
86        DetachOldRoot = 18 => "detaching the old root",
87        /// Change directory to the new root.
88        ChdirNewRoot = 19 => "changing to the new root directory",
89        /// Change to the configured working directory.
90        ChdirWorkdir = 20 => "changing to the working directory",
91        /// Execute the command.
92        Exec = 21 => "executing the command",
93        /// Reset the inherited signal dispositions and mask.
94        ResetSignals = 22 => "resetting the inherited signal state",
95        /// Fork the sandbox init process.
96        ForkInit = 23 => "forking the sandbox init process",
97        /// Send the supervisor's pidfd back to the caller.
98        SendPidfd = 24 => "publishing the supervisor pidfd",
99        /// Mount a fresh procfs instance.
100        MountProc = 25 => "mounting a fresh procfs",
101        /// Perform a raw mount.
102        MountRaw = 26 => "performing a raw mount",
103        /// Sweep inherited file descriptors.
104        SweepFds = 27 => "sweeping inherited file descriptors",
105        /// Fork the command process.
106        ForkCommand = 28 => "forking the command process",
107        /// Open a pidfd for the command process.
108        WatchCommand = 29 => "opening a pidfd for the command process",
109        /// Wire the command's standard streams.
110        WireStdio = 30 => "wiring the command's standard streams",
111        /// Drop or reduce the command's capabilities.
112        DropCapabilities = 31 => "dropping capabilities",
113        /// Set the no-new-privileges flag before restricting the command.
114        SetNoNewPrivs = 32 => "setting the no-new-privileges flag",
115        /// Create the Landlock ruleset.
116        LandlockCreateRuleset = 33 => "creating the Landlock ruleset",
117        /// Add a rule to the Landlock ruleset.
118        LandlockAddRule = 34 => "adding a Landlock rule",
119        /// Enforce the Landlock ruleset on the command.
120        LandlockRestrictSelf = 35 => "enforcing the Landlock ruleset",
121        /// Install the seccomp filter.
122        InstallSeccomp = 36 => "installing the seccomp filter",
123        /// Wait at the launch gate for the caller to attach a network stack and
124        /// release the command.
125        AwaitNetworkStack = 37 => "waiting for the network stack to attach",
126        /// Wait for the caller's delegate to establish the identity map.
127        AwaitIdentityMap = 38 => "waiting for the identity map to be established",
128        /// Lock the securebits that preserve capabilities across the identity
129        /// switch.
130        SetSecurebits = 39 => "locking the securebits for the identity switch",
131        /// Set the command's supplementary groups.
132        SetGroups = 40 => "setting the supplementary groups",
133        /// Switch to the command's gid.
134        SetGid = 41 => "switching to the command gid",
135        /// Switch to the command's uid.
136        SetUid = 42 => "switching to the command uid",
137        /// Mount the overlay that roots an overlay-rooted cage.
138        MountOverlayRoot = 43 => "mounting the overlay root",
139        /// Apply a resource limit to the command.
140        SetRlimit = 44 => "applying a resource limit",
141        /// Start the sandbox's own session, detaching it from the caller's
142        /// controlling terminal.
143        NewSession = 45 => "starting the sandbox's own session",
144        /// Make the launch's pseudoterminal the controlling terminal of the
145        /// sandbox's session.
146        SetControllingTerminal = 46 => "making the sandbox's pseudoterminal its controlling terminal",
147        /// Enter the nested user namespace that locks the sandbox's mount flags.
148        NestedUnshare = 47 => "entering the nested user namespace",
149        /// Write `deny` to the nested user namespace's `setgroups`.
150        NestedDenySetgroups = 48 => "denying setgroups in the nested user namespace",
151        /// Write the nested user namespace's gid map.
152        NestedWriteGidMap = 49 => "writing the nested user namespace's gid map",
153        /// Write the nested user namespace's uid map.
154        NestedWriteUidMap = 50 => "writing the nested user namespace's uid map",
155        /// Wait for the sandbox's own delegate to establish the nested user
156        /// namespace's identity map.
157        AwaitNestedIdentityMap = 51 => "waiting for the nested identity map to be established",
158        /// Open the procfs the nested user namespace's identity map is
159        /// established through.
160        OpenSandboxProcfs = 52 => "opening the procfs the nested identity map is established through",
161        /// Create or operate the gate the nested identity map is established
162        /// across.
163        NestedMapGate = 53 => "operating the nested identity-map gate",
164        /// Mark the sandbox's root mount `nosuid`.
165        NosuidRootfs = 54 => "marking the root mount nosuid",
166    }
167}
168
169/// Which directory of an overlay root a failure names.
170///
171/// Every layer is the caller's to place, and each must exist and be resolvable
172/// before the mount, so a failure preparing one has to say which it was. The
173/// rootfs is the overlay's first lower and keeps its own
174/// [`RootfsUnusable`](ConfigError::RootfsUnusable) reporting; `Lower` names the
175/// stacked layers beneath it.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
177#[non_exhaustive]
178pub enum OverlayLayer {
179    /// A lower layer stacked beneath the rootfs: read-only, contributing files
180    /// the merge shows through.
181    Lower,
182    /// The upper layer: where the sandbox's writes land.
183    Upper,
184    /// The work directory the overlay uses for its own bookkeeping.
185    Work,
186}
187
188impl fmt::Display for OverlayLayer {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        f.write_str(match self {
191            OverlayLayer::Lower => "lower",
192            OverlayLayer::Upper => "upper",
193            OverlayLayer::Work => "work",
194        })
195    }
196}
197
198/// A sandbox configuration rejected by [`CageBuilder::build`].
199///
200/// [`CageBuilder::build`]: crate::CageBuilder::build
201#[derive(Debug)]
202#[non_exhaustive]
203pub enum ConfigError {
204    /// No rootfs was provided.
205    RootfsMissing,
206    /// A plain rootfs and an overlay root were both configured, where exactly
207    /// one root can be in force.
208    ///
209    /// The builder's setters keep the two exclusive — [`rootfs`] clears an
210    /// overlay and [`overlay`] clears a rootfs — so this arises from a profile
211    /// that carries both keys. Honoring either one silently would discard the
212    /// other's configuration.
213    ///
214    /// [`rootfs`]: crate::CageBuilder::rootfs
215    /// [`overlay`]: crate::CageBuilder::overlay
216    RootContradiction,
217    /// The rootfs path could not be resolved and opened as a directory.
218    #[non_exhaustive]
219    RootfsUnusable {
220        /// The rootfs path as it was given to the builder.
221        path: PathBuf,
222        /// The error encountered while resolving or opening it.
223        source: io::Error,
224    },
225    /// The rootfs resolves to the host root directory.
226    RootfsIsHostRoot,
227    /// No command was provided, or the path given names nothing.
228    ///
229    /// An empty path is the second: it is not a command that could not be
230    /// found, it is the absence of one written down. Every later check reads it
231    /// as something else — it carries no slash, so a path lookup takes it for a
232    /// bare name and joining it onto a `PATH` entry yields that entry — so it is
233    /// refused where the command is read rather than deferred to an `execve` of
234    /// a directory.
235    CommandMissing,
236    /// The command path is not absolute.
237    ///
238    /// The message names the remedy that fits: a bare command name — the most
239    /// common form of this mistake — is resolvable with
240    /// [`path_lookup`](crate::CageBuilder::path_lookup), while a relative path
241    /// containing a slash is never searched and must be made absolute.
242    #[non_exhaustive]
243    CommandNotAbsolute {
244        /// The command path as it was given to the builder.
245        command: PathBuf,
246    },
247    /// The managed mount profile was opted out of, while one of its mounts was
248    /// explicitly asked for. The two cannot both be honored.
249    #[non_exhaustive]
250    ManagedMountsContradiction {
251        /// The builder method naming the mount that was explicitly requested.
252        toggle: &'static str,
253    },
254    /// Path lookup was requested, but the `PATH` in force holds no absolute
255    /// directory to search, so the command could not resolve to any candidate.
256    #[non_exhaustive]
257    SearchPathUnusable {
258        /// The `PATH` the lookup would have searched.
259        path: std::ffi::OsString,
260    },
261    /// A bind-mount source could not be resolved on the host.
262    #[non_exhaustive]
263    BindSourceUnusable {
264        /// The source path as it was given to the builder.
265        path: PathBuf,
266        /// The error encountered while resolving it.
267        source: io::Error,
268    },
269    /// A mount target is not a normal absolute path inside the rootfs.
270    ///
271    /// A bind or raw-mount target must be absolute, must not be `/` itself,
272    /// and must not contain `.` or `..` components.
273    #[non_exhaustive]
274    MountTargetInvalid {
275        /// The target path as it was given to the builder.
276        path: PathBuf,
277    },
278    /// A raw mount's flags do not fit the kernel's mount-flags word.
279    #[non_exhaustive]
280    MountFlagsInvalid {
281        /// The flags as they were given to the builder.
282        flags: u64,
283    },
284    /// The working directory path is not absolute.
285    #[non_exhaustive]
286    WorkdirNotAbsolute {
287        /// The working directory as it was given to the builder.
288        path: PathBuf,
289    },
290    /// The hostname is empty or longer than 64 bytes.
291    HostnameInvalid,
292    /// An environment variable name is empty or contains `=`.
293    #[non_exhaustive]
294    EnvNameInvalid {
295        /// The offending variable name.
296        name: std::ffi::OsString,
297    },
298    /// A configured path, name, or value contains an interior NUL byte.
299    EmbeddedNul,
300    /// A seccomp policy could not be compiled into a BPF program.
301    #[non_exhaustive]
302    SeccompInvalid {
303        /// What the compiler reported.
304        reason: String,
305    },
306    /// Seccomp filtering is not available for the host architecture.
307    #[non_exhaustive]
308    SeccompUnsupportedArch {
309        /// The host architecture, as `std::env::consts::ARCH` reports it.
310        arch: String,
311    },
312    /// A seccomp program is longer than the kernel's instruction ceiling.
313    ///
314    /// The kernel accepts a classic BPF filter of at most `BPF_MAXINSNS`
315    /// (4096) instructions. A precompiled `SeccompPolicy::Program` escape
316    /// hatch that exceeds it is rejected here rather than being silently
317    /// truncated when the filter is installed.
318    #[non_exhaustive]
319    SeccompProgramTooLong {
320        /// The program's instruction count.
321        len: usize,
322        /// The kernel's maximum instruction count.
323        max: usize,
324    },
325    /// A Landlock grant path is not a normal absolute path.
326    ///
327    /// A grant path must be absolute and must not contain `.` or `..`
328    /// components. In a cage it is a sandbox path, resolved after the pivot;
329    /// in a restriction it is a host path.
330    #[non_exhaustive]
331    LandlockPathInvalid {
332        /// The grant path as it was given to the builder.
333        path: PathBuf,
334    },
335    /// A Landlock grant names no access at all.
336    ///
337    /// An empty access set is not "grant nothing here": enrolling any grant
338    /// denies everything ungranted, and the kernel skips a rule that permits
339    /// nothing — so an empty grant is a total denial of the class it belongs
340    /// to. That is never what an author means by naming a path or a port, so
341    /// it is refused rather than silently applied.
342    #[non_exhaustive]
343    LandlockGrantEmpty {
344        /// The grant as it was written: the path of a filesystem grant, or
345        /// `tcp:<port>` for a network grant.
346        grant: String,
347    },
348    /// A restriction names no Landlock grant and no seccomp policy.
349    ///
350    /// A restriction exists to confine the command; with nothing to enforce
351    /// it would launch a plain process while appearing to sandbox it, so an
352    /// empty restriction is rejected instead.
353    RestrictionEmpty,
354    /// A restriction's Landlock grants cannot be enforced on this host.
355    ///
356    /// A restriction is confined only by what the running kernel enforces.
357    /// When its Landlock grants all fall outside the supported Landlock ABI —
358    /// a network grant on a kernel below ABI 4 — or the Landlock LSM is
359    /// absent, no ruleset applies and the command would run unconfined, so the
360    /// build is refused rather than silently dropping the requested
361    /// confinement. A cage in the same case keeps its namespace isolation, so
362    /// this is specific to the restriction fallback.
363    #[non_exhaustive]
364    RestrictionLandlockUnenforceable {
365        /// The kernel's supported Landlock ABI, or `None` when the LSM is
366        /// absent.
367        abi: Option<i32>,
368    },
369    /// A host-network cage requests a Landlock network grant the kernel cannot
370    /// enforce.
371    ///
372    /// Under [`Network::Host`](crate::Network::Host) there is no network
373    /// namespace, so a Landlock network grant is the only network boundary.
374    /// When the kernel's Landlock ABI is below 4 (network rights first appear
375    /// there) or the LSM is absent, the grant governs nothing and the command
376    /// would reach the host network unrestricted, so the build is refused. A
377    /// [`Isolated`](crate::Network::Isolated) or [`None`](crate::Network::None)
378    /// cage is unaffected: its network namespace remains the boundary.
379    #[non_exhaustive]
380    HostNetworkLandlockUnenforceable {
381        /// The kernel's supported Landlock ABI, or `None` when the LSM is
382        /// absent.
383        abi: Option<i32>,
384    },
385    /// A profile loaded under the restricted policy uses an operation only a
386    /// trusted profile may.
387    ///
388    /// Deserializing a profile straight into a `CageBuilder` trusts it as
389    /// code-equivalent configuration. A profile from an untrusted source is
390    /// loaded under the restricted policy instead, which forbids the
391    /// operations that map host resources into the sandbox or share a host
392    /// namespace: bind mounts, raw mounts, an overlay root, host networking,
393    /// and sharing the host PID namespace.
394    #[non_exhaustive]
395    ProfileOperationForbidden {
396        /// The forbidden operation, as a short phrase.
397        operation: &'static str,
398    },
399    /// A launch attaches a conduit to a standard stream the sandbox was built
400    /// to send somewhere else.
401    ///
402    /// [`Stdio::Inherit`](crate::Stdio::Inherit) states no destination, so a
403    /// launch is free to supply one and this never arises against it.
404    /// [`Stdio::Null`](crate::Stdio::Null) and
405    /// [`Stdio::from_fd`](crate::Stdio::from_fd) state one, and a launch that
406    /// would supply its own is refused rather than silently overriding what the
407    /// caller said. Two launches supply one: an [`Observer`](crate::Observer)'s
408    /// capture pipes, on the output pair, and a terminal launch's
409    /// pseudoterminal replica, on all three at once.
410    ///
411    /// Reported by the launch rather than by
412    /// [`build`](crate::CageBuilder::build): the contradiction is between the
413    /// frozen sandbox and one particular launch of it, and the same sandbox
414    /// launches without contradiction through an entry point that attaches
415    /// nothing.
416    #[non_exhaustive]
417    StreamAttachmentConflict {
418        /// The stream whose disposition the attachment contradicts, as a short
419        /// phrase — `"standard input"`, `"standard output"`, or
420        /// `"standard error"`.
421        stream: &'static str,
422        /// What the launch attaches to it, as a short phrase.
423        attachment: &'static str,
424    },
425    /// An identity map's ranges are structurally invalid.
426    ///
427    /// The kernel's rules are enforced here so an unwritable map is a typed
428    /// configuration error: both lists non-empty, inside-id 0 mapped, no
429    /// zero-count or wrapping extents, no overlaps, and at most 340 extents.
430    #[non_exhaustive]
431    IdentityMapInvalid {
432        /// What makes the map unwritable.
433        reason: String,
434    },
435    /// No identity-map delegate can satisfy the requested range map.
436    ///
437    /// The fallback is between delegates, never between tiers: a range
438    /// request that no delegate can establish is refused rather than
439    /// silently downgraded to the single-identity map. The reason carries
440    /// each delegate's refusal, and the host probe's diagnosis when
441    /// [`host::range_map_blocker`](crate::host::range_map_blocker)
442    /// identifies one.
443    #[non_exhaustive]
444    IdentityMapUnavailable {
445        /// Why each delegate refused, and any identified host blocker.
446        reason: String,
447    },
448    /// A run-as id is not contained in the identity map.
449    ///
450    /// Every id the command runs as must be representable in the sandbox's
451    /// user namespace. Under the single-identity map the only id is 0; a
452    /// range map contains what its extents say.
453    #[non_exhaustive]
454    RunAsUnmapped {
455        /// The unmapped id.
456        id: u32,
457        /// Which id it is: `"uid"`, `"gid"`, or `"supplementary group"`.
458        space: &'static str,
459    },
460    /// A run-as identity names supplementary groups under the
461    /// single-identity map.
462    ///
463    /// Establishing that map requires denying `setgroups`, so no group list
464    /// can ever be set inside it. Supplementary groups need a range gid
465    /// map.
466    RunAsGroupsWithSingleMap,
467    /// The profile mounts no procfs the nested user namespace's identity map
468    /// can be established through.
469    ///
470    /// Every container enters a nested user namespace before it hardens, which
471    /// is what locks the flags of the mounts it inherited — without it the
472    /// command is root of the namespace those mounts belong to and may lift any
473    /// restriction they carry. Establishing that namespace's map reaches for a
474    /// procfs by path from inside the sandbox, so a sandbox with no procfs has
475    /// no way to establish one.
476    ///
477    /// Reachable only from a profile that opts out of the managed mounts with
478    /// [`managed_mounts(false)`](crate::CageBuilder::managed_mounts) (or
479    /// [`mount_proc(false)`](crate::CageBuilder::mount_proc)) and mounts no
480    /// procfs of its own, and then only where the map's route reads one. The
481    /// single-identity map's command writes its own map files, and a bind of
482    /// the host's procfs serves it — every instance resolves `self` for its
483    /// reader. A range [`identity_map`](crate::CageBuilder::identity_map) is
484    /// written by a delegate, and where the sandbox has a PID namespace that
485    /// delegate is inside it and needs a *fresh* procfs, since it names the
486    /// command by an in-namespace pid a bound procfs does not index. Where the
487    /// sandbox has no PID namespace the delegate is outside it and reads the
488    /// host's own `/proc`, so no procfs is required at all.
489    NestedUsernsNeedsProcfs,
490    /// A retained set-id capability alongside a non-root run-as identity.
491    ///
492    /// The securebits that preserve kept capabilities across the identity
493    /// switch also mean a command holding `CAP_SETUID`, `CAP_SETGID`, or
494    /// `CAP_SETPCAP` could return to the mapped uid 0, making the non-root
495    /// identity no boundary at all. The combination is rejected by name
496    /// rather than shipped as a claim the configuration does not keep.
497    #[non_exhaustive]
498    SetidCapWithNonRootIdentity {
499        /// The offending capability.
500        capability: &'static str,
501    },
502    /// An overlay-root path holds a character the overlay mount options cannot
503    /// carry.
504    ///
505    /// The lower, upper, and work directories are passed to the kernel as a
506    /// comma-separated, colon-delimited option string, so a `,` or `:` in any of
507    /// their resolved paths would be misparsed. Such a path is refused rather
508    /// than silently truncated.
509    #[non_exhaustive]
510    OverlayPathInvalid {
511        /// The offending path.
512        path: PathBuf,
513    },
514    /// An overlay root names no lower layer.
515    ///
516    /// The first lower is the base the overlay mounts over — the sandbox's root
517    /// filesystem — so an overlay carrying only an upper has nothing to merge;
518    /// [`Overlay::lower`](crate::Overlay::lower) is required.
519    OverlayLowerMissing,
520    /// An overlay root names no upper layer.
521    ///
522    /// An overlay with only lowers is a read-only merge; the sandbox needs
523    /// somewhere for its writes to land, so
524    /// [`Overlay::upper`](crate::Overlay::upper) is required.
525    OverlayUpperMissing,
526    /// An overlay layer directory could not be prepared.
527    ///
528    /// Every layer is canonicalized, since the mount options name the layers
529    /// absolutely; the upper and work directories are additionally created when
530    /// absent, while a stacked lower must already exist. This reports a host
531    /// failure at either step: a path that does not resolve, a permission
532    /// refusal, or a path that resolves to something other than a usable
533    /// directory.
534    #[non_exhaustive]
535    OverlayDirUnusable {
536        /// Which layer directory failed.
537        layer: OverlayLayer,
538        /// The directory that could not be prepared.
539        path: PathBuf,
540        /// The error the preparation failed with.
541        source: io::Error,
542    },
543    /// An overlay layer directory the library would have created was already
544    /// there, and is not a directory the calling user owns.
545    ///
546    /// The upper and work directories are created if absent, and the caller may
547    /// name them under a directory it shares with other local users. An entry
548    /// already at the path is adopted only when it is a directory belonging to
549    /// the calling user: a symbolic link — which would send every write the
550    /// sandbox makes to a destination of whoever planted it, and hand that
551    /// destination back to the caller as the layer its run produced — or a
552    /// directory owned by someone else is refused instead of used.
553    #[non_exhaustive]
554    OverlayDirUnowned {
555        /// Which layer directory was refused.
556        layer: OverlayLayer,
557        /// The path that was already occupied.
558        path: PathBuf,
559    },
560    /// An overlay's upper layer is the filesystem root.
561    ///
562    /// The upper needs a parent directory: the work directory the library
563    /// derives is a sibling of the upper, and the host overlay preflight runs in
564    /// that parent so its scratch entries land beside the upper rather than
565    /// inside it. `/` has no parent — and an upper there would put the sandbox's
566    /// writes over the whole host root.
567    #[non_exhaustive]
568    OverlayUpperIsRoot {
569        /// The offending path.
570        path: PathBuf,
571    },
572    /// An overlay-rooted cage was requested on a host that cannot establish an
573    /// unprivileged overlay mount.
574    ///
575    /// The overlay preflight names what is missing: the running kernel may
576    /// predate unprivileged overlay-in-a-user-namespace (Linux 5.11), or the
577    /// upper layer's filesystem may not support the `user.*` extended-attribute
578    /// namespace an unprivileged overlay records its metadata in — which tmpfs
579    /// gained only in Linux 6.6, so an on-disk upper (ext4, xfs, btrfs) is the
580    /// portable choice.
581    #[non_exhaustive]
582    OverlayUnavailable {
583        /// The host blocker the overlay preflight identified.
584        blocker: crate::host::OverlayBlocker,
585    },
586    /// A resource limit names a soft value above its hard value.
587    ///
588    /// The kernel refuses such a pair with `EINVAL`; it is caught here so the
589    /// mistake is a typed configuration error rather than a launch failure.
590    #[non_exhaustive]
591    RlimitInvalid {
592        /// The resource the limit governs.
593        resource: crate::limits::Resource,
594        /// The soft limit as it was given.
595        soft: crate::limits::Limit,
596        /// The hard limit as it was given.
597        hard: crate::limits::Limit,
598    },
599}
600
601impl fmt::Display for ConfigError {
602    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
603        match self {
604            ConfigError::RootfsMissing => f.write_str("no rootfs was provided"),
605            ConfigError::RootContradiction => f.write_str(
606                "a rootfs and an overlay root are both configured, but exactly one root is \
607                 in force; drop one of the two",
608            ),
609            ConfigError::RootfsUnusable { path, source } => {
610                write!(f, "cannot use {} as the rootfs: {}", path.display(), source,)
611            }
612            ConfigError::RootfsIsHostRoot => {
613                f.write_str("the host root directory cannot be the rootfs")
614            }
615            ConfigError::CommandMissing => f.write_str("no command was provided"),
616            // A bare name is the usual case, and `path_lookup` is exactly its
617            // remedy; a relative path with a slash is never searched, so
618            // pointing at path lookup there would be misdirection.
619            ConfigError::CommandNotAbsolute { command } if !command_has_slash(command) => write!(
620                f,
621                "the command path {} is not absolute; give an absolute path, or enable \
622                 path lookup to resolve a bare command name against the sandbox's PATH",
623                command.display(),
624            ),
625            ConfigError::CommandNotAbsolute { command } => {
626                write!(f, "the command path {} is not absolute", command.display())
627            }
628            ConfigError::ManagedMountsContradiction { toggle } => write!(
629                f,
630                "managed_mounts(false) suppresses every mount the library establishes, \
631                 but {toggle} explicitly asks for one; drop one of the two",
632            ),
633            ConfigError::SearchPathUnusable { path } => write!(
634                f,
635                "path lookup has nothing to search: PATH {path:?} holds no absolute directory",
636            ),
637            ConfigError::BindSourceUnusable { path, source } => write!(
638                f,
639                "cannot use {} as a bind-mount source: {}",
640                path.display(),
641                source,
642            ),
643            ConfigError::MountTargetInvalid { path } => write!(
644                f,
645                "the mount target {} is not a normal absolute path inside the rootfs",
646                path.display(),
647            ),
648            ConfigError::MountFlagsInvalid { flags } => write!(
649                f,
650                "the raw mount flags {flags:#x} do not fit the kernel's mount-flags word",
651            ),
652            ConfigError::WorkdirNotAbsolute { path } => write!(
653                f,
654                "the working directory {} is not absolute",
655                path.display(),
656            ),
657            ConfigError::HostnameInvalid => {
658                f.write_str("the hostname is empty or longer than 64 bytes")
659            }
660            ConfigError::EnvNameInvalid { name } => write!(
661                f,
662                "the environment variable name {:?} is empty or contains '='",
663                name,
664            ),
665            ConfigError::EmbeddedNul => {
666                f.write_str("a configured path, name, or value contains an interior NUL byte")
667            }
668            ConfigError::SeccompInvalid { reason } => {
669                write!(f, "the seccomp policy could not be compiled: {reason}")
670            }
671            ConfigError::SeccompUnsupportedArch { arch } => write!(
672                f,
673                "seccomp filtering is not available for the {arch} architecture",
674            ),
675            ConfigError::SeccompProgramTooLong { len, max } => write!(
676                f,
677                "the seccomp program is {len} instructions, over the kernel maximum of {max}",
678            ),
679            ConfigError::LandlockGrantEmpty { grant } => write!(
680                f,
681                "the Landlock grant on {grant} names no access; enrolling a grant denies \
682                 everything ungranted, so an empty one denies the whole class",
683            ),
684            ConfigError::LandlockPathInvalid { path } => write!(
685                f,
686                "the Landlock grant path {} is not a normal absolute path",
687                path.display(),
688            ),
689            ConfigError::RestrictionLandlockUnenforceable { abi } => match abi {
690                Some(abi) => write!(
691                    f,
692                    "the restriction's Landlock grants fall outside the kernel's supported \
693                     Landlock ABI ({abi}); a network grant needs ABI 4"
694                ),
695                None => f.write_str(
696                    "the restriction requests Landlock confinement, but the kernel offers no \
697                     Landlock LSM",
698                ),
699            },
700            ConfigError::HostNetworkLandlockUnenforceable { abi } => match abi {
701                Some(abi) => write!(
702                    f,
703                    "a host-network cage's Landlock network grant falls outside the kernel's \
704                     supported Landlock ABI ({abi}); a network grant needs ABI 4, and host \
705                     networking has no network namespace to fall back on"
706                ),
707                None => f.write_str(
708                    "a host-network cage requests a Landlock network grant, but the kernel offers \
709                     no Landlock LSM to enforce it, and host networking has no network namespace \
710                     to fall back on",
711                ),
712            },
713            ConfigError::ProfileOperationForbidden { operation } => write!(
714                f,
715                "this profile uses {operation}, which the restricted policy reserves for a \
716                 trusted profile"
717            ),
718            ConfigError::StreamAttachmentConflict { stream, attachment } => write!(
719                f,
720                "this launch attaches {attachment} to the command's {stream}, which the sandbox \
721                 directs elsewhere"
722            ),
723            ConfigError::RestrictionEmpty => {
724                f.write_str("the restriction names no Landlock grant or seccomp policy")
725            }
726            ConfigError::IdentityMapInvalid { reason } => {
727                write!(f, "the identity map is invalid: {reason}")
728            }
729            ConfigError::IdentityMapUnavailable { reason } => {
730                write!(
731                    f,
732                    "no delegate can establish the requested identity map: {reason}"
733                )
734            }
735            ConfigError::RunAsUnmapped { id, space } => {
736                write!(
737                    f,
738                    "the run-as {space} {id} is not contained in the identity map"
739                )
740            }
741            ConfigError::RunAsGroupsWithSingleMap => f.write_str(
742                "run-as supplementary groups require a range gid map; the single-identity \
743                 map denies setgroups",
744            ),
745            ConfigError::NestedUsernsNeedsProcfs => f.write_str(
746                "the sandbox mounts no procfs the nested user namespace that locks its mount \
747                 flags can establish its identity map through; mount one, or leave the \
748                 managed profile's own in place. A range identity map inside a PID namespace \
749                 of the sandbox's own needs a fresh procfs rather than a bind of the host's, \
750                 which the sandbox's pids do not index; without a PID namespace it needs none, \
751                 because its map is written from outside",
752            ),
753            ConfigError::SetidCapWithNonRootIdentity { capability } => write!(
754                f,
755                "keeping {capability} alongside a non-root run-as identity would let the \
756                 command return to uid 0; drop the capability or run as root inside",
757            ),
758            ConfigError::OverlayPathInvalid { path } => write!(
759                f,
760                "the overlay-root path {} contains a ',' or ':', which the overlay mount \
761                 options cannot carry",
762                path.display(),
763            ),
764            ConfigError::OverlayLowerMissing => {
765                f.write_str("the overlay root names no lower layer to mount over")
766            }
767            ConfigError::OverlayUpperMissing => {
768                f.write_str("the overlay root names no upper layer for its writes")
769            }
770            ConfigError::OverlayDirUnusable {
771                layer,
772                path,
773                source,
774            } => write!(
775                f,
776                "cannot use {} as the overlay {layer} directory: {}",
777                path.display(),
778                source,
779            ),
780            ConfigError::OverlayDirUnowned { layer, path } => write!(
781                f,
782                "cannot use {} as the overlay {layer} directory: something is already there that \
783                 is not a directory the calling user owns; the layer directories are created \
784                 fresh, and adopting an entry another user could have planted would send the \
785                 sandbox's writes wherever it pointed",
786                path.display(),
787            ),
788            ConfigError::OverlayUpperIsRoot { path } => write!(
789                f,
790                "cannot use {} as the overlay upper directory: the upper needs a parent \
791                 directory, for the work directory beside it and for the host preflight",
792                path.display(),
793            ),
794            ConfigError::OverlayUnavailable { blocker } => {
795                write!(f, "an overlay-rooted cage cannot be established: {blocker}")
796            }
797            ConfigError::RlimitInvalid {
798                resource,
799                soft,
800                hard,
801            } => write!(
802                f,
803                "the {resource} soft limit {soft} is above its hard limit {hard}",
804            ),
805        }
806    }
807}
808
809impl std::error::Error for ConfigError {
810    /// The OS failure underneath, for the three refusals that have one.
811    ///
812    /// Every other variant is a request the builder refused on its own terms —
813    /// a contradiction, a missing field, a path that is not the shape it has to
814    /// be — where nothing failed and there is nothing to return.
815    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
816        match self {
817            ConfigError::RootfsUnusable { source, .. }
818            | ConfigError::BindSourceUnusable { source, .. }
819            | ConfigError::OverlayDirUnusable { source, .. } => Some(source),
820            _ => None,
821        }
822    }
823}
824
825/// An error from the library itself.
826///
827/// [`Cage::run`] returns `Err` only when the library fails: invalid
828/// configuration, an unsupported host, a failed setup step, or a failure to
829/// spawn or wait. The sandboxed command's own exit code is data, carried by
830/// [`ExitStatus`], and is never an `Error`.
831///
832/// [`Cage::run`]: crate::Cage::run
833/// [`ExitStatus`]: crate::ExitStatus
834#[derive(Debug)]
835#[non_exhaustive]
836pub enum Error {
837    /// The sandbox configuration was rejected at build time.
838    Config(ConfigError),
839    /// The host cannot create unprivileged user namespaces.
840    ///
841    /// Produced when namespace creation is denied by the kernel. When the
842    /// probe in [`crate::host`] identifies the mechanism responsible, it is
843    /// carried in `blocker`.
844    #[non_exhaustive]
845    UsernsUnavailable {
846        /// The host configuration identified as blocking user namespaces,
847        /// when one could be determined.
848        blocker: Option<UsernsBlocker>,
849        /// The error the kernel returned for namespace creation.
850        source: io::Error,
851    },
852    /// The host's user-namespace budget is exhausted, so the command could not
853    /// enter the nested user namespace that locks the sandbox's mount flags.
854    ///
855    /// A user namespace is charged against `user.max_user_namespaces` at every
856    /// level up to the initial namespace, and a launch holds two: the sandbox's
857    /// own, and the nested one the command enters. A host whose ceiling admits
858    /// the first and not the second reports this rather than a bare `ENOSPC`.
859    ///
860    /// Distinct from [`UsernsUnavailable`](Self::UsernsUnavailable), which is a
861    /// host that permits no user namespace at all: here the sandbox was built
862    /// and only the second namespace was refused.
863    NestedUsernsBudgetExhausted,
864    /// A sandbox setup step failed in the child process.
865    #[non_exhaustive]
866    Setup {
867        /// The step that failed.
868        step: SetupStep,
869        /// The error the step failed with.
870        source: io::Error,
871        /// What the step was operating on, when the step has a subject — for a
872        /// mount step, the mount it was assembling; for
873        /// [`Exec`](SetupStep::Exec), the command path, and for a command
874        /// resolved by path lookup, the candidates the search tried.
875        ///
876        /// An `ENOENT` from the exec step means either that the command itself
877        /// is absent or that its ELF interpreter is: a dynamically linked
878        /// binary whose loader is missing from the rootfs reports the same
879        /// errno as a missing binary. The detail names the command, not the
880        /// interpreter, so a path that plainly exists inside the rootfs points
881        /// at the second reading.
882        detail: Option<String>,
883    },
884    /// The sandbox process could not be created.
885    #[non_exhaustive]
886    Spawn {
887        /// The error from pipe creation or fork.
888        source: io::Error,
889    },
890    /// The sandbox process outcome could not be collected.
891    #[non_exhaustive]
892    Wait {
893        /// The error from collecting the outcome: reading the setup-error,
894        /// status, or capture pipes, waiting on them, or the wait itself.
895        source: io::Error,
896    },
897    /// The sandbox supervisor exited without reporting the command's
898    /// outcome.
899    ///
900    /// The supervisor always reports the command's wait status before it
901    /// exits; its silent disappearance means it was killed from outside or
902    /// exited abnormally, and the command's outcome is unknown. A kill
903    /// requested through the handle is not this error: after
904    /// [`Running::kill`], the outcome is reported as termination by
905    /// `SIGKILL`.
906    ///
907    /// [`Running::kill`]: crate::Running::kill
908    SupervisorLost,
909    /// The sandbox could not be signaled through the handle.
910    #[non_exhaustive]
911    Signal {
912        /// The error from the signaling machinery.
913        source: io::Error,
914    },
915    /// The identity-map delegate failed to establish the range map.
916    ///
917    /// The delegate runs caller-side against the gated launch, so its own
918    /// error is the diagnostic; the gated sandbox is torn down.
919    IdentityMap(crate::IdMapError),
920    /// A pseudoterminal operation failed: allocating one for the sandbox, or
921    /// reading or setting its window size.
922    #[non_exhaustive]
923    Terminal {
924        /// The error the operation failed with.
925        source: io::Error,
926    },
927    /// The host has no free pseudoterminal to allocate.
928    ///
929    /// Pseudoterminals are a bounded resource — `/proc/sys/kernel/pty/max`
930    /// states the ceiling — so a host running many sandboxes at once can
931    /// exhaust them. Distinct from [`Terminal`](Self::Terminal) because it is
932    /// the one allocation failure that says nothing is wrong with the request:
933    /// the same launch succeeds once something releases a terminal.
934    TerminalsExhausted,
935}
936
937impl Error {
938    /// The exit code a launcher reports when a launch fails, following the
939    /// conventions `sh` and `timeout(1)` established.
940    ///
941    /// A caller whose whole purpose is to run one command inside a sandbox is a
942    /// launcher, and a launcher's own failures have to be distinguishable from
943    /// the command's exit codes. The conventions are:
944    ///
945    /// | Code | Meaning |
946    /// | --- | --- |
947    /// | 127 | The command does not exist. |
948    /// | 126 | The command exists but could not be executed. |
949    /// | 125 | The launcher itself failed, for any other reason. |
950    ///
951    /// The distinction between 127 and 126 comes from the errno the
952    /// [`Exec`](SetupStep::Exec) step failed with. An `ENOENT` there has two
953    /// readings — the command is absent, or its ELF interpreter is — and both
954    /// are reported as 127, since neither produced a runnable process.
955    ///
956    /// This is what the `fcage` binary returns. A caller with its own
957    /// convention is free to map [`Error`] itself; this is the answer for one
958    /// that has none, and the one that makes a consumer behave like a shell.
959    ///
960    /// 124, `timeout(1)`'s "the deadline expired", is not produced here: an
961    /// expired deadline is not an [`Error`], it is a
962    /// [`Running::wait_timeout`](crate::Running::wait_timeout) that returned no
963    /// status, so only the caller knows it happened.
964    ///
965    /// # Example
966    ///
967    /// ```
968    /// use std::process::ExitCode;
969    ///
970    /// use ferroday_cage::Cage;
971    ///
972    /// fn launch(rootfs: &str) -> ExitCode {
973    ///     match Cage::builder().command("/bin/true").rootfs(rootfs).build() {
974    ///         Ok(_cage) => ExitCode::SUCCESS,
975    ///         Err(error) => {
976    ///             eprintln!("myapp: {error}");
977    ///             ExitCode::from(error.shell_code())
978    ///         }
979    ///     }
980    /// }
981    /// # let _ = launch;
982    /// ```
983    pub fn shell_code(&self) -> u8 {
984        match self {
985            Error::Setup {
986                step: SetupStep::Exec,
987                source,
988                ..
989            } => match source.kind() {
990                std::io::ErrorKind::NotFound => 127,
991                _ => 126,
992            },
993            _ => 125,
994        }
995    }
996}
997
998impl fmt::Display for Error {
999    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1000        match self {
1001            Error::Config(err) => write!(f, "invalid sandbox configuration: {err}"),
1002            Error::UsernsUnavailable {
1003                blocker: Some(blocker),
1004                ..
1005            } => write!(f, "unprivileged user namespaces are unavailable: {blocker}"),
1006            Error::UsernsUnavailable {
1007                blocker: None,
1008                source,
1009            } => write!(
1010                f,
1011                "the kernel denied user namespace creation ({}); a seccomp filter or LSM \
1012                 policy may be blocking unprivileged user namespaces",
1013                source,
1014            ),
1015            Error::NestedUsernsBudgetExhausted => f.write_str(
1016                "the sandbox could not enter its nested user namespace: the host's user \
1017                 namespace budget is exhausted. Every launch holds two user namespaces; \
1018                 raise user.max_user_namespaces, or reduce the number of namespaces already \
1019                 live",
1020            ),
1021            Error::Setup {
1022                step,
1023                source,
1024                detail: Some(detail),
1025            } => write!(
1026                f,
1027                "sandbox setup failed while {step} ({detail}): {}",
1028                source,
1029            ),
1030            Error::Setup {
1031                step,
1032                source,
1033                detail: None,
1034            } => write!(f, "sandbox setup failed while {step}: {}", source,),
1035            Error::Spawn { source } => {
1036                write!(f, "could not spawn the sandbox process: {}", source,)
1037            }
1038            Error::Wait { source } => write!(
1039                f,
1040                "could not collect the sandbox process outcome: {}",
1041                source,
1042            ),
1043            Error::SupervisorLost => {
1044                f.write_str("the sandbox supervisor exited without reporting the command's outcome")
1045            }
1046            Error::Signal { source } => write!(f, "could not signal the sandbox: {}", source,),
1047            Error::IdentityMap(err) => {
1048                write!(f, "could not establish the identity map: {err}")
1049            }
1050            Error::Terminal { source } => {
1051                write!(f, "a pseudoterminal operation failed: {}", source,)
1052            }
1053            Error::TerminalsExhausted => f.write_str(
1054                "the host has no free pseudoterminal to allocate; \
1055                 /proc/sys/kernel/pty/max states the ceiling",
1056            ),
1057        }
1058    }
1059}
1060
1061impl std::error::Error for Error {
1062    /// The failure underneath, where there is one.
1063    ///
1064    /// Every OS failure this type carries is an [`io::Error`], so every one of
1065    /// them is returned here. The variants that answer `None` carry nothing:
1066    /// [`NestedUsernsBudgetExhausted`](Self::NestedUsernsBudgetExhausted),
1067    /// [`SupervisorLost`](Self::SupervisorLost), and
1068    /// [`TerminalsExhausted`](Self::TerminalsExhausted) are conditions the
1069    /// library recognized rather than syscalls that failed.
1070    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1071        match self {
1072            Error::Config(err) => Some(err),
1073            Error::IdentityMap(err) => Some(err),
1074            Error::UsernsUnavailable { source, .. }
1075            | Error::Setup { source, .. }
1076            | Error::Spawn { source }
1077            | Error::Wait { source }
1078            | Error::Signal { source }
1079            | Error::Terminal { source } => Some(source),
1080            _ => None,
1081        }
1082    }
1083}
1084
1085impl From<ConfigError> for Error {
1086    fn from(err: ConfigError) -> Self {
1087        Error::Config(err)
1088    }
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093    use super::*;
1094
1095    /// Every step decodes back to itself, and every step names itself in prose.
1096    ///
1097    /// The round trip is structural now — the decoder's match arms are the same
1098    /// literals the variants are declared with, so a value used twice is a
1099    /// duplicate discriminant and a duplicate pattern, both of which the
1100    /// compiler refuses. What this still checks is that the list of steps and
1101    /// the encoding agree about which values exist, and that no step was left
1102    /// without a description.
1103    #[test]
1104    fn setup_step_wire_round_trip() {
1105        for &step in SetupStep::ALL {
1106            assert_eq!(SetupStep::from_wire(step.wire()), Some(step));
1107            assert!(!step.describe().is_empty(), "{step:?} describes itself");
1108        }
1109    }
1110
1111    #[test]
1112    fn setup_step_unknown_wire_value_is_rejected() {
1113        assert_eq!(SetupStep::from_wire(0), None);
1114        assert_eq!(SetupStep::from_wire(SetupStep::past_the_last()), None);
1115        assert_eq!(SetupStep::from_wire(u32::MAX), None);
1116    }
1117
1118    #[test]
1119    fn error_display_names_the_failed_step() {
1120        let err = Error::Setup {
1121            step: SetupStep::PivotRoot,
1122            source: io::Error::from_raw_os_error(22),
1123            detail: None,
1124        };
1125        let message = err.to_string();
1126        assert!(message.contains("pivoting into the rootfs"), "{message}");
1127    }
1128
1129    #[test]
1130    fn a_bare_command_name_is_told_about_path_lookup() {
1131        let err = ConfigError::CommandNotAbsolute {
1132            command: PathBuf::from("sh"),
1133        };
1134        let message = err.to_string();
1135        assert!(
1136            message.contains("the command path sh is not absolute"),
1137            "{message}"
1138        );
1139        assert!(message.contains("path lookup"), "{message}");
1140    }
1141
1142    #[test]
1143    fn a_relative_command_path_is_not_told_about_path_lookup() {
1144        // Path lookup never searches a path containing a slash, so naming it
1145        // here would send the caller down a road that does not lead anywhere.
1146        let err = ConfigError::CommandNotAbsolute {
1147            command: PathBuf::from("./bin/sh"),
1148        };
1149        let message = err.to_string();
1150        assert!(
1151            message.contains("the command path ./bin/sh is not absolute"),
1152            "{message}"
1153        );
1154        assert!(!message.contains("path lookup"), "{message}");
1155    }
1156
1157    #[test]
1158    fn error_display_includes_the_step_detail() {
1159        let err = Error::Setup {
1160            step: SetupStep::BindMount,
1161            source: io::Error::from_raw_os_error(13),
1162            detail: Some("/host/data at /data, read-only".to_string()),
1163        };
1164        let message = err.to_string();
1165        assert!(
1166            message.contains("bind-mounting (/host/data at /data, read-only)"),
1167            "{message}"
1168        );
1169    }
1170}