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/kernel_trim/coverage.rs

//! **The measured kernel trim — the COVERAGE half** (tunnr 1.1 decision
//! 2026-10-30, Heimdall tooling plan step 2).
//!
//! The static half ([`super::TrimModel`]) says what a row COULD drop. This
//! half says what the row's check suite never touched:
//!
//! 1. **The probe kernel** — `--opt kernel.coverage=gcov` lands
//!    [`KernelProbe::Gcov`] on the row's kernel: [`GCOV_PROBE_FRAGMENT`]
//!    (`GCOV_KERNEL` + `GCOV_PROFILE_ALL`, `DEBUG_FS` which gcov needs, and
//!    KASLR off so the link addresses are the run addresses) merged after
//!    every other layer. It is a DEV probe: a build of it for a release or a
//!    signed medium is refused by name ([`refuse_probe`]).
//! 2. **The counters, read from outside** — the probe kernel boots under KVM
//!    and runs the row's checks; at each stage the suite saves the kernel image
//!    (`_text` .. `_end`) out of the VM with the QEMU monitor (`memsave`).
//!    [`walk_gcov`] follows the kernel's own `gcov_info` list in that dump
//!    (the layout of `kernel/gcov/gcc_4_7.c` for the compiler named in
//!    `vmlinux`'s `.comment`) — nothing runs in the guest, no debugfs is
//!    mounted, no evidence channel is needed.
//! 3. **Objects → `CONFIG_*`** — every instrumented object is mapped to the
//!    symbols that gate it with the static half's Makefile gate map
//!    ([`super::TreeSizes`]: `obj-$(CONFIG_X)`, directory gates, composite
//!    parts and their parents). A symbol is **executed** when one of its objects
//!    ran, **never executed** when it has instrumented objects and none ran,
//!    and **unmeasured** when it has no object of its own (code under
//!    `#ifdef CONFIG_X` inside a file that stays — the honest limit, stated).
//!
//! The report ([`CoverageReport`]) is stored beside the row in
//! `.nornir/kernel-coverage/<row>/<sha12>.json`, keyed by the sha256 of the
//! row's merged fragment (the kernel as it SHIPS, without the probe lines).

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow, bail, ensure};
use serde::{Deserialize, Serialize};

use super::{TreeSizes, full};
use crate::kernel::KernelBuild;

pub use nordisk_kernel_evidence::coverage::{COVERAGE_OPT, CoverageReport, GCOV_PROBE_FRAGMENT, KernelProbe, NotRun, StageResult, Status, SymbolCoverage, Totals};

/// The symbols the probe changes (on or off) — never a trim conclusion: what
/// they gate was measured on a kernel that differs from the row's there.
pub const PROBE_ALTERED: &[&str] = &[
    "CONFIG_DEBUG_FS",
    "CONFIG_DEBUG_FS_ALLOW_ALL",
    "CONFIG_DEBUG_FS_DISALLOW_MOUNT",
    "CONFIG_DEBUG_FS_ALLOW_NONE",
    "CONFIG_GCOV_KERNEL",
    "CONFIG_GCOV_PROFILE_ALL",
    "CONFIG_CONSTRUCTORS",
    "CONFIG_RANDOMIZE_BASE",
    "CONFIG_RANDOMIZE_MEMORY",
    "CONFIG_RANDOMIZE_MEMORY_PHYSICAL_PADDING",
];

/// The probe of `kernel.coverage` pairs (at most one).
pub fn probe_of_pairs(pairs: &[(String, String)]) -> Result<Option<KernelProbe>> {
    ensure!(pairs.len() <= 1, "kernel.{COVERAGE_OPT} given {} times — one probe per build", pairs.len());
    match pairs.first() {
        None => Ok(None),
        Some((_, v)) => KernelProbe::parse(v),
    }
}

/// **A probe kernel never reaches a release or a signed medium** — refused by
/// name when the medium is signed with a real root key (`signing_key` set and
/// not `dev`) or its kind is `release`.
pub fn refuse_probe(probe: Option<KernelProbe>, kernel: &str, signing_key: Option<&str>, media_kind: Option<&str>) -> Result<()> {
    let Some(p) = probe else { return Ok(()) };
    if media_kind.map(str::trim) == Some("release") {
        bail!("[kernel-probe-release] kernel '{kernel}': kernel.{COVERAGE_OPT}={} is a DEV probe kernel — refused for a release medium", p.word());
    }
    if let Some(k) = signing_key.map(str::trim).filter(|k| !k.is_empty() && *k != "dev") {
        let _ = k;
        bail!(
            "[kernel-probe-signed] kernel '{kernel}': kernel.{COVERAGE_OPT}={} is a DEV probe kernel — refused for a medium signed with a real root key (KORP_ROOT_SIGNING_KEY is not `dev`)",
            p.word()
        );
    }
    Ok(())
}

/// [`refuse_probe`] over the build's environment: `KORP_ROOT_SIGNING_KEY` and
/// `NORDISK_MEDIA_KIND`.
pub fn refuse_probe_from_env(k: &KernelBuild) -> Result<()> {
    let key = std::env::var("KORP_ROOT_SIGNING_KEY").ok();
    let kind = std::env::var("NORDISK_MEDIA_KIND").ok();
    refuse_probe(k.probe, &k.name, key.as_deref(), kind.as_deref())
}

// ── the kernel image and its gcov list ──────────────────────────────────────

/// **The layout of `struct gcov_info`** in `kernel/gcov/gcc_4_7.c` for one
/// GCC major (x86_64, 8-byte pointers).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GcovLayout {
    /// `GCOV_COUNTERS` — the length of `merge[]`.
    pub counters: usize,
    /// GCC >= 12 adds `checksum` after `stamp` (it fits the padding before
    /// `filename`, so the offsets do not move; kept for the record).
    pub checksum: bool,
}

impl GcovLayout {
    /// The table of `kernel/gcov/gcc_4_7.c` (6.12).
    pub fn for_gcc(major: u32) -> Self {
        let counters = match major {
            15.. => 10,
            14 => 9,
            10..=13 => 8,
            7..=9 => 9,
            _ => 10,
        };
        GcovLayout { counters, checksum: major >= 12 }
    }

    const FILENAME: u64 = 24;
    const MERGE: u64 = 32;

    fn n_functions(&self) -> u64 {
        Self::MERGE + 8 * self.counters as u64
    }

    fn functions(&self) -> u64 {
        self.n_functions() + 8
    }
}

/// The GCC major in a `.comment` text (`GCC: (Debian 14.2.0-19) 14.2.0`).
pub fn gcc_major_of_comment(comment: &[u8]) -> Option<u32> {
    let text = String::from_utf8_lossy(comment);
    let line = text.split('\0').find(|l| l.starts_with("GCC:"))?;
    let last = line.split_whitespace().last()?;
    last.split('.').next()?.parse().ok()
}

/// **What the coverage reader needs of `vmlinux`.**
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KernelImage {
    /// `_text`.
    pub text: u64,
    /// `_end`.
    pub end: u64,
    /// `&gcov_info_head`.
    pub head: u64,
    /// `&linux_banner` and its bytes in the file (the KASLR check).
    pub banner: (u64, Vec<u8>),
    /// The gcov layout of the compiler that built it.
    pub layout: GcovLayout,
}

impl KernelImage {
    /// Read `vmlinux` (ELF, symbols not stripped).
    pub fn read(vmlinux: &Path) -> Result<Self> {
        let data = std::fs::read(vmlinux).with_context(|| format!("read {}", vmlinux.display()))?;
        Self::from_elf(&data).with_context(|| format!("{}", vmlinux.display()))
    }

    /// From ELF bytes.
    pub fn from_elf(data: &[u8]) -> Result<Self> {
        use object::{Object, ObjectSection, ObjectSymbol};
        let f = object::File::parse(data).map_err(|e| anyhow!("not an ELF: {e}"))?;
        let mut want: BTreeMap<String, u64> = BTreeMap::new();
        for s in f.symbols() {
            if let Ok(n) = s.name() {
                if matches!(n, "_text" | "_end" | "gcov_info_head" | "linux_banner") {
                    want.insert(n.to_string(), s.address());
                }
            }
        }
        let get = |n: &str| want.get(n).copied().ok_or_else(|| anyhow!("no `{n}` symbol — not a gcov probe kernel (kernel.coverage=gcov), or a stripped vmlinux"));
        let (text, end, head, banner) = (get("_text")?, get("_end")?, get("gcov_info_head")?, get("linux_banner")?);
        let mut banner_bytes = Vec::new();
        for sec in f.sections() {
            if let Ok(Some(d)) = sec.data_range(banner, 64) {
                banner_bytes = d.to_vec();
                break;
            }
        }
        ensure!(!banner_bytes.is_empty(), "linux_banner at {banner:#x} is in no section with file bytes");
        let comment = f.section_by_name(".comment").and_then(|s| s.data().ok()).unwrap_or(&[]);
        let major = gcc_major_of_comment(comment).ok_or_else(|| anyhow!("no `GCC:` line in .comment — the gcov layout is GCC's; a clang kernel is not read here"))?;
        Ok(KernelImage { text, end, head, banner: (banner, banner_bytes), layout: GcovLayout::for_gcc(major) })
    }
}

/// **Kernel memory, by link address.**
pub trait Memory {
    /// `len` bytes at virtual address `addr`.
    fn read(&self, addr: u64, len: usize) -> Result<&[u8]>;

    /// A little-endian u32.
    fn u32(&self, addr: u64) -> Result<u32> {
        Ok(u32::from_le_bytes(self.read(addr, 4)?.try_into().unwrap()))
    }

    /// A little-endian u64.
    fn u64(&self, addr: u64) -> Result<u64> {
        Ok(u64::from_le_bytes(self.read(addr, 8)?.try_into().unwrap()))
    }

    /// A NUL-terminated string (at most 4096 bytes).
    fn cstr(&self, addr: u64) -> Result<String> {
        let mut out = Vec::new();
        for i in 0..4096u64 {
            let b = self.read(addr + i, 1)?[0];
            if b == 0 {
                return Ok(String::from_utf8_lossy(&out).to_string());
            }
            out.push(b);
        }
        bail!("no NUL within 4096 bytes at {addr:#x}")
    }
}

/// **A saved kernel image** — the bytes of `[base, base + len)` (`memsave
/// <_text> <_end - _text>` in the QEMU monitor).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageDump {
    /// The virtual address of byte 0.
    pub base: u64,
    /// The bytes.
    pub bytes: Vec<u8>,
}

impl ImageDump {
    /// Read a dump file saved at `base`.
    pub fn read(path: &Path, base: u64) -> Result<Self> {
        Ok(ImageDump { base, bytes: std::fs::read(path).with_context(|| format!("read {}", path.display()))? })
    }
}

impl Memory for ImageDump {
    fn read(&self, addr: u64, len: usize) -> Result<&[u8]> {
        let off = addr.checked_sub(self.base).ok_or_else(|| anyhow!("{addr:#x} is below the dump ({:#x})", self.base))? as usize;
        self.bytes.get(off..off + len).ok_or_else(|| anyhow!("{addr:#x}+{len} is past the dump ({:#x}+{})", self.base, self.bytes.len()))
    }
}

/// **One instrumented object's counters.**
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ObjectCounts {
    /// The `.gcda` path the kernel names it by (the build tree's path).
    pub gcda: String,
    /// Functions with counters.
    pub functions: u32,
    /// Functions whose arc counters are not all zero.
    pub functions_run: u32,
    /// Arc counters in the object.
    pub counters: u64,
    /// The sum of the arc counters (saturating).
    pub arc_sum: u64,
}

impl ObjectCounts {
    /// Did any of it run?
    pub fn ran(&self) -> bool {
        self.arc_sum > 0
    }
}

/// **Walk the kernel's `gcov_info` list** in `mem`. Refused when the image is
/// not the one `vmlinux` describes (the banner differs: another build, or
/// KASLR moved it) or a pointer leaves the image.
pub fn walk_gcov(img: &KernelImage, mem: &dyn Memory) -> Result<Vec<ObjectCounts>> {
    let (baddr, bbytes) = &img.banner;
    let live = mem.read(*baddr, bbytes.len()).context("read linux_banner")?;
    ensure!(
        live == bbytes.as_slice(),
        "[kernel-coverage-image] the saved image is not this vmlinux at its link address (linux_banner differs: another build, or KASLR moved the kernel — the probe turns RANDOMIZE_BASE off)"
    );
    let inside = |p: u64| p >= img.text && p < img.end;
    let l = img.layout;
    let mut out = Vec::new();
    let mut seen = BTreeSet::new();
    let mut p = mem.u64(img.head)?;
    while p != 0 {
        ensure!(inside(p), "gcov_info at {p:#x} is outside the kernel image");
        ensure!(seen.insert(p), "the gcov_info list loops at {p:#x}");
        ensure!(seen.len() <= 200_000, "more than 200000 gcov_info entries — not a gcov list");
        let fname = mem.u64(p + GcovLayout::FILENAME)?;
        ensure!(inside(fname), "gcov_info {p:#x}: filename pointer {fname:#x} is outside the image");
        let gcda = mem.cstr(fname)?;
        ensure!(gcda.ends_with(".gcda"), "gcov_info {p:#x}: filename {gcda:?} is not a .gcda path — wrong layout for this compiler?");
        let arcs_active = mem.u64(p + GcovLayout::MERGE)? != 0;
        let n = mem.u32(p + l.n_functions())?;
        ensure!(n <= 1_000_000, "gcov_info {p:#x} ({gcda}): {n} functions — wrong layout?");
        let fns = mem.u64(p + l.functions())?;
        let mut c = ObjectCounts { gcda, ..Default::default() };
        if arcs_active && n > 0 {
            ensure!(inside(fns), "gcov_info {p:#x}: functions pointer {fns:#x} is outside the image");
            for i in 0..u64::from(n) {
                let fi = mem.u64(fns + 8 * i)?;
                if fi == 0 || !inside(fi) || mem.u64(fi)? != p {
                    // NULL, or a COMDAT copy keyed to another object.
                    continue;
                }
                let num = mem.u32(fi + 24)?;
                ensure!(num <= 10_000_000, "{}: function {i}: {num} arc counters — wrong layout?", c.gcda);
                if num == 0 {
                    continue;
                }
                let values = mem.u64(fi + 32)?;
                ensure!(inside(values), "{}: function {i}: counters at {values:#x} are outside the image", c.gcda);
                let bytes = mem.read(values, num as usize * 8)?;
                let sum = bytes.chunks_exact(8).fold(0u64, |a, b| a.saturating_add(u64::from_le_bytes(b.try_into().unwrap())));
                c.functions += 1;
                c.counters += u64::from(num);
                if sum > 0 {
                    c.functions_run += 1;
                }
                c.arc_sum = c.arc_sum.saturating_add(sum);
            }
        }
        out.push(c);
        p = mem.u64(p + 8)?;
    }
    Ok(out)
}

/// The linked object a `.gcda` path names: the longest path suffix of
/// `<stem>.o` that is a linked object.
pub fn object_of_gcda(gcda: &str, linked: &BTreeSet<String>) -> Option<String> {
    let stem = gcda.strip_suffix(".gcda")?;
    let o = format!("{stem}.o");
    let mut rest = o.as_str();
    loop {
        if linked.contains(rest) {
            return Some(rest.to_string());
        }
        rest = &rest[rest.find('/')? + 1..];
    }
}

/// Every symbol gating one linked object — the ONE resolver
/// ([`crate::kbuild_gates::ObjectGates::gates_of`]), the same answer the
/// syscall join gets. It used to take the UNION of every line listing the
/// object; a gate is what every way in needs (KTOOL-W2 fix 14).
pub fn linked_gates(sizes: &TreeSizes, obj: &str) -> BTreeSet<String> {
    sizes.gates_of(obj)
}

// ── the report ──────────────────────────────────────────────────────────────

/// One saved image: which node, after which stage.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Snapshot {
    /// The VM (`cp`, `worker`).
    pub node: String,
    /// The suite stage it was saved after (`ready`, `bridge-checks`).
    pub stage: String,
    /// The counters read from it.
    pub counts: Vec<ObjectCounts>,
}

/// What a coverage run measured, before it becomes a report.
pub struct Measured<'a> {
    /// The row.
    pub row: &'a str,
    /// The kernel declaration.
    pub kernel: &'a KernelBuild,
    /// The row's merged fragment, as it ships.
    pub fragment: &'a str,
    /// The probe kernel's built `.config`.
    pub probe_config: &'a str,
    /// The static half's sizes of the probe tree.
    pub sizes: &'a TreeSizes,
    /// The snapshots, in the order the suite saved them.
    pub snapshots: &'a [Snapshot],
    /// The suite.
    pub suite: &'a str,
    /// The stages.
    pub stages: Vec<StageResult>,
    /// The checks not run.
    pub not_run: Vec<NotRun>,
    /// The tool line.
    pub tool: String,
}

/// **Map the counters to `CONFIG_*` symbols** — the report.
pub fn report(m: Measured<'_>) -> Result<CoverageReport> {
    let probe = m.kernel.probe.ok_or_else(|| anyhow!("kernel '{}' is not a probe kernel (kernel.coverage=gcov)", m.kernel.name))?;
    let cfg = crate::kconfig_graph::ConfigView::parse(m.probe_config);
    ensure!(cfg.is_set("GCOV_KERNEL"), "[kernel-coverage-no-gcov] the probe kernel's .config has no CONFIG_GCOV_KERNEL=y — the probe fragment did not land");
    let linked: BTreeSet<String> = m.sizes.objects.keys().cloned().collect();

    // Per object: run? and in which node:stage its counters rose.
    #[derive(Default)]
    struct Obj {
        instrumented: bool,
        ran: bool,
        covered_by: BTreeSet<String>,
    }
    let mut objs: BTreeMap<String, Obj> = BTreeMap::new();
    let mut last: BTreeMap<(String, String), u64> = BTreeMap::new();
    let mut labels = Vec::new();
    for snap in m.snapshots {
        let label = format!("{}:{}", snap.node, snap.stage);
        labels.push(label.clone());
        for c in &snap.counts {
            let Some(o) = object_of_gcda(&c.gcda, &linked) else { continue };
            let e = objs.entry(o.clone()).or_default();
            if c.counters > 0 {
                e.instrumented = true;
            }
            let prev = last.insert((snap.node.clone(), o), c.arc_sum).unwrap_or(0);
            if c.arc_sum > prev {
                e.ran = true;
                e.covered_by.insert(label.clone());
            }
        }
    }

    // Per symbol: the objects it gates.
    let mut gated: BTreeMap<String, Vec<&str>> = BTreeMap::new();
    for o in &linked {
        for g in linked_gates(m.sizes, o) {
            gated.entry(g).or_default().push(o.as_str());
        }
    }
    let altered: BTreeSet<&str> = PROBE_ALTERED.iter().copied().collect();
    let mut symbols = Vec::new();
    let mut totals = Totals { objects: linked.len(), ..Default::default() };
    totals.instrumented = objs.values().filter(|o| o.instrumented).count();
    totals.objects_run = objs.values().filter(|o| o.ran).count();
    for (k, v) in &cfg.values {
        if v != "y" {
            continue;
        }
        let mine = gated.get(k).cloned().unwrap_or_default();
        let mut sc = SymbolCoverage {
            symbol: full(k),
            status: Status::Unmeasured,
            objects: mine.len(),
            instrumented: 0,
            objects_run: 0,
            covered_by: Vec::new(),
            bytes: mine.iter().map(|o| m.sizes.objects.get(*o).copied().unwrap_or(0)).sum(),
            probe_altered: altered.contains(full(k).as_str()),
        };
        let mut by = BTreeSet::new();
        for o in &mine {
            if let Some(ob) = objs.get(*o) {
                if ob.instrumented {
                    sc.instrumented += 1;
                }
                if ob.ran {
                    sc.objects_run += 1;
                    by.extend(ob.covered_by.iter().cloned());
                }
            }
        }
        sc.status = if sc.objects_run > 0 {
            Status::Executed
        } else if sc.instrumented > 0 {
            Status::NeverExecuted
        } else {
            Status::Unmeasured
        };
        // Stage order, not alphabetical.
        sc.covered_by = labels.iter().filter(|l| by.contains(*l)).cloned().collect();
        match sc.status {
            Status::Executed => totals.executed += 1,
            Status::NeverExecuted => totals.never_executed += 1,
            Status::Unmeasured => totals.unmeasured += 1,
        }
        totals.symbols += 1;
        symbols.push(sc);
    }
    let never_run_objects = objs.iter().filter(|(_, o)| o.instrumented && !o.ran).map(|(k, _)| k.clone()).collect();
    Ok(CoverageReport {
        schema: CoverageReport::SCHEMA.to_string(),
        row: m.row.to_string(),
        kernel: m.kernel.name.clone(),
        version: m.kernel.version.clone(),
        fragment_sha256: nornir_hash::sha256_hex(m.fragment.as_bytes()),
        probe_fragment_sha256: nornir_hash::sha256_hex(probe.fragment().as_bytes()),
        probe,
        measured_on: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
        suite: m.suite.to_string(),
        stages: m.stages,
        not_run: m.not_run,
        snapshots: labels,
        tool: m.tool,
        totals,
        symbols,
        never_run_objects,
    })
}

/// `<repo>/.nornir/kernel-coverage/<row>/`.
pub fn coverage_dir(repo_root: &Path, row: &str) -> PathBuf {
    repo_root.join(".nornir").join("kernel-coverage").join(row)
}

/// Where the report of the row's fragment `sha` lives.
pub fn report_path(repo_root: &Path, row: &str, fragment_sha: &str) -> PathBuf {
    coverage_dir(repo_root, row).join(format!("{}.json", &fragment_sha[..12.min(fragment_sha.len())]))
}

/// Write a report beside the row; returns its path.
pub fn store(repo_root: &Path, r: &CoverageReport) -> Result<PathBuf> {
    let p = report_path(repo_root, &r.row, &r.fragment_sha256);
    std::fs::create_dir_all(p.parent().unwrap()).with_context(|| format!("create {}", p.parent().unwrap().display()))?;
    let mut text = serde_json::to_string_pretty(r)?;
    text.push('\n');
    std::fs::write(&p, text).with_context(|| format!("write {}", p.display()))?;
    Ok(p)
}

/// The report measured on THIS fragment, if there is one.
pub fn load_for(repo_root: &Path, row: &str, fragment: &str) -> Result<Option<CoverageReport>> {
    let sha = nornir_hash::sha256_hex(fragment.as_bytes());
    let p = report_path(repo_root, row, &sha);
    if !p.is_file() {
        return Ok(None);
    }
    let r: CoverageReport = serde_json::from_str(&std::fs::read_to_string(&p).with_context(|| format!("read {}", p.display()))?).with_context(|| format!("parse {}", p.display()))?;
    ensure!(r.fragment_sha256 == sha, "{}: measured on fragment {}, not {sha}", p.display(), r.fragment_sha256);
    Ok(Some(r))
}

// ── the protect list ────────────────────────────────────────────────────────

/// **The row's protect list** — error and recovery paths a suite that never
/// fails cannot exercise (block errors, watchdogs, NVMe reset): never a veto
/// candidate, whatever the coverage says. `<repo>/.nornir/kernel-vetoes/<row>/protect.list`,
/// one `PATTERN reason…` per line (`*` matches any run of characters); the
/// probe's own symbols ([`PROBE_ALTERED`]) are always protected.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProtectList {
    /// `(pattern, reason)`.
    pub rows: Vec<(String, String)>,
}

impl ProtectList {
    /// Parse the file text.
    pub fn parse(text: &str) -> Result<Self> {
        let mut rows = Vec::new();
        for (i, line) in text.lines().enumerate() {
            let l = line.trim();
            if l.is_empty() || l.starts_with('#') {
                continue;
            }
            let (pat, why) = l.split_once(char::is_whitespace).unwrap_or((l, ""));
            ensure!(pat.starts_with("CONFIG_"), "protect.list line {}: {pat:?} is not a CONFIG_* pattern", i + 1);
            rows.push((pat.to_string(), why.trim().to_string()));
        }
        Ok(ProtectList { rows })
    }

    /// The row's list (empty when the file is absent).
    pub fn load(repo_root: &Path, row: &str) -> Result<Self> {
        let p = super::veto::veto_dir(repo_root, row).join("protect.list");
        match std::fs::read_to_string(&p) {
            Ok(t) => Self::parse(&t).with_context(|| format!("{}", p.display())),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
            Err(e) => Err(e).with_context(|| format!("read {}", p.display())),
        }
    }

    /// Why `sym` is protected, if it is.
    pub fn why(&self, sym: &str) -> Option<String> {
        let f = full(sym);
        if PROBE_ALTERED.contains(&f.as_str()) {
            return Some("changed by the coverage probe itself".to_string());
        }
        self.rows.iter().find(|(p, _)| glob(p, &f)).map(|(p, w)| if w.is_empty() { p.clone() } else { format!("{p}: {w}") })
    }
}

/// `*`-glob.
pub fn glob(pat: &str, s: &str) -> bool {
    let parts: Vec<&str> = pat.split('*').collect();
    if parts.len() == 1 {
        return pat == s;
    }
    let mut rest = s;
    for (i, p) in parts.iter().enumerate() {
        if i == 0 {
            if !rest.starts_with(p) {
                return false;
            }
            rest = &rest[p.len()..];
        } else if i == parts.len() - 1 {
            return rest.ends_with(p);
        } else if let Some(at) = rest.find(p) {
            rest = &rest[at + p.len()..];
        } else {
            return false;
        }
    }
    true
}

#[cfg(test)]
mod tests;