server/lib/src/export_push.rs
//! **The upstream's push into the diode** (lane HOL-CDS, owner 2026-10-30).
//!
//! ```ron
//! export: Some((
//! push_to: Some("/var/lib/holger/diode-send"), // the diode's send side
//! every: "5m", // s, m, h or d; plain digits are seconds
//! vendor: "dmz", // the bundles' supplier (default "holger")
//! product: "packages", // and product (default "packages")
//! signer_dir: None, // default <data_root>/holger-export-push/signer
//! expires_days: 30,
//! )),
//! ```
//!
//! Every `every`, and once per `holger export-push`, the upstream takes the AVAILABLE
//! artifacts of its intake chain's production repository that no earlier push carried and
//! exports them through the ONE export door ([`crate::export_door::ExportDoor`]: the computed
//! verdict, the stored and signed evidence, the attestations, the intake approvals, audit
//! first, each artifact's `exported` step). The candidate is approved and release-signed by
//! this box's push signer (three ed25519 identities minted on the first push, keys 0600),
//! packed, and written into `push_to` ATOMICALLY: `.<name>.part` first, then renamed to
//! `<vendor>-<product>-<sequence>.holger.znippy`, so the diode never carries half a file
//! (and a downstream inbox skips `.`-names and `.part`). The sequence is the anti-rollback
//! floor's: 1, 2, 3, ... per box, kept with the set of artifacts already pushed under
//! `<data_root>/holger-export-push/state.json`, written after the rename.
//!
//! The secure side verifies those bundles with the pinned policy
//! [`ExportPush::trust_policy`] writes (`holger export-push --trust-policy-out <file>`),
//! carried across once, out of band. Each artifact also carries its own two-person intake
//! approvals as signed evidence; the push signer's approvals say "this box exported it".
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
fn default_every() -> String {
"5m".into()
}
fn default_vendor() -> String {
"holger".into()
}
fn default_product() -> String {
"packages".into()
}
fn default_expires() -> i64 {
30
}
/// **The `export:` block.**
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExportPushSettings {
/// The diode's send folder. `None`: no push (the block only names the bundles' vendor).
#[serde(default)]
pub push_to: Option<String>,
/// How often the started server pushes (`30s`, `5m`, `2h`, `1d`, or seconds).
#[serde(default = "default_every")]
pub every: String,
#[serde(default = "default_vendor")]
pub vendor: String,
#[serde(default = "default_product")]
pub product: String,
/// Where the push signer lives. `None`: `<data_root>/holger-export-push/signer`.
#[serde(default)]
pub signer_dir: Option<String>,
#[serde(default = "default_expires")]
pub expires_days: i64,
}
/// The state directory under the data root.
pub const STATE_DIR: &str = "holger-export-push";
/// The state file in [`STATE_DIR`].
pub const STATE_FILE: &str = "state.json";
/// The signer directory in [`STATE_DIR`] when `signer_dir` names none.
pub const SIGNER_DIR: &str = "signer";
/// Pure: **an interval** (`30s`, `5m`, `2h`, `1d`, or plain seconds); at least one second.
pub fn parse_interval(s: &str) -> Result<Duration, String> {
let t = s.trim();
let (num, unit) = match t.char_indices().find(|(_, c)| !c.is_ascii_digit()) {
Some((i, _)) => (&t[..i], t[i..].trim()),
None => (t, "s"),
};
let n: u64 = num
.parse()
.map_err(|_| format!("`every: {s:?}` is not an interval (30s, 5m, 2h, 1d)"))?;
let secs = match unit {
"s" => n,
"m" => n.saturating_mul(60),
"h" => n.saturating_mul(3600),
"d" => n.saturating_mul(86_400),
_ => return Err(format!("`every: {s:?}`: the unit is s, m, h or d")),
};
if secs == 0 {
return Err(format!("`every: {s:?}` is no interval at all"));
}
Ok(Duration::from_secs(secs))
}
impl ExportPushSettings {
/// Refuse a block that cannot push (`holger-export-1`).
pub fn validate(&self) -> Result<(), String> {
let refuse = |why: String| holger_errcode::EXPORT_PUSH_CONFIG_REFUSED.tag(why);
parse_interval(&self.every).map_err(refuse)?;
for (field, v) in [("vendor", &self.vendor), ("product", &self.product)] {
if v.trim().is_empty()
|| !v
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
{
return Err(refuse(format!(
"`export.{field}: {v:?}` must be non-empty [A-Za-z0-9._-]"
)));
}
}
if let Some(p) = &self.push_to {
if p.trim().is_empty() {
return Err(refuse("`export.push_to` names no folder".into()));
}
}
if !(1..=3660).contains(&self.expires_days) {
return Err(refuse(format!(
"`export.expires_days: {}` is not 1 to 3660",
self.expires_days
)));
}
Ok(())
}
pub fn interval(&self) -> Duration {
parse_interval(&self.every).unwrap_or(Duration::from_secs(300))
}
}
/// What the push state remembers: the last sequence and every sha256 already pushed.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PushState {
pub sequence: u64,
pub pushed: std::collections::BTreeSet<String>,
}
/// Pure: the bundle's file name in `push_to`.
pub fn bundle_name(vendor: &str, product: &str, sequence: u64) -> String {
format!("{vendor}-{product}-{sequence}.holger.znippy")
}
/// What one push did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PushOutcome {
/// Nothing AVAILABLE that no earlier push carried.
NothingNew,
/// One bundle written.
Pushed {
path: PathBuf,
vendor: String,
product: String,
sequence: u64,
artifacts: Vec<String>,
},
}
impl PushOutcome {
/// The line the CLI and the server log print.
pub fn line(&self) -> String {
match self {
PushOutcome::NothingNew => "export push: nothing new to push".into(),
PushOutcome::Pushed { path, vendor, product, sequence, artifacts } => format!(
"export push: pushed {} (vendor {vendor} product {product} sequence {sequence}, {} artifact(s): {})",
path.display(),
artifacts.len(),
artifacts.join(", ")
),
}
}
}
#[cfg(feature = "export-push")]
pub use push::{ExportPush, Signer};
#[cfg(feature = "export-push")]
static LIVE: std::sync::OnceLock<std::sync::Arc<ExportPush>> = std::sync::OnceLock::new();
/// The served process's push (set once at start), for the `export.push` job.
#[cfg(feature = "export-push")]
pub fn live() -> Option<&'static std::sync::Arc<ExportPush>> {
LIVE.get()
}
/// Make `p` the served process's push (once).
#[cfg(feature = "export-push")]
pub fn set_live(p: std::sync::Arc<ExportPush>) {
let _ = LIVE.set(p);
}
#[cfg(feature = "export-push")]
mod push {
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use update_bundle::container::{atomic_write, pack_bundle};
use update_bundle::digest::sha256_hex;
use update_bundle::finalize::{add_approval, finalize};
use update_bundle::policy::{
ApprovedSigner, CertificateValidity, ExpiredPosture, TrustMode, TrustPolicy,
};
use update_bundle::schema::POLICY_SCHEMA;
use super::*;
use crate::export_door::{ExportAsk, ExportDoor};
use crate::promote::intake::{ArtifactState, IntakeChain};
const ROLES: [&str; 3] = ["security-reviewer", "system-owner", "release"];
/// The push signer: three ed25519 identities on the data disk, minted once.
pub struct Signer {
files: Vec<(String, Vec<u8>, Vec<u8>, String)>, // role, cert DER, key PKCS#8, fingerprint
}
impl Signer {
pub fn open_or_mint(dir: &Path) -> Result<Self> {
std::fs::create_dir_all(dir).with_context(|| format!("mkdir {}", dir.display()))?;
let mut files = Vec::new();
for role in ROLES {
let (cert, key) = (
dir.join(format!("{role}.der")),
dir.join(format!("{role}.pk8")),
);
if !cert.exists() || !key.exists() {
let kp = rcgen::KeyPair::generate_for(&rcgen::PKCS_ED25519)
.context("mint an ed25519 key")?;
let mut params =
rcgen::CertificateParams::new(vec![]).context("cert params")?;
params.distinguished_name.push(
rcgen::DnType::CommonName,
format!("holger export push {role}"),
);
let c = params.self_signed(&kp).context("self-sign")?;
write_private(&key, &kp.serialize_der())?;
std::fs::write(&cert, c.der())
.with_context(|| format!("write {}", cert.display()))?;
}
let cert_der =
std::fs::read(&cert).with_context(|| format!("read {}", cert.display()))?;
let key_der = crate::keyfile::read_private(&key, "the export-push signer's key")
.with_context(|| format!("read {}", key.display()))?;
let fp = sha256_hex(&cert_der);
files.push((role.to_string(), cert_der, key_der, fp));
}
Ok(Self { files })
}
fn get(&self, role: &str) -> &(String, Vec<u8>, Vec<u8>, String) {
self.files.iter().find(|(r, ..)| r == role).expect("minted")
}
/// The identities as a trust bundle signs with them (lane HOL-OCI-EVIDENCE,
/// `crate::sigstore_trust::make`): the two approvals, then the release signer.
pub fn bundle_signers(
&self,
) -> (
Vec<(String, holger_provenance::trust::BundleSigner)>,
holger_provenance::trust::BundleSigner,
) {
let one = |role: &str| {
let (_, cert, key, _) = self.get(role);
holger_provenance::trust::BundleSigner {
cert_der: cert.clone(),
key_pkcs8: key.clone(),
}
};
(
vec![
("security-reviewer".into(), one("security-reviewer")),
("system-owner".into(), one("system-owner")),
],
one("release"),
)
}
/// The pinned policy that trusts exactly this signer, for `product` only.
pub fn policy(&self, product: &str) -> TrustPolicy {
TrustPolicy {
schema_version: POLICY_SCHEMA.into(),
trust_mode: Some(TrustMode::PinnedSigners),
trusted_roots: vec![],
certificate_validity: CertificateValidity::Enforce,
approved_signers: vec![
ApprovedSigner {
fingerprint: self.get("security-reviewer").3.clone(),
subject: None,
roles: vec!["security-reviewer".into()],
},
ApprovedSigner {
fingerprint: self.get("system-owner").3.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![self.get("release").3.clone()],
release_signer_must_be_distinct: true,
maximum_bundle_age_secs: None,
require_expiry: true,
on_expired_metadata: Some(ExpiredPosture::Refuse),
require_sbom: false,
require_vex: false,
require_scan: false,
allowed_products: vec![product.to_string()],
allowed_models: vec![],
}
}
/// Approve (two roles) and release-sign `members` under `policy` (the push's, and the
/// time token's: `crate::time_floor::make_token`).
pub fn sign(
&self,
members: &mut update_bundle::MemberMap,
policy: &TrustPolicy,
) -> Result<()> {
let now = chrono::Utc::now();
for role in ["security-reviewer", "system-owner"] {
let (_, cert, key, _) = self.get(role);
add_approval(
members,
role,
cert,
key,
Some("holger export push".into()),
now,
)
.map_err(|e| anyhow::anyhow!("approve ({role}): {e}"))?;
}
let (_, cert, key, _) = self.get("release");
finalize(members, policy, cert, key, now)
.map_err(|e| anyhow::anyhow!("finalize: {e}"))?;
Ok(())
}
}
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
use std::io::Write;
let mut o = std::fs::OpenOptions::new();
o.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
o.mode(0o600);
}
let mut f = o
.open(path)
.with_context(|| format!("create {}", path.display()))?;
f.write_all(bytes)
.with_context(|| format!("write {}", path.display()))
}
/// **The push** over one Holger's config.
pub struct ExportPush {
settings: ExportPushSettings,
chain: Arc<IntakeChain>,
door: ExportDoor,
state_dir: PathBuf,
signer: Signer,
serial: std::sync::Mutex<()>,
}
fn failed(why: impl std::fmt::Display) -> anyhow::Error {
anyhow::anyhow!("{}", holger_errcode::EXPORT_PUSH_FAILED.tag(why))
}
impl ExportPush {
/// The push of `holger`'s `export:` block, or `None` when it declares none.
pub fn of(holger: &crate::Holger) -> Result<Option<Self>> {
let Some(settings) = holger.export.clone() else {
return Ok(None);
};
settings.validate().map_err(|e| anyhow::anyhow!("{e}"))?;
let refuse = |why: &str| {
anyhow::anyhow!("{}", holger_errcode::EXPORT_PUSH_CONFIG_REFUSED.tag(why))
};
let audit = holger.audit_log()?;
let chain = holger.intake_chain(audit)?.ok_or_else(|| refuse("this Holger declares no intake chain (`promotion.intake`), so there is no approved set to push"))?;
let door =
ExportDoor::of(holger)?.ok_or_else(|| refuse("this Holger has no export door"))?;
let state_dir = holger.data_root().join(STATE_DIR);
let signer_dir = settings
.signer_dir
.clone()
.map(PathBuf::from)
.unwrap_or_else(|| state_dir.join(SIGNER_DIR));
let signer = Signer::open_or_mint(&signer_dir).map_err(|e| {
failed(format!(
"the push signer under {}: {e:#}",
signer_dir.display()
))
})?;
Ok(Some(Self {
settings,
chain,
door,
state_dir,
signer,
serial: std::sync::Mutex::new(()),
}))
}
pub fn settings(&self) -> &ExportPushSettings {
&self.settings
}
/// The pinned trust policy a secure side verifies these pushes with.
pub fn trust_policy(&self) -> TrustPolicy {
self.signer.policy(&self.settings.product)
}
fn state(&self) -> Result<PushState> {
let p = self.state_dir.join(STATE_FILE);
match std::fs::read(&p) {
Ok(b) => {
serde_json::from_slice(&b).map_err(|e| failed(format!("{}: {e}", p.display())))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PushState::default()),
Err(e) => Err(failed(format!("{}: {e}", p.display()))),
}
}
/// ★ **One push**: the new AVAILABLE artifacts as one signed bundle, written
/// atomically into `push_to`; nothing new writes nothing.
pub fn push_once(&self) -> Result<PushOutcome> {
use crate::lock::LockOrRecover as _;
let _one = self.serial.lock_or_recover();
let Some(push_to) = self.settings.push_to.as_deref().map(PathBuf::from) else {
return Err(anyhow::anyhow!(
"{}",
holger_errcode::EXPORT_PUSH_CONFIG_REFUSED
.tag("`export.push_to` names no folder to push into")
));
};
let mut state = self.state()?;
let fresh: Vec<(String, String)> = self
.chain
.all()
.into_iter()
.filter(|(_, s)| s.state == ArtifactState::Available)
.filter(|(_, s)| !state.pushed.contains(&s.sha256))
.map(|(id, s)| (crate::promote::artifact_label(&id), s.sha256))
.collect();
if fresh.is_empty() {
return Ok(PushOutcome::NothingNew);
}
let sequence = state.sequence + 1;
let (vendor, product) = (self.settings.vendor.clone(), self.settings.product.clone());
let name = bundle_name(&vendor, &product, sequence);
let ask = ExportAsk {
bundle_id: name.trim_end_matches(".holger.znippy").to_string(),
sequence,
supplier: vendor.clone(),
product: product.clone(),
release_version: sequence.to_string(),
expires_days: self.settings.expires_days,
artifacts: fresh.iter().map(|(l, _)| l.clone()).collect(),
};
let exported = self
.door
.export(
&ask,
"export-push",
"local:export.push",
&format!("holger export push {}", env!("CARGO_PKG_VERSION")),
)
.map_err(|e| failed(e.tagged()))?;
let mut members = exported.members;
self.signer
.sign(&mut members, &self.trust_policy())
.map_err(|e| failed(format!("{e:#}")))?;
std::fs::create_dir_all(&push_to)
.map_err(|e| failed(format!("create {}: {e}", push_to.display())))?;
let part = push_to.join(format!(".{name}.part"));
let dest = push_to.join(&name);
if dest.exists() {
return Err(failed(format!(
"{} is already there; the push state under {} was lost or rolled back",
dest.display(),
self.state_dir.display()
)));
}
pack_bundle(&members, &part).map_err(|e| {
let _ = std::fs::remove_file(&part);
failed(format!("pack {}: {e}", part.display()))
})?;
if let Ok(f) = std::fs::File::open(&part) {
let _ = f.sync_all();
}
std::fs::rename(&part, &dest).map_err(|e| {
let _ = std::fs::remove_file(&part);
failed(format!(
"rename {} to {}: {e}",
part.display(),
dest.display()
))
})?;
state.sequence = sequence;
state.pushed.extend(fresh.iter().map(|(_, s)| s.clone()));
let body = serde_json::to_vec_pretty(&state).map_err(failed)?;
atomic_write(&self.state_dir.join(STATE_FILE), &body)
.map_err(|e| failed(format!("the push state: {e}")))?;
Ok(PushOutcome::Pushed {
path: dest,
vendor,
product,
sequence,
artifacts: fresh.into_iter().map(|(l, _)| l).collect(),
})
}
}
}
/// The refusal of a push on a config with no `export:` block (`holger-export-1`).
pub fn not_declared(config: &str) -> String {
holger_errcode::EXPORT_PUSH_CONFIG_REFUSED
.tag(format!("config {config} declares no `export:` block"))
}
/// Where the push state lives under `data_root`.
pub fn state_dir(data_root: &Path) -> PathBuf {
data_root.join(STATE_DIR)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn intervals_read_as_written() {
assert_eq!(parse_interval("30s"), Ok(Duration::from_secs(30)));
assert_eq!(parse_interval("5m"), Ok(Duration::from_secs(300)));
assert_eq!(parse_interval("2h"), Ok(Duration::from_secs(7200)));
assert_eq!(parse_interval("1d"), Ok(Duration::from_secs(86_400)));
assert_eq!(parse_interval("45"), Ok(Duration::from_secs(45)));
for bad in ["", "0s", "5x", "m", "-1s"] {
assert!(parse_interval(bad).is_err(), "{bad:?}");
}
}
#[test]
fn the_block_is_refused_by_name_when_it_cannot_push() {
let ok: ExportPushSettings =
ron::from_str(r#"(push_to: Some("/d"), every: "1m")"#).unwrap();
assert_eq!(
(ok.vendor.as_str(), ok.product.as_str(), ok.expires_days),
("holger", "packages", 30)
);
assert!(ok.validate().is_ok());
for bad in [
r#"(push_to: Some("/d"), every: "soon")"#,
r#"(push_to: Some(" "))"#,
r#"(vendor: "a b")"#,
r#"(expires_days: 0)"#,
] {
let s: ExportPushSettings = ron::from_str(bad).unwrap();
let e = s.validate().unwrap_err();
assert!(e.starts_with("[holger-export-1]"), "{bad}: {e}");
}
assert!(
ron::from_str::<ExportPushSettings>(r#"(push_too: None)"#).is_err(),
"a mistyped key is refused"
);
}
#[test]
fn the_name_carries_vendor_product_and_sequence() {
assert_eq!(
bundle_name("dmz", "packages", 7),
"dmz-packages-7.holger.znippy"
);
let o = PushOutcome::Pushed {
path: "/s/x".into(),
vendor: "dmz".into(),
product: "p".into(),
sequence: 7,
artifacts: vec!["a".into()],
};
assert!(
o.line()
.contains("vendor dmz product p sequence 7, 1 artifact(s): a"),
"{}",
o.line()
);
}
}