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

nordisk/holger

server/lib/src/promote/airgap.rs

//! **The airgap: export the approved set, import it verified on the secure side.**
//!
//! ```text
//!  online holger                                   secure holger (network: Offline)
//!  production: AVAILABLE ──export──▶ signed bundle ──USB──▶ verified import ──▶ production: AVAILABLE
//!     + stored evidence                (holger-bundle         bundle signature,     + the online
//!     + signed attestations             approve/finalize)     per-artifact hashes,    record, audited
//!     + SBOM + intake record                                  evidence attestations
//! ```
//!
//! ## The export
//!
//! [`collect_export`] takes AVAILABLE artifacts from the production repository
//! (all of them, or the ones named), with:
//!
//! * the bytes, read back from the sealed store against the ingested SHA-256;
//! * the newest stored scan evidence about those bytes ([`crate::scanner::EvidenceStore`]);
//! * every signed attestation filed about them (scan evidence and SBOM), and the
//!   signer certificate a verifier needs;
//! * the SBOM document, and the artifact's whole intake record.
//!
//! **The verdict is computed, never typed.** The stored evidence is folded again
//! ([`crate::scanner::fold`]); an artifact passes only when a vulnerability
//! scanner actually ran, nothing blocks, the evidence is signed and verifies, and
//! its intake record holds an approval. An artifact that fails any of these is
//! refused BY NAME, and the export writes nothing.
//!
//! ## The import
//!
//! [`verify_import`] runs on the secure side after the bundle's own verifier
//! (release signature, manifest, anti-rollback). It re-checks every payload's
//! SHA-256, BLAKE3 and size against the export index AND the signed `core.json`,
//! verifies every scan-evidence attestation against THIS side's trust roots and
//! enrolled signers, and folds the verified evidence again. Only then does
//! [`super::intake::IntakeChain::land_imported`] land each artifact AVAILABLE.

use serde::{Deserialize, Serialize};
use traits::ArtifactId;

use super::coord_key;
use super::intake::{ArtifactState, EvidenceKind, EvidenceVerdict, IntakeChain, IntakeEntry};
use crate::attest::{ArtifactDigest, AttestStore, CYCLONEDX_BOM, HOLGER_SCAN_EVIDENCE_V1};
use crate::scanner::{EvidenceStore, ScanVerdict, ScannerKind};

/// The export index's schema.
pub const EXPORT_SCHEMA: &str = "holger.airgap-export/v1";
/// `core.json`'s `source_provenance.source_kind` for an export bundle.
pub const SOURCE_KIND: &str = "holger-airgap-export";
/// Where the index lives, relative to the bundle's `evidence/`.
pub const INDEX_PATH: &str = "holger-export.json";

/// What one export carries, as `evidence/holger-export.json`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExportIndex {
    pub schema: String,
    /// Unix seconds. Never filled by an export (the decision profile,
    /// [`crate::export_profile`]): `core.json`'s `created_at` is the bundle's time.
    #[serde(default, skip_serializing_if = "is_zero")]
    pub exported_at: i64,
    /// Never filled by an export: who exported stays in the online audit log.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub exported_by: String,
    /// Never filled by an export: the online box's repository name stays online.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub production: String,
    /// `pass`: every artifact in the set passed. Computed.
    pub verdict: String,
    /// What the verdict was computed from, in words.
    pub computed_from: String,
    pub artifacts: Vec<ExportedArtifact>,
}

fn is_zero(n: &i64) -> bool {
    *n == 0
}

/// One artifact of an export.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExportedArtifact {
    #[serde(default)]
    pub namespace: Option<String>,
    pub name: String,
    #[serde(default)]
    pub version: String,
    /// The bytes, relative to the bundle's `payload/`.
    pub payload: String,
    pub sha256: String,
    pub blake3: String,
    pub size: u64,
    /// `pass` or `warn`, computed from the evidence.
    pub verdict: String,
    /// Why it is `warn`, one sentence each.
    #[serde(default)]
    pub reasons: Vec<String>,
    /// The scanner kinds that ran.
    pub ran: Vec<String>,
    /// The scanner kinds no scanner is installed for. Never a pass.
    pub not_run: Vec<String>,
    /// Scan-evidence records, relative to `evidence/`.
    pub evidence: Vec<String>,
    /// DSSE envelopes (scan evidence and SBOM), relative to `evidence/`.
    pub attestations: Vec<String>,
    /// The attestation signer's certificate (DER), relative to `evidence/`.
    pub signer_cert: String,
    /// The CycloneDX SBOM, relative to `evidence/`.
    #[serde(default)]
    pub sbom: Option<String>,
    /// The online intake record (JSON lines), relative to `evidence/`.
    pub intake_record: String,
    /// The distinct principals whose approval brought it to APPROVED.
    pub approvers: Vec<String>,
    /// The provenance material that came with the artifact (lane G3a), relative
    /// to `evidence/`. The secure side verifies it again under its own policy.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub provenance: Vec<String>,
    /// The artifact's index row (lane HOL-PERF-2, a `holger.index-row/v1` document),
    /// relative to `evidence/`: what the secure side's indexes render, so it never parses
    /// the package itself.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub index_row: Option<String>,
}

impl ExportedArtifact {
    pub fn id(&self) -> ArtifactId {
        ArtifactId {
            namespace: self.namespace.clone(),
            name: self.name.clone(),
            version: self.version.clone(),
        }
    }
    pub fn label(&self) -> String {
        super::artifact_label(&self.id())
    }
}

/// What a bundle member is, for the bundle's own evidence kinds.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileKind {
    Payload,
    Sbom,
    Scan,
    Other,
}

/// One file of an export, at its path relative to `payload/` or `evidence/`.
#[derive(Debug, Clone)]
pub struct ExportFile {
    pub path: String,
    pub bytes: Vec<u8>,
    pub media_type: String,
    pub kind: FileKind,
}

/// A collected export: the index and every file.
#[derive(Debug, Clone)]
pub struct ExportSet {
    pub index: ExportIndex,
    pub payloads: Vec<ExportFile>,
    pub evidence: Vec<ExportFile>,
}

/// Why an export or an import did not happen. Nothing was written.
#[derive(Debug)]
pub enum AirgapError {
    NotAvailable {
        artifact: String,
        state: String,
    },
    NoEvidence {
        artifact: String,
        why: String,
    },
    EvidenceFailed {
        artifact: String,
        reasons: Vec<String>,
    },
    NotAttested {
        artifact: String,
        why: String,
    },
    NotApproved(String),
    NothingToExport,
    /// Every refusal of one export, each by name.
    Refused(Vec<AirgapError>),
    IndexInvalid(String),
    PayloadMismatch {
        artifact: String,
        why: String,
    },
    AttestationInvalid {
        artifact: String,
        why: String,
    },
    AuditFailed(String),
    Intake(super::intake::IntakeError),
}

impl AirgapError {
    pub fn code(&self) -> &'static holger_errcode::ErrCode {
        match self {
            AirgapError::NotAvailable { .. } => &holger_errcode::AIRGAP_NOT_AVAILABLE,
            AirgapError::NoEvidence { .. } => &holger_errcode::AIRGAP_NO_EVIDENCE,
            AirgapError::EvidenceFailed { .. } => &holger_errcode::AIRGAP_EVIDENCE_FAILED,
            AirgapError::NotAttested { .. } => &holger_errcode::AIRGAP_NOT_ATTESTED,
            AirgapError::NotApproved(_) => &holger_errcode::AIRGAP_NOT_APPROVED,
            AirgapError::NothingToExport => &holger_errcode::AIRGAP_NOTHING_TO_EXPORT,
            AirgapError::Refused(all) => all
                .first()
                .map(|e| e.code())
                .unwrap_or(&holger_errcode::AIRGAP_NOTHING_TO_EXPORT),
            AirgapError::IndexInvalid(_) => &holger_errcode::AIRGAP_INDEX_INVALID,
            AirgapError::PayloadMismatch { .. } => &holger_errcode::AIRGAP_PAYLOAD_MISMATCH,
            AirgapError::AttestationInvalid { .. } => &holger_errcode::AIRGAP_ATTESTATION_INVALID,
            AirgapError::AuditFailed(_) => &holger_errcode::AIRGAP_AUDIT_FAILED,
            AirgapError::Intake(e) => e.code(),
        }
    }

    /// The message with its code: `[holger-airgap-2] …`.
    pub fn tagged(&self) -> String {
        match self {
            AirgapError::Refused(all) => all
                .iter()
                .map(|e| e.tagged())
                .collect::<Vec<_>>()
                .join("\n"),
            AirgapError::Intake(e) => e.tagged(),
            other => other.code().tag(other),
        }
    }

    /// The artifacts a refusal names.
    pub fn artifacts(&self) -> Vec<String> {
        match self {
            AirgapError::NotAvailable { artifact, .. }
            | AirgapError::NoEvidence { artifact, .. }
            | AirgapError::EvidenceFailed { artifact, .. }
            | AirgapError::NotAttested { artifact, .. }
            | AirgapError::PayloadMismatch { artifact, .. }
            | AirgapError::AttestationInvalid { artifact, .. } => vec![artifact.clone()],
            AirgapError::NotApproved(a) => vec![a.clone()],
            AirgapError::Refused(all) => all.iter().flat_map(|e| e.artifacts()).collect(),
            _ => Vec::new(),
        }
    }
}

impl std::fmt::Display for AirgapError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AirgapError::NotAvailable { artifact, state } => {
                write!(
                    f,
                    "'{artifact}' is {state}, not AVAILABLE in production, so it is not exported"
                )
            }
            AirgapError::NoEvidence { artifact, why } => {
                write!(f, "'{artifact}' is refused from the export: {why}")
            }
            AirgapError::EvidenceFailed { artifact, reasons } => write!(
                f,
                "'{artifact}' is refused from the export: its stored evidence does not pass ({})",
                reasons.join("; ")
            ),
            AirgapError::NotAttested { artifact, why } => {
                write!(
                    f,
                    "'{artifact}' is refused from the export: its evidence is not signed ({why})"
                )
            }
            AirgapError::NotApproved(a) => {
                write!(
                    f,
                    "'{a}' is refused from the export: its intake record holds no approval"
                )
            }
            AirgapError::NothingToExport => {
                write!(f, "there is no AVAILABLE artifact in production to export")
            }
            AirgapError::Refused(all) => {
                write!(
                    f,
                    "{}",
                    all.iter()
                        .map(|e| e.to_string())
                        .collect::<Vec<_>>()
                        .join("; ")
                )
            }
            AirgapError::IndexInvalid(m) => write!(f, "the bundle's export index is invalid: {m}"),
            AirgapError::PayloadMismatch { artifact, why } => {
                write!(f, "'{artifact}' does not match the bundle's record: {why}")
            }
            AirgapError::AttestationInvalid { artifact, why } => {
                write!(
                    f,
                    "'{artifact}': an evidence attestation does not verify: {why}"
                )
            }
            AirgapError::AuditFailed(m) => {
                write!(
                    f,
                    "the audit record could not be written, so nothing happened: {m}"
                )
            }
            AirgapError::Intake(e) => write!(f, "{e}"),
        }
    }
}

impl std::error::Error for AirgapError {}

impl From<super::intake::IntakeError> for AirgapError {
    fn from(e: super::intake::IntakeError) -> Self {
        AirgapError::Intake(e)
    }
}

/// The verdict of one artifact's evidence, folded again.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EvidenceVerdictSummary {
    /// `pass` or `warn`.
    pub verdict: String,
    pub reasons: Vec<String>,
    pub ran: Vec<String>,
    pub not_run: Vec<String>,
}

/// ★ **The computed verdict of one evidence record.** Refuses when nothing
/// scanned for vulnerabilities, when the fold blocks, or when the record's own
/// stated verdict disagrees with the fold (a record that says `pass` over
/// records that block was changed).
pub fn judge_evidence(
    artifact: &str,
    ev: &crate::scanner::Evidence,
) -> Result<EvidenceVerdictSummary, AirgapError> {
    let ran: Vec<String> = ev
        .records
        .iter()
        .filter(|r| !r.is_not_configured())
        .map(|r| r.kind.as_str().to_string())
        .collect();
    let not_run: Vec<String> = ScannerKind::ALL
        .iter()
        .filter(|k| {
            !ev.records
                .iter()
                .any(|r| r.kind == **k && !r.is_not_configured())
        })
        .map(|k| k.as_str().to_string())
        .collect();
    if !ev
        .records
        .iter()
        .any(|r| r.kind == ScannerKind::Vulnerability && !r.is_not_configured())
    {
        return Err(AirgapError::NoEvidence {
            artifact: artifact.to_string(),
            why:
                "no vulnerability scanner ran over it, and an artifact nothing scanned never passes"
                    .into(),
        });
    }
    let (verdict, reasons) = crate::scanner::fold(&ev.records);
    if verdict != ev.verdict {
        return Err(AirgapError::EvidenceFailed {
            artifact: artifact.to_string(),
            reasons: vec![format!(
                "the record states {} but its scanners fold to {}",
                ev.verdict.as_str(),
                verdict.as_str()
            )],
        });
    }
    match verdict {
        ScanVerdict::Pass => Ok(EvidenceVerdictSummary {
            verdict: "pass".into(),
            reasons,
            ran,
            not_run,
        }),
        ScanVerdict::Warn => Ok(EvidenceVerdictSummary {
            verdict: "warn".into(),
            reasons,
            ran,
            not_run,
        }),
        ScanVerdict::Block | ScanVerdict::Error => Err(AirgapError::EvidenceFailed {
            artifact: artifact.to_string(),
            reasons,
        }),
    }
}

/// The approvers on an intake record, and a refusal when there are none or when
/// any slot on the record failed.
fn judge_record(artifact: &str, history: &[IntakeEntry]) -> Result<Vec<String>, AirgapError> {
    let failing: Vec<String> = history
        .iter()
        .filter(|e| e.to != ArtifactState::Blocked)
        .flat_map(|e| e.evidence.iter())
        .filter(|e| e.verdict == EvidenceVerdict::Fail)
        .map(|e| format!("{} evidence from '{}' failed", e.kind.name(), e.source))
        .collect();
    if !failing.is_empty() {
        return Err(AirgapError::EvidenceFailed {
            artifact: artifact.to_string(),
            reasons: failing,
        });
    }
    let approved = history
        .iter()
        .rev()
        .find(|e| e.to == ArtifactState::Approved)
        .ok_or_else(|| AirgapError::NotApproved(artifact.to_string()))?;
    let mut approvers: Vec<String> = approved
        .evidence
        .iter()
        .filter(|e| e.kind == EvidenceKind::Approval && e.verdict != EvidenceVerdict::Fail)
        .map(|e| e.source.clone())
        .collect();
    approvers.sort();
    approvers.dedup();
    if approvers.is_empty() {
        return Err(AirgapError::NotApproved(artifact.to_string()));
    }
    Ok(approvers)
}

fn matches(sel: &str, id: &ArtifactId) -> bool {
    let base = id.name.rsplit('/').next().unwrap_or(&id.name);
    sel == id.name || sel == base || sel == super::artifact_label(id)
}

/// ★ **Collect the export of the approved set.**
///
/// `select` names artifacts (file name, name, or `name@version`); empty means
/// every AVAILABLE artifact. `signer_cert` is the certificate the evidence was
/// signed with. Every artifact is judged; if any is refused, the result is
/// [`AirgapError::Refused`] naming each, and nothing is exported.
pub fn collect_export(
    chain: &IntakeChain,
    evidence: &EvidenceStore,
    attest: Option<&AttestStore>,
    signer_cert: Option<&[u8]>,
    select: &[String],
) -> Result<ExportSet, AirgapError> {
    let all = chain.all();
    let mut chosen: Vec<ArtifactId> = Vec::new();
    let mut refusals = Vec::new();
    if select.is_empty() {
        chosen.extend(
            all.iter()
                .filter(|(_, s)| s.state == ArtifactState::Available)
                .map(|(id, _)| id.clone()),
        );
    } else {
        for sel in select {
            match all.iter().find(|(id, _)| matches(sel, id)) {
                Some((id, s)) if s.state == ArtifactState::Available => chosen.push(id.clone()),
                Some((id, s)) => refusals.push(AirgapError::NotAvailable {
                    artifact: super::artifact_label(id),
                    state: s.state.to_string(),
                }),
                None => refusals.push(AirgapError::NotAvailable {
                    artifact: sel.clone(),
                    state: "unknown here (no intake record)".into(),
                }),
            }
        }
    }
    if chosen.is_empty() && refusals.is_empty() {
        return Err(AirgapError::NothingToExport);
    }

    let mut index = ExportIndex {
        schema: EXPORT_SCHEMA.into(),
        // The decision profile (HOL-NCSC): who, when and from which repository stay online.
        exported_at: 0,
        exported_by: String::new(),
        production: String::new(),
        verdict: String::new(),
        computed_from:
            "the newest stored scan evidence about each artifact's SHA-256, folded again; \
                        its signed attestations, verified; the intake record's approvals"
                .into(),
        artifacts: Vec::new(),
    };
    let mut payloads = Vec::new();
    let mut files = Vec::new();
    for id in chosen {
        match export_one(chain, evidence, attest, signer_cert, &id) {
            Ok((art, payload, ev_files)) => {
                index.artifacts.push(art);
                payloads.push(payload);
                files.extend(ev_files);
            }
            Err(e) => refusals.push(e),
        }
    }
    if !refusals.is_empty() {
        return Err(AirgapError::Refused(refusals));
    }
    index.verdict = "pass".into();
    let bytes = serde_json::to_vec_pretty(&index).expect("the index is plain data");
    files.push(ExportFile {
        path: INDEX_PATH.into(),
        bytes,
        media_type: "application/json".into(),
        kind: FileKind::Other,
    });
    Ok(ExportSet {
        index,
        payloads,
        evidence: files,
    })
}

fn export_one(
    chain: &IntakeChain,
    evidence: &EvidenceStore,
    attest: Option<&AttestStore>,
    signer_cert: Option<&[u8]>,
    id: &ArtifactId,
) -> Result<(ExportedArtifact, ExportFile, Vec<ExportFile>), AirgapError> {
    let label = super::artifact_label(id);
    let bytes = chain.serve(id)?.ok_or_else(|| AirgapError::NotAvailable {
        artifact: label.clone(),
        state: chain.state(id).to_string(),
    })?;
    let digest = ArtifactDigest::of_sha256(&bytes);
    let sha = digest.hex().to_string();
    let history = chain.history(id);

    let stored = evidence
        .latest(&sha)
        .map_err(|e| AirgapError::NoEvidence {
            artifact: label.clone(),
            why: e.tagged(),
        })?
        .ok_or_else(|| AirgapError::NoEvidence {
            artifact: label.clone(),
            why: format!("no scan evidence is stored for sha256 {sha}"),
        })?;
    let summary = judge_evidence(&label, &stored)?;
    let approvers = judge_record(&label, &history)?;

    let attest = attest.ok_or_else(|| AirgapError::NotAttested {
        artifact: label.clone(),
        why: "this holger declares no `attestation:` block".into(),
    })?;
    let cert = signer_cert.ok_or_else(|| AirgapError::NotAttested {
        artifact: label.clone(),
        why: "this holger declares no attestation signer".into(),
    })?;
    let certs = vec![cert.to_vec()];
    let scan_envs = attest
        .query(&digest, HOLGER_SCAN_EVIDENCE_V1)
        .map_err(|e| AirgapError::NotAttested {
            artifact: label.clone(),
            why: e.tagged(),
        })?;
    let signed = scan_envs.iter().any(|a| {
        crate::scanner::attestation::verify_evidence(attest, &a.envelope_json, &certs, &digest)
            .map(|ev| ev == stored)
            .unwrap_or(false)
    });
    if !signed {
        return Err(AirgapError::NotAttested {
            artifact: label,
            why: format!(
                "none of the {} scan-evidence attestation(s) filed about sha256 {sha} verifies to its newest evidence",
                scan_envs.len()
            ),
        });
    }
    let sbom_envs = attest
        .query(&digest, CYCLONEDX_BOM)
        .map_err(|e| AirgapError::NotAttested {
            artifact: label.clone(),
            why: e.tagged(),
        })?;

    let key = coord_key(id);
    let base = id.name.rsplit('/').next().unwrap_or(&id.name).to_string();
    let payload_path = format!("{key}/{base}");
    let mut out = Vec::new();
    let mut put = |path: String, bytes: Vec<u8>, media: &str, kind: FileKind| {
        out.push(ExportFile {
            path: path.clone(),
            bytes,
            media_type: media.into(),
            kind,
        });
        path
    };
    let ev_path = put(
        format!("{key}/scan-evidence.json"),
        stored.to_bytes(),
        "application/json",
        FileKind::Scan,
    );
    let mut attestations = Vec::new();
    for a in scan_envs.iter().chain(sbom_envs.iter()) {
        attestations.push(put(
            format!("{key}/attestations/{}.dsse.json", &a.envelope_id[..16]),
            a.envelope_json.clone(),
            "application/vnd.dsse.envelope.v1+json",
            FileKind::Other,
        ));
    }
    let cert_path = put(
        format!("{key}/attestation-signer.der"),
        cert.to_vec(),
        "application/pkix-cert",
        FileKind::Other,
    );
    let sbom = stored.document_of(ScannerKind::Sbom).map(|doc| {
        put(
            format!("{key}/sbom.cdx.json"),
            serde_json::to_vec_pretty(doc).expect("an SBOM is JSON"),
            "application/vnd.cyclonedx+json",
            FileKind::Sbom,
        )
    });
    // The decision record (HOL-NCSC, `crate::export_profile`): each line as the secure side
    // needs it, the rest of the online record stays online.
    let record: String = history
        .iter()
        .map(|e| {
            serde_json::to_string(&crate::export_profile::decision_entry(e))
                .expect("an intake entry is plain data")
                + "\n"
        })
        .collect();
    let record_path = put(
        format!("{key}/intake.jsonl"),
        record.into_bytes(),
        "application/jsonl",
        FileKind::Other,
    );
    #[cfg_attr(not(feature = "scanners"), allow(unused_mut))]
    let mut provenance = Vec::new();
    #[cfg(feature = "scanners")]
    if let Some(p) = chain.scanner().and_then(|s| s.provenance()) {
        let materials = p
            .materials_for(
                &sha,
                &base,
                history
                    .last()
                    .map(|e| e.origin.as_str())
                    .filter(|s| !s.is_empty()),
            )
            .map_err(|e| AirgapError::NoEvidence {
                artifact: label.clone(),
                why: format!("its provenance material: {e:#}"),
            })?;
        for (name, bytes) in materials {
            // An APK source's signed index travels too, so the secure side verifies again.
            let mt = if name.ends_with(".tar.gz") {
                "application/gzip"
            } else {
                "application/json"
            };
            provenance.push(put(
                format!("{key}/provenance/{name}"),
                bytes,
                mt,
                FileKind::Other,
            ));
        }
    }
    let index_row = chain.index_row(id).map(|row| {
        put(
            format!("{key}/index-row.json"),
            serde_json::to_vec(row.as_ref()).expect("a row is JSON"),
            "application/json",
            FileKind::Other,
        )
    });

    let art = ExportedArtifact {
        namespace: id.namespace.clone(),
        name: id.name.clone(),
        version: id.version.clone(),
        payload: payload_path.clone(),
        sha256: sha,
        blake3: crate::digest::blake3_hex(&bytes),
        size: bytes.len() as u64,
        verdict: summary.verdict,
        reasons: summary.reasons,
        ran: summary.ran,
        not_run: summary.not_run,
        evidence: vec![ev_path],
        attestations,
        signer_cert: cert_path,
        sbom,
        intake_record: record_path,
        approvers,
        provenance,
        index_row,
    };
    let payload = ExportFile {
        path: payload_path,
        bytes,
        media_type: media_type_of(&base).into(),
        kind: FileKind::Payload,
    };
    Ok((art, payload, out))
}

fn media_type_of(file: &str) -> &'static str {
    let f = file.to_ascii_lowercase();
    if f.ends_with(".whl") || f.ends_with(".zip") {
        "application/zip"
    } else if f.ends_with(".tar.gz") || f.ends_with(".tgz") {
        "application/gzip"
    } else {
        "application/octet-stream"
    }
}

// ── the import ──────────────────────────────────────────────────────────────

/// One artifact of a verified import, ready to land.
#[derive(Debug, Clone)]
pub struct VerifiedArtifact {
    pub id: ArtifactId,
    pub bytes: Vec<u8>,
    pub sha256: String,
    pub verdict: EvidenceVerdictSummary,
    pub evidence: crate::scanner::Evidence,
    /// The attestations that verified, as they came in the bundle.
    pub envelopes: Vec<Vec<u8>>,
    pub signer_cert: Vec<u8>,
    pub online_history: Vec<IntakeEntry>,
    /// The provenance material the bundle carried, `(file name, bytes)`.
    pub provenance: Vec<(String, Vec<u8>)>,
    /// The artifact's index row the bundle carried (lane HOL-PERF-2), checked.
    pub index_row: Option<holger_index_rows::IndexRow>,
}

/// A bundle member, by its full path (`payload/…`, `evidence/…`).
pub trait Members {
    fn member(&self, path: &str) -> Option<&[u8]>;
}

impl Members for std::collections::BTreeMap<String, Vec<u8>> {
    fn member(&self, path: &str) -> Option<&[u8]> {
        self.get(path).map(|v| v.as_slice())
    }
}

/// The recorded digests of one payload in the signed `core.json`.
#[derive(Debug, Clone)]
pub struct CorePayload {
    pub path: String,
    pub sha256: String,
    pub blake3: String,
    pub size: u64,
}

/// Read the export index out of a bundle's members.
pub fn read_index(members: &dyn Members) -> Result<ExportIndex, AirgapError> {
    let raw = members
        .member(&format!("evidence/{INDEX_PATH}"))
        .ok_or_else(|| {
            AirgapError::IndexInvalid(format!("evidence/{INDEX_PATH} is not in the bundle"))
        })?;
    let index: ExportIndex = serde_json::from_slice(raw)
        .map_err(|e| AirgapError::IndexInvalid(format!("it does not parse: {e}")))?;
    if index.schema != EXPORT_SCHEMA {
        return Err(AirgapError::IndexInvalid(format!(
            "schema {:?}, expected {EXPORT_SCHEMA:?}",
            index.schema
        )));
    }
    if index.verdict != "pass" {
        return Err(AirgapError::IndexInvalid(format!(
            "the export's verdict is {:?}, not pass",
            index.verdict
        )));
    }
    if index.artifacts.is_empty() {
        return Err(AirgapError::IndexInvalid("it lists no artifact".into()));
    }
    Ok(index)
}

/// ★ **Verify every artifact of an export bundle** whose release signature the
/// bundle verifier already checked. `core_payloads` are the payload records of
/// the signed `core.json`; `attest` is THIS side's attestation store (its roots
/// and enrolled signers decide). Nothing is written.
pub fn verify_import(
    members: &dyn Members,
    core_payloads: &[CorePayload],
    attest: Option<&AttestStore>,
) -> Result<(ExportIndex, Vec<VerifiedArtifact>), AirgapError> {
    let index = read_index(members)?;
    let attest = attest.ok_or_else(|| AirgapError::AttestationInvalid {
        artifact: index.artifacts[0].label(),
        why: "this holger declares no `attestation:` block, so it cannot verify the evidence"
            .into(),
    })?;
    let mut out = Vec::new();
    for art in &index.artifacts {
        let label = art.label();
        let mismatch = |why: String| AirgapError::PayloadMismatch {
            artifact: label.clone(),
            why,
        };
        let full = format!("payload/{}", art.payload);
        let bytes = members
            .member(&full)
            .ok_or_else(|| mismatch(format!("{full} is not in the bundle")))?;
        let core = core_payloads
            .iter()
            .find(|p| p.path == full)
            .ok_or_else(|| mismatch(format!("{full} is not recorded in the signed core.json")))?;
        let sha = ArtifactDigest::of_sha256(bytes);
        let blake = crate::digest::blake3_hex(bytes);
        for (what, want, got) in [
            ("sha256", art.sha256.as_str(), sha.hex()),
            ("blake3", art.blake3.as_str(), blake.as_str()),
            ("core sha256", core.sha256.as_str(), sha.hex()),
            ("core blake3", core.blake3.as_str(), blake.as_str()),
        ] {
            if want != got {
                return Err(mismatch(format!("{what} is {got}, the record says {want}")));
            }
        }
        if art.size != bytes.len() as u64 || core.size != bytes.len() as u64 {
            return Err(mismatch(format!(
                "size is {}, the record says {}",
                bytes.len(),
                art.size
            )));
        }
        let invalid = |why: String| AirgapError::AttestationInvalid {
            artifact: label.clone(),
            why,
        };
        let cert = members
            .member(&format!("evidence/{}", art.signer_cert))
            .ok_or_else(|| invalid(format!("evidence/{} is not in the bundle", art.signer_cert)))?
            .to_vec();
        let ev_path = art
            .evidence
            .first()
            .ok_or_else(|| invalid("the index names no evidence record".into()))?;
        let ev_bytes = members
            .member(&format!("evidence/{ev_path}"))
            .ok_or_else(|| invalid(format!("evidence/{ev_path} is not in the bundle")))?;
        let carried: crate::scanner::Evidence = serde_json::from_slice(ev_bytes)
            .map_err(|e| invalid(format!("{ev_path} does not parse: {e}")))?;
        let certs = vec![cert.clone()];
        let mut verified: Option<crate::scanner::Evidence> = None;
        let mut envelopes = Vec::new();
        let mut last_err = String::from("the index names no attestation");
        for a in &art.attestations {
            let env = members
                .member(&format!("evidence/{a}"))
                .ok_or_else(|| invalid(format!("evidence/{a} is not in the bundle")))?;
            match attest.verify(env, &certs, Some(&sha)) {
                Ok(stmt) if stmt.predicate_type == HOLGER_SCAN_EVIDENCE_V1 => {
                    let ev =
                        crate::scanner::attestation::verify_evidence(attest, env, &certs, &sha)
                            .map_err(|e| invalid(format!("{a}: {}", e.tagged())))?;
                    if ev == carried {
                        verified = Some(ev);
                    }
                    envelopes.push(env.to_vec());
                }
                Ok(_) => envelopes.push(env.to_vec()),
                Err(e) => last_err = format!("{a}: {}", e.tagged()),
            }
        }
        if envelopes.len() != art.attestations.len() {
            return Err(invalid(last_err));
        }
        let evidence = verified.ok_or_else(|| {
            invalid(
                "no scan-evidence attestation verifies to the evidence record the bundle carries"
                    .into(),
            )
        })?;
        let verdict = judge_evidence(&label, &evidence)?;
        let record = members
            .member(&format!("evidence/{}", art.intake_record))
            .ok_or_else(|| {
                invalid(format!(
                    "evidence/{} is not in the bundle",
                    art.intake_record
                ))
            })?;
        let online_history: Vec<IntakeEntry> = std::str::from_utf8(record)
            .map_err(|e| invalid(format!("the intake record is not UTF-8: {e}")))?
            .lines()
            .filter(|l| !l.trim().is_empty())
            .map(serde_json::from_str::<IntakeEntry>)
            .collect::<Result<_, _>>()
            .map_err(|e| invalid(format!("the intake record does not parse: {e}")))?;
        judge_record(&label, &online_history)?;
        if online_history.last().map(|e| e.to) != Some(ArtifactState::Available) {
            return Err(invalid(
                "the online intake record does not end AVAILABLE".into(),
            ));
        }
        let mut provenance = Vec::new();
        for p in &art.provenance {
            let bytes = members
                .member(&format!("evidence/{p}"))
                .ok_or_else(|| invalid(format!("evidence/{p} is not in the bundle")))?;
            let name = p.rsplit('/').next().unwrap_or(p).to_string();
            provenance.push((name, bytes.to_vec()));
        }
        // The row the online side's worker read (lane HOL-PERF-2), checked like any
        // other member: about these bytes, within its bounds, its size the payload's.
        let index_row = match &art.index_row {
            None => None,
            Some(p) => {
                let raw = members
                    .member(&format!("evidence/{p}"))
                    .ok_or_else(|| invalid(format!("evidence/{p} is not in the bundle")))?;
                let row = holger_index_rows::StoredRow::parse(raw, &art.sha256)
                    .map_err(|e| invalid(format!("evidence/{p}: {e}")))?;
                if row.size != art.size {
                    return Err(invalid(format!(
                        "evidence/{p} is about {} bytes, the payload is {}",
                        row.size, art.size
                    )));
                }
                Some(row.row)
            }
        };
        out.push(VerifiedArtifact {
            index_row,
            provenance,
            id: art.id(),
            bytes: bytes.to_vec(),
            sha256: sha.hex().to_string(),
            verdict,
            evidence,
            envelopes,
            signer_cert: cert,
            online_history,
        });
    }
    Ok((index, out))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::scanner::{fold, ScanEvidence};

    fn record(kind: ScannerKind, verdict: ScanVerdict, id: &str) -> ScanEvidence {
        ScanEvidence {
            scanner_id: id.into(),
            scanner_version: "1".into(),
            kind,
            verdict,
            findings: vec![],
            inputs: vec![],
            document: None,
            detail: "d".into(),
            required: false,
            intel_sequence: None,
            hold: false,
        }
    }

    fn evidence(records: Vec<ScanEvidence>) -> crate::scanner::Evidence {
        let (verdict, reasons) = fold(&records);
        crate::scanner::Evidence {
            schema: crate::scanner::EVIDENCE_SCHEMA.into(),
            subject: crate::scanner::EvidenceSubject {
                file_name: "x.whl".into(),
                ecosystem: "pip".into(),
                name: None,
                version: None,
                size: 1,
                inputs: vec![],
            },
            verdict,
            reasons,
            records,
            scanned_at: 0,
        }
    }

    #[test]
    fn a_distribution_names_its_pep_503_project() {
        use crate::promote::intake::python_project_of as p;
        assert_eq!(p("idna-3.20-py3-none-any.whl").as_deref(), Some("idna"));
        assert_eq!(
            p("Typing_Extensions-4.16.0-py3-none-any.whl").as_deref(),
            Some("typing-extensions")
        );
        assert_eq!(
            p("zope.interface-7.0.tar.gz").as_deref(),
            Some("zope-interface")
        );
        assert_eq!(p("firmware.bin"), None);
    }

    #[test]
    fn evidence_where_nothing_scanned_never_passes() {
        let ev = evidence(vec![record(
            ScannerKind::Sbom,
            ScanVerdict::Pass,
            "wheel-sbom",
        )]);
        let e = judge_evidence("x.whl@", &ev).unwrap_err();
        assert_eq!(e.code().code, "holger-airgap-2", "{e}");
    }

    #[test]
    fn a_blocking_record_refuses_and_a_changed_verdict_is_caught() {
        let ev = evidence(vec![record(
            ScannerKind::Vulnerability,
            ScanVerdict::Block,
            "osv",
        )]);
        assert_eq!(
            judge_evidence("x", &ev).unwrap_err().code().code,
            "holger-airgap-3"
        );
        let mut forged = evidence(vec![record(
            ScannerKind::Vulnerability,
            ScanVerdict::Block,
            "osv",
        )]);
        forged.verdict = ScanVerdict::Pass;
        assert_eq!(
            judge_evidence("x", &forged).unwrap_err().code().code,
            "holger-airgap-3"
        );
        let ok = evidence(vec![record(
            ScannerKind::Vulnerability,
            ScanVerdict::Pass,
            "osv",
        )]);
        let s = judge_evidence("x", &ok).unwrap();
        assert_eq!(s.verdict, "pass");
        assert!(
            s.not_run.contains(&"malware".to_string()),
            "{:?}",
            s.not_run
        );
    }
}