Skip to main content

greentic_pack/
builder.rs

1use std::collections::BTreeSet;
2use std::fs;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use anyhow::{Context, Result, anyhow, bail};
8use base64::Engine;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use blake3::Hasher;
11use ed25519_dalek::Signer as _;
12use ed25519_dalek::SigningKey;
13use pkcs8::EncodePrivateKey;
14use rand_core_06::OsRng;
15use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair, PKCS_ED25519};
16use rustls_pki_types::PrivatePkcs8KeyDer;
17use schemars::JsonSchema;
18use semver::Version;
19use serde::{Deserialize, Serialize};
20use serde_json::{Map as JsonMap, Value as JsonValue};
21use time::OffsetDateTime;
22use time::format_description::well_known::Rfc3339;
23use zip::write::SimpleFileOptions;
24use zip::{CompressionMethod, DateTime as ZipDateTime, ZipWriter};
25
26use crate::events::EventsSection;
27use crate::kind::PackKind;
28use crate::messaging::MessagingSection;
29use crate::repo::{InterfaceBinding, RepoPackSection};
30
31pub(crate) const SBOM_FORMAT: &str = "greentic-sbom-v1";
32pub(crate) const SIGNATURE_PATH: &str = "signatures/pack.sig";
33pub(crate) const SIGNATURE_CHAIN_PATH: &str = "signatures/chain.pem";
34pub const PACK_VERSION: u32 = 1;
35
36fn default_pack_version() -> u32 {
37    PACK_VERSION
38}
39
40#[derive(Clone, Debug, Serialize, Deserialize)]
41pub struct PackMeta {
42    #[serde(rename = "packVersion", default = "default_pack_version")]
43    pub pack_version: u32,
44    pub pack_id: String,
45    pub version: Version,
46    pub name: String,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub kind: Option<PackKind>,
49    #[serde(default)]
50    pub description: Option<String>,
51    #[serde(default)]
52    pub authors: Vec<String>,
53    #[serde(default)]
54    pub license: Option<String>,
55    #[serde(default)]
56    pub homepage: Option<String>,
57    #[serde(default)]
58    pub support: Option<String>,
59    #[serde(default)]
60    pub vendor: Option<String>,
61    #[serde(default)]
62    pub imports: Vec<ImportRef>,
63    pub entry_flows: Vec<String>,
64    pub created_at_utc: String,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub events: Option<EventsSection>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub repo: Option<RepoPackSection>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub messaging: Option<MessagingSection>,
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub interfaces: Vec<InterfaceBinding>,
73    #[serde(default)]
74    pub annotations: JsonMap<String, JsonValue>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub distribution: Option<DistributionSection>,
77    #[serde(default)]
78    pub components: Vec<ComponentDescriptor>,
79}
80
81impl PackMeta {
82    fn validate(&self) -> Result<()> {
83        if self.pack_version != PACK_VERSION {
84            bail!(
85                "unsupported packVersion {}; expected {}",
86                self.pack_version,
87                PACK_VERSION
88            );
89        }
90        if self.pack_id.trim().is_empty() {
91            bail!("pack_id is required");
92        }
93        if self.name.trim().is_empty() {
94            bail!("name is required");
95        }
96        if self.entry_flows.is_empty() {
97            bail!("at least one entry flow is required");
98        }
99        if self.created_at_utc.trim().is_empty() {
100            bail!("created_at_utc is required");
101        }
102        if let Some(kind) = &self.kind {
103            kind.validate_allowed()?;
104        }
105        if let Some(events) = &self.events {
106            events.validate()?;
107        }
108        if let Some(repo) = &self.repo {
109            repo.validate()?;
110        }
111        if let Some(messaging) = &self.messaging {
112            messaging.validate()?;
113        }
114        for binding in &self.interfaces {
115            binding.validate("interfaces")?;
116        }
117        validate_distribution(self.kind.as_ref(), self.distribution.as_ref())?;
118        validate_components(&self.components)?;
119        Ok(())
120    }
121}
122
123pub use greentic_flow::flow_bundle::{ComponentPin, FlowBundle, NodeRef};
124
125#[derive(Clone, Debug, Serialize, Deserialize)]
126pub struct ImportRef {
127    pub pack_id: String,
128    pub version_req: String,
129}
130
131#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
132pub struct ComponentDescriptor {
133    pub component_id: String,
134    pub version: String,
135    pub digest: String,
136    pub artifact_path: String,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub kind: Option<String>,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub artifact_type: Option<String>,
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub tags: Vec<String>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub platform: Option<String>,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub entrypoint: Option<String>,
147}
148
149#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
150pub struct DistributionSection {
151    #[serde(default)]
152    pub bundle_id: Option<String>,
153    #[serde(default)]
154    pub tenant: JsonMap<String, JsonValue>,
155    pub environment_ref: String,
156    pub desired_state_version: String,
157    #[serde(default)]
158    pub components: Vec<ComponentDescriptor>,
159    #[serde(default)]
160    pub platform_components: Vec<ComponentDescriptor>,
161}
162
163#[derive(Clone, Debug)]
164pub struct ComponentArtifact {
165    pub name: String,
166    pub version: Version,
167    pub wasm_path: PathBuf,
168    pub schema_json: Option<String>,
169    pub manifest_json: Option<String>,
170    pub capabilities: Option<JsonValue>,
171    pub world: Option<String>,
172    pub hash_blake3: Option<String>,
173}
174
175#[derive(Clone, Debug, Serialize, Deserialize)]
176pub struct Provenance {
177    pub builder: String,
178    #[serde(default)]
179    pub git_commit: Option<String>,
180    #[serde(default)]
181    pub git_repo: Option<String>,
182    #[serde(default)]
183    pub toolchain: Option<String>,
184    pub built_at_utc: String,
185    #[serde(default)]
186    pub host: Option<String>,
187    #[serde(default)]
188    pub notes: Option<String>,
189}
190
191#[derive(Clone, Debug, Serialize, Deserialize)]
192pub struct ExternalSignature {
193    pub alg: String,
194    pub sig: Vec<u8>,
195}
196
197pub trait Signer: Send + Sync {
198    fn sign(&self, message: &[u8]) -> Result<ExternalSignature>;
199    fn chain_pem(&self) -> Result<Vec<u8>>;
200}
201
202type DynSigner = dyn Signer + Send + Sync + 'static;
203
204#[derive(Clone, Default)]
205pub enum Signing {
206    #[default]
207    Dev,
208    None,
209    External(Arc<DynSigner>),
210}
211
212pub struct PackBuilder {
213    meta: PackMeta,
214    flows: Vec<FlowBundle>,
215    components: Vec<ComponentArtifact>,
216    assets: Vec<Asset>,
217    signing: Signing,
218    provenance: Option<Provenance>,
219    component_descriptors: Vec<ComponentDescriptor>,
220    distribution: Option<DistributionSection>,
221}
222
223struct Asset {
224    path: String,
225    bytes: Vec<u8>,
226}
227
228#[derive(Debug, Clone)]
229pub struct BuildResult {
230    pub out_path: PathBuf,
231    pub manifest_hash_blake3: String,
232    pub files: Vec<SbomEntry>,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
236pub struct SbomEntry {
237    pub path: String,
238    pub size: u64,
239    pub hash_blake3: String,
240    pub media_type: String,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct PackManifest {
245    pub meta: PackMeta,
246    pub flows: Vec<FlowEntry>,
247    pub components: Vec<ComponentEntry>,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub distribution: Option<DistributionSection>,
250    #[serde(default, skip_serializing_if = "Vec::is_empty")]
251    pub component_descriptors: Vec<ComponentDescriptor>,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct FlowEntry {
256    pub id: String,
257    pub kind: String,
258    pub entry: String,
259    pub file_yaml: String,
260    pub file_json: String,
261    pub hash_blake3: String,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ComponentEntry {
266    pub name: String,
267    pub version: Version,
268    pub file_wasm: String,
269    pub hash_blake3: String,
270    pub schema_file: Option<String>,
271    pub manifest_file: Option<String>,
272    pub world: Option<String>,
273    pub capabilities: Option<JsonValue>,
274}
275
276#[derive(Debug, Serialize, Deserialize)]
277pub(crate) struct SignatureEnvelope {
278    pub alg: String,
279    pub sig: String,
280    pub digest: String,
281    pub signed_at_utc: String,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub key_fingerprint: Option<String>,
284}
285
286impl SignatureEnvelope {
287    fn new(
288        alg: impl Into<String>,
289        sig_bytes: &[u8],
290        digest: &blake3::Hash,
291        key_fingerprint: Option<String>,
292    ) -> Self {
293        let signed_at = OffsetDateTime::now_utc()
294            .format(&Rfc3339)
295            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
296        Self {
297            alg: alg.into(),
298            sig: URL_SAFE_NO_PAD.encode(sig_bytes),
299            digest: digest.to_hex().to_string(),
300            signed_at_utc: signed_at,
301            key_fingerprint,
302        }
303    }
304}
305
306struct PendingFile {
307    path: String,
308    media_type: String,
309    bytes: Vec<u8>,
310}
311
312impl PendingFile {
313    fn new(path: String, media_type: impl Into<String>, bytes: Vec<u8>) -> Self {
314        Self {
315            path,
316            media_type: media_type.into(),
317            bytes,
318        }
319    }
320
321    fn size(&self) -> u64 {
322        self.bytes.len() as u64
323    }
324
325    fn hash(&self) -> String {
326        hex_hash(&self.bytes)
327    }
328}
329
330impl PackBuilder {
331    pub fn new(meta: PackMeta) -> Self {
332        Self {
333            component_descriptors: meta.components.clone(),
334            distribution: meta.distribution.clone(),
335            meta,
336            flows: Vec::new(),
337            components: Vec::new(),
338            assets: Vec::new(),
339            signing: Signing::Dev,
340            provenance: None,
341        }
342    }
343
344    pub fn with_flow(mut self, flow: FlowBundle) -> Self {
345        self.flows.push(flow);
346        self
347    }
348
349    pub fn with_component(mut self, component: ComponentArtifact) -> Self {
350        self.components.push(component);
351        self
352    }
353
354    pub fn with_component_wasm(
355        self,
356        name: impl Into<String>,
357        version: Version,
358        wasm_path: impl Into<PathBuf>,
359    ) -> Self {
360        self.with_component(ComponentArtifact {
361            name: name.into(),
362            version,
363            wasm_path: wasm_path.into(),
364            schema_json: None,
365            manifest_json: None,
366            capabilities: None,
367            world: None,
368            hash_blake3: None,
369        })
370    }
371
372    pub fn with_asset_bytes(mut self, path_in_pack: impl Into<String>, bytes: Vec<u8>) -> Self {
373        self.assets.push(Asset {
374            path: path_in_pack.into(),
375            bytes,
376        });
377        self
378    }
379
380    pub fn with_signing(mut self, signing: Signing) -> Self {
381        self.signing = signing;
382        self
383    }
384
385    pub fn with_provenance(mut self, provenance: Provenance) -> Self {
386        self.provenance = Some(provenance);
387        self
388    }
389
390    pub fn with_component_descriptors(
391        mut self,
392        descriptors: impl IntoIterator<Item = ComponentDescriptor>,
393    ) -> Self {
394        self.component_descriptors.extend(descriptors);
395        self
396    }
397
398    pub fn with_distribution(mut self, distribution: DistributionSection) -> Self {
399        self.distribution = Some(distribution);
400        self
401    }
402
403    pub fn build(self, out_path: impl AsRef<Path>) -> Result<BuildResult> {
404        let meta = self.meta;
405        meta.validate()?;
406        let distribution = self.distribution.or_else(|| meta.distribution.clone());
407        let component_descriptors = if self.component_descriptors.is_empty() {
408            meta.components.clone()
409        } else {
410            self.component_descriptors.clone()
411        };
412
413        if self.flows.is_empty() {
414            bail!("at least one flow must be provided");
415        }
416
417        let mut flow_entries = Vec::new();
418        let mut pending_files: Vec<PendingFile> = Vec::new();
419        let mut seen_flow_ids = BTreeSet::new();
420
421        for flow in self.flows {
422            validate_identifier(&flow.id, "flow id")?;
423            if flow.entry.trim().is_empty() {
424                bail!("flow {} is missing an entry node", flow.id);
425            }
426            if !seen_flow_ids.insert(flow.id.clone()) {
427                bail!("duplicate flow id detected: {}", flow.id);
428            }
429
430            let yaml_path = normalize_relative_path(&["flows", &flow.id, "flow.ygtc"])?;
431            let yaml_bytes = normalize_newlines(&flow.yaml).into_bytes();
432            pending_files.push(PendingFile::new(
433                yaml_path.clone(),
434                "application/yaml",
435                yaml_bytes,
436            ));
437
438            let json_path = normalize_relative_path(&["flows", &flow.id, "flow.json"])?;
439            let json_bytes = serde_json::to_vec(&flow.json)?;
440            pending_files.push(PendingFile::new(
441                json_path.clone(),
442                "application/json",
443                json_bytes,
444            ));
445
446            flow_entries.push(FlowEntry {
447                id: flow.id,
448                kind: flow.kind,
449                entry: flow.entry,
450                file_yaml: yaml_path,
451                file_json: json_path,
452                hash_blake3: flow.hash_blake3,
453            });
454        }
455
456        for entry in &meta.entry_flows {
457            if !seen_flow_ids.contains(entry) {
458                bail!("entry flow `{}` not present in provided flows", entry);
459            }
460        }
461
462        flow_entries.sort_by(|a, b| a.id.cmp(&b.id));
463
464        let mut component_entries = Vec::new();
465        let mut seen_components = BTreeSet::new();
466
467        for component in self.components {
468            validate_identifier(&component.name, "component name")?;
469            let key = format!("{}@{}", component.name, component.version);
470            if !seen_components.insert(key.clone()) {
471                bail!("duplicate component artifact detected: {}", key);
472            }
473
474            let wasm_bytes = fs::read(&component.wasm_path).with_context(|| {
475                format!(
476                    "failed to read component wasm at {}",
477                    component.wasm_path.display()
478                )
479            })?;
480            let wasm_hash = hex_hash(&wasm_bytes);
481            if let Some(expected) = component.hash_blake3.as_deref()
482                && !equals_ignore_case(expected, &wasm_hash)
483            {
484                bail!(
485                    "component {} hash mismatch: expected {}, got {}",
486                    key,
487                    expected,
488                    wasm_hash
489                );
490            }
491
492            let wasm_path = normalize_relative_path(&["components", &key, "component.wasm"])?;
493            pending_files.push(PendingFile::new(
494                wasm_path.clone(),
495                "application/wasm",
496                wasm_bytes,
497            ));
498
499            let mut schema_file = None;
500            if let Some(schema) = component.schema_json.as_ref() {
501                let schema_path = normalize_relative_path(&["schemas", &key, "node.schema.json"])?;
502                pending_files.push(PendingFile::new(
503                    schema_path.clone(),
504                    "application/schema+json",
505                    normalize_newlines(schema).into_bytes(),
506                ));
507                schema_file = Some(schema_path);
508            }
509
510            let mut manifest_file = None;
511            if let Some(manifest_json) = component.manifest_json.as_ref() {
512                let manifest_path =
513                    normalize_relative_path(&["components", &key, "manifest.json"])?;
514                pending_files.push(PendingFile::new(
515                    manifest_path.clone(),
516                    "application/json",
517                    normalize_newlines(manifest_json).into_bytes(),
518                ));
519                manifest_file = Some(manifest_path);
520            }
521
522            component_entries.push(ComponentEntry {
523                name: component.name,
524                version: component.version,
525                file_wasm: wasm_path,
526                hash_blake3: wasm_hash,
527                schema_file,
528                manifest_file,
529                world: component.world,
530                capabilities: component.capabilities,
531            });
532        }
533
534        component_entries
535            .sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
536
537        for asset in self.assets {
538            let path = normalize_relative_path(&["assets", &asset.path])?;
539            pending_files.push(PendingFile::new(
540                path,
541                "application/octet-stream",
542                asset.bytes,
543            ));
544        }
545
546        let manifest_model = PackManifest {
547            meta: meta.clone(),
548            flows: flow_entries,
549            components: component_entries,
550            distribution,
551            component_descriptors,
552        };
553
554        let manifest_cbor = encode_manifest_cbor(&manifest_model)?;
555        let manifest_json = serde_json::to_vec_pretty(&manifest_model)?;
556
557        pending_files.push(PendingFile::new(
558            "manifest.cbor".to_string(),
559            "application/cbor",
560            manifest_cbor.clone(),
561        ));
562        pending_files.push(PendingFile::new(
563            "manifest.json".to_string(),
564            "application/json",
565            manifest_json,
566        ));
567
568        let provenance = finalize_provenance(self.provenance);
569        let provenance_json = serde_json::to_vec_pretty(&provenance)?;
570        pending_files.push(PendingFile::new(
571            "provenance.json".to_string(),
572            "application/json",
573            provenance_json,
574        ));
575
576        let mut sbom_entries = Vec::new();
577        for file in pending_files.iter() {
578            sbom_entries.push(SbomEntry {
579                path: file.path.clone(),
580                size: file.size(),
581                hash_blake3: file.hash(),
582                media_type: file.media_type.clone(),
583            });
584        }
585        let build_files = sbom_entries.clone();
586
587        let sbom_document = serde_json::json!({
588            "format": SBOM_FORMAT,
589            "files": sbom_entries,
590        });
591        let sbom_bytes = serde_json::to_vec_pretty(&sbom_document)?;
592        pending_files.push(PendingFile::new(
593            "sbom.json".to_string(),
594            "application/json",
595            sbom_bytes.clone(),
596        ));
597
598        let manifest_hash = hex_hash(&manifest_cbor);
599
600        let mut signature_files = Vec::new();
601        if !matches!(self.signing, Signing::None) {
602            let digest = signature_digest_from_entries(&build_files, &manifest_cbor, &sbom_bytes);
603            let (signature_doc, chain_bytes) = match &self.signing {
604                Signing::Dev => dev_signature(&digest)?,
605                Signing::None => unreachable!(),
606                Signing::External(signer) => external_signature(&**signer, &digest)?,
607            };
608
609            let sig_bytes = serde_json::to_vec_pretty(&signature_doc)?;
610            signature_files.push(PendingFile::new(
611                SIGNATURE_PATH.to_string(),
612                "application/json",
613                sig_bytes,
614            ));
615
616            if let Some(chain) = chain_bytes {
617                signature_files.push(PendingFile::new(
618                    SIGNATURE_CHAIN_PATH.to_string(),
619                    "application/x-pem-file",
620                    chain,
621                ));
622            }
623        }
624
625        let mut all_files = pending_files;
626        all_files.extend(signature_files);
627        all_files.sort_by(|a, b| a.path.cmp(&b.path));
628
629        let out_path = out_path.as_ref().to_path_buf();
630        if let Some(parent) = out_path.parent() {
631            fs::create_dir_all(parent)
632                .with_context(|| format!("failed to create directory {}", parent.display()))?;
633        }
634
635        write_zip(&out_path, &all_files)?;
636
637        Ok(BuildResult {
638            out_path,
639            manifest_hash_blake3: manifest_hash,
640            files: build_files,
641        })
642    }
643}
644
645fn equals_ignore_case(expected: &str, actual: &str) -> bool {
646    expected.trim().eq_ignore_ascii_case(actual.trim())
647}
648
649fn normalize_newlines(input: &str) -> String {
650    input.replace("\r\n", "\n")
651}
652
653fn normalize_relative_path(parts: &[&str]) -> Result<String> {
654    let mut segments = Vec::new();
655    for part in parts {
656        let normalized = part.replace('\\', "/");
657        for piece in normalized.split('/') {
658            if piece.is_empty() {
659                bail!("invalid path segment");
660            }
661            if piece == "." || piece == ".." {
662                bail!("path traversal is not permitted");
663            }
664            segments.push(piece.to_string());
665        }
666    }
667    Ok(segments.join("/"))
668}
669
670fn validate_identifier(value: &str, label: &str) -> Result<()> {
671    if value.trim().is_empty() {
672        bail!("{} must not be empty", label);
673    }
674    if value.contains("..") {
675        bail!("{} must not contain '..'", label);
676    }
677    Ok(())
678}
679
680fn encode_manifest_cbor(manifest: &PackManifest) -> Result<Vec<u8>> {
681    let mut buffer = Vec::new();
682    {
683        let mut serializer = serde_cbor::ser::Serializer::new(&mut buffer);
684        serializer.self_describe()?;
685        manifest.serialize(&mut serializer)?;
686    }
687    Ok(buffer)
688}
689
690fn validate_digest(digest: &str) -> Result<()> {
691    if digest.trim().is_empty() {
692        bail!("component digest must not be empty");
693    }
694    if !digest.starts_with("sha256:") {
695        bail!("component digest must start with sha256:");
696    }
697    Ok(())
698}
699
700fn validate_component_descriptor(component: &ComponentDescriptor) -> Result<()> {
701    if component.component_id.trim().is_empty() {
702        bail!("component_id must not be empty");
703    }
704    if component.version.trim().is_empty() {
705        bail!("component version must not be empty");
706    }
707    if component.artifact_path.trim().is_empty() {
708        bail!("component artifact_path must not be empty");
709    }
710    validate_digest(&component.digest)?;
711    if let Some(kind) = &component.kind
712        && kind.trim().is_empty()
713    {
714        bail!("component kind must not be empty when provided");
715    }
716    if let Some(artifact_type) = &component.artifact_type
717        && artifact_type.trim().is_empty()
718    {
719        bail!("component artifact_type must not be empty when provided");
720    }
721    if let Some(platform) = &component.platform
722        && platform.trim().is_empty()
723    {
724        bail!("component platform must not be empty when provided");
725    }
726    if let Some(entrypoint) = &component.entrypoint
727        && entrypoint.trim().is_empty()
728    {
729        bail!("component entrypoint must not be empty when provided");
730    }
731    for tag in &component.tags {
732        if tag.trim().is_empty() {
733            bail!("component tags must not contain empty entries");
734        }
735    }
736    Ok(())
737}
738
739pub fn validate_components(components: &[ComponentDescriptor]) -> Result<()> {
740    let mut seen = BTreeSet::new();
741    for component in components {
742        validate_component_descriptor(component)?;
743        let key = (component.component_id.clone(), component.version.clone());
744        if !seen.insert(key) {
745            bail!("duplicate component entry detected");
746        }
747    }
748    Ok(())
749}
750
751pub fn validate_distribution(
752    kind: Option<&PackKind>,
753    distribution: Option<&DistributionSection>,
754) -> Result<()> {
755    match (kind, distribution) {
756        (Some(PackKind::DistributionBundle), Some(section)) => {
757            if let Some(bundle_id) = &section.bundle_id
758                && bundle_id.trim().is_empty()
759            {
760                bail!("distribution.bundle_id must not be empty when provided");
761            }
762            if section.environment_ref.trim().is_empty() {
763                bail!("distribution.environment_ref must not be empty");
764            }
765            if section.desired_state_version.trim().is_empty() {
766                bail!("distribution.desired_state_version must not be empty");
767            }
768            validate_components(&section.components)?;
769            validate_components(&section.platform_components)?;
770        }
771        (Some(PackKind::DistributionBundle), None) => {
772            bail!("distribution section is required for kind distribution-bundle");
773        }
774        (_, Some(_)) => {
775            bail!("distribution section is only allowed when kind is distribution-bundle");
776        }
777        _ => {}
778    }
779    Ok(())
780}
781
782fn finalize_provenance(provenance: Option<Provenance>) -> Provenance {
783    let builder_default = format!("greentic-pack@{}", env!("CARGO_PKG_VERSION"));
784    let now = OffsetDateTime::now_utc()
785        .format(&Rfc3339)
786        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
787    match provenance {
788        Some(mut prov) => {
789            if prov.builder.trim().is_empty() {
790                prov.builder = builder_default;
791            }
792            if prov.built_at_utc.trim().is_empty() {
793                prov.built_at_utc = now;
794            }
795            prov
796        }
797        None => Provenance {
798            builder: builder_default,
799            git_commit: None,
800            git_repo: None,
801            toolchain: None,
802            built_at_utc: now,
803            host: None,
804            notes: None,
805        },
806    }
807}
808
809pub(crate) fn signature_digest_from_entries(
810    entries: &[SbomEntry],
811    manifest_cbor: &[u8],
812    sbom_bytes: &[u8],
813) -> blake3::Hash {
814    let mut hasher = Hasher::new();
815    hasher.update(manifest_cbor);
816    hasher.update(sbom_bytes);
817
818    let mut records: Vec<(String, String)> = entries
819        .iter()
820        .map(|entry| (entry.path.clone(), entry.hash_blake3.clone()))
821        .collect();
822    records.sort_by(|a, b| a.0.cmp(&b.0));
823
824    for (path, hash) in records {
825        hasher.update(path.as_bytes());
826        hasher.update(b"\n");
827        hasher.update(hash.as_bytes());
828    }
829
830    hasher.finalize()
831}
832
833fn dev_signature(digest: &blake3::Hash) -> Result<(SignatureEnvelope, Option<Vec<u8>>)> {
834    let mut rng = OsRng;
835    let signing_key = SigningKey::generate(&mut rng);
836    let signature = signing_key.sign(digest.as_bytes());
837    let signature_bytes = signature.to_bytes();
838
839    let pkcs8_doc = signing_key
840        .to_pkcs8_der()
841        .map_err(|err| anyhow!("failed to encode dev keypair: {err}"))?;
842    let pkcs8_der = PrivatePkcs8KeyDer::from(pkcs8_doc.as_bytes().to_vec());
843    let key_pair = KeyPair::from_pkcs8_der_and_sign_algo(&pkcs8_der, &PKCS_ED25519)
844        .map_err(|err| anyhow!("failed to load dev keypair for certificate: {err}"))?;
845
846    let mut params = CertificateParams::new(Vec::<String>::new())?;
847    params.distinguished_name = DistinguishedName::new();
848    params
849        .distinguished_name
850        .push(DnType::CommonName, "greentic-dev-local");
851    let cert = params.self_signed(&key_pair)?;
852    let chain = normalize_newlines(&cert.pem()).into_bytes();
853    let fingerprint = hex_hash(signing_key.verifying_key().as_bytes());
854
855    let envelope = SignatureEnvelope::new("ed25519", &signature_bytes, digest, Some(fingerprint));
856    Ok((envelope, Some(chain)))
857}
858
859fn external_signature(
860    signer: &DynSigner,
861    digest: &blake3::Hash,
862) -> Result<(SignatureEnvelope, Option<Vec<u8>>)> {
863    let ExternalSignature { alg, sig } = signer.sign(digest.as_bytes())?;
864    let chain = signer.chain_pem()?;
865    let chain_bytes = if chain.is_empty() {
866        None
867    } else {
868        let chain_str = String::from_utf8(chain)?;
869        Some(normalize_newlines(&chain_str).into_bytes())
870    };
871    let envelope = SignatureEnvelope::new(alg, &sig, digest, None);
872    Ok((envelope, chain_bytes))
873}
874
875pub(crate) fn hex_hash(bytes: &[u8]) -> String {
876    blake3::hash(bytes).to_hex().to_string()
877}
878
879fn write_zip(out_path: &Path, files: &[PendingFile]) -> Result<()> {
880    let file = fs::File::create(out_path)
881        .with_context(|| format!("failed to create {}", out_path.display()))?;
882    let mut writer = ZipWriter::new(file);
883    let timestamp = zip_timestamp();
884
885    for entry in files {
886        let options = SimpleFileOptions::default()
887            .compression_method(CompressionMethod::Stored)
888            .last_modified_time(timestamp)
889            .unix_permissions(0o644)
890            .large_file(false);
891        writer
892            .start_file(&entry.path, options)
893            .with_context(|| format!("failed to add {} to archive", entry.path))?;
894        writer
895            .write_all(&entry.bytes)
896            .with_context(|| format!("failed to write {}", entry.path))?;
897    }
898
899    writer.finish().context("failed to finish gtpack archive")?;
900    Ok(())
901}
902
903fn zip_timestamp() -> ZipDateTime {
904    ZipDateTime::from_date_and_time(1980, 1, 1, 0, 0, 0).unwrap_or_else(|_| ZipDateTime::default())
905}
906
907#[cfg(test)]
908mod tests {
909    use super::*;
910    use serde_json::json;
911    use tempfile::tempdir;
912    use zip::ZipArchive;
913
914    use std::fs::{self, File};
915    use std::io::Read;
916
917    #[test]
918    fn deterministic_build_without_signing() {
919        let temp = tempdir().unwrap();
920        let wasm_path = temp.path().join("component.wasm");
921        fs::write(&wasm_path, test_wasm_bytes()).unwrap();
922
923        let builder = || {
924            PackBuilder::new(sample_meta())
925                .with_flow(sample_flow())
926                .with_component(sample_component(&wasm_path))
927                .with_signing(Signing::None)
928                .with_provenance(sample_provenance())
929        };
930
931        let out_a = temp.path().join("a.gtpack");
932        let out_b = temp.path().join("b.gtpack");
933
934        builder().build(&out_a).unwrap();
935        builder().build(&out_b).unwrap();
936
937        let bytes_a = fs::read(&out_a).unwrap();
938        let bytes_b = fs::read(&out_b).unwrap();
939        assert_eq!(bytes_a, bytes_b, "gtpack output should be deterministic");
940
941        let result = PackBuilder::new(sample_meta())
942            .with_flow(sample_flow())
943            .with_component(sample_component(&wasm_path))
944            .with_signing(Signing::None)
945            .with_provenance(sample_provenance())
946            .build(temp.path().join("result.gtpack"))
947            .unwrap();
948
949        assert!(
950            result
951                .files
952                .iter()
953                .any(|entry| entry.path == "components/oauth@1.0.0/component.wasm")
954        );
955    }
956
957    #[test]
958    fn dev_signing_writes_signature_files() {
959        let temp = tempdir().unwrap();
960        let wasm_path = temp.path().join("component.wasm");
961        fs::write(&wasm_path, test_wasm_bytes()).unwrap();
962
963        let out_path = temp.path().join("signed.gtpack");
964        PackBuilder::new(sample_meta())
965            .with_flow(sample_flow())
966            .with_component(sample_component(&wasm_path))
967            .with_signing(Signing::Dev)
968            .with_provenance(sample_provenance())
969            .build(&out_path)
970            .unwrap();
971
972        let reader = File::open(&out_path).unwrap();
973        let mut archive = ZipArchive::new(reader).unwrap();
974        let mut signature_found = false;
975        let mut chain_found = false;
976
977        for i in 0..archive.len() {
978            let mut file = archive.by_index(i).unwrap();
979            match file.name() {
980                SIGNATURE_PATH => {
981                    signature_found = true;
982                    let mut contents = String::new();
983                    file.read_to_string(&mut contents).unwrap();
984                    assert!(contents.contains("\"alg\": \"ed25519\""));
985                }
986                SIGNATURE_CHAIN_PATH => {
987                    chain_found = true;
988                }
989                _ => {}
990            }
991        }
992
993        assert!(signature_found, "signature should be present");
994        assert!(chain_found, "certificate chain should be present");
995    }
996
997    fn sample_meta() -> PackMeta {
998        PackMeta {
999            pack_version: PACK_VERSION,
1000            pack_id: "ai.greentic.demo.test".to_string(),
1001            version: Version::parse("0.1.0").unwrap(),
1002            name: "Test Pack".to_string(),
1003            kind: None,
1004            description: Some("integration test".to_string()),
1005            authors: vec!["Greentic".to_string()],
1006            license: Some("MIT".to_string()),
1007            homepage: None,
1008            support: None,
1009            vendor: None,
1010            imports: Vec::new(),
1011            entry_flows: vec!["main".to_string()],
1012            created_at_utc: "2025-01-01T00:00:00Z".to_string(),
1013            events: None,
1014            repo: None,
1015            messaging: None,
1016            interfaces: Vec::new(),
1017            annotations: JsonMap::new(),
1018            distribution: None,
1019            components: Vec::new(),
1020        }
1021    }
1022
1023    fn sample_flow() -> FlowBundle {
1024        let flow_json = json!({
1025            "id": "main",
1026            "kind": "flow/v1",
1027            "entry": "start",
1028            "nodes": []
1029        });
1030        let hash = blake3::hash(&serde_json::to_vec(&flow_json).unwrap())
1031            .to_hex()
1032            .to_string();
1033        FlowBundle {
1034            id: "main".to_string(),
1035            kind: "flow/v1".to_string(),
1036            entry: "start".to_string(),
1037            yaml: "id: main\nentry: start\n".to_string(),
1038            json: flow_json,
1039            hash_blake3: hash,
1040            nodes: Vec::new(),
1041        }
1042    }
1043
1044    fn sample_component(wasm_path: &Path) -> ComponentArtifact {
1045        ComponentArtifact {
1046            name: "oauth".to_string(),
1047            version: Version::parse("1.0.0").unwrap(),
1048            wasm_path: wasm_path.to_path_buf(),
1049            schema_json: None,
1050            manifest_json: None,
1051            capabilities: None,
1052            world: Some("component:tool".to_string()),
1053            hash_blake3: None,
1054        }
1055    }
1056
1057    fn test_wasm_bytes() -> Vec<u8> {
1058        vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]
1059    }
1060
1061    fn sample_provenance() -> Provenance {
1062        Provenance {
1063            builder: "greentic-pack@test".to_string(),
1064            git_commit: Some("abc123".to_string()),
1065            git_repo: Some("https://example.com/repo.git".to_string()),
1066            toolchain: Some("rustc 1.85.0".to_string()),
1067            built_at_utc: "2025-01-01T00:00:00Z".to_string(),
1068            host: Some("ci".to_string()),
1069            notes: None,
1070        }
1071    }
1072}