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

nordisk/znippy

plugins/native/znippy-plugin-elf/src/refusal.rs

//! The four named refusals.
//!
//! **None of them excludes hardware.** Every one of them turns a box that starts
//! and dies into a box that says *why*, before it starts.
//!
//! House style, verbatim from the estate (`gunnar/xtask/src/isoinstaller.rs`,
//! `edda/crates/korp-installer/src/installer_source.rs:257`):
//!
//! ```text
//! REFUSED [<kebab-name>] <one sentence naming BOTH sides and the fix>
//! ```
//!
//! [`check`] is a **pure function** of `(facts, host, coordinate)`, so every arm
//! is testable without a box, a binary or a boot.

use std::fmt;
use std::path::Path;

use crate::claim;
use crate::facts::ElfFacts;

/// What the box running the check actually is. Every field is *measured*, never
/// configured — `detect` reads CPU features in-process through `std::arch` and
/// the filesystem through `std::fs`. No subprocess, no `/proc` text parsing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostFacts {
    /// Canonical machine name, same vocabulary as `elf_machine`.
    pub machine: String,
    /// The highest x86-64 ISA level this CPU provides, or the aarch64 features.
    pub abi_level: Option<String>,
    /// Absolute interpreter paths that exist on this box.
    pub interpreters: Vec<String>,
}

impl HostFacts {
    /// Measure this box.
    pub fn detect() -> Self {
        HostFacts {
            machine: host_machine().to_string(),
            abi_level: host_abi_level(),
            interpreters: KNOWN_INTERPRETERS
                .iter()
                .filter(|p| Path::new(p).exists())
                .map(|p| (*p).to_string())
                .collect(),
        }
    }
}

/// The interpreters a nordisk box can plausibly have. The list is only used to
/// answer "does the one this binary asks for exist" — an interpreter NOT in this
/// list is still checked, by `Path::exists` on the binary's own `PT_INTERP`.
const KNOWN_INTERPRETERS: &[&str] = &[
    "/lib64/ld-linux-x86-64.so.2",
    "/lib/ld-linux-aarch64.so.1",
    "/lib/ld-musl-x86_64.so.1",
    "/lib/ld-musl-aarch64.so.1",
    "/lib/ld-linux-riscv64-lp64d.so.1",
];

fn host_machine() -> &'static str {
    match std::env::consts::ARCH {
        "x86_64" => "x86-64",
        "x86" => "x86",
        "aarch64" => "aarch64",
        "arm" => "arm",
        "riscv64" => "riscv64",
        "powerpc64" => "powerpc64",
        "s390x" => "s390x",
        "loongarch64" => "loongarch64",
        other => other,
    }
}

/// The host's ISA level, read from the CPU in-process.
#[cfg(target_arch = "x86_64")]
fn host_abi_level() -> Option<String> {
    let v2 = is_x86_feature_detected!("popcnt")
        && is_x86_feature_detected!("sse4.2")
        && is_x86_feature_detected!("ssse3");
    let v3 = v2
        && is_x86_feature_detected!("avx2")
        && is_x86_feature_detected!("bmi1")
        && is_x86_feature_detected!("bmi2")
        && is_x86_feature_detected!("fma");
    let v4 = v3 && is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512bw");
    Some(
        if v4 {
            "x86-64-v4"
        } else if v3 {
            "x86-64-v3"
        } else if v2 {
            "x86-64-v2"
        } else {
            "x86-64-baseline"
        }
        .to_string(),
    )
}

#[cfg(target_arch = "aarch64")]
fn host_abi_level() -> Option<String> {
    let mut f: Vec<&str> = Vec::new();
    if std::arch::is_aarch64_feature_detected!("bti") {
        f.push("bti");
    }
    if std::arch::is_aarch64_feature_detected!("paca") {
        f.push("pac");
    }
    if f.is_empty() { None } else { Some(f.join("+")) }
}

#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
fn host_abi_level() -> Option<String> {
    None
}

/// The rank of an x86-64 ISA level, for comparison. `None` for anything that is
/// not an x86-64 level — an unranked level is never "above" another one, because
/// guessing an ordering is how a working box gets refused.
fn x86_rank(level: &str) -> Option<u8> {
    match level {
        "x86-64-baseline" => Some(0),
        "x86-64-v2" => Some(1),
        "x86-64-v3" => Some(2),
        "x86-64-v4" => Some(3),
        _ => None,
    }
}

/// Whether the ARCHIVE's coordinate says this member should be static.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Linkage {
    /// The coordinate says static; a `PT_INTERP` is then a contradiction.
    Static,
    /// The coordinate says dynamic.
    Dynamic,
    /// The coordinate does not say. Silence is not a contradiction.
    Unstated,
}

/// The three states a recipe check can end in — the same shape
/// `installer-source-guard` proved (`Verdict { Match, Unchecked, Unpinned }`):
/// an unchecked build is **recorded**, never silent, and only a genuine
/// contradiction refuses.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecipeVerdict {
    /// Checked and agreed.
    Match,
    /// Nothing to check against. Printed as a NAMED skip, never as silence — a
    /// check nobody can see not running is worse than no check at all.
    Unchecked,
}

impl RecipeVerdict {
    pub fn word(self) -> &'static str {
        match self {
            RecipeVerdict::Match => "match",
            RecipeVerdict::Unchecked => "unchecked",
        }
    }
}

/// What the archive's coordinate and its signature claim about this member.
/// Every field is optional: the coordinate saying nothing is `Unchecked`, not a
/// refusal.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Coordinate {
    /// The member's stored path — the only thing that carries an arch CLAIM.
    pub path: String,
    /// What the coordinate says the linkage should be.
    pub linkage: Option<Linkage>,
    /// The toolchain image digest the archive's signed statement vouches for.
    pub expect_toolchain_image_sha256: Option<String>,
    /// The source commit the archive's signed statement vouches for.
    pub expect_source_commit: Option<String>,
    /// The recipe coordinate (`krate:version:hash`) the statement vouches for.
    pub expect_recipe_coordinate: Option<String>,
}

/// One named refusal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ElfRefusal {
    /// The header's machine disagrees with the coordinate, or with the host.
    Machine { header: String, other: String, whose: &'static str },
    /// Dynamic where the coordinate says static.
    LibcDynamicWhereStatic { interp: String },
    /// The interpreter this binary asks for is not on this box.
    LibcInterpreterMissing { interp: String },
    /// The micro-architecture level is above the host's.
    Cpu { needs: String, host: String },
    /// The build coordinate is missing entirely.
    RecipeMissing { field: &'static str },
    /// The binary's own receipt contradicts the archive's signed statement.
    RecipeContradicts { field: &'static str, binary: String, statement: String },
}

impl ElfRefusal {
    /// The kebab-case refusal name — the estate's `[name]`.
    pub fn name(&self) -> &'static str {
        match self {
            ElfRefusal::Machine { .. } => "elf-machine",
            ElfRefusal::LibcDynamicWhereStatic { .. }
            | ElfRefusal::LibcInterpreterMissing { .. } => "elf-libc",
            ElfRefusal::Cpu { .. } => "elf-cpu",
            ElfRefusal::RecipeMissing { .. } | ElfRefusal::RecipeContradicts { .. } => "elf-recipe",
        }
    }
}

impl fmt::Display for ElfRefusal {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "REFUSED [{}] ", self.name())?;
        match self {
            ElfRefusal::Machine { header, other, whose } => write!(
                f,
                "the ELF header says {header} and the {whose} says {other} — one of the two is \
                 wrong, and the header is the one that was measured; rebuild for {other} or file \
                 this member under {header}"
            ),
            ElfRefusal::LibcDynamicWhereStatic { interp } => write!(
                f,
                "this member asks for the interpreter {interp}, but the coordinate says it is \
                 statically linked — the binary is dynamic; either build it with a static libc \
                 or stop calling it static"
            ),
            ElfRefusal::LibcInterpreterMissing { interp } => write!(
                f,
                "this member asks for the interpreter {interp} and this box does not have that \
                 file — it would start and die; install that libc, or ship the static build"
            ),
            ElfRefusal::Cpu { needs, host } => write!(
                f,
                "this member requires {needs} and this CPU provides {host} — it would SIGILL on \
                 the first instruction it cannot run; this is a host limit, not a policy, so \
                 rebuild at {host} or run it on a {needs} box"
            ),
            ElfRefusal::RecipeMissing { field } => write!(
                f,
                "this member carries no {field} — neither its .nordisk.buildthing receipt nor a \
                 .note.package note says how it was built, so nothing can vouch for it; rebuild \
                 it through build-thing so the receipt is linked in"
            ),
            ElfRefusal::RecipeContradicts { field, binary, statement } => write!(
                f,
                "the binary's own receipt says {field} is {binary} and the archive's signed \
                 statement says {statement} — two records disagree about one artifact; the \
                 statement is the authority, so this binary is not the one that was attested"
            ),
        }
    }
}

/// Run every check. Pure: no filesystem, no clock, no network — `host` was
/// already measured by [`HostFacts::detect`].
///
/// Returns every refusal, not just the first: a box that has to be rebooted once
/// per problem is a box nobody keeps.
pub fn check(facts: &ElfFacts, host: &HostFacts, coord: &Coordinate) -> Vec<ElfRefusal> {
    let mut out = Vec::new();

    // ── elf-machine ───────────────────────────────────────────────────────────
    // The archive-wide `arch_lies = 1` query and this refusal are ONE rule seen
    // from two vantage points: the column sweeps the archive, this checks a start.
    if let Some(claimed) = claim::arch_claim(&coord.path) {
        if claim::lies(Some(claimed), &facts.machine) {
            out.push(ElfRefusal::Machine {
                header: facts.machine.clone(),
                other: claimed.to_string(),
                whose: "coordinate",
            });
        }
    }
    if facts.machine != host.machine && facts.machine != "none" {
        out.push(ElfRefusal::Machine {
            header: facts.machine.clone(),
            other: host.machine.clone(),
            whose: "host",
        });
    }

    // ── elf-libc ──────────────────────────────────────────────────────────────
    if let Some(interp) = &facts.interp {
        if coord.linkage == Some(Linkage::Static) {
            out.push(ElfRefusal::LibcDynamicWhereStatic { interp: interp.clone() });
        }
        if !host.interpreters.iter().any(|p| p == interp) {
            out.push(ElfRefusal::LibcInterpreterMissing { interp: interp.clone() });
        }
    }

    // ── elf-cpu ───────────────────────────────────────────────────────────────
    if let (Some(needs), Some(have)) = (&facts.abi_level, &host.abi_level) {
        if let (Some(n), Some(h)) = (x86_rank(needs), x86_rank(have)) {
            if n > h {
                out.push(ElfRefusal::Cpu { needs: needs.clone(), host: have.clone() });
            }
        }
    }

    // ── elf-recipe ────────────────────────────────────────────────────────────
    if facts.recipe.is_empty() {
        out.push(ElfRefusal::RecipeMissing { field: "build coordinate" });
    } else {
        contradiction(
            "toolchain_image_sha256",
            facts.recipe.toolchain_image_sha256.as_deref(),
            coord.expect_toolchain_image_sha256.as_deref(),
            &mut out,
        );
        contradiction(
            "source_commit",
            facts.recipe.source_commit.as_deref(),
            coord.expect_source_commit.as_deref(),
            &mut out,
        );
        contradiction(
            "recipe_coordinate",
            facts.recipe.coordinate.as_deref(),
            coord.expect_recipe_coordinate.as_deref(),
            &mut out,
        );
    }

    out
}

/// A contradiction needs BOTH sides. One side missing is [`RecipeVerdict::Unchecked`]
/// — recorded by [`recipe_verdict`], never refused here.
fn contradiction(
    field: &'static str,
    binary: Option<&str>,
    statement: Option<&str>,
    out: &mut Vec<ElfRefusal>,
) {
    if let (Some(b), Some(s)) = (binary, statement) {
        if b != s {
            out.push(ElfRefusal::RecipeContradicts {
                field,
                binary: b.to_string(),
                statement: s.to_string(),
            });
        }
    }
}

/// Was the toolchain image actually checked against anything?
///
/// Until `BuildProvenance` carries the toolchain image (`.nornir/elf-design.md`
/// §6.3(b), an owner decision in edda), this is `Unchecked` for every artifact —
/// and it must be PRINTED as a named skip rather than pass silently.
pub fn recipe_verdict(facts: &ElfFacts, coord: &Coordinate) -> RecipeVerdict {
    match (&facts.recipe.toolchain_image_sha256, &coord.expect_toolchain_image_sha256) {
        (Some(_), Some(_)) => RecipeVerdict::Match,
        _ => RecipeVerdict::Unchecked,
    }
}