OXYGENHUB

Coverage / crates/flow/src/syscall.rs 3.0%

19 of 629 lines run, 5 of 192 functions · run #201 on main · run missed

1
//! Tier 1 (spec §4.2, docs/m0.md §3): the syscall sandbox, for machines
2
//! without hardware virtualisation. The job runs in new user, mount, PID,
3
//! network, IPC and UTS namespaces, on the rootfs image's tree (unpacked
4
//! once per hash, read-only) with the workspace directory at `/src` and a
5
//! tmpfs at `/tmp`. Landlock limits writes to `/src`, `/tmp` and `/dev`; a
6
//! seccomp filter refuses sockets other than `AF_UNIX` (and, with
7
//! `network "loopback"`, IPv4 and IPv6 on its own `lo`) and the calls that
8
//! reach past the sandbox (`ptrace`, `mount`, `bpf`, `perf_event_open`,
9
//! keys, modules, io_uring); and the sandbox's CPU and memory limits apply
10
//! through a cgroup v2 group of the runner's own delegated cgroup. Without
11
//! that delegation the tier is not offered, so no job runs unbounded.
12
//!
13
//! The runner starts `flow __sandbox <spec>` (this same binary, through
14
//! `/proc/self/exe`) for each job. That helper joins the job's cgroup,
15
//! makes the namespaces and forks the sandbox's PID 1, which builds the new
16
//! root, pivots into it and runs the job as an unprivileged user, the way
17
//! `flow-init` does in a microVM. Records, receipts and artifacts are the
18
//! same as tier 2's.
19
20
use crate::guest::{dev_links, job_env, loopback_up, resolve_program, PATH};
21
use crate::home::Home;
22
use crate::microvm::{Outcome, Sink};
23
pub use crate::tier::disk_dir;
24
pub(crate) use crate::tier::{lines, self_hash, FAILED};
25
use anyhow::{bail, Context, Result};
26
use flow_core::api::{Record, SecretInput};
27
use flow_core::{Environment, Job, MountSource, Network, Sandbox};
28
use serde::{Deserialize, Serialize};
29
use std::ffi::CString;
30
use std::path::{Path, PathBuf};
31
use std::sync::{mpsc, OnceLock};
32
use std::time::{Duration, Instant};
33
34
/// The user and group a job runs as inside the sandbox; the runner's own
35
/// on the host.
36
const JOB_ID: u32 = 1000;
37
38
/// The receipt environment for a job in this tier: the sandbox is this
39
/// binary, so its hash stands where tier 2 has Firecracker's and
40
/// flow-init's, with the host kernel's release.
410×
pub fn environment(sandbox: &Sandbox, job: &Job) -> Environment {
42
    Environment {
430×
        runtime: "syscall".into(),
440×
        parts: vec![
450×
            ("flow".into(), self_hash().to_string()),
460×
            ("kernel".into(), kernel_release()),
470×
            ("memory_mib".into(), sandbox.memory_mib.to_string()),
480×
            ("cpus".into(), sandbox.cpus.to_string()),
49
        ]
500×
        .into_iter()
510×
        .chain(job.mounts.iter().map(|m| (format!("mount {}", m.target), format!("{} {} MiB", m.source.as_str(), m.size_mib))))
520×
        .collect(),
530×
        rootfs: sandbox.rootfs.clone(),
540×
        argv: job.exec.clone(),
550×
        env: job.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
56
    }
570×
}
58
590×
fn kernel_release() -> String {
600×
    crate::facts::kernel().unwrap_or_else(|| "unknown".into())
610×
}
62
63
/// Whether this machine can run the tier now: a cgroup this runner may
64
/// manage, and namespaces, Landlock and seccomp that work for this binary
65
/// (Ubuntu's AppArmor can deny unprivileged user namespaces to programs
66
/// without a profile). `Err` says what is missing.
673×
pub fn available() -> std::result::Result<(), String> {
683×
    Cgroups::delegated()?;
690×
    let out = std::process::Command::new("/proc/self/exe")
700×
        .arg("__sandbox-probe")
710×
        .stdin(std::process::Stdio::null())
720×
        .output()
730×
        .map_err(|e| format!("cannot start the probe: {e}"))?;
740×
    if out.status.success() {
750×
        Ok(())
76
    } else {
770×
        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
78
    }
793×
}
80
81
/// `flow __sandbox-probe`: try each kernel feature the sandbox uses, in a
82
/// throwaway process, and say which one failed.
830×
pub fn probe_main() -> i32 {
840×
    let r = (|| -> std::result::Result<(), String> {
850×
        let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
860×
        enter_namespaces(libc::CLONE_NEWUSER | libc::CLONE_NEWNS, uid, gid)
870×
            .map_err(|e| format!("user namespaces are not allowed for this program ({e}); on Ubuntu, kernel.apparmor_restrict_unprivileged_userns=1 denies them"))?;
880×
        mount("none", "/", "", libc::MS_REC | libc::MS_PRIVATE, "")
890×
            .and_then(|_| mount("tmpfs", "/tmp", "tmpfs", libc::MS_NOSUID | libc::MS_NODEV, ""))
900×
            .map_err(|e| {
910×
                format!(
92
                    "user namespaces give no mount rights here ({e}); on Ubuntu, kernel.apparmor_restrict_unprivileged_userns=1 does this"
93
                )
940×
            })?;
950×
        let abi = landlock_abi();
960×
        if abi < 1 {
970×
            return Err("Landlock is not enabled in this kernel (it must be in the lsm= list)".into());
980×
        }
990×
        no_new_privs()?;
1000×
        seccomp(&[], families(false)).map_err(|e| format!("seccomp filters are not available: {e}"))
101
    })();
1020×
    match r {
1030×
        Ok(()) => 0,
1040×
        Err(e) => {
1050×
            eprintln!("{e}");
1060×
            1
107
        }
108
    }
1090×
}
110
111
/// What the helper reads from the spec file the runner wrote (0600, in
112
/// the job's scratch directory, which the job never sees); it removes it
113
/// once read.
114
#[derive(Serialize, Deserialize)]
115
struct Spec {
116
    /// The image's unpacked tree.
117
    rootfs: PathBuf,
118
    /// An empty directory to build the new root on.
119
    root: PathBuf,
120
    workspace: PathBuf,
121
    /// The job's cgroup, joined before anything else.
122
    cgroup: PathBuf,
123
    argv: Vec<String>,
124
    env: Vec<(String, String)>,
125
    mounts: Vec<MountSpec>,
126
    loopback: bool,
127
    secrets: Vec<(String, String)>,
128
}
129
130
#[derive(Serialize, Deserialize)]
131
struct MountSpec {
132
    target: String,
133
    size_mib: u32,
134
    /// A host directory bound at `target` (`ephemeral://disk`); a tmpfs
135
    /// of `size_mib` when there is none.
136
    dir: Option<PathBuf>,
137
}
138
139
/// Run `job` with `workspace` (a directory) as `/src`. `scratch` is a
140
/// directory this call may fill; `name` names the job's cgroup.
141
#[allow(clippy::too_many_arguments)]
1420×
pub fn run(
1430×
    home: &Home,
1440×
    sandbox: &Sandbox,
1450×
    job: &Job,
1460×
    secrets: &[SecretInput],
1470×
    workspace: &Path,
1480×
    scratch: &Path,
1490×
    timeout: Duration,
1500×
    sink: &mut dyn Sink,
1510×
    name: &str,
1520×
) -> Result<Outcome> {
1530×
    let cgroups = Cgroups::delegated().map_err(|e| anyhow::anyhow!("the syscall tier cannot run here: {e}"))?;
1540×
    let image = home.rootfs(&sandbox.rootfs);
1550×
    if !image.is_file() {
1560×
        bail!("rootfs {} is not on this runner (import it with `flow rootfs import`)", sandbox.rootfs);
1570×
    }
1580×
    let image = home.verified(&image, &sandbox.rootfs)?;
1590×
    let rootfs = unpacked(home, &sandbox.rootfs, &image)?;
1600×
    let environment = environment(sandbox, job);
161
1620×
    let mut mounts = Vec::new();
1630×
    for (i, m) in job.mounts.iter().enumerate() {
1640×
        let dir = match m.source {
1650×
            MountSource::Tmpfs => None,
166
            MountSource::Disk => {
1670×
                let d = disk_dir(scratch, job.mounts.iter().take(i).filter(|m| m.source == MountSource::Disk).count());
1680×
                std::fs::create_dir_all(&d)?;
1690×
                Some(d)
170
            }
171
        };
1720×
        mounts.push(MountSpec { target: m.target.clone(), size_mib: m.size_mib, dir });
173
    }
1740×
    let root = scratch.join("root");
1750×
    std::fs::create_dir_all(&root)?;
1760×
    let group = cgroups.job(name, sandbox.memory_mib, sandbox.cpus)?;
1770×
    let spec = Spec {
1780×
        rootfs,
1790×
        root,
1800×
        workspace: workspace.to_path_buf(),
1810×
        cgroup: group.dir.clone(),
1820×
        argv: job.exec.clone(),
1830×
        env: job.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
1840×
        mounts,
1850×
        loopback: sandbox.network == Network::Loopback,
1860×
        secrets: secrets.iter().map(|s| (s.path.clone(), s.value.clone())).collect(),
187
    };
1880×
    let spec_path = scratch.join("sandbox.json");
189
    {
190
        use std::io::Write;
191
        use std::os::unix::fs::OpenOptionsExt;
1920×
        let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&spec_path)?;
1930×
        f.write_all(&serde_json::to_vec(&spec)?)?;
194
    }
195
1960×
    let started = Instant::now();
1970×
    let mut child = std::process::Command::new("/proc/self/exe")
1980×
        .arg("__sandbox")
1990×
        .arg(&spec_path)
2000×
        .env_clear()
2010×
        .current_dir(scratch)
2020×
        .stdin(std::process::Stdio::null())
2030×
        .stdout(std::process::Stdio::piped())
2040×
        .stderr(std::process::Stdio::piped())
2050×
        .spawn()
2060×
        .context("start the sandbox")?;
2070×
    let (tx, rx) = mpsc::channel::<(bool, Vec<u8>)>();
2080×
    let out = child.stdout.take().unwrap();
2090×
    let err = child.stderr.take().unwrap();
2100×
    let tx2 = tx.clone();
2110×
    let readers = [std::thread::spawn(move || lines(out, true, tx)), std::thread::spawn(move || lines(err, false, tx2))];
2120×
    let mut failed_to_start = false;
2130×
    let mut timed_out = false;
2140×
    let mut cancelled = false;
2150×
    let deadline = started + timeout;
2160×
    let t = |s: &Instant| s.elapsed().as_millis() as u64;
2170×
    let stop = |child: &mut std::process::Child| {
2180×
        let _ = child.kill();
2190×
        group.kill();
2200×
    };
221
    // The sandbox helper's peak resident memory (oxygenflow#41): the job's
222
    // own processes are its children, so this is a floor, not the whole.
2230×
    let mut peak_memory_mib = None;
224
    loop {
2250×
        if let Some(m) = crate::facts::peak_rss_mib(child.id()) {
2260×
            peak_memory_mib = Some(m);
2270×
        }
2280×
        match rx.recv_timeout(Duration::from_millis(200)) {
2290×
            Ok((stdout, line)) => {
2300×
                let text = String::from_utf8_lossy(&line).into_owned();
2310×
                let rec = |stream: &str, text: &str| Record { t: t(&started), stream: stream.into(), text: text.into() };
2320×
                match text.strip_prefix(FAILED) {
2330×
                    Some(why) if !stdout => {
2340×
                        failed_to_start = true;
2350×
                        sink.record(rec("flow", &format!("the sandbox failed to start the job: {why}")))
236
                    }
2370×
                    _ => sink.record(rec(if stdout { "out" } else { "err" }, &text)),
238
                }
239
            }
2400×
            Err(mpsc::RecvTimeoutError::Timeout) => {}
2410×
            Err(mpsc::RecvTimeoutError::Disconnected) => break,
242
        }
2430×
        if !timed_out && !cancelled && sink.cancelled() {
2440×
            cancelled = true;
2450×
            stop(&mut child);
2460×
            sink.record(Record { t: t(&started), stream: "flow".into(), text: "stopped: the run was cancelled".into() });
2470×
        }
2480×
        if Instant::now() >= deadline && !timed_out && !cancelled {
2490×
            timed_out = true;
2500×
            stop(&mut child);
2510×
            sink.record(Record {
2520×
                t: t(&started),
2530×
                stream: "flow".into(),
2540×
                text: format!("stopped at the time limit of {} s", timeout.as_secs()),
2550×
            });
2560×
        }
257
    }
2580×
    let status = child.wait()?;
2590×
    for r in readers {
2600×
        let _ = r.join();
2610×
    }
2620×
    if group.out_of_memory() {
2630×
        sink.record(Record {
2640×
            t: t(&started),
2650×
            stream: "flow".into(),
2660×
            text: format!("the job ran out of memory: the sandbox allows {} MiB", sandbox.memory_mib),
2670×
        });
2680×
    }
2690×
    let exit_code = status.code();
2700×
    if exit_code.is_none() && !timed_out && !cancelled && !failed_to_start {
2710×
        sink.record(Record {
2720×
            t: t(&started),
2730×
            stream: "flow".into(),
2740×
            text: format!("the sandbox ended without the job's exit code ({status})"),
2750×
        });
2760×
    }
2770×
    drop(group);
2780×
    let _ = std::fs::remove_file(&spec_path);
279
    Ok(Outcome {
2800×
        exit_code: if timed_out || cancelled { None } else { exit_code },
2810×
        duration_ms: t(&started),
2820×
        environment,
2830×
        timed_out,
2840×
        cancelled,
2850×
        peak_memory_mib,
2860×
        pruned: Vec::new(),
287
    })
2880×
}
289
290
/// The image's tree, unpacked from its ext4 file once per hash with
291
/// debugfs (no mount rights needed). Files belong to the runner; set-id
292
/// bits and device nodes are dropped, which a sandboxed job cannot use
293
/// anyway.
2940×
fn unpacked(home: &Home, hash: &str, image: &Path) -> Result<PathBuf> {
2950×
    let trees = home.root.join("trees");
2960×
    let dir = trees.join(hash);
2970×
    if dir.is_dir() {
2980×
        return Ok(dir);
2990×
    }
3000×
    std::fs::create_dir_all(&trees)?;
3010×
    let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0);
3020×
    let tmp = trees.join(format!(".{hash}.{}.{nanos}", std::process::id()));
3030×
    std::fs::create_dir_all(&tmp)?;
3040×
    let out = std::process::Command::new("debugfs")
3050×
        .arg("-R")
3060×
        .arg(format!("rdump / \"{}\"", tmp.display()))
3070×
        .arg(image)
3080×
        .output()
3090×
        .context("run debugfs (e2fsprogs) to unpack the rootfs")?;
3100×
    let empty = std::fs::read_dir(&tmp).map(|mut d| d.next().is_none()).unwrap_or(true);
3110×
    if !out.status.success() || empty {
3120×
        let _ = crate::workspace::remove_tree(&tmp);
3130×
        bail!("unpacking rootfs {}: {}", &hash[..12.min(hash.len())], String::from_utf8_lossy(&out.stderr).trim());
3140×
    }
3150×
    match std::fs::rename(&tmp, &dir) {
3160×
        Ok(()) => Ok(dir),
317
        // Another job unpacked it first.
3180×
        Err(_) if dir.is_dir() => {
3190×
            let _ = crate::workspace::remove_tree(&tmp);
3200×
            Ok(dir)
321
        }
3220×
        Err(e) => Err(e).with_context(|| format!("keep the unpacked rootfs at {}", dir.display())),
323
    }
3240×
}
325
326
// ---- cgroups ----
327
328
/// The runner's own cgroup v2 group, when it may manage it (a systemd
329
/// service with `Delegate=yes`, or a user service). The runner moves
330
/// itself into a `flow-main` leaf so the group can hold job groups with
331
/// the cpu and memory controllers on.
332
#[derive(Clone)]
333
pub struct Cgroups {
334
    base: PathBuf,
335
}
336
337
/// The leaf the runner process moves itself into.
338
const MAIN_LEAF: &str = "flow-main";
339
340
impl Cgroups {
3413×
    pub fn delegated() -> std::result::Result<Cgroups, String> {
342
        static ONCE: OnceLock<std::result::Result<Cgroups, String>> = OnceLock::new();
3433×
        ONCE.get_or_init(Self::set_up).clone()
3443×
    }
345
3463×
    fn set_up() -> std::result::Result<Cgroups, String> {
3473×
        let own = std::fs::read_to_string("/proc/self/cgroup").map_err(|e| format!("read /proc/self/cgroup: {e}"))?;
3483×
        let rel = own
3493×
            .lines()
3503×
            .find_map(|l| l.strip_prefix("0::"))
3513×
            .ok_or("this machine does not use cgroup v2 (the unified hierarchy)")?
3520×
            .trim()
3530×
            .to_string();
3540×
        let mut base = Path::new("/sys/fs/cgroup").join(rel.trim_start_matches('/'));
3550×
        if base.file_name().is_some_and(|n| n == MAIN_LEAF) {
3560×
            base.pop();
3570×
        }
3580×
        let controllers = std::fs::read_to_string(base.join("cgroup.controllers")).unwrap_or_default();
3590×
        let has = |c: &str| controllers.split_whitespace().any(|x| x == c);
3600×
        let delegate_hint = "run the runner as a systemd service with Delegate=yes, or as a user service; for `flow run`, use `systemd-run --user --scope -p Delegate=yes flow run …`";
3610×
        if !has("cpu") || !has("memory") {
3620×
            return Err(format!("the cgroup {} has no cpu and memory controllers to give jobs; {delegate_hint}", base.display()));
3630×
        }
3640×
        let writable =
3650×
            |p: &Path| CString::new(p.as_os_str().as_encoded_bytes()).is_ok_and(|c| unsafe { libc::access(c.as_ptr(), libc::W_OK) } == 0);
3660×
        if !writable(&base) || !writable(&base.join("cgroup.subtree_control")) || !writable(&base.join("cgroup.procs")) {
3670×
            return Err(format!("this runner may not manage its cgroup {}; {delegate_hint}", base.display()));
3680×
        }
369
        // Only this process may be in the group: anything else would block
370
        // the controllers, and nothing is changed for a shared group.
3710×
        let me = std::process::id().to_string();
3720×
        let others = std::fs::read_to_string(base.join("cgroup.procs")).unwrap_or_default();
3730×
        if others.lines().any(|p| p.trim() != me) {
3740×
            return Err(format!("the cgroup {} holds other processes; {delegate_hint}", base.display()));
3750×
        }
3760×
        let leaf = base.join(MAIN_LEAF);
3770×
        if !leaf.is_dir() {
3780×
            std::fs::create_dir(&leaf).map_err(|e| format!("make {}: {e}", leaf.display()))?;
3790×
        }
3800×
        std::fs::write(leaf.join("cgroup.procs"), "0").map_err(|e| format!("move into {}: {e}", leaf.display()))?;
3810×
        let want: Vec<&str> = ["cpu", "memory", "pids"].into_iter().filter(|c| has(c)).collect();
3820×
        let enable = want.iter().map(|c| format!("+{c}")).collect::<Vec<_>>().join(" ");
3830×
        std::fs::write(base.join("cgroup.subtree_control"), &enable)
3840×
            .map_err(|e| format!("enable {enable} in {}: {e} (another process may still be in it; {delegate_hint})", base.display()))?;
3850×
        Ok(Cgroups { base })
3863×
    }
387
388
    /// A fresh group for one job, with its limits set.
3890×
    pub fn job(&self, name: &str, memory_mib: u32, cpus: u32) -> Result<JobGroup> {
3900×
        let dir = self.base.join(name);
3910×
        if dir.is_dir() {
3920×
            // Left by a runner that stopped mid-job.
3930×
            drop(JobGroup { dir: dir.clone() });
3940×
        }
3950×
        std::fs::create_dir(&dir).with_context(|| format!("make the job's cgroup {}", dir.display()))?;
3960×
        let group = JobGroup { dir };
3970×
        let set = |file: &str, value: String| {
3980×
            std::fs::write(group.dir.join(file), &value).with_context(|| format!("set {file} to {value} in {}", group.dir.display()))
3990×
        };
4000×
        set("memory.max", (u64::from(memory_mib) << 20).to_string())?;
4010×
        if group.dir.join("memory.swap.max").exists() {
4020×
            set("memory.swap.max", "0".into())?;
4030×
        }
4040×
        set("cpu.max", format!("{} 100000", u64::from(cpus.max(1)) * 100_000))?;
4050×
        Ok(group)
4060×
    }
407
}
408
409
/// One job's cgroup; dropping it kills what is left in it and removes it.
410
pub struct JobGroup {
411
    dir: PathBuf,
412
}
413
414
impl JobGroup {
4150×
    fn kill(&self) {
4160×
        let _ = std::fs::write(self.dir.join("cgroup.kill"), "1");
4170×
    }
418
4190×
    fn out_of_memory(&self) -> bool {
4200×
        std::fs::read_to_string(self.dir.join("memory.events"))
4210×
            .ok()
4220×
            .and_then(|s| s.lines().find_map(|l| l.strip_prefix("oom_kill ")).and_then(|n| n.trim().parse::<u64>().ok()))
4230×
            .is_some_and(|n| n > 0)
4240×
    }
425
}
426
427
impl Drop for JobGroup {
4280×
    fn drop(&mut self) {
4290×
        self.kill();
4300×
        for _ in 0..100 {
4310×
            if std::fs::remove_dir(&self.dir).is_ok() || !self.dir.exists() {
4320×
                return;
4330×
            }
4340×
            std::thread::sleep(Duration::from_millis(50));
435
        }
4360×
        eprintln!("flow: could not remove the cgroup {}", self.dir.display());
4370×
    }
438
}
439
440
// ---- the helper: `flow __sandbox <spec>` ----
441
442
/// The helper's entry point; returns the job's exit code (125 when the
443
/// sandbox could not start it, after a `FAILED` line on stderr).
4440×
pub fn sandbox_main(spec_path: &Path) -> i32 {
4450×
    match helper(spec_path) {
4460×
        Ok(code) => code,
4470×
        Err(e) => {
4480×
            eprintln!("{FAILED}{e}");
4490×
            125
450
        }
451
    }
4520×
}
453
4540×
fn helper(spec_path: &Path) -> std::result::Result<i32, String> {
4550×
    let spec: Spec = serde_json::from_slice(&std::fs::read(spec_path).map_err(|e| format!("read the spec: {e}"))?)
4560×
        .map_err(|e| format!("parse the spec: {e}"))?;
4570×
    let _ = std::fs::remove_file(spec_path);
4580×
    if spec.argv.is_empty() {
4590×
        return Err("empty argv".into());
4600×
    }
4610×
    std::fs::write(spec.cgroup.join("cgroup.procs"), "0").map_err(|e| format!("join the job's cgroup: {e}"))?;
4620×
    let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
4630×
    enter_namespaces(
4640×
        libc::CLONE_NEWUSER | libc::CLONE_NEWNS | libc::CLONE_NEWPID | libc::CLONE_NEWNET | libc::CLONE_NEWIPC | libc::CLONE_NEWUTS,
4650×
        uid,
4660×
        gid,
4670×
    )?;
468
    // The first child is PID 1 of the new PID namespace; everything in it
469
    // dies with it, and it dies with this helper.
4700×
    let pid = unsafe { libc::fork() };
4710×
    if pid < 0 {
4720×
        return Err(format!("fork: {}", std::io::Error::last_os_error()));
4730×
    }
4740×
    if pid == 0 {
4750×
        let code = match init(&spec) {
4760×
            Ok(code) => code,
4770×
            Err(e) => {
4780×
                eprintln!("{FAILED}{e}");
4790×
                125
480
            }
481
        };
4820×
        unsafe { libc::_exit(code) };
4830×
    }
4840×
    Ok(wait_for(pid))
4850×
}
486
487
/// Wait for `pid`, reaping any other child on the way: its exit code, or
488
/// 128 + the signal that killed it.
4890×
fn wait_for(pid: libc::pid_t) -> i32 {
4900×
    let mut status = 0;
491
    loop {
4920×
        let r = unsafe { libc::waitpid(-1, &mut status, 0) };
4930×
        if r == pid {
4940×
            break;
4950×
        }
4960×
        if r < 0 && std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) {
4970×
            return 125;
4980×
        }
499
    }
5000×
    if libc::WIFEXITED(status) {
5010×
        libc::WEXITSTATUS(status)
502
    } else {
5030×
        128 + libc::WTERMSIG(status)
504
    }
5050×
}
506
507
/// New namespaces of `flags` (which include a user namespace) where
508
/// [`JOB_ID`] is the caller's `uid` and `gid`.
5090×
fn enter_namespaces(flags: libc::c_int, uid: u32, gid: u32) -> std::result::Result<(), String> {
5100×
    if unsafe { libc::unshare(flags) } != 0 {
5110×
        return Err(format!("unshare: {}", std::io::Error::last_os_error()));
5120×
    }
5130×
    let write = |file: &str, body: String| std::fs::write(file, body).map_err(|e| format!("write {file}: {e}"));
5140×
    write("/proc/self/setgroups", "deny".into())?;
5150×
    write("/proc/self/uid_map", format!("{JOB_ID} {uid} 1\n"))?;
5160×
    write("/proc/self/gid_map", format!("{JOB_ID} {gid} 1\n"))
5170×
}
518
519
/// PID 1 of the sandbox: build the root, enter it, run the job, wait.
5200×
fn init(spec: &Spec) -> std::result::Result<i32, String> {
521
    // Dies with the helper; the job's cgroup is killed at the end as well.
5220×
    unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) };
5230×
    let nosuid = libc::MS_NOSUID | libc::MS_NODEV;
5240×
    mount("none", "/", "", libc::MS_REC | libc::MS_PRIVATE, "")?;
5250×
    let root = spec.root.to_str().ok_or("the root path is not UTF-8")?;
5260×
    let at = |p: &str| format!("{root}{p}");
5270×
    bind(spec.rootfs.to_str().ok_or("the rootfs path is not UTF-8")?, root)?;
5280×
    for d in ["/src", "/tmp", "/proc", "/dev"] {
5290×
        if !Path::new(&at(d)).is_dir() {
5300×
            return Err(format!("the image has no {d} directory"));
5310×
        }
532
    }
5330×
    remount_bind(root, libc::MS_RDONLY | nosuid)?;
5340×
    bind(spec.workspace.to_str().ok_or("the workspace path is not UTF-8")?, &at("/src"))?;
5350×
    mount("tmpfs", &at("/tmp"), "tmpfs", nosuid, "mode=1777")?;
5360×
    mount("proc", &at("/proc"), "proc", nosuid | libc::MS_NOEXEC, "")?;
537
    // A /dev of the harmless devices, bound from the host's.
5380×
    mount("tmpfs", &at("/dev"), "tmpfs", libc::MS_NOSUID | libc::MS_NOEXEC, "mode=755")?;
5390×
    for d in ["null", "zero", "full", "random", "urandom"] {
5400×
        let target = at(&format!("/dev/{d}"));
5410×
        std::fs::File::create(&target).map_err(|e| format!("make {target}: {e}"))?;
5420×
        bind(&format!("/dev/{d}"), &target)?;
543
    }
5440×
    dev_links(&at("/dev"))?;
5450×
    std::fs::create_dir(at("/dev/shm")).map_err(|e| format!("make /dev/shm: {e}"))?;
5460×
    mount("tmpfs", &at("/dev/shm"), "tmpfs", nosuid, "mode=1777")?;
5470×
    if Path::new(&at("/run")).is_dir() {
5480×
        mount("tmpfs", &at("/run"), "tmpfs", nosuid, "mode=755")?;
5490×
    }
550
    // Ephemeral mounts: made under the writable /src or /tmp.
5510×
    for m in &spec.mounts {
5520×
        let target = at(&m.target);
5530×
        std::fs::create_dir_all(&target).map_err(|e| format!("make {}: {e}", m.target))?;
5540×
        match &m.dir {
5550×
            Some(dir) => bind(dir.to_str().ok_or("a mount path is not UTF-8")?, &target)?,
5560×
            None => mount("tmpfs", &target, "tmpfs", nosuid, &format!("size={}m,mode=755", m.size_mib))?,
557
        }
558
    }
559
    // Secrets: an in-memory mount of their own, each file 0400.
5600×
    if !spec.secrets.is_empty() {
5610×
        if !Path::new(&at("/run")).is_dir() {
5620×
            return Err("the image has no /run, where secrets are mounted".into());
5630×
        }
5640×
        let dir = at("/run/secrets");
5650×
        std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {dir}: {e}"))?;
5660×
        mount("tmpfs", &dir, "tmpfs", nosuid | libc::MS_NOEXEC, "size=1m,mode=700")?;
5670×
        for (path, value) in &spec.secrets {
5680×
            let Some(rel) = path.strip_prefix("/run/secrets/") else {
5690×
                return Err(format!("secret path {path:?} is not under /run/secrets/"));
570
            };
5710×
            let full = format!("{dir}/{rel}");
5720×
            if let Some(parent) = Path::new(&full).parent() {
5730×
                std::fs::create_dir_all(parent).map_err(|e| format!("mkdir for {path}: {e}"))?;
5740×
            }
5750×
            std::fs::write(&full, value.as_bytes()).map_err(|e| format!("write {path}: {e}"))?;
5760×
            std::fs::set_permissions(&full, std::os::unix::fs::PermissionsExt::from_mode(0o400))
5770×
                .map_err(|e| format!("chmod {path}: {e}"))?;
578
        }
5790×
    }
5800×
    let host = b"flow";
5810×
    unsafe { libc::sethostname(host.as_ptr() as *const libc::c_char, host.len()) };
5820×
    if spec.loopback {
5830×
        loopback_up()?;
5840×
    }
585
    // Into the new root; the old one is detached, so the host's files, the
586
    // spec and the scratch directory are out of reach.
5870×
    std::env::set_current_dir(root).map_err(|e| format!("cd {root}: {e}"))?;
5880×
    let dot = c".";
5890×
    if unsafe { libc::syscall(libc::SYS_pivot_root, dot.as_ptr(), dot.as_ptr()) } != 0 {
5900×
        return Err(format!("pivot_root: {}", std::io::Error::last_os_error()));
5910×
    }
5920×
    if unsafe { libc::umount2(dot.as_ptr(), libc::MNT_DETACH) } != 0 {
5930×
        return Err(format!("detach the old root: {}", std::io::Error::last_os_error()));
5940×
    }
5950×
    std::env::set_current_dir("/src").map_err(|e| format!("cd /src: {e}"))?;
596
5970×
    let vars = job_env(std::fs::read_to_string("/etc/flow/env").ok().as_deref(), &spec.env);
5980×
    let path = vars.get("PATH").map(String::as_str).unwrap_or(PATH);
5990×
    let program =
6000×
        cstr(&resolve_program(&spec.argv[0], path).ok_or_else(|| format!("cannot run {:?}: not found in {path}", spec.argv[0]))?)?;
6010×
    let argv: Vec<CString> = spec.argv.iter().map(|a| cstr(a)).collect::<Result<_, _>>()?;
6020×
    let env: Vec<CString> = vars.iter().map(|(k, v)| cstr(&format!("{k}={v}"))).collect::<Result<_, _>>()?;
6030×
    let mut writable: Vec<String> = ["/src", "/tmp", "/dev"].into_iter().map(String::from).collect();
6040×
    writable.extend(spec.mounts.iter().map(|m| m.target.clone()));
6050×
    let pid = unsafe { libc::fork() };
6060×
    if pid < 0 {
6070×
        return Err(format!("fork: {}", std::io::Error::last_os_error()));
6080×
    }
6090×
    if pid == 0 {
610
        // The job: no new privileges, then the walls, then the program.
6110×
        let confined = (|| {
6120×
            unsafe {
6130×
                let null = libc::open(c"/dev/null".as_ptr(), libc::O_RDONLY);
6140×
                libc::dup2(null, 0);
6150×
                libc::setsid();
6160×
            }
6170×
            no_new_privs()?;
6180×
            landlock(&writable)?;
6190×
            seccomp(DENIED, families(spec.loopback))
620
        })();
621
        unsafe {
6220×
            if let Err(e) = confined {
6230×
                let msg = format!("{FAILED}{e}\n");
6240×
                libc::write(2, msg.as_ptr() as *const libc::c_void, msg.len());
6250×
                libc::_exit(125);
6260×
            }
6270×
            let mut a: Vec<*const libc::c_char> = argv.iter().map(|c| c.as_ptr()).collect();
6280×
            a.push(std::ptr::null());
6290×
            let mut e: Vec<*const libc::c_char> = env.iter().map(|c| c.as_ptr()).collect();
6300×
            e.push(std::ptr::null());
6310×
            libc::execve(program.as_ptr(), a.as_ptr(), e.as_ptr());
6320×
            let msg = format!("flow: cannot run {:?}: {}\n", spec.argv[0], std::io::Error::last_os_error());
6330×
            libc::write(2, msg.as_ptr() as *const libc::c_void, msg.len());
6340×
            libc::_exit(127);
635
        }
6360×
    }
6370×
    Ok(wait_for(pid))
6380×
}
639
6400×
fn cstr(s: &str) -> std::result::Result<CString, String> {
6410×
    CString::new(s).map_err(|_| format!("{s:?} contains NUL"))
6420×
}
643
6440×
fn mount(src: &str, target: &str, fstype: &str, flags: libc::c_ulong, data: &str) -> std::result::Result<(), String> {
6450×
    let (s, t, f, d) = (cstr(src)?, cstr(target)?, cstr(fstype)?, cstr(data)?);
6460×
    let fstype = if fstype.is_empty() { std::ptr::null() } else { f.as_ptr() };
6470×
    let data = if data.is_empty() { std::ptr::null() } else { d.as_ptr() as *const libc::c_void };
6480×
    if unsafe { libc::mount(s.as_ptr(), t.as_ptr(), fstype, flags, data) } != 0 {
6490×
        return Err(format!("mount {src} on {target}: {}", std::io::Error::last_os_error()));
6500×
    }
6510×
    Ok(())
6520×
}
653
6540×
fn bind(src: &str, target: &str) -> std::result::Result<(), String> {
6550×
    mount(src, target, "", libc::MS_BIND | libc::MS_REC, "")
6560×
}
657
658
/// Remount a bind mount with `flags` added. In a user namespace the
659
/// flags the mount already had are locked, so they are kept.
6600×
fn remount_bind(target: &str, flags: libc::c_ulong) -> std::result::Result<(), String> {
6610×
    let t = cstr(target)?;
6620×
    let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
6630×
    if unsafe { libc::statvfs(t.as_ptr(), &mut st) } != 0 {
6640×
        return Err(format!("statvfs {target}: {}", std::io::Error::last_os_error()));
6650×
    }
6660×
    let kept = [
6670×
        (libc::ST_NOSUID, libc::MS_NOSUID),
6680×
        (libc::ST_NODEV, libc::MS_NODEV),
6690×
        (libc::ST_NOEXEC, libc::MS_NOEXEC),
6700×
        (libc::ST_NOATIME, libc::MS_NOATIME),
6710×
        (libc::ST_NODIRATIME, libc::MS_NODIRATIME),
6720×
        (libc::ST_RELATIME, libc::MS_RELATIME),
6730×
    ]
6740×
    .into_iter()
6750×
    .filter(|(st_flag, _)| st.f_flag & *st_flag != 0)
6760×
    .fold(0, |acc, (_, ms)| acc | ms);
6770×
    mount("none", target, "", libc::MS_BIND | libc::MS_REMOUNT | flags | kept, "")
6780×
}
679
6800×
fn no_new_privs() -> std::result::Result<(), String> {
6810×
    if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
6820×
        return Err(format!("no_new_privs: {}", std::io::Error::last_os_error()));
6830×
    }
6840×
    Ok(())
6850×
}
686
687
// ---- Landlock ----
688
689
const LANDLOCK_CREATE_RULESET_VERSION: libc::c_uint = 1;
690
const LANDLOCK_RULE_PATH_BENEATH: libc::c_int = 1;
691
const ACCESS_EXECUTE: u64 = 1;
692
const ACCESS_READ_FILE: u64 = 1 << 2;
693
const ACCESS_READ_DIR: u64 = 1 << 3;
694
/// Every right of Landlock ABI 1 (execute .. make_sym).
695
const ACCESS_ABI1: u64 = (1 << 13) - 1;
696
const ACCESS_REFER: u64 = 1 << 13;
697
const ACCESS_TRUNCATE: u64 = 1 << 14;
698
699
#[repr(C)]
700
struct RulesetAttr {
701
    handled_access_fs: u64,
702
}
703
704
#[repr(C, packed)]
705
struct PathBeneath {
706
    allowed_access: u64,
707
    parent_fd: i32,
708
}
709
710
/// The kernel's Landlock ABI version; below 1 when there is none.
7110×
fn landlock_abi() -> i64 {
7120×
    unsafe { libc::syscall(libc::SYS_landlock_create_ruleset, std::ptr::null::<RulesetAttr>(), 0usize, LANDLOCK_CREATE_RULESET_VERSION) }
7130×
}
714
715
/// Read and execute everywhere; write only beneath `writable`.
7160×
fn landlock(writable: &[String]) -> std::result::Result<(), String> {
7170×
    let abi = landlock_abi();
7180×
    if abi < 1 {
7190×
        return Err("Landlock is not available".into());
7200×
    }
7210×
    let mut all = ACCESS_ABI1;
7220×
    if abi >= 2 {
7230×
        all |= ACCESS_REFER;
7240×
    }
7250×
    if abi >= 3 {
7260×
        all |= ACCESS_TRUNCATE;
7270×
    }
7280×
    let attr = RulesetAttr { handled_access_fs: all };
7290×
    let ruleset = unsafe { libc::syscall(libc::SYS_landlock_create_ruleset, &attr, std::mem::size_of::<RulesetAttr>(), 0) };
7300×
    if ruleset < 0 {
7310×
        return Err(format!("landlock ruleset: {}", std::io::Error::last_os_error()));
7320×
    }
7330×
    let ruleset = ruleset as libc::c_int;
7340×
    let rule = |path: &str, access: u64| -> std::result::Result<(), String> {
7350×
        let p = cstr(path)?;
7360×
        let fd = unsafe { libc::open(p.as_ptr(), libc::O_PATH | libc::O_CLOEXEC) };
7370×
        if fd < 0 {
7380×
            return Err(format!("open {path} for Landlock: {}", std::io::Error::last_os_error()));
7390×
        }
7400×
        let beneath = PathBeneath { allowed_access: access, parent_fd: fd };
7410×
        let rc = unsafe { libc::syscall(libc::SYS_landlock_add_rule, ruleset, LANDLOCK_RULE_PATH_BENEATH, &beneath, 0) };
7420×
        unsafe { libc::close(fd) };
7430×
        if rc != 0 {
7440×
            return Err(format!("landlock rule for {path}: {}", std::io::Error::last_os_error()));
7450×
        }
7460×
        Ok(())
7470×
    };
7480×
    rule("/", ACCESS_EXECUTE | ACCESS_READ_FILE | ACCESS_READ_DIR)?;
7490×
    for w in writable {
7500×
        rule(w, all)?;
751
    }
7520×
    if unsafe { libc::syscall(libc::SYS_landlock_restrict_self, ruleset, 0) } != 0 {
7530×
        return Err(format!("landlock restrict: {}", std::io::Error::last_os_error()));
7540×
    }
7550×
    unsafe { libc::close(ruleset) };
7560×
    Ok(())
7570×
}
758
759
// ---- seccomp ----
760
761
#[cfg(target_arch = "x86_64")]
762
const AUDIT_ARCH: u32 = 0xC000_003E;
763
#[cfg(target_arch = "aarch64")]
764
const AUDIT_ARCH: u32 = 0xC000_00B7;
765
#[cfg(target_arch = "riscv64")]
766
const AUDIT_ARCH: u32 = 0xC000_00F3;
767
768
/// Calls a job never makes: they reach past the sandbox (tracing, mounts,
769
/// kernel programs and keys, modules, kexec, swap, reboot) or around the
770
/// socket check (io_uring).
771
const DENIED: &[libc::c_long] = &[
772
    libc::SYS_ptrace,
773
    libc::SYS_process_vm_readv,
774
    libc::SYS_process_vm_writev,
775
    libc::SYS_mount,
776
    libc::SYS_umount2,
777
    libc::SYS_pivot_root,
778
    libc::SYS_open_tree,
779
    libc::SYS_move_mount,
780
    libc::SYS_fsopen,
781
    libc::SYS_fsconfig,
782
    libc::SYS_fsmount,
783
    libc::SYS_fspick,
784
    libc::SYS_bpf,
785
    libc::SYS_perf_event_open,
786
    libc::SYS_keyctl,
787
    libc::SYS_add_key,
788
    libc::SYS_request_key,
789
    libc::SYS_init_module,
790
    libc::SYS_finit_module,
791
    libc::SYS_delete_module,
792
    libc::SYS_kexec_load,
793
    libc::SYS_swapon,
794
    libc::SYS_swapoff,
795
    libc::SYS_reboot,
796
    libc::SYS_io_uring_setup,
797
    libc::SYS_io_uring_enter,
798
    libc::SYS_io_uring_register,
799
];
800
801
/// Classic BPF opcodes (linux/bpf_common.h), fixed by the kernel's ABI;
802
/// spelled out because libc does not export them for every target
803
/// (Android, where the runner is built for the WASM tier only).
804
const BPF_LD: u16 = 0x00;
805
const BPF_W: u16 = 0x00;
806
const BPF_ABS: u16 = 0x20;
807
const BPF_JMP: u16 = 0x05;
808
const BPF_JEQ: u16 = 0x10;
809
const BPF_JGE: u16 = 0x30;
810
const BPF_K: u16 = 0x00;
811
const BPF_RET: u16 = 0x06;
812
813
/// Socket families a job may open: `AF_UNIX` always; with `network
814
/// "loopback"` also IPv4 and IPv6 (oxygenflow#73), which in the job's own
815
/// network namespace reach only its `lo`.
8160×
fn families(loopback: bool) -> &'static [libc::c_int] {
8170×
    if loopback {
8180×
        &[libc::AF_UNIX, libc::AF_INET, libc::AF_INET6]
819
    } else {
8200×
        &[libc::AF_UNIX]
821
    }
8220×
}
823
824
/// Install a filter that fails each of `denied` with EPERM and `socket`
825
/// for a family not in `allowed` with EAFNOSUPPORT (an empty `denied`
826
/// still checks sockets). Another architecture's calls kill the process.
8270×
fn seccomp(denied: &[libc::c_long], allowed: &[libc::c_int]) -> std::result::Result<(), String> {
828
    const LD: u16 = BPF_LD | BPF_W | BPF_ABS;
829
    const JEQ: u16 = BPF_JMP | BPF_JEQ | BPF_K;
830
    const RET: u16 = BPF_RET | BPF_K;
8310×
    let op = |code: u16, k: u32, jt: u8, jf: u8| libc::sock_filter { code, jt, jf, k };
8320×
    let errno = |e: libc::c_int| libc::SECCOMP_RET_ERRNO | (e as u32 & libc::SECCOMP_RET_DATA);
833
    // struct seccomp_data: nr at 0, arch at 4, args[0] at 16 (its low
834
    // half on these little-endian machines).
8350×
    let mut f = vec![op(LD, 4, 0, 0), op(JEQ, AUDIT_ARCH, 1, 0), op(RET, libc::SECCOMP_RET_KILL_PROCESS, 0, 0), op(LD, 0, 0, 0)];
836
    #[cfg(target_arch = "x86_64")]
837
    {
838
        // x32 calls carry this bit; there are none in a 64-bit job.
839
        const JGE: u16 = BPF_JMP | BPF_JGE | BPF_K;
8400×
        f.push(op(JGE, 0x4000_0000, 0, 1));
8410×
        f.push(op(RET, errno(libc::EPERM), 0, 0));
842
    }
8430×
    for nr in denied {
8440×
        f.push(op(JEQ, *nr as u32, 0, 1));
8450×
        f.push(op(RET, errno(libc::EPERM), 0, 0));
8460×
    }
847
    // socket(): past the family checks and the refusal to the allow when
848
    // it is another call; each allowed family jumps over the rest to it.
8490×
    let n = allowed.len() as u8;
8500×
    f.push(op(JEQ, libc::SYS_socket as u32, 0, n + 2));
8510×
    f.push(op(LD, 16, 0, 0));
8520×
    for (i, fam) in allowed.iter().enumerate() {
8530×
        f.push(op(JEQ, *fam as u32, n - i as u8, 0));
8540×
    }
8550×
    f.push(op(RET, errno(libc::EAFNOSUPPORT), 0, 0));
8560×
    f.push(op(RET, libc::SECCOMP_RET_ALLOW, 0, 0));
8570×
    let prog = libc::sock_fprog { len: f.len() as u16, filter: f.as_mut_ptr() };
8580×
    if unsafe { libc::syscall(libc::SYS_seccomp, libc::SECCOMP_SET_MODE_FILTER, 0, &prog) } != 0 {
8590×
        return Err(format!("seccomp: {}", std::io::Error::last_os_error()));
8600×
    }
8610×
    Ok(())
8620×
}
863
864
#[cfg(all(test, not(target_os = "android")))]
865
mod tests {
866
    /// The opcodes written out above are the ABI's, as glibc's libc has them.
867
    #[test]
8681×
    fn bpf_opcodes_are_the_kernels() {
869
        use super::*;
8701×
        assert_eq!(
871
            [BPF_LD, BPF_W, BPF_ABS, BPF_JMP, BPF_JEQ, BPF_JGE, BPF_K, BPF_RET],
8721×
            [libc::BPF_LD, libc::BPF_W, libc::BPF_ABS, libc::BPF_JMP, libc::BPF_JEQ, libc::BPF_JGE, libc::BPF_K, libc::BPF_RET]
8738×
                .map(|v| v as u16)
874
        );
8751×
    }
876
}