Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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