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