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

nordisk/holger

bench-scenarios/src/secure_update.rs

//! Secure Update Gateway bundle-path throughput scenario.
//!
//! This is holger's **unique** capability: the network-free signed
//! `holger.update-bundle/v1` format with a two-person approval + release-signature
//! trust model and a fully offline verifier. Artifactory and Nexus have **no
//! equivalent**, so — unlike the `nexus`/`artifactory` head-to-heads — this bench
//! measures holger against itself: it drives the REAL
//! [`holger_update_bundle`] lifecycle and reports the throughput of each stage.
//!
//! Lifecycle (the live tooling path — `holger-bundle` / `holger-verify` /
//! the offline server import):
//!
//! ```text
//! prepare  → approve ×2 (distinct signers) → finalize (release signature)
//!          → [cross airgap] → verify (offline) → anti-rollback → import (atomic)
//! ```
//!
//! Four arms, each a **serial** measurement (one bundle is an inherently
//! sequential unit of work — no rayon/thread::spawn pool is used, exactly like the
//! single-writer `audit` bench):
//!
//! * [`bundle_prepare_throughput`] — MB/s of artifact bytes packed into a
//!   candidate bundle. This is the **znippy** path.
//! * [`bundle_sign_finalize_throughput`] — bundles/sec through approve×2 +
//!   finalize (ed25519 detached approval signatures + the ed25519 release
//!   signature over the manifest, signer identity = X.509).
//! * [`bundle_verify_throughput`] — MB/s of the offline verifier, the
//!   protected-side gate that runs before anything touches the data root.
//! * [`bundle_import_throughput`] — MB/s of atomic import (verify-before-extract
//!   into a fresh tree via staging-dir + rename) into the protected data root,
//!   plus the anti-rollback accept.
//!
//! ## Delta vs whole-payload (the znippy question)
//!
//! `bundle_prepare` **packs the whole payload** — it does NOT delta/patch against a
//! prior bundle. `prepare::Preparation::build` inserts each payload's full bytes as
//! a member (`update-bundle/src/prepare.rs:82`), and `container::pack_bundle` stages
//! every member and calls `znippy_compress::compress_dir(&staging, …)`
//! (`update-bundle/src/container.rs:51`) — a whole-directory compress with no base
//! archive to diff against (`compress_dir`'s signature carries no delta/base
//! parameter). The `supersedes` / `withdraws` fields in `core.json` are metadata
//! reference lists of prior bundle IDs, **not** a binary delta. znippy-compress does
//! have a blob-reuse `append` path, but the bundle prepare path never calls it. So
//! there is no update-in-place: each candidate re-packs the artifact whole. This arm
//! asserts that structurally — the packed archive carries the payload member at its
//! full uncompressed size.
//!
//! ## Red-when-broken ANSWER floors
//!
//! Every arm asserts the *answer*, not just a number, via `ensure!` — a broken
//! path returns `Err`, which the bench runner records as a **failed** test row
//! (never a hollow green throughput):
//!
//! * prepare  — the packed bundle round-trips through `load_bundle` and the payload
//!   member's SHA-256 matches the source bytes (and is stored whole).
//! * finalize — `finalize` returns a `VerifyReport` with exactly the two required
//!   approvals and a release signer distinct from both approvers.
//! * verify   — the offline verifier accepts the honest bundle (2 approvals) AND
//!   rejects a single-byte-tampered copy.
//! * import   — the extracted `payload/firmware.bin` bytes equal the source, and the
//!   anti-rollback state **rejects a replay** (same sequence, different bytes) and a
//!   **downgrade** (lower sequence) while accepting an exact idempotent re-import.

use std::time::{Duration, Instant};

use anyhow::{ensure, Context, Result};
use chrono::{Duration as ChronoDuration, Utc};
use rcgen::{CertificateParams, DnType, KeyPair, PKCS_ED25519};

use holger_update_bundle::core::{EvidenceKind, PolicyResult, SourceProvenance};
use holger_update_bundle::digest::sha256_hex;
use holger_update_bundle::finalize::{add_approval, finalize};
use holger_update_bundle::policy::{
    ApprovedSigner, CertificateValidity, ExpiredPosture, TrustMode, TrustPolicy,
};
use holger_update_bundle::prepare::{EvidenceInput, PayloadInput, Preparation};
use holger_update_bundle::rollback::RollbackStore;
use holger_update_bundle::schema::{PAYLOAD_PREFIX, POLICY_SCHEMA};
use holger_update_bundle::{
    extract_bundle_file, load_bundle, pack_bundle, verify_bundle_file, AcceptedEntry,
    Admissibility, MemberMap, TargetSpec,
};

use crate::{Scale, Throughput};

const MIB: usize = 1024 * 1024;

/// The relative payload path inside `payload/` (the firmware image every arm packs).
const FIRMWARE_REL: &str = "firmware.bin";

/// A minted ed25519 signer: its self-signed X.509 cert (DER), the PKCS#8 private
/// key, and the SHA-256 fingerprint the trust policy pins.
struct Signer {
    cert_der: Vec<u8>,
    key_pkcs8: Vec<u8>,
    fingerprint: String,
}

/// Mint a self-signed ed25519 signer in-process (rcgen) — never a committed key.
/// Mirrors `update-bundle/src/tests/testkit.rs::self_signed` (certificate-pinning
/// mode), which is `#[cfg(test)]` and therefore not reachable from this crate.
fn mint_signer(cn: &str) -> Signer {
    let key = KeyPair::generate_for(&PKCS_ED25519).expect("generate ed25519 keypair");
    let mut params = CertificateParams::new(vec![]).expect("cert params");
    params.distinguished_name.push(DnType::CommonName, cn);
    let cert = params.self_signed(&key).expect("self-sign cert");
    let der = cert.der().to_vec();
    Signer {
        fingerprint: sha256_hex(&der),
        cert_der: der,
        key_pkcs8: key.serialize_der(),
    }
}

/// The pinned critical trust policy: two distinct approvers (security-reviewer +
/// system-owner) and a distinct trusted release signer, SBOM + expiry required.
/// Mirrors `testkit::pinned_policy`.
fn pinned_policy(reviewer: &Signer, owner: &Signer, release: &Signer) -> TrustPolicy {
    TrustPolicy {
        schema_version: POLICY_SCHEMA.to_string(),
        // STATED, not inferred: these signers are anchored by fingerprint alone.
        // An empty `trusted_roots` is no longer a posture — see
        // `update-bundle/src/policy.rs::TrustMode`.
        trust_mode: Some(TrustMode::PinnedSigners),
        trusted_roots: vec![],
        certificate_validity: CertificateValidity::Enforce,
        approved_signers: vec![
            ApprovedSigner {
                fingerprint: reviewer.fingerprint.clone(),
                subject: None,
                roles: vec!["security-reviewer".into()],
            },
            ApprovedSigner {
                fingerprint: owner.fingerprint.clone(),
                subject: None,
                roles: vec!["system-owner".into()],
            },
        ],
        required_approval_roles: vec!["security-reviewer".into(), "system-owner".into()],
        minimum_distinct_approvers: 2,
        trusted_release_signers: vec![release.fingerprint.clone()],
        release_signer_must_be_distinct: true,
        maximum_bundle_age_secs: None,
        require_expiry: true,
        on_expired_metadata: Some(ExpiredPosture::ReadOnly),
        require_sbom: true,
        require_vex: false,
        require_scan: false,
        allowed_products: vec![],
        allowed_models: vec![],
    }
}

/// Fill `buf` with cheap, low-compressibility pseudo-random bytes (xorshift64*), so
/// the znippy pack path does real compression work instead of trivially storing a
/// run of zeros — a firmware image is not compressible to nothing.
fn fill_pseudo_random(buf: &mut [u8], seed: u64) {
    let mut x = seed | 1;
    for chunk in buf.chunks_mut(8) {
        x ^= x >> 12;
        x ^= x << 25;
        x ^= x >> 27;
        let v = x.wrapping_mul(0x2545F491_4F6CDD1D).to_le_bytes();
        for (b, s) in chunk.iter_mut().zip(v.iter()) {
            *b = *s;
        }
    }
}

/// A firmware payload of `bytes` pseudo-random bytes for bundle `seed`.
fn firmware_payload(bytes: usize, seed: u64) -> Vec<u8> {
    let mut v = vec![0u8; bytes];
    fill_pseudo_random(&mut v, seed.wrapping_add(0x9E3779B97F4A7C15));
    v
}

/// Build a candidate [`Preparation`] at `sequence` carrying `payload` as
/// `payload/firmware.bin`, plus the SBOM + source-receipt evidence the pinned
/// policy requires. Mirrors `testkit::base_preparation` with a caller-sized payload.
fn base_prep(sequence: u64, bundle_id: &str, payload: Vec<u8>) -> Preparation {
    let now = Utc::now();
    Preparation {
        bundle_id: bundle_id.to_string(),
        sequence,
        created_at: now,
        expires_at: Some(now + ChronoDuration::days(30)),
        producer: "intake-station".into(),
        supplier: "ExampleCorp".into(),
        product: "ExampleDrive".into(),
        release_version: "1.2.3".into(),
        supported_hardware_models: vec!["ACS880".into()],
        supported_hardware_revisions: vec!["R4".into()],
        source_provenance: SourceProvenance {
            source_kind: "vendor-portal".into(),
            source_uri: Some("https://vendor.example/downloads/fw".into()),
            source_repository: None,
            source_asset_path: None,
            fetched_at: Some(now),
            reported_size: Some(payload.len() as u64),
            vendor_checksum: None,
            vendor_signature_status: Some("unverified".into()),
            tls_origin: Some("vendor.example".into()),
            transfer_receipt_digest: None,
        },
        policy_result: PolicyResult {
            policy_digest: sha256_hex(b"trust-policy-v1"),
            advisory_snapshot_digest: None,
            verdict: "pass".into(),
            non_production: true,
        },
        supersedes: vec![],
        withdraws: vec![],
        payloads: vec![PayloadInput {
            relative_path: FIRMWARE_REL.into(),
            bytes: payload,
            media_type: "application/octet-stream".into(),
        }],
        evidence: vec![
            EvidenceInput {
                relative_path: "sbom/app.cdx.json".into(),
                bytes: br#"{"bomFormat":"CycloneDX","specVersion":"1.5"}"#.to_vec(),
                media_type: "application/json".into(),
                kind: EvidenceKind::Sbom,
            },
            EvidenceInput {
                relative_path: "source-receipt.json".into(),
                bytes: br#"{"mode":"Strict","complete":true}"#.to_vec(),
                media_type: "application/json".into(),
                kind: EvidenceKind::SourceReceipt,
            },
        ],
    }
}

/// The full member path of the firmware payload inside a bundle (`payload/firmware.bin`).
fn firmware_member() -> String {
    format!("{PAYLOAD_PREFIX}{FIRMWARE_REL}")
}

// ──────────────────────────── scale sizing ────────────────────────────

/// The firmware artifact size (bytes) for a [`Scale`] on the throughput arms
/// (prepare / verify / import). A real update image is MB-scale; the size grows with
/// the tier but stays bounded so a run is seconds, not minutes. Byte-budgeted scales
/// (`huge`) clamp their per-bundle image into a sane 8–128 MiB window.
fn artifact_bytes(scale: &Scale) -> usize {
    if let Some(t) = scale.target_bytes {
        return (t as usize).clamp(8 * MIB, 128 * MIB);
    }
    if scale.entries >= 500_000 {
        32 * MIB
    } else if scale.entries >= 100_000 {
        16 * MIB
    } else {
        8 * MIB
    }
}

/// A small artifact (bytes) for the sign/finalize arm, so the measured rate is the
/// signing + release-manifest + self-verify cost, not payload hashing.
const FINALIZE_ARTIFACT_BYTES: usize = 64 * 1024;

/// The measurement window for a [`Scale`] (at least 1s).
fn window(scale: &Scale) -> Duration {
    Duration::from_secs(scale.bench_secs.max(1))
}

// ──────────────────────────── arm 1: prepare ────────────────────────────

/// `bundle_prepare` — MB/s of artifact bytes packed into a candidate bundle (the
/// znippy path). Each iteration builds a candidate from a fresh firmware image and
/// packs it to a `.holger.znippy` archive on disk. `mb_per_sec` is source artifact
/// MB/s; `ops_sec` is candidates/sec.
///
/// ANSWER floor: the packed archive round-trips through `load_bundle`, the payload
/// member's SHA-256 matches the source bytes, and it is stored **whole** (the packed
/// member's uncompressed size equals the source size — proof this is a whole-pack,
/// not a delta).
pub fn bundle_prepare_throughput(scale: &Scale) -> Result<Throughput> {
    let art = artifact_bytes(scale);
    let dir = tempfile::tempdir().context("prepare bench: tempdir")?;
    let dur = window(scale);

    let mut ops: u64 = 0;
    let mut bytes: u64 = 0;
    // Verify the answer on the FIRST packed bundle, out of the hot region's way but
    // still red-when-broken (a broken pack fails here before any number is reported).
    let first_payload = firmware_payload(art, 0);
    let first_sha = sha256_hex(&first_payload);
    let out0 = dir.path().join("bundle-000000.holger.znippy");
    {
        let candidate = base_prep(1, "bundle-000000", first_payload)
            .build()
            .map_err(|e| anyhow::anyhow!("prepare bench: build candidate: {e}"))?;
        pack_bundle(&candidate.members, &out0)
            .map_err(|e| anyhow::anyhow!("prepare bench: pack: {e}"))?;
        let loaded =
            load_bundle(&out0).map_err(|e| anyhow::anyhow!("prepare bench: load-back: {e}"))?;
        let member = firmware_member();
        let got = loaded
            .get(&member)
            .context("prepare bench: packed bundle is missing the firmware payload member")?;
        ensure!(
            got.len() == art,
            "prepare bench: payload stored size {} != source {} — not a whole-payload pack",
            got.len(),
            art
        );
        ensure!(
            sha256_hex(got) == first_sha,
            "prepare bench: round-tripped payload digest mismatch — the pack path is broken"
        );
        ops += 1;
        bytes += art as u64;
    }

    let start = Instant::now();
    while start.elapsed() < dur {
        let payload = firmware_payload(art, ops);
        let candidate = base_prep(ops + 1, &format!("bundle-{ops:06}"), payload)
            .build()
            .map_err(|e| anyhow::anyhow!("prepare bench: build candidate: {e}"))?;
        let out = dir.path().join(format!("bundle-{ops:06}.holger.znippy"));
        pack_bundle(&candidate.members, &out)
            .map_err(|e| anyhow::anyhow!("prepare bench: pack: {e}"))?;
        // Keep the scratch dir bounded — drop each packed archive after it is written.
        let _ = std::fs::remove_file(&out);
        ops += 1;
        bytes += art as u64;
    }
    let elapsed = start.elapsed();
    let s = elapsed.as_secs_f64().max(f64::MIN_POSITIVE);
    Ok(Throughput {
        ops,
        elapsed,
        bytes,
        ops_sec: ops as f64 / s,
        mb_per_sec: bytes as f64 / s / 1_048_576.0,
    })
}

// ──────────────────────── arm 2: sign + finalize ────────────────────────

/// `bundle_sign_finalize` — bundles/sec through approve×2 (two distinct ed25519
/// signers) + finalize (the ed25519 release signature over the release manifest,
/// signer identity X.509). A small artifact isolates the signing cost from payload
/// hashing. `ops_sec` is finalized-bundles/sec.
///
/// ANSWER floor: `finalize` returns a `VerifyReport` with exactly the two required
/// approvals and a release signer distinct from both approvers (finalize also
/// self-verifies, so a bundle that would not verify is never emitted).
pub fn bundle_sign_finalize_throughput(scale: &Scale) -> Result<Throughput> {
    let reviewer = mint_signer("security-reviewer");
    let owner = mint_signer("system-owner");
    let release = mint_signer("release-signer");
    let policy = pinned_policy(&reviewer, &owner, &release);
    let dur = window(scale);

    // Prepare ONE candidate (small payload); each iter clones it and drives the full
    // approve×2 + finalize on the clone.
    let payload = firmware_payload(FINALIZE_ARTIFACT_BYTES, 7);
    let candidate = base_prep(5, "bundle-sign", payload)
        .build()
        .map_err(|e| anyhow::anyhow!("finalize bench: build candidate: {e}"))?;
    let template: MemberMap = candidate.members;

    let mut ops: u64 = 0;
    let mut bytes: u64 = 0;
    let mut checked = false;
    let start = Instant::now();
    while start.elapsed() < dur {
        let now = Utc::now();
        let mut members = template.clone();
        add_approval(
            &mut members,
            "security-reviewer",
            &reviewer.cert_der,
            &reviewer.key_pkcs8,
            None,
            now,
        )
        .map_err(|e| anyhow::anyhow!("finalize bench: approve(reviewer): {e}"))?;
        add_approval(
            &mut members,
            "system-owner",
            &owner.cert_der,
            &owner.key_pkcs8,
            None,
            now,
        )
        .map_err(|e| anyhow::anyhow!("finalize bench: approve(owner): {e}"))?;
        let report = finalize(
            &mut members,
            &policy,
            &release.cert_der,
            &release.key_pkcs8,
            now,
        )
        .map_err(|e| anyhow::anyhow!("finalize bench: finalize: {e}"))?;
        if !checked {
            ensure!(
                report.approvals.len() == 2,
                "finalize bench: expected 2 approvals, got {} — the two-person rule is broken",
                report.approvals.len()
            );
            ensure!(
                report.release_signer_fingerprint == release.fingerprint,
                "finalize bench: release signer fingerprint mismatch"
            );
            ensure!(
                report.release_signer_fingerprint != reviewer.fingerprint
                    && report.release_signer_fingerprint != owner.fingerprint,
                "finalize bench: release signer must be distinct from the approvers"
            );
            checked = true;
        }
        ops += 1;
        bytes += FINALIZE_ARTIFACT_BYTES as u64;
    }
    let elapsed = start.elapsed();
    let s = elapsed.as_secs_f64().max(f64::MIN_POSITIVE);
    Ok(Throughput {
        ops,
        elapsed,
        bytes,
        ops_sec: ops as f64 / s,
        mb_per_sec: bytes as f64 / s / 1_048_576.0,
    })
}

/// Build one honest, finalized bundle at `sequence` and pack it to `out`. Returns
/// the packed path's policy + the source payload (for the ANSWER floors). Shared by
/// the verify and import arms.
fn finalized_bundle(
    sequence: u64,
    bundle_id: &str,
    art: usize,
    out: &std::path::Path,
) -> Result<(TrustPolicy, Vec<u8>)> {
    let payload = firmware_payload(art, sequence);
    let policy = sign_and_pack(base_prep(sequence, bundle_id, payload.clone()), out)?;
    Ok((policy, payload))
}

/// An attestation signing identity on disk: a P-256 root and a P-256 leaf it
/// issued (DER), and the leaf's PKCS#8 key (DER) — what a holger config's
/// `attestation:` block names (`signer_alg: "ecdsa-p256"`).
pub struct AttestPki {
    pub root_der: std::path::PathBuf,
    pub cert_der: std::path::PathBuf,
    pub key_pkcs8: std::path::PathBuf,
}

/// Mint an [`AttestPki`] under `dir`, files named after `cn`.
pub fn mint_attestation_pki(dir: &std::path::Path, cn: &str) -> Result<AttestPki> {
    use rcgen::{BasicConstraints, IsCa, Issuer, KeyUsagePurpose, PKCS_ECDSA_P256_SHA256};
    std::fs::create_dir_all(dir)?;
    let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)?;
    let mut ca = CertificateParams::new(vec![])?;
    ca.distinguished_name
        .push(DnType::CommonName, format!("{cn} root"));
    ca.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
    ca.key_usages = vec![
        KeyUsagePurpose::KeyCertSign,
        KeyUsagePurpose::DigitalSignature,
    ];
    let ca_cert = ca.self_signed(&ca_key)?;
    let issuer = Issuer::new(ca, ca_key);
    let leaf_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)?;
    let mut leaf = CertificateParams::new(vec![])?;
    leaf.distinguished_name.push(DnType::CommonName, cn);
    leaf.key_usages = vec![KeyUsagePurpose::DigitalSignature];
    let leaf_cert = leaf.signed_by(&leaf_key, &issuer)?;
    let pki = AttestPki {
        root_der: dir.join(format!("{cn}-root.der")),
        cert_der: dir.join(format!("{cn}.der")),
        key_pkcs8: dir.join(format!("{cn}.pk8")),
    };
    std::fs::write(&pki.root_der, ca_cert.der())?;
    std::fs::write(&pki.cert_der, leaf_cert.der())?;
    std::fs::write(&pki.key_pkcs8, leaf_key.serialize_der())?;
    // HOL-NCSC: Holger refuses a key file others can read (`holger-key-1`).
    #[cfg(unix)]
    std::fs::set_permissions(
        &pki.key_pkcs8,
        <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
    )?;
    Ok(pki)
}

/// **Sign an already-built candidate** (a member map, e.g. the directory
/// `holger-server export` wrote): mint the three signers, approve twice,
/// finalize and pack to `out`. Returns the pinned policy that trusts them. For
/// the airgap export's end-to-end tests.
pub fn sign_candidate_and_pack(
    members: holger_update_bundle::MemberMap,
    out: &std::path::Path,
) -> Result<TrustPolicy> {
    let reviewer = mint_signer("security-reviewer");
    let owner = mint_signer("system-owner");
    let release = mint_signer("release-signer");
    let policy = pinned_policy(&reviewer, &owner, &release);
    let now = Utc::now();
    let mut members = members;
    add_approval(
        &mut members,
        "security-reviewer",
        &reviewer.cert_der,
        &reviewer.key_pkcs8,
        None,
        now,
    )
    .map_err(|e| anyhow::anyhow!("approve(reviewer): {e}"))?;
    add_approval(
        &mut members,
        "system-owner",
        &owner.cert_der,
        &owner.key_pkcs8,
        None,
        now,
    )
    .map_err(|e| anyhow::anyhow!("approve(owner): {e}"))?;
    finalize(
        &mut members,
        &policy,
        &release.cert_der,
        &release.key_pkcs8,
        now,
    )
    .map_err(|e| anyhow::anyhow!("finalize: {e}"))?;
    pack_bundle(&members, out).map_err(|e| anyhow::anyhow!("pack: {e}"))?;
    Ok(policy)
}

/// Mint the three signers, approve twice, finalize and pack `prep` to `out`;
/// the pinned policy that trusts exactly those signers is returned.
fn sign_and_pack(prep: Preparation, out: &std::path::Path) -> Result<TrustPolicy> {
    let reviewer = mint_signer("security-reviewer");
    let owner = mint_signer("system-owner");
    let release = mint_signer("release-signer");
    let policy = pinned_policy(&reviewer, &owner, &release);
    let now = Utc::now();
    let candidate = prep
        .build()
        .map_err(|e| anyhow::anyhow!("build candidate: {e}"))?;
    let mut members = candidate.members;
    add_approval(
        &mut members,
        "security-reviewer",
        &reviewer.cert_der,
        &reviewer.key_pkcs8,
        None,
        now,
    )
    .map_err(|e| anyhow::anyhow!("approve(reviewer): {e}"))?;
    add_approval(
        &mut members,
        "system-owner",
        &owner.cert_der,
        &owner.key_pkcs8,
        None,
        now,
    )
    .map_err(|e| anyhow::anyhow!("approve(owner): {e}"))?;
    finalize(
        &mut members,
        &policy,
        &release.cert_der,
        &release.key_pkcs8,
        now,
    )
    .map_err(|e| anyhow::anyhow!("finalize: {e}"))?;
    pack_bundle(&members, out).map_err(|e| anyhow::anyhow!("pack: {e}"))?;
    Ok(policy)
}

/// **A signed bundle whose metadata expires at `expires_at`**, packed to `out`,
/// and the pinned policy that trusts its signers (it states
/// `on_expired_metadata: Some(ReadOnly)` only because a downstream verb refuses
/// to start without a posture — the choice is a fixture's, not a ruling). For
/// the downstream verbs' clock tests: a bundle that is fresh by one clock and
/// expired by another. `expires_at` must be after now (finalize verifies).
pub fn finalized_bundle_expiring_at(
    sequence: u64,
    bundle_id: &str,
    expires_at: chrono::DateTime<Utc>,
    out: &std::path::Path,
) -> Result<TrustPolicy> {
    let mut prep = base_prep(sequence, bundle_id, firmware_payload(4096, sequence));
    prep.expires_at = Some(expires_at);
    sign_and_pack(prep, out)
}

// ──────────────────────────── arm 3: verify ────────────────────────────

/// `bundle_verify` — MB/s of the offline verifier (the protected-side gate). Builds
/// one finalized bundle, then loops `verify_bundle_file` (fully offline: digest
/// re-check of every covered member + the ed25519 release signature). `mb_per_sec`
/// is artifact MB/s verified; `ops_sec` is verifications/sec.
///
/// ANSWER floor: every verification accepts the honest bundle with exactly 2
/// approvals, AND a single-byte-tampered copy is rejected.
pub fn bundle_verify_throughput(scale: &Scale) -> Result<Throughput> {
    let art = artifact_bytes(scale);
    let dir = tempfile::tempdir().context("verify bench: tempdir")?;
    let out = dir.path().join("bundle.holger.znippy");
    let (policy, _payload) = finalized_bundle(9, "bundle-verify", art, &out)
        .context("verify bench: build finalized bundle")?;

    // ANSWER floor (tamper): a byte-flipped copy MUST NOT verify.
    {
        let tampered = dir.path().join("tampered.holger.znippy");
        let mut raw = std::fs::read(&out).context("verify bench: read bundle for tamper")?;
        // Flip a byte in the archive body (past the header) — the manifest digest
        // layer must catch it.
        let pos = raw.len() / 2;
        raw[pos] ^= 0x01;
        std::fs::write(&tampered, &raw).context("verify bench: write tampered")?;
        ensure!(
            verify_bundle_file(&tampered, &policy, &TargetSpec::default(), None).is_err(),
            "verify bench: a tampered archive verified — the offline gate is broken"
        );
    }

    let dur = window(scale);
    let mut ops: u64 = 0;
    let mut bytes: u64 = 0;
    let start = Instant::now();
    while start.elapsed() < dur {
        let report = verify_bundle_file(&out, &policy, &TargetSpec::default(), None)
            .map_err(|e| anyhow::anyhow!("verify bench: verify: {e}"))?;
        ensure!(
            report.approvals.len() == 2,
            "verify bench: honest bundle verified with {} approvals, expected 2",
            report.approvals.len()
        );
        ops += 1;
        bytes += art as u64;
    }
    let elapsed = start.elapsed();
    let s = elapsed.as_secs_f64().max(f64::MIN_POSITIVE);
    Ok(Throughput {
        ops,
        elapsed,
        bytes,
        ops_sec: ops as f64 / s,
        mb_per_sec: bytes as f64 / s / 1_048_576.0,
    })
}

// ──────────────────────────── arm 4: import ────────────────────────────

/// `bundle_import` — MB/s of atomic import into the protected data root. Each
/// iteration verifies-then-extracts the bundle into a fresh tree (staging dir +
/// atomic rename — no partial tree ever visible) and records the accepted sequence
/// in the anti-rollback store. `mb_per_sec` is artifact MB/s imported; `ops_sec` is
/// imports/sec.
///
/// ANSWER floor: the extracted `payload/firmware.bin` bytes equal the source, AND
/// the anti-rollback state **rejects a replay** (same sequence, different bytes) and
/// a **downgrade** (lower sequence) while accepting an exact idempotent re-import.
pub fn bundle_import_throughput(scale: &Scale) -> Result<Throughput> {
    let art = artifact_bytes(scale);
    let dir = tempfile::tempdir().context("import bench: tempdir")?;
    let bundle = dir.path().join("bundle.holger.znippy");
    let (policy, payload) = finalized_bundle(9, "bundle-import", art, &bundle)
        .context("import bench: build finalized bundle")?;
    let payload_sha = sha256_hex(&payload);

    let data_root = dir.path().join("protected-root");
    std::fs::create_dir_all(&data_root).context("import bench: mkdir data root")?;
    let rollback_root = dir.path().join("rollback-state");
    let store = RollbackStore::open(&rollback_root);

    // ANSWER floor #1 (first import): extracted payload bytes must equal the source.
    {
        let dest0 = data_root.join("import-000000");
        let report = extract_bundle_file(&bundle, &policy, &TargetSpec::default(), &dest0)
            .map_err(|e| anyhow::anyhow!("import bench: extract: {e}"))?;
        let got = std::fs::read(dest0.join("payload").join(FIRMWARE_REL))
            .context("import bench: read extracted firmware")?;
        ensure!(
            sha256_hex(&got) == payload_sha,
            "import bench: extracted payload digest mismatch — atomic import is broken"
        );
        store
            .record(
                &report.supplier,
                &report.product,
                &AcceptedEntry {
                    sequence: report.sequence,
                    bundle_id: report.bundle_id.clone(),
                    core_digest: report.candidate_digest.clone(),
                    accepted_at: Utc::now(),
                },
            )
            .map_err(|e| anyhow::anyhow!("import bench: record accepted: {e}"))?;
    }

    // ANSWER floor #2 (anti-rollback): a replay and a downgrade must be REJECTED,
    // an exact idempotent re-import ACCEPTED as such. Load the on-disk accepted
    // state and interrogate the admissibility gate directly.
    {
        let report = verify_bundle_file(&bundle, &policy, &TargetSpec::default(), None)
            .map_err(|e| anyhow::anyhow!("import bench: re-verify for rollback check: {e}"))?;
        let state = store
            .load(&report.supplier, &report.product)
            .map_err(|e| anyhow::anyhow!("import bench: load accepted state: {e}"))?;
        let members =
            load_bundle(&bundle).map_err(|e| anyhow::anyhow!("import bench: load: {e}"))?;
        let core_bytes = members
            .get("core.json")
            .context("import bench: bundle missing core.json")?;
        let core = holger_update_bundle::core::BundleCore::from_json_bytes(core_bytes)
            .map_err(|e| anyhow::anyhow!("import bench: parse core.json: {e}"))?;
        let digest = report.candidate_digest.clone();

        // Exact re-import → idempotent (accepted no-op).
        match state.check_admissible(&core, &digest) {
            Ok(Admissibility::IdempotentReimport) => {}
            other => anyhow::bail!(
                "import bench: exact re-import should be IdempotentReimport, got {other:?}"
            ),
        }
        // Replay: same sequence, DIFFERENT bytes → rejected.
        ensure!(
            state
                .check_admissible(&core, "deadbeef_replayed_digest")
                .is_err(),
            "import bench: anti-rollback accepted a same-sequence replay — the gate is broken"
        );
        // Downgrade: a lower sequence than the accepted high-water mark → rejected.
        let mut older = core.clone();
        older.sequence = core.sequence.saturating_sub(1);
        let older_digest = sha256_hex(
            &older
                .to_json_bytes()
                .map_err(|e| anyhow::anyhow!("import bench: reserialize older core: {e}"))?,
        );
        ensure!(
            state.check_admissible(&older, &older_digest).is_err(),
            "import bench: anti-rollback accepted a downgrade — the gate is broken"
        );
    }

    // Timed loop: verify-then-extract into a fresh tree each iteration (the atomic
    // import hot path). The anti-rollback record is exercised above; the throughput
    // number is the verify + extract cost, which is what an import actually pays.
    let dur = window(scale);
    let mut ops: u64 = 1; // the floor import above counts as one
    let mut bytes: u64 = art as u64;
    let start = Instant::now();
    while start.elapsed() < dur {
        let dest = data_root.join(format!("import-{ops:06}"));
        extract_bundle_file(&bundle, &policy, &TargetSpec::default(), &dest)
            .map_err(|e| anyhow::anyhow!("import bench: extract: {e}"))?;
        // Reclaim the extracted tree so the scratch dir stays bounded across a long run.
        let _ = std::fs::remove_dir_all(&dest);
        ops += 1;
        bytes += art as u64;
    }
    let elapsed = start.elapsed();
    let s = elapsed.as_secs_f64().max(f64::MIN_POSITIVE);
    Ok(Throughput {
        ops,
        elapsed,
        bytes,
        ops_sec: ops as f64 / s,
        mb_per_sec: bytes as f64 / s / 1_048_576.0,
    })
}

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

    /// Red-when-broken correctness gate for all four arms at the sub-2s `sane`
    /// scale: each arm produces a positive throughput over real artifact bytes, and
    /// — crucially — each arm's `ensure!` ANSWER floor holds (a broken bundle path
    /// would `Err` out of the `*_throughput` fn before these asserts, failing the
    /// test). This is the twin of the numbers the xtask bench arm reports.
    #[test]
    fn secure_update_arms_yield_real_throughput_and_hold_the_floors() {
        let scale = Scale::sane();

        let prep = bundle_prepare_throughput(&scale).expect("prepare arm runs + floor holds");
        assert!(prep.ops >= 1 && prep.mb_per_sec > 0.0, "prepare: {prep:?}");

        let fin = bundle_sign_finalize_throughput(&scale).expect("finalize arm runs + floor holds");
        assert!(fin.ops >= 1 && fin.ops_sec > 0.0, "finalize: {fin:?}");

        let ver = bundle_verify_throughput(&scale).expect("verify arm runs + floor holds");
        assert!(ver.ops >= 1 && ver.mb_per_sec > 0.0, "verify: {ver:?}");

        let imp = bundle_import_throughput(&scale).expect("import arm runs + floor holds");
        assert!(imp.ops >= 1 && imp.mb_per_sec > 0.0, "import: {imp:?}");
    }
}