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:?}");
}
}