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 ¶ms {
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}