Skip to main content

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