Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/heimdall

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:?}"),
    }
}