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/facts.rs

//! Every fact this plugin writes, read **from the bytes of the binary**.
//!
//! Law **E-0** (`.nornir/elf-design.md` §2): a path may widen candidacy, it may
//! never assert a fact. Nothing in this module takes a path. The single input is
//! `&[u8]`, and the single gate is the four magic bytes.
//!
//! Law **P-4**: nothing here panics and nothing here returns `Err`. A truncated,
//! hostile or merely unusual binary yields an [`ElfFacts`] with the fields it
//! could prove and `None` everywhere else. There is no `unwrap` on parsed data
//! in this file.

use object::elf;
use object::read::elf::{Dyn, FileHeader, ProgramHeader, SectionHeader};
use object::Endianness;

/// The four bytes that make a file an ELF. Read from the data, never inferred.
pub const ELF_MAGIC: [u8; 4] = [0x7f, b'E', b'L', b'F'];

/// The linker section a nordisk-built artifact carries its build-thing receipt
/// in — `nornir_build_thing::contract::RECEIPT_SECTION`.
pub const RECEIPT_SECTION: &str = ".nordisk.buildthing";

/// The receipt schema this reader understands
/// (`nornir_build_thing::contract::RECEIPT_VERSION`). A receipt from the future
/// is refused rather than interpreted — a misread receipt answers the provenance
/// question *wrongly*, which is worse than not answering it.
pub const RECEIPT_VERSION: u32 = 1;

/// cargo-auditable's embedded dependency tree: zlib-compressed JSON.
pub const DEP_V0_SECTION: &str = ".dep-v0";

/// The systemd/Fedora ELF package-metadata note: owner `FDO`, type `0xcafe1a7e`,
/// payload a NUL-terminated JSON object.
pub const NOTE_PACKAGE_TYPE: u32 = 0xcafe_1a7e;
/// The note owner string for [`NOTE_PACKAGE_TYPE`].
pub const NOTE_PACKAGE_OWNER: &[u8] = b"FDO";

/// A hard ceiling on an inflated `.dep-v0` tree. A section claiming to expand to
/// gigabytes is a decompression bomb, and P-4 says a bomb yields a null column,
/// not an OOM.
const DEP_V0_MAX: usize = 8 * 1024 * 1024;

/// Is this an ELF at all? The only gate. Four bytes, no guessing.
pub fn is_elf(data: &[u8]) -> bool {
    data.len() >= 4 && data[..4] == ELF_MAGIC
}

/// The recipe half of a binary's self-description — read from the
/// `.nordisk.buildthing` receipt first, then from `.note.package`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Recipe {
    /// `krate:version:hash` — `Receipt.build_thing`, the recipe's own identity.
    pub coordinate: Option<String>,
    /// What was built (`Receipt.atom`, else `.note.package` `variant`).
    pub variant: Option<String>,
    /// Resolved settings / features, sorted, space-separated.
    pub features: Option<String>,
    /// `exe` / `container` / `iso-installer` — `Receipt.target`.
    pub target: Option<String>,
    /// The toolchain container image reference (no digest).
    pub toolchain_image: Option<String>,
    /// Its manifest digest: bare lowercase hex, no `sha256:` prefix.
    pub toolchain_image_sha256: Option<String>,
    /// The VCS revision the build was cut from.
    pub source_commit: Option<String>,
}

impl Recipe {
    /// True when the binary said nothing at all about how it was built.
    pub fn is_empty(&self) -> bool {
        *self == Recipe::default()
    }
}

/// Everything read out of one ELF.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ElfFacts {
    pub member_kind: &'static str,
    pub class: &'static str,
    pub machine: String,
    pub osabi: String,
    pub etype: &'static str,
    pub interp: Option<String>,
    pub abi_level: Option<String>,
    pub build_id: Option<String>,
    pub needed: Option<String>,
    pub text_bytes: u64,
    pub debug_bytes: u64,
    pub has_debug: bool,
    pub recipe: Recipe,
    pub auditable_root: Option<String>,
}

/// Read every fact from `data`. `None` when the bytes are not an ELF — which is
/// the plugin declining to claim the member, not an error.
pub fn read(data: &[u8]) -> Option<ElfFacts> {
    if !is_elf(data) {
        return None;
    }
    // e_ident is fixed by the spec at offset 0..16 for BOTH classes, so class and
    // osabi are readable before anything else is trusted.
    let class = match data.get(4)? {
        1 => "ELF32",
        2 => "ELF64",
        _ => return None,
    };
    let osabi = osabi_name(elf::OsAbi(*data.get(7)?));

    // Dispatch on the class ONCE; everything below is generic over the header.
    let mut facts = if class == "ELF64" {
        read_generic::<elf::FileHeader64<Endianness>>(data)
    } else {
        read_generic::<elf::FileHeader32<Endianness>>(data)
    }?;
    facts.class = class;
    facts.osabi = osabi;
    Some(facts)
}

fn read_generic<Elf: FileHeader<Endian = Endianness>>(data: &[u8]) -> Option<ElfFacts> {
    let header = Elf::parse(data).ok()?;
    let endian = header.endian().ok()?;

    let machine = machine_name(header.e_machine(endian)).to_string();
    let etype = type_name(header.e_type(endian));

    let mut facts = ElfFacts {
        member_kind: "relocatable",
        class: "ELF64",
        machine,
        osabi: String::new(),
        etype,
        interp: None,
        abi_level: None,
        build_id: None,
        needed: None,
        text_bytes: 0,
        debug_bytes: 0,
        has_debug: false,
        recipe: Recipe::default(),
        auditable_root: None,
    };

    // ── program headers: PT_INTERP ────────────────────────────────────────────
    if let Ok(segments) = header.program_headers(endian, data) {
        for seg in segments {
            if seg.p_type(endian) == elf::PT_INTERP {
                if let Ok(bytes) = seg.data(endian, data) {
                    facts.interp = cstr(bytes);
                }
            }
        }
    }

    // ── sections ──────────────────────────────────────────────────────────────
    let sections = match header.sections(endian, data) {
        Ok(s) => s,
        // No section table: the header facts still stand. P-4.
        Err(_) => {
            facts.member_kind = member_kind(etype, facts.interp.is_some(), false, false);
            return Some(facts);
        }
    };

    let mut has_modinfo = false;
    let mut has_debuglink = false;

    for section in sections.iter() {
        let name = sections.section_name(endian, section).unwrap_or(b"");
        let size = section.sh_size(endian).into();
        let flags: u64 = section.sh_flags(endian).0.into();

        // text: allocated AND executable. `SHF_ALLOC` alone would count rodata.
        if flags & elf::SHF_ALLOC.0 != 0 && flags & elf::SHF_EXECINSTR.0 != 0 {
            facts.text_bytes = facts.text_bytes.saturating_add(size);
        }
        if name.starts_with(b".debug_") || name.starts_with(b".zdebug_") {
            facts.debug_bytes = facts.debug_bytes.saturating_add(size);
            facts.has_debug = true;
        }
        if name == b".gnu_debuglink" {
            has_debuglink = true;
        }
        if name == b".modinfo" {
            has_modinfo = true;
        }
        if name == RECEIPT_SECTION.as_bytes() {
            if let Ok(bytes) = section.data(endian, data) {
                read_receipt_into(bytes, &mut facts.recipe);
            }
        }
        if name == DEP_V0_SECTION.as_bytes() {
            if let Ok(bytes) = section.data(endian, data) {
                facts.auditable_root = read_dep_v0_root(bytes);
            }
        }
        // notes
        if let Ok(Some(mut notes)) = section.notes(endian, data) {
            while let Ok(Some(note)) = notes.next() {
                let owner = note.name();
                let ntype = note.n_type(endian);
                if owner == b"GNU" && ntype == elf::NT_GNU_BUILD_ID {
                    facts.build_id = Some(hex(note.desc()));
                } else if owner == b"GNU" && ntype == elf::NT_GNU_PROPERTY_TYPE_0 {
                    if let Some(level) =
                        abi_level_from_property(note.desc(), endian, header.is_class_64())
                    {
                        facts.abi_level = Some(level);
                    }
                } else if owner == NOTE_PACKAGE_OWNER && ntype == elf::NoteType(NOTE_PACKAGE_TYPE) {
                    read_note_package_into(note.desc(), &mut facts.recipe);
                }
            }
        }
    }
    facts.has_debug = facts.has_debug || has_debuglink;

    // ── DT_NEEDED, augmented with .gnu.version_r ──────────────────────────────
    facts.needed = read_needed::<Elf>(endian, data, &sections);

    facts.member_kind = member_kind(etype, facts.interp.is_some(), has_modinfo, facts.has_debug);
    Some(facts)
}

/// `DT_NEEDED` sonames, each carrying the highest symbol-version it demands
/// from that library, sorted, space-separated:
/// `libc.so.6(GLIBC_2.34) libssl.so.3(OPENSSL_3.0.0)`.
///
/// The soname alone gives the ABI major; `.gnu.version_r` gives the minor the
/// binary actually requires. "Which binaries link libssl 3.0.x" needs both.
fn read_needed<Elf: FileHeader<Endian = Endianness>>(
    endian: Endianness,
    data: &[u8],
    sections: &object::read::elf::SectionTable<'_, Elf>,
) -> Option<String> {
    let (dynamic, strtab_index) = sections.dynamic(endian, data).ok()??;
    let strings = sections.strings(endian, data, strtab_index).ok()?;

    // soname -> the version strings required from it
    let mut needed: Vec<(String, Vec<String>)> = Vec::new();
    for d in dynamic {
        if d.tag(endian) == elf::DT_NEEDED {
            if let Ok(name) = d.string(endian, strings) {
                needed.push((String::from_utf8_lossy(name).into_owned(), Vec::new()));
            }
        }
        if d.tag(endian) == elf::DT_NULL {
            break;
        }
    }
    if needed.is_empty() {
        return None;
    }

    if let Ok(Some((mut verneeds, link))) = sections.gnu_verneed(endian, data) {
        if let Ok(vstrings) = sections.strings(endian, data, link) {
            while let Ok(Some((verneed, mut vernauxs))) = verneeds.next() {
                let file = verneed
                    .file(endian, vstrings)
                    .map(|f| String::from_utf8_lossy(f).into_owned())
                    .unwrap_or_default();
                let mut versions: Vec<String> = Vec::new();
                while let Ok(Some(aux)) = vernauxs.next() {
                    if let Ok(n) = aux.name(endian, vstrings) {
                        versions.push(String::from_utf8_lossy(n).into_owned());
                    }
                }
                versions.sort();
                if let Some(slot) = needed.iter_mut().find(|(s, _)| *s == file) {
                    slot.1 = versions;
                }
            }
        }
    }

    let mut out: Vec<String> = needed
        .into_iter()
        .map(|(soname, versions)| match versions.last() {
            Some(v) => format!("{soname}({v})"),
            None => soname,
        })
        .collect();
    out.sort();
    Some(out.join(" "))
}

/// `.nordisk.buildthing` — plain serde JSON written by
/// `nornir_build_thing::receipt::encode`.
///
/// This reads the documented JSON shape rather than linking
/// `nornir-build-thing`: that crate pulls `cargo_metadata`, `toml`, `chrono` and
/// `gix`, and znippy has no dependency arrow into edda at all. The three rules
/// its own `decode` states are honoured here verbatim — trailing NUL padding is
/// expected, an INTERIOR NUL means two writers each contributed a receipt and is
/// refused rather than resolved by picking one, and a `receipt_version` above
/// [`RECEIPT_VERSION`] is refused rather than partially interpreted.
fn read_receipt_into(bytes: &[u8], recipe: &mut Recipe) {
    let trimmed = match bytes.iter().rposition(|b| *b != 0) {
        Some(end) => &bytes[..=end],
        None => return,
    };
    if trimmed.contains(&0) {
        // Two receipts in one artifact: which recipe built it is exactly the
        // question the receipt exists to answer, so answer neither.
        return;
    }
    let Ok(v) = serde_json::from_slice::<serde_json::Value>(trimmed) else { return };
    match v.get("receipt_version").and_then(serde_json::Value::as_u64) {
        Some(n) if n <= u64::from(RECEIPT_VERSION) => {}
        _ => return,
    }

    if let Some(bt) = v.get("build_thing") {
        let krate = bt.get("krate").and_then(|x| x.as_str());
        let version = bt.get("version").and_then(|x| x.as_str());
        let hash = bt.get("hash").and_then(|x| x.as_str());
        recipe.coordinate = match (krate, version, hash) {
            (Some(k), Some(ver), Some(h)) => Some(format!("{k}:{ver}:{h}")),
            (Some(k), Some(ver), None) => Some(format!("{k}:{ver}")),
            _ => None,
        };
    }
    recipe.variant = str_field(&v, "atom");
    recipe.target = str_field(&v, "target");

    if let Some(map) = v.get("settings").and_then(serde_json::Value::as_object) {
        let mut on: Vec<String> = map
            .iter()
            .map(|(k, val)| match val.as_str() {
                Some(s) => format!("{k}={s}"),
                None => format!("{k}={val}"),
            })
            .collect();
        on.sort();
        if !on.is_empty() {
            recipe.features = Some(on.join(" "));
        }
    }
    if let Some(map) = v.get("inputs").and_then(serde_json::Value::as_object) {
        for key in ["commit", "source_commit", "vcs_rev"] {
            if recipe.source_commit.is_none() {
                recipe.source_commit = map.get(key).and_then(|x| x.as_str()).map(str::to_owned);
            }
        }
        if recipe.toolchain_image.is_none() {
            recipe.toolchain_image =
                map.get("toolchain_image").and_then(|x| x.as_str()).map(str::to_owned);
        }
        if recipe.toolchain_image_sha256.is_none() {
            recipe.toolchain_image_sha256 = map
                .get("toolchain_image_sha256")
                .or_else(|| map.get("image_sha256"))
                .and_then(|x| x.as_str())
                .map(str::to_owned);
        }
        // `toolchain_image` may arrive as the full `name@sha256:hex` ref that
        // `CargoToolchain::image_ref()` produces. Split it rather than store a
        // ref where a digest column is expected — but never NORMALISE the
        // difference away: what was found is what is written.
        if let Some(r) = recipe.toolchain_image.clone() {
            if let Some((name, digest)) = r.split_once("@sha256:") {
                recipe.toolchain_image = Some(name.to_string());
                if recipe.toolchain_image_sha256.is_none() {
                    recipe.toolchain_image_sha256 = Some(digest.to_string());
                }
            }
        }
    }
}

/// `.note.package` — the systemd/Fedora ELF package note. Read as the SECOND
/// source: it only fills what the receipt left null, so a nordisk receipt always
/// wins over a packager's note.
fn read_note_package_into(desc: &[u8], recipe: &mut Recipe) {
    let trimmed = match desc.iter().rposition(|b| *b != 0) {
        Some(end) => &desc[..=end],
        None => return,
    };
    let Ok(v) = serde_json::from_slice::<serde_json::Value>(trimmed) else { return };

    if recipe.variant.is_none() {
        recipe.variant = str_field(&v, "variant");
    }
    if recipe.features.is_none() {
        match v.get("features") {
            Some(serde_json::Value::Array(a)) => {
                let mut f: Vec<String> =
                    a.iter().filter_map(|x| x.as_str().map(str::to_owned)).collect();
                f.sort();
                if !f.is_empty() {
                    recipe.features = Some(f.join(" "));
                }
            }
            Some(serde_json::Value::String(s)) => recipe.features = Some(s.clone()),
            _ => {}
        }
    }
    if recipe.source_commit.is_none() {
        recipe.source_commit =
            str_field(&v, "sourceCommit").or_else(|| str_field(&v, "source_commit"));
    }
    if recipe.toolchain_image.is_none() {
        recipe.toolchain_image =
            str_field(&v, "toolchainImage").or_else(|| str_field(&v, "toolchain_image"));
    }
    if recipe.toolchain_image_sha256.is_none() {
        recipe.toolchain_image_sha256 = str_field(&v, "toolchainImageSha256")
            .or_else(|| str_field(&v, "toolchain_image_sha256"));
    }
    if let Some(r) = recipe.toolchain_image.clone() {
        if let Some((name, digest)) = r.split_once("@sha256:") {
            recipe.toolchain_image = Some(name.to_string());
            if recipe.toolchain_image_sha256.is_none() {
                recipe.toolchain_image_sha256 = Some(digest.to_string());
            }
        }
    }
}

/// `.dep-v0` — cargo-auditable's zlib-compressed JSON dependency tree. Returns
/// `"<name> <version>"` of the package marked `root`.
///
/// The whole tree is deliberately NOT a column; this is the entry point, and the
/// bytes themselves are already in the archive.
fn read_dep_v0_root(section: &[u8]) -> Option<String> {
    // cargo-auditable writes a ZLIB stream (RFC 1950): a two-byte CMF/FLG header
    // around a raw DEFLATE body. `linflate` inflates raw DEFLATE, so the header
    // is stepped over when it is there — recognised by the spec's own two rules
    // (CM == 8, and the big-endian header word divisible by 31), never by
    // assuming the first two bytes are always a header.
    let compressed = match section {
        [cmf, flg, rest @ ..]
            if cmf & 0x0f == 8 && (u16::from(*cmf) << 8 | u16::from(*flg)) % 31 == 0 =>
        {
            rest
        }
        other => other,
    };
    // The inflate is bounded by what DEFLATE can physically produce from this
    // many bytes, capped at [`DEP_V0_MAX`]: a section claiming to expand without
    // limit is a bomb, and P-4 says a bomb yields a null column, not an OOM.
    let hint = compressed.len().saturating_mul(1032).min(DEP_V0_MAX);
    let raw = linflate::inflate_to_vec(compressed, hint).ok()?;
    let v: serde_json::Value = serde_json::from_slice(&raw).ok()?;
    let packages = v.get("packages")?.as_array()?;
    let root = packages
        .iter()
        .find(|p| p.get("root").and_then(serde_json::Value::as_bool).unwrap_or(false))?;
    let name = root.get("name")?.as_str()?;
    let version = root.get("version")?.as_str()?;
    Some(format!("{name} {version}"))
}

fn str_field(v: &serde_json::Value, key: &str) -> Option<String> {
    v.get(key)?.as_str().map(str::to_owned)
}

/// The ISA level a binary DEMANDS, from `.note.gnu.property`.
///
/// x86: `GNU_PROPERTY_X86_ISA_1_NEEDED` (0xc0008002) — bit 0 baseline, 1 = v2,
/// 2 = v3, 3 = v4; the highest bit set is the level.
/// aarch64: `GNU_PROPERTY_AARCH64_FEATURE_1_AND` (0xc0000000) — BTI / PAC.
fn abi_level_from_property(desc: &[u8], endian: Endianness, class64: bool) -> Option<String> {
    let align = if class64 { 8 } else { 4 };
    let mut off = 0usize;
    while off + 8 <= desc.len() {
        let ptype = u32_at(desc, off, endian)?;
        let psize = u32_at(desc, off + 4, endian)? as usize;
        let pdata = desc.get(off + 8..off + 8 + psize)?;
        match ptype {
            0xc000_8002 => {
                let bits = u32_at(pdata, 0, endian)?;
                let level = match 32 - bits.leading_zeros() {
                    0 => return None,
                    1 => "x86-64-baseline",
                    2 => "x86-64-v2",
                    3 => "x86-64-v3",
                    _ => "x86-64-v4",
                };
                return Some(level.to_string());
            }
            0xc000_0000 => {
                let bits = u32_at(pdata, 0, endian)?;
                let mut f: Vec<&str> = Vec::new();
                if bits & 1 != 0 {
                    f.push("bti");
                }
                if bits & 2 != 0 {
                    f.push("pac");
                }
                if !f.is_empty() {
                    return Some(f.join("+"));
                }
            }
            _ => {}
        }
        // Each property is padded to the file's pointer alignment.
        let step = 8 + psize.next_multiple_of(align);
        off = off.checked_add(step)?;
    }
    None
}

fn u32_at(b: &[u8], off: usize, endian: Endianness) -> Option<u32> {
    let s: [u8; 4] = b.get(off..off + 4)?.try_into().ok()?;
    Some(match endian {
        Endianness::Little => u32::from_le_bytes(s),
        Endianness::Big => u32::from_be_bytes(s),
    })
}

/// What this ELF *is* — the distinction `e_type` cannot draw, because a PIE
/// executable and a shared library are both `ET_DYN`. The separator is
/// `PT_INTERP`: an executable names its interpreter, a library does not.
fn member_kind(etype: &str, has_interp: bool, has_modinfo: bool, has_debug: bool) -> &'static str {
    match etype {
        "exec" => "executable",
        "dyn" if has_interp => "pie-executable",
        "dyn" => "shared-object",
        "rel" if has_modinfo => "kernel-module",
        "rel" if has_debug => "debuginfo",
        "rel" => "relocatable",
        "core" => "core",
        _ => "relocatable",
    }
}

/// The canonical machine vocabulary. `crate::claim` folds path spellings into
/// exactly these strings so `arch_lies` compares like with like.
pub fn machine_name(m: elf::Machine) -> &'static str {
    match m {
        elf::EM_386 => "x86",
        elf::EM_X86_64 => "x86-64",
        elf::EM_AARCH64 => "aarch64",
        elf::EM_ARM => "arm",
        elf::EM_RISCV => "riscv64",
        elf::EM_PPC64 => "powerpc64",
        elf::EM_S390 => "s390x",
        elf::EM_LOONGARCH => "loongarch64",
        elf::EM_MIPS => "mips64",
        elf::EM_SPARCV9 => "sparc64",
        elf::EM_NONE => "none",
        _ => "unknown",
    }
}

fn type_name(t: elf::FileType) -> &'static str {
    match t {
        elf::ET_REL => "rel",
        elf::ET_EXEC => "exec",
        elf::ET_DYN => "dyn",
        elf::ET_CORE => "core",
        _ => "unknown",
    }
}

fn osabi_name(b: elf::OsAbi) -> String {
    match b {
        elf::ELFOSABI_NONE => "SysV",
        elf::ELFOSABI_HPUX => "HP-UX",
        elf::ELFOSABI_NETBSD => "NetBSD",
        elf::ELFOSABI_GNU => "GNU",
        elf::ELFOSABI_SOLARIS => "Solaris",
        elf::ELFOSABI_AIX => "AIX",
        elf::ELFOSABI_FREEBSD => "FreeBSD",
        elf::ELFOSABI_OPENBSD => "OpenBSD",
        elf::ELFOSABI_ARM => "ARM",
        elf::ELFOSABI_STANDALONE => "Standalone",
        _ => "unknown",
    }
    .to_string()
}

/// A NUL-terminated string out of raw section/segment bytes.
fn cstr(b: &[u8]) -> Option<String> {
    let end = b.iter().position(|c| *c == 0).unwrap_or(b.len());
    if end == 0 {
        return None;
    }
    Some(String::from_utf8_lossy(&b[..end]).into_owned())
}

fn hex(b: &[u8]) -> String {
    let mut s = String::with_capacity(b.len() * 2);
    for x in b {
        s.push(char::from_digit(u32::from(x >> 4), 16).unwrap_or('0'));
        s.push(char::from_digit(u32::from(x & 0xf), 16).unwrap_or('0'));
    }
    s
}