mermaid_runtime/sandbox.rs
1//! Optional OS sandboxing for model-driven shell commands.
2//!
3//! Two independent confinement dimensions, one platform-neutral facade:
4//!
5//! - A **network kill-switch** (`--no-network` / `safety.network = "deny"`).
6//! Linux: a seccomp-BPF filter — creating an internet socket (`AF_INET` /
7//! `AF_INET6`) dies with `SIGSYS`, while `AF_UNIX` and other local socket
8//! domains keep working so nscd / D-Bus / X11 are unaffected. `SIGSYS` is a
9//! distinctive, catchable signal, which the exec tool maps to a clear
10//! "blocked by the network sandbox" outcome. macOS: a Seatbelt
11//! `(deny network*)` sparing `AF_UNIX`, denied at use time with `EPERM`
12//! (no signal — detection is a hedged text signature).
13//! - A **filesystem write-confinement** (`--confine-fs` /
14//! `safety.filesystem = "project"`). Write-class access (create / write /
15//! truncate / remove / rename) is allowed only beneath an explicit set of
16//! directories; everything else fails with a permission error. Reads and
17//! execution stay unrestricted. Linux: Landlock, best-effort by design — a
18//! kernel without it (pre-5.13) degrades to a warned no-op rather than
19//! refusing to run. macOS: Seatbelt `deny file-write*` outside the roots.
20//!
21//! Everything funnels through [`enforce`], called from the
22//! `mermaid __sandbox-exec` launcher — ordinary, single-threaded code — just
23//! before it runs the real command. On Linux the restrictions are applied to
24//! the launcher itself ([`Enforcement::SelfApplied`]) and survive `execve` and
25//! `fork`; on macOS the argv is rewritten to run under `/usr/bin/sandbox-exec`
26//! ([`Enforcement::ExecArgv`]), whose profile is likewise inherited by
27//! everything the command spawns. Platforms without a backend return `Err`
28//! when confinement was requested, so the launcher fails closed (exit 126)
29//! instead of ever running the command unconfined.
30
31use std::ffi::OsString;
32use std::path::PathBuf;
33
34/// What the caller asked the OS to confine. Both dimensions are independent;
35/// an all-off policy enforces nothing (and [`enforce`] is a no-op for it on
36/// every platform).
37#[derive(Debug, Clone, Default)]
38pub struct SandboxPolicy {
39 /// Deny network access (internet sockets; local `AF_UNIX` is spared).
40 pub deny_network: bool,
41 /// When non-empty, confine write-class filesystem access to (beneath)
42 /// these directories. Roots that don't exist are skipped, narrowing the
43 /// sandbox rather than erroring.
44 pub allowed_writes: Vec<PathBuf>,
45}
46
47/// How the platform enforced a [`SandboxPolicy`] — the contract between
48/// [`enforce`] and the `__sandbox-exec` launcher.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum Enforcement {
51 /// The restrictions were installed on the *current* process (Linux
52 /// seccomp + Landlock); the caller must now exec its own argv.
53 /// `fs_enforced` is `false` when write-confinement was requested but the
54 /// kernel cannot enforce it (documented best-effort — the caller warns
55 /// and continues).
56 SelfApplied { fs_enforced: bool },
57 /// The caller must exec this rewritten argv instead (macOS: the command
58 /// wrapped in `/usr/bin/sandbox-exec`).
59 ExecArgv(Vec<OsString>),
60 /// The platform ran the child itself and this is its exit code
61 /// (Windows AppContainer follow-up; no constructor yet).
62 Ran(i32),
63}
64
65/// Enforce `policy` for the command `argv`. Called from single-threaded
66/// launcher code. Any `Err` means the requested confinement could not be
67/// applied — the caller MUST fail closed (exit 126), never run unconfined.
68pub fn enforce(policy: &SandboxPolicy, argv: &[OsString]) -> anyhow::Result<Enforcement> {
69 if !policy.deny_network && policy.allowed_writes.is_empty() {
70 // Nothing requested: nothing to enforce, on any platform.
71 return Ok(Enforcement::SelfApplied { fs_enforced: true });
72 }
73 #[cfg(target_os = "linux")]
74 {
75 use anyhow::Context as _;
76 // Self-applied: the caller execs its own argv afterwards.
77 let _ = argv;
78 // Landlock first (its setup opens the allowed dirs), then seccomp.
79 let mut fs_enforced = true;
80 if !policy.allowed_writes.is_empty() {
81 fs_enforced = linux::apply_fs_confinement(&policy.allowed_writes)
82 .context("filesystem sandbox unavailable")?;
83 }
84 if policy.deny_network {
85 linux::apply_network_killswitch().context("network sandbox unavailable")?;
86 }
87 Ok(Enforcement::SelfApplied { fs_enforced })
88 }
89 #[cfg(target_os = "macos")]
90 {
91 anyhow::ensure!(
92 macos::sandbox_exec_present(),
93 "{} not found; refusing to run the command unconfined",
94 macos::SANDBOX_EXEC
95 );
96 Ok(Enforcement::ExecArgv(macos::wrap_argv(policy, argv)))
97 }
98 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
99 {
100 let _ = argv;
101 anyhow::bail!("no OS sandbox backend on this platform; refusing to run unconfined")
102 }
103}
104
105/// Whether the network kill-switch is really available on this platform:
106/// Linux when the seccomp filter assembles (supported arch), macOS when
107/// `/usr/bin/sandbox-exec` exists, `false` everywhere else (Windows
108/// AppContainer is a follow-up). Used by `mermaid self-test` and the exec
109/// tool as a safe, fork-free probe — it installs nothing.
110pub fn network_killswitch_available() -> bool {
111 #[cfg(target_os = "linux")]
112 {
113 linux::network_filter().is_ok()
114 }
115 #[cfg(target_os = "macos")]
116 {
117 macos::sandbox_exec_present()
118 }
119 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
120 {
121 false
122 }
123}
124
125/// Whether filesystem write-confinement is really available on this platform:
126/// Linux when the Landlock ruleset assembles, macOS when
127/// `/usr/bin/sandbox-exec` exists, `false` everywhere else. Like
128/// [`network_killswitch_available`]: a safe probe that restricts nothing.
129/// (On Linux enforcement remains best-effort at apply time — a pre-Landlock
130/// kernel builds the ruleset but cannot enforce it.)
131pub fn fs_confinement_available() -> bool {
132 #[cfg(target_os = "linux")]
133 {
134 linux::fs_ruleset_builds()
135 }
136 #[cfg(target_os = "macos")]
137 {
138 macos::sandbox_exec_present()
139 }
140 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
141 {
142 false
143 }
144}
145
146/// macOS Seatbelt backend: generate an allow-default SBPL profile and rewrite
147/// the command argv to run under `/usr/bin/sandbox-exec`.
148///
149/// Profile generation and argv rewriting are PURE functions, compiled (and
150/// unit-tested) on every platform — only [`enforce`] reaches them at runtime,
151/// and only on macOS. Security invariant: no caller-controlled path is ever
152/// spliced into the profile string; paths travel exclusively through `-D`
153/// parameters that the profile references as `(param "WRn")`, so a hostile
154/// directory name cannot inject SBPL.
155#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
156mod macos {
157 use std::ffi::OsString;
158 use std::path::{Path, PathBuf};
159
160 use super::SandboxPolicy;
161
162 /// Fixed, absolute path — deliberately not resolved via `PATH`.
163 pub(super) const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";
164
165 pub(super) fn sandbox_exec_present() -> bool {
166 Path::new(SANDBOX_EXEC).exists()
167 }
168
169 /// Build the SBPL profile for `policy`, plus the `-D` parameters it
170 /// references (name → path). Allow-default so toolchains keep working;
171 /// only the requested dimensions are denied.
172 ///
173 /// Each existing allowed root yields a literal param (`WRn`) and, when it
174 /// differs, a canonicalized one (`WRnC`): on macOS `TMPDIR` lives under
175 /// the `/var` firmlink while Seatbelt sees `/private/var`, so matching on
176 /// only one form would deny legitimate temp writes (or under-match).
177 /// Nonexistent roots are skipped — parity with Landlock's
178 /// `path_beneath_rules`, which silently narrows the sandbox for paths it
179 /// cannot open.
180 pub(super) fn profile(policy: &SandboxPolicy) -> (String, Vec<(String, PathBuf)>) {
181 let mut sbpl = String::from("(version 1)\n(allow default)\n");
182 if policy.deny_network {
183 // Deny all network, then re-allow AF_UNIX sockets (later SBPL
184 // rules win) — parity with the Linux seccomp filter, which only
185 // kills AF_INET/AF_INET6 so D-Bus/nscd-style local IPC survives.
186 // If macOS ever rejects this filter grammar, the CI
187 // profile-compile canary fails loudly; the fallback is deleting
188 // the two `allow` lines (stricter than Linux, documented delta).
189 sbpl.push_str("(deny network*)\n");
190 sbpl.push_str("(allow network* (remote unix))\n");
191 sbpl.push_str("(allow network* (local unix))\n");
192 }
193 let mut params: Vec<(String, PathBuf)> = Vec::new();
194 if !policy.allowed_writes.is_empty() {
195 for (n, root) in policy.allowed_writes.iter().enumerate() {
196 if !root.exists() {
197 continue;
198 }
199 params.push((format!("WR{n}"), root.clone()));
200 if let Ok(canonical) = std::fs::canonicalize(root)
201 && canonical != *root
202 {
203 params.push((format!("WR{n}C"), canonical));
204 }
205 }
206 if params.is_empty() {
207 // Confinement was requested but every allowed root is
208 // missing: deny all writes (Landlock parity — missing dirs
209 // narrow the sandbox) rather than emit an empty require-all.
210 sbpl.push_str("(deny file-write*)\n");
211 } else {
212 // FOOTGUN: the require-nots MUST be wrapped in `require-all`.
213 // A bare filter list ORs its members, and "not under root A
214 // OR not under root B" is true for every path once there are
215 // two roots — denying ALL writes. `require-all` makes it
216 // "outside every allowed root", which is the intent.
217 sbpl.push_str("(deny file-write* (require-all");
218 for (name, _) in ¶ms {
219 // Param NAMES are generated here (WRn/WRnC, never from
220 // input); path VALUES ride the -D argv, not this string.
221 sbpl.push_str(&format!(" (require-not (subpath (param \"{name}\")))"));
222 }
223 sbpl.push_str("))\n");
224 }
225 }
226 (sbpl, params)
227 }
228
229 /// Rewrite `argv` to run under `sandbox-exec` with the policy's profile:
230 /// `/usr/bin/sandbox-exec -p <profile> [-D WRn=<path>]... -- <argv...>`.
231 pub(super) fn wrap_argv(policy: &SandboxPolicy, argv: &[OsString]) -> Vec<OsString> {
232 let (sbpl, params) = profile(policy);
233 let mut wrapped: Vec<OsString> = vec![SANDBOX_EXEC.into(), "-p".into(), sbpl.into()];
234 for (name, value) in params {
235 wrapped.push("-D".into());
236 let mut kv = OsString::from(format!("{name}="));
237 kv.push(value.as_os_str());
238 wrapped.push(kv);
239 }
240 // `--` ends option parsing so a command starting with `-` cannot be
241 // taken for a sandbox-exec flag.
242 wrapped.push("--".into());
243 wrapped.extend(argv.iter().cloned());
244 wrapped
245 }
246
247 #[cfg(test)]
248 mod tests {
249 use super::*;
250
251 fn policy(deny_network: bool, allowed_writes: &[PathBuf]) -> SandboxPolicy {
252 SandboxPolicy {
253 deny_network,
254 allowed_writes: allowed_writes.to_vec(),
255 }
256 }
257
258 /// A fresh existing directory under the temp dir.
259 fn tempdir(tag: &str) -> PathBuf {
260 let dir = std::env::temp_dir().join(format!(
261 "mermaid-sbpl-test-{tag}-{}-{}",
262 std::process::id(),
263 std::time::SystemTime::now()
264 .duration_since(std::time::UNIX_EPOCH)
265 .unwrap()
266 .as_nanos()
267 ));
268 std::fs::create_dir_all(&dir).unwrap();
269 dir
270 }
271
272 #[test]
273 fn network_denial_lines_present_iff_requested() {
274 let (with_net, _) = profile(&policy(true, &[]));
275 assert!(with_net.contains("(deny network*)"));
276 // AF_UNIX-sparing allows ship with the deny (Linux parity).
277 assert!(with_net.contains("(allow network* (remote unix))"));
278 assert!(with_net.contains("(allow network* (local unix))"));
279 // deny_network-only: no write confinement may leak into the profile.
280 assert!(!with_net.contains("file-write"));
281
282 let dir = tempdir("no-net");
283 let (without_net, _) = profile(&policy(false, std::slice::from_ref(&dir)));
284 assert!(!without_net.contains("network"));
285 let _ = std::fs::remove_dir_all(&dir);
286 }
287
288 #[test]
289 fn write_denial_nests_require_nots_under_require_all() {
290 let a = tempdir("ra-a");
291 let b = tempdir("ra-b");
292 let (sbpl, params) = profile(&policy(false, &[a.clone(), b.clone()]));
293 // Both roots exist and are already canonical-ish; at least the
294 // two literal params must be present and referenced.
295 assert!(params.iter().any(|(n, _)| n == "WR0"));
296 assert!(params.iter().any(|(n, _)| n == "WR1"));
297 // The require-nots are wrapped in ONE require-all (a bare list
298 // would OR and deny everything — see the generator comment).
299 let deny = sbpl
300 .lines()
301 .find(|l| l.starts_with("(deny file-write*"))
302 .expect("deny file-write* line");
303 assert!(deny.starts_with("(deny file-write* (require-all (require-not"));
304 assert_eq!(
305 deny.matches("(require-not (subpath (param ").count(),
306 params.len(),
307 "one require-not per emitted param: {deny}"
308 );
309 let _ = std::fs::remove_dir_all(&a);
310 let _ = std::fs::remove_dir_all(&b);
311 }
312
313 #[test]
314 fn dual_literal_and_canonical_params_when_they_differ() {
315 let real = tempdir("canon");
316 let sub = real.join("sub");
317 std::fs::create_dir_all(&sub).unwrap();
318 // `<real>/sub/..` exists but canonicalizes to `<real>` — a
319 // platform-neutral stand-in for the macOS TMPDIR firmlink
320 // (/var/... vs /private/var/...).
321 let alias = sub.join("..");
322 let (sbpl, params) = profile(&policy(false, std::slice::from_ref(&alias)));
323 let literal = params.iter().find(|(n, _)| n == "WR0").expect("literal");
324 let canonical = params.iter().find(|(n, _)| n == "WR0C").expect("canonical");
325 assert_eq!(literal.1, alias);
326 assert_ne!(canonical.1, alias);
327 assert!(sbpl.contains("(param \"WR0\")"));
328 assert!(sbpl.contains("(param \"WR0C\")"));
329 let _ = std::fs::remove_dir_all(&real);
330 }
331
332 #[test]
333 fn nonexistent_roots_are_skipped_and_all_missing_denies_all_writes() {
334 let missing = std::env::temp_dir().join("mermaid-sbpl-test-definitely-missing");
335 let (sbpl, params) = profile(&policy(false, std::slice::from_ref(&missing)));
336 assert!(params.is_empty());
337 // No surviving root: plain deny (narrowed sandbox, Landlock
338 // parity), not an empty require-all of unknown SBPL validity.
339 assert!(sbpl.contains("(deny file-write*)\n"));
340 assert!(!sbpl.contains("require-all"));
341 }
342
343 #[test]
344 fn paths_never_reach_the_profile_string() {
345 // A directory name full of SBPL metacharacters must not appear in
346 // the profile text — it rides only in the -D parameter values.
347 let evil = tempdir("evil").join("x) (allow default) (deny");
348 std::fs::create_dir_all(&evil).unwrap();
349 let (sbpl, params) = profile(&policy(true, std::slice::from_ref(&evil)));
350 assert!(!sbpl.contains("allow default) (deny"));
351 assert!(
352 params.iter().any(|(_, v)| *v == evil),
353 "the path must ride a param instead"
354 );
355 let _ = std::fs::remove_dir_all(evil.parent().unwrap());
356 }
357
358 #[test]
359 fn wrap_argv_shape_is_frozen() {
360 let dir = tempdir("argv");
361 let argv: Vec<OsString> = vec!["sh".into(), "-c".into(), "echo hi".into()];
362 let wrapped = wrap_argv(&policy(true, std::slice::from_ref(&dir)), &argv);
363 assert_eq!(wrapped[0], OsString::from(SANDBOX_EXEC));
364 assert_eq!(wrapped[1], OsString::from("-p"));
365 let profile_arg = wrapped[2].to_string_lossy();
366 assert!(profile_arg.starts_with("(version 1)\n(allow default)\n"));
367 assert_eq!(wrapped[3], OsString::from("-D"));
368 let kv = wrapped[4].to_string_lossy();
369 assert!(kv.starts_with("WR0="), "param assignment: {kv}");
370 // `--` separates sandbox-exec options from the wrapped command.
371 let sep = wrapped
372 .iter()
373 .position(|a| a == "--")
374 .expect("-- separator");
375 assert_eq!(&wrapped[sep + 1..], argv.as_slice());
376 let _ = std::fs::remove_dir_all(&dir);
377 }
378 }
379}
380
381#[cfg(target_os = "linux")]
382mod linux {
383 use std::collections::BTreeMap;
384
385 use anyhow::Context;
386 use seccompiler::{
387 BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter,
388 SeccompRule, apply_filter,
389 };
390
391 /// Build the "deny internet sockets" BPF program: kill the process on
392 /// `socket(AF_INET|AF_INET6, …)`, allow everything else (including
393 /// `AF_UNIX`). Comparing the low 32 bits (`Dword`) of the domain argument is
394 /// deliberate — it catches a family smuggled in the high bits, which the
395 /// kernel would still truncate to `AF_INET`.
396 pub(super) fn network_filter() -> anyhow::Result<BpfProgram> {
397 let inet = SeccompRule::new(vec![SeccompCondition::new(
398 0,
399 SeccompCmpArgLen::Dword,
400 SeccompCmpOp::Eq,
401 libc::AF_INET as u64,
402 )?])?;
403 let inet6 = SeccompRule::new(vec![SeccompCondition::new(
404 0,
405 SeccompCmpArgLen::Dword,
406 SeccompCmpOp::Eq,
407 libc::AF_INET6 as u64,
408 )?])?;
409
410 let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
411 rules.insert(libc::SYS_socket, vec![inet, inet6]);
412
413 let filter = SeccompFilter::new(
414 rules,
415 SeccompAction::Allow, // syscalls with no matching rule: allow
416 SeccompAction::KillProcess, // an inet socket: SIGSYS-kill the process
417 std::env::consts::ARCH
418 .try_into()
419 .context("seccomp: unsupported target arch")?,
420 )
421 .context("seccomp: build network filter")?;
422
423 let program: BpfProgram = filter.try_into().context("seccomp: assemble network BPF")?;
424 Ok(program)
425 }
426
427 pub fn apply_network_killswitch() -> anyhow::Result<()> {
428 let program = network_filter()?;
429 apply_filter(&program).context("seccomp: install network filter")?;
430 Ok(())
431 }
432
433 /// Landlock ABI the write-confinement targets. V3 (kernel 6.2) rounds out
434 /// the write set with `Truncate` on top of V2's `Refer` (cross-directory
435 /// rename/link). `CompatLevel::BestEffort` degrades gracefully on older
436 /// kernels.
437 const LANDLOCK_ABI: landlock::ABI = landlock::ABI::V3;
438
439 /// Build + apply the "writes only beneath these directories" Landlock
440 /// ruleset. Only write-class access is handled, so reads and execution stay
441 /// unrestricted everywhere. Returns whether the kernel actually enforces.
442 pub(super) fn apply_fs_confinement(
443 allowed_writes: &[std::path::PathBuf],
444 ) -> anyhow::Result<bool> {
445 use landlock::{
446 AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr, RulesetCreatedAttr,
447 RulesetStatus, path_beneath_rules,
448 };
449
450 let write_access = AccessFs::from_write(LANDLOCK_ABI);
451 let status = Ruleset::default()
452 .set_compatibility(CompatLevel::BestEffort)
453 .handle_access(write_access)
454 .context("landlock: handle write access")?
455 .create()
456 .context("landlock: create ruleset")?
457 // `path_beneath_rules` silently skips paths that can't be opened,
458 // so a missing allowed dir narrows the sandbox instead of erroring.
459 .add_rules(path_beneath_rules(allowed_writes, write_access))
460 .context("landlock: add write rules")?
461 .restrict_self()
462 .context("landlock: restrict self")?;
463 Ok(status.ruleset != RulesetStatus::NotEnforced)
464 }
465
466 /// Whether the confinement ruleset assembles (fork-free `self-test` probe;
467 /// creates a ruleset fd and drops it without restricting anything).
468 pub(super) fn fs_ruleset_builds() -> bool {
469 use landlock::{AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr};
470 Ruleset::default()
471 .set_compatibility(CompatLevel::BestEffort)
472 .handle_access(AccessFs::from_write(LANDLOCK_ABI))
473 .and_then(|r| r.create())
474 .is_ok()
475 }
476
477 #[cfg(test)]
478 mod tests {
479 use super::*;
480
481 /// Fork a child, install `bpf`, attempt `socket(domain, SOCK_STREAM, 0)`,
482 /// and return the child's raw wait status. The BPF is built in the
483 /// parent so the post-`fork` child only performs near-async-signal-safe
484 /// work (the seccomp syscall + `socket` + `_exit`).
485 fn child_socket_status(bpf: &BpfProgram, domain: libc::c_int) -> libc::c_int {
486 // SAFETY: single-threaded test path; the child only calls
487 // `apply_filter`, `socket`/`close`, and `_exit`.
488 unsafe {
489 let pid = libc::fork();
490 assert!(pid >= 0, "fork failed");
491 if pid == 0 {
492 if apply_filter(bpf).is_err() {
493 libc::_exit(77);
494 }
495 let fd = libc::socket(domain, libc::SOCK_STREAM, 0);
496 if fd >= 0 {
497 libc::close(fd);
498 }
499 libc::_exit(0);
500 }
501 let mut status: libc::c_int = 0;
502 let waited = libc::waitpid(pid, &mut status, 0);
503 assert_eq!(waited, pid, "waitpid failed");
504 status
505 }
506 }
507
508 #[test]
509 fn inet_socket_is_killed_with_sigsys() {
510 let bpf = network_filter().expect("build filter");
511 let status = child_socket_status(&bpf, libc::AF_INET);
512 assert!(
513 libc::WIFSIGNALED(status),
514 "AF_INET socket should be signal-killed, status={status}"
515 );
516 assert_eq!(
517 libc::WTERMSIG(status),
518 libc::SIGSYS,
519 "AF_INET socket should die with SIGSYS"
520 );
521 }
522
523 #[test]
524 fn unix_socket_is_allowed() {
525 let bpf = network_filter().expect("build filter");
526 let status = child_socket_status(&bpf, libc::AF_UNIX);
527 assert!(
528 libc::WIFEXITED(status),
529 "AF_UNIX socket should exit cleanly, status={status}"
530 );
531 assert_eq!(
532 libc::WEXITSTATUS(status),
533 0,
534 "AF_UNIX socket must be allowed under the network kill-switch"
535 );
536 }
537
538 #[test]
539 fn fs_confinement_allows_inside_and_denies_outside_writes() {
540 // Two sibling temp dirs; confinement grants writes beneath only one.
541 let base = std::env::temp_dir().join(format!(
542 "mermaid-landlock-test-{}-{}",
543 std::process::id(),
544 // Distinguish parallel test binaries reusing a pid.
545 std::time::SystemTime::now()
546 .duration_since(std::time::UNIX_EPOCH)
547 .unwrap()
548 .as_nanos()
549 ));
550 let allowed = base.join("allowed");
551 let outside = base.join("outside");
552 std::fs::create_dir_all(&allowed).unwrap();
553 std::fs::create_dir_all(&outside).unwrap();
554
555 // Exit codes: 0 = enforced correctly; 42 = kernel can't enforce
556 // (skip); 10 = inside write failed; 11 = outside write succeeded;
557 // 77 = apply failed. Same fork pattern as the seccomp tests above.
558 // SAFETY: the child only runs the confinement setup, two writes,
559 // and `_exit`.
560 let status = unsafe {
561 let pid = libc::fork();
562 assert!(pid >= 0, "fork failed");
563 if pid == 0 {
564 let code = match apply_fs_confinement(std::slice::from_ref(&allowed)) {
565 Err(_) => 77,
566 Ok(false) => 42,
567 Ok(true) => {
568 let inside_ok = std::fs::write(allowed.join("in.txt"), b"x").is_ok();
569 let outside_ok = std::fs::write(outside.join("out.txt"), b"x").is_ok();
570 match (inside_ok, outside_ok) {
571 (true, false) => 0,
572 (false, _) => 10,
573 (true, true) => 11,
574 }
575 },
576 };
577 libc::_exit(code);
578 }
579 let mut status: libc::c_int = 0;
580 assert_eq!(libc::waitpid(pid, &mut status, 0), pid, "waitpid failed");
581 status
582 };
583
584 let _ = std::fs::remove_dir_all(&base);
585
586 assert!(
587 libc::WIFEXITED(status),
588 "child should exit, status={status}"
589 );
590 let code = libc::WEXITSTATUS(status);
591 if code == 42 {
592 eprintln!("skipping: kernel does not enforce Landlock");
593 return;
594 }
595 assert_eq!(
596 code, 0,
597 "confined child: 10 = allowed write failed, 11 = outside write \
598 succeeded, 77 = apply failed"
599 );
600 }
601 }
602}