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

nordisk/edda

crates/nornir-build-thing/src/syscall_join/tests.rs

//! The join on small synthetic kernel trees and synthetic profiles: the map
//! is read off the tree (never hand-written), the join names programs and
//! calls, and a drop that would ENOSYS a measured call is refused by name.

use std::collections::BTreeSet;
use std::path::Path;

use super::*;
use crate::kernel_trim::{Composed, Composer, RowKernel, composed_paths, parse_kernel_verb, run_kernel_verb};

fn s(v: &[&str]) -> Vec<String> {
    v.iter().map(|x| x.to_string()).collect()
}

fn put(root: &Path, rel: &str, text: &str) {
    let p = root.join(rel);
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
    std::fs::write(p, text).unwrap();
}

const TABLE: &str = "\
# comment line
0\tcommon\tread\t\t\tsys_read
4\tcommon\tstat\t\t\tsys_newstat
9\tcommon\tmmap\t\t\tsys_mmap
232\tcommon\tepoll_wait\t\tsys_epoll_wait
321\tcommon\tbpf\t\t\tsys_bpf
330\tcommon\tpkey_alloc\t\tsys_pkey_alloc
400\tcommon\tsignalx\t\tsys_signalx
401\t64\tsigx_extra\t\tsys_sigx_extra
512\tx32\trt_sigaction\t\tcompat_sys_rt_sigaction
600\tcommon\treserved_one
";

const KCONFIG: &str = r#"
config BPF
	bool

config BPF_SYSCALL
	bool "bpf syscall"
	select BPF

config EPOLL
	bool "epoll"
	default y

config SIGNALFD_X
	bool "signalx"

config SIGX_EXTRA
	bool "sigx extra"
	depends on SIGNALFD_X

config ARCH_HAS_PKEYS
	bool
"#;

const FRAGMENT: &str = "CONFIG_BPF_SYSCALL=y\nCONFIG_EPOLL=y\nCONFIG_SIGNALFD_X=y\nCONFIG_SIGX_EXTRA=y\n";
const BUILT: &str = "CONFIG_BPF=y\nCONFIG_BPF_SYSCALL=y\nCONFIG_EPOLL=y\nCONFIG_SIGNALFD_X=y\nCONFIG_SIGX_EXTRA=y\n";

/// A tiny kernel tree: Makefile gates (a directory gate, an object gate, a
/// composite object with a gated part), `#ifdef` gates, a compat definition,
/// a definition in another arch (ignored), `COND_SYSCALL` stubs and the
/// x86_64 table.
fn fake_tree(root: &Path) {
    put(root, "Kconfig", KCONFIG);
    put(root, "arch/x86/entry/syscalls/syscall_64.tbl", TABLE);
    put(root, "fs/Makefile", "obj-y := read_write.o stat.o \\\n\topen.o\nobj-$(CONFIG_EPOLL) += eventpoll.o\n");
    put(root, "fs/read_write.c", "SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)\n{\n}\n");
    put(root, "fs/stat.c", "SYSCALL_DEFINE2(newstat, const char __user *, filename,\n\t\tstruct stat __user *, statbuf)\n{}\n#ifdef CONFIG_COMPAT\nCOMPAT_SYSCALL_DEFINE2(newstat, const char __user *, filename,\n\t\tstruct compat_stat __user *, statbuf)\n{}\n#endif\n");
    put(root, "fs/eventpoll.c", "SYSCALL_DEFINE4(epoll_wait, int, epfd, struct epoll_event __user *, events,\n\t\tint, maxevents, int, timeout)\n{}\n");
    put(root, "kernel/Makefile", "obj-y += sys_ni.o\nobj-$(CONFIG_BPF) += bpf/\nobj-$(CONFIG_SIGNALFD_X) += sigx.o\nsigx-y := sigx_main.o\nsigx-$(CONFIG_SIGX_EXTRA) += sigx_extra.o\n");
    put(root, "kernel/bpf/Makefile", "obj-$(CONFIG_BPF_SYSCALL) += syscall.o verifier.o\n");
    put(root, "kernel/bpf/syscall.c", "SYSCALL_DEFINE3(bpf, int, cmd, union bpf_attr __user *, uattr, unsigned int, size)\n{}\n");
    put(root, "kernel/sigx_main.c", "SYSCALL_DEFINE1(signalx, int, x)\n{}\n");
    put(root, "kernel/sigx_extra.c", "SYSCALL_DEFINE1(sigx_extra, int, x)\n{}\n");
    put(root, "kernel/sys_ni.c", "/* stubs */\n#define COND_SYSCALL(name) cond_syscall(sys_##name)\nCOND_SYSCALL(bpf);\nCOND_SYSCALL(epoll_wait);\nCOND_SYSCALL(pkey_alloc);\nCOND_SYSCALL(signalx);\nCOND_SYSCALL(sigx_extra);\nCOND_SYSCALL_COMPAT(newstat);\n");
    put(root, "mm/Makefile", "obj-y += mprotect.o\n");
    put(root, "mm/mprotect.c", "#ifdef CONFIG_ARCH_HAS_PKEYS\nSYSCALL_DEFINE2(pkey_alloc, unsigned long, flags, unsigned long, init_val)\n{}\n#endif /* CONFIG_ARCH_HAS_PKEYS */\n");
    put(root, "arch/x86/Kbuild", "obj-y += kernel/\n");
    put(root, "arch/x86/kernel/Makefile", "obj-y += sys_x86_64.o\n");
    put(root, "arch/x86/kernel/sys_x86_64.c", "SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len,\n\t\tunsigned long, prot, unsigned long, flags,\n\t\tunsigned long, fd, unsigned long, off)\n{}\n");
    put(root, "arch/arm64/kernel/Makefile", "obj-$(CONFIG_ARM64_ONLY) += sys.o\n");
    put(root, "arch/arm64/kernel/sys.c", "SYSCALL_DEFINE6(mmap, unsigned long, addr)\n{}\n");
}

/// The synthetic profiles of row `node`: `tunnr-cni` calls bpf in a run
/// that SUCCEEDS; `node-pid1` calls epoll_wait only in an error-path run.
fn fake_profiles(repo: &Path) {
    let row = repo.join(".nornir/profiles/node");
    put(
        &row,
        "measure.json",
        r#"{"row":"node","tool":{"crate":"nordisk-heimdall-cli","version":"0.2.0","sha256":"00"},
            "programs":[
              {"program":"tunnr-cni","libc":"musl","arch":"x86_64","syscalls":["bpf","read","stat"],"profile":".nornir/profiles/node/tunnr-cni/x86_64-musl"},
              {"program":"node-pid1","libc":"musl","arch":"x86_64","syscalls":["epoll_wait","read","mmap"],"profile":".nornir/profiles/node/node-pid1/x86_64-musl"}
            ],
            "vm_later":[{"program":"korp-start","why":"parks forever"},{"program":"tunnr-cni","why":"not covered yet: ADD success"}]}"#,
    );
    put(
        &row,
        "tunnr-cni/x86_64-musl.probe.json",
        r#"{"workload":"tunnr-cni","arch":"x86_64","libc":"musl",
            "runs":[{"name":"add_attaches_bpf","error_path":false,"exit":"exit 0"},{"name":"refused_without_subnet","error_path":true,"exit":"exit 1"}],
            "calls":[
              {"syscall":"bpf","runs":["add_attaches_bpf"]},
              {"syscall":"read","runs":["add_attaches_bpf","refused_without_subnet"]},
              {"syscall":"stat","args":{"1":"0x2"},"runs":["refused_without_subnet"]},
              {"syscall":"stat","runs":["add_attaches_bpf"]}
            ]}"#,
    );
    put(
        &row,
        "node-pid1/x86_64-musl.probe.json",
        r#"{"workload":"node-pid1","arch":"x86_64","libc":"musl",
            "runs":[{"name":"not_pid_1_refuses","error_path":true,"exit":"exit 78"}],
            "calls":[
              {"syscall":"epoll_wait","runs":["not_pid_1_refuses"]},
              {"syscall":"read","runs":["not_pid_1_refuses"]},
              {"syscall":"mmap","runs":["not_pid_1_refuses"]}
            ]}"#,
    );
}

fn map() -> SyscallMap {
    let d = tempfile::tempdir().unwrap();
    fake_tree(d.path());
    SyscallMap::read(d.path(), "x86_64", "6.12.104").unwrap()
}

fn measured() -> MeasuredRow {
    let d = tempfile::tempdir().unwrap();
    fake_profiles(d.path());
    MeasuredRow::load(d.path(), "node").unwrap().expect("the row has profiles")
}

#[test]
fn the_map_is_read_off_the_tree_definitions_gates_stubs_and_the_x86_64_table() {
    let m = map();
    assert_eq!(m.schema, SYSCALL_MAP_SCHEMA);
    assert_eq!((m.version.as_str(), m.arch.as_str()), ("6.12.104", "x86_64"));
    // A directory gate and an object gate, and the COND_SYSCALL stub.
    let bpf = &m.syscalls["bpf"];
    assert_eq!((bpf.nr, bpf.entry.as_str()), (321, "sys_bpf"));
    assert_eq!(bpf.needs(), s(&["CONFIG_BPF", "CONFIG_BPF_SYSCALL"]));
    assert!(bpf.stub, "COND_SYSCALL(bpf): ENOSYS when off");
    assert_eq!(bpf.defs.len(), 1);
    assert_eq!(bpf.defs[0].file, "kernel/bpf/syscall.c");
    // Always built.
    assert!(m.syscalls["read"].needs().is_empty());
    assert!(!m.syscalls["read"].stub);
    // The table's entry names the definition (`stat` is `newstat`); the
    // compat definition is kept and marked, but needs come from native ones.
    let stat = &m.syscalls["stat"];
    assert_eq!(stat.entry, "sys_newstat");
    assert_eq!(stat.defs.iter().filter(|d| d.compat).count(), 1);
    assert_eq!(stat.defs.iter().filter(|d| !d.compat).count(), 1);
    assert!(stat.needs().is_empty(), "{:?}", stat.defs);
    // Another arch's definition is never read.
    assert_eq!(m.syscalls["mmap"].defs.len(), 1, "{:?}", m.syscalls["mmap"].defs);
    assert_eq!(m.syscalls["mmap"].defs[0].file, "arch/x86/kernel/sys_x86_64.c");
    // An #ifdef gate inside a file that is always built.
    assert_eq!(m.syscalls["pkey_alloc"].needs(), s(&["CONFIG_ARCH_HAS_PKEYS"]));
    // A composite object, and a gated part of it.
    assert_eq!(m.syscalls["signalx"].needs(), s(&["CONFIG_SIGNALFD_X"]));
    assert_eq!(m.syscalls["sigx_extra"].needs(), s(&["CONFIG_SIGNALFD_X", "CONFIG_SIGX_EXTRA"]));
    // x32 rows and rows with no entry are not x86_64 calls.
    assert!(!m.syscalls.contains_key("rt_sigaction"));
    assert!(!m.syscalls.contains_key("reserved_one"));
    // What one option alone takes away.
    let g = m.gating_options();
    assert_eq!(g["CONFIG_SIGNALFD_X"], s(&["signalx", "sigx_extra"]));
    assert_eq!(g["CONFIG_SIGX_EXTRA"], s(&["sigx_extra"]));
    assert!(!g.contains_key("CONFIG_COMPAT"), "a compat-only gate takes no x86_64 call away");
}

#[test]
fn the_map_is_cached_per_kernel_version() {
    let d = tempfile::tempdir().unwrap();
    let src = d.path().join("linux");
    fake_tree(&src);
    let cache = d.path().join("cache");
    let a = SyscallMap::cached(&src, &cache, "x86_64", "6.12.104").unwrap();
    assert!(cache.join("6.12.104-x86_64.json").is_file());
    std::fs::remove_dir_all(&src).unwrap();
    let b = SyscallMap::cached(&src, &cache, "x86_64", "6.12.104").unwrap();
    assert_eq!(a, b, "the second read comes from the cache");
    assert!(SyscallMap::cached(&src, &cache, "x86_64", "6.13.1").is_err(), "another version is not the cached one");
    assert!(SyscallMap::read(d.path(), "aarch64", "6.12.104").is_err(), "only x86_64 is wired");
}

#[test]
fn ifdef_gates_follow_nesting_else_and_simple_expressions() {
    let text = "\
#ifdef CONFIG_A
SYSCALL_DEFINE0(a)
#ifndef CONFIG_B
SYSCALL_DEFINE0(a_not_b)
#else
SYSCALL_DEFINE0(a_and_b)
#endif
#endif
#if IS_ENABLED(CONFIG_C) && defined(CONFIG_D)
SYSCALL_DEFINE1(cd, int, x)
#elif defined(CONFIG_E)
SYSCALL_DEFINE1(e_or_whatever, int, x)
#endif
#if defined(CONFIG_F) || defined(CONFIG_G)
SYSCALL_DEFINE0(either)
#endif
#if !IS_ENABLED(CONFIG_H)
SYSCALL_DEFINE0(not_h)
#else
COMPAT_SYSCALL_DEFINE0(h)
#endif
#define SYSCALL_DEFINE7(x) nothing
  SYSCALL_DEFINE0(plain)
";
    let got = syscall_defines(text);
    let find = |n: &str| got.iter().find(|d| d.0 == n).unwrap_or_else(|| panic!("{n} missing: {got:?}"));
    assert_eq!(find("a").2, s(&["CONFIG_A"]));
    assert_eq!(find("a_not_b").2, s(&["CONFIG_A"]));
    assert_eq!(find("a_and_b").2, s(&["CONFIG_A", "CONFIG_B"]));
    assert_eq!(find("cd").2, s(&["CONFIG_C", "CONFIG_D"]));
    assert!(find("e_or_whatever").2.is_empty(), "an #elif is not read");
    assert!(find("either").2.is_empty(), "an || is not a gate");
    assert!(find("not_h").2.is_empty());
    let h = find("h");
    assert!(h.1, "compat");
    assert_eq!(h.2, s(&["CONFIG_H"]));
    assert!(find("plain").2.is_empty());
    assert_eq!(got.len(), 9, "{got:?}");
}

#[test]
fn measured_rows_read_the_probe_and_mark_near_misses() {
    let m = measured();
    assert_eq!(m.row, "node");
    assert_eq!(m.vm_later, s(&["korp-start", "tunnr-cni"]));
    let cni = m.programs.iter().find(|p| p.program == "tunnr-cni").unwrap();
    let call = |n: &str| cni.calls.iter().find(|c| c.syscall == n).unwrap();
    assert!(!call("bpf").near_miss);
    assert_eq!(call("bpf").runs, s(&["add_attaches_bpf"]));
    // Two probe entries of one call (other args) are one call; it ran on a
    // success path, so it is not a near-miss.
    assert_eq!(cni.calls.iter().filter(|c| c.syscall == "stat").count(), 1);
    assert_eq!(call("stat").runs, s(&["add_attaches_bpf", "refused_without_subnet"]));
    assert!(!call("stat").near_miss);
    let pid1 = m.programs.iter().find(|p| p.program == "node-pid1").unwrap();
    assert!(pid1.calls.iter().all(|c| c.near_miss), "{:?}", pid1.calls);
    // A row with no measure.json has no profiles (not an error).
    let d = tempfile::tempdir().unwrap();
    assert!(MeasuredRow::load(d.path(), "node").unwrap().is_none());
}

#[test]
fn needs_names_the_options_by_program_and_call() {
    let n = needs(&map(), &measured(), BUILT);
    assert_eq!(n.schema, KERNEL_NEEDS_SCHEMA);
    let opt = |o: &str| n.options.iter().find(|x| x.option == o).unwrap_or_else(|| panic!("{o}: {:?}", n.options));
    let bpf = opt("CONFIG_BPF_SYSCALL");
    assert!(bpf.enabled);
    assert_eq!(bpf.callers.len(), 1);
    assert_eq!((bpf.callers[0].program.as_str(), bpf.callers[0].syscall.as_str()), ("tunnr-cni", "bpf"));
    assert!(opt("CONFIG_EPOLL").callers[0].near_miss);
    // A measured call whose option the row does NOT enable: it gets ENOSYS today.
    assert!(n.options.iter().all(|o| o.option != "CONFIG_ARCH_HAS_PKEYS"), "nobody measured pkey_alloc");
    // The inverse hint: syscall-gating options this row enables that no
    // measured call needs; never a veto, only a hint.
    let nc: Vec<&str> = n.no_measured_caller.iter().map(|x| x.option.as_str()).collect();
    assert_eq!(nc, vec!["CONFIG_SIGNALFD_X", "CONFIG_SIGX_EXTRA"]);
    assert_eq!(n.no_measured_caller[0].syscalls, s(&["signalx", "sigx_extra"]));
    // Calls with no gate at all.
    assert!(n.ungated.contains(&"read".to_string()), "{:?}", n.ungated);
    let text = n.render();
    assert!(text.contains("tunnr-cni bpf -> CONFIG_BPF, CONFIG_BPF_SYSCALL (test add_attaches_bpf)"), "{text}");
    assert!(text.contains("node-pid1 epoll_wait -> CONFIG_EPOLL (near-miss: not_pid_1_refuses)"), "{text}");
    assert!(text.contains("no measured caller"), "{text}");
    assert!(text.contains("not measured yet (VM): korp-start\n"), "{text}");
    assert!(text.contains("partly measured (runs wait for the VM): tunnr-cni\n"), "{text}");
    // An option the row has OFF that a measured call needs is shouted.
    let off = needs(&map(), &measured(), &BUILT.replace("CONFIG_EPOLL=y\n", ""));
    assert!(!off.options.iter().find(|x| x.option == "CONFIG_EPOLL").unwrap().enabled);
    assert!(off.render().contains("OFF"), "{}", off.render());
}

#[test]
fn a_drop_that_would_enosys_a_measured_call_is_refused_by_name_and_a_near_miss_only_warns() {
    let (m, r) = (map(), measured());
    let gone = |v: &[&str]| -> BTreeSet<String> { v.iter().map(|x| x.to_string()).collect() };
    let v = vetoes(&m, &r, &gone(&["CONFIG_BPF_SYSCALL"]));
    assert_eq!(v.len(), 1, "{v:?}");
    assert!(!v[0].near_miss);
    assert_eq!(v[0].render(), "CONFIG_BPF_SYSCALL: tunnr-cni calls bpf (test add_attaches_bpf)");
    let v = vetoes(&m, &r, &gone(&["CONFIG_EPOLL"]));
    assert_eq!(v.len(), 1);
    assert!(v[0].near_miss);
    assert_eq!(v[0].render(), "CONFIG_EPOLL: node-pid1 calls epoll_wait (near-miss: not_pid_1_refuses)");
    assert!(vetoes(&m, &r, &gone(&["CONFIG_SIGNALFD_X", "CONFIG_SIGX_EXTRA"])).is_empty());
}

// ── the verbs, end to end through a fixture composer ────────────────────────

struct FakeComposer;

impl Composer for FakeComposer {
    fn compose(&self, k: &crate::kernel::KernelBuild, root: &Path) -> anyhow::Result<Composed> {
        let c = composed_paths(k, root);
        fake_tree(&c.src);
        let frag = k.decision_fragment.clone().unwrap_or_default();
        let mut cfg = BUILT.to_string();
        if frag.contains("# CONFIG_SIGX_EXTRA is not set") {
            cfg = cfg.replace("CONFIG_SIGX_EXTRA=y\n", "");
        }
        std::fs::write(&c.config, &cfg).unwrap();
        std::fs::write(&c.base_config, "").unwrap();
        std::fs::write(&c.bz_image, vec![0u8; if cfg == BUILT { 1000 } else { 990 }]).unwrap();
        Ok(c)
    }
}

fn row() -> RowKernel {
    let mut chosen = crate::kernel::KernelBuild { name: "k".into(), version: "6.12.104".into(), ..Default::default() };
    chosen.profile = "vanilla".into();
    chosen.preset = "full".into();
    RowKernel { row: "node".into(), line: "--build kernel".into(), chosen, user_fragment: Some(FRAGMENT.into()) }
}

fn run(args: &[&str], repo: Option<&Path>, work: &Path) -> anyhow::Result<String> {
    let mut v = parse_kernel_verb(&s(args)).unwrap();
    v.repo_root = repo.map(Path::to_path_buf);
    run_kernel_verb(&v, &row(), &FakeComposer, work)
}

#[test]
fn what_if_refuses_a_drop_a_measured_call_needs_and_measure_never_builds_it() {
    let (repo, work) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
    fake_profiles(repo.path());
    let out = run(&["what-if", "node", "--drop", "CONFIG_BPF_SYSCALL"], Some(repo.path()), work.path()).unwrap();
    assert!(out.contains("verdict: REFUSED"), "{out}");
    assert!(out.contains("REFUSED by a measured call: CONFIG_BPF_SYSCALL: tunnr-cni calls bpf (test add_attaches_bpf)"), "{out}");
    let e = run(&["what-if", "node", "--drop", "CONFIG_BPF_SYSCALL", "--measure"], Some(repo.path()), work.path()).unwrap_err();
    assert!(format!("{e:#}").contains("--measure refused"), "{e:#}");
    // A near-miss only warns; the what-if stays clean and builds.
    let out = run(&["what-if", "node", "--drop", "CONFIG_EPOLL"], Some(repo.path()), work.path()).unwrap();
    assert!(out.contains("verdict: CLEAN"), "{out}");
    assert!(out.contains("WARN near-miss: CONFIG_EPOLL: node-pid1 calls epoll_wait (near-miss: not_pid_1_refuses)"), "{out}");
    let out = run(&["what-if", "node", "--drop", "CONFIG_SIGX_EXTRA", "--measure"], Some(repo.path()), work.path()).unwrap();
    assert!(out.contains("verdict: CLEAN") && out.contains("inverse: HOLDS"), "{out}");
    assert!(out.contains("syscalls: no measured call loses its kernel code"), "{out}");
    // No profiles: said, never silently passed.
    let out = run(&["what-if", "node", "--drop", "CONFIG_BPF_SYSCALL"], None, work.path()).unwrap();
    assert!(out.contains("syscalls: not checked"), "{out}");
    // The JSON form carries the same verdict.
    let j = run(&["what-if", "node", "--drop", "CONFIG_BPF_SYSCALL", "--json"], Some(repo.path()), work.path()).unwrap();
    let j: serde_json::Value = serde_json::from_str(&j).unwrap();
    assert_eq!(j["schema"], KERNEL_WHAT_IF_SCHEMA);
    assert_eq!(j["verdict"], "REFUSED");
    assert_eq!(j["what_if"]["syscall_vetoes"][0]["option"], "CONFIG_BPF_SYSCALL");
    assert_eq!(j["what_if"]["syscall_vetoes"][0]["program"], "tunnr-cni");
}

#[test]
fn candidates_drop_vetoed_symbols_by_name_and_mark_no_measured_caller() {
    let (repo, work) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
    fake_profiles(repo.path());
    let out = run(&["candidates", "node"], Some(repo.path()), work.path()).unwrap();
    let none = run(&["candidates", "node"], None, work.path()).unwrap();
    assert!(none.contains("# syscalls: not checked"), "{none}");
    assert!(out.contains("vetoed by a measured call (1): CONFIG_BPF_SYSCALL: tunnr-cni calls bpf (test add_attaches_bpf)"), "{out}");
    let row_of = |sym: &str| out.lines().find(|l| l.contains(&format!(" {sym} "))).map(str::to_string);
    assert!(row_of("CONFIG_BPF_SYSCALL").is_none(), "{out}");
    assert!(row_of("CONFIG_SIGNALFD_X").unwrap().contains("no measured caller"), "{out}");
    assert!(row_of("CONFIG_EPOLL").unwrap().contains("near-miss"), "{out}");
    let j = run(&["candidates", "node", "--json"], Some(repo.path()), work.path()).unwrap();
    let j: serde_json::Value = serde_json::from_str(&j).unwrap();
    assert_eq!(j["schema"], KERNEL_CANDIDATES_SCHEMA);
    assert_eq!(j["candidates"]["syscall_vetoed"][0]["option"], "CONFIG_BPF_SYSCALL");
    let rows = j["candidates"]["rows"].as_array().unwrap();
    let sig = rows.iter().find(|r| r["symbol"] == "CONFIG_SIGNALFD_X").unwrap();
    assert_eq!(sig["no_measured_caller"], true);
}

#[test]
fn the_needs_verb_text_and_json() {
    let (repo, work) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
    fake_profiles(repo.path());
    let out = run(&["needs", "node"], Some(repo.path()), work.path()).unwrap();
    assert!(out.contains("tunnr-cni bpf -> CONFIG_BPF, CONFIG_BPF_SYSCALL"), "{out}");
    let j = run(&["needs", "node", "--json"], Some(repo.path()), work.path()).unwrap();
    let j: serde_json::Value = serde_json::from_str(&j).unwrap();
    assert_eq!(j["schema"], KERNEL_NEEDS_SCHEMA);
    assert_eq!(j["row"], "node");
    // The map is cached under the work dir, per kernel version.
    assert!(work.path().join("syscall-map/6.12.104-x86_64.json").is_file());
    // No profiles: the needs verb refuses by name.
    let e = run(&["needs", "node"], None, work.path()).unwrap_err();
    assert!(format!("{e:#}").contains("no measured profiles"), "{e:#}");
    assert!(parse_kernel_verb(&s(&["needs", "node", "--drop", "CONFIG_X"])).is_err());
}

/// The needs document reads back (the review pane in dwarves-gui reads a saved
/// `kernel needs <row> --json`).
#[test]
fn the_needs_json_reads_back() {
    let (repo, work) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
    fake_profiles(repo.path());
    let j = run(&["needs", "node", "--json"], Some(repo.path()), work.path()).unwrap();
    let n: KernelNeeds = serde_json::from_str(&j).unwrap();
    assert_eq!(n.schema, KERNEL_NEEDS_SCHEMA);
    assert_eq!(serde_json::to_string_pretty(&n).unwrap(), j);
}