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/tests/history_end_to_end.rs

//! **The build history over real artifacts** — attest, lose it, regenerate it,
//! verify it, and watch each of those fail.
//!
//! `history.rs`'s own tests build attestations from synthetic bytes. That covers
//! the layout, the determinism and the refusals, but it cannot cover the claim
//! the package actually makes:
//!
//! > a real binary, built by a real linker, is attested from the receipt IN IT
//! > and the sha256 OF it; the package regenerates byte-identically from those
//! > same artifacts; and holding the artifact is enough to check the claim.
//!
//! So this file builds real binaries and works from those. It is slow — three
//! small `rustc` invocations — and it is the only thing here that is evidence.

mod common;
use common::build_fixture;

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

use nornir_build_thing::contract::{Coordinate, RECEIPT_VERSION, Receipt};
use nornir_build_thing::history::{
    AttestationVerdict, HistoryPackage, PACKAGE_KIND, Unattestable, attest,
};
use nornir_build_thing::receipt::emit_source;

/// A receipt for one atom of the same recipe. `hash` is set, because an unpinned
/// recipe is refused and that refusal has its own unit test.
fn receipt(atom: &str) -> Receipt {
    let mut settings = BTreeMap::new();
    settings.insert("feature.gpu".to_string(), "false".to_string());
    settings.insert("profile".to_string(), "release".to_string());
    let mut inputs = BTreeMap::new();
    inputs.insert("source-commit".to_string(), "9f2c1ab".to_string());
    inputs.insert("target".to_string(), "x86_64-unknown-linux-gnu".to_string());
    Receipt {
        receipt_version: RECEIPT_VERSION,
        build_thing: Coordinate::parse("korp-build-thing:0.3.1:a91f4c2e").unwrap(),
        atom: atom.to_string(),
        target: "exe".to_string(),
        settings,
        inputs,
    }
}

/// **The whole lane in one test**, because the halves only mean anything against
/// each other: a package that attests everything proves nothing, and a verifier
/// that passes everything proves less.
///
/// Seven assertions, each with a subject that can go red on its own:
///
/// 1. two real binaries carrying receipts are attested from their own bytes;
/// 2. a binary carrying NO receipt is REFUSED BY NAME, not silently skipped —
///    the red probe for (1), and the failure mode that matters most, because a
///    history that quietly covers two of three looks complete;
/// 3. the package regenerates byte-identically from the same artifacts — "lose
///    it and it comes back" as a measurement;
/// 4. …including from COPIES under different names in a different directory,
///    which is what makes the package a function of bytes rather than of paths;
/// 5. holding the artifact is enough to verify: hash it, compare;
/// 6. one flipped byte in the artifact is caught, with both hashes named — the
///    red probe for (5);
/// 7. an artifact the history does not cover is NOT MEASURED rather than
///    attested — the release-gate case, and never a pass.
#[test]
fn real_artifacts_are_attested_regenerated_and_verified_and_each_one_can_fail() {
    let root = tempfile::tempdir().expect("scratch dir");
    let root = root.path();

    let server = build_fixture(
        root,
        "atomserver",
        &emit_source(&receipt("korp-server")).expect("generate"),
        true,
    );
    let ui = build_fixture(
        root,
        "atomui",
        &emit_source(&receipt("korp-ui")).expect("generate"),
        true,
    );
    // Same shape, same flags, no emit — so anything the history says about it is
    // about the receipt and not about the fixture.
    let unreceipted = build_fixture(root, "atomnone", "", true);

    // (1) + (2) One call, both outcomes. The refusals come back as data because
    // the release gate has to be able to fail on them.
    let (package, refused) = HistoryPackage::from_artifacts(&[&server, &ui, &unreceipted]);
    assert_eq!(
        package.len(),
        2,
        "both receipted artifacts must be attested"
    );
    assert_eq!(
        refused.len(),
        1,
        "and exactly one must be refused: {refused:?}"
    );
    match &refused[0] {
        Unattestable::NoReceipt(p) => assert_eq!(
            p, &unreceipted,
            "the refusal must name the artifact it is about"
        ),
        other => panic!("wrong refusal for a binary with no receipt: {other}"),
    }
    assert!(
        refused[0].to_string().contains(".nordisk.buildthing"),
        "the refusal must say what is missing: {}",
        refused[0]
    );

    // The attestation carries the receipt VERBATIM and the sha of the bytes.
    let attested = attest(&server).expect("a receipted binary is attestable");
    assert_eq!(attested.receipt, receipt("korp-server"));
    let on_disk = std::fs::read(&server).expect("read the artifact");
    assert_eq!(
        attested.artifact.sha256,
        nornir_hash::sha256_hex(&on_disk),
        "the attested hash is the hash of the artifact's bytes"
    );
    assert_eq!(attested.artifact.bytes, on_disk.len() as u64);

    // (3) Regeneration. `from_artifacts` is the only constructor, so this is
    // literally the same code path — which is the point: there is no second
    // writer that could drift from it.
    let (again, _) = HistoryPackage::from_artifacts(&[&ui, &server]);
    assert_eq!(
        again.files().expect("files"),
        package.files().expect("files"),
        "the package did not regenerate byte-identically from the same artifacts"
    );

    // (4) …and from copies with other names, in another directory.
    let elsewhere = root.join("elsewhere");
    std::fs::create_dir_all(&elsewhere).expect("dir");
    let copy_a = elsewhere.join("renamed-a");
    let copy_b = elsewhere.join("renamed-b");
    std::fs::copy(&server, &copy_a).expect("copy");
    std::fs::copy(&ui, &copy_b).expect("copy");
    let (from_copies, refused_copies) = HistoryPackage::from_artifacts(&[&copy_a, &copy_b]);
    assert!(refused_copies.is_empty(), "{refused_copies:?}");
    assert_eq!(
        from_copies.files().expect("files"),
        package.files().expect("files"),
        "the package is not a function of the artifacts' bytes — a rename changed it"
    );

    // (5) The attestation checked against the artifact, which is the whole
    // premise: no registry, no network, no signature to trust.
    let verdict = package.verify_artifact(&server);
    assert_eq!(verdict, AttestationVerdict::Attested, "{verdict}");
    assert!(verdict.holds());

    // (6) THE RED PROBE for (5). One byte appended — still an ELF, still parses,
    // still carries the very same receipt. Only the hash moved.
    let tampered = elsewhere.join("tampered");
    let mut bytes = on_disk.clone();
    bytes.push(0);
    std::fs::write(&tampered, &bytes).expect("write");
    let verdict = package.verify_artifact(&tampered);
    match &verdict {
        AttestationVerdict::Unreadable(why) => {
            assert!(
                why.contains(&nornir_hash::sha256_hex(&bytes)),
                "the miss must name the hash that was not found: {why}"
            );
        }
        other => panic!("a tampered artifact was not caught: {other}"),
    }
    assert!(!verdict.holds());

    // …and the same tampering caught by the attestation directly, which is the
    // arm that names BOTH hashes.
    let one = package
        .by_artifact(&attested.artifact.sha256)
        .expect("the history covers it");
    match one.verify(&tampered) {
        AttestationVerdict::ArtifactMismatch {
            attested: a,
            actual,
        } => {
            assert_eq!(a, attested.artifact.sha256);
            assert_eq!(actual, nornir_hash::sha256_hex(&bytes));
        }
        other => panic!("a tampered artifact was not caught by its attestation: {other}"),
    }

    // (7) An artifact the history does not cover is NOT MEASURED. This is the
    // release gate's case — step 3 before step 4 — and an unmeasured check is
    // never an approved check.
    let verdict = package.verify_artifact(&unreceipted);
    assert!(
        matches!(verdict, AttestationVerdict::Unreadable(_)),
        "an uncovered artifact must not verify: {verdict}"
    );
    assert!(!verdict.holds());
}

/// **A package edited after it was written is caught by the artifact itself.**
///
/// Its own test because it is the one dishonesty the hash alone cannot see: the
/// bytes are right and the *claim about them* is wrong. Whoever holds the
/// artifact can still tell, because the receipt is inside the bytes.
#[test]
fn an_attestation_whose_receipt_was_edited_is_caught_against_the_real_artifact() {
    let root = tempfile::tempdir().expect("scratch dir");
    let root = root.path();
    let bin = build_fixture(
        root,
        "editedclaim",
        &emit_source(&receipt("korp-server")).expect("generate"),
        true,
    );

    let mut a = attest(&bin).expect("attestable");
    assert_eq!(
        a.verify(&bin),
        AttestationVerdict::Attested,
        "the untouched attestation must pass, or the red below proves nothing"
    );

    // Rewrite the claim, keep the hash. This is what a forged history looks like:
    // the same artifact attributed to a different atom.
    a.receipt.atom = "korp-ui".to_string();
    assert_eq!(
        a.verify(&bin),
        AttestationVerdict::ReceiptMismatch,
        "an attestation rewritten after the fact must not verify"
    );

    a.receipt.atom = "korp-server".to_string();
    a.receipt
        .settings
        .insert("feature.gpu".to_string(), "true".to_string());
    assert_eq!(
        a.verify(&bin),
        AttestationVerdict::ReceiptMismatch,
        "a flipped setting is the same forgery in a smaller place"
    );
}

/// The package the sealer is handed is a plain path→bytes list with nothing
/// absolute, nothing host-specific and nothing dated in it.
///
/// This crate seals no archive itself (it links no znippy, by design), so the
/// only thing it can promise the sealer is that what it hands over is portable.
/// A timestamp or a build-host path in here would make two identical builds
/// produce two different archives, which is the property the whole package rests
/// on.
#[test]
fn the_files_handed_to_the_sealer_carry_no_absolute_path_and_no_date() {
    let root = tempfile::tempdir().expect("scratch dir");
    let root = root.path();
    let bin = build_fixture(
        root,
        "portable",
        &emit_source(&receipt("korp-server")).expect("generate"),
        true,
    );
    let (package, _) = HistoryPackage::from_artifacts(&[&bin]);
    let files = package.files().expect("files");

    let tmp = root.to_string_lossy().to_string();
    for (path, bytes) in &files {
        assert!(
            !path.starts_with('/') && !path.contains(".."),
            "archive path escapes the archive: {path}"
        );
        let body = String::from_utf8_lossy(bytes);
        assert!(
            !body.contains(&tmp),
            "{path} leaks the build directory {tmp}"
        );
        assert!(
            !body.contains("/home/") && !body.contains("/tmp/"),
            "{path} leaks an absolute path"
        );
    }

    // LAW 2 on the sweep above: it can actually see a leaked path. Without this,
    // a mistyped `contains` would pass over every file forever.
    let planted = format!("{{\"where\":\"{tmp}\"}}");
    assert!(
        planted.contains(&tmp),
        "the leak check cannot see a leak, so it can never fail"
    );

    // The manifest says what kind of package this is, in the file holger routes on.
    let index = files
        .iter()
        .find(|(p, _)| p == nornir_build_thing::history::INDEX_PATH)
        .map(|(_, b)| String::from_utf8_lossy(b).to_string())
        .expect("every package has a manifest");
    assert!(index.contains(PACKAGE_KIND), "{index}");
}

/// A directory of build outputs, attested in one call, with the non-artifacts
/// named rather than skipped.
///
/// This is the shape the forge actually calls with: `target/release` holds the
/// binaries next to `.d` files, `build/` and other rubble. Everything that is not
/// an object file at all comes back as NOT MEASURED — distinct from "carries no
/// receipt", because they are different problems with different fixes.
#[test]
fn a_file_that_is_not_an_object_file_at_all_is_not_measured_rather_than_unreceipted() {
    let root = tempfile::tempdir().expect("scratch dir");
    let root = root.path();
    let notes = root.join("notes.txt");
    std::fs::write(&notes, b"this is not an ELF").expect("write");

    let (package, refused) = HistoryPackage::from_artifacts(&[&notes]);
    assert!(package.is_empty());
    match &refused[0] {
        Unattestable::Unreadable { artifact, why } => {
            assert_eq!(artifact, &notes);
            assert!(!why.is_empty(), "an unmeasured file must say why");
        }
        other => panic!("a text file was reported as {other}"),
    }
    assert!(
        refused[0].to_string().contains("NOT MEASURED"),
        "an unparsable file is unmeasured, never merely unreceipted: {}",
        refused[0]
    );

    // And a missing file is unmeasured too — never an empty, healthy-looking
    // package.
    let (_, refused) = HistoryPackage::from_artifacts(&[Path::new("/does/not/exist")]);
    assert!(matches!(refused[0], Unattestable::Unreadable { .. }));
}