update-bundle/src/cli/bundle.rs
//! `holger-cli bundle prepare|approve|finalize|inspect|extract` — author, approve and
//! finalize holger update bundles (this was the `holger-bundle` program).
//!
//! Workflow: `prepare` a candidate directory, collect two `approve` signatures from
//! distinct roles, then `finalize` into a signed `.holger.znippy`. Private keys are
//! read only to sign and never copied into the bundle or printed.
use std::path::{Path, PathBuf};
use chrono::{Duration, Utc};
use clap::{Args, Subcommand};
use crate::container::pack_bundle;
use crate::core::{EvidenceKind, PolicyResult, SourceProvenance};
use crate::digest::sha256_hex;
use crate::finalize::{add_approval, candidate_digest, finalize};
use crate::prepare::{
read_members_dir, write_members_dir, EvidenceInput, PayloadInput, Preparation,
};
use crate::receipt::FinalizationReceipt;
use crate::TrustPolicy;
/// The local, offline verbs of a bundle's life (no server is dialled).
#[derive(Subcommand, Debug)]
#[allow(clippy::large_enum_variant)] // clap subcommand args structs; boxing hurts ergonomics
pub enum BundleTool {
/// Build a candidate bundle directory from payloads + evidence.
Prepare(PrepareArgs),
/// Sign an approval for a candidate directory in a given role.
Approve(ApproveArgs),
/// Verify approvals + policy and finalize into a signed `.holger.znippy`.
Finalize(FinalizeArgs),
/// Print the envelope + core summary of a candidate directory.
Inspect(InspectArgs),
/// Verify a bundle against a trust policy, then extract it into an empty directory
/// (it always verifies first).
Extract(super::verify::ExtractArgs),
}
#[derive(Args, Debug)]
pub struct PrepareArgs {
/// Output candidate directory (created).
#[arg(long)]
candidate: PathBuf,
#[arg(long)]
bundle_id: String,
#[arg(long)]
sequence: u64,
#[arg(long)]
supplier: String,
#[arg(long)]
product: String,
#[arg(long)]
release_version: String,
#[arg(long, default_value = "holger-bundle")]
producer: String,
#[arg(long = "model")]
models: Vec<String>,
#[arg(long = "revision")]
revisions: Vec<String>,
#[arg(long, default_value_t = 365)]
expires_days: i64,
/// Payload artifact file(s) (repeatable).
#[arg(long = "payload")]
payloads: Vec<PathBuf>,
#[arg(long)]
sbom: Option<PathBuf>,
#[arg(long)]
vex: Option<PathBuf>,
#[arg(long)]
scan: Option<PathBuf>,
/// Strict transfer receipt (embedded as evidence/source-receipt.json).
#[arg(long)]
receipt: Option<PathBuf>,
/// Advisory-DB snapshot metadata file.
#[arg(long)]
advisory_snapshot: Option<PathBuf>,
/// Accepted-exception document(s).
#[arg(long = "exception")]
exceptions: Vec<PathBuf>,
/// The reviewers' verdict, recorded in core.json and covered by every
/// signature. REQUIRED, with no default: a candidate nobody reviewed must
/// never read as passed. Only `pass` can later be finalized and imported.
#[arg(long)]
verdict: String,
/// Mark the advisory evidence as production intelligence (default: non-production).
#[arg(long)]
production: bool,
#[arg(long)]
source_kind: Option<String>,
#[arg(long)]
source_uri: Option<String>,
}
#[derive(Args, Debug)]
pub struct ApproveArgs {
#[arg(long)]
candidate: PathBuf,
#[arg(long)]
role: String,
/// Signer certificate (DER or PEM).
#[arg(long)]
cert: PathBuf,
/// Signer private key (PKCS#8 DER or PEM).
#[arg(long)]
key: PathBuf,
#[arg(long)]
comment: Option<String>,
}
#[derive(Args, Debug)]
pub struct FinalizeArgs {
#[arg(long)]
candidate: PathBuf,
#[arg(long)]
policy: PathBuf,
#[arg(long)]
release_cert: PathBuf,
#[arg(long)]
release_key: PathBuf,
/// Output `.holger.znippy` file.
#[arg(long)]
output: PathBuf,
/// Optional finalization-receipt output path.
#[arg(long)]
receipt: Option<PathBuf>,
}
#[derive(Args, Debug)]
pub struct InspectArgs {
#[arg(long)]
candidate: PathBuf,
}
/// Run one local bundle verb.
pub fn run(cmd: BundleTool) -> anyhow::Result<()> {
match cmd {
BundleTool::Prepare(a) => cmd_prepare(a),
BundleTool::Approve(a) => cmd_approve(a),
BundleTool::Finalize(a) => cmd_finalize(a),
BundleTool::Inspect(a) => cmd_inspect(a),
BundleTool::Extract(a) => super::verify::extract(a),
}
}
fn media_type_for(path: &Path) -> String {
match path.extension().and_then(|e| e.to_str()) {
Some("json") => "application/json",
Some("xml") => "application/xml",
_ => "application/octet-stream",
}
.to_string()
}
fn file_name(path: &Path) -> anyhow::Result<String> {
Ok(path
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("invalid file name"))?
.to_string())
}
fn cmd_prepare(a: PrepareArgs) -> anyhow::Result<()> {
let now = Utc::now();
let mut payloads = Vec::new();
for p in &a.payloads {
payloads.push(PayloadInput {
relative_path: file_name(p)?,
bytes: std::fs::read(p)?,
media_type: media_type_for(p),
});
}
let mut evidence = Vec::new();
let mut add_ev =
|opt: &Option<PathBuf>, subdir: &str, kind: EvidenceKind| -> anyhow::Result<()> {
if let Some(p) = opt {
evidence.push(EvidenceInput {
relative_path: format!("{subdir}/{}", file_name(p)?),
bytes: std::fs::read(p)?,
media_type: media_type_for(p),
kind,
});
}
Ok(())
};
add_ev(&a.sbom, "sbom", EvidenceKind::Sbom)?;
add_ev(&a.vex, "vex", EvidenceKind::Vex)?;
add_ev(&a.scan, "scan", EvidenceKind::Scan)?;
add_ev(&a.advisory_snapshot, "advisory", EvidenceKind::Advisory)?;
for ex in &a.exceptions {
evidence.push(EvidenceInput {
relative_path: format!("exceptions/{}", file_name(ex)?),
bytes: std::fs::read(ex)?,
media_type: media_type_for(ex),
kind: EvidenceKind::Exception,
});
}
if let Some(p) = &a.receipt {
evidence.push(EvidenceInput {
relative_path: "source-receipt.json".into(),
bytes: std::fs::read(p)?,
media_type: "application/json".into(),
kind: EvidenceKind::SourceReceipt,
});
}
let advisory_digest = match &a.advisory_snapshot {
Some(p) => Some(sha256_hex(&std::fs::read(p)?)),
None => None,
};
let prep = Preparation {
bundle_id: a.bundle_id,
sequence: a.sequence,
created_at: now,
expires_at: Some(now + Duration::days(a.expires_days)),
producer: a.producer,
supplier: a.supplier,
product: a.product,
release_version: a.release_version,
supported_hardware_models: a.models,
supported_hardware_revisions: a.revisions,
source_provenance: SourceProvenance {
source_kind: a.source_kind.unwrap_or_else(|| "manual".into()),
source_uri: a.source_uri,
source_repository: None,
source_asset_path: None,
fetched_at: Some(now),
reported_size: None,
vendor_checksum: None,
vendor_signature_status: None,
tls_origin: None,
transfer_receipt_digest: None,
},
policy_result: PolicyResult {
policy_digest: sha256_hex(b"holger-bundle-prepare"),
advisory_snapshot_digest: advisory_digest,
verdict: a.verdict,
non_production: !a.production,
},
supersedes: vec![],
withdraws: vec![],
payloads,
evidence,
};
let candidate = prep.build()?;
write_members_dir(&candidate.members, &a.candidate)?;
println!("prepared candidate at {}", a.candidate.display());
println!("candidate-digest: {}", candidate.candidate_digest);
Ok(())
}
fn cmd_approve(a: ApproveArgs) -> anyhow::Result<()> {
let mut members = read_members_dir(&a.candidate)?;
let cert_der = read_cert_or_key(&a.cert)?;
let key_der = read_cert_or_key(&a.key)?;
let digest = candidate_digest(&members)?;
let rec = add_approval(
&mut members,
&a.role,
&cert_der,
&key_der,
a.comment,
Utc::now(),
)?;
write_members_dir(&members, &a.candidate)?;
println!(
"approved as role {} by {} [{}]",
rec.role, rec.subject, rec.fingerprint
);
println!("candidate-digest: {digest}");
Ok(())
}
fn cmd_finalize(a: FinalizeArgs) -> anyhow::Result<()> {
let mut members = read_members_dir(&a.candidate)?;
let policy = TrustPolicy::from_ron(&std::fs::read_to_string(&a.policy)?)?;
let release_cert = read_cert_or_key(&a.release_cert)?;
let release_key = read_cert_or_key(&a.release_key)?;
let report = finalize(
&mut members,
&policy,
&release_cert,
&release_key,
Utc::now(),
)?;
pack_bundle(&members, &a.output)?;
let bundle_bytes = std::fs::read(&a.output)?;
let receipt = FinalizationReceipt {
bundle_id: report.bundle_id.clone(),
sequence: report.sequence,
supplier: report.supplier.clone(),
product: report.product.clone(),
release_version: report.release_version.clone(),
candidate_digest: report.candidate_digest.clone(),
release_signer_fingerprint: report.release_signer_fingerprint.clone(),
approvals: report.approvals.clone(),
finalized_at: Utc::now(),
bundle_sha256: sha256_hex(&bundle_bytes),
bundle_size_bytes: bundle_bytes.len() as u64,
};
if let Some(rp) = &a.receipt {
std::fs::write(rp, serde_json::to_vec_pretty(&receipt)?)?;
}
println!(
"finalized {} ({} bytes)",
a.output.display(),
receipt.bundle_size_bytes
);
println!("bundle-sha256: {}", receipt.bundle_sha256);
println!("candidate-digest: {}", receipt.candidate_digest);
for ap in &receipt.approvals {
println!(" approval: {} [{}]", ap.role, ap.fingerprint);
}
Ok(())
}
fn cmd_inspect(a: InspectArgs) -> anyhow::Result<()> {
let members = read_members_dir(&a.candidate)?;
let core = members
.get(crate::schema::CORE_JSON)
.ok_or_else(|| anyhow::anyhow!("no core.json in candidate"))?;
let core = crate::BundleCore::from_json_bytes(core)?;
println!("bundle-id: {}", core.bundle_id);
println!("supplier/product: {} / {}", core.supplier, core.product);
println!("release-version: {}", core.release_version);
println!("sequence: {}", core.sequence);
println!("candidate-digest: {}", candidate_digest(&members)?);
println!("payloads: {}", core.payloads.len());
println!("evidence: {}", core.evidence.len());
let approvals = members
.keys()
.filter(|k| k.ends_with(".approval.json"))
.count();
println!("approvals: {approvals}");
Ok(())
}
// ---- cert/key loading (DER or PEM) ----
fn read_cert_or_key(path: &Path) -> anyhow::Result<Vec<u8>> {
let raw = std::fs::read(path)?;
if raw.starts_with(b"-----BEGIN") {
pem_to_der(&raw)
} else {
Ok(raw)
}
}
fn pem_to_der(pem: &[u8]) -> anyhow::Result<Vec<u8>> {
let s = std::str::from_utf8(pem)?;
let b64: String = s
.lines()
.filter(|l| !l.starts_with("-----"))
.flat_map(|l| l.chars())
.filter(|c| !c.is_whitespace())
.collect();
base64_decode(&b64)
}
fn base64_decode(s: &str) -> anyhow::Result<Vec<u8>> {
fn val(c: u8) -> anyhow::Result<u8> {
Ok(match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
b'/' => 63,
_ => anyhow::bail!("invalid base64 character"),
})
}
let bytes: Vec<u8> = s.bytes().filter(|&b| b != b'=').collect();
let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
for chunk in bytes.chunks(4) {
let mut buf = [0u8; 4];
let n = chunk.len();
for (i, &c) in chunk.iter().enumerate() {
buf[i] = val(c)?;
}
let b0 = (buf[0] << 2) | (buf[1] >> 4);
out.push(b0);
if n >= 3 {
out.push((buf[1] << 4) | (buf[2] >> 2));
}
if n >= 4 {
out.push((buf[2] << 6) | buf[3]);
}
}
Ok(out)
}