update-bundle/src/prepare.rs
//! Candidate preparation: assemble `core.json`, the envelope, and the payload +
//! evidence members. Pure and in-memory; disk helpers wrap it for the CLI.
use std::fs;
use std::path::Path;
use chrono::{DateTime, Utc};
use crate::core::{
BundleCore, EvidenceEntry, EvidenceKind, PayloadEntry, PolicyResult, SourceProvenance,
};
use crate::digest::{sha256_hex, Digests};
use crate::error::{BundleError, Result};
use crate::manifest::EnvelopeManifest;
use crate::pathsafe::{validate_member_path, PathGuard};
use crate::schema::{BUNDLE_SCHEMA, CORE_JSON, EVIDENCE_PREFIX, MANIFEST_JSON, PAYLOAD_PREFIX};
use crate::MemberMap;
/// A payload to include: path relative to `payload/`, its bytes, and a media type.
pub struct PayloadInput {
pub relative_path: String,
pub bytes: Vec<u8>,
pub media_type: String,
}
/// An evidence document to include: path relative to `evidence/`, bytes, media
/// type, and kind.
pub struct EvidenceInput {
pub relative_path: String,
pub bytes: Vec<u8>,
pub media_type: String,
pub kind: EvidenceKind,
}
/// The inputs to a candidate bundle.
pub struct Preparation {
pub bundle_id: String,
pub sequence: u64,
pub created_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
pub producer: String,
pub supplier: String,
pub product: String,
pub release_version: String,
pub supported_hardware_models: Vec<String>,
pub supported_hardware_revisions: Vec<String>,
pub source_provenance: SourceProvenance,
pub policy_result: PolicyResult,
pub supersedes: Vec<String>,
pub withdraws: Vec<String>,
pub payloads: Vec<PayloadInput>,
pub evidence: Vec<EvidenceInput>,
}
/// A prepared candidate: the members and the candidate digest over `core.json`.
pub struct Candidate {
pub members: MemberMap,
pub candidate_digest: String,
}
impl Preparation {
/// Build the candidate member map + candidate digest. Rejects unsafe payload /
/// evidence paths and evidence/payload collisions.
pub fn build(mut self) -> Result<Candidate> {
let mut members = MemberMap::new();
let mut guard = PathGuard::new();
// Every member's two digests on ONE fan-out (stinky 30), then the bytes
// MOVE into the member map — they were cloned, a whole second copy of
// every payload, before.
let payloads = std::mem::take(&mut self.payloads);
let evidence = std::mem::take(&mut self.evidence);
let all: Vec<&[u8]> = payloads
.iter()
.map(|p| p.bytes.as_slice())
.chain(evidence.iter().map(|e| e.bytes.as_slice()))
.collect();
let mut digests = Digests::of_many(&all).into_iter();
drop(all);
// Payloads under payload/.
let mut payload_entries = Vec::with_capacity(payloads.len());
for p in payloads {
validate_member_path(&p.relative_path)?;
let full = format!("{PAYLOAD_PREFIX}{}", p.relative_path);
guard.check(&full)?;
let d = digests.next().expect("one digest per member");
payload_entries.push(PayloadEntry {
relative_path: full.clone(),
media_type: p.media_type.clone(),
size_bytes: d.size_bytes,
sha256: d.sha256,
blake3: d.blake3,
});
members.insert(full, p.bytes);
}
// Evidence under evidence/. Auto-fill the transfer-receipt digest from a
// SourceReceipt evidence document if the caller did not set it.
let mut evidence_entries = Vec::with_capacity(evidence.len());
for e in evidence {
validate_member_path(&e.relative_path)?;
let full = format!("{EVIDENCE_PREFIX}{}", e.relative_path);
guard.check(&full)?;
let d = digests.next().expect("one digest per member");
if e.kind == EvidenceKind::SourceReceipt
&& self.source_provenance.transfer_receipt_digest.is_none()
{
self.source_provenance.transfer_receipt_digest = Some(d.sha256.clone());
}
evidence_entries.push(EvidenceEntry {
relative_path: full.clone(),
media_type: e.media_type.clone(),
size_bytes: d.size_bytes,
sha256: d.sha256,
blake3: d.blake3,
evidence_kind: e.kind,
});
members.insert(full, e.bytes);
}
let core = BundleCore {
schema_version: BUNDLE_SCHEMA.to_string(),
bundle_id: self.bundle_id.clone(),
sequence: self.sequence,
created_at: self.created_at,
expires_at: self.expires_at,
producer: self.producer.clone(),
supplier: self.supplier.clone(),
product: self.product.clone(),
release_version: self.release_version.clone(),
supported_hardware_models: self.supported_hardware_models.clone(),
supported_hardware_revisions: self.supported_hardware_revisions.clone(),
source_provenance: self.source_provenance.clone(),
payloads: payload_entries,
evidence: evidence_entries,
policy_result: self.policy_result.clone(),
supersedes: self.supersedes.clone(),
withdraws: self.withdraws.clone(),
};
let core_bytes = core.to_json_bytes()?;
let candidate_digest = sha256_hex(&core_bytes);
let envelope = EnvelopeManifest {
schema_version: BUNDLE_SCHEMA.to_string(),
bundle_id: core.bundle_id.clone(),
sequence: core.sequence,
supplier: core.supplier.clone(),
product: core.product.clone(),
release_version: core.release_version.clone(),
created_at: core.created_at,
member_count: members.len() + 2, // + manifest.json + core.json
core_digest: candidate_digest.clone(),
};
members.insert(CORE_JSON.to_string(), core_bytes);
members.insert(MANIFEST_JSON.to_string(), envelope.to_json_bytes()?);
Ok(Candidate {
members,
candidate_digest,
})
}
}
/// Write a member map to a candidate directory (each member at its relative path).
pub fn write_members_dir(members: &MemberMap, dir: &Path) -> Result<()> {
for (rel, bytes) in members {
validate_member_path(rel)?;
let dest = dir.join(rel);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| BundleError::io("mkdir candidate", e))?;
}
fs::write(&dest, bytes).map_err(|e| BundleError::io("write candidate member", e))?;
}
Ok(())
}
/// Read a candidate directory into a member map, enforcing path safety.
pub fn read_members_dir(dir: &Path) -> Result<MemberMap> {
let mut members = MemberMap::new();
let mut guard = PathGuard::new();
read_dir_recursive(dir, dir, &mut members, &mut guard)?;
Ok(members)
}
fn read_dir_recursive(
base: &Path,
cur: &Path,
out: &mut MemberMap,
guard: &mut PathGuard,
) -> Result<()> {
for de in fs::read_dir(cur).map_err(|e| BundleError::io("read candidate dir", e))? {
let de = de.map_err(|e| BundleError::io("read candidate entry", e))?;
let path = de.path();
let ft = de.file_type().map_err(|e| BundleError::io("stat", e))?;
if ft.is_symlink() {
return Err(BundleError::UnsafePath(
"symlink in candidate directory".into(),
));
}
if ft.is_dir() {
read_dir_recursive(base, &path, out, guard)?;
} else if ft.is_file() {
let rel = path
.strip_prefix(base)
.map_err(|_| BundleError::Malformed("candidate path escapes base".into()))?
.to_string_lossy()
.replace('\\', "/");
guard.check(&rel)?;
let bytes = fs::read(&path).map_err(|e| BundleError::io("read candidate member", e))?;
out.insert(rel, bytes);
}
}
Ok(())
}