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

//! **The airgap export, as one door** (lane B3, 2026-10-26).
//!
//! `holger export` (server/cli `airgap.rs`) used to hold the whole export: collect the
//! approved set with its evidence, compute the verdict, build the bundle candidate,
//! audit first, record each artifact's `exported` step. The thin client `holger-cli`
//! has no config and no data root, so it needs the same act over the wire
//! (`holger.doors.v1.BundleService.Export`). This module is that act, once: the local
//! verb writes the candidate it answers to a directory, the gRPC door streams it.
//!
//! Nothing about the verdict is typed in, and an export that cannot be audited is
//! refused (`holger-airgap-10`) before a byte leaves.

use crate::lock::LockOrRecover as _;
use std::sync::{Arc, Mutex};

use update_bundle::core::{EvidenceKind, PolicyResult, SourceProvenance};
use update_bundle::digest::sha256_hex;
use update_bundle::prepare::{EvidenceInput, PayloadInput, Preparation};
use update_bundle::MemberMap;

use crate::audit::AuditLog;
use crate::promote::airgap::{self, FileKind};
use crate::promote::intake::IntakeChain;

/// Where an export carries its sync manifest, relative to the bundle's `evidence/`
/// (`holger_sync::BUNDLE_MANIFEST`).
pub const SYNC_MANIFEST: &str = "holger-sync.json";

/// What the caller asks for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportAsk {
    pub bundle_id: String,
    /// Monotonic per (supplier, product); the secure side's anti-rollback floor.
    pub sequence: u64,
    pub supplier: String,
    pub product: String,
    pub release_version: String,
    pub expires_days: i64,
    /// Only these artifacts (file name, name or `name@version`). Empty: every
    /// AVAILABLE artifact in production.
    pub artifacts: Vec<String>,
}

/// One exported artifact, as the summary says it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportedArtifact {
    pub label: String,
    pub sha256: String,
    pub verdict: String,
    pub ran: Vec<String>,
    pub not_run: Vec<String>,
    pub approvers: Vec<String>,
    pub sbom: bool,
}

impl ExportedArtifact {
    /// Pure: the line `holger export` prints for it.
    pub fn line(&self) -> String {
        format!(
            "{}  sha256 {}  verdict {}  ran [{}]  not run [{}]  approvers [{}]  sbom {}",
            self.label,
            self.sha256,
            self.verdict,
            self.ran.join(","),
            self.not_run.join(","),
            self.approvers.join(","),
            if self.sbom { "yes" } else { "no" }
        )
    }
}

/// The candidate, built and audited.
pub struct Exported {
    pub members: MemberMap,
    pub candidate_digest: String,
    pub verdict: String,
    pub artifacts: Vec<ExportedArtifact>,
}

/// Why an export was refused. Each says its code.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExportError {
    /// The audit log is off: an export must leave a record (`holger-airgap-10`).
    AuditOff,
    /// The audit record could not be written (`holger-airgap-10`).
    AuditFailed(String),
    /// Artifacts without passing evidence, one tagged line each (`holger-airgap-1..6`).
    Refused {
        lines: Vec<String>,
        artifacts: Vec<String>,
    },
    /// The request itself is not an export (no bundle id, …).
    Invalid(String),
    /// The candidate could not be built, or a trail step could not be recorded.
    Failed(String),
}

impl ExportError {
    /// The sentence a caller sees, with its code.
    pub fn tagged(&self) -> String {
        match self {
            ExportError::AuditOff => holger_errcode::AIRGAP_AUDIT_FAILED.tag("the export is refused: the audit log is off, and an export must leave an audit record"),
            ExportError::AuditFailed(e) => holger_errcode::AIRGAP_AUDIT_FAILED.tag(e),
            // The first refusal leads, so the answer carries its code (`holger-airgap-*`).
            ExportError::Refused { lines, artifacts } => {
                let first = lines.first().map(|l| l.trim_start_matches("REFUSED ").to_owned()).unwrap_or_else(|| holger_errcode::AIRGAP_NOTHING_TO_EXPORT.tag("nothing to export"));
                let rest: Vec<&str> = lines.iter().skip(1).map(|l| l.as_str()).collect();
                let mut out = format!("{first} (export refused: {} artifact(s) without passing evidence: {})", artifacts.len(), artifacts.join(", "));
                for l in rest {
                    out.push('\n');
                    out.push_str(l);
                }
                out
            }
            ExportError::Invalid(e) => e.clone(),
            ExportError::Failed(e) => format!("export refused: {e}"),
        }
    }
}

/// **The box's export door**: its intake chain, evidence and attestation stores, the
/// signer's certificate, the promotion policy the verdict is judged under, and the audit
/// sink. One export at a time per process.
pub struct ExportDoor {
    chain: Arc<IntakeChain>,
    evidence: crate::scanner::EvidenceStore,
    attest: Option<Arc<crate::attest::AttestStore>>,
    signer_cert: Option<Vec<u8>>,
    audit: Arc<dyn AuditLog>,
    policy_bytes: Vec<u8>,
    serial: Mutex<()>,
}

impl ExportDoor {
    /// The door over `holger`'s config, or `None` when it declares no intake chain
    /// (`promotion.intake`): then there is no approved set to export.
    pub fn of(holger: &crate::Holger) -> anyhow::Result<Option<Self>> {
        let audit = holger.audit_log()?;
        let Some(chain) = holger.intake_chain(audit.clone())? else {
            return Ok(None);
        };
        let evidence = holger.scan_evidence_store(audit.clone());
        let attest = holger.attestation_store()?;
        let signer = holger.attestation_signer()?;
        // The signer's certificate exists only in a build that can sign (`sign`); without
        // it no evidence was ever signed here, and the export refuses it by name.
        #[cfg(feature = "sign")]
        let signer_cert = signer.as_ref().map(|s| s.cert_der().to_vec());
        #[cfg(not(feature = "sign"))]
        let signer_cert: Option<Vec<u8>> = {
            let _ = &signer;
            None
        };
        let policy_bytes = serde_json::to_vec(&holger.promotion)
            .map_err(|e| anyhow::anyhow!("serialize the promotion policy: {e}"))?;
        Ok(Some(Self {
            chain,
            evidence,
            attest,
            signer_cert,
            audit,
            policy_bytes,
            serial: Mutex::new(()),
        }))
    }

    /// Pure: is `ask` an export at all?
    pub fn check(ask: &ExportAsk) -> Result<(), ExportError> {
        for (field, value) in [
            ("bundle_id", &ask.bundle_id),
            ("supplier", &ask.supplier),
            ("product", &ask.product),
            ("release_version", &ask.release_version),
        ] {
            if value.trim().is_empty() {
                return Err(ExportError::Invalid(format!(
                    "an export names its `{field}`"
                )));
            }
        }
        if ask.sequence == 0 {
            return Err(ExportError::Invalid(
                "an export's `sequence` starts at 1 (it is the secure side's anti-rollback floor)"
                    .into(),
            ));
        }
        if !(1..=3660).contains(&ask.expires_days) {
            return Err(ExportError::Invalid(format!(
                "an export lives 1 to 3660 days, not {}",
                ask.expires_days
            )));
        }
        Ok(())
    }

    /// **Export**: collect the approved set, build the candidate, audit it FIRST (no
    /// record, no export), then record each artifact's `exported` trail step.
    /// `actor` is who asked; `source` where from; `producer` the program that answers.
    pub fn export(
        &self,
        ask: &ExportAsk,
        actor: &str,
        source: &str,
        producer: &str,
    ) -> Result<Exported, ExportError> {
        Self::check(ask)?;
        if self.audit.mode() == crate::audit::AuditMode::Off {
            return Err(ExportError::AuditOff);
        }
        let _one = self.serial.lock_or_recover();
        let now = chrono::Utc::now();
        let mut set = airgap::collect_export(
            &self.chain,
            &self.evidence,
            self.attest.as_deref(),
            self.signer_cert.as_deref(),
            &ask.artifacts,
        )
        .map_err(|e| ExportError::Refused {
            lines: e.tagged().lines().map(|l| format!("REFUSED {l}")).collect(),
            artifacts: e.artifacts(),
        })?;

        // H4: with a `sync:` block every export carries a signed sync manifest, a new
        // generation with exactly the packages of this bundle and every invalidation
        // (`evidence/holger-sync.json`); the import on the downstream applies it.
        #[cfg(feature = "sync")]
        let sync_member: Option<Vec<u8>> = match self.chain.sync() {
            Some(hub) => {
                let added = set
                    .index
                    .artifacts
                    .iter()
                    .map(|art| crate::sync::protocol::PackageRef {
                        coordinate: crate::sync::coordinate_of(&art.id()),
                        sha256: art.sha256.clone(),
                    })
                    .collect();
                let m = hub
                    .bundle_manifest(
                        added,
                        &format!("bundle {} seq {}", ask.bundle_id, ask.sequence),
                    )
                    .map_err(|e| ExportError::Failed(e.tagged()))?;
                Some(m.to_bytes())
            }
            None => None,
        };
        #[cfg(not(feature = "sync"))]
        let sync_member: Option<Vec<u8>> = None;
        let advisory: Vec<String> = set
            .index
            .artifacts
            .iter()
            .flat_map(|art| {
                set.evidence
                    .iter()
                    .filter(|f| art.evidence.contains(&f.path))
                    .filter_map(|f| {
                        serde_json::from_slice::<crate::scanner::Evidence>(&f.bytes).ok()
                    })
                    .flat_map(|ev| {
                        ev.records
                            .into_iter()
                            .filter(|r| r.kind == crate::scanner::ScannerKind::Vulnerability)
                            .map(|r| format!("{} {}", r.scanner_id, r.scanner_version))
                            .collect::<Vec<_>>()
                    })
                    .collect::<Vec<_>>()
            })
            .collect();
        let prep = Preparation {
            bundle_id: ask.bundle_id.clone(),
            sequence: ask.sequence,
            created_at: now,
            expires_at: Some(now + chrono::Duration::days(ask.expires_days)),
            producer: producer.to_owned(),
            supplier: ask.supplier.clone(),
            product: ask.product.clone(),
            release_version: ask.release_version.clone(),
            supported_hardware_models: Vec::new(),
            supported_hardware_revisions: Vec::new(),
            source_provenance: SourceProvenance {
                source_kind: airgap::SOURCE_KIND.into(),
                // The decision profile (HOL-NCSC, `crate::export_profile`): the online box's
                // repository name and its timing stay online.
                source_uri: None,
                source_repository: None,
                source_asset_path: None,
                fetched_at: None,
                reported_size: None,
                vendor_checksum: None,
                vendor_signature_status: Some("evidence attestations verified at export".into()),
                tls_origin: None,
                transfer_receipt_digest: None,
            },
            policy_result: PolicyResult {
                policy_digest: sha256_hex(&self.policy_bytes),
                advisory_snapshot_digest: (!advisory.is_empty())
                    .then(|| sha256_hex(advisory.join("\n").as_bytes())),
                // COMPUTED: collect_export returned, so every artifact passed.
                verdict: set.index.verdict.clone(),
                non_production: true,
            },
            supersedes: Vec::new(),
            withdraws: Vec::new(),
            // The bytes MOVE into the preparation (RA1-13): a clone was a second copy of
            // every payload for the life of the export.
            payloads: std::mem::take(&mut set.payloads)
                .into_iter()
                .map(|p| PayloadInput {
                    relative_path: p.path,
                    bytes: p.bytes,
                    media_type: p.media_type,
                })
                .collect(),
            evidence: std::mem::take(&mut set.evidence)
                .into_iter()
                .map(|f| EvidenceInput {
                    relative_path: f.path,
                    bytes: f.bytes,
                    media_type: f.media_type,
                    kind: match f.kind {
                        FileKind::Sbom => EvidenceKind::Sbom,
                        FileKind::Scan => EvidenceKind::Scan,
                        FileKind::Payload | FileKind::Other => EvidenceKind::Other,
                    },
                })
                .chain(sync_member.map(|bytes| EvidenceInput {
                    relative_path: SYNC_MANIFEST.into(),
                    bytes,
                    media_type: "application/json".into(),
                    kind: EvidenceKind::Other,
                }))
                .collect(),
        };
        let candidate = prep
            .build()
            .map_err(|e| ExportError::Failed(format!("build the candidate: {e}")))?;

        // Audit first: no record, no export.
        let names: Vec<String> = set.index.artifacts.iter().map(|x| x.label()).collect();
        let production = self.chain.settings().production.clone();
        let ev = crate::audit::AuditEvent::new(actor, crate::audit::AuditAction::Other, production, ask.bundle_id.clone(), source, 200, 0).with_detail(format!(
            "airgap export {} seq {}: {} artifact(s) [{}], verdict {} (computed), candidate sha256:{}",
            ask.bundle_id,
            ask.sequence,
            names.len(),
            names.join(", "),
            set.index.verdict,
            candidate.candidate_digest
        ));
        self.audit
            .record(ev)
            .and_then(|()| self.audit.flush())
            .map_err(|e| ExportError::AuditFailed(format!("{e:?}")))?;

        // Each exported artifact's own trail step (`exported`), fail-closed.
        for art in &set.index.artifacts {
            self.chain
                .record_export(
                    actor,
                    &art.id(),
                    &format!("airgap bundle {} seq {}", ask.bundle_id, ask.sequence),
                    source,
                )
                .map_err(|e| ExportError::Failed(e.tagged()))?;
        }
        let artifacts = set
            .index
            .artifacts
            .iter()
            .map(|art| ExportedArtifact {
                label: art.label(),
                sha256: art.sha256.clone(),
                verdict: art.verdict.clone(),
                ran: art.ran.clone(),
                not_run: art.not_run.clone(),
                approvers: art.approvers.clone(),
                sbom: art.sbom.is_some(),
            })
            .collect();
        Ok(Exported {
            members: candidate.members,
            candidate_digest: candidate.candidate_digest,
            verdict: set.index.verdict.clone(),
            artifacts,
        })
    }
}

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

    fn ask() -> ExportAsk {
        ExportAsk {
            bundle_id: "b1".into(),
            sequence: 1,
            supplier: "s".into(),
            product: "p".into(),
            release_version: "1".into(),
            expires_days: 30,
            artifacts: vec![],
        }
    }

    #[test]
    fn an_export_names_what_it_is_and_lives_a_bounded_time() {
        assert_eq!(ExportDoor::check(&ask()), Ok(()));
        for broken in [
            ExportAsk {
                bundle_id: " ".into(),
                ..ask()
            },
            ExportAsk {
                supplier: String::new(),
                ..ask()
            },
            ExportAsk {
                product: String::new(),
                ..ask()
            },
            ExportAsk {
                release_version: String::new(),
                ..ask()
            },
            ExportAsk {
                sequence: 0,
                ..ask()
            },
            ExportAsk {
                expires_days: 0,
                ..ask()
            },
            ExportAsk {
                expires_days: 4000,
                ..ask()
            },
        ] {
            assert!(
                matches!(ExportDoor::check(&broken), Err(ExportError::Invalid(_))),
                "{broken:?}"
            );
        }
    }

    #[test]
    fn a_refusal_says_its_code() {
        assert!(
            ExportError::AuditOff
                .tagged()
                .starts_with("[holger-airgap-10]"),
            "{}",
            ExportError::AuditOff.tagged()
        );
        let r = ExportError::Refused {
            lines: vec![
                "REFUSED [holger-airgap-2] x".into(),
                "REFUSED [holger-airgap-5] y".into(),
            ],
            artifacts: vec!["x".into(), "y".into()],
        };
        assert!(
            r.tagged()
                .starts_with("[holger-airgap-2] x (export refused: 2 artifact(s)"),
            "{}",
            r.tagged()
        );
        assert!(
            r.tagged().ends_with("\nREFUSED [holger-airgap-5] y"),
            "{}",
            r.tagged()
        );
    }

    #[test]
    fn the_line_is_the_one_holger_export_prints() {
        let a = ExportedArtifact {
            label: "x@1".into(),
            sha256: "ab".into(),
            verdict: "pass".into(),
            ran: vec!["clamav".into()],
            not_run: vec![],
            approvers: vec!["anna".into()],
            sbom: true,
        };
        assert_eq!(
            a.line(),
            "x@1  sha256 ab  verdict pass  ran [clamav]  not run []  approvers [anna]  sbom yes"
        );
    }
}