Skip to main content

greentic_pack/
builder.rs

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