OXYGENHUB
# 93feb1f3061a oxygenflow / crates / flow / tests / syscall.rs
6.0 KiB · Rust · last changed A runner declines a job it cannot run 1 month ago source blame history raw
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 (or `FLOW_TEST_BUSYBOX`); skipped
6
//! otherwise.
7
8
use std::path::Path;
9
use std::process::Command;
10
11
const APPLETS: &[&str] = &[
12
    "sh", "echo", "cat", "id", "touch", "wget", "tail", "cut", "tr", "true", "false", "ls", "grep", "wc", "mount", "hostname", "head",
13
    "sed",
14
];
15
16
/// `flow`, in a delegated cgroup scope of its own when systemd's user
17
/// manager can make one (the test itself shares cargo's).
18
fn flow(home: &Path) -> Command {
19
    static SCOPE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
20
    let scope = *SCOPE
21
        .get_or_init(|| Command::new("systemd-run").args(["--user", "--scope", "-q", "true"]).output().is_ok_and(|o| o.status.success()));
22
    let mut c = if scope {
23
        let mut c = Command::new("systemd-run");
24
        c.args(["--user", "--scope", "-q", "-p", "Delegate=yes", env!("CARGO_BIN_EXE_flow")]);
25
        c
26
    } else {
27
        Command::new(env!("CARGO_BIN_EXE_flow"))
28
    };
29
    c.arg("--home").arg(home);
30
    c
31
}
32
33
#[test]
34
fn a_pipeline_runs_confined_in_the_syscall_sandbox() {
35
    let busybox = std::env::var_os("FLOW_TEST_BUSYBOX").map(std::path::PathBuf::from).unwrap_or_else(|| "/usr/bin/busybox".into());
36
    let busybox = busybox.as_path();
37
    let dir = tempfile::tempdir().unwrap();
38
    let home = dir.path().join("home");
39
    let tiers = flow(&home).arg("tiers").output().unwrap();
40
    let tiers = String::from_utf8_lossy(&tiers.stdout).into_owned();
41
    if !tiers.contains("syscall: available") || !busybox.is_file() {
42
        eprintln!("skipped: the syscall tier is not available here or there is no static busybox\n{tiers}");
43
        return;
44
    }
45
    // A rootfs of busybox and its applets.
46
    let tree = dir.path().join("tree");
47
    for d in ["bin", "src", "tmp", "proc", "dev", "etc", "run"] {
48
        std::fs::create_dir_all(tree.join(d)).unwrap();
49
    }
50
    std::fs::copy(busybox, tree.join("bin/busybox")).unwrap();
51
    for a in APPLETS {
52
        std::os::unix::fs::symlink("busybox", tree.join("bin").join(a)).unwrap();
53
    }
54
    let tarball = dir.path().join("rootfs.tar");
55
    assert!(Command::new("tar").arg("-cf").arg(&tarball).arg("-C").arg(&tree).arg(".").status().unwrap().success());
56
    let import = flow(&home).args(["rootfs", "import"]).arg(&tarball).output().unwrap();
57
    let said = String::from_utf8_lossy(&import.stdout).to_string() + &String::from_utf8_lossy(&import.stderr);
58
    assert!(import.status.success(), "{said}");
59
    let rootfs = said.split(|c: char| !c.is_ascii_hexdigit()).find(|w| w.len() == 64).expect("the import names the hash").to_string();
60
61
    let work = dir.path().join("work");
62
    std::fs::create_dir_all(work.join(".flow")).unwrap();
63
    std::fs::write(
64
        work.join(".flow/pipeline.kdl"),
65
        format!(
66
            r#"schema-version 1
67
sandbox "bb" {{
68
    runtime "syscall"
69
    rootfs "cas://blob/{rootfs}"
70
    memory-limit "256MiB"
71
}}
72
job "literal" {{
73
    sandbox "bb"
74
    exec "echo" "a; touch /src/pwned && $(id) | `id`"
75
}}
76
job "isolated" {{
77
    sandbox "bb"
78
    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"
79
}}
80
job "readonly" {{
81
    sandbox "bb"
82
    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"
83
}}
84
job "walls" {{
85
    sandbox "bb"
86
    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"
87
}}
88
job "greedy" {{
89
    sandbox "bb"
90
    exec "sh" "-c" "x=$(head -c 400000000 /dev/zero | tr '\\0' a); echo SURVIVED"
91
}}
92
job "fails" {{
93
    sandbox "bb"
94
    exec "false"
95
}}
96
job "after-failure" {{
97
    sandbox "bb"
98
    depends-on "fails"
99
    exec "echo" "SHOULD-NOT-RUN"
100
}}
101
"#
102
        ),
103
    )
104
    .unwrap();
105
    let out = flow(&home).arg("run").current_dir(&work).output().unwrap();
106
    let stdout = String::from_utf8_lossy(&out.stdout);
107
    let stderr = String::from_utf8_lossy(&out.stderr);
108
    let all = format!("{stdout}\n{stderr}");
109
    assert!(!out.status.success(), "a failed job fails the run: {all}");
110
    assert!(stdout.contains("   a; touch /src/pwned && $(id) | `id`\n"), "argv is literal: {all}");
111
    assert!(!work.join("pwned").exists());
112
    assert!(stdout.contains("   lo\n") && stdout.contains("no-network") && !stdout.contains("   NETWORK\n"), "only loopback: {all}");
113
    assert!(stdout.contains("   ok\n") && !stdout.contains("   ROOTFS-WRITABLE\n"), "rootfs read-only, workspace writable: {all}");
114
    assert!(stdout.contains("   1000\n") && !stdout.contains("   0\n"), "the job never runs as root: {all}");
115
    assert!(stdout.contains("   flow\n"), "its own host name: {all}");
116
    assert!(!stdout.contains("   MOUNTED\n") && stdout.contains("   done\n"), "no mounts: {all}");
117
    assert!(stderr.contains("Address family not supported"), "sockets other than AF_UNIX are refused: {all}");
118
    assert!(!work.join("w").exists(), "jobs write to their own copy: {all}");
119
    let procs: u32 = stdout.lines().find_map(|l| l.trim().strip_prefix("procs ")).and_then(|n| n.parse().ok()).expect(&all);
120
    assert!(procs < 10, "only the sandbox's processes are visible: {all}");
121
    assert!(!stdout.contains("home "), "none of the host's files: {all}");
122
    assert!(!stdout.contains("   RUN-WRITABLE\n"), "Landlock keeps a writable mount outside /src and /tmp read-only: {all}");
123
    assert!(stdout.contains("── greedy failed") && !stdout.contains("   SURVIVED\n"), "{all}");
124
    assert!(stderr.contains("the job ran out of memory: the sandbox allows 256 MiB"), "{all}");
125
    assert!(stdout.contains("── fails failed (exit 1)"), "{all}");
126
    assert!(stdout.contains("── after-failure skipped: fails failed") && !stdout.contains("   SHOULD-NOT-RUN\n"), "{all}");
127
}