gunnar-server 2.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
//! **The build sandbox: no network, by construction** (roadmap phase 3).
//!
//! `BuildRecord::network` is a claim a builder makes about itself, and the
//! provenance type says so out loud. This is what turns the claim into a fact:
//! the build runs in a Linux **network namespace of its own**, with nothing in
//! it but a loopback interface that is down. There is no route to anywhere, so
//! a build that tries to fetch a dependency fails rather than succeeding
//! quietly and making the record a lie.
//!
//! # How it works unprivileged, which is the whole trick
//!
//! Creating a network namespace needs `CAP_SYS_ADMIN`. An ordinary user does
//! not have it — but a user who first creates a **user** namespace is root
//! *inside* it, and root inside a user namespace may create the other kinds.
//! So the child does, in order, before `exec`:
//!
//! 1. `unshare(CLONE_NEWUSER)` — now it is uid 65534 (overflow) with no map;
//! 2. `deny` to `/proc/self/setgroups` — required before a uid map may be
//!    written by an unprivileged process, and it is what stops the namespace
//!    being used to drop groups;
//! 3. `0 <euid> 1` to `/proc/self/uid_map` and the same for gid — so inside the
//!    namespace it is root, and outside it is still the same unprivileged user
//!    it always was. **No privilege is gained**: every file access is still
//!    checked against the real uid.
//! 4. `unshare(CLONE_NEWNET)` — allowed now, and the point of all of it.
//!
//! # Everything here runs between `fork` and `exec`
//!
//! Which is the most constrained place in Unix: the child has one thread, and
//! any lock another thread held at `fork` is held for ever. So there is no
//! allocation and no formatting — `rustix`'s `open`/`write` over stack buffers,
//! and the map integers rendered by hand. That is why this file looks the way it
//! does; it is not style.
//!
//! `rustix` and not `libc`, deliberately: the workspace chose it because on
//! `x86_64-linux` its backend is raw syscalls written in Rust, which is what
//! keeps the musl appliance free of a `PT_INTERP`. Reaching for `libc` to get
//! one syscall rustix already spells would have undone that.
//!
//! # What it does not do
//!
//! No mount namespace, no seccomp filter, no cgroup. A build can still read the
//! whole filesystem it is run as, and can still spend the machine's CPU. Those
//! are the next things and they are named rather than implied: this module
//! closes **network** hermeticity, which is the one that decides whether a
//! dependency came from the vendored tree or from the internet.

/// Everything the sandbox can fail at, as a number the child can report.
///
/// An enum rather than a message, because the failing code runs after `fork`
/// and cannot allocate one. The parent turns it back into a sentence.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxError {
    UserNamespace,
    SetGroups,
    UidMap,
    GidMap,
    NetworkNamespace,
}

impl SandboxError {
    fn from_raw(code: i32) -> Option<SandboxError> {
        Some(match code {
            1 => SandboxError::UserNamespace,
            2 => SandboxError::SetGroups,
            3 => SandboxError::UidMap,
            4 => SandboxError::GidMap,
            5 => SandboxError::NetworkNamespace,
            _ => return None,
        })
    }

    /// What an operator can act on.
    pub fn explain(self) -> &'static str {
        match self {
            SandboxError::UserNamespace => {
                "this kernel refused to create a user namespace. Unprivileged user namespaces \
                 may be disabled (`kernel.unprivileged_userns_clone=0`, or an AppArmor/Docker \
                 profile). Without one, an ordinary user cannot make a network namespace and \
                 the build cannot be made hermetic."
            }
            SandboxError::SetGroups => {
                "writing `deny` to /proc/self/setgroups failed, so no uid map may be written"
            }
            SandboxError::UidMap => "writing /proc/self/uid_map failed",
            SandboxError::GidMap => "writing /proc/self/gid_map failed",
            SandboxError::NetworkNamespace => {
                "this kernel refused to create a network namespace inside the user namespace"
            }
        }
    }
}

/// Turn the child's exit status back into a sandbox failure, if it is one.
///
/// The child exits with a small reserved code when it could not enter the
/// sandbox, **before** the build command runs. Reserved rather than shared with
/// the build's own exit codes, so "the sandbox failed" is never mistaken for
/// "the build failed" — the two need different people.
pub const SANDBOX_EXIT_BASE: i32 = 120;

/// The exit code a child uses when the sandbox could not be entered.
pub fn exit_code(error: SandboxError) -> i32 {
    SANDBOX_EXIT_BASE
        + match error {
            SandboxError::UserNamespace => 1,
            SandboxError::SetGroups => 2,
            SandboxError::UidMap => 3,
            SandboxError::GidMap => 4,
            SandboxError::NetworkNamespace => 5,
        }
}

/// **Leave a forked child from its `pre_exec` hook**: `_exit(2)`, which ends the process
/// and runs nothing. NOT `std::process::exit` (libc `exit(3)`): that runs the atexit
/// handlers and the stdio cleanup of a process forked from a MULTI-threaded one, and a
/// lock another thread held at the fork is never released there — the child parks on a
/// futex for good (the gunnar suite, 2026-09-30: 2 h 30 min in the sandbox test; a server
/// whose sandbox cannot be entered would hold that build forever).
pub fn leave_child(code: i32) -> ! {
    extern "C" {
        fn _exit(status: i32) -> !;
    }
    // SAFETY: _exit(2) is async-signal-safe and takes no lock; it never returns.
    unsafe { _exit(code) }
}

/// The failure a child's exit status describes, if it is a sandbox one.
pub fn error_from_exit(code: i32) -> Option<SandboxError> {
    if !(SANDBOX_EXIT_BASE..SANDBOX_EXIT_BASE + 16).contains(&code) {
        return None;
    }
    SandboxError::from_raw(code - SANDBOX_EXIT_BASE)
}

/// Write `bytes` to `path`, allocating nothing.
///
/// Called between `fork` and `exec`, where nothing may allocate or take a lock.
/// `rustix::fs::open` puts the path on the stack for a short one, and the write
/// is one syscall.
fn write_proc(path: &str, bytes: &[u8]) -> bool {
    let Ok(fd) = rustix::fs::open(path, rustix::fs::OFlags::WRONLY, rustix::fs::Mode::empty())
    else {
        return false;
    };
    // One write, and it must take everything: `/proc` map files reject a
    // partial write rather than continuing it, so a short write is a failure
    // and not something to loop on.
    matches!(rustix::io::write(&fd, bytes), Ok(n) if n == bytes.len())
}

/// Render `value` into `buf` as decimal, returning the slice used.
///
/// Hand-rolled because `format!` allocates and this runs after `fork`.
fn render(value: u32, buf: &mut [u8; 12]) -> &[u8] {
    if value == 0 {
        buf[0] = b'0';
        return &buf[..1];
    }
    let mut at = buf.len();
    let mut value = value;
    while value > 0 {
        at -= 1;
        buf[at] = b'0' + (value % 10) as u8;
        value /= 10;
    }
    // Shift to the front so the caller gets a slice starting at zero.
    let len = buf.len() - at;
    buf.copy_within(at.., 0);
    &buf[..len]
}

/// Enter a user namespace and then a network namespace of our own.
///
/// # Safety
///
/// Must be called between `fork` and `exec`, from the child. `unshare` moves
/// the whole *process* into new namespaces, which is unsound while other
/// threads are running; a forked child has exactly one thread, which is what
/// makes this sound there and nowhere else. It also allocates nothing and takes
/// no lock — see the module docs for why that matters in the same place.
pub unsafe fn enter() -> Result<(), SandboxError> {
    use rustix::thread::UnshareFlags;

    let uid = rustix::process::geteuid().as_raw();
    let gid = rustix::process::getegid().as_raw();

    // `unshare_unsafe`, and the name is right: `unshare` changes the whole
    // PROCESS's namespaces, which is unsound in a multi-threaded program. Here
    // the process is a freshly forked child with exactly one thread, on its way
    // to `exec` — the one place where it is sound, and the reason this function
    // may only be called from `pre_exec`.
    if unsafe { rustix::thread::unshare_unsafe(UnshareFlags::NEWUSER) }.is_err() {
        return Err(SandboxError::UserNamespace);
    }
    // Required before an unprivileged uid map may be written, and it is what
    // stops a user namespace being a way to drop supplementary groups.
    if !write_proc("/proc/self/setgroups", b"deny") {
        return Err(SandboxError::SetGroups);
    }

    // `0 <uid> 1` — root inside, the same user outside. No privilege is gained:
    // every file access is still checked against the real uid.
    let mut map = [0u8; 32];
    let mut digits = [0u8; 12];
    let mut at = 0usize;
    map[at] = b'0';
    at += 1;
    map[at] = b' ';
    at += 1;
    let rendered = render(uid, &mut digits);
    map[at..at + rendered.len()].copy_from_slice(rendered);
    at += rendered.len();
    map[at..at + 2].copy_from_slice(b" 1");
    at += 2;
    if !write_proc("/proc/self/uid_map", &map[..at]) {
        return Err(SandboxError::UidMap);
    }

    let mut at = 0usize;
    map[at] = b'0';
    at += 1;
    map[at] = b' ';
    at += 1;
    let rendered = render(gid, &mut digits);
    map[at..at + rendered.len()].copy_from_slice(rendered);
    at += rendered.len();
    map[at..at + 2].copy_from_slice(b" 1");
    at += 2;
    if !write_proc("/proc/self/gid_map", &map[..at]) {
        return Err(SandboxError::GidMap);
    }

    // The point of all of it.
    if unsafe { rustix::thread::unshare_unsafe(UnshareFlags::NEWNET) }.is_err() {
        return Err(SandboxError::NetworkNamespace);
    }
    Ok(())
}

/// Is an unprivileged sandbox available on this kernel at all?
///
/// Asked by forking a child that does nothing but try to enter one. **Not**
/// inferred from a sysctl: there are three separate ways a distribution can
/// forbid this (the sysctl, an AppArmor profile, a seccomp filter in a
/// container), and reading one of them would answer for one of the three.
pub fn available() -> Result<(), SandboxError> {
    use std::os::unix::process::CommandExt as _;
    // `true(1)` and not this binary: re-execing the server to probe would run
    // whatever `main` does with no arguments, and the probe must do nothing at
    // all beyond entering the namespace.
    // 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.
    let mut command = std::process::Command::new("true");
    // The hook is bound OUTSIDE the `unsafe` block for the same reason as in
    // `runner.rs`: an `unsafe` block extends lexically into a closure written
    // inside it, so the inner one was redundant (`unused_unsafe`) and its
    // SAFETY note unenforced. Bound here, each `unsafe` covers one call.
    let enter_sandbox = || {
        // SAFETY: `pre_exec` runs in the forked child, single-threaded,
        // before `exec` — which is `enter`'s whole requirement.
        if let Err(err) = unsafe { enter() } {
            leave_child(exit_code(err));
        }
        Ok(())
    };
    // SAFETY: `enter` allocates nothing and takes no lock, which is the whole
    // requirement `pre_exec` imposes. [`leave_child`] (`_exit(2)`) in the child is the
    // documented way to report a pre-exec failure without unwinding.
    unsafe {
        command.pre_exec(enter_sandbox);
    }
    let status = command
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status();
    // A probe that could not even be spawned is not a sandbox verdict, and must
    // not be reported as one.
    let Ok(status) = status else {
        return Ok(());
    };
    match status.code().and_then(error_from_exit) {
        Some(err) => Err(err),
        None => Ok(()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// **A `pre_exec` hook leaves by `_exit(2)`, never by `exit(3)`** (found 2026-09-30:
    /// the gunnar suite hung 2 h 30 min in `a_build_in_the_sandbox_cannot_reach_a_listener_the_host_can`,
    /// its forked child in the new user namespace, parked on a futex — `enter` failed and
    /// the hook called `std::process::exit`, which runs the atexit handlers and stdio
    /// cleanup of a process forked from a MULTI-threaded one: a lock another thread held at
    /// the fork is never released there; and `spawn` itself waits on the exec pipe, so the
    /// parent hangs with it). Reproduced deterministically: an atexit handler that stalls
    /// 3 s, only in a forked child. The control arm (`std::process::exit`) runs it — the
    /// spawn is held that long; [`leave_child`] runs nothing and exits at once with its code.
    #[test]
    fn a_pre_exec_hook_that_fails_leaves_at_once_even_when_an_atexit_handler_would_block() {
        use std::os::unix::process::CommandExt as _;
        use std::sync::atomic::{AtomicI32, Ordering};
        static PARENT: AtomicI32 = AtomicI32::new(0);
        extern "C" fn blocks_in_a_child() {
            if std::process::id() as i32 != PARENT.load(Ordering::SeqCst) {
                std::thread::sleep(std::time::Duration::from_secs(3));
            }
        }
        extern "C" {
            fn atexit(f: extern "C" fn()) -> i32;
        }
        PARENT.store(std::process::id() as i32, Ordering::SeqCst);
        // SAFETY: registering a plain extern "C" fn with libc's atexit.
        assert_eq!(unsafe { atexit(blocks_in_a_child) }, 0);
        // How long the spawn is held, and the code the child left with.
        let timed = |leave: fn(i32) -> !| -> (std::time::Duration, Option<i32>) {
            let mut c = std::process::Command::new("true");
            // SAFETY: the hook only leaves; it is what is under test.
            unsafe {
                c.pre_exec(move || leave(97));
            }
            let t0 = std::time::Instant::now();
            let st = c.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().expect("spawned");
            (t0.elapsed(), st.code())
        };
        let (held, code) = timed(|c| std::process::exit(c));
        assert!(held >= std::time::Duration::from_millis(2500), "the control: exit(3) in the forked child ran the stalling handler (held {held:?}, code {code:?})");
        let (held, code) = timed(leave_child);
        assert_eq!(code, Some(97), "leave_child exits with its code");
        assert!(held < std::time::Duration::from_millis(2500), "leave_child ran an atexit handler: held {held:?}");
    }

    #[test]
    fn the_exit_codes_round_trip_and_do_not_collide_with_ordinary_ones() {
        for err in [
            SandboxError::UserNamespace,
            SandboxError::SetGroups,
            SandboxError::UidMap,
            SandboxError::GidMap,
            SandboxError::NetworkNamespace,
        ] {
            assert_eq!(error_from_exit(exit_code(err)), Some(err));
        }
        // A build that exits 1 is a failed build, not a failed sandbox. This is
        // the confusion the reserved range exists to prevent.
        for ordinary in [0, 1, 2, 101, 119] {
            assert_eq!(error_from_exit(ordinary), None, "code {ordinary}");
        }
    }

    #[test]
    fn the_decimal_renderer_agrees_with_the_formatter() {
        for value in [0u32, 1, 9, 10, 99, 1000, 65534, u32::MAX] {
            let mut buf = [0u8; 12];
            assert_eq!(
                std::str::from_utf8(render(value, &mut buf)).unwrap(),
                value.to_string(),
                "rendering {value}"
            );
        }
    }
}

#[cfg(test)]
mod live_tests {
    use super::*;
    use std::io::Read as _;
    use std::os::unix::process::CommandExt as _;

    /// Run `bash -c <script>`, optionally inside the sandbox, and report
    /// whether it succeeded.
    fn run(script: &str, sandboxed: bool) -> Option<bool> {
        let mut command = std::process::Command::new("bash");
        command.arg("-c").arg(script);
        if sandboxed {
            // SAFETY: `enter` allocates nothing and takes no lock.
            unsafe {
                command.pre_exec(|| {
                    if let Err(err) = enter() {
                        leave_child(exit_code(err));
                    }
                    Ok(())
                });
            }
        }
        let status = command
            .stdin(std::process::Stdio::null())
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .ok()?;
        // A sandbox that could not be entered is not a verdict about the
        // script, so it is `None` and the caller skips rather than asserting.
        if status.code().and_then(error_from_exit).is_some() {
            return None;
        }
        Some(status.success())
    }

    /// **The sandbox really cuts the network**, and the control says the test
    /// could have noticed if it did not.
    ///
    /// A listener on loopback is the target: inside a fresh network namespace
    /// there is one interface, `lo`, and it is **down**, so the connect fails.
    /// Outside, it succeeds — which is the half that makes the first assertion
    /// mean something rather than being satisfied by a broken script.
    #[test]
    fn a_build_in_the_sandbox_cannot_reach_a_listener_the_host_can() {
        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
        let port = listener.local_addr().expect("addr").port();
        // Accept in the background so a successful connect completes rather
        // than sitting in the backlog.
        std::thread::spawn(move || {
            for mut stream in listener.incoming().take(4).flatten() {
                let mut sink = [0u8; 1];
                let _ = stream.read(&mut sink);
            }
        });

        let script = format!("exec 3<>/dev/tcp/127.0.0.1/{port}");
        let Some(outside) = run(&script, false) else {
            unreachable!("the unsandboxed arm cannot report a sandbox failure")
        };
        assert!(
            outside,
            "the control failed: bash could not reach a listener on loopback without any \
             sandbox, so this test cannot tell a cut network from a broken script"
        );

        match run(&script, true) {
            None => eprintln!(
                "skipped: this kernel does not allow unprivileged user namespaces, so the \
                 sandbox cannot be exercised here. `gunnar runner` refuses to build rather \
                 than building without one."
            ),
            Some(inside) => assert!(
                !inside,
                "a process inside the build sandbox reached a listener on the host's loopback. \
                 The network namespace is not being entered, and every `network: false` in a \
                 build record this server signs is a lie."
            ),
        }
    }

    /// The sandbox does not break the ordinary case: a build that touches only
    /// the filesystem still runs, and still runs **as the same user**.
    #[test]
    fn a_build_in_the_sandbox_still_owns_the_files_it_writes() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("written");
        let script = format!("echo hello > {}", path.display());
        match run(&script, true) {
            None => eprintln!("skipped: no unprivileged user namespaces on this kernel"),
            Some(ok) => {
                assert!(
                    ok,
                    "a build that only writes a file failed inside the sandbox"
                );
                assert_eq!(
                    std::fs::read_to_string(&path).expect("read it back"),
                    "hello\n",
                    "the sandboxed build could not write to the directory it was given. The uid \
                     map is wrong: inside the namespace it must be root and outside it must \
                     still be the same unprivileged user."
                );
            }
        }
    }
}