Skip to main content

gunnar_server/
sandbox.rs

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