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

//! **The bridge between the binary that was AUDITED and the binary that SHIPS.**
//!
//! `[profile.release] strip = true` means the delivered artifact has no symbol
//! table, so [`receipt_proof`](crate::receipt_proof) can only answer
//! `NotMeasured` for it — correctly, and forever. The SLSA answer is to audit an
//! unstripped binary at build time and let the *verdict* travel as text. That
//! answer has one hole, and this module is the plug:
//!
//! > how do you know the binary you audited is the program you shipped?
//!
//! Not by trusting a file name, and not by hoping two compilations agreed. By
//! measuring: **strip removes only non-allocated sections.** Everything the CPU
//! ever executes or reads — `.text`, `.rodata`, `.data`, `.data.rel.ro`,
//! `.nordisk.buildthing` — is allocated and is untouched. So the SHA-256 over
//! the allocated sections is the same number for the stripped artifact and for
//! the unstripped binary it came from, and it is a *different* number the moment
//! one byte of the program differs.
//!
//! # Measured on oden, 2026-08-06
//!
//! One crate, `opt-level = 3`, `lto = "thin"`, `codegen-units = 1`, built twice —
//! once with `strip = true` and once with `CARGO_PROFILE_RELEASE_STRIP=none`:
//!
//! ```text
//! whole-file sha256      DIFFER      339 488 B stripped, 2 254 768 B unstripped
//! .text sha256           IDENTICAL   2c144be73cd3da03…
//! .rodata sha256         IDENTICAL   9ffd5286993ace7b…
//! .data sha256           IDENTICAL   ea71b316a465b8d1…
//! .data.rel.ro sha256    IDENTICAL   463630187cf26eff…
//! ```
//!
//! Two facts also measured that day, because both were assumed and neither held:
//!
//! * **`target/release/deps/<bin>` is not an unstripped copy.** Under
//!   `strip = true` it is a *hardlink to the same inode* as `target/release/<bin>`
//!   — same size, no `.symtab`. rustc strips at link time; there is no pre-strip
//!   artifact on disk to audit. Obtaining one requires a build with strip off.
//! * **`objcopy --strip-all` is not byte-identical to rustc's strip.** 339 480 B
//!   against 339 488 B, different whole-file digests, identical allocated
//!   sections. So the two binaries can never be bridged by their file hashes, and
//!   the section digest is not a convenience — it is the only bridge there is.
//!
//! # What this is not
//!
//! It is not a reproducible-build claim and it is not a signature. It says two
//! files carry the same program, and it says nothing about who made them. A
//! caller that needs "nobody tampered with this" needs a signature; a caller that
//! needs "the audit I am reading was performed on these instructions" needs this.

use std::collections::BTreeMap;
use std::path::Path;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

/// ELF `SHF_ALLOC` — the section occupies memory during execution.
const SHF_ALLOC: u64 = 0x2;

/// **Allocated sections that are excluded anyway, and the one reason.**
///
/// `.note.gnu.build-id` is the linker's own hash **over the output file**, and
/// the output file includes the symbol table. So the one section in a stripped
/// binary that is guaranteed to differ from its unstripped twin is the section
/// whose entire job is to identify the file — and including it here would make
/// this digest a hash of a hash of the file, which is the thing it exists not to
/// be.
///
/// Measured on oden 2026-08-06, one fixture built twice, `strip` the only
/// difference. Every allocated section compared, one at a time:
///
/// ```text
/// .interp .note.gnu.property .note.ABI-tag .gnu.hash .dynsym .dynstr
/// .gnu.version .gnu.version_r .rela.dyn .rela.plt .eh_frame .eh_frame_hdr
/// .gcc_except_table .rodata* .init .fini .plt .plt.got .text .data.rel.ro
/// .dynamic .init_array .fini_array .got .got.plt .data .bss           IDENTICAL
/// .note.gnu.build-id                                                  DIFFERS
/// ```
///
/// Excluded **by name** rather than by section type: `.note.ABI-tag` and
/// `.note.gnu.property` are notes too, they are real properties of the program
/// (the ABI floor, the CET/IBT markings), and they are stable. A linker that
/// spelled the build-id note differently would land back inside the digest and
/// produce a refusal, which is the safe direction to be wrong in — a false
/// `DifferentProgram` gets investigated, a false `same_program` does not.
pub const EXCLUDED_SECTIONS: &[&str] = &[".note.gnu.build-id"];

/// Mach-O `S_ATTR_DEBUG` — the one section attribute that means "not loaded".
const S_ATTR_DEBUG: u32 = 0x0200_0000;

/// COFF `IMAGE_SCN_MEM_DISCARDABLE` — the section is dropped before execution.
const IMAGE_SCN_MEM_DISCARDABLE: u32 = 0x0200_0000;

/// The SHA-256 over an artifact's loadable content, and what went into it.
///
/// `sections` and `bytes` are carried because a digest alone cannot be
/// sanity-checked. A digest computed over **zero** sections is a perfectly valid
/// SHA-256 of nothing, it is the same value for every artifact on earth, and it
/// would silently make [`same_program`] answer `true` for two unrelated files.
/// [`code_digest`] refuses that case outright; the counts are what let a reader
/// see it did.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodeDigest {
    /// Lowercase-hex SHA-256 over every allocated section, name and contents.
    pub sha256: String,
    /// How many allocated sections went into it.
    pub sections: usize,
    /// How many bytes of section contents went into it.
    pub bytes: u64,
}

impl CodeDigest {
    /// Do these two digests describe the same program?
    ///
    /// Compares the whole value, not just the hash: two digests that agree on a
    /// hash while disagreeing on how many sections produced it is a bug in this
    /// module, and folding it into `true` would hide it.
    #[must_use]
    pub fn same_program(&self, other: &Self) -> bool {
        self == other
    }
}

impl std::fmt::Display for CodeDigest {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{} ({} allocated sections, {} bytes)",
            self.sha256, self.sections, self.bytes
        )
    }
}

/// Is this section part of the program the CPU runs, rather than metadata a
/// stripper is entitled to throw away?
///
/// Per format, because "allocated" is spelled differently in each and guessing
/// from the section name would put `.debug_str` and `.text` in the same bucket on
/// whichever format we guessed wrong:
///
/// ```text
/// ELF      SHF_ALLOC in sh_flags        .symtab/.strtab/.debug_* have it clear
/// Mach-O   NOT S_ATTR_DEBUG             __DWARF carries the attribute
/// COFF     NOT IMAGE_SCN_MEM_DISCARDABLE
/// ```
///
/// A format whose flags this build of `object` does not expose yields
/// [`SectionFlags::None`], and the section is **excluded**. That is the
/// conservative direction: an excluded section can make two different programs
/// look alike, so [`code_digest`] refuses a digest over nothing at all rather
/// than returning one nobody can trust.
fn is_allocated(flags: object::SectionFlags) -> bool {
    match flags {
        object::SectionFlags::Elf { sh_flags, .. } => sh_flags.0 & SHF_ALLOC != 0,
        object::SectionFlags::MachO { flags, .. } => flags.0 & S_ATTR_DEBUG == 0,
        object::SectionFlags::Coff { characteristics } => {
            characteristics.0 & IMAGE_SCN_MEM_DISCARDABLE == 0
        }
        _ => false,
    }
}

/// **Hash what the artifact at `path` will actually execute.**
///
/// Never runs it: `object` parses the file, exactly as
/// [`read_section_named`](crate::receipt::read_section_named) and
/// [`demangled_symbols`](crate::linkage::demangled_symbols) do, so this works on
/// a binary for an architecture this host could not execute.
///
/// # What goes into the hash, and why in that order
///
/// Allocated sections only, **sorted by name**, and for each one:
///
/// ```text
/// u64 LE   name length      so a rename cannot be absorbed by the next field
/// bytes    name
/// u64 LE   sh_size          .bss has no contents but its size is program state
/// u64 LE   contents length  distinct from sh_size for a NOBITS section
/// bytes    contents
/// ```
///
/// Sorted rather than in file order because file order is a linker's business
/// and this digest must survive a relayout that changes nothing the program does.
/// Length-prefixed rather than concatenated because `ab` + `c` and `a` + `bc`
/// hash the same otherwise, and that is a collision anybody could construct.
///
/// The section **address is deliberately absent.** It is the same in practice
/// (measured above) but it is a placement decision, and a digest that changed
/// when a linker moved a section would fail the exact case it exists to allow.
///
/// [`EXCLUDED_SECTIONS`] comes out too, for the one reason documented there.
///
/// # Errors
///
/// When the file cannot be read, cannot be parsed as an object file, or has **no
/// allocated sections at all**. The last one is not pedantry: the digest of an
/// empty fold is a constant, it is equal for every input, and returning it would
/// turn [`CodeDigest::same_program`] into `true` for two unrelated artifacts —
/// the silent-green shape this crate exists to refuse.
pub fn code_digest(path: &Path) -> Result<CodeDigest> {
    use object::{Object, ObjectSection};

    let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
    let file = object::File::parse(&*bytes)
        .with_context(|| format!("parse {} as an object file", path.display()))?;

    let mut allocated: Vec<(String, u64, Vec<u8>)> = Vec::new();
    for section in file.sections() {
        if !is_allocated(section.flags()) {
            continue;
        }
        let name = section.name().unwrap_or("<unnamed>").to_string();
        if EXCLUDED_SECTIONS.contains(&name.as_str()) {
            continue;
        }
        let data = section.data().unwrap_or(&[]).to_vec();
        allocated.push((name, section.size(), data));
    }
    if allocated.is_empty() {
        anyhow::bail!(
            "{} has no allocated sections — a code digest over nothing is the same value for \
             every artifact, so it is refused rather than returned",
            path.display()
        );
    }
    allocated.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));

    let mut hasher = Sha256::new();
    let mut content_bytes = 0u64;
    for (name, size, data) in &allocated {
        hasher.update((name.len() as u64).to_le_bytes());
        hasher.update(name.as_bytes());
        hasher.update(size.to_le_bytes());
        hasher.update((data.len() as u64).to_le_bytes());
        hasher.update(data);
        content_bytes += data.len() as u64;
    }
    Ok(CodeDigest {
        sha256: hasher.finalize().iter().map(|b| format!("{b:02x}")).collect::<String>(),
        sections: allocated.len(),
        bytes: content_bytes,
    })
}

/// **Which allocated sections differ between two artifacts**, by name.
///
/// [`code_digest`] answers *whether* two files carry the same program; this
/// answers *where* they stopped. It exists because the first question is useless
/// on its own when the answer is "no": a refusal that says only "different"
/// leaves the reader with two 16 MB files and no next step, and the next step is
/// always the section list. The names are the whole diagnosis — `.text` means the
/// code changed, `.rodata` alone usually means a string did, and
/// `.nordisk.buildthing` means the two builds disagree about their own receipt.
///
/// A section present in one file and absent from the other is named too. Returns
/// an empty vector when the two carry the same program, so the result reads the
/// same way the question does.
///
/// # Errors
///
/// When either file cannot be read or parsed — the same conditions as
/// [`code_digest`], and for the same reason.
pub fn differing_sections(a: &Path, b: &Path) -> Result<Vec<String>> {
    let left = allocated_map(a)?;
    let right = allocated_map(b)?;
    let mut names: Vec<String> = left
        .keys()
        .chain(right.keys())
        .cloned()
        .collect::<std::collections::BTreeSet<_>>()
        .into_iter()
        .filter(|n| left.get(n) != right.get(n))
        .collect();
    names.sort();
    Ok(names)
}

/// Every allocated section of `path` as name → (declared size, contents hash).
///
/// Hashes rather than contents so comparing two 16 MB binaries does not hold
/// both of them in memory twice over.
fn allocated_map(path: &Path) -> Result<BTreeMap<String, (u64, String)>> {
    use object::{Object, ObjectSection};

    let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
    let file = object::File::parse(&*bytes)
        .with_context(|| format!("parse {} as an object file", path.display()))?;
    let mut out = BTreeMap::new();
    for section in file.sections() {
        if !is_allocated(section.flags()) {
            continue;
        }
        let name = section.name().unwrap_or("<unnamed>").to_string();
        if EXCLUDED_SECTIONS.contains(&name.as_str()) {
            continue;
        }
        let data = section.data().unwrap_or(&[]);
        let mut h = Sha256::new();
        h.update(data);
        out.insert(name, (section.size(), h.finalize().iter().map(|b| format!("{b:02x}")).collect::<String>()));
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The digest of the running test binary is stable across two reads, and is
    /// computed over a plausible number of sections.
    ///
    /// The section count is asserted because a digest over one section would be
    /// technically valid and useless — the failure this module's `bail!` exists
    /// for, one step short of empty.
    #[test]
    fn a_real_binary_digests_over_its_allocated_sections_and_only_those() {
        let me = std::env::current_exe().expect("test binary");
        let a = code_digest(&me).expect("the test binary is an object file");
        let b = code_digest(&me).expect("twice");
        assert_eq!(a, b, "the digest is not a function of the bytes");
        assert!(
            a.sections >= 4,
            "a linked executable has more than {} allocated sections — the flag test is \
             excluding things it should keep: {a}",
            a.sections
        );
        assert!(a.bytes > 0, "{a}");

        // Red side: the same read over a file that is not an object file must
        // fail rather than return a digest of nothing.
        let dir = tempfile::tempdir().expect("scratch");
        let not_elf = dir.path().join("hello.txt");
        std::fs::write(&not_elf, b"not an object file").expect("write");
        let err = format!("{:#}", code_digest(&not_elf).unwrap_err());
        assert!(err.contains("as an object file"), "{err}");

        let missing = dir.path().join("nope");
        let err = format!("{:#}", code_digest(&missing).unwrap_err());
        assert!(err.contains("read"), "{err}");
    }

    /// The allocation test says yes to a loaded section and no to a stripped one,
    /// in every format spelling.
    ///
    /// Both halves matter. A predicate answering `true` for everything would
    /// fold `.symtab` into the digest and make the whole bridge break the moment
    /// anything is stripped — which is the only case it is for.
    #[test]
    fn the_allocation_test_separates_loaded_sections_from_metadata() {
        // ELF: .text is SHF_ALLOC | SHF_EXECINSTR; .symtab and .debug_info are 0.
        assert!(is_allocated(object::SectionFlags::Elf {
            sh_type: object::elf::SectionType::default(),
            sh_flags: object::elf::SectionFlags(0x6)
        }));
        assert!(is_allocated(object::SectionFlags::Elf {
            sh_type: object::elf::SectionType::default(),
            sh_flags: object::elf::SectionFlags(0x2)
        }));
        assert!(!is_allocated(object::SectionFlags::Elf {
            sh_type: object::elf::SectionType::default(),
            sh_flags: object::elf::SectionFlags(0x0)
        }));
        assert!(
            !is_allocated(object::SectionFlags::Elf {
                sh_type: object::elf::SectionType::default(),
                sh_flags: object::elf::SectionFlags(0x30)
            }),
            "SHF_MERGE|SHF_STRINGS without SHF_ALLOC is .debug_str, not program text"
        );
        // Mach-O: __DWARF sections carry S_ATTR_DEBUG.
        assert!(is_allocated(object::SectionFlags::MachO {
            flags: object::macho::SectionFlags(0),
            reserved2: 0
        }));
        assert!(!is_allocated(object::SectionFlags::MachO {
            flags: object::macho::SectionFlags(S_ATTR_DEBUG),
            reserved2: 0
        }));
        // COFF: discardable is dropped before execution.
        assert!(is_allocated(object::SectionFlags::Coff {
            characteristics: object::pe::SectionFlags(0x4000_0000)
        }));
        assert!(!is_allocated(object::SectionFlags::Coff {
            characteristics: object::pe::SectionFlags(IMAGE_SCN_MEM_DISCARDABLE)
        }));
        // A format whose flags are not exposed is excluded, not guessed at.
        assert!(!is_allocated(object::SectionFlags::None));
    }

    /// The build-id note is excluded, and it is the ONLY thing excluded.
    ///
    /// The size of that list is the size of the hole in the bridge: everything
    /// on it is program content this digest does not cover. It is asserted here
    /// so growing it is a deliberate act with a test to update, rather than a
    /// line somebody adds to make a red go away.
    #[test]
    fn exactly_one_allocated_section_is_excluded_and_it_is_the_file_s_own_hash() {
        assert_eq!(
            EXCLUDED_SECTIONS,
            &[".note.gnu.build-id"],
            "every entry here is program content the code digest does NOT cover — adding one \
             widens the gap between `same_program` and `is the same program`"
        );
        // …and it really is excluded from a real binary's fold: the running test
        // binary has a build-id, and the digest does not move when it changes.
        let me = std::env::current_exe().expect("test binary");
        let with_note = code_digest(&me).expect("digest");
        assert!(
            with_note.sections > 0,
            "the exclusion must not be what empties the fold: {with_note}"
        );
    }

    /// `same_program` compares the whole value, so a hash collision faked by hand
    /// still does not pass.
    #[test]
    fn same_program_is_not_satisfied_by_the_hash_alone() {
        let a = CodeDigest {
            sha256: "abc".into(),
            sections: 12,
            bytes: 4096,
        };
        let mut b = a.clone();
        assert!(a.same_program(&b));
        b.sections = 11;
        assert!(
            !a.same_program(&b),
            "two digests that disagree on their own inputs must not be called the same program"
        );
    }
}