gunnar_server/sandbox.rs
1//! **An unprivileged network namespace of the caller's own.**
2//!
3//! Gunnar builds nothing (builds run in dwarves through Build Thing). This
4//! module survives the build runner because the PID 1 networking tests
5//! (`tests/pid1_edda_netns.rs`) enter a Linux **network namespace of their
6//! own**, with nothing in it but a loopback interface that is down, to prove
7//! the appliance's network bring-up without touching the host's.
8//!
9//! # How it works unprivileged, which is the whole trick
10//!
11//! Creating a network namespace needs `CAP_SYS_ADMIN`. An ordinary user does
12//! not have it — but a user who first creates a **user** namespace is root
13//! *inside* it, and root inside a user namespace may create the other kinds.
14//! So the child does, in order, before `exec`:
15//!
16//! 1. `unshare(CLONE_NEWUSER)` — now it is uid 65534 (overflow) with no map;
17//! 2. `deny` to `/proc/self/setgroups` — required before a uid map may be
18//! written by an unprivileged process, and it is what stops the namespace
19//! being used to drop groups;
20//! 3. `0 <euid> 1` to `/proc/self/uid_map` and the same for gid — so inside the
21//! namespace it is root, and outside it is still the same unprivileged user
22//! it always was. **No privilege is gained**: every file access is still
23//! checked against the real uid.
24//! 4. `unshare(CLONE_NEWNET)` — allowed now, and the point of all of it.
25//!
26//! # Everything here runs between `fork` and `exec`
27//!
28//! Which is the most constrained place in Unix: the child has one thread, and
29//! any lock another thread held at `fork` is held for ever. So there is no
30//! allocation and no formatting — `rustix`'s `open`/`write` over stack buffers,
31//! and the map integers rendered by hand. That is why this file looks the way it
32//! does; it is not style.
33//!
34//! `rustix` and not `libc`, deliberately: the workspace chose it because on
35//! `x86_64-linux` its backend is raw syscalls written in Rust, which is what
36//! keeps the musl appliance free of a `PT_INTERP`. Reaching for `libc` to get
37//! one syscall rustix already spells would have undone that.
38//!
39//! # What it does not do
40//!
41//! No mount namespace, no seccomp filter, no cgroup. A build can still read the
42//! whole filesystem it is run as, and can still spend the machine's CPU. Those
43//! are the next things and they are named rather than implied: this module
44//! closes **network** hermeticity, which is the one that decides whether a
45//! dependency came from the vendored tree or from the internet.
46
47/// Everything the sandbox can fail at, as a number the child can report.
48///
49/// An enum rather than a message, because the failing code runs after `fork`
50/// and cannot allocate one. The parent turns it back into a sentence.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum SandboxError {
53 UserNamespace,
54 SetGroups,
55 UidMap,
56 GidMap,
57 NetworkNamespace,
58}
59
60impl SandboxError {
61 fn from_raw(code: i32) -> Option<SandboxError> {
62 Some(match code {
63 1 => SandboxError::UserNamespace,
64 2 => SandboxError::SetGroups,
65 3 => SandboxError::UidMap,
66 4 => SandboxError::GidMap,
67 5 => SandboxError::NetworkNamespace,
68 _ => return None,
69 })
70 }
71
72 /// What an operator can act on.
73 pub fn explain(self) -> &'static str {
74 match self {
75 SandboxError::UserNamespace => {
76 "this kernel refused to create a user namespace. Unprivileged user namespaces \
77 may be disabled (`kernel.unprivileged_userns_clone=0`, or an AppArmor/Docker \
78 profile). Without one, an ordinary user cannot make a network namespace and \
79 the build cannot be made hermetic."
80 }
81 SandboxError::SetGroups => {
82 "writing `deny` to /proc/self/setgroups failed, so no uid map may be written"
83 }
84 SandboxError::UidMap => "writing /proc/self/uid_map failed",
85 SandboxError::GidMap => "writing /proc/self/gid_map failed",
86 SandboxError::NetworkNamespace => {
87 "this kernel refused to create a network namespace inside the user namespace"
88 }
89 }
90 }
91}
92
93/// Turn the child's exit status back into a sandbox failure, if it is one.
94///
95/// The child exits with a small reserved code when it could not enter the
96/// sandbox, **before** the build command runs. Reserved rather than shared with
97/// the build's own exit codes, so "the sandbox failed" is never mistaken for
98/// "the build failed" — the two need different people.
99pub const SANDBOX_EXIT_BASE: i32 = 120;
100
101/// The exit code a child uses when the sandbox could not be entered.
102pub fn exit_code(error: SandboxError) -> i32 {
103 SANDBOX_EXIT_BASE
104 + match error {
105 SandboxError::UserNamespace => 1,
106 SandboxError::SetGroups => 2,
107 SandboxError::UidMap => 3,
108 SandboxError::GidMap => 4,
109 SandboxError::NetworkNamespace => 5,
110 }
111}
112
113/// **Leave a forked child from its `pre_exec` hook**: `_exit(2)`, which ends the process
114/// and runs nothing. NOT `std::process::exit` (libc `exit(3)`): that runs the atexit
115/// handlers and the stdio cleanup of a process forked from a MULTI-threaded one, and a
116/// lock another thread held at the fork is never released there — the child parks on a
117/// futex for good (the gunnar suite, 2026-09-30: 2 h 30 min in the sandbox test; a server
118/// whose sandbox cannot be entered would hold that build forever).
119pub fn leave_child(code: i32) -> ! {
120 extern "C" {
121 fn _exit(status: i32) -> !;
122 }
123 // SAFETY: _exit(2) is async-signal-safe and takes no lock; it never returns.
124 unsafe { _exit(code) }
125}
126
127/// The failure a child's exit status describes, if it is a sandbox one.
128pub fn error_from_exit(code: i32) -> Option<SandboxError> {
129 if !(SANDBOX_EXIT_BASE..SANDBOX_EXIT_BASE + 16).contains(&code) {
130 return None;
131 }
132 SandboxError::from_raw(code - SANDBOX_EXIT_BASE)
133}
134
135/// Write `bytes` to `path`, allocating nothing.
136///
137/// Called between `fork` and `exec`, where nothing may allocate or take a lock.
138/// `rustix::fs::open` puts the path on the stack for a short one, and the write
139/// is one syscall.
140fn write_proc(path: &str, bytes: &[u8]) -> bool {
141 let Ok(fd) = rustix::fs::open(path, rustix::fs::OFlags::WRONLY, rustix::fs::Mode::empty())
142 else {
143 return false;
144 };
145 // One write, and it must take everything: `/proc` map files reject a
146 // partial write rather than continuing it, so a short write is a failure
147 // and not something to loop on.
148 matches!(rustix::io::write(&fd, bytes), Ok(n) if n == bytes.len())
149}
150
151/// Render `value` into `buf` as decimal, returning the slice used.
152///
153/// Hand-rolled because `format!` allocates and this runs after `fork`.
154fn render(value: u32, buf: &mut [u8; 12]) -> &[u8] {
155 if value == 0 {
156 buf[0] = b'0';
157 return &buf[..1];
158 }
159 let mut at = buf.len();
160 let mut value = value;
161 while value > 0 {
162 at -= 1;
163 buf[at] = b'0' + (value % 10) as u8;
164 value /= 10;
165 }
166 // Shift to the front so the caller gets a slice starting at zero.
167 let len = buf.len() - at;
168 buf.copy_within(at.., 0);
169 &buf[..len]
170}
171
172/// Enter a user namespace and then a network namespace of our own.
173///
174/// # Safety
175///
176/// Must be called between `fork` and `exec`, from the child. `unshare` moves
177/// the whole *process* into new namespaces, which is unsound while other
178/// threads are running; a forked child has exactly one thread, which is what
179/// makes this sound there and nowhere else. It also allocates nothing and takes
180/// no lock — see the module docs for why that matters in the same place.
181pub unsafe fn enter() -> Result<(), SandboxError> {
182 use rustix::thread::UnshareFlags;
183
184 let uid = rustix::process::geteuid().as_raw();
185 let gid = rustix::process::getegid().as_raw();
186
187 // `unshare_unsafe`, and the name is right: `unshare` changes the whole
188 // PROCESS's namespaces, which is unsound in a multi-threaded program. Here
189 // the process is a freshly forked child with exactly one thread, on its way
190 // to `exec` — the one place where it is sound, and the reason this function
191 // may only be called from `pre_exec`.
192 if unsafe { rustix::thread::unshare_unsafe(UnshareFlags::NEWUSER) }.is_err() {
193 return Err(SandboxError::UserNamespace);
194 }
195 // Required before an unprivileged uid map may be written, and it is what
196 // stops a user namespace being a way to drop supplementary groups.
197 if !write_proc("/proc/self/setgroups", b"deny") {
198 return Err(SandboxError::SetGroups);
199 }
200
201 // `0 <uid> 1` — root inside, the same user outside. No privilege is gained:
202 // every file access is still checked against the real uid.
203 let mut map = [0u8; 32];
204 let mut digits = [0u8; 12];
205 let mut at = 0usize;
206 map[at] = b'0';
207 at += 1;
208 map[at] = b' ';
209 at += 1;
210 let rendered = render(uid, &mut digits);
211 map[at..at + rendered.len()].copy_from_slice(rendered);
212 at += rendered.len();
213 map[at..at + 2].copy_from_slice(b" 1");
214 at += 2;
215 if !write_proc("/proc/self/uid_map", &map[..at]) {
216 return Err(SandboxError::UidMap);
217 }
218
219 let mut at = 0usize;
220 map[at] = b'0';
221 at += 1;
222 map[at] = b' ';
223 at += 1;
224 let rendered = render(gid, &mut digits);
225 map[at..at + rendered.len()].copy_from_slice(rendered);
226 at += rendered.len();
227 map[at..at + 2].copy_from_slice(b" 1");
228 at += 2;
229 if !write_proc("/proc/self/gid_map", &map[..at]) {
230 return Err(SandboxError::GidMap);
231 }
232
233 // The point of all of it.
234 if unsafe { rustix::thread::unshare_unsafe(UnshareFlags::NEWNET) }.is_err() {
235 return Err(SandboxError::NetworkNamespace);
236 }
237 Ok(())
238}
239
240/// Is an unprivileged sandbox available on this kernel at all?
241///
242/// Asked by forking a child that does nothing but try to enter one. **Not**
243/// inferred from a sysctl: there are three separate ways a distribution can
244/// forbid this (the sysctl, an AppArmor profile, a seccomp filter in a
245/// container), and reading one of them would answer for one of the three.
246pub fn available() -> Result<(), SandboxError> {
247 use std::os::unix::process::CommandExt as _;
248 // `true(1)` and not this binary: re-execing the server to probe would run
249 // whatever `main` does with no arguments, and the probe must do nothing at
250 // all beyond entering the namespace.
251 // A32-14, a process by design: the namespace probe runs `true(1)`, which does nothing, to learn whether this box may enter a namespace at all.
252 let mut command = std::process::Command::new("true");
253 // The hook is bound OUTSIDE the `unsafe` block: an `unsafe` block extends
254 // lexically into a closure written inside it, so the inner one was redundant (`unused_unsafe`) and its
255 // SAFETY note unenforced. Bound here, each `unsafe` covers one call.
256 let enter_sandbox = || {
257 // SAFETY: `pre_exec` runs in the forked child, single-threaded,
258 // before `exec` — which is `enter`'s whole requirement.
259 if let Err(err) = unsafe { enter() } {
260 leave_child(exit_code(err));
261 }
262 Ok(())
263 };
264 // SAFETY: `enter` allocates nothing and takes no lock, which is the whole
265 // requirement `pre_exec` imposes. [`leave_child`] (`_exit(2)`) in the child is the
266 // documented way to report a pre-exec failure without unwinding.
267 unsafe {
268 command.pre_exec(enter_sandbox);
269 }
270 let status = command
271 .stdin(std::process::Stdio::null())
272 .stdout(std::process::Stdio::null())
273 .stderr(std::process::Stdio::null())
274 .status();
275 // A probe that could not even be spawned is not a sandbox verdict, and must
276 // not be reported as one.
277 let Ok(status) = status else {
278 return Ok(());
279 };
280 match status.code().and_then(error_from_exit) {
281 Some(err) => Err(err),
282 None => Ok(()),
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 /// **A `pre_exec` hook leaves by `_exit(2)`, never by `exit(3)`** (found 2026-09-30:
291 /// the gunnar suite hung 2 h 30 min in `a_build_in_the_sandbox_cannot_reach_a_listener_the_host_can`,
292 /// its forked child in the new user namespace, parked on a futex — `enter` failed and
293 /// the hook called `std::process::exit`, which runs the atexit handlers and stdio
294 /// cleanup of a process forked from a MULTI-threaded one: a lock another thread held at
295 /// the fork is never released there; and `spawn` itself waits on the exec pipe, so the
296 /// parent hangs with it). Reproduced deterministically: an atexit handler that stalls
297 /// 3 s, only in a forked child. The control arm (`std::process::exit`) runs it — the
298 /// spawn is held that long; [`leave_child`] runs nothing and exits at once with its code.
299 #[test]
300 fn a_pre_exec_hook_that_fails_leaves_at_once_even_when_an_atexit_handler_would_block() {
301 use std::os::unix::process::CommandExt as _;
302 use std::sync::atomic::{AtomicI32, Ordering};
303 static PARENT: AtomicI32 = AtomicI32::new(0);
304 extern "C" fn blocks_in_a_child() {
305 if std::process::id() as i32 != PARENT.load(Ordering::SeqCst) {
306 std::thread::sleep(std::time::Duration::from_secs(3));
307 }
308 }
309 extern "C" {
310 fn atexit(f: extern "C" fn()) -> i32;
311 }
312 PARENT.store(std::process::id() as i32, Ordering::SeqCst);
313 // SAFETY: registering a plain extern "C" fn with libc's atexit.
314 assert_eq!(unsafe { atexit(blocks_in_a_child) }, 0);
315 // How long the spawn is held, and the code the child left with.
316 let timed = |leave: fn(i32) -> !| -> (std::time::Duration, Option<i32>) {
317 let mut c = std::process::Command::new("true");
318 // SAFETY: the hook only leaves; it is what is under test.
319 unsafe {
320 c.pre_exec(move || leave(97));
321 }
322 let t0 = std::time::Instant::now();
323 let st = c
324 .stdin(std::process::Stdio::null())
325 .stdout(std::process::Stdio::null())
326 .stderr(std::process::Stdio::null())
327 .status()
328 .expect("spawned");
329 (t0.elapsed(), st.code())
330 };
331 let (held, code) = timed(|c| std::process::exit(c));
332 assert!(held >= std::time::Duration::from_millis(2500), "the control: exit(3) in the forked child ran the stalling handler (held {held:?}, code {code:?})");
333 let (held, code) = timed(leave_child);
334 assert_eq!(code, Some(97), "leave_child exits with its code");
335 assert!(
336 held < std::time::Duration::from_millis(2500),
337 "leave_child ran an atexit handler: held {held:?}"
338 );
339 }
340
341 #[test]
342 fn the_exit_codes_round_trip_and_do_not_collide_with_ordinary_ones() {
343 for err in [
344 SandboxError::UserNamespace,
345 SandboxError::SetGroups,
346 SandboxError::UidMap,
347 SandboxError::GidMap,
348 SandboxError::NetworkNamespace,
349 ] {
350 assert_eq!(error_from_exit(exit_code(err)), Some(err));
351 }
352 // A build that exits 1 is a failed build, not a failed sandbox. This is
353 // the confusion the reserved range exists to prevent.
354 for ordinary in [0, 1, 2, 101, 119] {
355 assert_eq!(error_from_exit(ordinary), None, "code {ordinary}");
356 }
357 }
358
359 #[test]
360 fn the_decimal_renderer_agrees_with_the_formatter() {
361 for value in [0u32, 1, 9, 10, 99, 1000, 65534, u32::MAX] {
362 let mut buf = [0u8; 12];
363 assert_eq!(
364 std::str::from_utf8(render(value, &mut buf)).unwrap(),
365 value.to_string(),
366 "rendering {value}"
367 );
368 }
369 }
370}
371
372#[cfg(test)]
373mod live_tests {
374 use super::*;
375 use std::io::Read as _;
376 use std::os::unix::process::CommandExt as _;
377
378 /// Run `bash -c <script>`, optionally inside the sandbox, and report
379 /// whether it succeeded.
380 fn run(script: &str, sandboxed: bool) -> Option<bool> {
381 let mut command = std::process::Command::new("bash");
382 command.arg("-c").arg(script);
383 if sandboxed {
384 // SAFETY: `enter` allocates nothing and takes no lock.
385 unsafe {
386 command.pre_exec(|| {
387 if let Err(err) = enter() {
388 leave_child(exit_code(err));
389 }
390 Ok(())
391 });
392 }
393 }
394 let status = command
395 .stdin(std::process::Stdio::null())
396 .stdout(std::process::Stdio::null())
397 .stderr(std::process::Stdio::null())
398 .status()
399 .ok()?;
400 // A sandbox that could not be entered is not a verdict about the
401 // script, so it is `None` and the caller skips rather than asserting.
402 if status.code().and_then(error_from_exit).is_some() {
403 return None;
404 }
405 Some(status.success())
406 }
407
408 /// **The sandbox really cuts the network**, and the control says the test
409 /// could have noticed if it did not.
410 ///
411 /// A listener on loopback is the target: inside a fresh network namespace
412 /// there is one interface, `lo`, and it is **down**, so the connect fails.
413 /// Outside, it succeeds — which is the half that makes the first assertion
414 /// mean something rather than being satisfied by a broken script.
415 #[test]
416 fn a_build_in_the_sandbox_cannot_reach_a_listener_the_host_can() {
417 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
418 let port = listener.local_addr().expect("addr").port();
419 // Accept in the background so a successful connect completes rather
420 // than sitting in the backlog.
421 std::thread::spawn(move || {
422 for mut stream in listener.incoming().take(4).flatten() {
423 let mut sink = [0u8; 1];
424 let _ = stream.read(&mut sink);
425 }
426 });
427
428 let script = format!("exec 3<>/dev/tcp/127.0.0.1/{port}");
429 let Some(outside) = run(&script, false) else {
430 unreachable!("the unsandboxed arm cannot report a sandbox failure")
431 };
432 assert!(
433 outside,
434 "the control failed: bash could not reach a listener on loopback without any \
435 sandbox, so this test cannot tell a cut network from a broken script"
436 );
437
438 match run(&script, true) {
439 None => eprintln!(
440 "skipped: this kernel does not allow unprivileged user namespaces, so the \
441 sandbox cannot be exercised here."
442 ),
443 Some(inside) => assert!(
444 !inside,
445 "a process inside the sandbox reached a listener on the host's loopback. \
446 The network namespace is not being entered."
447 ),
448 }
449 }
450
451 /// The sandbox does not break the ordinary case: a build that touches only
452 /// the filesystem still runs, and still runs **as the same user**.
453 #[test]
454 fn a_build_in_the_sandbox_still_owns_the_files_it_writes() {
455 let dir = tempfile::tempdir().expect("tempdir");
456 let path = dir.path().join("written");
457 let script = format!("echo hello > {}", path.display());
458 match run(&script, true) {
459 None => eprintln!("skipped: no unprivileged user namespaces on this kernel"),
460 Some(ok) => {
461 assert!(
462 ok,
463 "a build that only writes a file failed inside the sandbox"
464 );
465 assert_eq!(
466 std::fs::read_to_string(&path).expect("read it back"),
467 "hello\n",
468 "the sandboxed build could not write to the directory it was given. The uid \
469 map is wrong: inside the namespace it must be root and outside it must \
470 still be the same unprivileged user."
471 );
472 }
473 }
474 }
475}