//! Tier 1 conformance (docs/m0.md §3): a pipeline on a busybox rootfs in //! the syscall sandbox. Runs where `flow tiers` offers the syscall tier //! (user namespaces, Landlock, seccomp and a delegated cgroup: each `flow` //! runs in a delegated systemd user scope when one can be made) and a static //! busybox is at /usr/bin/busybox; skipped otherwise. use std::path::Path; use std::process::Command; const APPLETS: &[&str] = &[ "sh", "echo", "cat", "id", "touch", "wget", "tail", "cut", "tr", "true", "false", "ls", "grep", "wc", "mount", "hostname", "head", "sed", ]; /// `flow`, in a delegated cgroup scope of its own when systemd's user /// manager can make one (the test itself shares cargo's). fn flow(home: &Path) -> Command { static SCOPE: std::sync::OnceLock = std::sync::OnceLock::new(); let scope = *SCOPE .get_or_init(|| Command::new("systemd-run").args(["--user", "--scope", "-q", "true"]).output().is_ok_and(|o| o.status.success())); let mut c = if scope { let mut c = Command::new("systemd-run"); c.args(["--user", "--scope", "-q", "-p", "Delegate=yes", env!("CARGO_BIN_EXE_flow")]); c } else { Command::new(env!("CARGO_BIN_EXE_flow")) }; c.arg("--home").arg(home); c } #[test] fn a_pipeline_runs_confined_in_the_syscall_sandbox() { let busybox = Path::new("/usr/bin/busybox"); let dir = tempfile::tempdir().unwrap(); let home = dir.path().join("home"); let tiers = flow(&home).arg("tiers").output().unwrap(); let tiers = String::from_utf8_lossy(&tiers.stdout).into_owned(); if !tiers.contains("syscall: available") || !busybox.is_file() { eprintln!("skipped: the syscall tier is not available here or there is no static busybox\n{tiers}"); return; } // A rootfs of busybox and its applets. let tree = dir.path().join("tree"); for d in ["bin", "src", "tmp", "proc", "dev", "etc", "run"] { std::fs::create_dir_all(tree.join(d)).unwrap(); } std::fs::copy(busybox, tree.join("bin/busybox")).unwrap(); for a in APPLETS { std::os::unix::fs::symlink("busybox", tree.join("bin").join(a)).unwrap(); } let tarball = dir.path().join("rootfs.tar"); assert!(Command::new("tar").arg("-cf").arg(&tarball).arg("-C").arg(&tree).arg(".").status().unwrap().success()); let import = flow(&home).args(["rootfs", "import"]).arg(&tarball).output().unwrap(); let said = String::from_utf8_lossy(&import.stdout).to_string() + &String::from_utf8_lossy(&import.stderr); assert!(import.status.success(), "{said}"); let rootfs = said.split(|c: char| !c.is_ascii_hexdigit()).find(|w| w.len() == 64).expect("the import names the hash").to_string(); let work = dir.path().join("work"); std::fs::create_dir_all(work.join(".flow")).unwrap(); std::fs::write( work.join(".flow/pipeline.kdl"), format!( r#"schema-version 1 sandbox "bb" {{ runtime "syscall" rootfs "cas://blob/{rootfs}" memory-limit "256MiB" }} job "literal" {{ sandbox "bb" exec "echo" "a; touch /src/pwned && $(id) | `id`" }} job "isolated" {{ sandbox "bb" 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" }} job "readonly" {{ sandbox "bb" 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" }} job "walls" {{ sandbox "bb" 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" }} job "greedy" {{ sandbox "bb" exec "sh" "-c" "x=$(head -c 400000000 /dev/zero | tr '\\0' a); echo SURVIVED" }} job "fails" {{ sandbox "bb" exec "false" }} job "after-failure" {{ sandbox "bb" depends-on "fails" exec "echo" "SHOULD-NOT-RUN" }} "# ), ) .unwrap(); let out = flow(&home).arg("run").current_dir(&work).output().unwrap(); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); let all = format!("{stdout}\n{stderr}"); assert!(!out.status.success(), "a failed job fails the run: {all}"); assert!(stdout.contains(" a; touch /src/pwned && $(id) | `id`\n"), "argv is literal: {all}"); assert!(!work.join("pwned").exists()); assert!(stdout.contains(" lo\n") && stdout.contains("no-network") && !stdout.contains(" NETWORK\n"), "only loopback: {all}"); assert!(stdout.contains(" ok\n") && !stdout.contains(" ROOTFS-WRITABLE\n"), "rootfs read-only, workspace writable: {all}"); assert!(stdout.contains(" 1000\n") && !stdout.contains(" 0\n"), "the job never runs as root: {all}"); assert!(stdout.contains(" flow\n"), "its own host name: {all}"); assert!(!stdout.contains(" MOUNTED\n") && stdout.contains(" done\n"), "no mounts: {all}"); assert!(stderr.contains("Address family not supported"), "sockets other than AF_UNIX are refused: {all}"); assert!(!work.join("w").exists(), "jobs write to their own copy: {all}"); let procs: u32 = stdout.lines().find_map(|l| l.trim().strip_prefix("procs ")).and_then(|n| n.parse().ok()).expect(&all); assert!(procs < 10, "only the sandbox's processes are visible: {all}"); assert!(!stdout.contains("home "), "none of the host's files: {all}"); assert!(!stdout.contains(" RUN-WRITABLE\n"), "Landlock keeps a writable mount outside /src and /tmp read-only: {all}"); assert!(stdout.contains("── greedy failed") && !stdout.contains(" SURVIVED\n"), "{all}"); assert!(stderr.contains("the job ran out of memory: the sandbox allows 256 MiB"), "{all}"); assert!(stdout.contains("── fails failed (exit 1)"), "{all}"); assert!(stdout.contains("── after-failure skipped: fails failed") && !stdout.contains(" SHOULD-NOT-RUN\n"), "{all}"); }