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}