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

nordisk/holger

jobs/runner/src/heimdall_tests.rs

//! [`crate::heimdall::HeimdallJobs`] over a runner that plays Heimdall's side of the
//! scan-worker job contract (heimdall docs/API.md): `/in` is the staged input, the
//! worker's argv is exactly `[<worker>]`, its stdout goes nowhere, the answer is a file in
//! `/out`, the runner lists and signs every output with its per-boot key. The real kernel
//! run is `server/scan-worker/tests/jobs_under_heimdall.rs`.

use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

use heimdall_core::{
    BootId, BootKey, ExitInfo, Features, ImageRef, Job, JobKind, JobResult, JobRunner,
    KernelFeatures, KillReason, NetAllow, Output, Policy, Protection, RunError, SignedResult,
    Verdict, VerifyingKey,
};

use crate::heimdall::{holger_code_of, mint_layout, HeimdallJobs, WORKER_BIN, WORKER_ENV};
use crate::wire::{deliver, Answer, Dirs, Program, WireJob, ANSWER_FILE};
use crate::{accept, worker, ResultKey};

const DIGEST: &str = "sha256:1111111111111111111111111111111111111111111111111111111111111111";

/// What the fake does after the worker ran.
#[derive(Clone, PartialEq, Eq, Debug)]
enum Twist {
    None,
    /// The signed result says this Heimdall refusal (with this exit).
    Refuse(&'static str, ExitInfo),
    /// Signed with a key nobody pinned.
    ForeignKey,
    /// The result of another job's nonce.
    OtherNonce,
    /// An output's listed sha256 is not its bytes.
    WrongListing,
    /// The worker writes no answer file.
    NoAnswer,
}

#[derive(Default, Clone)]
struct Saw {
    image: Option<ImageRef>,
    argv: Vec<String>,
    wire: Option<WireJob>,
    input_tree: Vec<String>,
    limits: Option<heimdall_core::Limits>,
    /// The staged input file's link count: 1 when it was copied.
    input_nlink: u64,
    /// The network allowance Heimdall was handed.
    net: Option<NetAllow>,
    /// The seccomp profile Heimdall was handed.
    seccomp: Option<heimdall_core::SeccompProfileRef>,
}

struct FakeHeimdall {
    key: BootKey,
    prog: Arc<Program>,
    twist: Twist,
    saw: Arc<Mutex<Saw>>,
}

fn kernel() -> KernelFeatures {
    KernelFeatures {
        release: "7.0.0-test".into(),
        seccomp_filter: true,
        user_notif: true,
        notif_addfd: true,
        landlock_abi: Some(6),
        cgroup_v2: true,
        cgroup_controllers: vec!["cpu".into(), "memory".into(), "pids".into()],
        user_namespaces: true,
    }
}

fn heimdall_features() -> Features {
    Features {
        runner: "heimdall".into(),
        container: true,
        seccomp: Protection::Default,
        landlock: true,
        broker: false,
        wasm: false,
        image_verified_each_start: true,
    }
}

fn tree(dir: &Path, base: &Path, out: &mut Vec<String>) {
    let mut es: Vec<_> = std::fs::read_dir(dir).unwrap().flatten().collect();
    es.sort_by_key(|e| e.file_name());
    for e in es {
        let rel = e.path().strip_prefix(base).unwrap().display().to_string();
        if e.file_type().unwrap().is_dir() {
            out.push(format!("{rel}/"));
            tree(&e.path(), base, out);
        } else {
            out.push(rel);
        }
    }
}

impl JobRunner for FakeHeimdall {
    fn run(&self, job: Job) -> Result<SignedResult, RunError> {
        let JobKind::Native { image, argv } = &job.kind else {
            return Err(RunError::Unsupported {
                what: "wasm".into(),
            });
        };
        // The contract: `/in` is input_dir, `/in/job.json` the worker's document.
        let wire: WireJob =
            serde_json::from_slice(&std::fs::read(job.input_dir.join("job.json")).unwrap())
                .unwrap();
        let mut t = Vec::new();
        tree(&job.input_dir, &job.input_dir, &mut t);
        *self.saw.lock().unwrap() = Saw {
            image: Some(image.clone()),
            argv: argv.clone(),
            wire: Some(wire.clone()),
            input_tree: t,
            limits: Some(job.policy.limits),
            input_nlink: {
                use std::os::unix::fs::MetadataExt as _;
                std::fs::metadata(job.input_dir.join("i/a"))
                    .unwrap()
                    .nlink()
            },
            net: job.policy.net.clone(),
            seccomp: job.policy.seccomp.clone(),
        };
        // The worker, as holger-scan-worker's main runs it over `/in`.
        let dirs = Dirs {
            input: job.input_dir.join("i"),
            read: (0..wire.reads)
                .map(|n| job.input_dir.join(format!("r{n}")))
                .collect(),
            out: job.output_dir.clone(),
        };
        let a = (self.prog)(&wire, &dirs);
        if self.twist != Twist::NoAnswer {
            assert_eq!(
                deliver(&wire, &dirs.out, &a).unwrap(),
                None,
                "the answer is a file"
            );
        }
        // Heimdall lists every regular file in /out itself.
        let mut outputs = Vec::new();
        let mut names: Vec<_> = std::fs::read_dir(&job.output_dir)
            .unwrap()
            .flatten()
            .map(|e| e.file_name().to_string_lossy().into_owned())
            .collect();
        names.sort();
        for n in names {
            let b = std::fs::read(job.output_dir.join(&n)).unwrap();
            outputs.push(Output::of(&n, &b));
        }
        if self.twist == Twist::WrongListing {
            outputs[0] = Output::of(outputs[0].name.clone(), b"not these bytes");
        }
        let (verdict, exit) = match self.twist.clone() {
            Twist::Refuse(code, exit) => (
                Verdict::Refused {
                    code: code.into(),
                    reason: "the fake says so".into(),
                },
                exit,
            ),
            _ => (Verdict::Accepted, ExitInfo::exited(0)),
        };
        let nonce = if self.twist == Twist::OtherNonce {
            heimdall_core::Nonce::random()
        } else {
            job.nonce
        };
        let result = JobResult {
            job_id: job.id,
            nonce,
            verdict,
            outputs,
            exit,
            started_unix: 1_800_000_000,
            finished_unix: 1_800_000_001,
            runner: heimdall_features(),
            events: Vec::new(),
            kernel: Some(kernel()),
        };
        let key = if self.twist == Twist::ForeignKey {
            BootKey::from_seed([3; 32], BootId([3; 16]))
        } else {
            BootKey::from_seed([7; 32], BootId([7; 16]))
        };
        key.sign(result).map_err(RunError::Sign)
    }
    fn verifying_key(&self) -> VerifyingKey {
        self.key.verifying_key()
    }
    fn features(&self) -> Features {
        heimdall_features()
    }
}

fn adapter(prog: Arc<Program>, twist: Twist) -> (HeimdallJobs, Arc<Mutex<Saw>>) {
    let key = BootKey::from_seed([7; 32], BootId([7; 16]));
    let pinned = key.pinned();
    let saw = Arc::new(Mutex::new(Saw::default()));
    let fake = FakeHeimdall {
        key,
        prog,
        twist,
        saw: saw.clone(),
    };
    let image = ImageRef {
        name: crate::WORKER.into(),
        digest: DIGEST.into(),
    };
    (
        HeimdallJobs::over(
            Box::new(fake),
            pinned,
            Some(kernel()),
            image,
            WORKER_BIN,
            ResultKey::from_seed([9; 32]),
        )
        .unwrap(),
        saw,
    )
}

/// The copy program: `in/a` → `out/a`, and `m/b` → `out/b` when there.
fn copy() -> Arc<Program> {
    Arc::new(|_w: &WireJob, d: &Dirs| {
        std::fs::copy(d.input.join("a"), d.out.join("a")).unwrap();
        let mut outs = vec!["a".to_string()];
        if d.input.join("m/b").exists() {
            std::fs::copy(d.input.join("m/b"), d.out.join("b")).unwrap();
            outs.push("b".into());
        }
        if let Some(r) = d.read.first() {
            std::fs::copy(r.join("rule"), d.out.join("rule")).unwrap();
            outs.push("rule".into());
        }
        Answer::accepted(outs)
    })
}

struct Dirs2 {
    root: tempfile::TempDir,
    input: PathBuf,
    out: PathBuf,
}

fn dirs() -> Dirs2 {
    let root = tempfile::tempdir().unwrap();
    let input = root.path().join("in");
    let out = root.path().join("out");
    std::fs::create_dir_all(input.join("m")).unwrap();
    std::fs::create_dir(&out).unwrap();
    std::fs::write(input.join("a"), b"alpha").unwrap();
    std::fs::write(input.join("m/b"), b"beta").unwrap();
    Dirs2 { root, input, out }
}

fn job(d: &Dirs2, policy: Policy) -> Job {
    Job::new(
        worker(&["scan"]),
        d.input.clone(),
        d.out.clone(),
        serde_json::json!({"x": 1}),
        policy,
    )
}

fn names(dir: &Path) -> Vec<String> {
    let mut v: Vec<String> = std::fs::read_dir(dir)
        .unwrap()
        .flatten()
        .map(|e| e.file_name().to_string_lossy().into_owned())
        .collect();
    v.sort();
    v
}

#[test]
fn a_job_runs_as_heimdalls_contract_says_and_its_answer_is_believed_only_after_heimdalls_signature()
{
    let (h, saw) = adapter(copy(), Twist::None);
    let d = dirs();
    let rules = tempfile::tempdir().unwrap();
    std::fs::write(rules.path().join("rule"), b"rule x { condition: true }").unwrap();
    let limits = heimdall_core::Limits {
        wall: std::time::Duration::from_secs(9),
        memory_bytes: 300 << 20,
        cpus: 0.5,
        pids: 32,
        ..Default::default()
    };
    let j = job(
        &d,
        Policy {
            read: vec![rules.path().to_path_buf()],
            limits,
            ..Default::default()
        },
    );
    let t = j.ticket();
    let s = h.run(j).unwrap();
    // The caller believes it with the ADAPTER's pinned key, as every Holger runner.
    let r = accept(&s, &h.verifying_key(), &t, &d.out).unwrap();
    assert_eq!(r.verdict, Verdict::Accepted);
    assert_eq!(
        r.outputs,
        vec![
            Output::of("a", b"alpha"),
            Output::of("b", b"beta"),
            Output::of("rule", b"rule x { condition: true }"),
        ]
    );
    assert_eq!(
        names(&d.out),
        ["a", "b", "rule"],
        "no answer file is handed over"
    );
    // Heimdall's own words travel unchanged: its per-job features and its kernel.
    assert_eq!(r.runner, heimdall_features());
    assert_eq!(r.kernel, Some(kernel()));
    assert_eq!(
        (r.started_unix, r.finished_unix),
        (1_800_000_000, 1_800_000_001)
    );
    assert_eq!(h.features().runner, "heimdall");
    assert_eq!(h.kernel(), Some(&kernel()));
    // What Heimdall was given: the pinned image, argv exactly the worker, the job's limits,
    // and the staged input: job.json (the parameters inside), i/ and r0/.
    let saw = saw.lock().unwrap().clone();
    assert_eq!(saw.image.unwrap().digest, DIGEST);
    assert_eq!(saw.argv, [WORKER_BIN]);
    assert_eq!(saw.limits.unwrap(), limits);
    assert_eq!(
        saw.input_tree,
        ["i/", "i/a", "i/m/", "i/m/b", "job.json", "r0/", "r0/rule"]
    );
    let w = saw.wire.unwrap();
    assert_eq!(w.argv, ["scan"]);
    assert_eq!(w.params, serde_json::json!({"x": 1}));
    assert_eq!(w.reads, 1);
    assert_eq!(w.answer.as_deref(), Some(ANSWER_FILE));
    // Nothing of the staging is left.
    assert_eq!(names(d.root.path()), ["in", "out"]);
}

#[test]
fn heimdalls_refusals_are_holgers_frozen_codes_with_heimdalls_code_first() {
    let killed = |k| ExitInfo {
        killed: Some(k),
        ..Default::default()
    };
    for (code, exit, holger) in [
        (
            "heimdall-limit-wall",
            killed(KillReason::Wall),
            &holger_errcode::SANDBOX_TIMEOUT,
        ),
        (
            "heimdall-limit-memory",
            killed(KillReason::Memory),
            &holger_errcode::SANDBOX_KILLED,
        ),
        (
            "heimdall-limit-pids",
            ExitInfo::exited(0),
            &holger_errcode::SANDBOX_KILLED,
        ),
        (
            "heimdall-seccomp-1",
            ExitInfo::default(),
            &holger_errcode::SANDBOX_KILLED,
        ),
        (
            "heimdall-exit-2",
            ExitInfo::default(),
            &holger_errcode::SANDBOX_KILLED,
        ),
        (
            "heimdall-exit-1",
            ExitInfo::exited(1),
            &holger_errcode::SANDBOX_WORKER_FAILED,
        ),
        (
            "heimdall-out-1",
            ExitInfo::exited(0),
            &holger_errcode::SANDBOX_JOB_UNDECLARED,
        ),
        (
            "heimdall-out-2",
            ExitInfo::exited(0),
            &holger_errcode::SANDBOX_JOB_UNDECLARED,
        ),
    ] {
        assert_eq!(holger_code_of(code), Some(holger));
        let (h, _) = adapter(copy(), Twist::Refuse(code, exit.clone()));
        let d = dirs();
        let j = job(&d, Policy::default());
        let t = j.ticket();
        let s = h.run(j).unwrap();
        let r = accept(&s, &h.verifying_key(), &t, &d.out).unwrap();
        let Verdict::Refused { code: c, reason } = &r.verdict else {
            panic!("{code}: {:?}", r.verdict)
        };
        assert_eq!(c, holger.code, "{code}");
        assert!(reason.contains(code), "{code}: {reason}");
        assert_eq!(r.exit, exit, "{code}");
        assert!(
            r.outputs.is_empty() && names(&d.out).is_empty(),
            "{code}: nothing handed over"
        );
    }
    assert_eq!(holger_code_of("heimdall-something-new"), None);
}

#[test]
fn a_heimdall_result_not_signed_by_the_pinned_key_for_this_job_or_with_other_outputs_is_never_used()
{
    for twist in [Twist::ForeignKey, Twist::OtherNonce, Twist::WrongListing] {
        let (h, _) = adapter(copy(), twist.clone());
        let d = dirs();
        let e = h.run(job(&d, Policy::default())).unwrap_err();
        assert!(matches!(e, RunError::Setup { .. }), "{twist:?}: {e:?}");
        assert!(names(&d.out).is_empty(), "{twist:?}: nothing handed over");
        assert_eq!(names(d.root.path()), ["in", "out"], "{twist:?}");
    }
}

#[test]
fn no_answer_an_undeclared_output_or_the_workers_own_refusal_hands_over_nothing() {
    let code = |r: &JobResult| match &r.verdict {
        Verdict::Refused { code, .. } => code.clone(),
        v => panic!("{v:?}"),
    };
    let (h, _) = adapter(copy(), Twist::NoAnswer);
    let d = dirs();
    let j = job(&d, Policy::default());
    let t = j.ticket();
    let s = h.run(j).unwrap();
    let r = accept(&s, &h.verifying_key(), &t, &d.out).unwrap();
    assert_eq!(code(r), holger_errcode::SANDBOX_JOB_ANSWER_INVALID.code);
    assert!(names(&d.out).is_empty());

    let sneaky: Arc<Program> = Arc::new(|_w: &WireJob, d: &Dirs| {
        std::fs::write(d.out.join("a"), b"a").unwrap();
        std::fs::write(d.out.join("extra"), b"x").unwrap();
        Answer::accepted(vec!["a".into()])
    });
    let (h, _) = adapter(sneaky, Twist::None);
    let d = dirs();
    let j = job(&d, Policy::default());
    let t = j.ticket();
    let s = h.run(j).unwrap();
    let r = accept(&s, &h.verifying_key(), &t, &d.out).unwrap();
    assert_eq!(code(r), holger_errcode::SANDBOX_JOB_UNDECLARED.code);
    assert!(names(&d.out).is_empty());

    let no: Arc<Program> = Arc::new(|_w: &WireJob, _d: &Dirs| {
        Answer::refused("holger-update-7", "the bundle is not signed")
    });
    let (h, _) = adapter(no, Twist::None);
    let d = dirs();
    let j = job(&d, Policy::default());
    let t = j.ticket();
    let s = h.run(j).unwrap();
    let r = accept(&s, &h.verifying_key(), &t, &d.out).unwrap();
    assert_eq!(code(r), "holger-update-7");
}

#[test]
fn a_wasm_job_a_network_a_foreign_image_a_writable_path_or_an_unpinned_image_is_refused() {
    let (h, saw) = adapter(copy(), Twist::None);
    let d = dirs();
    let mut w = job(&d, Policy::default());
    w.kind = JobKind::wasm(heimdall_core::GuestRef {
        path: "/g.wasm".into(),
        blake3: "00".into(),
    });
    assert!(matches!(h.run(w), Err(RunError::Unsupported { .. })));
    let mut n = job(&d, Policy::default());
    n.policy.net = Some(NetAllow {
        peers: vec![heimdall_core::NetPeer::new(
            "upstream.test",
            "10.0.0.1",
            443,
        )],
    });
    assert!(matches!(h.run(n), Err(RunError::Unsupported { .. })));
    let mut f = job(&d, Policy::default());
    f.kind = JobKind::Native {
        image: ImageRef {
            name: "docker.io/library/busybox".into(),
            digest: DIGEST.into(),
        },
        argv: vec!["/bin/sh".into()],
    };
    assert!(matches!(h.run(f), Err(RunError::Unsupported { .. })));
    let mut wr = job(&d, Policy::default());
    wr.policy.write = vec!["/var/tmp".into()];
    assert!(matches!(h.run(wr), Err(RunError::Unsupported { .. })));
    assert!(
        saw.lock().unwrap().image.is_none(),
        "none of them reached Heimdall"
    );
    let key = BootKey::from_seed([7; 32], BootId([7; 16]));
    let unpinned = HeimdallJobs::over(
        Box::new(FakeHeimdall {
            key: BootKey::from_seed([7; 32], BootId([7; 16])),
            prog: copy(),
            twist: Twist::None,
            saw,
        }),
        key.pinned(),
        None,
        ImageRef {
            name: crate::WORKER.into(),
            digest: String::new(),
        },
        WORKER_BIN,
        ResultKey::from_seed([9; 32]),
    );
    assert!(matches!(unpinned, Err(RunError::InvalidJob { .. })));
}

/// The worker image minted from the medium's rootfs tar is an OCI layout Heimdall's own
/// image code verifies and unpacks (heimdall-plain `OciLayout`, which HeimdallRunner
/// uses): the same tar gives the same digest, a changed byte another.
#[test]
fn the_worker_image_minted_from_its_rootfs_tar_is_one_heimdall_verifies_and_unpacks() {
    let tar = {
        let mut t = tar::Builder::new(Vec::new());
        let mut h = tar::Header::new_gnu();
        h.set_size(5);
        h.set_mode(0o555);
        t.append_data(&mut h, "holger-scan-worker", &b"#!x\n\n"[..])
            .unwrap();
        t.into_inner().unwrap()
    };
    let a = tempfile::tempdir().unwrap();
    let b = tempfile::tempdir().unwrap();
    let ia = mint_layout(a.path(), &tar, WORKER_ENV).unwrap();
    let ib = mint_layout(b.path(), &tar, WORKER_ENV).unwrap();
    assert_eq!(ia, ib, "deterministic");
    assert!(ia.sha256().is_some());
    let root = tempfile::tempdir().unwrap();
    let u = heimdall_plain::image::OciLayout::new(a.path())
        .unpack(&ia, root.path())
        .unwrap();
    assert_eq!(u.env, WORKER_ENV);
    assert_eq!(u.layers, 1);
    assert_eq!(
        std::fs::read(root.path().join("holger-scan-worker")).unwrap(),
        b"#!x\n\n"
    );
    let mut other = tar.clone();
    other[512] ^= 1;
    assert_ne!(mint_layout(b.path(), &other, WORKER_ENV).unwrap(), ia);
}

#[test]
fn the_heimdall_block_refuses_what_it_cannot_mean() {
    use crate::heimdall::HeimdallSettings as S;
    let ok = S {
        image_layout: "/var/lib/holger/oci".into(),
        image: Some(DIGEST.into()),
        ..S::default()
    };
    assert_eq!(ok.validate(), Ok(()));
    let bad = [
        S {
            image_layout: "oci".into(),
            ..ok.clone()
        },
        S {
            image: Some("latest".into()),
            ..ok.clone()
        },
        S {
            image: None,
            rootfs_tar: None,
            ..ok.clone()
        },
        S {
            image: None,
            rootfs_tar: Some("w.tar".into()),
            ..ok.clone()
        },
        S {
            shim: Some("shim".into()),
            ..ok.clone()
        },
        S {
            worker: "holger-scan-worker".into(),
            ..ok.clone()
        },
    ];
    for b in bad {
        assert!(b.validate().is_err(), "{b:?}");
    }
    let minted = S {
        image: None,
        rootfs_tar: Some("/data/holger/sandbox/holger-scan-worker.rootfs.tar".into()),
        ..ok
    };
    assert_eq!(minted.validate(), Ok(()));
    let ron: S = ron::from_str(
        r#"(image_layout: "/oci", image: Some("sha256:1111111111111111111111111111111111111111111111111111111111111111"))"#,
    )
    .unwrap();
    assert!(ron.strict && ron.fallback_plain && ron.worker == WORKER_BIN);
}

/// Lane HOL-HEIMDALL-03 with Heimdall 0.2.1: as root under `require_rootless`, a job's root
/// maps to the host id range at crun's base (100000, 65536 ids), never to host root; as an
/// ordinary user, or without the rule, it maps to the server's own uid (no range). Heimdall is
/// no longer refused for running as root: a missing shim is what stops this one.
#[test]
fn as_root_with_require_rootless_a_jobs_root_maps_to_the_host_id_range() {
    use crate::heimdall::{host_ids_for, start_as, HeimdallSettings, HOST_IDS_COUNT};
    assert_eq!(HOST_IDS_COUNT, 65536);
    assert_eq!(host_ids_for(true, 0, 100000, 65536), Some((100000, 65536)));
    assert_eq!(host_ids_for(true, 1000, 100000, 65536), None);
    assert_eq!(host_ids_for(false, 0, 100000, 65536), None);
    let dir = tempfile::tempdir().unwrap();
    let s = HeimdallSettings {
        image_layout: dir.path().join("oci").display().to_string(),
        image: Some(DIGEST.into()),
        shim: Some(dir.path().join("no-shim").display().to_string()),
        plain_shim: Some(dir.path().join("no-shim").display().to_string()),
        ..Default::default()
    };
    assert_eq!(s.host_id_count, HOST_IDS_COUNT);
    let e =
        start_as(&s, dir.path(), host_ids_for(true, 0, 100000, 65536)).expect_err("no shim here");
    assert!(e.contains("no-shim") && !e.contains("host root"), "{e}");
}

/// With a host id range (Heimdall 0.2.1 gives the job's root its input tree), the inputs are
/// staged as copies, so the caller's own files are never handed over through a hard link.
#[test]
fn with_a_host_id_range_the_inputs_are_copies_not_hard_links() {
    let (h, saw) = adapter(copy(), Twist::None);
    assert_eq!(h.host_ids(), None);
    let d = dirs();
    h.run(job(&d, Policy::default())).unwrap();
    assert_eq!(
        saw.lock().unwrap().input_nlink,
        2,
        "a hard link without a range"
    );
    let (h, saw) = adapter(copy(), Twist::None);
    let h = h.with_host_ids(Some((100000, 65536)));
    assert_eq!(h.host_ids(), Some((100000, 65536)));
    let d = dirs();
    let j = job(&d, Policy::default());
    let t = j.ticket();
    let s = h.run(j).unwrap();
    assert_eq!(saw.lock().unwrap().input_nlink, 1, "a copy with a range");
    assert!(accept(&s, &h.verifying_key(), &t, &d.out).is_ok());
}

/// ★ **A fetch adapter hands Heimdall its job's declared peers** (lane cds4, HOL-CDS-4): the fetching
/// medium's worker runs as `HeimdallJobs::with_network`, and only its `fetch` program gets the
/// job's `policy.net` (the address Heimdall's broker connects); any other program, and every
/// job of a scan adapter, is still refused a network before it reaches Heimdall.
#[test]
fn only_a_fetch_adapter_hands_heimdall_a_fetch_jobs_declared_peers() {
    let peer = NetAllow {
        peers: vec![
            heimdall_core::NetPeer::new("pypi.org", "151.101.0.223", 443),
            heimdall_core::NetPeer::new("files.pythonhosted.org", "151.101.64.223", 443),
        ],
    };
    let (h, saw) = adapter(copy(), Twist::None);
    let h = h.with_network();
    let d = dirs();
    let mut f = job(&d, Policy::default());
    f.kind = worker(&["fetch"]);
    f.policy.net = Some(peer.clone());
    h.run(f).expect("a fetch job runs with its peer");
    assert_eq!(saw.lock().unwrap().net, Some(peer.clone()));
    let d = dirs();
    let mut s = job(&d, Policy::default());
    s.policy.net = Some(peer.clone());
    assert!(
        matches!(h.run(s), Err(RunError::Unsupported { .. })),
        "a scan job gets no network, even from a fetch adapter"
    );
    let (plain, _) = adapter(copy(), Twist::None);
    let d = dirs();
    let mut f = job(&d, Policy::default());
    f.kind = worker(&["fetch"]);
    f.policy.net = Some(peer);
    assert!(
        matches!(plain.run(f), Err(RunError::Unsupported { .. })),
        "a scan adapter gives no job a network"
    );
}

/// ★ **A fetch job never runs under the scan worker's generated profile** (hol312: prof + cds4):
/// the generated seccomp profile is the scan worker's own (lane prof); a fetch job runs the fetch
/// worker, a different program, so its job keeps Heimdall's fixed profile even when the adapter
/// carries the scan worker's; a scan job of the same adapter still gets it.
#[test]
fn a_fetch_job_does_not_take_the_scan_workers_generated_profile() {
    let profile = heimdall_core::SeccompProfileRef {
        source: heimdall_core::ProfileSource::OciFile("/p/holger-scan-worker.json".into()),
        sha256: format!("sha256:{}", "a".repeat(64)),
        fallback_to_default: false,
    };
    let (h, saw) = adapter(copy(), Twist::None);
    let h = h.with_network().with_seccomp(Some(profile.clone()));
    let d = dirs();
    let mut f = job(&d, Policy::default());
    f.kind = worker(&["fetch"]);
    f.policy.net = Some(NetAllow {
        peers: vec![heimdall_core::NetPeer::new(
            "pypi.org",
            "151.101.0.223",
            443,
        )],
    });
    h.run(f).expect("the fetch job runs");
    assert_eq!(
        saw.lock().unwrap().seccomp,
        None,
        "the fetch job: Heimdall's fixed profile"
    );
    let d = dirs();
    h.run(job(&d, Policy::default()))
        .expect("the scan job runs");
    assert_eq!(
        saw.lock().unwrap().seccomp,
        Some(profile),
        "a scan job: the generated profile"
    );
}