OXYGENHUB
# 9a6fac2b40a9 oxygenflow / crates / flow / tests / syscall.rs
5.9 KiB · Rust · last changed The syscall tier: jobs without KVM 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; 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
}