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_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()
        );
    }
}