| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 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 |
|
| 16 |
|
| 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 |
|
| 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 |
} |