OXYGENHUB

The syscall tier: jobs without KVM ea56c56eaf90

runtime "syscall" jobs run in user, mount, PID, network, IPC and UTS
namespaces on the image's tree (unpacked once per hash with debugfs,
read-only), with the workspace directory at /src. Landlock limits writes
to /src, /tmp and /dev; seccomp refuses sockets other than AF_UNIX and
ptrace, mounts, bpf, perf events, keys, modules and io_uring; a cgroup v2
group of the runner's delegated cgroup sets memory.max and cpu.max. The
runner offers the tier only where all of that works, and `flow tiers`
says which tiers a machine can run and why not. flow-init's job-starting
code is shared with the new helper. refs #36
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-05 00:26 UTC
Signature
✔ signed by Andy McCall
Hash
ea56c56eaf90763e11badbeb8f43fc79d955b6be6c0fa800e7b8f69935877c30
Depends on
280ef951d193 9e8f4a56630b a67b97e9a717 a72abeeedd78 bb643c252515 c3db750ed96e c4285535b03a c58efc6a74db c5b702e59c1f e0918b887850 e2c125610f43 eb5c4db673e5 fc10d9e80383
Depended on by
21a3ea4aab88 1215f05ff8d0 494a1b9a8ac2 e6949d87205b 3554afefb6aa cc462b56fbbb 337afb8f6043 fa963b1b7eb6 5cf1e8aefbb6 28cc603f737d 077a65242359 2594278f49b2 46fe2c815cc3 ea81796bb757 de1d7926185f 82cf6e91d398 d60aac6c3f8a b14aeaae683a 8f211e20bea5 62829d15ef09 6d53fb7d00ec 9a6fac2b40a9 b11e30f5031d
In bookmarks
main newer-pipeline push-uses-login
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
37 atoms, 3 tree operations, 10 files

Browse the tree at this change

added crates/flow-init/src/job.rs +136 −0
1 +
//! What a job's sandbox needs from inside, in either tier: its
2 +
//! environment, its program found in its own `PATH`, the usual `/dev`
3 +
//! links and `lo`. Compiled into `flow-init` (tier 2, the microVM's PID 1)
4 +
//! and into `flow` itself (tier 1, the syscall sandbox), so both start a
5 +
//! job the same way.
6 +
7 +
pub const PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
8 +
9 +
/// The file `program` names: itself when it holds a `/`, otherwise the first
10 +
/// executable regular file of that name in `path`'s directories.
11 +
pub fn resolve_program(program: &str, path: &str) -> Option<String> {
12 +
    use std::os::unix::fs::PermissionsExt;
13 +
    if program.is_empty() {
14 +
        return None;
15 +
    }
16 +
    if program.contains('/') {
17 +
        return Some(program.to_string());
18 +
    }
19 +
    path.split(':')
20 +
        .filter(|d| !d.is_empty())
21 +
        .map(|d| format!("{d}/{program}"))
22 +
        .find(|p| std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0))
23 +
}
24 +
25 +
/// A job's environment: `PATH` and `HOME=/tmp` by default, then the
26 +
/// image's own (`/etc/flow/env`, written by `flow rootfs import --env`),
27 +
/// then the job's (its sandbox's and its own, already merged by the host).
28 +
/// Later ones win, so a pipeline can always set `PATH`.
29 +
pub fn job_env(image: Option<&str>, job: &[(String, String)]) -> std::collections::BTreeMap<String, String> {
30 +
    let mut env = std::collections::BTreeMap::new();
31 +
    env.insert("PATH".to_string(), PATH.to_string());
32 +
    env.insert("HOME".to_string(), "/tmp".to_string());
33 +
    for line in image.unwrap_or("").lines() {
34 +
        if let Some((k, v)) = line.split_once('=') {
35 +
            if !k.is_empty() {
36 +
                env.insert(k.to_string(), v.to_string());
37 +
            }
38 +
        }
39 +
    }
40 +
    for (k, v) in job {
41 +
        env.insert(k.clone(), v.clone());
42 +
    }
43 +
    env
44 +
}
45 +
46 +
/// The links every Linux system has under /dev and devtmpfs does not
47 +
/// make: `/dev/fd` (bash's process substitution, `<(…)`) and the standard
48 +
/// streams, all through the job's own /proc.
49 +
pub fn dev_links(dev: &str) -> Result<(), String> {
50 +
    for (name, target) in DEV_LINKS {
51 +
        let link = format!("{dev}/{name}");
52 +
        if std::fs::symlink_metadata(&link).is_err() {
53 +
            std::os::unix::fs::symlink(target, &link).map_err(|e| format!("link {link}: {e}"))?;
54 +
        }
55 +
    }
56 +
    Ok(())
57 +
}
58 +
59 +
const DEV_LINKS: [(&str, &str); 4] =
60 +
    [("fd", "/proc/self/fd"), ("stdin", "/proc/self/fd/0"), ("stdout", "/proc/self/fd/1"), ("stderr", "/proc/self/fd/2")];
61 +
62 +
/// Set `lo` up; the kernel gives it 127.0.0.1 and ::1 itself.
63 +
pub fn loopback_up() -> Result<(), String> {
64 +
    let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) };
65 +
    if fd < 0 {
66 +
        return Err(format!("socket for lo: {}", std::io::Error::last_os_error()));
67 +
    }
68 +
    let mut req: libc::ifreq = unsafe { std::mem::zeroed() };
69 +
    for (i, b) in b"lo".iter().enumerate() {
70 +
        req.ifr_name[i] = *b as libc::c_char;
71 +
    }
72 +
    let ok = unsafe {
73 +
        libc::ioctl(fd, libc::SIOCGIFFLAGS, &mut req) == 0 && {
74 +
            req.ifr_ifru.ifru_flags |= libc::IFF_UP as libc::c_short;
75 +
            libc::ioctl(fd, libc::SIOCSIFFLAGS, &req) == 0
76 +
        }
77 +
    };
78 +
    let err = std::io::Error::last_os_error();
79 +
    unsafe { libc::close(fd) };
80 +
    if !ok {
81 +
        return Err(format!("bring up lo: {err}"));
82 +
    }
83 +
    Ok(())
84 +
}
85 +
86 +
#[cfg(test)]
87 +
mod tests {
88 +
    use super::{dev_links, job_env, resolve_program};
89 +
90 +
    #[test]
91 +
    fn the_image_environment_sits_under_the_jobs() {
92 +
        let image = "PATH=/opt/llvm-mos/bin:/usr/bin:/bin\nX16EMU=/opt/x16emu\nnot a line\n";
93 +
        let env = job_env(Some(image), &[("X16EMU".into(), "/job".into()), ("LANG".into(), "C".into())]);
94 +
        assert_eq!(env["PATH"], "/opt/llvm-mos/bin:/usr/bin:/bin", "the image's PATH replaces the default");
95 +
        assert_eq!(env["X16EMU"], "/job", "the job's value wins");
96 +
        assert_eq!(env["HOME"], "/tmp");
97 +
        assert_eq!(env["LANG"], "C");
98 +
        assert_eq!(job_env(None, &[])["PATH"], super::PATH);
99 +
    }
100 +
101 +
    #[test]
102 +
    fn dev_gets_the_usual_links_once() {
103 +
        let dir = std::env::temp_dir().join(format!("flow-init-dev-{}", std::process::id()));
104 +
        let _ = std::fs::remove_dir_all(&dir);
105 +
        std::fs::create_dir_all(&dir).unwrap();
106 +
        let d = dir.to_str().unwrap();
107 +
        dev_links(d).unwrap();
108 +
        dev_links(d).unwrap();
109 +
        assert_eq!(std::fs::read_link(dir.join("fd")).unwrap(), std::path::Path::new("/proc/self/fd"));
110 +
        assert_eq!(std::fs::read_link(dir.join("stdout")).unwrap(), std::path::Path::new("/proc/self/fd/1"));
111 +
        // Through the host's /proc, the link works as in a job.
112 +
        assert!(dir.join("fd").join("0").exists() || !std::path::Path::new("/proc/self/fd/0").exists());
113 +
        std::fs::remove_dir_all(&dir).unwrap();
114 +
    }
115 +
116 +
    use std::os::unix::fs::PermissionsExt;
117 +
118 +
    #[test]
119 +
    fn programs_are_found_in_the_jobs_path() {
120 +
        let dir = std::env::temp_dir().join(format!("flow-init-test-{}", std::process::id()));
121 +
        let (a, b) = (dir.join("a"), dir.join("b"));
122 +
        std::fs::create_dir_all(&a).unwrap();
123 +
        std::fs::create_dir_all(&b).unwrap();
124 +
        std::fs::write(a.join("tool"), "not executable").unwrap();
125 +
        std::fs::write(b.join("tool"), "#!/bin/sh\n").unwrap();
126 +
        std::fs::set_permissions(b.join("tool"), std::fs::Permissions::from_mode(0o755)).unwrap();
127 +
        std::fs::create_dir_all(a.join("dir")).unwrap();
128 +
        let path = format!("{}::{}", a.display(), b.display());
129 +
        assert_eq!(resolve_program("tool", &path), Some(format!("{}/tool", b.display())), "skips a file that is not executable");
130 +
        assert_eq!(resolve_program("dir", &path), None, "and directories");
131 +
        assert_eq!(resolve_program("missing", &path), None);
132 +
        assert_eq!(resolve_program("./x", &path).as_deref(), Some("./x"), "a path is used as given");
133 +
        assert_eq!(resolve_program("", &path), None);
134 +
        std::fs::remove_dir_all(&dir).unwrap();
135 +
    }
136 +
}
added crates/flow/src/syscall.rs +857 −0
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 the calls that
7 +
//! reach past the sandbox (`ptrace`, `mount`, `bpf`, `perf_event_open`,
8 +
//! keys, modules, io_uring); and the sandbox's CPU and memory limits apply
9 +
//! through a cgroup v2 group of the runner's own delegated cgroup. Without
10 +
//! that delegation the tier is not offered, so no job runs unbounded.
11 +
//!
12 +
//! The runner starts `flow __sandbox <spec>` (this same binary, through
13 +
//! `/proc/self/exe`) for each job. That helper joins the job's cgroup,
14 +
//! makes the namespaces and forks the sandbox's PID 1, which builds the new
15 +
//! root, pivots into it and runs the job as an unprivileged user, the way
16 +
//! `flow-init` does in a microVM. Records, receipts and artifacts are the
17 +
//! same as tier 2's.
18 +
19 +
use crate::guest::{dev_links, job_env, loopback_up, resolve_program, PATH};
20 +
use crate::home::Home;
21 +
use crate::microvm::{Outcome, Sink};
22 +
use anyhow::{bail, Context, Result};
23 +
use flow_core::api::{Record, SecretInput};
24 +
use flow_core::{Environment, Job, MountSource, Network, Sandbox};
25 +
use serde::{Deserialize, Serialize};
26 +
use std::ffi::CString;
27 +
use std::io::{BufRead, BufReader, Read};
28 +
use std::path::{Path, PathBuf};
29 +
use std::sync::{mpsc, OnceLock};
30 +
use std::time::{Duration, Instant};
31 +
32 +
/// The user and group a job runs as inside the sandbox; the runner's own
33 +
/// on the host.
34 +
const JOB_ID: u32 = 1000;
35 +
36 +
/// The receipt environment for a job in this tier: the sandbox is this
37 +
/// binary, so its hash stands where tier 2 has Firecracker's and
38 +
/// flow-init's, with the host kernel's release.
39 +
pub fn environment(sandbox: &Sandbox, job: &Job) -> Environment {
40 +
    Environment {
41 +
        runtime: "syscall".into(),
42 +
        parts: vec![
43 +
            ("flow".into(), self_hash().to_string()),
44 +
            ("kernel".into(), kernel_release()),
45 +
            ("memory_mib".into(), sandbox.memory_mib.to_string()),
46 +
            ("cpus".into(), sandbox.cpus.to_string()),
47 +
        ]
48 +
        .into_iter()
49 +
        .chain(job.mounts.iter().map(|m| (format!("mount {}", m.target), format!("{} {} MiB", m.source.as_str(), m.size_mib))))
50 +
        .collect(),
51 +
        rootfs: sandbox.rootfs.clone(),
52 +
        argv: job.exec.clone(),
53 +
        env: job.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
54 +
    }
55 +
}
56 +
57 +
fn self_hash() -> &'static str {
58 +
    static HASH: OnceLock<String> = OnceLock::new();
59 +
    HASH.get_or_init(|| crate::home::hash_file(Path::new("/proc/self/exe")).unwrap_or_else(|_| "unknown".into()))
60 +
}
61 +
62 +
fn kernel_release() -> String {
63 +
    std::fs::read_to_string("/proc/sys/kernel/osrelease").map(|s| s.trim().to_string()).unwrap_or_else(|_| "unknown".into())
64 +
}
65 +
66 +
/// Whether this machine can run the tier now: a cgroup this runner may
67 +
/// manage, and namespaces, Landlock and seccomp that work for this binary
68 +
/// (Ubuntu's AppArmor can deny unprivileged user namespaces to programs
69 +
/// without a profile). `Err` says what is missing.
70 +
pub fn available() -> std::result::Result<(), String> {
71 +
    Cgroups::delegated()?;
72 +
    let out = std::process::Command::new("/proc/self/exe")
73 +
        .arg("__sandbox-probe")
74 +
        .stdin(std::process::Stdio::null())
75 +
        .output()
76 +
        .map_err(|e| format!("cannot start the probe: {e}"))?;
77 +
    if out.status.success() {
78 +
        Ok(())
79 +
    } else {
80 +
        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
81 +
    }
82 +
}
83 +
84 +
/// `flow __sandbox-probe`: try each kernel feature the sandbox uses, in a
85 +
/// throwaway process, and say which one failed.
86 +
pub fn probe_main() -> i32 {
87 +
    let r = (|| -> std::result::Result<(), String> {
88 +
        let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
89 +
        enter_namespaces(libc::CLONE_NEWUSER | libc::CLONE_NEWNS, uid, gid)
90 +
            .map_err(|e| format!("user namespaces are not allowed for this program ({e}); on Ubuntu, kernel.apparmor_restrict_unprivileged_userns=1 denies them"))?;
91 +
        mount("none", "/", "", libc::MS_REC | libc::MS_PRIVATE, "")
92 +
            .and_then(|_| mount("tmpfs", "/tmp", "tmpfs", libc::MS_NOSUID | libc::MS_NODEV, ""))
93 +
            .map_err(|e| {
94 +
                format!(
95 +
                    "user namespaces give no mount rights here ({e}); on Ubuntu, kernel.apparmor_restrict_unprivileged_userns=1 does this"
96 +
                )
97 +
            })?;
98 +
        let abi = landlock_abi();
99 +
        if abi < 1 {
100 +
            return Err("Landlock is not enabled in this kernel (it must be in the lsm= list)".into());
101 +
        }
102 +
        no_new_privs()?;
103 +
        seccomp(&[]).map_err(|e| format!("seccomp filters are not available: {e}"))
104 +
    })();
105 +
    match r {
106 +
        Ok(()) => 0,
107 +
        Err(e) => {
108 +
            eprintln!("{e}");
109 +
            1
110 +
        }
111 +
    }
112 +
}
113 +
114 +
/// What the helper reads from the spec file the runner wrote (0600, in
115 +
/// the job's scratch directory, which the job never sees); it removes it
116 +
/// once read.
117 +
#[derive(Serialize, Deserialize)]
118 +
struct Spec {
119 +
    /// The image's unpacked tree.
120 +
    rootfs: PathBuf,
121 +
    /// An empty directory to build the new root on.
122 +
    root: PathBuf,
123 +
    workspace: PathBuf,
124 +
    /// The job's cgroup, joined before anything else.
125 +
    cgroup: PathBuf,
126 +
    argv: Vec<String>,
127 +
    env: Vec<(String, String)>,
128 +
    mounts: Vec<MountSpec>,
129 +
    loopback: bool,
130 +
    secrets: Vec<(String, String)>,
131 +
}
132 +
133 +
#[derive(Serialize, Deserialize)]
134 +
struct MountSpec {
135 +
    target: String,
136 +
    size_mib: u32,
137 +
    /// A host directory bound at `target` (`ephemeral://disk`); a tmpfs
138 +
    /// of `size_mib` when there is none.
139 +
    dir: Option<PathBuf>,
140 +
}
141 +
142 +
/// Run `job` with `workspace` (a directory) as `/src`. `scratch` is a
143 +
/// directory this call may fill; `name` names the job's cgroup.
144 +
#[allow(clippy::too_many_arguments)]
145 +
pub fn run(
146 +
    home: &Home,
147 +
    sandbox: &Sandbox,
148 +
    job: &Job,
149 +
    secrets: &[SecretInput],
150 +
    workspace: &Path,
151 +
    scratch: &Path,
152 +
    timeout: Duration,
153 +
    sink: &mut dyn Sink,
154 +
    name: &str,
155 +
) -> Result<Outcome> {
156 +
    let cgroups = Cgroups::delegated().map_err(|e| anyhow::anyhow!("the syscall tier cannot run here: {e}"))?;
157 +
    let image = home.rootfs(&sandbox.rootfs);
158 +
    if !image.is_file() {
159 +
        bail!("rootfs {} is not on this runner (import it with `flow rootfs import`)", sandbox.rootfs);
160 +
    }
161 +
    let image = home.verified(&image, &sandbox.rootfs)?;
162 +
    let rootfs = unpacked(home, &sandbox.rootfs, &image)?;
163 +
    let environment = environment(sandbox, job);
164 +
165 +
    let mut mounts = Vec::new();
166 +
    for (i, m) in job.mounts.iter().enumerate() {
167 +
        let dir = match m.source {
168 +
            MountSource::Tmpfs => None,
169 +
            MountSource::Disk => {
170 +
                let d = disk_dir(scratch, job.mounts.iter().take(i).filter(|m| m.source == MountSource::Disk).count());
171 +
                std::fs::create_dir_all(&d)?;
172 +
                Some(d)
173 +
            }
174 +
        };
175 +
        mounts.push(MountSpec { target: m.target.clone(), size_mib: m.size_mib, dir });
176 +
    }
177 +
    let root = scratch.join("root");
178 +
    std::fs::create_dir_all(&root)?;
179 +
    let group = cgroups.job(name, sandbox.memory_mib, sandbox.cpus)?;
180 +
    let spec = Spec {
181 +
        rootfs,
182 +
        root,
183 +
        workspace: workspace.to_path_buf(),
184 +
        cgroup: group.dir.clone(),
185 +
        argv: job.exec.clone(),
186 +
        env: job.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
187 +
        mounts,
188 +
        loopback: sandbox.network == Network::Loopback,
189 +
        secrets: secrets.iter().map(|s| (s.path.clone(), s.value.clone())).collect(),
190 +
    };
191 +
    let spec_path = scratch.join("sandbox.json");
192 +
    {
193 +
        use std::io::Write;
194 +
        use std::os::unix::fs::OpenOptionsExt;
195 +
        let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&spec_path)?;
196 +
        f.write_all(&serde_json::to_vec(&spec)?)?;
197 +
    }
198 +
199 +
    let started = Instant::now();
200 +
    let mut child = std::process::Command::new("/proc/self/exe")
201 +
        .arg("__sandbox")
202 +
        .arg(&spec_path)
203 +
        .env_clear()
204 +
        .current_dir(scratch)
205 +
        .stdin(std::process::Stdio::null())
206 +
        .stdout(std::process::Stdio::piped())
207 +
        .stderr(std::process::Stdio::piped())
208 +
        .spawn()
209 +
        .context("start the sandbox")?;
210 +
    let (tx, rx) = mpsc::channel::<(bool, Vec<u8>)>();
211 +
    let out = child.stdout.take().unwrap();
212 +
    let err = child.stderr.take().unwrap();
213 +
    let tx2 = tx.clone();
214 +
    let readers = [std::thread::spawn(move || lines(out, true, tx)), std::thread::spawn(move || lines(err, false, tx2))];
215 +
    let mut failed_to_start = false;
216 +
    let mut timed_out = false;
217 +
    let mut cancelled = false;
218 +
    let deadline = started + timeout;
219 +
    let t = |s: &Instant| s.elapsed().as_millis() as u64;
220 +
    let stop = |child: &mut std::process::Child| {
221 +
        let _ = child.kill();
222 +
        group.kill();
223 +
    };
224 +
    loop {
225 +
        match rx.recv_timeout(Duration::from_millis(200)) {
226 +
            Ok((stdout, line)) => {
227 +
                let text = String::from_utf8_lossy(&line).into_owned();
228 +
                let rec = |stream: &str, text: &str| Record { t: t(&started), stream: stream.into(), text: text.into() };
229 +
                match text.strip_prefix(FAILED) {
230 +
                    Some(why) if !stdout => {
231 +
                        failed_to_start = true;
232 +
                        sink.record(rec("flow", &format!("the sandbox failed to start the job: {why}")))
233 +
                    }
234 +
                    _ => sink.record(rec(if stdout { "out" } else { "err" }, &text)),
235 +
                }
236 +
            }
237 +
            Err(mpsc::RecvTimeoutError::Timeout) => {}
238 +
            Err(mpsc::RecvTimeoutError::Disconnected) => break,
239 +
        }
240 +
        if !timed_out && !cancelled && sink.cancelled() {
241 +
            cancelled = true;
242 +
            stop(&mut child);
243 +
            sink.record(Record { t: t(&started), stream: "flow".into(), text: "stopped: the run was cancelled".into() });
244 +
        }
245 +
        if Instant::now() >= deadline && !timed_out && !cancelled {
246 +
            timed_out = true;
247 +
            stop(&mut child);
248 +
            sink.record(Record {
249 +
                t: t(&started),
250 +
                stream: "flow".into(),
251 +
                text: format!("stopped at the time limit of {} s", timeout.as_secs()),
252 +
            });
253 +
        }
254 +
    }
255 +
    let status = child.wait()?;
256 +
    for r in readers {
257 +
        let _ = r.join();
258 +
    }
259 +
    if group.out_of_memory() {
260 +
        sink.record(Record {
261 +
            t: t(&started),
262 +
            stream: "flow".into(),
263 +
            text: format!("the job ran out of memory: the sandbox allows {} MiB", sandbox.memory_mib),
264 +
        });
265 +
    }
266 +
    let exit_code = status.code();
267 +
    if exit_code.is_none() && !timed_out && !cancelled && !failed_to_start {
268 +
        sink.record(Record {
269 +
            t: t(&started),
270 +
            stream: "flow".into(),
271 +
            text: format!("the sandbox ended without the job's exit code ({status})"),
272 +
        });
273 +
    }
274 +
    drop(group);
275 +
    let _ = std::fs::remove_file(&spec_path);
276 +
    Ok(Outcome {
277 +
        exit_code: if timed_out || cancelled { None } else { exit_code },
278 +
        duration_ms: t(&started),
279 +
        environment,
280 +
        timed_out,
281 +
        cancelled,
282 +
    })
283 +
}
284 +
285 +
/// Where the `n`th `ephemeral://disk` mount of a job keeps its files.
286 +
pub fn disk_dir(scratch: &Path, n: usize) -> PathBuf {
287 +
    scratch.join(format!("mount-{n}"))
288 +
}
289 +
290 +
/// How the helper says the job never started, on its stderr.
291 +
const FAILED: &str = "\u{1e}F ";
292 +
293 +
fn lines(r: impl Read, stdout: bool, tx: mpsc::Sender<(bool, Vec<u8>)>) {
294 +
    let mut r = BufReader::new(r);
295 +
    let mut buf = Vec::new();
296 +
    loop {
297 +
        buf.clear();
298 +
        // A long line is cut at 16 KiB, as flow-init cuts it.
299 +
        match (&mut r).take(16 * 1024).read_until(b'\n', &mut buf) {
300 +
            Ok(0) | Err(_) => break,
301 +
            Ok(_) => {
302 +
                if buf.last() == Some(&b'\n') {
303 +
                    buf.pop();
304 +
                }
305 +
                if tx.send((stdout, buf.clone())).is_err() {
306 +
                    break;
307 +
                }
308 +
            }
309 +
        }
310 +
    }
311 +
}
312 +
313 +
/// The image's tree, unpacked from its ext4 file once per hash with
314 +
/// debugfs (no mount rights needed). Files belong to the runner; set-id
315 +
/// bits and device nodes are dropped, which a sandboxed job cannot use
316 +
/// anyway.
317 +
fn unpacked(home: &Home, hash: &str, image: &Path) -> Result<PathBuf> {
318 +
    let trees = home.root.join("trees");
319 +
    let dir = trees.join(hash);
320 +
    if dir.is_dir() {
321 +
        return Ok(dir);
322 +
    }
323 +
    std::fs::create_dir_all(&trees)?;
324 +
    let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0);
325 +
    let tmp = trees.join(format!(".{hash}.{}.{nanos}", std::process::id()));
326 +
    std::fs::create_dir_all(&tmp)?;
327 +
    let out = std::process::Command::new("debugfs")
328 +
        .arg("-R")
329 +
        .arg(format!("rdump / \"{}\"", tmp.display()))
330 +
        .arg(image)
331 +
        .output()
332 +
        .context("run debugfs (e2fsprogs) to unpack the rootfs")?;
333 +
    let empty = std::fs::read_dir(&tmp).map(|mut d| d.next().is_none()).unwrap_or(true);
334 +
    if !out.status.success() || empty {
335 +
        let _ = crate::workspace::remove_tree(&tmp);
336 +
        bail!("unpacking rootfs {}: {}", &hash[..12.min(hash.len())], String::from_utf8_lossy(&out.stderr).trim());
337 +
    }
338 +
    match std::fs::rename(&tmp, &dir) {
339 +
        Ok(()) => Ok(dir),
340 +
        // Another job unpacked it first.
341 +
        Err(_) if dir.is_dir() => {
342 +
            let _ = crate::workspace::remove_tree(&tmp);
343 +
            Ok(dir)
344 +
        }
345 +
        Err(e) => Err(e).with_context(|| format!("keep the unpacked rootfs at {}", dir.display())),
346 +
    }
347 +
}
348 +
349 +
// ---- cgroups ----
350 +
351 +
/// The runner's own cgroup v2 group, when it may manage it (a systemd
352 +
/// service with `Delegate=yes`, or a user service). The runner moves
353 +
/// itself into a `flow-main` leaf so the group can hold job groups with
354 +
/// the cpu and memory controllers on.
355 +
#[derive(Clone)]
356 +
pub struct Cgroups {
357 +
    base: PathBuf,
358 +
}
359 +
360 +
/// The leaf the runner process moves itself into.
361 +
const MAIN_LEAF: &str = "flow-main";
362 +
363 +
impl Cgroups {
364 +
    pub fn delegated() -> std::result::Result<Cgroups, String> {
365 +
        static ONCE: OnceLock<std::result::Result<Cgroups, String>> = OnceLock::new();
366 +
        ONCE.get_or_init(Self::set_up).clone()
367 +
    }
368 +
369 +
    fn set_up() -> std::result::Result<Cgroups, String> {
370 +
        let own = std::fs::read_to_string("/proc/self/cgroup").map_err(|e| format!("read /proc/self/cgroup: {e}"))?;
371 +
        let rel = own
372 +
            .lines()
373 +
            .find_map(|l| l.strip_prefix("0::"))
374 +
            .ok_or("this machine does not use cgroup v2 (the unified hierarchy)")?
375 +
            .trim()
376 +
            .to_string();
377 +
        let mut base = Path::new("/sys/fs/cgroup").join(rel.trim_start_matches('/'));
378 +
        if base.file_name().is_some_and(|n| n == MAIN_LEAF) {
379 +
            base.pop();
380 +
        }
381 +
        let controllers = std::fs::read_to_string(base.join("cgroup.controllers")).unwrap_or_default();
382 +
        let has = |c: &str| controllers.split_whitespace().any(|x| x == c);
383 +
        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 …`";
384 +
        if !has("cpu") || !has("memory") {
385 +
            return Err(format!("the cgroup {} has no cpu and memory controllers to give jobs; {delegate_hint}", base.display()));
386 +
        }
387 +
        let writable =
388 +
            |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);
389 +
        if !writable(&base) || !writable(&base.join("cgroup.subtree_control")) || !writable(&base.join("cgroup.procs")) {
390 +
            return Err(format!("this runner may not manage its cgroup {}; {delegate_hint}", base.display()));
391 +
        }
392 +
        // Only this process may be in the group: anything else would block
393 +
        // the controllers, and nothing is changed for a shared group.
394 +
        let me = std::process::id().to_string();
395 +
        let others = std::fs::read_to_string(base.join("cgroup.procs")).unwrap_or_default();
396 +
        if others.lines().any(|p| p.trim() != me) {
397 +
            return Err(format!("the cgroup {} holds other processes; {delegate_hint}", base.display()));
398 +
        }
399 +
        let leaf = base.join(MAIN_LEAF);
400 +
        if !leaf.is_dir() {
401 +
            std::fs::create_dir(&leaf).map_err(|e| format!("make {}: {e}", leaf.display()))?;
402 +
        }
403 +
        std::fs::write(leaf.join("cgroup.procs"), "0").map_err(|e| format!("move into {}: {e}", leaf.display()))?;
404 +
        let want: Vec<&str> = ["cpu", "memory", "pids"].into_iter().filter(|c| has(c)).collect();
405 +
        let enable = want.iter().map(|c| format!("+{c}")).collect::<Vec<_>>().join(" ");
406 +
        std::fs::write(base.join("cgroup.subtree_control"), &enable)
407 +
            .map_err(|e| format!("enable {enable} in {}: {e} (another process may still be in it; {delegate_hint})", base.display()))?;
408 +
        Ok(Cgroups { base })
409 +
    }
410 +
411 +
    /// A fresh group for one job, with its limits set.
412 +
    pub fn job(&self, name: &str, memory_mib: u32, cpus: u32) -> Result<JobGroup> {
413 +
        let dir = self.base.join(name);
414 +
        if dir.is_dir() {
415 +
            // Left by a runner that stopped mid-job.
416 +
            drop(JobGroup { dir: dir.clone() });
417 +
        }
418 +
        std::fs::create_dir(&dir).with_context(|| format!("make the job's cgroup {}", dir.display()))?;
419 +
        let group = JobGroup { dir };
420 +
        let set = |file: &str, value: String| {
421 +
            std::fs::write(group.dir.join(file), &value).with_context(|| format!("set {file} to {value} in {}", group.dir.display()))
422 +
        };
423 +
        set("memory.max", (u64::from(memory_mib) << 20).to_string())?;
424 +
        if group.dir.join("memory.swap.max").exists() {
425 +
            set("memory.swap.max", "0".into())?;
426 +
        }
427 +
        set("cpu.max", format!("{} 100000", u64::from(cpus.max(1)) * 100_000))?;
428 +
        Ok(group)
429 +
    }
430 +
}
431 +
432 +
/// One job's cgroup; dropping it kills what is left in it and removes it.
433 +
pub struct JobGroup {
434 +
    dir: PathBuf,
435 +
}
436 +
437 +
impl JobGroup {
438 +
    fn kill(&self) {
439 +
        let _ = std::fs::write(self.dir.join("cgroup.kill"), "1");
440 +
    }
441 +
442 +
    fn out_of_memory(&self) -> bool {
443 +
        std::fs::read_to_string(self.dir.join("memory.events"))
444 +
            .ok()
445 +
            .and_then(|s| s.lines().find_map(|l| l.strip_prefix("oom_kill ")).and_then(|n| n.trim().parse::<u64>().ok()))
446 +
            .is_some_and(|n| n > 0)
447 +
    }
448 +
}
449 +
450 +
impl Drop for JobGroup {
451 +
    fn drop(&mut self) {
452 +
        self.kill();
453 +
        for _ in 0..100 {
454 +
            if std::fs::remove_dir(&self.dir).is_ok() || !self.dir.exists() {
455 +
                return;
456 +
            }
457 +
            std::thread::sleep(Duration::from_millis(50));
458 +
        }
459 +
        eprintln!("flow: could not remove the cgroup {}", self.dir.display());
460 +
    }
461 +
}
462 +
463 +
// ---- the helper: `flow __sandbox <spec>` ----
464 +
465 +
/// The helper's entry point; returns the job's exit code (125 when the
466 +
/// sandbox could not start it, after a `FAILED` line on stderr).
467 +
pub fn sandbox_main(spec_path: &Path) -> i32 {
468 +
    match helper(spec_path) {
469 +
        Ok(code) => code,
470 +
        Err(e) => {
471 +
            eprintln!("{FAILED}{e}");
472 +
            125
473 +
        }
474 +
    }
475 +
}
476 +
477 +
fn helper(spec_path: &Path) -> std::result::Result<i32, String> {
478 +
    let spec: Spec = serde_json::from_slice(&std::fs::read(spec_path).map_err(|e| format!("read the spec: {e}"))?)
479 +
        .map_err(|e| format!("parse the spec: {e}"))?;
480 +
    let _ = std::fs::remove_file(spec_path);
481 +
    if spec.argv.is_empty() {
482 +
        return Err("empty argv".into());
483 +
    }
484 +
    std::fs::write(spec.cgroup.join("cgroup.procs"), "0").map_err(|e| format!("join the job's cgroup: {e}"))?;
485 +
    let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
486 +
    enter_namespaces(
487 +
        libc::CLONE_NEWUSER | libc::CLONE_NEWNS | libc::CLONE_NEWPID | libc::CLONE_NEWNET | libc::CLONE_NEWIPC | libc::CLONE_NEWUTS,
488 +
        uid,
489 +
        gid,
490 +
    )?;
491 +
    // The first child is PID 1 of the new PID namespace; everything in it
492 +
    // dies with it, and it dies with this helper.
493 +
    let pid = unsafe { libc::fork() };
494 +
    if pid < 0 {
495 +
        return Err(format!("fork: {}", std::io::Error::last_os_error()));
496 +
    }
497 +
    if pid == 0 {
498 +
        let code = match init(&spec) {
499 +
            Ok(code) => code,
500 +
            Err(e) => {
501 +
                eprintln!("{FAILED}{e}");
502 +
                125
503 +
            }
504 +
        };
505 +
        unsafe { libc::_exit(code) };
506 +
    }
507 +
    Ok(wait_for(pid))
508 +
}
509 +
510 +
/// Wait for `pid`, reaping any other child on the way: its exit code, or
511 +
/// 128 + the signal that killed it.
512 +
fn wait_for(pid: libc::pid_t) -> i32 {
513 +
    let mut status = 0;
514 +
    loop {
515 +
        let r = unsafe { libc::waitpid(-1, &mut status, 0) };
516 +
        if r == pid {
517 +
            break;
518 +
        }
519 +
        if r < 0 && std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) {
520 +
            return 125;
521 +
        }
522 +
    }
523 +
    if libc::WIFEXITED(status) {
524 +
        libc::WEXITSTATUS(status)
525 +
    } else {
526 +
        128 + libc::WTERMSIG(status)
527 +
    }
528 +
}
529 +
530 +
/// New namespaces of `flags` (which include a user namespace) where
531 +
/// [`JOB_ID`] is the caller's `uid` and `gid`.
532 +
fn enter_namespaces(flags: libc::c_int, uid: u32, gid: u32) -> std::result::Result<(), String> {
533 +
    if unsafe { libc::unshare(flags) } != 0 {
534 +
        return Err(format!("unshare: {}", std::io::Error::last_os_error()));
535 +
    }
536 +
    let write = |file: &str, body: String| std::fs::write(file, body).map_err(|e| format!("write {file}: {e}"));
537 +
    write("/proc/self/setgroups", "deny".into())?;
538 +
    write("/proc/self/uid_map", format!("{JOB_ID} {uid} 1\n"))?;
539 +
    write("/proc/self/gid_map", format!("{JOB_ID} {gid} 1\n"))
540 +
}
541 +
542 +
/// PID 1 of the sandbox: build the root, enter it, run the job, wait.
543 +
fn init(spec: &Spec) -> std::result::Result<i32, String> {
544 +
    // Dies with the helper; the job's cgroup is killed at the end as well.
545 +
    unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) };
546 +
    let nosuid = libc::MS_NOSUID | libc::MS_NODEV;
547 +
    mount("none", "/", "", libc::MS_REC | libc::MS_PRIVATE, "")?;
548 +
    let root = spec.root.to_str().ok_or("the root path is not UTF-8")?;
549 +
    let at = |p: &str| format!("{root}{p}");
550 +
    bind(spec.rootfs.to_str().ok_or("the rootfs path is not UTF-8")?, root)?;
551 +
    for d in ["/src", "/tmp", "/proc", "/dev"] {
552 +
        if !Path::new(&at(d)).is_dir() {
553 +
            return Err(format!("the image has no {d} directory"));
554 +
        }
555 +
    }
556 +
    remount_bind(root, libc::MS_RDONLY | nosuid)?;
557 +
    bind(spec.workspace.to_str().ok_or("the workspace path is not UTF-8")?, &at("/src"))?;
558 +
    mount("tmpfs", &at("/tmp"), "tmpfs", nosuid, "mode=1777")?;
559 +
    mount("proc", &at("/proc"), "proc", nosuid | libc::MS_NOEXEC, "")?;
560 +
    // A /dev of the harmless devices, bound from the host's.
561 +
    mount("tmpfs", &at("/dev"), "tmpfs", libc::MS_NOSUID | libc::MS_NOEXEC, "mode=755")?;
562 +
    for d in ["null", "zero", "full", "random", "urandom"] {
563 +
        let target = at(&format!("/dev/{d}"));
564 +
        std::fs::File::create(&target).map_err(|e| format!("make {target}: {e}"))?;
565 +
        bind(&format!("/dev/{d}"), &target)?;
566 +
    }
567 +
    dev_links(&at("/dev"))?;
568 +
    std::fs::create_dir(at("/dev/shm")).map_err(|e| format!("make /dev/shm: {e}"))?;
569 +
    mount("tmpfs", &at("/dev/shm"), "tmpfs", nosuid, "mode=1777")?;
570 +
    if Path::new(&at("/run")).is_dir() {
571 +
        mount("tmpfs", &at("/run"), "tmpfs", nosuid, "mode=755")?;
572 +
    }
573 +
    // Ephemeral mounts: made under the writable /src or /tmp.
574 +
    for m in &spec.mounts {
575 +
        let target = at(&m.target);
576 +
        std::fs::create_dir_all(&target).map_err(|e| format!("make {}: {e}", m.target))?;
577 +
        match &m.dir {
578 +
            Some(dir) => bind(dir.to_str().ok_or("a mount path is not UTF-8")?, &target)?,
579 +
            None => mount("tmpfs", &target, "tmpfs", nosuid, &format!("size={}m,mode=755", m.size_mib))?,
580 +
        }
581 +
    }
582 +
    // Secrets: an in-memory mount of their own, each file 0400.
583 +
    if !spec.secrets.is_empty() {
584 +
        if !Path::new(&at("/run")).is_dir() {
585 +
            return Err("the image has no /run, where secrets are mounted".into());
586 +
        }
587 +
        let dir = at("/run/secrets");
588 +
        std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {dir}: {e}"))?;
589 +
        mount("tmpfs", &dir, "tmpfs", nosuid | libc::MS_NOEXEC, "size=1m,mode=700")?;
590 +
        for (path, value) in &spec.secrets {
591 +
            let Some(rel) = path.strip_prefix("/run/secrets/") else {
592 +
                return Err(format!("secret path {path:?} is not under /run/secrets/"));
593 +
            };
594 +
            let full = format!("{dir}/{rel}");
595 +
            if let Some(parent) = Path::new(&full).parent() {
596 +
                std::fs::create_dir_all(parent).map_err(|e| format!("mkdir for {path}: {e}"))?;
597 +
            }
598 +
            std::fs::write(&full, value.as_bytes()).map_err(|e| format!("write {path}: {e}"))?;
599 +
            std::fs::set_permissions(&full, std::os::unix::fs::PermissionsExt::from_mode(0o400))
600 +
                .map_err(|e| format!("chmod {path}: {e}"))?;
601 +
        }
602 +
    }
603 +
    let host = b"flow";
604 +
    unsafe { libc::sethostname(host.as_ptr() as *const libc::c_char, host.len()) };
605 +
    if spec.loopback {
606 +
        loopback_up()?;
607 +
    }
608 +
    // Into the new root; the old one is detached, so the host's files, the
609 +
    // spec and the scratch directory are out of reach.
610 +
    std::env::set_current_dir(root).map_err(|e| format!("cd {root}: {e}"))?;
611 +
    let dot = c".";
612 +
    if unsafe { libc::syscall(libc::SYS_pivot_root, dot.as_ptr(), dot.as_ptr()) } != 0 {
613 +
        return Err(format!("pivot_root: {}", std::io::Error::last_os_error()));
614 +
    }
615 +
    if unsafe { libc::umount2(dot.as_ptr(), libc::MNT_DETACH) } != 0 {
616 +
        return Err(format!("detach the old root: {}", std::io::Error::last_os_error()));
617 +
    }
618 +
    std::env::set_current_dir("/src").map_err(|e| format!("cd /src: {e}"))?;
619 +
620 +
    let vars = job_env(std::fs::read_to_string("/etc/flow/env").ok().as_deref(), &spec.env);
621 +
    let path = vars.get("PATH").map(String::as_str).unwrap_or(PATH);
622 +
    let program =
623 +
        cstr(&resolve_program(&spec.argv[0], path).ok_or_else(|| format!("cannot run {:?}: not found in {path}", spec.argv[0]))?)?;
624 +
    let argv: Vec<CString> = spec.argv.iter().map(|a| cstr(a)).collect::<Result<_, _>>()?;
625 +
    let env: Vec<CString> = vars.iter().map(|(k, v)| cstr(&format!("{k}={v}"))).collect::<Result<_, _>>()?;
626 +
    let mut writable: Vec<String> = ["/src", "/tmp", "/dev"].into_iter().map(String::from).collect();
627 +
    writable.extend(spec.mounts.iter().map(|m| m.target.clone()));
628 +
    let pid = unsafe { libc::fork() };
629 +
    if pid < 0 {
630 +
        return Err(format!("fork: {}", std::io::Error::last_os_error()));
631 +
    }
632 +
    if pid == 0 {
633 +
        // The job: no new privileges, then the walls, then the program.
634 +
        let confined = (|| {
635 +
            unsafe {
636 +
                let null = libc::open(c"/dev/null".as_ptr(), libc::O_RDONLY);
637 +
                libc::dup2(null, 0);
638 +
                libc::setsid();
639 +
            }
640 +
            no_new_privs()?;
641 +
            landlock(&writable)?;
642 +
            seccomp(DENIED)
643 +
        })();
644 +
        unsafe {
645 +
            if let Err(e) = confined {
646 +
                let msg = format!("{FAILED}{e}\n");
647 +
                libc::write(2, msg.as_ptr() as *const libc::c_void, msg.len());
648 +
                libc::_exit(125);
649 +
            }
650 +
            let mut a: Vec<*const libc::c_char> = argv.iter().map(|c| c.as_ptr()).collect();
651 +
            a.push(std::ptr::null());
652 +
            let mut e: Vec<*const libc::c_char> = env.iter().map(|c| c.as_ptr()).collect();
653 +
            e.push(std::ptr::null());
654 +
            libc::execve(program.as_ptr(), a.as_ptr(), e.as_ptr());
655 +
            let msg = format!("flow: cannot run {:?}: {}\n", spec.argv[0], std::io::Error::last_os_error());
656 +
            libc::write(2, msg.as_ptr() as *const libc::c_void, msg.len());
657 +
            libc::_exit(127);
658 +
        }
659 +
    }
660 +
    Ok(wait_for(pid))
661 +
}
662 +
663 +
fn cstr(s: &str) -> std::result::Result<CString, String> {
664 +
    CString::new(s).map_err(|_| format!("{s:?} contains NUL"))
665 +
}
666 +
667 +
fn mount(src: &str, target: &str, fstype: &str, flags: libc::c_ulong, data: &str) -> std::result::Result<(), String> {
668 +
    let (s, t, f, d) = (cstr(src)?, cstr(target)?, cstr(fstype)?, cstr(data)?);
669 +
    let fstype = if fstype.is_empty() { std::ptr::null() } else { f.as_ptr() };
670 +
    let data = if data.is_empty() { std::ptr::null() } else { d.as_ptr() as *const libc::c_void };
671 +
    if unsafe { libc::mount(s.as_ptr(), t.as_ptr(), fstype, flags, data) } != 0 {
672 +
        return Err(format!("mount {src} on {target}: {}", std::io::Error::last_os_error()));
673 +
    }
674 +
    Ok(())
675 +
}
676 +
677 +
fn bind(src: &str, target: &str) -> std::result::Result<(), String> {
678 +
    mount(src, target, "", libc::MS_BIND | libc::MS_REC, "")
679 +
}
680 +
681 +
/// Remount a bind mount with `flags` added. In a user namespace the
682 +
/// flags the mount already had are locked, so they are kept.
683 +
fn remount_bind(target: &str, flags: libc::c_ulong) -> std::result::Result<(), String> {
684 +
    let t = cstr(target)?;
685 +
    let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
686 +
    if unsafe { libc::statvfs(t.as_ptr(), &mut st) } != 0 {
687 +
        return Err(format!("statvfs {target}: {}", std::io::Error::last_os_error()));
688 +
    }
689 +
    let kept = [
690 +
        (libc::ST_NOSUID, libc::MS_NOSUID),
691 +
        (libc::ST_NODEV, libc::MS_NODEV),
692 +
        (libc::ST_NOEXEC, libc::MS_NOEXEC),
693 +
        (libc::ST_NOATIME, libc::MS_NOATIME),
694 +
        (libc::ST_NODIRATIME, libc::MS_NODIRATIME),
695 +
        (libc::ST_RELATIME, libc::MS_RELATIME),
696 +
    ]
697 +
    .into_iter()
698 +
    .filter(|(st_flag, _)| st.f_flag & *st_flag != 0)
699 +
    .fold(0, |acc, (_, ms)| acc | ms);
700 +
    mount("none", target, "", libc::MS_BIND | libc::MS_REMOUNT | flags | kept, "")
701 +
}
702 +
703 +
fn no_new_privs() -> std::result::Result<(), String> {
704 +
    if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
705 +
        return Err(format!("no_new_privs: {}", std::io::Error::last_os_error()));
706 +
    }
707 +
    Ok(())
708 +
}
709 +
710 +
// ---- Landlock ----
711 +
712 +
const LANDLOCK_CREATE_RULESET_VERSION: libc::c_uint = 1;
713 +
const LANDLOCK_RULE_PATH_BENEATH: libc::c_int = 1;
714 +
const ACCESS_EXECUTE: u64 = 1;
715 +
const ACCESS_READ_FILE: u64 = 1 << 2;
716 +
const ACCESS_READ_DIR: u64 = 1 << 3;
717 +
/// Every right of Landlock ABI 1 (execute .. make_sym).
718 +
const ACCESS_ABI1: u64 = (1 << 13) - 1;
719 +
const ACCESS_REFER: u64 = 1 << 13;
720 +
const ACCESS_TRUNCATE: u64 = 1 << 14;
721 +
722 +
#[repr(C)]
723 +
struct RulesetAttr {
724 +
    handled_access_fs: u64,
725 +
}
726 +
727 +
#[repr(C, packed)]
728 +
struct PathBeneath {
729 +
    allowed_access: u64,
730 +
    parent_fd: i32,
731 +
}
732 +
733 +
/// The kernel's Landlock ABI version; below 1 when there is none.
734 +
fn landlock_abi() -> i64 {
735 +
    unsafe { libc::syscall(libc::SYS_landlock_create_ruleset, std::ptr::null::<RulesetAttr>(), 0usize, LANDLOCK_CREATE_RULESET_VERSION) }
736 +
}
737 +
738 +
/// Read and execute everywhere; write only beneath `writable`.
739 +
fn landlock(writable: &[String]) -> std::result::Result<(), String> {
740 +
    let abi = landlock_abi();
741 +
    if abi < 1 {
742 +
        return Err("Landlock is not available".into());
743 +
    }
744 +
    let mut all = ACCESS_ABI1;
745 +
    if abi >= 2 {
746 +
        all |= ACCESS_REFER;
747 +
    }
748 +
    if abi >= 3 {
749 +
        all |= ACCESS_TRUNCATE;
750 +
    }
751 +
    let attr = RulesetAttr { handled_access_fs: all };
752 +
    let ruleset = unsafe { libc::syscall(libc::SYS_landlock_create_ruleset, &attr, std::mem::size_of::<RulesetAttr>(), 0) };
753 +
    if ruleset < 0 {
754 +
        return Err(format!("landlock ruleset: {}", std::io::Error::last_os_error()));
755 +
    }
756 +
    let ruleset = ruleset as libc::c_int;
757 +
    let rule = |path: &str, access: u64| -> std::result::Result<(), String> {
758 +
        let p = cstr(path)?;
759 +
        let fd = unsafe { libc::open(p.as_ptr(), libc::O_PATH | libc::O_CLOEXEC) };
760 +
        if fd < 0 {
761 +
            return Err(format!("open {path} for Landlock: {}", std::io::Error::last_os_error()));
762 +
        }
763 +
        let beneath = PathBeneath { allowed_access: access, parent_fd: fd };
764 +
        let rc = unsafe { libc::syscall(libc::SYS_landlock_add_rule, ruleset, LANDLOCK_RULE_PATH_BENEATH, &beneath, 0) };
765 +
        unsafe { libc::close(fd) };
766 +
        if rc != 0 {
767 +
            return Err(format!("landlock rule for {path}: {}", std::io::Error::last_os_error()));
768 +
        }
769 +
        Ok(())
770 +
    };
771 +
    rule("/", ACCESS_EXECUTE | ACCESS_READ_FILE | ACCESS_READ_DIR)?;
772 +
    for w in writable {
773 +
        rule(w, all)?;
774 +
    }
775 +
    if unsafe { libc::syscall(libc::SYS_landlock_restrict_self, ruleset, 0) } != 0 {
776 +
        return Err(format!("landlock restrict: {}", std::io::Error::last_os_error()));
777 +
    }
778 +
    unsafe { libc::close(ruleset) };
779 +
    Ok(())
780 +
}
781 +
782 +
// ---- seccomp ----
783 +
784 +
#[cfg(target_arch = "x86_64")]
785 +
const AUDIT_ARCH: u32 = 0xC000_003E;
786 +
#[cfg(target_arch = "aarch64")]
787 +
const AUDIT_ARCH: u32 = 0xC000_00B7;
788 +
#[cfg(target_arch = "riscv64")]
789 +
const AUDIT_ARCH: u32 = 0xC000_00F3;
790 +
791 +
/// Calls a job never makes: they reach past the sandbox (tracing, mounts,
792 +
/// kernel programs and keys, modules, kexec, swap, reboot) or around the
793 +
/// socket check (io_uring).
794 +
const DENIED: &[libc::c_long] = &[
795 +
    libc::SYS_ptrace,
796 +
    libc::SYS_process_vm_readv,
797 +
    libc::SYS_process_vm_writev,
798 +
    libc::SYS_mount,
799 +
    libc::SYS_umount2,
800 +
    libc::SYS_pivot_root,
801 +
    libc::SYS_open_tree,
802 +
    libc::SYS_move_mount,
803 +
    libc::SYS_fsopen,
804 +
    libc::SYS_fsconfig,
805 +
    libc::SYS_fsmount,
806 +
    libc::SYS_fspick,
807 +
    libc::SYS_bpf,
808 +
    libc::SYS_perf_event_open,
809 +
    libc::SYS_keyctl,
810 +
    libc::SYS_add_key,
811 +
    libc::SYS_request_key,
812 +
    libc::SYS_init_module,
813 +
    libc::SYS_finit_module,
814 +
    libc::SYS_delete_module,
815 +
    libc::SYS_kexec_load,
816 +
    libc::SYS_swapon,
817 +
    libc::SYS_swapoff,
818 +
    libc::SYS_reboot,
819 +
    libc::SYS_io_uring_setup,
820 +
    libc::SYS_io_uring_enter,
821 +
    libc::SYS_io_uring_register,
822 +
];
823 +
824 +
/// Install a filter that fails each of `denied` with EPERM and `socket`
825 +
/// for any family but `AF_UNIX` with EAFNOSUPPORT (an empty `denied`
826 +
/// still checks sockets). Another architecture's calls kill the process.
827 +
fn seccomp(denied: &[libc::c_long]) -> std::result::Result<(), String> {
828 +
    const LD: u16 = (libc::BPF_LD | libc::BPF_W | libc::BPF_ABS) as u16;
829 +
    const JEQ: u16 = (libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K) as u16;
830 +
    const RET: u16 = (libc::BPF_RET | libc::BPF_K) as u16;
831 +
    let op = |code: u16, k: u32, jt: u8, jf: u8| libc::sock_filter { code, jt, jf, k };
832 +
    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).
835 +
    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 = (libc::BPF_JMP | libc::BPF_JGE | libc::BPF_K) as u16;
840 +
        f.push(op(JGE, 0x4000_0000, 0, 1));
841 +
        f.push(op(RET, errno(libc::EPERM), 0, 0));
842 +
    }
843 +
    for nr in denied {
844 +
        f.push(op(JEQ, *nr as u32, 0, 1));
845 +
        f.push(op(RET, errno(libc::EPERM), 0, 0));
846 +
    }
847 +
    f.push(op(JEQ, libc::SYS_socket as u32, 0, 3));
848 +
    f.push(op(LD, 16, 0, 0));
849 +
    f.push(op(JEQ, libc::AF_UNIX as u32, 1, 0));
850 +
    f.push(op(RET, errno(libc::EAFNOSUPPORT), 0, 0));
851 +
    f.push(op(RET, libc::SECCOMP_RET_ALLOW, 0, 0));
852 +
    let prog = libc::sock_fprog { len: f.len() as u16, filter: f.as_mut_ptr() };
853 +
    if unsafe { libc::syscall(libc::SYS_seccomp, libc::SECCOMP_SET_MODE_FILTER, 0, &prog) } != 0 {
854 +
        return Err(format!("seccomp: {}", std::io::Error::last_os_error()));
855 +
    }
856 +
    Ok(())
857 +
}
added crates/flow/tests/syscall.rs +125 −0
1 +
//! Tier 1 conformance (docs/m0.md §3): a pipeline on a busybox rootfs in
2 +
//! the syscall sandbox. Runs where `flow tiers` offers the syscall tier
3 +
//! (user namespaces, Landlock, seccomp and a delegated cgroup: each `flow`
4 +
//! runs in a delegated systemd user scope when one can be made) and a static
5 +
//! busybox is at /usr/bin/busybox; skipped otherwise.
6 +
7 +
use std::path::Path;
8 +
use std::process::Command;
9 +
10 +
const APPLETS: &[&str] = &[
11 +
    "sh", "echo", "cat", "id", "touch", "wget", "tail", "cut", "tr", "true", "false", "ls", "grep", "wc", "mount", "hostname", "head",
12 +
    "sed",
13 +
];
14 +
15 +
/// `flow`, in a delegated cgroup scope of its own when systemd's user
16 +
/// manager can make one (the test itself shares cargo's).
17 +
fn flow(home: &Path) -> Command {
18 +
    static SCOPE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
19 +
    let scope = *SCOPE
20 +
        .get_or_init(|| Command::new("systemd-run").args(["--user", "--scope", "-q", "true"]).output().is_ok_and(|o| o.status.success()));
21 +
    let mut c = if scope {
22 +
        let mut c = Command::new("systemd-run");
23 +
        c.args(["--user", "--scope", "-q", "-p", "Delegate=yes", env!("CARGO_BIN_EXE_flow")]);
24 +
        c
25 +
    } else {
26 +
        Command::new(env!("CARGO_BIN_EXE_flow"))
27 +
    };
28 +
    c.arg("--home").arg(home);
29 +
    c
30 +
}
31 +
32 +
#[test]
33 +
fn a_pipeline_runs_confined_in_the_syscall_sandbox() {
34 +
    let busybox = Path::new("/usr/bin/busybox");
35 +
    let dir = tempfile::tempdir().unwrap();
36 +
    let home = dir.path().join("home");
37 +
    let tiers = flow(&home).arg("tiers").output().unwrap();
38 +
    let tiers = String::from_utf8_lossy(&tiers.stdout).into_owned();
39 +
    if !tiers.contains("syscall: available") || !busybox.is_file() {
40 +
        eprintln!("skipped: the syscall tier is not available here or there is no static busybox\n{tiers}");
41 +
        return;
42 +
    }
43 +
    // A rootfs of busybox and its applets.
44 +
    let tree = dir.path().join("tree");
45 +
    for d in ["bin", "src", "tmp", "proc", "dev", "etc", "run"] {
46 +
        std::fs::create_dir_all(tree.join(d)).unwrap();
47 +
    }
48 +
    std::fs::copy(busybox, tree.join("bin/busybox")).unwrap();
49 +
    for a in APPLETS {
50 +
        std::os::unix::fs::symlink("busybox", tree.join("bin").join(a)).unwrap();
51 +
    }
52 +
    let tarball = dir.path().join("rootfs.tar");
53 +
    assert!(Command::new("tar").arg("-cf").arg(&tarball).arg("-C").arg(&tree).arg(".").status().unwrap().success());
54 +
    let import = flow(&home).args(["rootfs", "import"]).arg(&tarball).output().unwrap();
55 +
    let said = String::from_utf8_lossy(&import.stdout).to_string() + &String::from_utf8_lossy(&import.stderr);
56 +
    assert!(import.status.success(), "{said}");
57 +
    let rootfs = said.split(|c: char| !c.is_ascii_hexdigit()).find(|w| w.len() == 64).expect("the import names the hash").to_string();
58 +
59 +
    let work = dir.path().join("work");
60 +
    std::fs::create_dir_all(work.join(".flow")).unwrap();
61 +
    std::fs::write(
62 +
        work.join(".flow/pipeline.kdl"),
63 +
        format!(
64 +
            r#"schema-version 1
65 +
sandbox "bb" {{
66 +
    runtime "syscall"
67 +
    rootfs "cas://blob/{rootfs}"
68 +
    memory-limit "256MiB"
69 +
}}
70 +
job "literal" {{
71 +
    sandbox "bb"
72 +
    exec "echo" "a; touch /src/pwned && $(id) | `id`"
73 +
}}
74 +
job "isolated" {{
75 +
    sandbox "bb"
76 +
    exec "sh" "-c" "cat /proc/net/dev | tail -n +3 | cut -d: -f1 | tr -d ' '; wget -q -T 2 -O /dev/null http://1.1.1.1 && echo NETWORK || echo no-network"
77 +
}}
78 +
job "readonly" {{
79 +
    sandbox "bb"
80 +
    exec "sh" "-c" "touch /etc/x 2>/dev/null && echo ROOTFS-WRITABLE; touch /run/x 2>/dev/null && echo RUN-WRITABLE; echo ok > /src/w && cat /src/w; id -u; hostname"
81 +
}}
82 +
job "walls" {{
83 +
    sandbox "bb"
84 +
    exec "sh" "-c" "mount -t tmpfs t /tmp 2>/dev/null && echo MOUNTED; ls /proc | grep -c '^[0-9]' | sed 's/^/procs /'; ls / | tr '\n' ' '; echo; echo done"
85 +
}}
86 +
job "greedy" {{
87 +
    sandbox "bb"
88 +
    exec "sh" "-c" "x=$(head -c 400000000 /dev/zero | tr '\\0' a); echo SURVIVED"
89 +
}}
90 +
job "fails" {{
91 +
    sandbox "bb"
92 +
    exec "false"
93 +
}}
94 +
job "after-failure" {{
95 +
    sandbox "bb"
96 +
    depends-on "fails"
97 +
    exec "echo" "SHOULD-NOT-RUN"
98 +
}}
99 +
"#
100 +
        ),
101 +
    )
102 +
    .unwrap();
103 +
    let out = flow(&home).arg("run").current_dir(&work).output().unwrap();
104 +
    let stdout = String::from_utf8_lossy(&out.stdout);
105 +
    let stderr = String::from_utf8_lossy(&out.stderr);
106 +
    let all = format!("{stdout}\n{stderr}");
107 +
    assert!(!out.status.success(), "a failed job fails the run: {all}");
108 +
    assert!(stdout.contains("   a; touch /src/pwned && $(id) | `id`\n"), "argv is literal: {all}");
109 +
    assert!(!work.join("pwned").exists());
110 +
    assert!(stdout.contains("   lo\n") && stdout.contains("no-network") && !stdout.contains("   NETWORK\n"), "only loopback: {all}");
111 +
    assert!(stdout.contains("   ok\n") && !stdout.contains("   ROOTFS-WRITABLE\n"), "rootfs read-only, workspace writable: {all}");
112 +
    assert!(stdout.contains("   1000\n") && !stdout.contains("   0\n"), "the job never runs as root: {all}");
113 +
    assert!(stdout.contains("   flow\n"), "its own host name: {all}");
114 +
    assert!(!stdout.contains("   MOUNTED\n") && stdout.contains("   done\n"), "no mounts: {all}");
115 +
    assert!(stderr.contains("Address family not supported"), "sockets other than AF_UNIX are refused: {all}");
116 +
    assert!(!work.join("w").exists(), "jobs write to their own copy: {all}");
117 +
    let procs: u32 = stdout.lines().find_map(|l| l.trim().strip_prefix("procs ")).and_then(|n| n.parse().ok()).expect(&all);
118 +
    assert!(procs < 10, "only the sandbox's processes are visible: {all}");
119 +
    assert!(!stdout.contains("home "), "none of the host's files: {all}");
120 +
    assert!(!stdout.contains("   RUN-WRITABLE\n"), "Landlock keeps a writable mount outside /src and /tmp read-only: {all}");
121 +
    assert!(stdout.contains("── greedy failed") && !stdout.contains("   SURVIVED\n"), "{all}");
122 +
    assert!(stderr.contains("the job ran out of memory: the sandbox allows 256 MiB"), "{all}");
123 +
    assert!(stdout.contains("── fails failed (exit 1)"), "{all}");
124 +
    assert!(stdout.contains("── after-failure skipped: fails failed") && !stdout.contains("   SHOULD-NOT-RUN\n"), "{all}");
125 +
}
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **The syscall tier (#36, docs/m0.md §3).** `runtime "syscall"` jobs run without KVM, so boards such as the OrangePi RV2 can take them. The sandbox uses user, mount, PID, network, IPC and UTS namespaces on the image's unpacked tree, read-only. Landlock limits writes to `/src` and `/tmp`, and a seccomp filter refuses network sockets, tracing, mounts, BPF, keys, modules and io_uring. A delegated cgroup v2 group enforces the CPU and memory limits, and a job killed for memory is told so. A runner offers the tier only where all of this works. `flow tiers` shows which tiers a machine can run and why the others cannot, and `flow run` runs syscall jobs too.
8 +
7 9
- **A runner offers only the tiers it can run (#36).** It checks that `/dev/kvm` opens before offering the MicroVM tier, says which tiers it serves when it starts, and refuses to start with none, naming why, rather than claiming jobs it would fail.
8 10
9 11
- **A runner tries a fetch again after a network hiccup (oxygenhub#111).** Fetching or cloning the job's repository or a subrepo is retried on DNS, connection, timeout and 502/503/504 errors, waiting 2, 4, 8, 16 and 30 seconds; any other error still ends the job at once.
…
20 unchanged lines
modified crates/flow-init/src/main.rs +3 −131
…
14 unchanged lines
15 15
use std::os::fd::FromRawFd;
16 16
use std::sync::{Arc, Mutex};
17 17
18 +
mod job;
19 +
use job::{dev_links, job_env, loopback_up, resolve_program, PATH};
20 +
18 21
#[derive(Deserialize)]
19 22
struct JobSpec {
20 23
    argv: Vec<String>,
…
25 unchanged lines
46 49
    #[serde(default)]
47 50
    device: Option<String>,
48 51
}
49 -
50 -
const PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
51 52
52 53
fn main() {
53 54
    let console = Arc::new(Mutex::new(std::io::stdout()));
…
48 unchanged lines
102 103
    for d in missing.iter().rev() {
103 104
        std::fs::create_dir(d).map_err(|e| format!("mkdir {}: {e}", d.display()))?;
104 105
        chown(&d.to_string_lossy(), uid, gid)?;
105 -
    }
106 -
    Ok(())
107 -
}
108 -
109 -
/// The file `program` names: itself when it holds a `/`, otherwise the first
110 -
/// executable regular file of that name in `path`'s directories.
111 -
fn resolve_program(program: &str, path: &str) -> Option<String> {
112 -
    use std::os::unix::fs::PermissionsExt;
113 -
    if program.is_empty() {
114 -
        return None;
115 -
    }
116 -
    if program.contains('/') {
117 -
        return Some(program.to_string());
118 -
    }
119 -
    path.split(':')
120 -
        .filter(|d| !d.is_empty())
121 -
        .map(|d| format!("{d}/{program}"))
122 -
        .find(|p| std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0))
123 -
}
124 -
125 -
/// A job's environment: `PATH` and `HOME=/tmp` by default, then the
126 -
/// image's own (`/etc/flow/env`, written by `flow rootfs import --env`),
127 -
/// then the job's (its sandbox's and its own, already merged by the host).
128 -
/// Later ones win, so a pipeline can always set `PATH`.
129 -
fn job_env(image: Option<&str>, job: &[(String, String)]) -> std::collections::BTreeMap<String, String> {
130 -
    let mut env = std::collections::BTreeMap::new();
131 -
    env.insert("PATH".to_string(), PATH.to_string());
132 -
    env.insert("HOME".to_string(), "/tmp".to_string());
133 -
    for line in image.unwrap_or("").lines() {
134 -
        if let Some((k, v)) = line.split_once('=') {
135 -
            if !k.is_empty() {
136 -
                env.insert(k.to_string(), v.to_string());
137 -
            }
138 -
        }
139 106
    }
140 -
    for (k, v) in job {
141 -
        env.insert(k.clone(), v.clone());
142 -
    }
143 -
    env
144 -
}
145 -
146 -
/// The links every Linux system has under /dev and devtmpfs does not
147 -
/// make: `/dev/fd` (bash's process substitution, `<(…)`) and the standard
148 -
/// streams, all through the job's own /proc.
149 -
fn dev_links(dev: &str) -> Result<(), String> {
150 -
    for (name, target) in DEV_LINKS {
151 -
        let link = format!("{dev}/{name}");
152 -
        if std::fs::symlink_metadata(&link).is_err() {
153 -
            std::os::unix::fs::symlink(target, &link).map_err(|e| format!("link {link}: {e}"))?;
154 -
        }
155 -
    }
156 107
    Ok(())
157 108
}
158 -
159 -
const DEV_LINKS: [(&str, &str); 4] =
160 -
    [("fd", "/proc/self/fd"), ("stdin", "/proc/self/fd/0"), ("stdout", "/proc/self/fd/1"), ("stderr", "/proc/self/fd/2")];
161 109
162 -
/// Set `lo` up; the kernel gives it 127.0.0.1 and ::1 itself.
163 -
fn loopback_up() -> Result<(), String> {
164 -
    let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) };
165 -
    if fd < 0 {
166 -
        return Err(format!("socket for lo: {}", std::io::Error::last_os_error()));
167 -
    }
168 -
    let mut req: libc::ifreq = unsafe { std::mem::zeroed() };
169 -
    for (i, b) in b"lo".iter().enumerate() {
170 -
        req.ifr_name[i] = *b as libc::c_char;
171 -
    }
172 -
    let ok = unsafe {
173 -
        libc::ioctl(fd, libc::SIOCGIFFLAGS, &mut req) == 0 && {
174 -
            req.ifr_ifru.ifru_flags |= libc::IFF_UP as libc::c_short;
175 -
            libc::ioctl(fd, libc::SIOCSIFFLAGS, &req) == 0
176 -
        }
177 -
    };
178 -
    let err = std::io::Error::last_os_error();
179 -
    unsafe { libc::close(fd) };
180 -
    if !ok {
181 -
        return Err(format!("bring up lo: {err}"));
182 -
    }
183 -
    Ok(())
184 -
}
185 -
186 110
fn mount(src: &str, target: &str, fstype: &str, flags: libc::c_ulong, data: &str) -> Result<(), String> {
187 111
    let (s, t, f, d) = (cstr(src)?, cstr(target)?, cstr(fstype)?, cstr(data)?);
188 112
    let rc = unsafe { libc::mount(s.as_ptr(), t.as_ptr(), f.as_ptr(), flags, d.as_ptr() as *const libc::c_void) };
…
164 unchanged lines
353 277
    }
354 278
    if !line.is_empty() {
355 279
        frame(console, kind, &line);
356 -
    }
357 -
}
358 -
359 -
#[cfg(test)]
360 -
mod tests {
361 -
    use super::{dev_links, job_env, resolve_program};
362 -
363 -
    #[test]
364 -
    fn the_image_environment_sits_under_the_jobs() {
365 -
        let image = "PATH=/opt/llvm-mos/bin:/usr/bin:/bin\nX16EMU=/opt/x16emu\nnot a line\n";
366 -
        let env = job_env(Some(image), &[("X16EMU".into(), "/job".into()), ("LANG".into(), "C".into())]);
367 -
        assert_eq!(env["PATH"], "/opt/llvm-mos/bin:/usr/bin:/bin", "the image's PATH replaces the default");
368 -
        assert_eq!(env["X16EMU"], "/job", "the job's value wins");
369 -
        assert_eq!(env["HOME"], "/tmp");
370 -
        assert_eq!(env["LANG"], "C");
371 -
        assert_eq!(job_env(None, &[])["PATH"], super::PATH);
372 -
    }
373 -
374 -
    #[test]
375 -
    fn dev_gets_the_usual_links_once() {
376 -
        let dir = std::env::temp_dir().join(format!("flow-init-dev-{}", std::process::id()));
377 -
        let _ = std::fs::remove_dir_all(&dir);
378 -
        std::fs::create_dir_all(&dir).unwrap();
379 -
        let d = dir.to_str().unwrap();
380 -
        dev_links(d).unwrap();
381 -
        dev_links(d).unwrap();
382 -
        assert_eq!(std::fs::read_link(dir.join("fd")).unwrap(), std::path::Path::new("/proc/self/fd"));
383 -
        assert_eq!(std::fs::read_link(dir.join("stdout")).unwrap(), std::path::Path::new("/proc/self/fd/1"));
384 -
        // Through the host's /proc, the link works as in a job.
385 -
        assert!(dir.join("fd").join("0").exists() || !std::path::Path::new("/proc/self/fd/0").exists());
386 -
        std::fs::remove_dir_all(&dir).unwrap();
387 -
    }
388 -
389 -
    use std::os::unix::fs::PermissionsExt;
390 -
391 -
    #[test]
392 -
    fn programs_are_found_in_the_jobs_path() {
393 -
        let dir = std::env::temp_dir().join(format!("flow-init-test-{}", std::process::id()));
394 -
        let (a, b) = (dir.join("a"), dir.join("b"));
395 -
        std::fs::create_dir_all(&a).unwrap();
396 -
        std::fs::create_dir_all(&b).unwrap();
397 -
        std::fs::write(a.join("tool"), "not executable").unwrap();
398 -
        std::fs::write(b.join("tool"), "#!/bin/sh\n").unwrap();
399 -
        std::fs::set_permissions(b.join("tool"), std::fs::Permissions::from_mode(0o755)).unwrap();
400 -
        std::fs::create_dir_all(a.join("dir")).unwrap();
401 -
        let path = format!("{}::{}", a.display(), b.display());
402 -
        assert_eq!(resolve_program("tool", &path), Some(format!("{}/tool", b.display())), "skips a file that is not executable");
403 -
        assert_eq!(resolve_program("dir", &path), None, "and directories");
404 -
        assert_eq!(resolve_program("missing", &path), None);
405 -
        assert_eq!(resolve_program("./x", &path).as_deref(), Some("./x"), "a path is used as given");
406 -
        assert_eq!(resolve_program("", &path), None);
407 -
        std::fs::remove_dir_all(&dir).unwrap();
408 280
    }
409 281
}
modified crates/flow/src/main.rs +43 −6
…
1 unchanged lines
2 2
//! (docs/spec.md, docs/m0.md).
3 3
4 4
mod cpio;
5 +
/// The job-starting code `flow-init` uses in a microVM, shared with the
6 +
/// syscall tier.
7 +
#[path = "../../flow-init/src/job.rs"]
8 +
mod guest;
5 9
mod home;
6 10
mod microvm;
7 11
mod outputs;
8 12
mod runner;
13 +
mod syscall;
9 14
mod wait;
10 15
mod workspace;
11 16
…
88 unchanged lines
100 105
        #[arg(long, default_value_t = 60)]
101 106
        timeout: u64,
102 107
    },
108 +
    /// Show which isolation tiers this machine can run jobs in, and why
109 +
    /// the others cannot
110 +
    Tiers,
111 +
    /// The syscall tier's sandbox helper, started by the runner for each job
112 +
    #[command(name = "__sandbox", hide = true)]
113 +
    Sandbox { spec: PathBuf },
114 +
    /// Check that this machine can run the syscall tier
115 +
    #[command(name = "__sandbox-probe", hide = true)]
116 +
    SandboxProbe,
103 117
}
104 118
105 119
#[derive(Subcommand)]
…
24 unchanged lines
130 144
131 145
fn run(cli: Cli) -> Result<()> {
132 146
    match cli.cmd {
147 +
        Cmd::Sandbox { spec } => std::process::exit(syscall::sandbox_main(&spec)),
148 +
        Cmd::SandboxProbe => std::process::exit(syscall::probe_main()),
133 149
        Cmd::Check { file } => check(&file.unwrap_or_else(|| PathBuf::from(PIPELINE_PATH))),
134 150
        Cmd::Hash { file } => {
135 151
            println!("{}", home::hash_file(&file)?);
…
57 unchanged lines
193 209
            }
194 210
            runner::serve(&home)
195 211
        }
212 +
        Cmd::Tiers => {
213 +
            let home = Home::locate(cli.home.as_deref())?;
214 +
            match runner::tiers(&home) {
215 +
                Ok((have, missing)) => {
216 +
                    for t in have {
217 +
                        println!("{t}: available");
218 +
                    }
219 +
                    for why in missing {
220 +
                        println!("{}", why.replacen(": ", ": unavailable: ", 1));
221 +
                    }
222 +
                }
223 +
                Err(why) => println!("{why}"),
224 +
            }
225 +
            Ok(())
226 +
        }
196 227
        Cmd::Wait { url, state, timeout } => {
197 228
            let token = std::env::var("OXY_TOKEN").ok();
198 229
            let ca = std::env::var_os("OXY_CA").map(PathBuf::from);
…
179 unchanged lines
378 409
            continue;
379 410
        }
380 411
        let sb = &p.sandboxes[&job.sandbox];
381 -
        if sb.runtime != Runtime::Microvm {
412 +
        if sb.runtime == Runtime::Wasm {
382 413
            bail!("job {}: runtime {} is not available in this build yet", job.name, sb.runtime.as_str());
383 414
        }
384 415
        println!("── {} ({})", job.name, job.exec.join(" "));
385 416
        let js = scratch.join(&job.name);
386 417
        std::fs::create_dir_all(&js)?;
387 -
        // Each job starts from the same files: jobs do not share writes.
388 -
        workspace::image(&scratch.join("tree"), &js.join("workspace.ext4"))?;
389 418
        let job = &job
390 419
            .in_sandbox(sb)
391 420
            .with_flow_env(&[("FLOW_JOB".into(), job.name.clone()), ("FLOW_RUN".into(), "local".into())])
…
13 unchanged lines
405 434
                    .ok_or_else(|| anyhow::anyhow!("job {}: secret {:?} is not given; pass --secret {}=VALUE", job.name, s.name, s.name))
406 435
            })
407 436
            .collect::<Result<Vec<_>>>()?;
408 -
        let out = microvm::run(home, sb, job, &inputs, &js.join("workspace.ext4"), &js, timeout, &mut |r: Record| print_record(&r))?;
409 -
        let _ = std::fs::remove_dir_all(&js);
437 +
        // Each job starts from the same files: jobs do not share writes.
438 +
        let out = if sb.runtime == Runtime::Syscall {
439 +
            workspace::copy_tree(&scratch.join("tree"), &js.join("tree"))?;
440 +
            let group = format!("local-{}-{}", std::process::id(), js.file_name().unwrap_or_default().to_string_lossy());
441 +
            syscall::run(home, sb, job, &inputs, &js.join("tree"), &js, timeout, &mut |r: Record| print_record(&r), &group)?
442 +
        } else {
443 +
            workspace::image(&scratch.join("tree"), &js.join("workspace.ext4"))?;
444 +
            microvm::run(home, sb, job, &inputs, &js.join("workspace.ext4"), &js, timeout, &mut |r: Record| print_record(&r))?
445 +
        };
446 +
        let _ = workspace::remove_tree(&js);
410 447
        match out.exit_code {
411 448
            Some(0) => println!("── {} passed in {} ms", job.name, out.duration_ms),
412 449
            other => {
…
8 unchanged lines
421 458
            }
422 459
        }
423 460
    }
424 -
    let _ = std::fs::remove_dir_all(&scratch);
461 +
    let _ = workspace::remove_tree(&scratch);
425 462
    if worst != 0 {
426 463
        std::process::exit(worst);
427 464
    }
…
25 unchanged lines
modified crates/flow/src/outputs.rs +81 −3
1 -
//! A job's artifacts (spec §5.2): read from its disks after the microVM
2 -
//! has stopped, with debugfs, so the runner needs no mount rights.
1 +
//! A job's artifacts (spec §5.2): read after the job has stopped, from
2 +
//! its disk images with debugfs (tier 2, so the runner needs no mount
3 +
//! rights) or from its directories (tier 1).
3 4
4 5
use anyhow::{bail, Context, Result};
5 6
use flow_core::{Job, MountSource};
…
6 unchanged lines
12 13
/// inside its file system. The workspace is `/src`; each `ephemeral://disk`
13 14
/// mount is `mount-N.ext4` in `scratch`, numbered as `microvm::run` does.
14 15
pub fn locate(job: &Job, workspace: &Path, scratch: &Path, path: &str) -> (PathBuf, String) {
16 +
    locate_in(job, workspace, path, |i| scratch.join(format!("mount-{i}.ext4")))
17 +
}
18 +
19 +
/// [`locate`] for the syscall tier, where the workspace and each disk
20 +
/// mount are directories (`crate::syscall::disk_dir`).
21 +
pub fn locate_dir(job: &Job, workspace: &Path, scratch: &Path, path: &str) -> (PathBuf, String) {
22 +
    locate_in(job, workspace, path, |i| crate::syscall::disk_dir(scratch, i))
23 +
}
24 +
25 +
fn locate_in(job: &Job, workspace: &Path, path: &str, disk: impl Fn(usize) -> PathBuf) -> (PathBuf, String) {
15 26
    for (i, m) in job.mounts.iter().filter(|m| m.source == MountSource::Disk).enumerate() {
16 27
        if let Some(rest) = path.strip_prefix(&format!("{}/", m.target)) {
17 -
            return (scratch.join(format!("mount-{i}.ext4")), format!("/{rest}"));
28 +
            return (disk(i), format!("/{rest}"));
18 29
        }
19 30
    }
20 31
    (workspace.to_path_buf(), path.strip_prefix("/src").unwrap_or(path).to_string())
21 32
}
22 33
34 +
/// Read regular file `inner` beneath directory `base`, at most
35 +
/// [`MAX_ARTIFACT_BYTES`]. The job wrote that tree, so no link is
36 +
/// followed on the way: each directory is opened with `O_NOFOLLOW`, and
37 +
/// so is the file.
38 +
pub fn read_dir_file(base: &Path, inner: &str) -> Result<Vec<u8>> {
39 +
    use std::ffi::CString;
40 +
    use std::io::Read;
41 +
    use std::os::fd::FromRawFd;
42 +
    let parts: Vec<&str> = inner.split('/').filter(|p| !p.is_empty()).collect();
43 +
    if parts.is_empty() || parts.iter().any(|p| *p == "." || *p == "..") {
44 +
        bail!("{inner} is not a clean path");
45 +
    }
46 +
    let c = |s: &str| CString::new(s).context("a path holds NUL");
47 +
    let mut dir = unsafe { libc::open(c(&base.to_string_lossy())?.as_ptr(), libc::O_DIRECTORY | libc::O_RDONLY | libc::O_CLOEXEC) };
48 +
    if dir < 0 {
49 +
        bail!("open {}: {}", base.display(), std::io::Error::last_os_error());
50 +
    }
51 +
    let (last, dirs) = parts.split_last().unwrap();
52 +
    for d in dirs {
53 +
        let next = unsafe { libc::openat(dir, c(d)?.as_ptr(), libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_RDONLY | libc::O_CLOEXEC) };
54 +
        let err = std::io::Error::last_os_error();
55 +
        unsafe { libc::close(dir) };
56 +
        if next < 0 {
57 +
            bail!("{inner}: {d} is not a directory in the job's files ({err})");
58 +
        }
59 +
        dir = next;
60 +
    }
61 +
    let fd = unsafe { libc::openat(dir, c(last)?.as_ptr(), libc::O_NOFOLLOW | libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NONBLOCK) };
62 +
    let err = std::io::Error::last_os_error();
63 +
    unsafe { libc::close(dir) };
64 +
    if fd < 0 {
65 +
        bail!("{inner} is not a regular file in the job's files ({err})");
66 +
    }
67 +
    let file = unsafe { std::fs::File::from_raw_fd(fd) };
68 +
    let meta = file.metadata()?;
69 +
    if !meta.is_file() {
70 +
        bail!("{inner} is not a regular file in the job's files (it may be missing or a link)");
71 +
    }
72 +
    if meta.len() > MAX_ARTIFACT_BYTES {
73 +
        bail!("{inner} is {} MiB; artifacts are at most {} MiB", meta.len() >> 20, MAX_ARTIFACT_BYTES >> 20);
74 +
    }
75 +
    let mut bytes = Vec::with_capacity(meta.len() as usize);
76 +
    file.take(MAX_ARTIFACT_BYTES + 1).read_to_end(&mut bytes)?;
77 +
    if bytes.len() as u64 > MAX_ARTIFACT_BYTES {
78 +
        bail!("{inner} grew past {} MiB while it was read", MAX_ARTIFACT_BYTES >> 20);
79 +
    }
80 +
    Ok(bytes)
81 +
}
82 +
23 83
fn debugfs(image: &Path, request: &str) -> Result<String> {
24 84
    let out = std::process::Command::new("debugfs").arg("-R").arg(request).arg(image).output().context("run debugfs (e2fsprogs)")?;
25 85
    if !out.status.success() {
…
64 unchanged lines
90 150
        assert_eq!(locate(&job(), w, s, "/src/work/a"), (s.join("mount-1.ext4"), "/a".into()));
91 151
        assert_eq!(locate(&job(), w, s, "/src/docs/a.1"), (w.to_path_buf(), "/docs/a.1".into()));
92 152
        assert_eq!(locate(&job(), w, s, "/src/targets/x"), (w.to_path_buf(), "/targets/x".into()), "a prefix is not a parent");
153 +
    }
154 +
155 +
    #[test]
156 +
    fn files_are_read_from_directories_without_following_links() {
157 +
        let dir = tempfile::tempdir().unwrap();
158 +
        let tree = dir.path().join("tree");
159 +
        std::fs::create_dir_all(tree.join("release")).unwrap();
160 +
        std::fs::write(tree.join("release/tool"), b"built").unwrap();
161 +
        std::fs::write(dir.path().join("secret"), b"runner key").unwrap();
162 +
        std::os::unix::fs::symlink(dir.path().join("secret"), tree.join("release/link")).unwrap();
163 +
        std::os::unix::fs::symlink(dir.path(), tree.join("up")).unwrap();
164 +
        assert_eq!(read_dir_file(&tree, "/release/tool").unwrap(), b"built");
165 +
        assert!(read_dir_file(&tree, "/release/link").is_err(), "a link to a host file");
166 +
        assert!(read_dir_file(&tree, "/up/secret").is_err(), "a linked directory");
167 +
        assert!(read_dir_file(&tree, "/release/../release/tool").is_err());
168 +
        assert!(read_dir_file(&tree, "/release").is_err(), "a directory");
169 +
        let s = Path::new("/scratch");
170 +
        assert_eq!(locate_dir(&job(), &tree, s, "/src/target/a"), (s.join("mount-0"), "/a".into()));
93 171
    }
94 172
95 173
    #[test]
…
14 unchanged lines
modified crates/flow/src/runner.rs +50 −25
…
279 unchanged lines
280 280
}
281 281
282 282
/// The isolation tiers this machine can honour now (spec §11.1): the
283 -
/// MicroVM tier only when `/dev/kvm` opens for reading and writing and
284 -
/// flow.toml pins Firecracker. `Err` says why there are none.
285 -
pub fn tiers(home: &Home) -> std::result::Result<Vec<String>, String> {
283 +
/// MicroVM tier when `/dev/kvm` opens for reading and writing and
284 +
/// flow.toml pins Firecracker; the syscall tier when this binary may use
285 +
/// user namespaces, Landlock and seccomp and manage a delegated cgroup.
286 +
/// `Err` says why there are none; the reasons for missing tiers come back
287 +
/// with the ones there are.
288 +
pub fn tiers(home: &Home) -> std::result::Result<(Vec<String>, Vec<String>), String> {
289 +
    let mut have = Vec::new();
290 +
    let mut missing = Vec::new();
286 291
    let kvm = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm");
287 292
    match (kvm, home.config.microvm.is_some()) {
288 -
        (Ok(_), true) => Ok(vec!["microvm".into()]),
289 -
        (Ok(_), false) => Err("flow.toml has no [microvm] section, so no tier can run".into()),
290 -
        (Err(e), _) => Err(format!(
291 -
            "/dev/kvm cannot be opened ({e}): the MicroVM tier needs hardware virtualisation and this user in the kvm group, and no other tier is in this build"
293 +
        (Ok(_), true) => have.push("microvm".to_string()),
294 +
        (Ok(_), false) => missing.push("microvm: flow.toml has no [microvm] section".to_string()),
295 +
        (Err(e), _) => missing.push(format!(
296 +
            "microvm: /dev/kvm cannot be opened ({e}); the tier needs hardware virtualisation and this user in the kvm group"
292 297
        )),
293 298
    }
299 +
    match crate::syscall::available() {
300 +
        Ok(()) => have.push("syscall".to_string()),
301 +
        Err(e) => missing.push(format!("syscall: {e}")),
302 +
    }
303 +
    if have.is_empty() {
304 +
        return Err(format!("no isolation tier can run here: {}", missing.join("; ")));
305 +
    }
306 +
    Ok((have, missing))
294 307
}
295 308
296 309
pub fn serve(home: &Home) -> Result<()> {
297 310
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
298 311
    // A runner offers only what it can run: a machine without KVM claims
299 312
    // nothing rather than taking MicroVM jobs and failing them (#36).
300 -
    let runtimes = tiers(home).map_err(|why| anyhow::anyhow!("flow runner: {why}"))?;
313 +
    let (runtimes, missing) = tiers(home).map_err(|why| anyhow::anyhow!("flow runner: {why}"))?;
314 +
    for why in &missing {
315 +
        eprintln!("flow runner: not offering {why}");
316 +
    }
301 317
    let hub = Hub::new(&cfg)?;
302 318
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
303 319
    let budget = Budget::of(&cfg);
…
206 unchanged lines
510 526
            files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h)));
511 527
        }
512 528
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
513 -
        workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
514 -
        std::fs::remove_dir_all(scratch.join("tree"))?;
529 +
        // The syscall tier runs on the tree itself; a microVM gets an image.
530 +
        let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall;
531 +
        if !syscall_tier {
532 +
            workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
533 +
            std::fs::remove_dir_all(scratch.join("tree"))?;
534 +
        }
515 535
        preparing.take();
516 -
        // Cancelled while it was being prepared: no microVM.
536 +
        // Cancelled while it was being prepared: no sandbox.
517 537
        if microvm::Sink::cancelled(&mut sink) {
518 538
            return Ok(cancelled());
519 539
        }
520 540
        note(
521 541
            &mut sink,
522 542
            format!(
523 -
                "booting a microVM: {} vCPU, {} MiB, rootfs {}",
543 +
                "{}: {} {}, {} MiB, rootfs {}",
544 +
                if syscall_tier { "starting a syscall sandbox" } else { "booting a microVM" },
524 545
                claim.sandbox.cpus,
546 +
                if syscall_tier { "CPU" } else { "vCPU" },
525 547
                claim.sandbox.memory_mib,
526 548
                &claim.sandbox.rootfs[..12]
527 549
            ),
528 550
        );
529 551
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
530 -
        let outcome = microvm::run(
531 -
            home,
532 -
            &claim.sandbox,
533 -
            &job,
534 -
            &claim.secrets,
535 -
            &scratch.join("workspace.ext4"),
536 -
            &scratch,
537 -
            Duration::from_secs(claim.timeout_s),
538 -
            &mut sink,
539 -
        )?;
552 +
        let timeout = Duration::from_secs(claim.timeout_s);
553 +
        let outcome = if syscall_tier {
554 +
            let group = format!("job-{}", claim.job_id);
555 +
            crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)?
556 +
        } else {
557 +
            microvm::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("workspace.ext4"), &scratch, timeout, &mut sink)?
558 +
        };
540 559
        if outcome.cancelled {
541 560
            return Ok(cancelled());
542 561
        }
…
3 unchanged lines
546 565
        let mut output_error = None;
547 566
        if outcome.exit_code == Some(0) {
548 567
            for o in &claim.job.outputs {
549 -
                let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
550 -
                let kept = crate::outputs::read(&image, &inner, &scratch).and_then(|bytes| {
568 +
                let read = if syscall_tier {
569 +
                    let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
570 +
                    crate::outputs::read_dir_file(&dir, &inner)
571 +
                } else {
572 +
                    let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
573 +
                    crate::outputs::read(&image, &inner, &scratch)
574 +
                };
575 +
                let kept = read.and_then(|bytes| {
551 576
                    let blob = Repo::blob_ref(&bytes);
552 577
                    let named = hub.artifact(claim.job_id, &o.name, &bytes)?;
553 578
                    if named != blob.to_hex() {
…
36 unchanged lines
590 615
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
591 616
        })
592 617
    })();
593 -
    let _ = std::fs::remove_dir_all(&scratch);
618 +
    let _ = workspace::remove_tree(&scratch);
594 619
    sink.flush();
595 620
    result
596 621
}
…
308 unchanged lines
modified crates/flow/src/workspace.rs +27 −0
…
132 unchanged lines
133 133
    }
134 134
    Ok(())
135 135
}
136 +
137 +
/// Remove `dir` and everything in it, even where a job took away its own
138 +
/// write or search permission on a directory; links are never followed.
139 +
pub fn remove_tree(dir: &Path) -> std::io::Result<()> {
140 +
    match std::fs::remove_dir_all(dir) {
141 +
        Ok(()) => Ok(()),
142 +
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
143 +
        Err(_) => {
144 +
            open_up(dir);
145 +
            std::fs::remove_dir_all(dir)
146 +
        }
147 +
    }
148 +
}
149 +
150 +
fn open_up(dir: &Path) {
151 +
    use std::os::unix::fs::PermissionsExt;
152 +
    let Ok(meta) = std::fs::symlink_metadata(dir) else { return };
153 +
    if !meta.is_dir() {
154 +
        return;
155 +
    }
156 +
    let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
157 +
    if let Ok(entries) = std::fs::read_dir(dir) {
158 +
        for e in entries.flatten() {
159 +
            open_up(&e.path());
160 +
        }
161 +
    }
162 +
}
modified docs/m0.md +16 −2
…
167 unchanged lines
168 168
`ptrace`, `mount`, `bpf`, `perf_event_open`, `keyctl` and module calls;
169 169
`cpu-limit` and `memory-limit` apply through a cgroup v2 scope when the
170 170
runner has a delegated cgroup, and the job fails to start otherwise
171 -
rather than run unbounded. Same frames and receipts as tier 2. Done after
172 -
tier 2 works on Earth.
171 +
rather than run unbounded. Same frames and receipts as tier 2.
173 172
173 +
**How it runs.**
174 +
- **Helper:** for each job the runner starts `flow __sandbox <spec>` (its own binary, through `/proc/self/exe`). The helper joins the job's cgroup, makes the namespaces with the job's user (uid and gid 1000) mapped to the runner's, and forks the sandbox's PID 1.
175 +
- **Root:** PID 1 binds the unpacked tree read-only as the new root, the workspace at `/src`, tmpfs at `/tmp`, `/run` and `/run/secrets`, a fresh `/proc`, and a `/dev` of `null`, `zero`, `full`, `random` and `urandom` with `/dev/shm`. It brings `lo` up for `network "loopback"`, pivots into the root and detaches the old one, so the host's files, the spec and the scratch directory are out of reach.
176 +
- **Job:** the job gets no new privileges, then Landlock (read and execute everywhere, write beneath `/src`, `/tmp`, `/dev` and its mounts), then the seccomp filter, then `execve` with exactly the job's environment, found the way `flow-init` finds it (the two share that code). The filter also refuses io_uring (which could open sockets past it), the new mount calls, kexec, swap and reboot.
177 +
- **Unpacking:** `debugfs rdump` unpacks the image into `trees/<hash>` in the home once, without mount rights. Set-id bits and device nodes are dropped.
178 +
- **Ephemeral mounts:** a `tmpfs` mount is a tmpfs of its size. An `ephemeral://disk` mount is a scratch directory, so its size is not enforced in this tier.
179 +
- **Artifacts:** they are read from the workspace and mount directories without following links.
180 +
- **Receipts:** the environment's runtime is `syscall`. Its parts are the `flow` binary's BLAKE3, the host kernel's release, memory, CPUs and mounts.
181 +
- **Limits:** the job's cgroup gets `memory.max`, `memory.swap.max 0` and `cpu.max`. A job killed for memory is told so in its log. At the end the group is killed and removed.
182 +
183 +
**Where it is offered.** `flow tiers` prints the tiers this machine can run and why the others cannot; a runner offers only those (spec §11.1). The syscall tier needs:
184 +
- **Namespaces:** unprivileged user namespaces for the `flow` binary. Ubuntu's `kernel.apparmor_restrict_unprivileged_userns=1` can deny them to a program without an AppArmor profile.
185 +
- **Kernel features:** Landlock in the kernel's LSM list, and seccomp filters.
186 +
- **Cgroup:** a cgroup v2 group the runner may manage, with the cpu and memory controllers and no other process in it. That is a systemd service with `Delegate=yes`, or a user service under `systemd --user`. The runner moves itself into a `flow-main` leaf of that group and makes one group per job beside it. For `flow run`, use `systemd-run --user --scope -p Delegate=yes flow run`.
187 +
174 188
## 4. Receipts
175 189
176 190
Every finished job gets a receipt (spec §6.1): `job_name`,
…
63 unchanged lines