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