crates/heimdall-trace/tests/trace_doc.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! The trace document, the recorder's bound, the tracer's pure decisions,
//! the caller's verification and the diff. Heimdall is a defensive sandbox;
//! these tests need no kernel features (the kernel tests are in kernel.rs).
use std::path::{Path, PathBuf};
use std::time::Duration;
use heimdall_core::*;
use heimdall_trace::classify::*;
use heimdall_trace::record::Recorder;
use heimdall_trace::*;
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("heimdall-trace-{name}-{}", JobId::random()));
std::fs::create_dir_all(&d).unwrap();
d
}
fn sample() -> Trace {
let mut r = Recorder::new(
vec!["/usr/bin/python3".into(), "-c".into(), "import x".into()],
1 << 20,
);
r.exec(ExecEvent {
program: "/usr/bin/python3.13".into(),
path: "/bin/sh".into(),
argv: vec!["sh".into(), "-c".into(), "true".into()],
argv_cut: false,
});
r.net(NetEvent {
program: "/usr/bin/python3.13".into(),
op: "connect".into(),
family: "inet".into(),
address: "10.0.0.1:443".into(),
refused: true,
});
r.file(FileEvent {
program: "/usr/bin/python3.13".into(),
op: "openat".into(),
path: "/etc/passwd".into(),
flags: "0x80000".into(),
write: false,
outside_tmp: false,
refused: false,
});
r.syscall(
"/usr/bin/python3.13",
SyscallFact {
syscall: "openat".into(),
args: [("2".to_string(), "0x80000".to_string())].into(),
},
);
r.trace_mut().exit = "exit 0".into();
r.finish().0
}
#[test]
fn a_trace_round_trips_through_its_one_canonical_encoding() {
let t = sample();
let b = t.to_canonical();
assert_eq!(Trace::from_canonical(&b, 1 << 20).unwrap(), t);
assert_eq!(
Trace::from_canonical(&b, 1 << 20).unwrap().to_canonical(),
b
);
}
#[test]
fn pretty_or_reordered_json_of_the_same_trace_is_refused_as_not_canonical() {
let t = sample();
let pretty = serde_json::to_vec_pretty(&t).unwrap();
assert_eq!(
Trace::from_canonical(&pretty, 1 << 20),
Err(DocError::NotCanonical)
);
let v: serde_json::Value = serde_json::from_slice(&t.to_canonical()).unwrap();
// The same fields inserted in reverse order (sorted or kept in that
// order, depending on serde_json's features: never the struct order).
let mut m = serde_json::Map::new();
for (k, x) in v.as_object().unwrap().iter().rev() {
m.insert(k.clone(), x.clone());
}
let reordered = serde_json::to_vec(&serde_json::Value::Object(m)).unwrap();
assert_ne!(reordered, t.to_canonical());
assert_eq!(
Trace::from_canonical(&reordered, 1 << 20),
Err(DocError::NotCanonical)
);
}
#[test]
fn a_foreign_format_an_unknown_field_or_contradicting_fields_are_refused() {
let mut t = sample();
t.format = "heimdall.trace.v0".into();
assert!(matches!(
Trace::from_canonical(&t.to_canonical(), 1 << 20),
Err(DocError::Format(_))
));
let mut v: serde_json::Value = serde_json::from_slice(&sample().to_canonical()).unwrap();
v.as_object_mut()
.unwrap()
.insert("extra".into(), serde_json::json!(1));
assert!(matches!(
Trace::from_canonical(&serde_json::to_vec(&v).unwrap(), 1 << 20),
Err(DocError::Parse(_))
));
let mut t = sample();
t.status = Status::Incomplete;
assert!(matches!(
Trace::from_canonical(&t.to_canonical(), 1 << 20),
Err(DocError::Invalid(_))
));
let mut t = sample();
t.dropped = 3;
assert!(matches!(
Trace::from_canonical(&t.to_canonical(), 1 << 20),
Err(DocError::Invalid(_))
));
}
#[test]
fn a_trace_over_the_readers_bound_or_its_own_is_refused() {
let t = sample();
let b = t.to_canonical();
assert!(matches!(
Trace::from_canonical(&b, b.len() as u64 - 1),
Err(DocError::TooLarge { .. })
));
let mut r = Recorder::new(vec!["x".into()], 4096);
for i in 0..10 {
r.file(FileEvent {
program: "p".into(),
op: "openat".into(),
path: format!("/a/{i}"),
flags: String::new(),
write: false,
outside_tmp: false,
refused: false,
});
}
let (mut over, _) = r.finish();
over.max_bytes = 4096;
for i in 0..200 {
over.files.insert(FileEvent {
program: "p".into(),
op: "openat".into(),
path: format!("/b/{i}"),
flags: String::new(),
write: false,
outside_tmp: false,
refused: false,
});
}
let ob = over.to_canonical();
assert!(ob.len() > 4096);
assert!(matches!(
Trace::from_canonical(&ob, 1 << 20),
Err(DocError::TooLarge { max: 4096, .. })
));
}
#[test]
fn the_recorder_keeps_the_document_within_max_bytes_and_marks_it_truncated() {
let max = 16 * 1024;
let mut r = Recorder::new(vec!["/bin/sh".into()], max);
for i in 0..5000 {
r.file(FileEvent {
program: "/bin/busybox".into(),
op: "openat".into(),
path: format!("/tmp/a-rather-long-file-name-number-{i:06}"),
flags: "0x241".into(),
write: true,
outside_tmp: false,
refused: false,
});
r.syscall(
"/bin/busybox",
SyscallFact {
syscall: "ioctl".into(),
args: [("1".to_string(), format!("{i:#x}"))].into(),
},
);
}
r.trace_mut().exit = "exit 0".into();
let (t, b) = r.finish();
assert!(b.len() as u64 <= max, "{} > {max}", b.len());
assert_eq!(t.status, Status::Truncated);
assert!(t.dropped > 0);
assert!(!t.files.is_empty());
assert_eq!(Trace::from_canonical(&b, max).unwrap(), t);
}
#[test]
fn duplicate_entries_are_kept_once_and_cost_nothing() {
let mut r = Recorder::new(vec!["x".into()], 8192);
let e = NetEvent {
program: "p".into(),
op: "connect".into(),
family: "inet".into(),
address: "10.0.0.1:443".into(),
refused: true,
};
for _ in 0..10_000 {
r.net(e.clone());
}
let (t, _) = r.finish();
assert_eq!(t.net.len(), 1);
assert_eq!(t.status, Status::Complete);
}
#[test]
fn an_incomplete_trace_keeps_its_reason_and_is_not_usable() {
let mut r = Recorder::new(vec!["x".into()], 8192);
r.incomplete("the tracer is not PID 1");
let (t, b) = r.finish();
assert!(!t.is_usable());
assert_eq!(
Trace::from_canonical(&b, 8192).unwrap().incomplete,
"the tracer is not PID 1"
);
}
#[test]
fn write_flags_paths_and_refusals_are_classified() {
assert!(!open_writes(libc::O_RDONLY as u64 | libc::O_CLOEXEC as u64));
assert!(!open_writes(libc::O_DIRECTORY as u64));
assert!(open_writes(libc::O_WRONLY as u64));
assert!(open_writes(libc::O_RDWR as u64));
assert!(open_writes((libc::O_CREAT | libc::O_EXCL) as u64));
assert!(open_writes(libc::O_TMPFILE as u64 | libc::O_RDWR as u64));
assert_eq!(normalize("/a/./b//../c/"), "/a/c");
assert_eq!(normalize("/../.."), "/");
assert_eq!(join("/tmp/x", "../y"), "/tmp/y");
assert_eq!(join("/tmp/x", "/etc/z"), "/etc/z");
assert!(under("/tmp", "/tmp") && under("/tmp/a", "/tmp/") && !under("/tmpx", "/tmp"));
let w = vec!["/tmp".to_string()];
assert!(!refuse_write(Some("/tmp/a"), &w));
assert!(!refuse_write(Some("/dev/null"), &w));
assert!(refuse_write(Some("/etc/passwd"), &w));
assert!(refuse_write(Some("/root/.ssh/authorized_keys"), &w));
assert!(refuse_write(Some("/out/trace.json"), &w));
assert!(refuse_write(Some("/out"), &["/".to_string()]));
assert!(refuse_write(None, &w));
assert!(outside_tmp("/etc/x") && !outside_tmp("/tmp/x") && !outside_tmp("/dev/null"));
assert!(file_call("openat").is_some() && file_call("read").is_none());
assert_eq!(file_call("renameat2").unwrap().paths.len(), 2);
assert!(file_call("unlinkat").unwrap().always_write);
assert_eq!(net_call("connect"), Some((1, 2)));
assert_eq!(net_call("write"), None);
}
#[test]
fn socket_addresses_decode_for_every_family_and_only_unix_is_allowed() {
let mut v4 = vec![0u8; 16];
v4[..2].copy_from_slice(&(libc::AF_INET as u16).to_ne_bytes());
v4[2..4].copy_from_slice(&443u16.to_be_bytes());
v4[4..8].copy_from_slice(&[10, 0, 0, 1]);
assert_eq!(sockaddr(&v4), ("inet".into(), "10.0.0.1:443".into()));
let mut v6 = vec![0u8; 28];
v6[..2].copy_from_slice(&(libc::AF_INET6 as u16).to_ne_bytes());
v6[2..4].copy_from_slice(&53u16.to_be_bytes());
v6[23] = 1;
assert_eq!(sockaddr(&v6), ("inet6".into(), "[::1]:53".into()));
let mut un = (libc::AF_UNIX as u16).to_ne_bytes().to_vec();
un.extend_from_slice(b"/tmp/s\0junk");
assert_eq!(sockaddr(&un), ("unix".into(), "/tmp/s".into()));
let mut ab = (libc::AF_UNIX as u16).to_ne_bytes().to_vec();
ab.extend_from_slice(b"\0name");
assert_eq!(sockaddr(&ab), ("unix".into(), "@name".into()));
assert_eq!(sockaddr(&[1]).0, "unreadable");
let mut odd = (99u16).to_ne_bytes().to_vec();
odd.extend_from_slice(&[0; 14]);
assert_eq!(sockaddr(&odd).0, "family 99");
assert!(refuse_net("inet") && refuse_net("inet6") && refuse_net("netlink"));
assert!(refuse_net("unreadable") && !refuse_net("unix"));
}
#[test]
fn the_tracer_takes_its_options_and_the_command_after_the_double_dash() {
use heimdall_trace::tracer::parse_args;
let a = |v: &[&str]| parse_args(v.iter().map(std::ffi::OsString::from));
let c = a(&[
"--out",
"/out/trace.json",
"--timeout-ms",
"1500",
"--max-bytes",
"8192",
"--writable",
"/tmp/w/",
"--env",
"HOME=/tmp/h",
"--cwd",
"/tmp",
"--",
"/usr/bin/python3",
"-c",
"1",
])
.unwrap();
assert_eq!(c.timeout, Duration::from_millis(1500));
assert_eq!(c.max_bytes, 8192);
assert_eq!(c.writable, vec!["/tmp/w".to_string()]);
assert_eq!(c.env, vec![("HOME".into(), "/tmp/h".into())]);
assert_eq!(c.argv.len(), 3);
let d = a(&["--out", "/out/t", "--", "x"]).unwrap();
assert_eq!(d.writable, vec!["/tmp".to_string()]);
assert!(a(&["--out", "/out/t"]).is_err());
assert!(a(&["--out", "/out/t", "--"]).is_err());
assert!(a(&["--", "x"]).is_err());
assert!(a(&["--out", "/o", "--max-bytes", "10", "--", "x"]).is_err());
assert!(a(&["--out", "/o", "--writable", "tmp", "--", "x"]).is_err());
assert!(a(&["--out", "/o", "--env", "=v", "--", "x"]).is_err());
assert!(a(&["--out", "/o", "--bogus", "--", "x"]).is_err());
}
#[test]
fn a_trace_job_starts_the_tracer_with_the_command_and_a_deadline_before_the_wall() {
let img = ImageRef {
name: "w".into(),
digest: format!("sha256:{}", "a".repeat(64)),
};
let mut tj = TraceJob::new(img.clone(), vec!["/usr/bin/pip".into(), "install".into()]);
tj.limits.wall = Duration::from_secs(60);
assert_eq!(tj.tracer_timeout(), Duration::from_secs(55));
tj.limits.wall = Duration::from_secs(6);
assert_eq!(tj.tracer_timeout(), Duration::from_secs(3));
let job = tj.job("/i".into(), "/o".into());
let JobKind::Native { image, argv } = &job.kind else {
panic!("a native job")
};
assert_eq!(image, &img);
assert_eq!(argv[0], TRACER_PATH);
let dd = argv.iter().position(|a| a == "--").unwrap();
assert_eq!(&argv[dd + 1..], &["/usr/bin/pip", "install"]);
assert!(argv
.windows(2)
.any(|w| w[0] == "--out" && w[1] == "/out/trace.json"));
assert!(argv
.windows(2)
.any(|w| w[0] == "--env" && w[1] == "HOME=/tmp/home"));
assert_eq!(job.policy.limits.wall, Duration::from_secs(6));
assert!(job.policy.net.is_none());
}
// ==== the caller's side: signed results with a trace ====
struct Signed {
key: BootKey,
dir: PathBuf,
job: Job,
}
fn signed_with(files: &[(&str, &[u8])], verdict: Verdict) -> (Signed, SignedResult) {
signed_with_broker(files, verdict, false)
}
fn signed_with_broker(
files: &[(&str, &[u8])],
verdict: Verdict,
broker: bool,
) -> (Signed, SignedResult) {
let dir = scratch("verify");
let out = dir.join("out");
std::fs::create_dir_all(&out).unwrap();
let mut outputs = Vec::new();
for (n, b) in files {
std::fs::write(out.join(n), b).unwrap();
outputs.push(Output::of(*n, b));
}
outputs.sort_by(|a, b| a.name.cmp(&b.name));
let job = Job::new(
JobKind::Native {
image: ImageRef {
name: "w".into(),
digest: format!("sha256:{}", "b".repeat(64)),
},
argv: vec!["x".into()],
},
dir.clone(),
out,
serde_json::Value::Null,
Policy::default(),
);
let key = BootKey::generate();
let result = JobResult {
job_id: job.id,
nonce: job.nonce,
verdict,
outputs,
exit: ExitInfo::exited(0),
started_unix: 1,
finished_unix: 2,
runner: Features {
runner: "heimdall".into(),
container: true,
seccomp: Protection::Default,
landlock: true,
broker,
wasm: false,
image_verified_each_start: true,
},
events: vec![],
kernel: None,
};
let s = key.sign(result).unwrap();
(Signed { key, dir, job }, s)
}
fn out_dir(s: &Signed) -> &Path {
&s.job.output_dir
}
fn traced(o: &TraceOutcome) -> &Trace {
match o {
TraceOutcome::Traced { trace, .. } => trace,
TraceOutcome::Unknown { reason } => panic!("unknown: {reason}"),
}
}
#[test]
fn a_signed_trace_verifies_and_reads_back() {
let t = sample().to_canonical();
let (s, signed) = signed_with(&[(TRACE_FILE, &t)], Verdict::Accepted);
let o = verify_trace(
&signed,
&s.key.verifying_key(),
Some(&s.job.ticket()),
out_dir(&s),
1 << 20,
);
assert_eq!(traced(&o), &sample());
let _ = std::fs::remove_dir_all(&s.dir);
}
#[test]
fn a_bit_flipped_trace_file_is_unknown() {
let mut t = sample().to_canonical();
let (s, signed) = signed_with(&[(TRACE_FILE, &t)], Verdict::Accepted);
// One bit of the file on disk, after signing.
let i = t.len() / 2;
t[i] ^= 0x01;
std::fs::write(out_dir(&s).join(TRACE_FILE), &t).unwrap();
let o = verify_trace(&signed, &s.key.verifying_key(), None, out_dir(&s), 1 << 20);
assert!(o.is_unknown(), "{o:?}");
}
#[test]
fn a_bit_flipped_or_forged_signed_result_is_unknown() {
let t = sample().to_canonical();
let (s, signed) = signed_with(&[(TRACE_FILE, &t)], Verdict::Accepted);
let k = s.key.verifying_key();
// Every byte of the signed wire, flipped one at a time, is refused.
let wire = signed.to_bytes();
for i in (0..wire.len()).step_by(7) {
let mut w = wire.clone();
w[i] ^= 0x40;
if let Ok(f) = SignedResult::from_bytes(&w) {
assert!(
verify_trace(&f, &k, None, out_dir(&s), 1 << 20).is_unknown(),
"byte {i} flipped still verified"
);
}
}
// A forger with its own key: another key id, refused.
let forger = BootKey::generate();
let forged = forger.sign(signed.result.clone()).unwrap();
assert!(verify_trace(&forged, &k, None, out_dir(&s), 1 << 20).is_unknown());
// The real result checked against another pinned key.
assert!(
verify_trace(&signed, &forger.verifying_key(), None, out_dir(&s), 1 << 20).is_unknown()
);
// The result of another job (ticket mismatch).
let other = Job::new(
s.job.kind.clone(),
s.dir.clone(),
out_dir(&s).to_path_buf(),
serde_json::Value::Null,
Policy::default(),
);
assert!(verify_trace(&signed, &k, Some(&other.ticket()), out_dir(&s), 1 << 20).is_unknown());
}
#[test]
fn an_extra_output_a_missing_trace_or_a_refused_job_is_unknown() {
let t = sample().to_canonical();
let (s, signed) = signed_with(&[(TRACE_FILE, &t), ("evil", b"x")], Verdict::Accepted);
assert!(verify_trace(&signed, &s.key.verifying_key(), None, out_dir(&s), 1 << 20).is_unknown());
let (s, signed) = signed_with(&[("other.json", &t)], Verdict::Accepted);
assert!(verify_trace(&signed, &s.key.verifying_key(), None, out_dir(&s), 1 << 20).is_unknown());
for code in [
codes::EXIT_NONZERO,
codes::LIMIT_WALL,
codes::SECCOMP_KILL,
codes::OUT_EXTRA,
] {
let (s, signed) = signed_with(&[(TRACE_FILE, &t)], Verdict::refused(code, "r"));
assert!(
verify_trace(&signed, &s.key.verifying_key(), None, out_dir(&s), 1 << 20).is_unknown(),
"{code}"
);
}
}
#[test]
fn a_pids_limit_refusal_keeps_the_trace_and_names_the_code() {
let t = sample().to_canonical();
let (s, signed) = signed_with(
&[(TRACE_FILE, &t)],
Verdict::refused(codes::LIMIT_PIDS, "forks"),
);
let o = verify_trace(&signed, &s.key.verifying_key(), None, out_dir(&s), 1 << 20);
match o {
TraceOutcome::Traced { job_code, .. } => {
assert_eq!(job_code.as_deref(), Some(codes::LIMIT_PIDS))
}
TraceOutcome::Unknown { reason } => panic!("{reason}"),
}
}
#[test]
fn an_incomplete_an_oversized_or_a_non_canonical_trace_is_unknown() {
let mut r = Recorder::new(vec!["x".into()], 8192);
r.incomplete("could not trace");
let inc = r.finish().1;
let (s, signed) = signed_with(&[(TRACE_FILE, &inc)], Verdict::Accepted);
match verify_trace(&signed, &s.key.verifying_key(), None, out_dir(&s), 1 << 20) {
TraceOutcome::Unknown { reason } => assert!(reason.contains("could not trace"), "{reason}"),
o => panic!("{o:?}"),
}
let t = sample().to_canonical();
let (s, signed) = signed_with(&[(TRACE_FILE, &t)], Verdict::Accepted);
assert!(verify_trace(
&signed,
&s.key.verifying_key(),
None,
out_dir(&s),
t.len() as u64 - 1
)
.is_unknown());
let pretty = serde_json::to_vec_pretty(&sample()).unwrap();
let (s, signed) = signed_with(&[(TRACE_FILE, &pretty)], Verdict::Accepted);
assert!(verify_trace(&signed, &s.key.verifying_key(), None, out_dir(&s), 1 << 20).is_unknown());
}
// ==== the diff ====
#[test]
fn the_diff_names_new_execs_connects_writes_and_calls_and_ignores_tmp_scratch() {
let base = sample();
let mut t = base.clone();
assert!(diff(&base, &t).is_clean());
t.exec.insert(ExecEvent {
program: "/bin/dash".into(),
path: "/usr/bin/curl".into(),
argv: vec!["curl".into()],
argv_cut: false,
});
// Another argv of a known (program, path): not new.
t.exec.insert(ExecEvent {
program: "/usr/bin/python3.13".into(),
path: "/bin/sh".into(),
argv: vec!["sh".into(), "-c".into(), "other".into()],
argv_cut: false,
});
t.net.insert(NetEvent {
program: "/usr/bin/python3.13".into(),
op: "connect".into(),
family: "inet".into(),
address: "203.0.113.9:80".into(),
refused: true,
});
t.files.insert(FileEvent {
program: "/usr/bin/python3.13".into(),
op: "openat".into(),
path: "/etc/cron.d/x".into(),
flags: "0x241".into(),
write: true,
outside_tmp: true,
refused: true,
});
t.files.insert(FileEvent {
program: "/usr/bin/python3.13".into(),
op: "openat".into(),
path: "/tmp/pip-random-123/x".into(),
flags: "0x241".into(),
write: true,
outside_tmp: false,
refused: false,
});
t.syscalls
.entry("/usr/bin/python3.13".into())
.or_default()
.insert(SyscallFact {
syscall: "socket".into(),
args: [("0".to_string(), "0x2".to_string())].into(),
});
let d = diff(&base, &t);
assert!(!d.is_clean());
assert_eq!(d.exec.len(), 1);
assert_eq!(d.exec[0].path, "/usr/bin/curl");
assert_eq!(d.net.len(), 1);
assert_eq!(d.files.len(), 1);
assert_eq!(d.files[0].path, "/etc/cron.d/x");
assert_eq!(d.syscalls.len(), 1);
assert!(d.not_complete.is_none());
}
#[test]
fn a_truncated_trace_is_never_clean_against_its_baseline() {
let base = sample();
let mut t = base.clone();
t.status = Status::Truncated;
t.dropped = 1;
let d = diff(&base, &t);
assert!(!d.is_clean());
assert!(d.not_complete.unwrap().contains("truncated"));
let mut b2 = base.clone();
b2.status = Status::Incomplete;
b2.incomplete = "x".into();
assert!(!diff(&b2, &base).is_clean());
}
#[test]
fn the_tracer_layer_holds_the_tracer_at_its_path_executable_and_is_deterministic() {
let l1 = heimdall_trace::image::tracer_layer(b"\x7fELF fake").unwrap();
let l2 = heimdall_trace::image::tracer_layer(b"\x7fELF fake").unwrap();
assert_eq!(l1, l2);
let mut a = tar::Archive::new(&l1[..]);
let mut found = false;
for e in a.entries().unwrap() {
let e = e.unwrap();
if e.path().unwrap().to_str() == Some("heimdall/heimdall-trace") {
assert_eq!(e.header().mode().unwrap() & 0o777, 0o755);
found = true;
}
}
assert!(found);
}
#[test]
fn every_trace_names_the_tracer_as_the_holder_of_the_seccomp_listener() {
assert_eq!(sample().listener, heimdall_trace::doc::LISTENER);
let mut t = sample();
t.listener = "heimdall-broker".into();
assert!(matches!(
Trace::from_canonical(&t.to_canonical(), 1 << 20),
Err(DocError::Invalid(_))
));
}
#[test]
fn a_trace_job_result_from_a_runner_with_a_broker_is_unknown_by_name() {
let t = sample().to_canonical();
let (s, signed) = signed_with_broker(&[(TRACE_FILE, &t)], Verdict::Accepted, true);
match verify_trace(&signed, &s.key.verifying_key(), None, out_dir(&s), 1 << 20) {
TraceOutcome::Unknown { reason } => assert!(reason.contains("broker"), "{reason}"),
o => panic!("{o:?}"),
}
}
struct BrokerRunner(BootKey);
impl JobRunner for BrokerRunner {
fn run(&self, _job: Job) -> Result<SignedResult, RunError> {
panic!("a trace job must not run on a runner with a broker")
}
fn verifying_key(&self) -> VerifyingKey {
self.0.verifying_key()
}
fn features(&self) -> Features {
Features {
runner: "heimdall".into(),
container: true,
seccomp: Protection::Default,
landlock: true,
broker: true,
wasm: false,
image_verified_each_start: true,
}
}
}
#[test]
fn a_trace_job_on_a_runner_with_a_broker_is_refused_by_name_before_it_runs() {
let img = ImageRef {
name: "w".into(),
digest: format!("sha256:{}", "c".repeat(64)),
};
let tj = TraceJob::new(img, vec!["/bin/true".into()]);
let d = scratch("broker");
let (signed, o) = run_trace(
&BrokerRunner(BootKey::generate()),
&tj,
d.clone(),
d.join("out"),
);
assert!(signed.is_none());
match o {
TraceOutcome::Unknown { reason } => {
assert!(
reason.contains("broker") && reason.contains("listener"),
"{reason}"
)
}
o => panic!("{o:?}"),
}
}